feat: add more navigation shortcuts (#9523)

This commit is contained in:
Maribeth Moffatt
2025-12-10 15:37:01 -05:00
committed by GitHub
parent 4622cf538d
commit 12da1fb577
2 changed files with 548 additions and 18 deletions

View File

@@ -15,6 +15,7 @@ import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
import {isDraggable} from './interfaces/i_draggable.js';
import {IFocusableNode} from './interfaces/i_focusable_node.js';
import {RenderedConnection} from './rendered_connection.js';
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {aria} from './utils.js';
import {Coordinate} from './utils/coordinate.js';
@@ -36,6 +37,12 @@ export enum names {
REDO = 'redo',
READ_FULL_BLOCK_SUMMARY = 'read_full_block_summary',
READ_BLOCK_PARENT_SUMMARY = 'read_block_parent_summary',
JUMP_TOP_STACK = 'jump_to_top_of_stack',
JUMP_BOTTOM_STACK = 'jump_to_bottom_of_stack',
JUMP_BLOCK_START = 'jump_to_block_start',
JUMP_BLOCK_END = 'jump_to_block_end',
JUMP_FIRST_BLOCK = 'jump_to_first_block',
JUMP_LAST_BLOCK = 'jump_to_last_block',
}
/**
@@ -389,6 +396,20 @@ export function registerRedo() {
ShortcutRegistry.registry.register(redoShortcut);
}
/**
* PreconditionFn that returns true if the focused thing is a block or
* belongs to a block (such as field, icon, etc.)
*/
const focusedNodeHasBlockParent = function (workspace: WorkspaceSvg) {
return (
!workspace.isDragging() &&
!getFocusManager().ephemeralFocusTaken() &&
!!getFocusManager().getFocusedNode() &&
// Either a block or something that has a parent block is focused
!!workspace.getCursor().getSourceBlock()
);
};
/**
* Registers a keyboard shortcut for re-reading the current selected block's
* summary with additional verbosity to help provide context on where the user
@@ -400,15 +421,7 @@ export function registerRedo() {
export function registerReadFullBlockSummary() {
const readFullBlockSummaryShortcut: KeyboardShortcut = {
name: names.READ_FULL_BLOCK_SUMMARY,
preconditionFn(workspace) {
return (
!workspace.isDragging() &&
!getFocusManager().ephemeralFocusTaken() &&
!!getFocusManager().getFocusedNode() &&
// Either a block or something that has a parent block is focused
!!workspace.getCursor().getSourceBlock()
);
},
preconditionFn: focusedNodeHasBlockParent,
callback(workspace, e) {
const selectedBlock = workspace.getCursor().getSourceBlock();
if (!selectedBlock) return false;
@@ -433,15 +446,7 @@ export function registerReadBlockParentSummary() {
]);
const readBlockParentSummaryShortcut: KeyboardShortcut = {
name: names.READ_BLOCK_PARENT_SUMMARY,
preconditionFn(workspace) {
return (
!workspace.isDragging() &&
!getFocusManager().ephemeralFocusTaken() &&
!!getFocusManager().getFocusedNode() &&
// Either a block or something that has a parent block is focused
!!workspace.getCursor().getSourceBlock()
);
},
preconditionFn: focusedNodeHasBlockParent,
callback(workspace, e) {
const selectedBlock = workspace.getCursor().getSourceBlock();
if (!selectedBlock) return false;
@@ -460,6 +465,166 @@ export function registerReadBlockParentSummary() {
ShortcutRegistry.registry.register(readBlockParentSummaryShortcut);
}
/**
* Registers a keyboard shortcut that sets the focus to the block
* that owns the current focused node.
*/
export function registerJumpBlockStart() {
const jumpBlockStartShortcut: KeyboardShortcut = {
name: names.JUMP_BLOCK_START,
preconditionFn: (workspace) => {
return !workspace.isFlyout && focusedNodeHasBlockParent(workspace);
},
callback(workspace) {
const selectedBlock = workspace.getCursor().getSourceBlock();
if (!selectedBlock) return false;
getFocusManager().focusNode(selectedBlock);
return true;
},
keyCodes: [KeyCodes.HOME],
};
ShortcutRegistry.registry.register(jumpBlockStartShortcut);
}
/**
* Registers a keyboard shortcut that sets the focus to the
* last input of the block that owns the current focused node.
*/
export function registerJumpBlockEnd() {
const jumpBlockEndShortcut: KeyboardShortcut = {
name: names.JUMP_BLOCK_END,
preconditionFn: (workspace) => {
return !workspace.isFlyout && focusedNodeHasBlockParent(workspace);
},
callback(workspace) {
const selectedBlock = workspace.getCursor().getSourceBlock();
if (!selectedBlock) return false;
const inputs = selectedBlock.inputList;
if (!inputs.length) return false;
const connection = inputs[inputs.length - 1].connection;
if (!connection || !(connection instanceof RenderedConnection))
return false;
getFocusManager().focusNode(connection);
return true;
},
keyCodes: [KeyCodes.END],
};
ShortcutRegistry.registry.register(jumpBlockEndShortcut);
}
/**
* Registers a keyboard shortcut that sets the focus to the top block
* in the current stack.
*/
export function registerJumpTopStack() {
const jumpTopStackShortcut: KeyboardShortcut = {
name: names.JUMP_TOP_STACK,
preconditionFn: (workspace) => {
return !workspace.isFlyout && focusedNodeHasBlockParent(workspace);
},
callback(workspace) {
const selectedBlock = workspace.getCursor().getSourceBlock();
if (!selectedBlock) return false;
const topOfStack = selectedBlock.getRootBlock();
getFocusManager().focusNode(topOfStack);
return true;
},
keyCodes: [KeyCodes.PAGE_UP],
};
ShortcutRegistry.registry.register(jumpTopStackShortcut);
}
/**
* Registers a keyboard shortcut that sets the focus to the bottom block
* in the current stack.
*/
export function registerJumpBottomStack() {
const jumpBottomStackShortcut: KeyboardShortcut = {
name: names.JUMP_BOTTOM_STACK,
preconditionFn: (workspace) => {
return !workspace.isFlyout && focusedNodeHasBlockParent(workspace);
},
callback(workspace) {
const selectedBlock = workspace.getCursor().getSourceBlock();
if (!selectedBlock) return false;
// To get the bottom block in a stack, first go to the top of the stack
// Then get the last next connection
// Then get the last descendant of that block
const lastBlock = selectedBlock
.getRootBlock()
.lastConnectionInStack(false)
?.getSourceBlock();
if (!lastBlock) return false;
const descendants = lastBlock.getDescendants(true);
const bottomOfStack = descendants[descendants.length - 1];
getFocusManager().focusNode(bottomOfStack);
return true;
},
keyCodes: [KeyCodes.PAGE_DOWN],
};
ShortcutRegistry.registry.register(jumpBottomStackShortcut);
}
/**
* Registers a keyboard shortcut that sets the focus to the first
* block in the workspace.
*/
export function registerJumpFirstBlock() {
const ctrlHome = ShortcutRegistry.registry.createSerializedKey(
KeyCodes.HOME,
[KeyCodes.CTRL],
);
const metaHome = ShortcutRegistry.registry.createSerializedKey(
KeyCodes.HOME,
[KeyCodes.META],
);
const jumpFirstBlockShortcut: KeyboardShortcut = {
name: names.JUMP_FIRST_BLOCK,
preconditionFn: (workspace) => {
return (
!workspace.isDragging() && !getFocusManager().ephemeralFocusTaken()
);
},
callback(workspace) {
const topBlocks = workspace.getTopBlocks(true);
if (!topBlocks.length) return false;
getFocusManager().focusNode(topBlocks[0]);
return true;
},
keyCodes: [ctrlHome, metaHome],
};
ShortcutRegistry.registry.register(jumpFirstBlockShortcut);
}
/**
* Registers a keyboard shortcut that sets the focus to the last
* block in the workspace.
*/
export function registerJumpLastBlock() {
const ctrlEnd = ShortcutRegistry.registry.createSerializedKey(KeyCodes.END, [
KeyCodes.CTRL,
]);
const metaEnd = ShortcutRegistry.registry.createSerializedKey(KeyCodes.END, [
KeyCodes.META,
]);
const jumpLastBlockShortcut: KeyboardShortcut = {
name: names.JUMP_LAST_BLOCK,
preconditionFn: (workspace) => {
return (
!workspace.isDragging() && !getFocusManager().ephemeralFocusTaken()
);
},
callback(workspace) {
const allBlocks = workspace.getAllBlocks(true);
if (!allBlocks.length) return false;
getFocusManager().focusNode(allBlocks[allBlocks.length - 1]);
return true;
},
keyCodes: [ctrlEnd, metaEnd],
};
ShortcutRegistry.registry.register(jumpLastBlockShortcut);
}
/**
* Registers all default keyboard shortcut item. This should be called once per
* instance of KeyboardShortcutRegistry.
@@ -476,6 +641,12 @@ export function registerDefaultShortcuts() {
registerRedo();
registerReadFullBlockSummary();
registerReadBlockParentSummary();
registerJumpTopStack();
registerJumpBottomStack();
registerJumpBlockStart();
registerJumpBlockEnd();
registerJumpFirstBlock();
registerJumpLastBlock();
}
registerDefaultShortcuts();

View File

@@ -560,4 +560,363 @@ suite('Keyboard Shortcut Items', function () {
]),
);
});
const blockJson = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'controls_repeat_ext',
'id': 'controls_repeat_1',
'x': 63,
'y': 88,
'inputs': {
'TIMES': {
'shadow': {
'type': 'math_number',
'id': 'math_number_1',
'fields': {
'NUM': 10,
},
},
},
'DO': {
'block': {
'type': 'controls_forEach',
'id': 'controls_forEach_1',
'fields': {
'VAR': {
'id': '/wU7DoTDScBz~6hbq-[E',
},
},
'inputs': {
'LIST': {
'block': {
'type': 'lists_repeat',
'id': 'lists_repeat_1',
'inputs': {
'ITEM': {
'block': {
'type': 'lists_getIndex',
'id': 'lists_getIndex_1',
'fields': {
'MODE': 'GET',
'WHERE': 'FROM_START',
},
'inputs': {
'VALUE': {
'block': {
'type': 'variables_get',
'id': 'Lhk_B9iVsV%BhhJ%h]m$',
'fields': {
'VAR': {
'id': '.*~ZjUJ#Sua{h6xyVp7`',
},
},
},
},
},
},
},
'NUM': {
'shadow': {
'type': 'math_number',
'id': 'math_number_2',
'fields': {
'NUM': 5,
},
},
},
},
},
},
},
},
},
},
},
{
'type': 'controls_forEach',
'id': 'controls_forEach_2',
'x': 63,
'y': 288,
'fields': {
'VAR': {
'id': '+rcR|2HqfZ=vK}N8L{RU',
},
},
'inputs': {
'DO': {
'block': {
'type': 'controls_repeat_ext',
'id': 'controls_repeat_2',
'inputs': {
'TIMES': {
'shadow': {
'type': 'math_number',
'id': 'math_number_3',
'fields': {
'NUM': 10,
},
},
},
},
'next': {
'block': {
'type': 'text_print',
'id': 'text_print_1',
'inputs': {
'TEXT': {
'block': {
'type': 'text',
'id': 'text_1',
'fields': {
'TEXT': 'last block inside a loop',
},
},
},
},
},
},
},
},
},
'next': {
'block': {
'type': 'text_print',
'id': 'text_print_2',
'inputs': {
'TEXT': {
'block': {
'type': 'text',
'id': 'text_2',
'fields': {
'TEXT': 'last block on workspace',
},
},
},
},
},
},
},
],
},
};
suite('Jump shortcuts', function () {
setup(function () {
this.getFocusedNodeStub = sinon.stub(
Blockly.getFocusManager(),
'getFocusedNode',
);
this.focusNodeSpy = sinon.stub(Blockly.getFocusManager(), 'focusNode');
Blockly.serialization.workspaces.load(blockJson, this.workspace);
});
test('Home focuses current block if block is focused', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
this.getFocusedNodeStub.returns(inListBlock);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.HOME),
);
sinon.assert.calledWith(this.focusNodeSpy, inListBlock);
});
test('Home focuses owning block if field is focused', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
const fieldToFocus = inListBlock.getField('MODE');
this.getFocusedNodeStub.returns(fieldToFocus);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.HOME),
);
sinon.assert.calledWith(this.focusNodeSpy, inListBlock);
});
test('End focuses last input on owning block', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
const fieldToFocus = inListBlock.getField('MODE');
this.getFocusedNodeStub.returns(fieldToFocus);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END),
);
const expectedFocus = inListBlock.getInput('AT').connection;
sinon.assert.calledWith(this.focusNodeSpy, expectedFocus);
});
test('End has no effect if block has no inputs', function () {
const textBlock = this.workspace.getBlockById('text_1');
this.getFocusedNodeStub.returns(textBlock);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END),
);
sinon.assert.notCalled(this.focusNodeSpy);
});
test('CtrlHome focuses top block in workspace if block is focused', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
this.getFocusedNodeStub.returns(inListBlock);
const topBlock = this.workspace.getBlockById('controls_repeat_1');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [
Blockly.utils.KeyCodes.CTRL,
]),
);
sinon.assert.calledWith(this.focusNodeSpy, topBlock);
});
test('CtrlHome focuses top block in workspace if field is focused', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
const fieldToFocus = inListBlock.getField('MODE');
this.getFocusedNodeStub.returns(fieldToFocus);
const topBlock = this.workspace.getBlockById('controls_repeat_1');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [
Blockly.utils.KeyCodes.CTRL,
]),
);
sinon.assert.calledWith(this.focusNodeSpy, topBlock);
});
test('CtrlHome focuses top block in workspace if workspace is focused', function () {
this.getFocusedNodeStub.returns(this.workspace);
const topBlock = this.workspace.getBlockById('controls_repeat_1');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [
Blockly.utils.KeyCodes.CTRL,
]),
);
sinon.assert.calledWith(this.focusNodeSpy, topBlock);
});
test('CtrlEnd focuses last block in workspace if block is focused', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
this.getFocusedNodeStub.returns(inListBlock);
const lastBlock = this.workspace.getBlockById('text_2');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END, [
Blockly.utils.KeyCodes.CTRL,
]),
);
sinon.assert.calledWith(this.focusNodeSpy, lastBlock);
});
test('CtrlEnd focuses last block in workspace if field is focused', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
const fieldToFocus = inListBlock.getField('MODE');
this.getFocusedNodeStub.returns(fieldToFocus);
const lastBlock = this.workspace.getBlockById('text_2');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END, [
Blockly.utils.KeyCodes.CTRL,
]),
);
sinon.assert.calledWith(this.focusNodeSpy, lastBlock);
});
test('CtrlEnd focuses last block in workspace if workspace is focused', function () {
this.getFocusedNodeStub.returns(this.workspace);
const lastBlock = this.workspace.getBlockById('text_2');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END, [
Blockly.utils.KeyCodes.CTRL,
]),
);
sinon.assert.calledWith(this.focusNodeSpy, lastBlock);
});
test('PageUp focuses on first block in stack', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
const fieldToFocus = inListBlock.getField('MODE');
this.getFocusedNodeStub.returns(fieldToFocus);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_UP),
);
const expectedFocus = this.workspace.getBlockById('controls_repeat_1');
sinon.assert.calledWith(this.focusNodeSpy, expectedFocus);
});
test('PageDown focuses on last block in stack with nested row blocks', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
const fieldToFocus = inListBlock.getField('MODE');
this.getFocusedNodeStub.returns(fieldToFocus);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN),
);
const expectedFocus = this.workspace.getBlockById('math_number_2');
sinon.assert.calledWith(this.focusNodeSpy, expectedFocus);
});
test('PageDown focuses on last block in stack with many stack blocks', function () {
const blockToFocus = this.workspace.getBlockById('text_1');
this.getFocusedNodeStub.returns(blockToFocus);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN),
);
const expectedFocus = this.workspace.getBlockById('text_2');
sinon.assert.calledWith(this.focusNodeSpy, expectedFocus);
});
suite('in flyout', function () {
test('Home has no effect', function () {
this.workspace.internalIsFlyout = true;
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
this.getFocusedNodeStub.returns(inListBlock);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.HOME),
);
sinon.assert.notCalled(this.focusNodeSpy);
});
test('End has no effect', function () {
this.workspace.internalIsFlyout = true;
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
this.getFocusedNodeStub.returns(inListBlock);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END),
);
sinon.assert.notCalled(this.focusNodeSpy);
});
test('CtrlHome focuses top block in flyout workspace', function () {
this.workspace.internalIsFlyout = true;
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
this.getFocusedNodeStub.returns(inListBlock);
const topBlock = this.workspace.getBlockById('controls_repeat_1');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [
Blockly.utils.KeyCodes.CTRL,
]),
);
sinon.assert.calledWith(this.focusNodeSpy, topBlock);
});
test('CtrlEnd focuses last block in flyout workspace', function () {
this.workspace.internalIsFlyout = true;
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
this.getFocusedNodeStub.returns(inListBlock);
const lastBlock = this.workspace.getBlockById('text_2');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END, [
Blockly.utils.KeyCodes.CTRL,
]),
);
sinon.assert.calledWith(this.focusNodeSpy, lastBlock);
});
test('PageUp has no effect', function () {
this.workspace.internalIsFlyout = true;
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
this.getFocusedNodeStub.returns(inListBlock);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_UP),
);
sinon.assert.notCalled(this.focusNodeSpy);
});
test('PageDown has no effect', function () {
this.workspace.internalIsFlyout = true;
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
this.getFocusedNodeStub.returns(inListBlock);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN),
);
sinon.assert.notCalled(this.focusNodeSpy);
});
});
});
});