From 37f36508d77a8ae4dab485a02a2875d24dacd65e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 2 Apr 2026 08:49:58 -0700 Subject: [PATCH 1/4] feat: Add keyboard shortcuts to navigate between stacks --- .../core/keyboard_nav/navigators/navigator.ts | 3 +- packages/blockly/core/shortcut_items.ts | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index 36b72ab7d29..1a66a965bb9 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -456,6 +456,7 @@ export class Navigator { /** * Returns the next/previous stack relative to the given element's stack. * + * @internal * @param current The element whose stack will be navigated relative to. * @param delta The difference in index to navigate; positive values navigate * to the nth next stack, while negative values navigate to the nth @@ -464,7 +465,7 @@ export class Navigator { * current element's stack, or the last element in the stack offset by * `delta` relative to the current element's stack when navigating backwards. */ - protected navigateStacks(current: IFocusableNode, delta: number) { + navigateStacks(current: IFocusableNode, delta: number) { const stacks = this.getTopLevelItems(current); const root = this.getSourceBlockFromNode(current)?.getRootBlock() ?? current; diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index c904d04ee8b..1c485962180 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -17,6 +17,7 @@ import {isCopyable as isICopyable} from './interfaces/i_copyable.js'; import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; import {type IDraggable, isDraggable} from './interfaces/i_draggable.js'; import {type IFocusableNode} from './interfaces/i_focusable_node.js'; +import {isSelectable} from './interfaces/i_selectable.js'; import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js'; import {keyboardNavigationController} from './keyboard_navigation_controller.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; @@ -53,6 +54,8 @@ export enum names { NAVIGATE_UP = 'up', NAVIGATE_DOWN = 'down', DISCONNECT = 'disconnect', + NEXT_STACK = 'next_stack', + PREVIOUS_STACK = 'previous_stack', } /** @@ -716,6 +719,75 @@ export function registerDisconnectBlock() { ShortcutRegistry.registry.register(disconnectShortcut); } +/** + * Registers keyboard shortcuts to jump between stacks/top-level items on the + * workspace. + */ +export function registerStackNavigation() { + /** + * Finds the stack root of the currently focused or specified item. + */ + const resolveStack = ( + workspace: WorkspaceSvg, + node = getFocusManager().getFocusedNode(), + ) => { + const navigator = workspace.getNavigator(); + + for ( + let parent: IFocusableNode | null = node; + parent && parent !== workspace; + parent = navigator.getParent(node) + ) { + node = parent; + } + + if (!isSelectable(node)) return null; + + return node; + }; + + const nextStackShortcut: KeyboardShortcut = { + name: names.NEXT_STACK, + preconditionFn: (workspace) => + !workspace.isDragging() && !!resolveStack(workspace), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const start = resolveStack(workspace); + if (!start) return false; + const target = workspace.getNavigator().navigateStacks(start, 1); + if (!target) return false; + getFocusManager().focusNode(target); + return true; + }, + keyCodes: [KeyCodes.N], + }; + + const previousStackShortcut: KeyboardShortcut = { + name: names.PREVIOUS_STACK, + preconditionFn: (workspace) => + !workspace.isDragging() && !!resolveStack(workspace), + callback: (workspace) => { + keyboardNavigationController.setIsActive(true); + const start = resolveStack(workspace); + if (!start) return false; + // navigateStacks() returns the last connection in the stack when going + // backwards, but we want the root block, so resolve the stack from the + // element we get back. + const target = resolveStack( + workspace, + workspace.getNavigator().navigateStacks(start, -1), + ); + if (!target) return false; + getFocusManager().focusNode(target); + return true; + }, + keyCodes: [KeyCodes.B], + }; + + ShortcutRegistry.registry.register(nextStackShortcut); + ShortcutRegistry.registry.register(previousStackShortcut); +} + /** * Registers all default keyboard shortcut item. This should be called once per * instance of KeyboardShortcutRegistry. @@ -743,6 +815,7 @@ export function registerKeyboardNavigationShortcuts() { registerFocusToolbox(); registerArrowNavigation(); registerDisconnectBlock(); + registerStackNavigation(); } registerDefaultShortcuts(); From d02d6231442efde544f4d845953e13b5135580cf Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 2 Apr 2026 08:50:18 -0700 Subject: [PATCH 2/4] test: Add tests for stack jumping shortcuts --- .../tests/mocha/shortcut_items_test.js | 138 +++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 44e1e42bf62..e53d30c87bf 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -19,7 +19,7 @@ import {createKeyDownEvent} from './test_helpers/user_input.js'; suite('Keyboard Shortcut Items', function () { setup(function () { sharedTestSetup.call(this); - const toolbox = document.getElementById('toolbox-categories'); + const toolbox = document.getElementById('toolbox-test'); this.workspace = Blockly.inject('blocklyDiv', {toolbox}); this.injectionDiv = this.workspace.getInjectionDiv(); Blockly.ContextMenuRegistry.registry.reset(); @@ -799,4 +799,140 @@ suite('Keyboard Shortcut Items', function () { ); }); }); + + suite('Stack navigation (N / B)', function () { + const keyNextStack = () => createKeyDownEvent(Blockly.utils.KeyCodes.N); + const keyPrevStack = () => createKeyDownEvent(Blockly.utils.KeyCodes.B); + + setup(function () { + this.block1 = this.workspace.newBlock('controls_if'); + this.block2 = this.workspace.newBlock('stack_block'); + this.block3 = this.workspace.newBlock('stack_block'); + this.block2.moveBy(0, 100); + this.block3.moveBy(0, 400); + + this.comment1 = this.workspace.newComment(); + this.comment2 = this.workspace.newComment(); + this.comment1.moveBy(0, 200); + this.comment2.moveBy(0, 300); + }); + + test('Block forward to block', function () { + Blockly.getFocusManager().focusNode(this.block1); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); + + test('Block back to block', function () { + Blockly.getFocusManager().focusNode(this.block2); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block1, + ); + }); + + test('Block forward to workspace comment', function () { + Blockly.getFocusManager().focusNode(this.block2); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.comment1, + ); + }); + + test('Block back to workspace comment', function () { + Blockly.getFocusManager().focusNode(this.block3); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.comment2, + ); + }); + + test('Workspace comment forward to workspace comment', function () { + Blockly.getFocusManager().focusNode(this.comment1); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.comment2, + ); + }); + + test('Workspace comment back to workspace comment', function () { + Blockly.getFocusManager().focusNode(this.comment2); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.comment1, + ); + }); + + test('Workspace comment forward to block', function () { + Blockly.getFocusManager().focusNode(this.comment2); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block3, + ); + }); + + test('Workspace comment back to block', function () { + Blockly.getFocusManager().focusNode(this.comment1); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); + + test('Block forward to block in mutator workspace', async function () { + const icon = this.block1.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + this.clock.runAll(); + const mutatorWorkspace = icon.getWorkspace(); + const stack1 = mutatorWorkspace.newBlock('controls_if_elseif'); + const stack2 = mutatorWorkspace.newBlock('controls_if_elseif'); + stack1.initSvg(); + stack2.initSvg(); + stack1.render(); + stack2.render(); + stack1.moveBy(0, 100); + stack2.moveBy(0, 200); + Blockly.getFocusManager().focusNode(stack1); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), stack2); + }); + + test('Block back to block in mutator workspace', async function () { + const icon = this.block1.getIcon(Blockly.icons.MutatorIcon.TYPE); + await icon.setBubbleVisible(true); + this.clock.runAll(); + const mutatorWorkspace = icon.getWorkspace(); + const stack1 = mutatorWorkspace.newBlock('controls_if_elseif'); + const stack2 = mutatorWorkspace.newBlock('controls_if_elseif'); + stack1.initSvg(); + stack2.initSvg(); + stack1.render(); + stack2.render(); + stack1.moveBy(0, 100); + stack2.moveBy(0, 200); + Blockly.getFocusManager().focusNode(stack2); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), stack1); + }); + + test('Next stack from nested element', async function () { + const icon = this.block1.getIcon(Blockly.icons.MutatorIcon.TYPE); + Blockly.getFocusManager().focusNode(icon); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block2, + ); + }); + }); }); From f4e0d58b08ddbc9ebfd4c242be1743abd4bcdaee Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 2 Apr 2026 12:23:58 -0700 Subject: [PATCH 3/4] chore: Clarify logic --- packages/blockly/core/shortcut_items.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 1c485962180..b23b6d67bff 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -736,7 +736,7 @@ export function registerStackNavigation() { for ( let parent: IFocusableNode | null = node; parent && parent !== workspace; - parent = navigator.getParent(node) + parent = navigator.getParent(parent) ) { node = parent; } From c8d0a7245b559a34419ec63dee31d4fe5e95eef5 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 2 Apr 2026 12:24:11 -0700 Subject: [PATCH 4/4] test: Add additional tests for no-op stack navigation --- .../blockly/tests/mocha/shortcut_items_test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index e53d30c87bf..c5b5dcb33f8 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -817,6 +817,24 @@ suite('Keyboard Shortcut Items', function () { this.comment2.moveBy(0, 300); }); + test('First stack navigating back is a no-op', function () { + Blockly.getFocusManager().focusNode(this.block1); + this.injectionDiv.dispatchEvent(keyPrevStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block1, + ); + }); + + test('Last stack navigating forward is a no-op', function () { + Blockly.getFocusManager().focusNode(this.block3); + this.injectionDiv.dispatchEvent(keyNextStack()); + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + this.block3, + ); + }); + test('Block forward to block', function () { Blockly.getFocusManager().focusNode(this.block1); this.injectionDiv.dispatchEvent(keyNextStack());