From 63d59499e1770c447f49f9107dbc25d511496d36 Mon Sep 17 00:00:00 2001 From: Carolina Costa <93997120+acarolinacc@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:48:50 +0100 Subject: [PATCH 1/4] Add tests for custom duplicate block commands Added tests for custom duplicate block commands in the editor, including single and multi-block selections. --- .../document/document_shortcuts_test.dart | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_shortcuts_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_shortcuts_test.dart index cf33a66947922..bdfbc4033ba0b 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_shortcuts_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_shortcuts_test.dart @@ -136,5 +136,120 @@ void main() { equals(text1), ); }); + + testWidgets('custom duplicate block command - duplicate current block', ( + tester, + ) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const pageName = 'Test Duplicate Block Shortcut'; + await tester.createNewPageWithNameUnderParent(name: pageName); + + await tester.tap(find.byType(AppFlowyEditor)); + + final editorState = tester.editor.getCurrentEditorState(); + final transaction = editorState.transaction; + const text1 = 'First block'; + const text2 = 'Second block'; + transaction.insertNodes( + [0], + [ + paragraphNode(text: text1), + paragraphNode(text: text2), + ], + ); + await editorState.apply(transaction); + await tester.pumpAndSettle(); + + await tester.editor.updateSelection( + Selection.collapsed( + Position(path: [0], offset: text1.length), + ), + ); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyD, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect( + tester.editor.getNodeAtPath([0]).delta?.toPlainText(), + equals(text1), + ); + expect( + tester.editor.getNodeAtPath([1]).delta?.toPlainText(), + equals(text1), + ); + expect( + tester.editor.getNodeAtPath([2]).delta?.toPlainText(), + equals(text2), + ); + }); + + testWidgets( + 'custom duplicate block command - duplicate multi-block selection', ( + tester, + ) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const pageName = 'Test Duplicate Multi Block Shortcut'; + await tester.createNewPageWithNameUnderParent(name: pageName); + + await tester.tap(find.byType(AppFlowyEditor)); + + final editorState = tester.editor.getCurrentEditorState(); + final transaction = editorState.transaction; + const text1 = 'First block'; + const text2 = 'Second block'; + const text3 = 'Third block'; + transaction.insertNodes( + [0], + [ + paragraphNode(text: text1), + paragraphNode(text: text2), + paragraphNode(text: text3), + ], + ); + await editorState.apply(transaction); + await tester.pumpAndSettle(); + + editorState.selection = Selection( + start: Position(path: [0]), + end: Position(path: [1], offset: text2.length), + ); + + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyD, + isControlPressed: !UniversalPlatform.isMacOS, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(editorState.document.root.children, hasLength(5)); + expect( + tester.editor.getNodeAtPath([0]).delta?.toPlainText(), + equals(text1), + ); + expect( + tester.editor.getNodeAtPath([1]).delta?.toPlainText(), + equals(text2), + ); + expect( + tester.editor.getNodeAtPath([2]).delta?.toPlainText(), + equals(text1), + ); + expect( + tester.editor.getNodeAtPath([3]).delta?.toPlainText(), + equals(text2), + ); + expect( + tester.editor.getNodeAtPath([4]).delta?.toPlainText(), + equals(text3), + ); + }); }); } From 106f6ee03240f926a5714ba8255649dd0d3b0e63 Mon Sep 17 00:00:00 2001 From: Carolina Costa <93997120+acarolinacc@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:50:48 +0100 Subject: [PATCH 2/4] Implement right icon for duplicate option action Add right icon for duplicate option with platform-specific shortcut. --- .../actions/option/depth_option_action.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart index bc083cd61763c..70ca83001a8df 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart @@ -4,7 +4,9 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; +import 'package:universal_platform/universal_platform.dart'; enum OptionDepthType { h1(1, 'H1'), @@ -137,6 +139,19 @@ class OptionActionWrapper extends ActionCell { @override Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); + @override + Widget? rightIcon(Color iconColor) { + if (inner != OptionAction.duplicate) { + return null; + } + + return FlowyText.regular( + UniversalPlatform.isMacOS ? 'Cmd+D' : 'Ctrl+D', + color: iconColor.withValues(alpha: 0.65), + fontSize: 12, + ); + } + @override String get name => inner.description; } From fe92347e5d280d8f2f0dd3a9c77c49b5f3bc6a52 Mon Sep 17 00:00:00 2001 From: Carolina Costa <93997120+acarolinacc@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:51:50 +0100 Subject: [PATCH 3/4] Add custom duplicate block command to shortcuts --- .../editor_plugins/shortcuts/command_shortcuts.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart index aedfcff432415..67b7b6edf0ad5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart @@ -3,6 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/align_tool import 'package:appflowy/plugins/document/presentation/editor_plugins/math_equation/math_equation_shortcut.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/custom_delete_command.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/custom_duplicate_block_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'; @@ -39,6 +40,7 @@ List commandShortcutEvents = [ ...customTextAlignCommands, + customDuplicateBlockCommand, customDeleteCommand, insertInlineMathEquationCommand, From d31e212881382907abc3072ab517d069cac56dba Mon Sep 17 00:00:00 2001 From: Carolina Costa <93997120+acarolinacc@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:53:32 +0100 Subject: [PATCH 4/4] Add custom duplicate block command for editor Implement custom command to duplicate selected blocks in the editor. --- .../custom_duplicate_block_command.dart | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/custom_duplicate_block_command.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/custom_duplicate_block_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/custom_duplicate_block_command.dart new file mode 100644 index 0000000000000..f5fa4e7c049ba --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/custom_duplicate_block_command.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +/// Duplicate block(s). +/// +/// - support +/// - desktop +/// - web +final CommandShortcutEvent customDuplicateBlockCommand = CommandShortcutEvent( + key: 'duplicate selected block', + getDescription: () => LocaleKeys.document_plugins_optionAction_duplicate.tr(), + command: 'ctrl+d', + macOSCommand: 'cmd+d', + handler: _duplicateBlockCommandHandler, +); + +CommandShortcutEventHandler _duplicateBlockCommandHandler = (editorState) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + + final transaction = editorState.transaction; + final normalizedSelection = selection.normalized; + final isMultiBlockSelection = + normalizedSelection.start.path != normalizedSelection.end.path; + + if (editorState.selectionType == SelectionType.block || + isMultiBlockSelection) { + final nodes = editorState.getNodesInSelection(normalizedSelection); + if (nodes.isEmpty) { + return KeyEventResult.ignored; + } + + transaction.insertNodes( + normalizedSelection.end.path.next, + nodes.map((node) => node.deepCopy()).toList(), + ); + } else { + final node = editorState.getNodeAtPath(normalizedSelection.end.path); + if (node == null) { + return KeyEventResult.ignored; + } + + transaction.insertNode(node.path.next, node.deepCopy()); + } + + unawaited( + editorState.apply(transaction).then((_) { + EditorNotification.paste().post(); + }), + ); + + return KeyEventResult.handled; +};