Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
73 changes: 73 additions & 0 deletions packages/blockly/core/shortcut_items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,6 +54,8 @@ export enum names {
NAVIGATE_UP = 'up',
NAVIGATE_DOWN = 'down',
DISCONNECT = 'disconnect',
NEXT_STACK = 'next_stack',
PREVIOUS_STACK = 'previous_stack',
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -743,6 +815,7 @@ export function registerKeyboardNavigationShortcuts() {
registerFocusToolbox();
registerArrowNavigation();
registerDisconnectBlock();
registerStackNavigation();
}

registerDefaultShortcuts();
Expand Down
156 changes: 155 additions & 1 deletion packages/blockly/tests/mocha/shortcut_items_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
);
});
});
});