diff --git a/packages/blockly/core/keyboard_nav/navigators/navigator.ts b/packages/blockly/core/keyboard_nav/navigators/navigator.ts index 785f5e46075..a5def148641 100644 --- a/packages/blockly/core/keyboard_nav/navigators/navigator.ts +++ b/packages/blockly/core/keyboard_nav/navigators/navigator.ts @@ -167,10 +167,10 @@ export class Navigator { if (!previous || (previous as any) === node.getFocusableTree()) { const stackRoot = this.navigateStacks(node, -1); if (!stackRoot) return null; - previous = this.getLastNodeInStack(stackRoot, node); + previous = this.walkToLastNodeInStack(stackRoot, node); } - return this.getLeftmostSibling(previous); + return this.getFirstNodeInRow(previous); } /** @@ -385,23 +385,49 @@ export class Navigator { } /** - * Returns the leftmost node in the same row as the given node. + * Walks from `start` by repeatedly applying `step` until `stay` rejects the + * next candidate, a cycle is detected, or there is no next node. * - * @param node The node to find the leftmost sibling of. - * @returns The leftmost sibling of the given node in the same row. + * @param start The node to begin walking from. + * @param step Returns the next candidate from the current node. + * @param stay If provided, walking stops before a candidate that fails this + * check. + * @returns The last accepted node in the walk. */ - private getLeftmostSibling(node: IFocusableNode | null) { - if (!node) return null; - - let left = node; - let temp; + private walkAlong( + start: IFocusableNode, + step: (node: IFocusableNode) => IFocusableNode | null, + stay?: (candidate: IFocusableNode) => boolean, + ): IFocusableNode { + const visited = new Set(); + let current = start; + let next: IFocusableNode | null; while ( - (temp = this.getPreviousNodeImpl(left, left, NavigationDirection.OUT)) + (next = step(current)) && + !visited.has(next) && + (stay?.(next) ?? true) ) { - left = temp; + visited.add(current); + current = next; } + return current; + } - return left; + /** + * Returns the first node in the same row as the given node, i.e. the node + * reached by repeatedly navigating out (left in LTR). + * + * @param node The node to find the first in-row peer of. + * @returns The first node in the same row as the given node, or null if none + * was provided. + */ + private getFirstNodeInRow( + node: IFocusableNode | null, + ): IFocusableNode | null { + if (!node) return null; + return this.walkAlong(node, (current) => + this.getPreviousNodeImpl(current, current, NavigationDirection.OUT), + ); } /** @@ -413,20 +439,16 @@ export class Navigator { * encountered; typically the root node of the next stack. * @returns The last node in the given stack. */ - private getLastNodeInStack( + private walkToLastNodeInStack( stackRoot: IFocusableNode, - stopIfFound: IFocusableNode, + stopIfFound?: IFocusableNode, ) { - let target = stackRoot; - let temp; - while ( - (temp = this.getNextNodeImpl(target, target, NavigationDirection.NEXT)) && - temp !== stopIfFound - ) { - target = temp; - } - - return target; + return this.walkAlong( + stackRoot, + (current) => + this.getNextNodeImpl(current, current, NavigationDirection.NEXT), + (candidate) => candidate !== stopIfFound, + ); } private getRowId(node: IFocusableNode) { diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index 8c7609317ff..7c6ed01651a 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -30,9 +30,9 @@ import {type IFlyout} from './interfaces/i_flyout.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 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'; @@ -1354,8 +1354,154 @@ const shouldDoBlockNavigation = (workspace: WorkspaceSvg, scope: Scope) => { }; /** - * Registers a keyboard shortcut that sets the focus to the block - * that owns the current focused node. + * Moves focus to `dest` if it differs from the currently focused node. + * Always prevents the browser default (e.g. scrolling on Home/End). + * + * @param e The keyboard event that triggered the shortcut. + * @param current The currently focused node, if any. + * @param dest The node to focus, if any. + * @returns True if focus moved, otherwise false. + */ +function jumpFocus( + e: Event, + current: IFocusableNode | undefined, + dest: IFocusableNode | null | undefined, +): boolean { + e.preventDefault(); + if (!dest || dest === current) return false; + getFocusManager().focusNode(dest); + return true; +} + +/** + * Walks from `start` by repeatedly applying `step` until `stay` rejects the + * next candidate, a cycle is detected, or there is no next node. + */ +function walkFocusableNodes( + start: IFocusableNode, + step: (node: IFocusableNode) => IFocusableNode | null, + stay: (candidate: IFocusableNode) => boolean = () => true, +): IFocusableNode { + const visited = new Set(); + let current = start; + let next: IFocusableNode | null; + while ((next = step(current)) && !visited.has(next) && stay(next)) { + visited.add(current); + current = next; + } + return current; +} + +/** + * 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 the given node belongs to `owner` or one of its + * descendants (including nested value blocks). + */ +function isUnderOwningBlock( + 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; +} + +/** + * Returns whether `node` is in the same top-level stack as `stackRoot`. + */ +function isInSameStack( + navigator: Navigator, + node: IFocusableNode, + stackRoot: IFocusableNode, +): boolean { + if (node === stackRoot) return true; + return navigator.getSourceBlockFromNode(node)?.getRootBlock() === stackRoot; +} + +/** + * First focusable node in the current block reachable by repeatedly + * navigating out, without leaving that block. + */ +function getFirstNodeInBlock( + navigator: Navigator, + node: IFocusableNode, +): IFocusableNode { + const owner = getOwningBlock(navigator, node); + if (!owner) return node; + return walkFocusableNodes( + node, + (current) => navigator.getOutNode(current), + (candidate) => isUnderOwningBlock(navigator, candidate, owner), + ); +} + +/** + * Last focusable node on the current block's row reachable by repeatedly + * navigating in, without leaving that block. + */ +function getLastNodeInBlock( + navigator: Navigator, + node: IFocusableNode, +): IFocusableNode { + const owner = getOwningBlock(navigator, node); + if (!owner) return node; + return walkFocusableNodes( + node, + (current) => navigator.getInNode(current), + (candidate) => isUnderOwningBlock(navigator, candidate, owner), + ); +} + +/** + * Last node in the current stack reachable by repeatedly navigating down. + */ +function getLastNodeInStack( + navigator: Navigator, + node: IFocusableNode, +): IFocusableNode { + const root = navigator.getSourceBlockFromNode(node)?.getRootBlock() ?? node; + return walkFocusableNodes( + root, + (current) => navigator.getNextNode(current), + (candidate) => isInSameStack(navigator, candidate, root), + ); +} + +/** + * Last 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. + */ +function getLastFocusableNode(navigator: Navigator): IFocusableNode | null { + const lastTop = navigator.getLastNode(); + if (!lastTop) return null; + return walkFocusableNodes(getLastNodeInStack(navigator, lastTop), (current) => + navigator.getInNode(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 = { @@ -1363,18 +1509,11 @@ 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(); - } - if (!selectedBlock) return false; - - getFocusManager().focusNode(selectedBlock); - return true; + return jumpFocus( + e, + scope.focusedNode, + getFirstNodeInBlock(workspace.getNavigator(), scope.focusedNode), + ); }, keyCodes: [KeyCodes.HOME], displayText: () => Msg['SHORTCUTS_JUMP_BLOCK_START'], @@ -1383,8 +1522,8 @@ 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 + * focusable node on the current block's row. */ export function registerJumpBlockEnd() { const jumpBlockEndShortcut: KeyboardShortcut = { @@ -1392,22 +1531,11 @@ 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(); - } - 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; + return jumpFocus( + e, + scope.focusedNode, + getLastNodeInBlock(workspace.getNavigator(), scope.focusedNode), + ); }, keyCodes: [KeyCodes.END], displayText: () => Msg['SHORTCUTS_JUMP_BLOCK_END'], @@ -1429,9 +1557,7 @@ export function registerJumpTopStack() { .getNavigator() .getSourceBlockFromNode(scope.focusedNode); if (!selectedBlock) return false; - const topOfStack = selectedBlock.getRootBlock(); - getFocusManager().focusNode(topOfStack); - return true; + return jumpFocus(e, scope.focusedNode, selectedBlock.getRootBlock()); }, keyCodes: [KeyCodes.PAGE_UP], displayText: () => Msg['SHORTCUTS_JUMP_TOP_STACK'], @@ -1440,8 +1566,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 = { @@ -1449,22 +1575,11 @@ export function registerJumpBottomStack() { preconditionFn: shouldDoBlockNavigation, callback(workspace, e, shortcut, scope) { if (!scope.focusedNode) return false; - const selectedBlock = workspace - .getNavigator() - .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); - return true; + return jumpFocus( + e, + scope.focusedNode, + getLastNodeInStack(workspace.getNavigator(), scope.focusedNode), + ); }, keyCodes: [KeyCodes.PAGE_DOWN], displayText: () => Msg['SHORTCUTS_JUMP_BOTTOM_STACK'], @@ -1488,11 +1603,10 @@ export function registerJumpFirstBlock() { !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() ); }, - callback(workspace) { + callback(workspace, e, shortcut, scope) { const topBlocks = workspace.getTopBlocks(true); if (!topBlocks.length) return false; - getFocusManager().focusNode(topBlocks[0]); - return true; + return jumpFocus(e, scope.focusedNode, topBlocks[0]); }, keyCodes: [ctrlCmdHome], displayText: () => Msg['SHORTCUTS_JUMP_FIRST_BLOCK'], @@ -1502,7 +1616,7 @@ export function registerJumpFirstBlock() { /** * Registers a keyboard shortcut that sets the focus to the last - * block in the workspace. + * focusable node on the workspace. */ export function registerJumpLastBlock() { const ctrlCmdEnd = ShortcutRegistry.registry.createSerializedKey( @@ -1516,11 +1630,12 @@ export function registerJumpLastBlock() { !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() ); }, - callback(workspace) { - const allBlocks = workspace.getAllBlocks(true); - if (!allBlocks.length) return false; - getFocusManager().focusNode(allBlocks[allBlocks.length - 1]); - return true; + callback(workspace, e, shortcut, scope) { + return jumpFocus( + e, + scope.focusedNode, + getLastFocusableNode(workspace.getNavigator()), + ); }, keyCodes: [ctrlCmdEnd], displayText: () => Msg['SHORTCUTS_JUMP_LAST_BLOCK'], diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index d75141acb9e..b72aa75d867 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -2183,11 +2183,6 @@ suite('Keyboard Shortcut Items', function () { 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); }); @@ -2208,209 +2203,256 @@ suite('Keyboard Shortcut Items', function () { } }); - test('Home focuses current block if block is focused', function () { + test('Home has no effect if the owning block is already focused', function () { const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); - this.getFocusedNodeStub.returns(inListBlock); + Blockly.getFocusManager().focusNode(inListBlock); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.HOME), ); - sinon.assert.calledWith(this.focusNodeSpy, inListBlock); + assert.equal(Blockly.getFocusManager().getFocusedNode(), inListBlock); }); - test('Home focuses owning block if field is focused', function () { + test('Home from a middle field focuses the owning block', function () { const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); - const fieldToFocus = inListBlock.getField('MODE'); - this.getFocusedNodeStub.returns(fieldToFocus); + const fieldToFocus = inListBlock.getField('WHERE'); + Blockly.getFocusManager().focusNode(fieldToFocus); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.HOME), ); - sinon.assert.calledWith(this.focusNodeSpy, inListBlock); + 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'); - this.getFocusedNodeStub.returns(fieldToFocus); + Blockly.getFocusManager().focusNode(fieldToFocus); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.END), ); const expectedFocus = inListBlock.getInput('AT').connection; - sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus); + assert.notEqual( + expectedFocus, + inListBlock.getInput('VALUE')?.connection, + 'End should not focus a connected value input connection', + ); + }); + + 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); + assert.notEqual(expectedFocus, repeatBlock); + }); + + 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 has no effect if block has no inputs', function () { - const textBlock = this.workspace.getBlockById('text_1'); - this.getFocusedNodeStub.returns(textBlock); + test('End has no effect if already at the last in-block node', function () { + const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); + const last = inListBlock.getInput('AT').connection; + Blockly.getFocusManager().focusNode(last); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.END), ); - sinon.assert.notCalled(this.focusNodeSpy); + assert.equal(Blockly.getFocusManager().getFocusedNode(), last); }); test('CtrlHome focuses top block in workspace if block is focused', function () { const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); - this.getFocusedNodeStub.returns(inListBlock); + Blockly.getFocusManager().focusNode(inListBlock); const topBlock = this.workspace.getBlockById('controls_repeat_1'); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ Blockly.utils.KeyCodes.CTRL_CMD, ]), ); - sinon.assert.calledWith(this.focusNodeSpy, topBlock); + assert.equal(Blockly.getFocusManager().getFocusedNode(), 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); + Blockly.getFocusManager().focusNode(fieldToFocus); const topBlock = this.workspace.getBlockById('controls_repeat_1'); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ Blockly.utils.KeyCodes.CTRL_CMD, ]), ); - sinon.assert.calledWith(this.focusNodeSpy, topBlock); + assert.equal(Blockly.getFocusManager().getFocusedNode(), topBlock); }); test('CtrlHome focuses top block in workspace if workspace is focused', function () { - this.getFocusedNodeStub.returns(this.workspace); + Blockly.getFocusManager().focusNode(this.workspace); const topBlock = this.workspace.getBlockById('controls_repeat_1'); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ Blockly.utils.KeyCodes.CTRL_CMD, ]), ); - sinon.assert.calledWith(this.focusNodeSpy, topBlock); + 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'); - this.getFocusedNodeStub.returns(inListBlock); - const lastBlock = this.workspace.getBlockById('text_2'); + Blockly.getFocusManager().focusNode(inListBlock); + const expectedFocus = this.workspace + .getBlockById('text_2') + .getField('TEXT'); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.END, [ Blockly.utils.KeyCodes.CTRL_CMD, ]), ); - sinon.assert.calledWith(this.focusNodeSpy, 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'); - this.getFocusedNodeStub.returns(fieldToFocus); - const lastBlock = this.workspace.getBlockById('text_2'); + Blockly.getFocusManager().focusNode(fieldToFocus); + const expectedFocus = this.workspace + .getBlockById('text_2') + .getField('TEXT'); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.END, [ Blockly.utils.KeyCodes.CTRL_CMD, ]), ); - sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus); }); - test('CtrlEnd focuses last block in workspace if workspace is focused', function () { - this.getFocusedNodeStub.returns(this.workspace); - const lastBlock = this.workspace.getBlockById('text_2'); + test('CtrlEnd focuses last focusable node in workspace if workspace is focused', function () { + Blockly.getFocusManager().focusNode(this.workspace); + const expectedFocus = this.workspace + .getBlockById('text_2') + .getField('TEXT'); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.END, [ Blockly.utils.KeyCodes.CTRL_CMD, ]), ); - sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus); }); 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); + Blockly.getFocusManager().focusNode(fieldToFocus); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_UP), ); const expectedFocus = this.workspace.getBlockById('controls_repeat_1'); - sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + 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'); - this.getFocusedNodeStub.returns(fieldToFocus); + Blockly.getFocusManager().focusNode(fieldToFocus); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN), ); - const expectedFocus = this.workspace.getBlockById('math_number_2'); - sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + 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'); - this.getFocusedNodeStub.returns(blockToFocus); + Blockly.getFocusManager().focusNode(blockToFocus); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN), ); - const expectedFocus = this.workspace.getBlockById('text_2'); - sinon.assert.calledWith(this.focusNodeSpy, expectedFocus); + 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 () { test('Home has no effect', function () { this.workspace.internalIsFlyout = true; const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); - this.getFocusedNodeStub.returns(inListBlock); + Blockly.getFocusManager().focusNode(inListBlock); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.HOME), ); - sinon.assert.notCalled(this.focusNodeSpy); + assert.equal(Blockly.getFocusManager().getFocusedNode(), inListBlock); }); test('End has no effect', function () { this.workspace.internalIsFlyout = true; const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); - this.getFocusedNodeStub.returns(inListBlock); + Blockly.getFocusManager().focusNode(inListBlock); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.END), ); - sinon.assert.notCalled(this.focusNodeSpy); + assert.equal(Blockly.getFocusManager().getFocusedNode(), inListBlock); }); 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); + Blockly.getFocusManager().focusNode(inListBlock); const topBlock = this.workspace.getBlockById('controls_repeat_1'); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.HOME, [ Blockly.utils.KeyCodes.CTRL_CMD, ]), ); - sinon.assert.calledWith(this.focusNodeSpy, topBlock); + assert.equal(Blockly.getFocusManager().getFocusedNode(), topBlock); }); - test('CtrlEnd focuses last block in flyout workspace', function () { + test('CtrlEnd focuses last focusable node 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'); + Blockly.getFocusManager().focusNode(inListBlock); + const expectedFocus = this.workspace + .getBlockById('text_2') + .getField('TEXT'); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.END, [ Blockly.utils.KeyCodes.CTRL_CMD, ]), ); - sinon.assert.calledWith(this.focusNodeSpy, lastBlock); + assert.equal(Blockly.getFocusManager().getFocusedNode(), expectedFocus); }); test('PageUp has no effect', function () { this.workspace.internalIsFlyout = true; const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); - this.getFocusedNodeStub.returns(inListBlock); + Blockly.getFocusManager().focusNode(inListBlock); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_UP), ); - sinon.assert.notCalled(this.focusNodeSpy); + assert.equal(Blockly.getFocusManager().getFocusedNode(), inListBlock); }); test('PageDown has no effect', function () { this.workspace.internalIsFlyout = true; const inListBlock = this.workspace.getBlockById('lists_getIndex_1'); - this.getFocusedNodeStub.returns(inListBlock); + Blockly.getFocusManager().focusNode(inListBlock); this.injectionDiv.dispatchEvent( createKeyDownEvent(Blockly.utils.KeyCodes.PAGE_DOWN), ); - sinon.assert.notCalled(this.focusNodeSpy); + assert.equal(Blockly.getFocusManager().getFocusedNode(), inListBlock); }); }); }); diff --git a/packages/docs/docs/guides/configure/keyboard-nav.mdx b/packages/docs/docs/guides/configure/keyboard-nav.mdx index 69ccd112c1d..1c45d2c5dba 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 | We recommend that you enable these for your application by calling: