Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
145 changes: 145 additions & 0 deletions src/actions/duplicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {
BlockSvg,
clipboard,
ContextMenuRegistry,
ICopyable,
ShortcutRegistry,
utils,
comments,
ICopyData,
} from 'blockly';
import * as Constants from '../constants';
import {getMenuItem} from '../shortcut_formatting';

/**
* Duplicate action that adds a keyboard shortcut for duplicate and overrides
* the context menu item to show it if the context menu item is registered.
*/
export class DuplicateAction {
private duplicateShortcut: ShortcutRegistry.KeyboardShortcut | null = null;
private uninstallHandlers: Array<() => void> = [];

/**
* Install the shortcuts and override context menu entries.
*
* No change is made if there's already a 'duplicate' shortcut.
*/
install() {
this.duplicateShortcut = this.registerDuplicateShortcut();
if (this.duplicateShortcut) {
this.uninstallHandlers.push(
overrideContextMenuItemForShortcutText(
'blockDuplicate',
Constants.SHORTCUT_NAMES.DUPLICATE,
),
);
this.uninstallHandlers.push(
overrideContextMenuItemForShortcutText(
'commentDuplicate',
Constants.SHORTCUT_NAMES.DUPLICATE,
),
);
}
}

/**
* Unregister the shortcut and reinstate the original context menu entries.
*/
uninstall() {
this.uninstallHandlers.forEach((handler) => handler());
this.uninstallHandlers.length = 0;
if (this.duplicateShortcut) {
ShortcutRegistry.registry.unregister(this.duplicateShortcut.name);
}
}

/**
* Create and register the keyboard shortcut for the duplicate action.
* Same behaviour as for the core context menu.
* Skipped if there is a shortcut with a matching name already.
*/
private registerDuplicateShortcut(): ShortcutRegistry.KeyboardShortcut | null {
if (
ShortcutRegistry.registry.getRegistry()[
Constants.SHORTCUT_NAMES.DUPLICATE
]
) {
return null;
}

const shortcut: ShortcutRegistry.KeyboardShortcut = {
name: Constants.SHORTCUT_NAMES.DUPLICATE,
// Equivalent to the core context menu entry.
preconditionFn(workspace, scope) {
const {focusedNode} = scope;
if (focusedNode instanceof BlockSvg) {
return (
!focusedNode.isInFlyout &&
focusedNode.isDeletable() &&
focusedNode.isMovable() &&
focusedNode.isDuplicatable()
);
} else if (focusedNode instanceof comments.RenderedWorkspaceComment) {
return focusedNode.isMovable();
}
return false;
},
callback(workspace, e, shortcut, scope) {
const copiable = scope.focusedNode as ICopyable<ICopyData>;
Copy link
Collaborator

Choose a reason for hiding this comment

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

can you spell this copyable for consistency?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, thanks. ec3964c

const data = copiable.toCopyData();
if (!data) return false;
return !!clipboard.paste(data, workspace);
},
keyCodes: [utils.KeyCodes.D],
};
ShortcutRegistry.registry.register(shortcut);
return shortcut;
}
}

/**
* Replace a context menu item to add shortcut text to its displayText.
*
* Nothing happens if there is not a matching context menu item registered.
*
* @param registryId Context menu registry id to replace if present.
* @param shortcutName The corresponding shortcut name.
* @returns A function to reinstate the original context menu entry.
*/
function overrideContextMenuItemForShortcutText(
registryId: string,
shortcutName: string,
): () => void {
const original = ContextMenuRegistry.registry.getItem(registryId);
if (!original || 'separator' in original) {
return () => {};
}

const override: ContextMenuRegistry.RegistryItem = {
...original,
displayText: (scope: ContextMenuRegistry.Scope) => {
const displayText =
typeof original.displayText === 'function'
? original.displayText(scope)
: original.displayText;
if (displayText instanceof HTMLElement) {
// We can't cope in this scenario.
return displayText;
}
return getMenuItem(displayText, shortcutName);
},
};
ContextMenuRegistry.registry.unregister(registryId);
ContextMenuRegistry.registry.register(override);

return () => {
ContextMenuRegistry.registry.unregister(registryId);
ContextMenuRegistry.registry.register(original);
};
}
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export enum SHORTCUT_NAMES {
COPY = 'keyboard_nav_copy',
CUT = 'keyboard_nav_cut',
PASTE = 'keyboard_nav_paste',
DUPLICATE = 'duplicate',
MOVE_WS_CURSOR_UP = 'workspace_up',
MOVE_WS_CURSOR_DOWN = 'workspace_down',
MOVE_WS_CURSOR_LEFT = 'workspace_left',
Expand Down Expand Up @@ -89,6 +90,7 @@ SHORTCUT_CATEGORIES[Msg['SHORTCUTS_EDITING']] = [
'cut',
'copy',
'paste',
SHORTCUT_NAMES.DUPLICATE,
'undo',
'redo',
];
Expand Down
5 changes: 5 additions & 0 deletions src/navigation_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {ActionMenu} from './actions/action_menu';
import {MoveActions} from './actions/move';
import {Mover} from './actions/mover';
import {UndoRedoAction} from './actions/undo_redo';
import {DuplicateAction} from './actions/duplicate';

const KeyCodes = BlocklyUtils.KeyCodes;

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

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

duplicateAction = new DuplicateAction();

workspaceMovement: WorkspaceMovement = new WorkspaceMovement(this.navigation);

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

this.clipboard.install();
this.duplicateAction.install();
this.moveActions.install();
this.shortcutDialog.install();

Expand All @@ -266,6 +270,7 @@ export class NavigationController {
this.editAction.uninstall();
this.disconnectAction.uninstall();
this.clipboard.uninstall();
this.duplicateAction.uninstall();
this.workspaceMovement.uninstall();
this.arrowNavigation.uninstall();
this.exitAction.uninstall();
Expand Down
2 changes: 1 addition & 1 deletion test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ function createWorkspace(): Blockly.WorkspaceSvg {
}
const workspace = Blockly.inject(blocklyDiv, injectOptions);

new KeyboardNavigation(workspace);
Blockly.ContextMenuItems.registerCommentOptions();
new KeyboardNavigation(workspace);
registerRunCodeShortcut();

// Disable blocks that aren't inside the setup or draw loops.
Expand Down
1 change: 1 addition & 0 deletions test/webdriverio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function createWorkspace(): Blockly.WorkspaceSvg {
throw new Error('Missing blocklyDiv');
}
const workspace = Blockly.inject(blocklyDiv, injectOptions);
Blockly.ContextMenuItems.registerCommentOptions();

new KeyboardNavigation(workspace);

Expand Down
4 changes: 2 additions & 2 deletions test/webdriverio/test/actions_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ suite('Menus test', function () {
await this.browser.keys([Key.Ctrl, Key.Return]);
await this.browser.pause(PAUSE_TIME);
chai.assert.isTrue(
await contextMenuExists(this.browser, 'Duplicate'),
await contextMenuExists(this.browser, 'Collapse Block'),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Adding a shortcut to duplicate meant that these didn't match anymore. Simplest to switch to something else without a shortcut.

'The menu should be openable on a block',
);
});
Expand Down Expand Up @@ -66,7 +66,7 @@ suite('Menus test', function () {
await this.browser.keys([Key.Ctrl, Key.Return]);
await this.browser.pause(PAUSE_TIME);
chai.assert.isTrue(
await contextMenuExists(this.browser, 'Duplicate', true),
await contextMenuExists(this.browser, 'Collapse Block', true),
'The menu should not be openable during a move',
);
});
Expand Down
84 changes: 84 additions & 0 deletions test/webdriverio/test/duplicate_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as Blockly from 'blockly';
import * as chai from 'chai';
import {
focusOnBlock,
getCurrentFocusedBlockId,
getFocusedBlockType,
PAUSE_TIME,
tabNavigateToWorkspace,
testFileLocations,
testSetup,
} from './test_setup.js';

suite('Duplicate test', function () {
// Setting timeout to unlimited as these tests take longer time to run
this.timeout(0);

// Clear the workspace and load start blocks
setup(async function () {
this.browser = await testSetup(testFileLocations.BASE);
await this.browser.pause(PAUSE_TIME);
});

test('Duplicate block', async function () {
// Navigate to draw_circle_1.
await tabNavigateToWorkspace(this.browser);
await focusOnBlock(this.browser, 'draw_circle_1');

// Duplicate
await this.browser.keys('d');
await this.browser.pause(PAUSE_TIME);

// Check a different block of the same type has focus.
chai.assert.notEqual(
'draw_circle_1',
await getCurrentFocusedBlockId(this.browser),
);
chai.assert.equal('simple_circle', await getFocusedBlockType(this.browser));
});

test('Duplicate workspace comment', async function () {
await tabNavigateToWorkspace(this.browser);
const text = 'A comment with text';

// Create a single comment.
await this.browser.execute((text: string) => {
const workspace = Blockly.getMainWorkspace();
Blockly.serialization.workspaceComments.append(
{
text,
x: 200,
y: 200,
},
workspace,
);
Blockly.getFocusManager().focusNode(
(workspace as Blockly.WorkspaceSvg).getTopComments()[0],
);
}, text);
await this.browser.pause(PAUSE_TIME);

// Duplicate.
await this.browser.keys('d');

// Assert we have two comments with the same text.
const commentTexts = await this.browser.execute(() =>
Blockly.getMainWorkspace()
.getTopComments(true)
.map((comment) => comment.getText()),
);
chai.assert.deepEqual(commentTexts, [text, text]);
// Assert it's the duplicate that is focused (positioned below).
const [comment1, comment2] = await this.browser.$$('.blocklyComment');
chai.assert.isTrue(await comment2.isFocused());
chai.assert.isTrue(
(await comment2.getLocation()).y > (await comment1.getLocation()).y,
);
});
});