Skip to content

Commit 3389f87

Browse files
authored
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
1 parent dc4d751 commit 3389f87

3 files changed

Lines changed: 230 additions & 2 deletions

File tree

packages/blockly/core/keyboard_nav/navigators/navigator.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ export class Navigator {
456456
/**
457457
* Returns the next/previous stack relative to the given element's stack.
458458
*
459+
* @internal
459460
* @param current The element whose stack will be navigated relative to.
460461
* @param delta The difference in index to navigate; positive values navigate
461462
* to the nth next stack, while negative values navigate to the nth
@@ -464,7 +465,7 @@ export class Navigator {
464465
* current element's stack, or the last element in the stack offset by
465466
* `delta` relative to the current element's stack when navigating backwards.
466467
*/
467-
protected navigateStacks(current: IFocusableNode, delta: number) {
468+
navigateStacks(current: IFocusableNode, delta: number) {
468469
const stacks = this.getTopLevelItems(current);
469470
const root =
470471
this.getSourceBlockFromNode(current)?.getRootBlock() ?? current;

packages/blockly/core/shortcut_items.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
1717
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
1818
import {type IDraggable, isDraggable} from './interfaces/i_draggable.js';
1919
import {type IFocusableNode} from './interfaces/i_focusable_node.js';
20+
import {isSelectable} from './interfaces/i_selectable.js';
2021
import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js';
2122
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
2223
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
@@ -53,6 +54,8 @@ export enum names {
5354
NAVIGATE_UP = 'up',
5455
NAVIGATE_DOWN = 'down',
5556
DISCONNECT = 'disconnect',
57+
NEXT_STACK = 'next_stack',
58+
PREVIOUS_STACK = 'previous_stack',
5659
}
5760

5861
/**
@@ -716,6 +719,75 @@ export function registerDisconnectBlock() {
716719
ShortcutRegistry.registry.register(disconnectShortcut);
717720
}
718721

722+
/**
723+
* Registers keyboard shortcuts to jump between stacks/top-level items on the
724+
* workspace.
725+
*/
726+
export function registerStackNavigation() {
727+
/**
728+
* Finds the stack root of the currently focused or specified item.
729+
*/
730+
const resolveStack = (
731+
workspace: WorkspaceSvg,
732+
node = getFocusManager().getFocusedNode(),
733+
) => {
734+
const navigator = workspace.getNavigator();
735+
736+
for (
737+
let parent: IFocusableNode | null = node;
738+
parent && parent !== workspace;
739+
parent = navigator.getParent(parent)
740+
) {
741+
node = parent;
742+
}
743+
744+
if (!isSelectable(node)) return null;
745+
746+
return node;
747+
};
748+
749+
const nextStackShortcut: KeyboardShortcut = {
750+
name: names.NEXT_STACK,
751+
preconditionFn: (workspace) =>
752+
!workspace.isDragging() && !!resolveStack(workspace),
753+
callback: (workspace) => {
754+
keyboardNavigationController.setIsActive(true);
755+
const start = resolveStack(workspace);
756+
if (!start) return false;
757+
const target = workspace.getNavigator().navigateStacks(start, 1);
758+
if (!target) return false;
759+
getFocusManager().focusNode(target);
760+
return true;
761+
},
762+
keyCodes: [KeyCodes.N],
763+
};
764+
765+
const previousStackShortcut: KeyboardShortcut = {
766+
name: names.PREVIOUS_STACK,
767+
preconditionFn: (workspace) =>
768+
!workspace.isDragging() && !!resolveStack(workspace),
769+
callback: (workspace) => {
770+
keyboardNavigationController.setIsActive(true);
771+
const start = resolveStack(workspace);
772+
if (!start) return false;
773+
// navigateStacks() returns the last connection in the stack when going
774+
// backwards, but we want the root block, so resolve the stack from the
775+
// element we get back.
776+
const target = resolveStack(
777+
workspace,
778+
workspace.getNavigator().navigateStacks(start, -1),
779+
);
780+
if (!target) return false;
781+
getFocusManager().focusNode(target);
782+
return true;
783+
},
784+
keyCodes: [KeyCodes.B],
785+
};
786+
787+
ShortcutRegistry.registry.register(nextStackShortcut);
788+
ShortcutRegistry.registry.register(previousStackShortcut);
789+
}
790+
719791
/**
720792
* Registers all default keyboard shortcut item. This should be called once per
721793
* instance of KeyboardShortcutRegistry.
@@ -743,6 +815,7 @@ export function registerKeyboardNavigationShortcuts() {
743815
registerFocusToolbox();
744816
registerArrowNavigation();
745817
registerDisconnectBlock();
818+
registerStackNavigation();
746819
}
747820

748821
registerDefaultShortcuts();

packages/blockly/tests/mocha/shortcut_items_test.js

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {createKeyDownEvent} from './test_helpers/user_input.js';
1919
suite('Keyboard Shortcut Items', function () {
2020
setup(function () {
2121
sharedTestSetup.call(this);
22-
const toolbox = document.getElementById('toolbox-categories');
22+
const toolbox = document.getElementById('toolbox-test');
2323
this.workspace = Blockly.inject('blocklyDiv', {toolbox});
2424
this.injectionDiv = this.workspace.getInjectionDiv();
2525
Blockly.ContextMenuRegistry.registry.reset();
@@ -799,4 +799,158 @@ suite('Keyboard Shortcut Items', function () {
799799
);
800800
});
801801
});
802+
803+
suite('Stack navigation (N / B)', function () {
804+
const keyNextStack = () => createKeyDownEvent(Blockly.utils.KeyCodes.N);
805+
const keyPrevStack = () => createKeyDownEvent(Blockly.utils.KeyCodes.B);
806+
807+
setup(function () {
808+
this.block1 = this.workspace.newBlock('controls_if');
809+
this.block2 = this.workspace.newBlock('stack_block');
810+
this.block3 = this.workspace.newBlock('stack_block');
811+
this.block2.moveBy(0, 100);
812+
this.block3.moveBy(0, 400);
813+
814+
this.comment1 = this.workspace.newComment();
815+
this.comment2 = this.workspace.newComment();
816+
this.comment1.moveBy(0, 200);
817+
this.comment2.moveBy(0, 300);
818+
});
819+
820+
test('First stack navigating back is a no-op', function () {
821+
Blockly.getFocusManager().focusNode(this.block1);
822+
this.injectionDiv.dispatchEvent(keyPrevStack());
823+
assert.strictEqual(
824+
Blockly.getFocusManager().getFocusedNode(),
825+
this.block1,
826+
);
827+
});
828+
829+
test('Last stack navigating forward is a no-op', function () {
830+
Blockly.getFocusManager().focusNode(this.block3);
831+
this.injectionDiv.dispatchEvent(keyNextStack());
832+
assert.strictEqual(
833+
Blockly.getFocusManager().getFocusedNode(),
834+
this.block3,
835+
);
836+
});
837+
838+
test('Block forward to block', function () {
839+
Blockly.getFocusManager().focusNode(this.block1);
840+
this.injectionDiv.dispatchEvent(keyNextStack());
841+
assert.strictEqual(
842+
Blockly.getFocusManager().getFocusedNode(),
843+
this.block2,
844+
);
845+
});
846+
847+
test('Block back to block', function () {
848+
Blockly.getFocusManager().focusNode(this.block2);
849+
this.injectionDiv.dispatchEvent(keyPrevStack());
850+
assert.strictEqual(
851+
Blockly.getFocusManager().getFocusedNode(),
852+
this.block1,
853+
);
854+
});
855+
856+
test('Block forward to workspace comment', function () {
857+
Blockly.getFocusManager().focusNode(this.block2);
858+
this.injectionDiv.dispatchEvent(keyNextStack());
859+
assert.strictEqual(
860+
Blockly.getFocusManager().getFocusedNode(),
861+
this.comment1,
862+
);
863+
});
864+
865+
test('Block back to workspace comment', function () {
866+
Blockly.getFocusManager().focusNode(this.block3);
867+
this.injectionDiv.dispatchEvent(keyPrevStack());
868+
assert.strictEqual(
869+
Blockly.getFocusManager().getFocusedNode(),
870+
this.comment2,
871+
);
872+
});
873+
874+
test('Workspace comment forward to workspace comment', function () {
875+
Blockly.getFocusManager().focusNode(this.comment1);
876+
this.injectionDiv.dispatchEvent(keyNextStack());
877+
assert.strictEqual(
878+
Blockly.getFocusManager().getFocusedNode(),
879+
this.comment2,
880+
);
881+
});
882+
883+
test('Workspace comment back to workspace comment', function () {
884+
Blockly.getFocusManager().focusNode(this.comment2);
885+
this.injectionDiv.dispatchEvent(keyPrevStack());
886+
assert.strictEqual(
887+
Blockly.getFocusManager().getFocusedNode(),
888+
this.comment1,
889+
);
890+
});
891+
892+
test('Workspace comment forward to block', function () {
893+
Blockly.getFocusManager().focusNode(this.comment2);
894+
this.injectionDiv.dispatchEvent(keyNextStack());
895+
assert.strictEqual(
896+
Blockly.getFocusManager().getFocusedNode(),
897+
this.block3,
898+
);
899+
});
900+
901+
test('Workspace comment back to block', function () {
902+
Blockly.getFocusManager().focusNode(this.comment1);
903+
this.injectionDiv.dispatchEvent(keyPrevStack());
904+
assert.strictEqual(
905+
Blockly.getFocusManager().getFocusedNode(),
906+
this.block2,
907+
);
908+
});
909+
910+
test('Block forward to block in mutator workspace', async function () {
911+
const icon = this.block1.getIcon(Blockly.icons.MutatorIcon.TYPE);
912+
await icon.setBubbleVisible(true);
913+
this.clock.runAll();
914+
const mutatorWorkspace = icon.getWorkspace();
915+
const stack1 = mutatorWorkspace.newBlock('controls_if_elseif');
916+
const stack2 = mutatorWorkspace.newBlock('controls_if_elseif');
917+
stack1.initSvg();
918+
stack2.initSvg();
919+
stack1.render();
920+
stack2.render();
921+
stack1.moveBy(0, 100);
922+
stack2.moveBy(0, 200);
923+
Blockly.getFocusManager().focusNode(stack1);
924+
this.injectionDiv.dispatchEvent(keyNextStack());
925+
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), stack2);
926+
});
927+
928+
test('Block back to block in mutator workspace', async function () {
929+
const icon = this.block1.getIcon(Blockly.icons.MutatorIcon.TYPE);
930+
await icon.setBubbleVisible(true);
931+
this.clock.runAll();
932+
const mutatorWorkspace = icon.getWorkspace();
933+
const stack1 = mutatorWorkspace.newBlock('controls_if_elseif');
934+
const stack2 = mutatorWorkspace.newBlock('controls_if_elseif');
935+
stack1.initSvg();
936+
stack2.initSvg();
937+
stack1.render();
938+
stack2.render();
939+
stack1.moveBy(0, 100);
940+
stack2.moveBy(0, 200);
941+
Blockly.getFocusManager().focusNode(stack2);
942+
this.injectionDiv.dispatchEvent(keyPrevStack());
943+
assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), stack1);
944+
});
945+
946+
test('Next stack from nested element', async function () {
947+
const icon = this.block1.getIcon(Blockly.icons.MutatorIcon.TYPE);
948+
Blockly.getFocusManager().focusNode(icon);
949+
this.injectionDiv.dispatchEvent(keyNextStack());
950+
assert.strictEqual(
951+
Blockly.getFocusManager().getFocusedNode(),
952+
this.block2,
953+
);
954+
});
955+
});
802956
});

0 commit comments

Comments
 (0)