Add Cmd/Ctrl + D shortcut for duplicating document blocks #8578 #8627
Add Cmd/Ctrl + D shortcut for duplicating document blocks #8578 #8627acarolinacc wants to merge 4 commits intoAppFlowy-IO:mainfrom
Conversation
Added tests for custom duplicate block commands in the editor, including single and multi-block selections.
Add right icon for duplicate option with platform-specific shortcut.
Implement custom command to duplicate selected blocks in the editor.
Reviewer's GuideAdds a Cmd/Ctrl + D keyboard shortcut that reuses the existing duplicate-block behavior, supports single and multi-block selections, and surfaces the shortcut in the block action menu, with integration tests covering the new shortcut behavior. Sequence diagram for Cmd_or_Ctrl_D duplicate block shortcut handlingsequenceDiagram
actor User
participant EditorWidget
participant CommandShortcutRegistry
participant customDuplicateBlockCommand
participant EditorState
participant Transaction
participant EditorNotification
User->>EditorWidget: Press_Cmd_or_Ctrl_D
EditorWidget->>CommandShortcutRegistry: onKeyEvent(ctrl_or_cmd_plus_d)
CommandShortcutRegistry->>customDuplicateBlockCommand: invokeHandler(editorState)
customDuplicateBlockCommand->>EditorState: read_selection()
alt no_selection
customDuplicateBlockCommand-->>CommandShortcutRegistry: KeyEventResult_ignored
else block_or_multi_block_selection
customDuplicateBlockCommand->>EditorState: transaction = transaction
customDuplicateBlockCommand->>EditorState: getNodesInSelection(normalized_selection)
customDuplicateBlockCommand->>Transaction: insertNodes(end_path.next, deep_copied_nodes)
else caret_inside_single_block
customDuplicateBlockCommand->>EditorState: getNodeAtPath(end_path)
customDuplicateBlockCommand->>Transaction: insertNode(node.path.next, node.deepCopy())
end
customDuplicateBlockCommand->>EditorState: apply(transaction)
EditorState-->>customDuplicateBlockCommand: Future_complete
customDuplicateBlockCommand->>EditorNotification: paste().post()
Updated class diagram for duplicate block shortcut and option action menuclassDiagram
class CommandShortcutEvent {
String key
String command
String macOSCommand
CommandShortcutEventHandler handler
String getDescription()
}
class CommandShortcutEventHandler {
}
class customDuplicateBlockCommand {
<<constant>>
+CommandShortcutEvent value
}
class EditorState {
Selection selection
SelectionType selectionType
Transaction transaction
Selection get normalized
List~Node~ getNodesInSelection(Selection selection)
Node getNodeAtPath(Path path)
Future apply(Transaction transaction)
}
class Transaction {
void insertNodes(Path path, List~Node~ nodes)
void insertNode(Path path, Node node)
}
class OptionActionWrapper {
OptionAction inner
Widget leftIcon(Color iconColor)
Widget rightIcon(Color iconColor)
String get name
}
class OptionAction {
<<enum>>
OptionAction duplicate
String description
String svg
}
class EditorNotification {
static EditorNotification paste()
void post()
}
class FlowyText {
static Widget regular(String text, Color color, double fontSize)
}
class UniversalPlatform {
static bool isMacOS
}
CommandShortcutEvent "1" --> "1" CommandShortcutEventHandler : uses
customDuplicateBlockCommand "1" --> "1" CommandShortcutEvent : wraps
customDuplicateBlockCommand "1" --> "1" EditorState : handler_uses
EditorState "1" --> "1" Transaction : creates_and_uses
customDuplicateBlockCommand "1" --> "1" EditorNotification : posts_paste
OptionActionWrapper "1" --> "1" OptionAction : wraps
OptionActionWrapper "1" --> "1" FlowyText : displays_shortcut_label
OptionActionWrapper "1" --> "1" UniversalPlatform : checks_platform
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
|
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The
Cmd+D/Ctrl+Dlabel inOptionActionWrapper.rightIconis hard-coded and duplicates the shortcut definition incustom_duplicate_block_command; consider deriving the display string from the same shortcut configuration (or a shared helper) so that changing the keybinding in one place automatically updates the menu label.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The `Cmd+D` / `Ctrl+D` label in `OptionActionWrapper.rightIcon` is hard-coded and duplicates the shortcut definition in `custom_duplicate_block_command`; consider deriving the display string from the same shortcut configuration (or a shared helper) so that changing the keybinding in one place automatically updates the menu label.
## Individual Comments
### Comment 1
<location path="frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart" line_range="139-148" />
<code_context>
@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),
</code_context>
<issue_to_address>
**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.
</issue_to_address>
### Comment 2
<location path="frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/custom_duplicate_block_command.dart" line_range="14-23" />
<code_context>
+final CommandShortcutEvent customDuplicateBlockCommand = CommandShortcutEvent(
</code_context>
<issue_to_address>
**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:
```dart
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:
```dart
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:
```dart
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:
```dart
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.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| @override | ||
| Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); | ||
|
|
||
| @override | ||
| Widget? rightIcon(Color iconColor) { | ||
| if (inner != OptionAction.duplicate) { | ||
| return null; | ||
| } | ||
|
|
||
| return FlowyText.regular( |
There was a problem hiding this comment.
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,
);
}
- Ensure that
customDuplicateBlockCommandis 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 likeCmd+D/Ctrl+Dbased on the current keybinding configuration and platform.
- If
customDuplicateBlockCommandis currently only defining the keybinding but not a label:- Add a
String get shortcutLabelto that command (or a shared helper) that builds the label from its configured keybinding(s) instead of hardcoding platform-specific strings in the UI.
- Add a
- Once the shared shortcut label API is in place, remove any now-unused imports such as
universal_platformfrom this file.
| 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; |
There was a problem hiding this comment.
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:
-
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;
-
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); }
-
Compute the selection for the duplicated blocks
After you've scheduled the insertions on the transaction, compute a new
Selectionthat 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); }
-
Apply the transaction and return the appropriate
KeyEventResultEnsure the selection update happens as part of the same edit cycle:
editorState.apply(transaction); return KeyEventResult.handled; }
-
Align with actual APIs in your codebase
- Replace
getNodesInSelection,insertNode,Selection,Position,transaction.afterSelection,editorState.apply, andnode.copyWithwith the actual APIs fromappflowy_editorand 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 theSelectionfor those paths.
- Replace
In summary, the handler should:
- Derive the list (and paths) of blocks being duplicated.
- Duplicate them exactly as you currently do.
- Compute a
Selectionthat targets the newly inserted blocks. - Set that selection on the transaction (or on
editorStateafter applying the transaction), so that subsequentCmd/Ctrl+Doperates on the duplicates.
Feature Preview
A short demo video is attached below showing the new shortcut behavior after the fix:
Ctrl + D/Cmd + DCES_After.mp4
Summary
This PR adds a keyboard shortcut for duplicating document blocks in AppFlowy.
The new shortcut works as follows:
Cmd + Don macOSCtrl + Don Windows/LinuxThe implementation reuses the existing duplicate block behavior instead of creating a separate duplication path. It also supports both single-block and multi-block duplication, and shows the shortcut label in the block action menu to make the feature easier to discover.
Related issue
Closes #8578
Changes made
Testing
Tested manually by:
Also validated with integration tests for:
PR Checklist
Summary by Sourcery
Add a keyboard shortcut to duplicate document blocks and expose it in the editor UI.
New Features:
Tests: