diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 8d59c361375..2d94f8adab1 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -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'; @@ -1366,8 +1366,64 @@ 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([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 = { @@ -1375,17 +1431,17 @@ export function registerJumpBlockStart() { 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], @@ -1395,8 +1451,9 @@ 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 = { @@ -1404,21 +1461,17 @@ export function registerJumpBlockEnd() { 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], @@ -1452,8 +1505,8 @@ 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 = { @@ -1461,21 +1514,20 @@ export function registerJumpBottomStack() { 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], @@ -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( @@ -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], diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 1b5d5d9ed8d..f54a34c011b 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -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); }); @@ -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); @@ -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 () { @@ -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 () { @@ -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 () { diff --git a/packages/docs/docs/guides/configure/keyboard-nav.mdx b/packages/docs/docs/guides/configure/keyboard-nav.mdx index aa0d17ab57c..37329cadc87 100644 --- a/packages/docs/docs/guides/configure/keyboard-nav.mdx +++ b/packages/docs/docs/guides/configure/keyboard-nav.mdx @@ -88,12 +88,12 @@ We also provide optional navigation shortcuts to make navigating the workspace e | Key | Action | | --- | --- | -| Home | Jump to block start | -| End | Jump to block end | -| Page Up | Jump to top of stack | -| Page Down | Jump to bottom of stack | -| Ctrl/Cmd + Home | Jump to first block | -| Ctrl/Cmd + End | Jump to last block | +| Home | Jump to the start of the current block (the block itself) | +| End | Jump to the last focusable position on the current block's row | +| Page Up | Jump to the top of the current stack | +| Page Down | Jump to the last block reachable by Down in the current stack | +| Ctrl/Cmd + Home | Jump to the first block on the workspace | +| Ctrl/Cmd + End | Jump to the last focusable position on the workspace | | Ctrl/Cmd + Arrow | Scroll the workspace or flyout | When focus is in the toolbox or the flyout, the same keys move between the