Skip to content

Commit c5a99cd

Browse files
feat: duplicate shortcut for blocks and comments (#642)
* feat: duplicate shortcut for blocks and comments Augment context menu entries from core with the shortcut text if present. Reorder initalization as we now require comment context menu actions to be registered first for this feature. Be defensive for folks that might already have such a shortcut. * chore: tweak naming to match type
1 parent dcbd21d commit c5a99cd

File tree

7 files changed

+240
-3
lines changed

7 files changed

+240
-3
lines changed

src/actions/duplicate.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
BlockSvg,
9+
clipboard,
10+
ContextMenuRegistry,
11+
ICopyable,
12+
ShortcutRegistry,
13+
utils,
14+
comments,
15+
ICopyData,
16+
} from 'blockly';
17+
import * as Constants from '../constants';
18+
import {getMenuItem} from '../shortcut_formatting';
19+
20+
/**
21+
* Duplicate action that adds a keyboard shortcut for duplicate and overrides
22+
* the context menu item to show it if the context menu item is registered.
23+
*/
24+
export class DuplicateAction {
25+
private duplicateShortcut: ShortcutRegistry.KeyboardShortcut | null = null;
26+
private uninstallHandlers: Array<() => void> = [];
27+
28+
/**
29+
* Install the shortcuts and override context menu entries.
30+
*
31+
* No change is made if there's already a 'duplicate' shortcut.
32+
*/
33+
install() {
34+
this.duplicateShortcut = this.registerDuplicateShortcut();
35+
if (this.duplicateShortcut) {
36+
this.uninstallHandlers.push(
37+
overrideContextMenuItemForShortcutText(
38+
'blockDuplicate',
39+
Constants.SHORTCUT_NAMES.DUPLICATE,
40+
),
41+
);
42+
this.uninstallHandlers.push(
43+
overrideContextMenuItemForShortcutText(
44+
'commentDuplicate',
45+
Constants.SHORTCUT_NAMES.DUPLICATE,
46+
),
47+
);
48+
}
49+
}
50+
51+
/**
52+
* Unregister the shortcut and reinstate the original context menu entries.
53+
*/
54+
uninstall() {
55+
this.uninstallHandlers.forEach((handler) => handler());
56+
this.uninstallHandlers.length = 0;
57+
if (this.duplicateShortcut) {
58+
ShortcutRegistry.registry.unregister(this.duplicateShortcut.name);
59+
}
60+
}
61+
62+
/**
63+
* Create and register the keyboard shortcut for the duplicate action.
64+
* Same behaviour as for the core context menu.
65+
* Skipped if there is a shortcut with a matching name already.
66+
*/
67+
private registerDuplicateShortcut(): ShortcutRegistry.KeyboardShortcut | null {
68+
if (
69+
ShortcutRegistry.registry.getRegistry()[
70+
Constants.SHORTCUT_NAMES.DUPLICATE
71+
]
72+
) {
73+
return null;
74+
}
75+
76+
const shortcut: ShortcutRegistry.KeyboardShortcut = {
77+
name: Constants.SHORTCUT_NAMES.DUPLICATE,
78+
// Equivalent to the core context menu entry.
79+
preconditionFn(workspace, scope) {
80+
const {focusedNode} = scope;
81+
if (focusedNode instanceof BlockSvg) {
82+
return (
83+
!focusedNode.isInFlyout &&
84+
focusedNode.isDeletable() &&
85+
focusedNode.isMovable() &&
86+
focusedNode.isDuplicatable()
87+
);
88+
} else if (focusedNode instanceof comments.RenderedWorkspaceComment) {
89+
return focusedNode.isMovable();
90+
}
91+
return false;
92+
},
93+
callback(workspace, e, shortcut, scope) {
94+
const copyable = scope.focusedNode as ICopyable<ICopyData>;
95+
const data = copyable.toCopyData();
96+
if (!data) return false;
97+
return !!clipboard.paste(data, workspace);
98+
},
99+
keyCodes: [utils.KeyCodes.D],
100+
};
101+
ShortcutRegistry.registry.register(shortcut);
102+
return shortcut;
103+
}
104+
}
105+
106+
/**
107+
* Replace a context menu item to add shortcut text to its displayText.
108+
*
109+
* Nothing happens if there is not a matching context menu item registered.
110+
*
111+
* @param registryId Context menu registry id to replace if present.
112+
* @param shortcutName The corresponding shortcut name.
113+
* @returns A function to reinstate the original context menu entry.
114+
*/
115+
function overrideContextMenuItemForShortcutText(
116+
registryId: string,
117+
shortcutName: string,
118+
): () => void {
119+
const original = ContextMenuRegistry.registry.getItem(registryId);
120+
if (!original || 'separator' in original) {
121+
return () => {};
122+
}
123+
124+
const override: ContextMenuRegistry.RegistryItem = {
125+
...original,
126+
displayText: (scope: ContextMenuRegistry.Scope) => {
127+
const displayText =
128+
typeof original.displayText === 'function'
129+
? original.displayText(scope)
130+
: original.displayText;
131+
if (displayText instanceof HTMLElement) {
132+
// We can't cope in this scenario.
133+
return displayText;
134+
}
135+
return getMenuItem(displayText, shortcutName);
136+
},
137+
};
138+
ContextMenuRegistry.registry.unregister(registryId);
139+
ContextMenuRegistry.registry.register(override);
140+
141+
return () => {
142+
ContextMenuRegistry.registry.unregister(registryId);
143+
ContextMenuRegistry.registry.register(original);
144+
};
145+
}

src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export enum SHORTCUT_NAMES {
4242
COPY = 'keyboard_nav_copy',
4343
CUT = 'keyboard_nav_cut',
4444
PASTE = 'keyboard_nav_paste',
45+
DUPLICATE = 'duplicate',
4546
MOVE_WS_CURSOR_UP = 'workspace_up',
4647
MOVE_WS_CURSOR_DOWN = 'workspace_down',
4748
MOVE_WS_CURSOR_LEFT = 'workspace_left',
@@ -89,6 +90,7 @@ SHORTCUT_CATEGORIES[Msg['SHORTCUTS_EDITING']] = [
8990
'cut',
9091
'copy',
9192
'paste',
93+
SHORTCUT_NAMES.DUPLICATE,
9294
'undo',
9395
'redo',
9496
];

src/navigation_controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {DisconnectAction} from './actions/disconnect';
3535
import {ActionMenu} from './actions/action_menu';
3636
import {MoveActions} from './actions/move';
3737
import {Mover} from './actions/mover';
38+
import {DuplicateAction} from './actions/duplicate';
3839

3940
const KeyCodes = BlocklyUtils.KeyCodes;
4041

@@ -59,6 +60,8 @@ export class NavigationController {
5960

6061
clipboard: Clipboard = new Clipboard(this.navigation);
6162

63+
duplicateAction = new DuplicateAction();
64+
6265
workspaceMovement: WorkspaceMovement = new WorkspaceMovement(this.navigation);
6366

6467
/** Keyboard navigation actions for the arrow keys. */
@@ -244,6 +247,7 @@ export class NavigationController {
244247
this.actionMenu.install();
245248

246249
this.clipboard.install();
250+
this.duplicateAction.install();
247251
this.moveActions.install();
248252
this.shortcutDialog.install();
249253

@@ -262,6 +266,7 @@ export class NavigationController {
262266
this.editAction.uninstall();
263267
this.disconnectAction.uninstall();
264268
this.clipboard.uninstall();
269+
this.duplicateAction.uninstall();
265270
this.workspaceMovement.uninstall();
266271
this.arrowNavigation.uninstall();
267272
this.exitAction.uninstall();

test/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ function createWorkspace(): Blockly.WorkspaceSvg {
9191
}
9292
const workspace = Blockly.inject(blocklyDiv, injectOptions);
9393

94-
new KeyboardNavigation(workspace);
9594
Blockly.ContextMenuItems.registerCommentOptions();
95+
new KeyboardNavigation(workspace);
9696
registerRunCodeShortcut();
9797

9898
// Disable blocks that aren't inside the setup or draw loops.

test/webdriverio/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ function createWorkspace(): Blockly.WorkspaceSvg {
7979
throw new Error('Missing blocklyDiv');
8080
}
8181
const workspace = Blockly.inject(blocklyDiv, injectOptions);
82+
Blockly.ContextMenuItems.registerCommentOptions();
8283

8384
new KeyboardNavigation(workspace);
8485
Blockly.ContextMenuItems.registerCommentOptions();

test/webdriverio/test/actions_test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ suite('Menus test', function () {
3535
await this.browser.keys([Key.Ctrl, Key.Return]);
3636
await this.browser.pause(PAUSE_TIME);
3737
chai.assert.isTrue(
38-
await contextMenuExists(this.browser, 'Duplicate'),
38+
await contextMenuExists(this.browser, 'Collapse Block'),
3939
'The menu should be openable on a block',
4040
);
4141
});
@@ -66,7 +66,7 @@ suite('Menus test', function () {
6666
await this.browser.keys([Key.Ctrl, Key.Return]);
6767
await this.browser.pause(PAUSE_TIME);
6868
chai.assert.isTrue(
69-
await contextMenuExists(this.browser, 'Duplicate', true),
69+
await contextMenuExists(this.browser, 'Collapse Block', true),
7070
'The menu should not be openable during a move',
7171
);
7272
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as Blockly from 'blockly';
8+
import * as chai from 'chai';
9+
import {
10+
focusOnBlock,
11+
getCurrentFocusedBlockId,
12+
getFocusedBlockType,
13+
PAUSE_TIME,
14+
tabNavigateToWorkspace,
15+
testFileLocations,
16+
testSetup,
17+
} from './test_setup.js';
18+
19+
suite('Duplicate test', function () {
20+
// Setting timeout to unlimited as these tests take longer time to run
21+
this.timeout(0);
22+
23+
// Clear the workspace and load start blocks
24+
setup(async function () {
25+
this.browser = await testSetup(testFileLocations.BASE);
26+
await this.browser.pause(PAUSE_TIME);
27+
});
28+
29+
test('Duplicate block', async function () {
30+
// Navigate to draw_circle_1.
31+
await tabNavigateToWorkspace(this.browser);
32+
await focusOnBlock(this.browser, 'draw_circle_1');
33+
34+
// Duplicate
35+
await this.browser.keys('d');
36+
await this.browser.pause(PAUSE_TIME);
37+
38+
// Check a different block of the same type has focus.
39+
chai.assert.notEqual(
40+
'draw_circle_1',
41+
await getCurrentFocusedBlockId(this.browser),
42+
);
43+
chai.assert.equal('simple_circle', await getFocusedBlockType(this.browser));
44+
});
45+
46+
test('Duplicate workspace comment', async function () {
47+
await tabNavigateToWorkspace(this.browser);
48+
const text = 'A comment with text';
49+
50+
// Create a single comment.
51+
await this.browser.execute((text: string) => {
52+
const workspace = Blockly.getMainWorkspace();
53+
Blockly.serialization.workspaceComments.append(
54+
{
55+
text,
56+
x: 200,
57+
y: 200,
58+
},
59+
workspace,
60+
);
61+
Blockly.getFocusManager().focusNode(
62+
(workspace as Blockly.WorkspaceSvg).getTopComments()[0],
63+
);
64+
}, text);
65+
await this.browser.pause(PAUSE_TIME);
66+
67+
// Duplicate.
68+
await this.browser.keys('d');
69+
70+
// Assert we have two comments with the same text.
71+
const commentTexts = await this.browser.execute(() =>
72+
Blockly.getMainWorkspace()
73+
.getTopComments(true)
74+
.map((comment) => comment.getText()),
75+
);
76+
chai.assert.deepEqual(commentTexts, [text, text]);
77+
// Assert it's the duplicate that is focused (positioned below).
78+
const [comment1, comment2] = await this.browser.$$('.blocklyComment');
79+
chai.assert.isTrue(await comment2.isFocused());
80+
chai.assert.isTrue(
81+
(await comment2.getLocation()).y > (await comment1.getLocation()).y,
82+
);
83+
});
84+
});

0 commit comments

Comments
 (0)