mirror of
https://github.com/google/blockly.git
synced 2026-04-28 08:00:20 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user