From eb6978aabe065d1d6197ad5c096191a152419238 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:28:21 -0400 Subject: [PATCH 1/4] feat: ctrl/cmd+v pastes near focused block --- .../blockly/core/clipboard/block_paster.ts | 2 +- .../core/keyboard_nav/navigators/navigator.ts | 4 + packages/blockly/core/shortcut_items.ts | 31 ++++++++ .../tests/mocha/shortcut_items_test.js | 73 +++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/clipboard/block_paster.ts b/packages/blockly/core/clipboard/block_paster.ts index 3ee14f7e7d9..dade36479c0 100644 --- a/packages/blockly/core/clipboard/block_paster.ts +++ b/packages/blockly/core/clipboard/block_paster.ts @@ -37,7 +37,7 @@ export class BlockPaster implements IPaster { // However, the algorithm for deciding where to paste a block depends on // the starting position of the copied block, so we'll pass those coordinates along const initialCoordinates = - coordinate || + coordinate ?? new Coordinate( copyData.blockState['x'] || 0, copyData.blockState['y'] || 0, diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index e7c203af9e8..785f5e46075 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -5,6 +5,7 @@ */ import {BlockSvg} from '../../block_svg.js'; +import {CommentEditor} from '../../comments/comment_editor.js'; import {Field} from '../../field.js'; import {getFocusManager} from '../../focus_manager.js'; import {Icon} from '../../icons/icon.js'; @@ -499,6 +500,9 @@ export class Navigator { return node.getSourceBlock(); } else if (node instanceof Icon) { return node.getSourceBlock() as BlockSvg; + } else if (node instanceof CommentEditor) { + const parent = node.getParent(); + return parent instanceof BlockSvg ? parent : null; } return null; diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 3e114833289..23af8867884 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -296,6 +296,27 @@ export function registerCut() { ShortcutRegistry.registry.register(cutShortcut); } +/** + * Returns workspace coordinates to use as the paste origin when focus is on a + * block or one of its children (fields, connections, icons, block comments). + * + * @param focusedNode The node that currently has focus. + * @param targetWorkspace The workspace where the paste will occur. + * @returns The parent block's location in workspace coordinates, or undefined + * if focus is not on a block or one of its children. + */ +function getPasteOriginFromFocus( + focusedNode: IFocusableNode | null, + targetWorkspace: WorkspaceSvg, +): Coordinate | undefined { + const block = targetWorkspace + .getNavigator() + .getSourceBlockFromNode(focusedNode); + if (!block) return undefined; + + return block.getRelativeToSurfaceXY(); +} + /** * Keyboard shortcut to paste a block on ctrl+v, cmd+v, or alt+v. */ @@ -330,6 +351,7 @@ export function registerPaste() { }, callback(workspace: WorkspaceSvg, e: Event) { const copyData = clipboard.getLastCopiedData(); + const focusedNode = getFocusManager().getFocusedNode(); if (!copyData) return false; const copyWorkspace = clipboard.getLastCopiedWorkspace(); @@ -355,6 +377,15 @@ export function registerPaste() { return !!clipboard.paste(copyData, targetWorkspace, mouseCoords); } + const pasteOrigin = getPasteOriginFromFocus( + focusedNode, + targetWorkspace, + ); + if (pasteOrigin) { + return !!clipboard.paste(copyData, targetWorkspace, pasteOrigin); + } + + // No spatial focus target (e.g. workspace root) — use copy-location behavior. const copyCoords = clipboard.getLastCopiedLocation(); if (!copyCoords) { // If we don't have location data about the original copyable, let the diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 9615aacf132..f22f9f58e8d 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -429,6 +429,79 @@ suite('Keyboard Shortcut Items', function () { sinon.assert.calledWith(toastSpy, this.workspace, 'copiedHint'); toastSpy.restore(); }); + + test('Pastes near focused block instead of copy origin', function () { + this.workspace.clear(); + const blockA = setSelectedBlock(this.workspace); + + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.C, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]), + ); + + const blockB = Blockly.serialization.blocks.append( + {type: 'stack_block', x: 300, y: 300}, + this.workspace, + ); + Blockly.getFocusManager().focusNode(blockB); + + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.V, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]), + ); + + const pastedBlock = this.workspace + .getAllBlocks(false) + .find((b) => ![blockA, blockB].includes(b)); + assert.isDefined(pastedBlock); + + const pastedXY = pastedBlock.getRelativeToSurfaceXY(); + // Check that the pasted block is closer to blockB than blockA, which means + // it used the focus location instead of the copy origin. + assert.isBelow( + Blockly.utils.Coordinate.distance( + pastedXY, + blockB.getRelativeToSurfaceXY(), + ), + Blockly.utils.Coordinate.distance( + pastedXY, + blockA.getRelativeToSurfaceXY(), + ), + ); + }); + + test('Uses copy origin when workspace has focus', function () { + const blockA = setSelectedBlock(this.workspace); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.C, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]), + ); + + Blockly.getFocusManager().focusNode(this.workspace); + this.injectionDiv.dispatchEvent( + createKeyDownEvent(Blockly.utils.KeyCodes.V, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]), + ); + + const pastedBlock = this.workspace + .getAllBlocks(false) + .find((b) => b.id !== blockA.id); + assert.isDefined(pastedBlock); + + const copyOrigin = blockA.getRelativeToSurfaceXY(); + const pastedXY = pastedBlock.getRelativeToSurfaceXY(); + assert.isBelow( + Blockly.utils.Coordinate.distance(pastedXY, copyOrigin), + Blockly.utils.Coordinate.distance( + pastedXY, + new Blockly.utils.Coordinate(300, 300), + ), + ); + }); }); suite('Undo', function () { From 72286cbc0a05add72e51aa552bc1bbf959f1cfcc Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:32:12 -0400 Subject: [PATCH 2/4] fix: lint --- packages/blockly/core/shortcut_items.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 23af8867884..0a8285004f3 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -377,10 +377,7 @@ export function registerPaste() { return !!clipboard.paste(copyData, targetWorkspace, mouseCoords); } - const pasteOrigin = getPasteOriginFromFocus( - focusedNode, - targetWorkspace, - ); + const pasteOrigin = getPasteOriginFromFocus(focusedNode, targetWorkspace); if (pasteOrigin) { return !!clipboard.paste(copyData, targetWorkspace, pasteOrigin); } From c6159405dab8ab1988f2fb02216bf81f2b359b18 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:12:12 -0400 Subject: [PATCH 3/4] chore: simplify --- packages/blockly/core/shortcut_items.ts | 27 +++++-------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 0a8285004f3..80081aa6ac0 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -296,27 +296,6 @@ export function registerCut() { ShortcutRegistry.registry.register(cutShortcut); } -/** - * Returns workspace coordinates to use as the paste origin when focus is on a - * block or one of its children (fields, connections, icons, block comments). - * - * @param focusedNode The node that currently has focus. - * @param targetWorkspace The workspace where the paste will occur. - * @returns The parent block's location in workspace coordinates, or undefined - * if focus is not on a block or one of its children. - */ -function getPasteOriginFromFocus( - focusedNode: IFocusableNode | null, - targetWorkspace: WorkspaceSvg, -): Coordinate | undefined { - const block = targetWorkspace - .getNavigator() - .getSourceBlockFromNode(focusedNode); - if (!block) return undefined; - - return block.getRelativeToSurfaceXY(); -} - /** * Keyboard shortcut to paste a block on ctrl+v, cmd+v, or alt+v. */ @@ -377,7 +356,11 @@ export function registerPaste() { return !!clipboard.paste(copyData, targetWorkspace, mouseCoords); } - const pasteOrigin = getPasteOriginFromFocus(focusedNode, targetWorkspace); + // If the focused node is a block, paste relative to that block's position. + const block = targetWorkspace + .getNavigator() + .getSourceBlockFromNode(focusedNode); + const pasteOrigin = block?.getRelativeToSurfaceXY(); if (pasteOrigin) { return !!clipboard.paste(copyData, targetWorkspace, pasteOrigin); } From e89a932ef3346ea88a02150c7947f60a86b462e3 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:07:15 -0400 Subject: [PATCH 4/4] chore: clarify comment --- packages/blockly/core/shortcut_items.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 80081aa6ac0..3be97e82eeb 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -356,7 +356,8 @@ export function registerPaste() { return !!clipboard.paste(copyData, targetWorkspace, mouseCoords); } - // If the focused node is a block, paste relative to that block's position. + // If the focused node is a block, or part of a block (connection, field, etc.), + // paste relative to that block's position. const block = targetWorkspace .getNavigator() .getSourceBlockFromNode(focusedNode);