diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 499c6b5fd..a1504ce94 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -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(); diff --git a/tests/mocha/shortcut_items_test.js b/tests/mocha/shortcut_items_test.js index dfbae3f09..7cc99a80d 100644 --- a/tests/mocha/shortcut_items_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -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); + }); + }); + }); });