Skip to content

Commit 9ba52a8

Browse files
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.
1 parent a51caa5 commit 9ba52a8

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 copiable = scope.focusedNode as ICopyable<ICopyData>;
95+
const data = copiable.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
@@ -36,6 +36,7 @@ import {ActionMenu} from './actions/action_menu';
3636
import {MoveActions} from './actions/move';
3737
import {Mover} from './actions/mover';
3838
import {UndoRedoAction} from './actions/undo_redo';
39+
import {DuplicateAction} from './actions/duplicate';
3940

4041
const KeyCodes = BlocklyUtils.KeyCodes;
4142

@@ -60,6 +61,8 @@ export class NavigationController {
6061

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

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

6568
/** Keyboard navigation actions for the arrow keys. */
@@ -248,6 +251,7 @@ export class NavigationController {
248251
this.actionMenu.install();
249252

250253
this.clipboard.install();
254+
this.duplicateAction.install();
251255
this.moveActions.install();
252256
this.shortcutDialog.install();
253257

@@ -266,6 +270,7 @@ export class NavigationController {
266270
this.editAction.uninstall();
267271
this.disconnectAction.uninstall();
268272
this.clipboard.uninstall();
273+
this.duplicateAction.uninstall();
269274
this.workspaceMovement.uninstall();
270275
this.arrowNavigation.uninstall();
271276
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

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)