Skip to content

Commit 3bc2f28

Browse files
feat: navigation between stacks via n/b
Include workspace comments in the sequence as they are also top-level navigation items.
1 parent d5151f2 commit 3bc2f28

File tree

5 files changed

+156
-0
lines changed

5 files changed

+156
-0
lines changed

src/actions/stack_navigation.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {ShortcutRegistry, WorkspaceSvg, utils} from 'blockly/core';
8+
import * as Constants from '../constants';
9+
10+
/**
11+
* Class for registering a shortcut for quick movement between top level bounds
12+
* in the workspace.
13+
*/
14+
export class StackNavigationAction {
15+
private stackShortcuts: ShortcutRegistry.KeyboardShortcut[] = [];
16+
17+
install() {
18+
const preconditionFn = (workspace: WorkspaceSvg) =>
19+
!!getCurNodeRoot(workspace);
20+
21+
function getCurNodeRoot(workspace: WorkspaceSvg) {
22+
const cursor = workspace.getCursor();
23+
// The fallback case includes workspace comments.
24+
return cursor.getSourceBlock()?.getRootBlock() ?? cursor.getCurNode();
25+
}
26+
27+
const previousStackShortcut: ShortcutRegistry.KeyboardShortcut = {
28+
name: Constants.SHORTCUT_NAMES.PREVIOUS_STACK,
29+
preconditionFn,
30+
callback: (workspace) => {
31+
const curNodeRoot = getCurNodeRoot(workspace);
32+
if (!curNodeRoot) return false;
33+
const prevRoot = workspace
34+
.getNavigator()
35+
.getPreviousSibling(curNodeRoot);
36+
if (!prevRoot) return false;
37+
workspace.getCursor().setCurNode(prevRoot);
38+
return true;
39+
},
40+
keyCodes: [utils.KeyCodes.B],
41+
};
42+
43+
const nextStackShortcut: ShortcutRegistry.KeyboardShortcut = {
44+
name: Constants.SHORTCUT_NAMES.NEXT_STACK,
45+
preconditionFn,
46+
callback: (workspace) => {
47+
const curNodeRoot = getCurNodeRoot(workspace);
48+
if (!curNodeRoot) return false;
49+
const nextRoot = workspace.getNavigator().getNextSibling(curNodeRoot);
50+
if (!nextRoot) return false;
51+
workspace.getCursor().setCurNode(nextRoot);
52+
return true;
53+
},
54+
keyCodes: [utils.KeyCodes.N],
55+
};
56+
57+
ShortcutRegistry.registry.register(previousStackShortcut);
58+
this.stackShortcuts.push(previousStackShortcut);
59+
ShortcutRegistry.registry.register(nextStackShortcut);
60+
this.stackShortcuts.push(nextStackShortcut);
61+
}
62+
63+
/**
64+
* Unregisters the shortcut.
65+
*/
66+
uninstall() {
67+
this.stackShortcuts.forEach((shortcut) =>
68+
ShortcutRegistry.registry.unregister(shortcut.name),
69+
);
70+
this.stackShortcuts.length = 0;
71+
}
72+
}

src/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export enum SHORTCUT_NAMES {
3333
DOWN = 'down',
3434
RIGHT = 'right',
3535
LEFT = 'left',
36+
NEXT_STACK = 'next_stack',
37+
PREVIOUS_STACK = 'previous_stack',
3638
INSERT = 'insert',
3739
EDIT_OR_CONFIRM = 'edit_or_confirm',
3840
DISCONNECT = 'disconnect',
@@ -100,4 +102,6 @@ SHORTCUT_CATEGORIES[Msg['SHORTCUTS_CODE_NAVIGATION']] = [
100102
SHORTCUT_NAMES.DOWN,
101103
SHORTCUT_NAMES.RIGHT,
102104
SHORTCUT_NAMES.LEFT,
105+
SHORTCUT_NAMES.NEXT_STACK,
106+
SHORTCUT_NAMES.PREVIOUS_STACK,
103107
];

src/navigation_controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {ActionMenu} from './actions/action_menu';
3636
import {MoveActions} from './actions/move';
3737
import {Mover} from './actions/mover';
3838
import {DuplicateAction} from './actions/duplicate';
39+
import {StackNavigationAction} from './actions/stack_navigation';
3940

4041
const KeyCodes = BlocklyUtils.KeyCodes;
4142

@@ -75,6 +76,8 @@ export class NavigationController {
7576

7677
moveActions = new MoveActions(this.mover);
7778

79+
stackNavigationAction: StackNavigationAction = new StackNavigationAction();
80+
7881
/**
7982
* Original Toolbox.prototype.onShortcut method, saved by
8083
* addShortcutHandlers.
@@ -250,6 +253,7 @@ export class NavigationController {
250253
this.duplicateAction.install();
251254
this.moveActions.install();
252255
this.shortcutDialog.install();
256+
this.stackNavigationAction.install();
253257

254258
// Initialize the shortcut modal with available shortcuts. Needs
255259
// to be done separately rather at construction, as many shortcuts
@@ -273,6 +277,7 @@ export class NavigationController {
273277
this.enterAction.uninstall();
274278
this.actionMenu.uninstall();
275279
this.shortcutDialog.uninstall();
280+
this.stackNavigationAction.uninstall();
276281

277282
for (const shortcut of Object.values(this.shortcuts)) {
278283
ShortcutRegistry.registry.unregister(shortcut.name);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as chai from 'chai';
8+
import {
9+
getCurrentFocusedBlockId,
10+
getCurrentFocusNodeId,
11+
PAUSE_TIME,
12+
tabNavigateToWorkspace,
13+
testFileLocations,
14+
testSetup,
15+
} from './test_setup.js';
16+
17+
suite('Stack navigation', function () {
18+
// Setting timeout to unlimited as these tests take longer time to run
19+
this.timeout(0);
20+
21+
// Clear the workspace and load start blocks
22+
setup(async function () {
23+
this.browser = await testSetup(testFileLocations.COMMENTS);
24+
await this.browser.pause(PAUSE_TIME);
25+
});
26+
27+
test('Next', async function () {
28+
await tabNavigateToWorkspace(this.browser);
29+
chai.assert.equal(
30+
'p5_setup_1',
31+
await getCurrentFocusedBlockId(this.browser),
32+
);
33+
await this.browser.keys('n');
34+
chai.assert.equal(
35+
'p5_draw_1',
36+
await getCurrentFocusedBlockId(this.browser),
37+
);
38+
await this.browser.keys('n');
39+
chai.assert.equal(
40+
'workspace_comment_1',
41+
await getCurrentFocusNodeId(this.browser),
42+
);
43+
await this.browser.keys('n');
44+
// Looped around.
45+
chai.assert.equal(
46+
'p5_setup_1',
47+
await getCurrentFocusedBlockId(this.browser),
48+
);
49+
});
50+
51+
test('Previous', async function () {
52+
await tabNavigateToWorkspace(this.browser);
53+
chai.assert.equal(
54+
'p5_setup_1',
55+
await getCurrentFocusedBlockId(this.browser),
56+
);
57+
await this.browser.keys('b');
58+
// Looped to bottom.
59+
chai.assert.equal(
60+
'workspace_comment_1',
61+
await getCurrentFocusNodeId(this.browser),
62+
);
63+
await this.browser.keys('b');
64+
chai.assert.equal(
65+
'p5_draw_1',
66+
await getCurrentFocusedBlockId(this.browser),
67+
);
68+
await this.browser.keys('b');
69+
chai.assert.equal(
70+
'p5_setup_1',
71+
await getCurrentFocusedBlockId(this.browser),
72+
);
73+
});
74+
});

test/webdriverio/test/test_setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export const testFileLocations = {
141141
MOVE_TEST_BLOCKS: createTestUrl(
142142
new URLSearchParams({scenario: 'moveTestBlocks'}),
143143
),
144+
COMMENTS: createTestUrl(new URLSearchParams({scenario: 'comments'})),
144145
// eslint-disable-next-line @typescript-eslint/naming-convention
145146
BASE_RTL: createTestUrl(new URLSearchParams({rtl: 'true'})),
146147
GERAS: createTestUrl(new URLSearchParams({renderer: 'geras'})),

0 commit comments

Comments
 (0)