Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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(
Comment on lines 139 to +148
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Avoid hardcoding the shortcut label to keep it in sync with the actual keybinding configuration.

The rightIcon hardcodes Cmd+D / Ctrl+D while the actual shortcut is defined in customDuplicateBlockCommand. If the binding changes or becomes configurable, the label will be wrong. Please derive this label from the same source as the shortcut (or a shared constant) so the UI shortcut hint always matches the real keybinding.

Suggested implementation:

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';
// TODO: Import the module that defines `customDuplicateBlockCommand` or exposes
// a shared shortcut label for the duplicate block action.
// import 'package:appflowy_editor/commands/custom_duplicate_block_command.dart';

  @override
  Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg);

  @override
  Widget? rightIcon(Color iconColor) {
    if (inner != OptionAction.duplicate) {
      return null;
    }

    // Use the same source of truth as the duplicate block keybinding so the
    // label always matches the actual shortcut configuration.
    final shortcutLabel = customDuplicateBlockCommand.shortcutLabel;

    if (shortcutLabel == null || shortcutLabel.isEmpty) {
      // If no shortcut is configured, don't show a hint.
      return null;
    }

    return FlowyText.regular(
      shortcutLabel,
      color: iconColor.withValues(alpha: 0.65),
      fontSize: 12,
    );
  }

  1. Ensure that customDuplicateBlockCommand is accessible in this file:
    • If it already exists (e.g. in a commands/shortcuts file), export it or import the defining file here.
    • The object should expose a shortcutLabel (or similarly named) property or getter that returns a user-facing label like Cmd+D / Ctrl+D based on the current keybinding configuration and platform.
  2. If customDuplicateBlockCommand is currently only defining the keybinding but not a label:
    • Add a String get shortcutLabel to that command (or a shared helper) that builds the label from its configured keybinding(s) instead of hardcoding platform-specific strings in the UI.
  3. Once the shared shortcut label API is in place, remove any now-unused imports such as universal_platform from this file.

UniversalPlatform.isMacOS ? 'Cmd+D' : 'Ctrl+D',
color: iconColor.withValues(alpha: 0.65),
fontSize: 12,
);
}

@override
String get name => inner.description;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,6 +40,7 @@ List<CommandShortcutEvent> commandShortcutEvents = [

...customTextAlignCommands,

customDuplicateBlockCommand,
customDeleteCommand,
insertInlineMathEquationCommand,

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +14 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider updating the selection to target the newly duplicated block(s) after the command runs.

Right now the selection stays on the original block(s), which can lead to accidental edits there and makes repeated Cmd/Ctrl+D less fluid. If the editor API supports it, update editorState.selection in the transaction to focus or select the newly created block range instead of the originals.

Suggested implementation:

final CommandShortcutEvent customDuplicateBlockCommand = CommandShortcutEvent(
  key: 'duplicate selected block',
  getDescription: () => LocaleKeys.document_plugins_optionAction_duplicate.tr(),
  command: 'ctrl+d',
  macOSCommand: 'cmd+d',
  handler: _duplicateBlockCommandHandler,
  // NOTE:
  // When duplicating, the selection should be moved to the newly created block(s)
  // so that repeated Cmd/Ctrl+D and subsequent edits operate on the duplicates
  // instead of the original blocks. The handler below is responsible for
  // updating the selection in the same transaction that performs the duplication.
);

To fully implement “selection moves to duplicated blocks”, you’ll need to update the body of _duplicateBlockCommandHandler. Since that part of the file isn’t visible here, below is a concrete pattern you can adapt to your actual duplication logic:

  1. Capture the original selection and the blocks being duplicated

    Right after your existing null-check, derive which block nodes are being duplicated and their paths:

    CommandShortcutEventHandler _duplicateBlockCommandHandler = (editorState) {
      final selection = editorState.selection;
      if (selection == null) {
        return KeyEventResult.ignored; // or whatever you currently return
      }
    
      // 1. Get all blocks in the current selection (API name may differ in your codebase)
      final selectedNodes = editorState.getNodesInSelection(selection)
          .where((node) => node.isBlock) // if needed
          .toList();
    
      if (selectedNodes.isEmpty) {
        return KeyEventResult.ignored;
      }
    
      final firstOriginalPath = selectedNodes.first.path;
      final lastOriginalPath = selectedNodes.last.path;
  2. Duplicate the blocks as you already do, but track the insertion position

    Wherever you are currently duplicating blocks, make sure you know the insertion index/path of the first duplicated block. For example, if you insert immediately after the last original block:

      final transaction = editorState.transaction;
    
      // This assumes you insert new blocks after the last original block.
      // Adjust indices to match your actual insertion logic.
      final insertionParentPath = lastOriginalPath.parent;
      final insertionIndex = lastOriginalPath.index + 1;
    
      final duplicatedNodes = <Node>[];
    
      for (final node in selectedNodes) {
        final cloned = node.copyWith(
          // If your Node has a dedicated clone method, use that instead.
          // Make sure IDs are regenerated if your model requires it.
        );
    
        transaction.insertNode(
          insertionParentPath,
          insertionIndex + duplicatedNodes.length,
          cloned,
        );
    
        duplicatedNodes.add(cloned);
      }
  3. Compute the selection for the duplicated blocks

    After you've scheduled the insertions on the transaction, compute a new Selection that spans from the first duplicated block to the last duplicated block:

      if (duplicatedNodes.isNotEmpty) {
        final firstDuplicated = duplicatedNodes.first;
        final lastDuplicated = duplicatedNodes.last;
    
        // Depending on your editor model, you may want to:
        // - select the entire block(s)
        // - or place a caret at a specific offset (e.g., at the start of the first duplicated block)
        final newSelection = Selection(
          base: Position(path: firstDuplicated.path, offset: 0),
          extent: Position(path: lastDuplicated.path, offset: lastDuplicated.length),
        );
    
        // If the transaction supports setting an "after selection", prefer that:
        transaction.afterSelection = newSelection;
    
        // Otherwise, after applying the transaction:
        // editorState.selection = newSelection;
        // or
        // editorState.updateSelection(newSelection);
      }
  4. Apply the transaction and return the appropriate KeyEventResult

    Ensure the selection update happens as part of the same edit cycle:

      editorState.apply(transaction);
      return KeyEventResult.handled;
    }
  5. Align with actual APIs in your codebase

    • Replace getNodesInSelection, insertNode, Selection, Position, transaction.afterSelection, editorState.apply, and node.copyWith with the actual APIs from appflowy_editor and your models.
    • If your editor already exposes a helper like editorState.duplicateSelection(selection) that returns the paths of the new nodes, use that directly and only handle computing and setting the Selection for those paths.

In summary, the handler should:

  1. Derive the list (and paths) of blocks being duplicated.
  2. Duplicate them exactly as you currently do.
  3. Compute a Selection that targets the newly inserted blocks.
  4. Set that selection on the transaction (or on editorState after applying the transaction), so that subsequent Cmd/Ctrl+D operates on the duplicates.

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;
};
Loading