Skip to content
Open
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
161 changes: 113 additions & 48 deletions packages/blockly/core/shortcut_items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ import {type IFocusableNode} from './interfaces/i_focusable_node.js';
import {isSelectable} from './interfaces/i_selectable.js';
import type {IToolbox} from './interfaces/i_toolbox.js';
import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js';
import type {Navigator} from './keyboard_nav/navigators/navigator.js';
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
import {Msg} from './msg.js';
import {RenderedConnection} from './rendered_connection.js';
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import * as Tooltip from './tooltip.js';
import {aria} from './utils.js';
Expand Down Expand Up @@ -1366,26 +1366,82 @@ const shouldDoBlockNavigation = (workspace: WorkspaceSvg, scope: Scope) => {
};

/**
* Registers a keyboard shortcut that sets the focus to the block
* that owns the current focused node.
* Returns the block that Home/End should be scoped to for the given node.
*
* Full-block field blocks look like fields, so the parent block is used.
*/
function getOwningBlock(
navigator: Navigator,
node: IFocusableNode,
): BlockSvg | null {
const block = navigator.getSourceBlockFromNode(node);
if (block?.getFullBlockField() && block.getParent()) {
return block.getParent() as BlockSvg;
}
return block;
}

/**
* Returns whether `node` belongs to `owner` or one of its descendants.
*/
function isNodeOnBlock(
navigator: Navigator,
node: IFocusableNode,
owner: BlockSvg,
): boolean {
if (node === owner) return true;
let block = navigator.getSourceBlockFromNode(node);
while (block) {
if (block === owner) return true;
block = block.getParent() as BlockSvg | null;
}
return false;
}

/**
* Follows `step` from `start` until there is no next node, a cycle is
* detected, or `stay` returns false for the next candidate.
*/
function getLastNodeAlong(
start: IFocusableNode,
step: (node: IFocusableNode) => IFocusableNode | null,
stay?: (candidate: IFocusableNode) => boolean,
): IFocusableNode {
const visited = new Set<IFocusableNode>([start]);
let current = start;
let next: IFocusableNode | null;
while (
(next = step(current)) &&
!visited.has(next) &&
(stay?.(next) ?? true)
) {
visited.add(next);
current = next;
}
return current;
}

/**
* Registers a keyboard shortcut that sets the focus to the first
* focusable node in the current block, typically the owning block.
*/
export function registerJumpBlockStart() {
const jumpBlockStartShortcut: KeyboardShortcut = {
name: names.JUMP_BLOCK_START,
preconditionFn: shouldDoBlockNavigation,
callback(workspace, e, shortcut, scope) {
if (!scope.focusedNode) return false;
let selectedBlock = workspace
.getNavigator()
.getSourceBlockFromNode(scope.focusedNode);
if (selectedBlock?.getFullBlockField() && !!selectedBlock.getParent()) {
// Act on the parent block if the current block is a full-block field block.
// Because full-block field blocks look like fields, so treat them that way.
selectedBlock = selectedBlock.getParent();
}
const navigator = workspace.getNavigator();
const selectedBlock = getOwningBlock(navigator, scope.focusedNode);
if (!selectedBlock) return false;

getFocusManager().focusNode(selectedBlock);
getFocusManager().focusNode(
getLastNodeAlong(
scope.focusedNode,
(node) => navigator.getOutNode(node),
(candidate) => isNodeOnBlock(navigator, candidate, selectedBlock),
),
);
return true;
},
keyCodes: [KeyCodes.HOME],
Expand All @@ -1395,30 +1451,27 @@ export function registerJumpBlockStart() {
}

/**
* Registers a keyboard shortcut that sets the focus to the
* last input of the block that owns the current focused node.
* Registers a keyboard shortcut that sets the focus to the last
* same-row node of the current block, reachable by repeatedly
* navigating in. Does not enter statement inputs.
*/
export function registerJumpBlockEnd() {
const jumpBlockEndShortcut: KeyboardShortcut = {
name: names.JUMP_BLOCK_END,
preconditionFn: shouldDoBlockNavigation,
callback(workspace, e, shortcut, scope) {
if (!scope.focusedNode) return false;
let selectedBlock = workspace
.getNavigator()
.getSourceBlockFromNode(scope.focusedNode);
if (selectedBlock?.getFullBlockField() && !!selectedBlock.getParent()) {
// Act on the parent block if the current block is a full-block field block.
// Because full-block field blocks look like fields, so treat them that way.
selectedBlock = selectedBlock.getParent();
}
const navigator = workspace.getNavigator();
const selectedBlock = getOwningBlock(navigator, scope.focusedNode);
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);

getFocusManager().focusNode(
getLastNodeAlong(
scope.focusedNode,
(node) => navigator.getInNode(node),
(candidate) => isNodeOnBlock(navigator, candidate, selectedBlock),
),
);
return true;
},
keyCodes: [KeyCodes.END],
Expand Down Expand Up @@ -1452,30 +1505,29 @@ export function registerJumpTopStack() {
}

/**
* Registers a keyboard shortcut that sets the focus to the bottom block
* in the current stack.
* Registers a keyboard shortcut that sets the focus to the last node
* in the current stack reachable by repeatedly pressing Down.
*/
export function registerJumpBottomStack() {
const jumpBottomStackShortcut: KeyboardShortcut = {
name: names.JUMP_BOTTOM_STACK,
preconditionFn: shouldDoBlockNavigation,
callback(workspace, e, shortcut, scope) {
if (!scope.focusedNode) return false;
const selectedBlock = workspace
.getNavigator()
.getSourceBlockFromNode(scope.focusedNode);
const navigator = workspace.getNavigator();
const selectedBlock = navigator.getSourceBlockFromNode(scope.focusedNode);
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);
const stackRoot = selectedBlock.getRootBlock();
getFocusManager().focusNode(
getLastNodeAlong(
stackRoot,
(node) => navigator.getNextNode(node),
(candidate) =>
candidate === stackRoot ||
navigator.getSourceBlockFromNode(candidate)?.getRootBlock() ===
stackRoot,
),
);
return true;
},
keyCodes: [KeyCodes.PAGE_DOWN],
Expand Down Expand Up @@ -1561,7 +1613,8 @@ export function registerJumpFirstBlock() {

/**
* Registers a keyboard shortcut that sets the focus to the last
* block in the workspace.
* focusable node on the workspace: last top-level stack, then Down
* to the end of that stack, then In to the end of that row.
*/
export function registerJumpLastBlock() {
const ctrlCmdEnd = ShortcutRegistry.registry.createSerializedKey(
Expand All @@ -1583,9 +1636,21 @@ export function registerJumpLastBlock() {
return true;
}

const allBlocks = workspace.getAllBlocks(true);
if (!allBlocks.length) return false;
getFocusManager().focusNode(allBlocks[allBlocks.length - 1]);
const topBlocks = workspace.getTopBlocks(true);
if (!topBlocks.length) return false;
const navigator = workspace.getNavigator();
const lastTop = topBlocks[topBlocks.length - 1];
const stackEnd = getLastNodeAlong(
lastTop,
(node) => navigator.getNextNode(node),
(candidate) =>
candidate === lastTop ||
navigator.getSourceBlockFromNode(candidate)?.getRootBlock() ===
lastTop,
);
getFocusManager().focusNode(
getLastNodeAlong(stackEnd, (node) => navigator.getInNode(node)),
);
return true;
},
keyCodes: [ctrlCmdEnd],
Expand Down
82 changes: 66 additions & 16 deletions packages/blockly/tests/mocha/shortcut_items_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2219,6 +2219,10 @@ suite('Keyboard Shortcut Items', function () {

suite('Jump shortcuts', function () {
setup(function () {
// jsdom does not provide CSS.escape; connection focus checks use it.
if (typeof globalThis.CSS === 'undefined') {
globalThis.CSS = {escape: (value) => String(value)};
}
Blockly.serialization.workspaces.load(blockJson, this.workspace);
});

Expand Down Expand Up @@ -2264,7 +2268,7 @@ suite('Keyboard Shortcut Items', function () {
assert.equal(Blockly.getFocusManager().getFocusedNode(), inListBlock);
});

test('End focuses last input on owning block', function () {
test('End focuses last same-row node on owning block', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
const fieldToFocus = inListBlock.getField('MODE');
Blockly.getFocusManager().focusNode(fieldToFocus);
Expand All @@ -2273,15 +2277,44 @@ suite('Keyboard Shortcut Items', function () {
);
const expectedFocus = inListBlock.getInput('AT').connection;
assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus);
assert.notEqual(
expectedFocus,
inListBlock.getInput('VALUE')?.connection,
'End should not focus a connected value input connection',
);
});

test('End has no effect if block has no inputs', function () {
test('End on a container block does not focus the statement input', function () {
const repeatBlock = this.workspace.getBlockById('controls_repeat_1');
Blockly.getFocusManager().focusNode(repeatBlock);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END),
);
const expectedFocus = this.workspace.getBlockById('math_number_1');
assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus);
assert.notEqual(expectedFocus, repeatBlock.getInput('DO').connection);
});

test('End has no effect on a container end statement position', function () {
const forEachBlock = this.workspace.getBlockById('controls_forEach_1');
const endStatement = forEachBlock.nextConnection;
Blockly.getFocusManager().focusNode(endStatement);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END),
);
assert.equal(Blockly.getFocusManager().getFocusedNode(), endStatement);
});

test('End focuses the text field on a field-only block', function () {
const textBlock = this.workspace.getBlockById('text_1');
Blockly.getFocusManager().focusNode(textBlock);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END),
);
assert.equal(Blockly.getFocusManager().getFocusedNode(), textBlock);
assert.equal(
Blockly.getFocusManager().getFocusedNode(),
textBlock.getField('TEXT'),
);
});

test('CtrlHome focuses top block in workspace if block is focused', function () {
Expand Down Expand Up @@ -2320,40 +2353,46 @@ suite('Keyboard Shortcut Items', function () {
assert.equal(Blockly.getFocusManager().getFocusedNode(), topBlock);
});

test('CtrlEnd focuses last block in workspace if block is focused', function () {
test('CtrlEnd focuses last focusable node in workspace if block is focused', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
Blockly.getFocusManager().focusNode(inListBlock);
const lastBlock = this.workspace.getBlockById('text_2');
const expectedFocus = this.workspace
.getBlockById('text_2')
.getField('TEXT');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END, [
Blockly.utils.KeyCodes.CTRL_CMD,
]),
);
assert.equal(Blockly.getFocusManager().getFocusedNode(), lastBlock);
assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus);
});

test('CtrlEnd focuses last block in workspace if field is focused', function () {
test('CtrlEnd focuses last focusable node in workspace if field is focused', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
const fieldToFocus = inListBlock.getField('MODE');
Blockly.getFocusManager().focusNode(fieldToFocus);
const lastBlock = this.workspace.getBlockById('text_2');
const expectedFocus = this.workspace
.getBlockById('text_2')
.getField('TEXT');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END, [
Blockly.utils.KeyCodes.CTRL_CMD,
]),
);
assert.equal(Blockly.getFocusManager().getFocusedNode(), lastBlock);
assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus);
});

test('CtrlEnd focuses last block in workspace if workspace is focused', function () {
test('CtrlEnd focuses last focusable node in workspace if workspace is focused', function () {
Blockly.getFocusManager().focusNode(this.workspace);
const lastBlock = this.workspace.getBlockById('text_2');
const expectedFocus = this.workspace
.getBlockById('text_2')
.getField('TEXT');
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.END, [
Blockly.utils.KeyCodes.CTRL_CMD,
]),
);
assert.equal(Blockly.getFocusManager().getFocusedNode(), lastBlock);
assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus);
});

test('PageUp focuses on first block in stack', function () {
Expand All @@ -2367,25 +2406,36 @@ suite('Keyboard Shortcut Items', function () {
assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus);
});

test('PageDown focuses on last block in stack with nested row blocks', function () {
test('PageDown focuses on last down-reachable node in stack with nested row blocks', function () {
const inListBlock = this.workspace.getBlockById('lists_getIndex_1');
const fieldToFocus = inListBlock.getField('MODE');
Blockly.getFocusManager().focusNode(fieldToFocus);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN),
);
const expectedFocus = this.workspace.getBlockById('math_number_2');
const expectedFocus =
this.workspace.getBlockById('controls_forEach_1').nextConnection;
assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus);
assert.notEqual(
expectedFocus,
this.workspace.getBlockById('math_number_2'),
'Page Down should not walk right into inline value inputs',
);
});

test('PageDown focuses on last block in stack with many stack blocks', function () {
test('PageDown focuses on last down-reachable node in stack with many stack blocks', function () {
const blockToFocus = this.workspace.getBlockById('text_1');
Blockly.getFocusManager().focusNode(blockToFocus);
this.injectionDiv.dispatchEvent(
createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN),
);
const expectedFocus = this.workspace.getBlockById('text_2');
const expectedFocus = this.workspace.getBlockById('text_print_2');
assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus);
assert.notEqual(
expectedFocus,
this.workspace.getBlockById('text_2'),
'Page Down should not walk right into inline value inputs',
);
});

suite('in flyout', function () {
Expand Down
Loading