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), + ); + }); }); } 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; } 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, 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; +};