feat: Add keyboard shortcuts to navigate between stacks (#9678)

* feat: Add keyboard shortcuts to navigate between stacks

* test: Add tests for stack jumping shortcuts

* chore: Clarify logic

* test: Add additional tests for no-op stack navigation
This commit is contained in:
Aaron Dodson
2026-04-02 12:26:52 -07:00
committed by GitHub
parent dc4d751b93
commit 3389f87cee
3 changed files with 230 additions and 2 deletions
@@ -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;
+73
View File
@@ -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(parent)
) {
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();
@@ -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,158 @@ 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('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());
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,
);
});
});
});