Skip to content

Commit a14cd8d

Browse files
authored
Merge branch 'main' into copy-workspace
2 parents 980797c + dcfe7df commit a14cd8d

File tree

9 files changed

+294
-24
lines changed

9 files changed

+294
-24
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

@@ -74,6 +75,8 @@ export class NavigationController {
7475
actionMenu: ActionMenu = new ActionMenu(this.navigation);
7576

7677
moveActions = new MoveActions(this.mover);
78+
79+
stackNavigationAction: StackNavigationAction = new StackNavigationAction();
7780

7881
constructor(
7982
private options: {allowCrossWorkspacePaste: boolean} = {
@@ -258,6 +261,7 @@ export class NavigationController {
258261
this.duplicateAction.install();
259262
this.moveActions.install();
260263
this.shortcutDialog.install();
264+
this.stackNavigationAction.install();
261265

262266
// Initialize the shortcut modal with available shortcuts. Needs
263267
// to be done separately rather at construction, as many shortcuts
@@ -281,6 +285,7 @@ export class NavigationController {
281285
this.enterAction.uninstall();
282286
this.actionMenu.uninstall();
283287
this.shortcutDialog.uninstall();
288+
this.stackNavigationAction.uninstall();
284289

285290
for (const shortcut of Object.values(this.shortcuts)) {
286291
ShortcutRegistry.registry.unregister(shortcut.name);

src/shortcut_formatting.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function getMenuItem(labelText: string, action: string): HTMLElement {
2222
label.textContent = labelText;
2323
const shortcut = document.createElement('span');
2424
shortcut.className = 'blocklyShortcut';
25-
shortcut.textContent = getShortActionShortcut(action);
25+
shortcut.textContent = ` ${getShortActionShortcut(action)}`;
2626
container.appendChild(label);
2727
container.appendChild(shortcut);
2828
return container;

test/webdriverio/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ function createWorkspace(): Blockly.WorkspaceSvg {
8282
KeyboardNavigation.registerKeyboardNavigationStyles();
8383
const workspace = Blockly.inject(blocklyDiv, injectOptions);
8484

85-
new KeyboardNavigation(workspace);
8685
Blockly.ContextMenuItems.registerCommentOptions();
86+
new KeyboardNavigation(workspace);
8787

8888
// Disable blocks that aren't inside the setup or draw loops.
8989
workspace.addChangeListener(Blockly.Events.disableOrphans);

test/webdriverio/test/actions_test.ts

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
testFileLocations,
1616
testSetup,
1717
keyRight,
18+
contextMenuItems,
1819
} from './test_setup.js';
1920

2021
suite('Menus test', function () {
@@ -27,20 +28,47 @@ suite('Menus test', function () {
2728
await this.browser.pause(PAUSE_TIME);
2829
});
2930

30-
test('Menu action opens menu', async function () {
31+
test('Menu on block', async function () {
3132
// Navigate to draw_circle_1.
3233
await tabNavigateToWorkspace(this.browser);
3334
await focusOnBlock(this.browser, 'draw_circle_1');
3435
await this.browser.pause(PAUSE_TIME);
3536
await this.browser.keys([Key.Ctrl, Key.Return]);
3637
await this.browser.pause(PAUSE_TIME);
37-
chai.assert.isTrue(
38-
await contextMenuExists(this.browser, 'Collapse Block'),
39-
'The menu should be openable on a block',
38+
39+
chai.assert.deepEqual(
40+
process.platform === 'darwin'
41+
? [
42+
{'text': 'Duplicate D'},
43+
{'text': 'Add Comment'},
44+
{'text': 'External Inputs'},
45+
{'text': 'Collapse Block'},
46+
{'text': 'Disable Block'},
47+
{'text': 'Delete 2 Blocks Delete'},
48+
{'text': 'Move Block M'},
49+
{'text': 'Edit Block contents Right'},
50+
{'text': 'Cut ⌘ X'},
51+
{'text': 'Copy ⌘ C'},
52+
{'disabled': true, 'text': 'Paste ⌘ V'},
53+
]
54+
: [
55+
{'text': 'Duplicate D'},
56+
{'text': 'Add Comment'},
57+
{'text': 'External Inputs'},
58+
{'text': 'Collapse Block'},
59+
{'text': 'Disable Block'},
60+
{'text': 'Delete 2 Blocks Delete'},
61+
{'text': 'Move Block M'},
62+
{'text': 'Edit Block contents Right'},
63+
{'text': 'Cut Ctrl + X'},
64+
{'text': 'Copy Ctrl + C'},
65+
{'disabled': true, 'text': 'Paste Ctrl + V'},
66+
],
67+
await contextMenuItems(this.browser),
4068
);
4169
});
4270

43-
test('Menu action returns true in the toolbox', async function () {
71+
test('Menu on block in the toolbox', async function () {
4472
// Navigate to draw_circle_1.
4573
await tabNavigateToWorkspace(this.browser);
4674
await focusOnBlock(this.browser, 'draw_circle_1');
@@ -51,13 +79,60 @@ suite('Menus test', function () {
5179
await this.browser.keys([Key.Ctrl, Key.Return]);
5280
await this.browser.pause(PAUSE_TIME);
5381

54-
chai.assert.isTrue(
55-
await contextMenuExists(this.browser, 'Help'),
56-
'The menu should be openable on a block in the toolbox',
82+
chai.assert.deepEqual(
83+
process.platform === 'darwin'
84+
? [
85+
{'text': 'Help'},
86+
{'disabled': true, 'text': 'Move Block M'},
87+
{'disabled': true, 'text': 'Cut ⌘ X'},
88+
{'text': 'Copy ⌘ C'},
89+
{'disabled': true, 'text': 'Paste ⌘ V'},
90+
]
91+
: [
92+
{'text': 'Help'},
93+
{'disabled': true, 'text': 'Move Block M'},
94+
{'disabled': true, 'text': 'Cut Ctrl + X'},
95+
{'text': 'Copy Ctrl + C'},
96+
{'disabled': true, 'text': 'Paste Ctrl + V'},
97+
],
98+
await contextMenuItems(this.browser),
99+
);
100+
});
101+
102+
test('Menu on workspace', async function () {
103+
// Navigate to draw_circle_1.
104+
await tabNavigateToWorkspace(this.browser);
105+
await this.browser.keys('w');
106+
await this.browser.keys([Key.Ctrl, Key.Return]);
107+
await this.browser.pause(PAUSE_TIME);
108+
109+
chai.assert.deepEqual(
110+
process.platform === 'darwin'
111+
? [
112+
{'disabled': true, 'text': 'Undo'},
113+
{'disabled': true, 'text': 'Redo'},
114+
{'text': 'Clean up Blocks'},
115+
{'text': 'Collapse Blocks'},
116+
{'disabled': true, 'text': 'Expand Blocks'},
117+
{'text': 'Delete 4 Blocks'},
118+
{'text': 'Add Comment'},
119+
{'disabled': true, 'text': 'Paste ⌘ V'},
120+
]
121+
: [
122+
{'disabled': true, 'text': 'Undo'},
123+
{'disabled': true, 'text': 'Redo'},
124+
{'text': 'Clean up Blocks'},
125+
{'text': 'Collapse Blocks'},
126+
{'disabled': true, 'text': 'Expand Blocks'},
127+
{'text': 'Delete 4 Blocks'},
128+
{'text': 'Add Comment'},
129+
{'disabled': true, 'text': 'Paste Ctrl + V'},
130+
],
131+
await contextMenuItems(this.browser),
57132
);
58133
});
59134

60-
test('Menu action returns false during drag', async function () {
135+
test('Menu on block during drag is not shown', async function () {
61136
// Navigate to draw_circle_1.
62137
await tabNavigateToWorkspace(this.browser);
63138
await focusOnBlock(this.browser, 'draw_circle_1');
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+
});

0 commit comments

Comments
 (0)