chore: update CHANGELOG.md (#7209)

* Revert "fix: disable deleting mutilple nodes in table"

This reverts commit 0507c39863c0a9e1e90a0c3ae4d77dda6d0daf9f.

* chore: bump version 0.8.1

* chore: remove unused tests
This commit is contained in:
Lucas 2025-01-14 21:04:00 +08:00 committed by GitHub
parent b2f3f902b2
commit 05c1924940
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 34 additions and 434 deletions

View File

@ -1,4 +1,16 @@
# Release Notes
## Version 0.8.1 - 14/01/2025
### New Features:
- AI Chat Layout Options: Customize how AI responses appear with new layouts—List, Table, Image with Text, and Media Only
- DALL-E Integration: Generate stunning AI images from text prompts, now available in AI Chat
- Improved Desktop Search: Find what you need faster using keywords or by asking questions in natural language
- Self-Hosting: Configure web server URLs directly in Settings to enable features like Publish, Copy Link to Share, Custom URLs, and more
- Sidebar Enhancement: Drag to reorder your favorited pages in the Sidebar
- Mobile Table Resizing: Adjust column widths in Simple Tables by long pressing the column borders on mobile
### Bug Fixes
- Resolved an icon rendering issue in callout blocks, tab bars, and search results
- Enhanced image reliability: Retry functionality ensures images load successfully if the first attempt fails
## Version 0.8.0 - 06/01/2025
### Bug Fixes
- Fixed error displaying in the page style menu

View File

@ -7,19 +7,16 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/cust
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/widgets/embed_image_url_widget.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
hide UploadImageMenu, ResizableImage;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:run_with_network_images/run_with_network_images.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
@ -29,58 +26,6 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('image block in document', () {
Future<void> testEmbedImage(WidgetTester tester, String url) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
find.descendant(
of: find.byType(ImagePlaceholder),
matching: find.byType(AppFlowyPopover),
),
findsOneWidget,
);
expect(find.byType(UploadImageMenu), findsOneWidget);
await tester.tapButtonWithName(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
);
await tester.enterText(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.byType(TextField),
),
url,
);
await tester.tapButton(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.text(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
findRichText: true,
),
),
);
await tester.pumpAndSettle();
expect(find.byType(ResizableImage), findsOneWidget);
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], url);
}
testWidgets('insert an image from local file', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@ -131,43 +76,6 @@ void main() {
file.deleteSync();
});
testWidgets('insert a gif image from network', (tester) async {
await testEmbedImage(
tester,
'https://www.easygifanimator.net/images/samples/sparkles.gif',
);
});
testWidgets('insert an image from unsplash', (tester) async {
await runWithNetworkImages(() async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
find.descendant(
of: find.byType(ImagePlaceholder),
matching: find.byType(AppFlowyPopover),
),
findsOneWidget,
);
expect(find.byType(UploadImageMenu), findsOneWidget);
expect(find.text('Unsplash'), findsOneWidget);
});
});
testWidgets('insert two images from local file at once', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();

View File

@ -87,7 +87,7 @@ void main() {
as ShortcutSettingTile;
expect(
second.command.command,
'backspace, shift+backspace',
'',
);
});
});

View File

@ -252,7 +252,6 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
child: ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier,
builder: (_, value, child) {
final url = node.attributes[CustomImageBlockKeys.url];
return Stack(
children: [
editorState.editable
@ -266,7 +265,7 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
child: child!,
)
: child!,
if (value && url.isNotEmpty == true)
if (value)
widget.menuBuilder!(widget.node, this, imageStateNotifier),
],
);

View File

@ -42,11 +42,12 @@ class _ImageMenuState extends State<ImageMenu> {
@override
Widget build(BuildContext context) {
final isPlaceholder = url == null || url!.isEmpty;
final theme = Theme.of(context);
return ValueListenableBuilder<ResizableImageState>(
valueListenable: widget.imageStateNotifier,
builder: (_, state, child) {
if (state == ResizableImageState.loading) {
if (state == ResizableImageState.loading && !isPlaceholder) {
return const SizedBox.shrink();
}
@ -66,21 +67,25 @@ class _ImageMenuState extends State<ImageMenu> {
child: Row(
children: [
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
iconData: FlowySvgs.full_view_s,
onTap: openFullScreen,
),
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.editor_copy.tr(),
iconData: FlowySvgs.copy_s,
onTap: copyImageLink,
),
const HSpace(4),
if (!isPlaceholder) ...[
MenuBlockButton(
tooltip: LocaleKeys.document_imageBlock_openFullScreen.tr(),
iconData: FlowySvgs.full_view_s,
onTap: openFullScreen,
),
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.editor_copy.tr(),
iconData: FlowySvgs.copy_s,
onTap: copyImageLink,
),
const HSpace(4),
],
if (widget.state.editorState.editable) ...[
_ImageAlignButton(node: widget.node, state: widget.state),
const _Divider(),
if (!isPlaceholder) ...[
_ImageAlignButton(node: widget.node, state: widget.state),
const _Divider(),
],
MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
iconData: FlowySvgs.trash_s,

View File

@ -1,321 +0,0 @@
import 'dart:math';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
/// Backspace key event.
///
/// - support
/// - desktop
/// - web
/// - mobile
///
final CommandShortcutEvent customBackspaceCommand = CommandShortcutEvent(
key: 'backspace',
getDescription: () => AppFlowyEditorL10n.current.cmdDeleteLeft,
command: 'backspace, shift+backspace',
handler: _backspaceCommandHandler,
);
CommandShortcutEventHandler _backspaceCommandHandler = (editorState) {
final selection = editorState.selection;
final selectionType = editorState.selectionType;
if (selection == null) {
return KeyEventResult.ignored;
}
final reason = editorState.selectionUpdateReason;
if (selectionType == SelectionType.block) {
return _backspaceInBlockSelection(editorState);
} else if (selection.isCollapsed) {
return _backspaceInCollapsedSelection(editorState);
} else if (reason == SelectionUpdateReason.selectAll) {
return _backspaceInSelectAll(editorState);
} else {
return _backspaceInNotCollapsedSelection(editorState);
}
};
/// Handle backspace key event when selection is collapsed.
CommandShortcutEventHandler _backspaceInCollapsedSelection = (editorState) {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return KeyEventResult.ignored;
}
final position = selection.start;
final node = editorState.getNodeAtPath(position.path);
if (node == null) {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction;
// delete the entire node if the delta is empty
if (node.delta == null) {
transaction.deleteNode(node);
transaction.afterSelection = Selection.collapsed(
Position(
path: position.path,
),
);
editorState.apply(transaction);
return KeyEventResult.handled;
}
// Why do we use prevRunPosition instead of the position start offset?
// Because some character's length > 1, for example, emoji.
final index = node.delta!.prevRunePosition(position.offset);
if (index < 0) {
// move this node to it's parent in below case.
// the node's next is null
// and the node's children is empty
if (node.next == null &&
node.children.isEmpty &&
node.parent?.parent != null &&
node.parent?.delta != null) {
final path = node.parent!.path.next;
transaction
..deleteNode(node)
..insertNode(path, node)
..afterSelection = Selection.collapsed(
Position(
path: path,
),
);
} else {
// If the deletion crosses columns and starts from the beginning position
// skip the node deletion process
// otherwise it will cause an error in table rendering.
if (node.parent?.type == SimpleTableCellBlockKeys.type &&
position.offset == 0) {
return KeyEventResult.handled;
}
final Node? tableParent = node
.findParent((element) => element.type == SimpleTableBlockKeys.type);
Node? prevTableParent;
final prev = node.previousNodeWhere((element) {
prevTableParent = element
.findParent((element) => element.type == SimpleTableBlockKeys.type);
// break if only one is in a table or they're in different tables
return tableParent != prevTableParent ||
// merge with the previous node contains delta.
element.delta != null;
});
// table nodes should be deleted using the table menu
// in-table paragraphs should only be deleted inside the table
if (prev != null && tableParent == prevTableParent) {
assert(prev.delta != null);
transaction
..mergeText(prev, node)
..insertNodes(
// insert children to previous node
prev.path.next,
node.children.toList(),
)
..deleteNode(node)
..afterSelection = Selection.collapsed(
Position(
path: prev.path,
offset: prev.delta!.length,
),
);
} else {
// do nothing if there is no previous node contains delta.
return KeyEventResult.ignored;
}
}
} else {
// Although the selection may be collapsed,
// its length may not always be equal to 1 because some characters have a length greater than 1.
transaction.deleteText(
node,
index,
position.offset - index,
);
}
editorState.apply(transaction);
return KeyEventResult.handled;
};
/// Handle backspace key event when selection is not collapsed.
CommandShortcutEventHandler _backspaceInNotCollapsedSelection = (editorState) {
final selection = editorState.selection;
if (selection == null || selection.isCollapsed) {
return KeyEventResult.ignored;
}
editorState.deleteSelectionV2(selection);
return KeyEventResult.handled;
};
CommandShortcutEventHandler _backspaceInBlockSelection = (editorState) {
final selection = editorState.selection;
if (selection == null || editorState.selectionType != SelectionType.block) {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction;
transaction.deleteNodesAtPath(selection.start.path);
editorState
.apply(transaction)
.then((value) => editorState.selectionType = null);
return KeyEventResult.handled;
};
CommandShortcutEventHandler _backspaceInSelectAll = (editorState) {
final selection = editorState.selection;
if (selection == null) {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction;
final nodes = editorState.getNodesInSelection(selection);
transaction.deleteNodes(nodes);
editorState.apply(transaction);
return KeyEventResult.handled;
};
extension on EditorState {
Future<bool> deleteSelectionV2(Selection selection) async {
// Nothing to do if the selection is collapsed.
if (selection.isCollapsed) {
return false;
}
// Normalize the selection so that it is never reversed or extended.
selection = selection.normalized;
// Start a new transaction.
final transaction = this.transaction;
// Get the nodes that are fully or partially selected.
final nodes = getNodesInSelection(selection);
// If only one node is selected, then we can just delete the selected text
// or node.
if (nodes.length == 1) {
// If table cell is selected, clear the cell node child.
final node = nodes.first.type == SimpleTableCellBlockKeys.type
? nodes.first.children.first
: nodes.first;
if (node.delta != null) {
transaction.deleteText(
node,
selection.startIndex,
selection.length,
);
} else if (node.parent?.type != SimpleTableCellBlockKeys.type &&
node.parent?.type != SimpleTableRowBlockKeys.type) {
transaction.deleteNode(node);
}
}
// Otherwise, multiple nodes are selected, so we have to do more work.
else {
// The nodes are guaranteed to be in order, so we can determine which
// nodes are at the beginning, middle, and end of the selection.
assert(nodes.first.path < nodes.last.path);
for (var i = 0; i < nodes.length; i++) {
final node = nodes[i];
// The first node is at the beginning of the selection.
// All other nodes can be deleted.
if (i != 0) {
// Never delete a table cell node child
if (node.parent?.type == SimpleTableCellBlockKeys.type) {
if (!nodes.any((n) => n.id == node.parent?.parent?.id) &&
node.delta != null) {
transaction.deleteText(
node,
0,
min(selection.end.offset, node.delta!.length),
);
}
}
// If first node was inside table cell then it wasn't mergable to last
// node, So we should not delete the last node. Just delete part of
// the text inside selection
else if (node.id == nodes.last.id &&
nodes.first.parent?.type == SimpleTableCellBlockKeys.type) {
transaction.deleteText(
node,
0,
selection.end.offset,
);
} else if (node.type != SimpleTableCellBlockKeys.type &&
node.type != SimpleTableRowBlockKeys.type) {
transaction.deleteNode(node);
}
continue;
}
// If the last node is also a text node and not a node inside table cell,
// and also the current node isn't inside table cell, then we can merge
// the text between the two nodes.
if (nodes.last.delta != null &&
![node.parent?.type, nodes.last.parent?.type]
.contains(SimpleTableCellBlockKeys.type)) {
transaction.mergeText(
node,
nodes.last,
leftOffset: selection.startIndex,
rightOffset: selection.endIndex,
);
// combine the children of the last node into the first node.
final last = nodes.last;
if (last.children.isNotEmpty) {
if (indentableBlockTypes.contains(node.type)) {
transaction.insertNodes(
node.path + [0],
last.children,
);
} else {
transaction.insertNodes(
node.path.next,
last.children,
);
}
}
}
// Otherwise, we can just delete the selected text.
else {
// If the last or first node is inside table we will only delete
// selection part of first node.
if (nodes.last.parent?.type == SimpleTableCellBlockKeys.type ||
node.parent?.type == SimpleTableCellBlockKeys.type) {
transaction.deleteText(
node,
selection.startIndex,
node.delta!.length - selection.startIndex,
);
} else {
transaction.deleteText(
node,
selection.startIndex,
selection.length,
);
}
}
}
}
// After the selection is deleted, we want to move the selection to the
// beginning of the deleted selection.
transaction.afterSelection = selection.collapse(atStart: true);
// Apply the transaction.
await apply(transaction);
return true;
}
}

View File

@ -1,7 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/backspace_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -38,8 +37,6 @@ List<CommandShortcutEvent> commandShortcutEvents = [
...customTextAlignCommands,
customBackspaceCommand,
// remove standard shortcuts for copy, cut, paste, todo
...standardCommandShortcutEvents
..removeWhere(