From 39c4b589eac103ffe1a7184c287a90e624980a31 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 18 Feb 2026 09:24:17 -0800 Subject: [PATCH 1/5] release: Bump version number to 12.4.0 (#9594) --- package-lock.json | 2 +- packages/blockly/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddf56bfba4d..f802a7e354f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1471,7 +1471,7 @@ } }, "packages/blockly": { - "version": "12.3.1", + "version": "12.4.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/packages/blockly/package.json b/packages/blockly/package.json index 450fd302a71..6955cb4529f 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -1,6 +1,6 @@ { "name": "blockly", - "version": "12.3.1", + "version": "12.4.0", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" From a5bdaf8b285bf30c2db36cc1151cdf5ac1a4dd21 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 18 Feb 2026 09:39:21 -0800 Subject: [PATCH 2/5] release: Bump version to 12.4.1 (#9595) --- package-lock.json | 2 +- packages/blockly/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index f802a7e354f..1eb2975365b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1471,7 +1471,7 @@ } }, "packages/blockly": { - "version": "12.4.0", + "version": "12.4.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/packages/blockly/package.json b/packages/blockly/package.json index 6955cb4529f..ad4a58688c6 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -1,6 +1,6 @@ { "name": "blockly", - "version": "12.4.0", + "version": "12.4.1", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" From 3b14950a36ed5cb452c72d53d8bb0e71605aacd9 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 25 Feb 2026 08:51:43 -0800 Subject: [PATCH 3/5] feat: Add dynamic keycode for primary modifier key (#9571) * feat: Add dynamic keycode for primary modifier key * fix: Don't duplicate constants. --- packages/blockly/core/shortcut_items.ts | 37 +- packages/blockly/core/utils/keycodes.ts | 8 + .../tests/mocha/shortcut_items_test.js | 365 +++++------------- 3 files changed, 123 insertions(+), 287 deletions(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index f8c95500770..ea3a3db9718 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -134,10 +134,7 @@ function isCuttable(focused: IFocusableNode): boolean { */ export function registerCopy() { const ctrlC = ShortcutRegistry.registry.createSerializedKey(KeyCodes.C, [ - KeyCodes.CTRL, - ]); - const metaC = ShortcutRegistry.registry.createSerializedKey(KeyCodes.C, [ - KeyCodes.META, + KeyCodes.CTRL_CMD, ]); const copyShortcut: KeyboardShortcut = { @@ -179,7 +176,7 @@ export function registerCopy() { : undefined; return !!clipboard.copy(focused, copyCoords); }, - keyCodes: [ctrlC, metaC], + keyCodes: [ctrlC], }; ShortcutRegistry.registry.register(copyShortcut); } @@ -189,10 +186,7 @@ export function registerCopy() { */ export function registerCut() { const ctrlX = ShortcutRegistry.registry.createSerializedKey(KeyCodes.X, [ - KeyCodes.CTRL, - ]); - const metaX = ShortcutRegistry.registry.createSerializedKey(KeyCodes.X, [ - KeyCodes.META, + KeyCodes.CTRL_CMD, ]); const cutShortcut: KeyboardShortcut = { @@ -224,7 +218,7 @@ export function registerCut() { } return !!copyData; }, - keyCodes: [ctrlX, metaX], + keyCodes: [ctrlX], }; ShortcutRegistry.registry.register(cutShortcut); @@ -235,10 +229,7 @@ export function registerCut() { */ export function registerPaste() { const ctrlV = ShortcutRegistry.registry.createSerializedKey(KeyCodes.V, [ - KeyCodes.CTRL, - ]); - const metaV = ShortcutRegistry.registry.createSerializedKey(KeyCodes.V, [ - KeyCodes.META, + KeyCodes.CTRL_CMD, ]); const pasteShortcut: KeyboardShortcut = { @@ -309,7 +300,7 @@ export function registerPaste() { const centerCoords = new Coordinate(left + width / 2, top + height / 2); return !!clipboard.paste(copyData, targetWorkspace, centerCoords); }, - keyCodes: [ctrlV, metaV], + keyCodes: [ctrlV], }; ShortcutRegistry.registry.register(pasteShortcut); @@ -320,10 +311,7 @@ export function registerPaste() { */ export function registerUndo() { const ctrlZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.CTRL, - ]); - const metaZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.META, + KeyCodes.CTRL_CMD, ]); const undoShortcut: KeyboardShortcut = { @@ -342,7 +330,7 @@ export function registerUndo() { e.preventDefault(); return true; }, - keyCodes: [ctrlZ, metaZ], + keyCodes: [ctrlZ], }; ShortcutRegistry.registry.register(undoShortcut); } @@ -353,13 +341,10 @@ export function registerUndo() { */ export function registerRedo() { const ctrlShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.CTRL, - KeyCodes.SHIFT, - ]); - const metaShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.META, + KeyCodes.CTRL_CMD, KeyCodes.SHIFT, ]); + // Ctrl-y is redo in Windows. Command-y is never valid on Macs. const ctrlY = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Y, [ KeyCodes.CTRL, @@ -381,7 +366,7 @@ export function registerRedo() { e.preventDefault(); return true; }, - keyCodes: [ctrlShiftZ, metaShiftZ, ctrlY], + keyCodes: [ctrlShiftZ, ctrlY], }; ShortcutRegistry.registry.register(redoShortcut); } diff --git a/packages/blockly/core/utils/keycodes.ts b/packages/blockly/core/utils/keycodes.ts index 1abe5dc8f19..d75eee16655 100644 --- a/packages/blockly/core/utils/keycodes.ts +++ b/packages/blockly/core/utils/keycodes.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as userAgent from '../utils/useragent.js'; + // Former goog.module ID: Blockly.utils.KeyCodes /* eslint-disable @typescript-eslint/no-duplicate-enum-values */ @@ -151,4 +153,10 @@ export enum KeyCodes { // indicates a hardware/bios problem. // http://en.community.dell.com/support-forums/laptop/f/3518/p/19285957/19523128.aspx PHANTOM = 255, + + // The primary modifier key on the current platform, i.e. Command on Apple + // platforms and Control elsewhere. + CTRL_CMD = userAgent.MAC || userAgent.IPHONE || userAgent.IPAD + ? MAC_WK_CMD_LEFT + : CTRL, } diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index dfbae3f0901..57f650843a8 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -158,87 +158,42 @@ suite('Keyboard Shortcut Items', function () { 'hideChaff', ); }); - const testCases = [ - [ - 'Control C', - createKeyDownEvent(Blockly.utils.KeyCodes.C, [ - Blockly.utils.KeyCodes.CTRL, - ]), - ], - [ - 'Meta C', - createKeyDownEvent(Blockly.utils.KeyCodes.C, [ - Blockly.utils.KeyCodes.META, - ]), - ], - ]; + const keyEvent = createKeyDownEvent(Blockly.utils.KeyCodes.C, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]); // Copy a block. - suite('Simple', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.calledOnce(this.copySpy); - sinon.assert.calledOnce(this.hideChaffSpy); - }); - }); + test('Simple', function () { + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.hideChaffSpy); }); // Allow copying a block if a workspace is in readonly mode. - suite('Called when readOnly is true', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - this.workspace.setIsReadOnly(true); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.calledOnce(this.copySpy); - sinon.assert.calledOnce(this.hideChaffSpy); - }); - }); + test('Called when readOnly is true', function () { + this.workspace.setIsReadOnly(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.hideChaffSpy); }); // Do not copy a block if a drag is in progress. - suite('Drag in progress', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - sinon.stub(this.workspace, 'isDragging').returns(true); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.notCalled(this.copySpy); - sinon.assert.notCalled(this.hideChaffSpy); - }); - }); + test('Drag in progress', function () { + sinon.stub(this.workspace, 'isDragging').returns(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.hideChaffSpy); }); // Do not copy a block if is is not deletable. - suite('Block is not deletable', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - sinon - .stub(Blockly.common.getSelected(), 'isOwnDeletable') - .returns(false); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.notCalled(this.copySpy); - sinon.assert.notCalled(this.hideChaffSpy); - }); - }); + test('Block is not deletable', function () { + sinon.stub(Blockly.common.getSelected(), 'isOwnDeletable').returns(false); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.hideChaffSpy); }); // Do not copy a block if it is not movable. - suite('Block is not movable', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - sinon - .stub(Blockly.common.getSelected(), 'isOwnMovable') - .returns(false); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.notCalled(this.copySpy); - sinon.assert.notCalled(this.hideChaffSpy); - }); - }); + test('Block is not movable', function () { + sinon.stub(Blockly.common.getSelected(), 'isOwnMovable').returns(false); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.hideChaffSpy); }); test('Not called when connection is focused', function () { // Restore the stub behavior called during setup @@ -254,19 +209,13 @@ suite('Keyboard Shortcut Items', function () { }); // Copy a comment. test('Workspace comment', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - Blockly.getFocusManager().getFocusedNode.restore(); - this.comment = setSelectedComment(this.workspace); - this.copySpy = sinon.spy(this.comment, 'toCopyData'); + Blockly.getFocusManager().getFocusedNode.restore(); + this.comment = setSelectedComment(this.workspace); + this.copySpy = sinon.spy(this.comment, 'toCopyData'); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.calledOnce(this.copySpy); - sinon.assert.calledOnce(this.hideChaffSpy); - }); - }); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.hideChaffSpy); }); }); @@ -280,92 +229,47 @@ suite('Keyboard Shortcut Items', function () { 'hideChaff', ); }); - const testCases = [ - [ - 'Control X', - createKeyDownEvent(Blockly.utils.KeyCodes.X, [ - Blockly.utils.KeyCodes.CTRL, - ]), - ], - [ - 'Meta X', - createKeyDownEvent(Blockly.utils.KeyCodes.X, [ - Blockly.utils.KeyCodes.META, - ]), - ], - ]; + const keyEvent = createKeyDownEvent(Blockly.utils.KeyCodes.X, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]); // Cut a block. - suite('Simple', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.calledOnce(this.copySpy); - sinon.assert.calledOnce(this.disposeSpy); - sinon.assert.calledOnce(this.hideChaffSpy); - }); - }); + test('Simple', function () { + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.disposeSpy); + sinon.assert.calledOnce(this.hideChaffSpy); }); // Do not cut a block if a workspace is in readonly mode. - suite('Not called when readOnly is true', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - this.workspace.setIsReadOnly(true); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.notCalled(this.copySpy); - sinon.assert.notCalled(this.disposeSpy); - sinon.assert.notCalled(this.hideChaffSpy); - }); - }); + test('Not called when readOnly is true', function () { + this.workspace.setIsReadOnly(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); }); // Do not cut a block if a drag is in progress. - suite('Drag in progress', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - sinon.stub(this.workspace, 'isDragging').returns(true); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.notCalled(this.copySpy); - sinon.assert.notCalled(this.disposeSpy); - sinon.assert.notCalled(this.hideChaffSpy); - }); - }); + test('Drag in progress', function () { + sinon.stub(this.workspace, 'isDragging').returns(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); }); // Do not cut a block if is is not deletable. - suite('Block is not deletable', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - sinon - .stub(Blockly.common.getSelected(), 'isOwnDeletable') - .returns(false); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.notCalled(this.copySpy); - sinon.assert.notCalled(this.disposeSpy); - sinon.assert.notCalled(this.hideChaffSpy); - }); - }); + test('Block is not deletable', function () { + sinon.stub(Blockly.common.getSelected(), 'isOwnDeletable').returns(false); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); }); // Do not cut a block if it is not movable. - suite('Block is not movable', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - sinon - .stub(Blockly.common.getSelected(), 'isOwnMovable') - .returns(false); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.notCalled(this.copySpy); - sinon.assert.notCalled(this.disposeSpy); - sinon.assert.notCalled(this.hideChaffSpy); - }); - }); + test('Block is not movable', function () { + sinon.stub(Blockly.common.getSelected(), 'isOwnMovable').returns(false); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); }); test('Not called when connection is focused', function () { // Restore the stub behavior called during setup @@ -382,21 +286,15 @@ suite('Keyboard Shortcut Items', function () { }); // Cut a comment. - suite('Workspace comment', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - Blockly.getFocusManager().getFocusedNode.restore(); - this.comment = setSelectedComment(this.workspace); - this.copySpy = sinon.spy(this.comment, 'toCopyData'); - this.disposeSpy = sinon.spy(this.comment, 'dispose'); + test('Workspace comment', function () { + Blockly.getFocusManager().getFocusedNode.restore(); + this.comment = setSelectedComment(this.workspace); + this.copySpy = sinon.spy(this.comment, 'toCopyData'); + this.disposeSpy = sinon.spy(this.comment, 'dispose'); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.calledOnce(this.copySpy); - sinon.assert.calledOnce(this.disposeSpy); - }); - }); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.disposeSpy); }); }); @@ -421,53 +319,26 @@ suite('Keyboard Shortcut Items', function () { 'hideChaff', ); }); - const testCases = [ - [ - 'Control Z', - createKeyDownEvent(Blockly.utils.KeyCodes.Z, [ - Blockly.utils.KeyCodes.CTRL, - ]), - ], - [ - 'Meta Z', - createKeyDownEvent(Blockly.utils.KeyCodes.Z, [ - Blockly.utils.KeyCodes.META, - ]), - ], - ]; + const keyEvent = createKeyDownEvent(Blockly.utils.KeyCodes.Z, [ + Blockly.utils.KeyCodes.CTRL_CMD, + ]); // Undo. - suite('Simple', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.calledOnce(this.undoSpy); - sinon.assert.calledWith(this.undoSpy, false); - sinon.assert.calledOnce(this.hideChaffSpy); - }); - }); + test('Simple', function () { + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.undoSpy); + sinon.assert.calledWith(this.undoSpy, false); + sinon.assert.calledOnce(this.hideChaffSpy); }); // Do not undo if a drag is in progress. - suite('Drag in progress', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - sinon.stub(this.workspace, 'isDragging').returns(true); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.notCalled(this.undoSpy); - sinon.assert.notCalled(this.hideChaffSpy); - }); - }); + test('Drag in progress', function () { + sinon.stub(this.workspace, 'isDragging').returns(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.undoSpy); + sinon.assert.notCalled(this.hideChaffSpy); }); // Do not undo if the workspace is in readOnly mode. - suite('Not called when readOnly is true', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - runReadOnlyTest(keyEvent, testCaseName); - }); + test('Not called when readOnly is true', function () { + runReadOnlyTest(keyEvent); }); }); @@ -479,55 +350,27 @@ suite('Keyboard Shortcut Items', function () { 'hideChaff', ); }); - const testCases = [ - [ - 'Control Shift Z', - createKeyDownEvent(Blockly.utils.KeyCodes.Z, [ - Blockly.utils.KeyCodes.CTRL, - Blockly.utils.KeyCodes.SHIFT, - ]), - ], - [ - 'Meta Shift Z', - createKeyDownEvent(Blockly.utils.KeyCodes.Z, [ - Blockly.utils.KeyCodes.META, - Blockly.utils.KeyCodes.SHIFT, - ]), - ], - ]; + const keyEvent = createKeyDownEvent(Blockly.utils.KeyCodes.Z, [ + Blockly.utils.KeyCodes.CTRL_CMD, + Blockly.utils.KeyCodes.SHIFT, + ]); // Undo. - suite('Simple', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.calledOnce(this.redoSpy); - sinon.assert.calledWith(this.redoSpy, true); - sinon.assert.calledOnce(this.hideChaffSpy); - }); - }); + test('Simple', function () { + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.redoSpy); + sinon.assert.calledWith(this.redoSpy, true); + sinon.assert.calledOnce(this.hideChaffSpy); }); // Do not redo if a drag is in progress. - suite('Drag in progress', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - test(testCaseName, function () { - sinon.stub(this.workspace, 'isDragging').returns(true); - this.injectionDiv.dispatchEvent(keyEvent); - sinon.assert.notCalled(this.redoSpy); - sinon.assert.notCalled(this.hideChaffSpy); - }); - }); + test('Drag in progress', function () { + sinon.stub(this.workspace, 'isDragging').returns(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.redoSpy); + sinon.assert.notCalled(this.hideChaffSpy); }); // Do not undo if the workspace is in readOnly mode. - suite('Not called when readOnly is true', function () { - testCases.forEach(function (testCase) { - const testCaseName = testCase[0]; - const keyEvent = testCase[1]; - runReadOnlyTest(keyEvent, testCaseName); - }); + test('Not called when readOnly is true', function () { + runReadOnlyTest(keyEvent); }); }); From 3d78491dd9f03e83c6e4d8ebac4c0de45d1768d6 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 26 Feb 2026 10:45:35 -0800 Subject: [PATCH 4/5] fix: Focus nearest neighbor when deleting a focused block (#9599) * fix: Focus the nearest neighbor on block deletion * test: Add tests * fix: Use `strictEqual` * chore: Reduce the number of test blocks * fix: Explicitly verify that dying blocks are not focused * fix: Fix exception when disposing of a workspace with a focused block * chore: Run formatter --- packages/blockly/core/block_svg.ts | 36 ++++++- packages/blockly/tests/mocha/block_test.js | 115 +++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 54bef4948bf..b5935297b8b 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -863,6 +863,32 @@ export class BlockSvg return this.svgGroup; } + /** + * Returns the closest live block to this one, if any. + */ + private getNearestNeighbour() { + if (!this.workspace.rendered) return null; + + const blocks = this.workspace + .getAllBlocks(false) + .filter((block) => !block.isDeadOrDying()); + let nearestNeighbour = null; + let closestDistance = Number.MAX_SAFE_INTEGER; + const self = this.getRelativeToSurfaceXY(); + for (const block of blocks) { + const other = block.getRelativeToSurfaceXY(); + const distance = Math.sqrt( + Math.pow(other.x - self.x, 2) + Math.pow(other.y - self.y, 2), + ); + if (distance < closestDistance) { + nearestNeighbour = block; + closestDistance = distance; + } + } + + return nearestNeighbour; + } + /** * Dispose of this block. * @@ -904,7 +930,15 @@ export class BlockSvg if (parent) { focusManager.focusNode(parent); } else { - setTimeout(() => focusManager.focusTree(this.workspace), 0); + const nearestNeighbour = this.getNearestNeighbour(); + if (nearestNeighbour) { + focusManager.focusNode(nearestNeighbour); + } else { + setTimeout(() => { + if (!this.workspace.rendered) return; + focusManager.focusTree(this.workspace); + }, 0); + } } } diff --git a/packages/blockly/tests/mocha/block_test.js b/packages/blockly/tests/mocha/block_test.js index ed41a728c30..f3d069a5aa7 100644 --- a/packages/blockly/tests/mocha/block_test.js +++ b/packages/blockly/tests/mocha/block_test.js @@ -2946,4 +2946,119 @@ suite('Blocks', function () { ); }); }); + + suite('Disposal focus management', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv'); + const firstBlock = this.workspace.newBlock('stack_block'); + firstBlock.moveBy(-500, -500); + }); + + test('Deleting the sole block on the workspace focuses the workspace', function () { + const block = this.workspace.getTopBlocks(false)[0]; + Blockly.getFocusManager().focusNode(block); + block.dispose(); + this.clock.runAll(); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.workspace, + 'Focus should move to the workspace when the focused block is deleted', + ); + }); + + test('Deleting a block with several adjacent blocks focuses the closest one', function () { + this.workspace.newBlock('stack_block'); + const blockMiddle = this.workspace.newBlock('stack_block'); + const blockRight = this.workspace.newBlock('stack_block'); + blockMiddle.moveBy(60, 0); + blockRight.moveBy(100, 0); + + Blockly.getFocusManager().focusNode(blockMiddle); + blockMiddle.dispose(); + this.clock.runAll(); + + const focused = Blockly.getFocusManager().getFocusedNode(); + assert.strictEqual( + focused, + blockRight, + 'Focus should move to the closest remaining block (blockRight at (100, 0))', + ); + }); + + test('Bulk deleting blocks does not focus another dying block', function () { + const blocks = this.workspace.getTopBlocks(false); + for (let i = 0; i < 5; i++) { + blocks.push(this.workspace.newBlock('stack_block')); + } + + // Focus the last block we added; clearing the workspace proceeds in block + // creation order, so if we focused an earlier block, it would (correctly) + // assign focus to a later-added block which is not yet dying, on down the + // chain. If we focus the last block, by the time deletion gets to it, all + // the other blocks will have already been marked as disposing, and should + // thus be ineligible to be focused. + Blockly.getFocusManager().focusNode( + this.workspace.getTopBlocks(false)[5], + ); + + const spy = sinon.spy(Blockly.getFocusManager(), 'focusNode'); + + this.workspace.clear(); + this.clock.runAll(); + + for (const block of blocks) { + assert.isFalse(spy.calledWith(block)); + } + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.workspace, + 'Focus should move to the workspace, not a dying peer block', + ); + + spy.restore(); + }); + + test('Deleting a block focuses its parent block', function () { + const parent = this.workspace.newBlock('stack_block'); + const child = this.workspace.newBlock('stack_block'); + parent.nextConnection.connect(child.previousConnection); + + Blockly.getFocusManager().focusNode(child); + child.dispose(); + this.clock.runAll(); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + parent, + 'Focus should move to the parent block when a connected child is deleted', + ); + }); + + test('Deleting an unfocused block does not change focus', function () { + const a = this.workspace.getTopBlocks(false)[0]; + const b = this.workspace.newBlock('stack_block'); + this.workspace.newBlock('stack_block'); + + Blockly.getFocusManager().focusNode(a); + b.dispose(); + this.clock.runAll(); + + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + a, + 'Focus should not change when an unfocused block is deleted', + ); + }); + + test('Disposing a workspace with a focused block succeeds', function () { + Blockly.getFocusManager().focusNode( + this.workspace.getTopBlocks(false)[0], + ); + this.workspace.dispose(); + this.clock.runAll(); + + // No assert, this just shouldn't throw. + }); + }); }); From a16580ae26bb4244a770095a446811dc1b08ab88 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 26 Feb 2026 13:39:56 -0800 Subject: [PATCH 5/5] feat: Add a keyboard shortcut for displaying the contextual menu (#9602) * feat: Add support for getting the contextual menu * feat: Add a keyboard shortcut for opening the contextual menu * test: Add tests for `ContextMenu.getMenu()`. * test: Add tests for context menu keyboard shortcut * fix: Fix tests when not run on their own * chore: Add type annotation --- packages/blockly/core/contextmenu.ts | 9 ++ packages/blockly/core/shortcut_items.ts | 33 ++++++++ .../blockly/tests/mocha/contextmenu_test.js | 21 +++++ .../tests/mocha/shortcut_items_test.js | 83 +++++++++++++++++++ 4 files changed, 146 insertions(+) diff --git a/packages/blockly/core/contextmenu.ts b/packages/blockly/core/contextmenu.ts index f3ebbd1c681..0ddc0843a00 100644 --- a/packages/blockly/core/contextmenu.ts +++ b/packages/blockly/core/contextmenu.ts @@ -238,6 +238,8 @@ function haltPropagation(e: Event) { export function hide() { WidgetDiv.hideIfOwner(dummyOwner); currentBlock = null; + menu_?.dispose(); + menu_ = null; } /** @@ -293,3 +295,10 @@ export function callbackFactory( return newBlock; }; } + +/** + * Returns the contextual menu if it is currently being shown. + */ +export function getMenu(): Menu | null { + return menu_; +} diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index ea3a3db9718..caa8ea84c9d 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -9,8 +9,10 @@ import {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; import {RenderedWorkspaceComment} from './comments.js'; +import * as contextmenu from './contextmenu.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; +import {hasContextMenu} from './interfaces/i_contextmenu.js'; import {isCopyable as isICopyable} from './interfaces/i_copyable.js'; import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; import {isDraggable} from './interfaces/i_draggable.js'; @@ -33,6 +35,7 @@ export enum names { PASTE = 'paste', UNDO = 'undo', REDO = 'redo', + MENU = 'menu', } /** @@ -371,6 +374,35 @@ export function registerRedo() { ShortcutRegistry.registry.register(redoShortcut); } +/** + * Keyboard shortcut to show the context menu on ctrl/cmd+Enter. + */ +export function registerShowContextMenu() { + const ctrlEnter = ShortcutRegistry.registry.createSerializedKey( + KeyCodes.ENTER, + [KeyCodes.CTRL_CMD], + ); + + const contextMenuShortcut: KeyboardShortcut = { + name: names.MENU, + preconditionFn: (workspace) => { + return !workspace.isDragging(); + }, + callback: (workspace, e) => { + const target = getFocusManager().getFocusedNode(); + if (hasContextMenu(target)) { + target.showContextMenu(e); + contextmenu.getMenu()?.highlightNext(); + + return true; + } + return false; + }, + keyCodes: [ctrlEnter], + }; + ShortcutRegistry.registry.register(contextMenuShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -385,6 +417,7 @@ export function registerDefaultShortcuts() { registerPaste(); registerUndo(); registerRedo(); + registerShowContextMenu(); } registerDefaultShortcuts(); diff --git a/packages/blockly/tests/mocha/contextmenu_test.js b/packages/blockly/tests/mocha/contextmenu_test.js index df5bf79dc35..8e8c40e78a2 100644 --- a/packages/blockly/tests/mocha/contextmenu_test.js +++ b/packages/blockly/tests/mocha/contextmenu_test.js @@ -70,4 +70,25 @@ suite('Context Menu', function () { ); }); }); + + suite('getMenu', function () { + test('returns null when context menu is not shown', function () { + assert.isNull(Blockly.ContextMenu.getMenu()); + }); + + test('returns Menu instance when context menu is shown', function () { + const e = new PointerEvent('pointerdown', {clientX: 10, clientY: 10}); + const menuOptions = [ + {text: 'Test option', enabled: true, callback: function () {}}, + ]; + Blockly.ContextMenu.show(e, menuOptions, false, this.workspace); + + const menu = Blockly.ContextMenu.getMenu(); + assert.instanceOf(menu, Blockly.Menu, 'getMenu() should return a Menu'); + assert.include(menu.getElement().innerText, 'Test option'); + + Blockly.ContextMenu.hide(); + assert.isNull(Blockly.ContextMenu.getMenu()); + }); + }); }); diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 57f650843a8..99c7bd4d0f9 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -18,6 +18,8 @@ suite('Keyboard Shortcut Items', function () { sharedTestSetup.call(this); this.workspace = Blockly.inject('blocklyDiv', {}); this.injectionDiv = this.workspace.getInjectionDiv(); + Blockly.ContextMenuRegistry.registry.reset(); + Blockly.ContextMenuItems.registerDefaultOptions(); }); teardown(function () { sharedTestTeardown.call(this); @@ -403,4 +405,85 @@ suite('Keyboard Shortcut Items', function () { ]), ); }); + + suite('Show context menu (Ctrl/Cmd+Enter)', function () { + const contextMenuKeyEvent = createKeyDownEvent( + Blockly.utils.KeyCodes.ENTER, + [Blockly.utils.KeyCodes.CTRL_CMD], + ); + + test('Displays context menu on a block using the keyboard shortcut', function () { + const block = setSelectedBlock(this.workspace); + this.injectionDiv.dispatchEvent(contextMenuKeyEvent); + + const menu = Blockly.ContextMenu.getMenu(); + assert.instanceOf(menu, Blockly.Menu, 'Context menu should be shown'); + + const menuOptions = + Blockly.ContextMenuRegistry.registry.getContextMenuOptions( + {block, focusedNode: block}, + contextMenuKeyEvent, + ); + for (const option of menuOptions) { + assert.include(menu.getElement().innerText, option.text); + } + }); + + test('Displays context menu on the workspace using the keyboard shortcut', function () { + Blockly.getFocusManager().focusNode(this.workspace); + this.injectionDiv.dispatchEvent(contextMenuKeyEvent); + + const menu = Blockly.ContextMenu.getMenu(); + assert.instanceOf(menu, Blockly.Menu, 'Context menu should be shown'); + const menuOptions = + Blockly.ContextMenuRegistry.registry.getContextMenuOptions( + {workspace: this.workspace, focusedNode: this.workspace}, + contextMenuKeyEvent, + ); + for (const option of menuOptions) { + assert.include(menu.getElement().innerText, option.text); + } + }); + + test('Displays context menu on a workspace comment using the keyboard shortcut', function () { + Blockly.ContextMenuItems.registerCommentOptions(); + const comment = setSelectedComment(this.workspace); + this.injectionDiv.dispatchEvent(contextMenuKeyEvent); + + const menu = Blockly.ContextMenu.getMenu(); + assert.instanceOf(menu, Blockly.Menu, 'Context menu should be shown'); + const menuOptions = + Blockly.ContextMenuRegistry.registry.getContextMenuOptions( + {comment, focusedNode: comment}, + contextMenuKeyEvent, + ); + for (const option of menuOptions) { + assert.include(menu.getElement().innerText, option.text); + } + }); + + test('First menu item is highlighted when context menu is shown via keyboard shortcut', function () { + setSelectedBlock(this.workspace); + this.injectionDiv.dispatchEvent(contextMenuKeyEvent); + + const menuEl = Blockly.ContextMenu.getMenu().getElement(); + const firstMenuItem = menuEl.querySelector('.blocklyMenuItem'); + assert.isTrue( + firstMenuItem.classList.contains('blocklyMenuItemHighlight'), + ); + }); + + test('Context menu is not shown when shortcut is invoked while a field is focused', function () { + const block = this.workspace.newBlock('math_arithmetic'); + block.initSvg(); + const field = block.getField('OP'); + Blockly.getFocusManager().focusNode(field); + this.injectionDiv.dispatchEvent(contextMenuKeyEvent); + + assert.isNull( + Blockly.ContextMenu.getMenu(), + 'Context menu should not be triggered when a field is focused', + ); + }); + }); });