diff --git a/src/actions/enter.ts b/src/actions/enter.ts new file mode 100644 index 00000000..f0b6aa14 --- /dev/null +++ b/src/actions/enter.ts @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ASTNode, ShortcutRegistry, utils as BlocklyUtils} from 'blockly/core'; + +import type { + Block, + BlockSvg, + Field, + FlyoutButton, + WorkspaceSvg, +} from 'blockly/core'; + +import * as Constants from '../constants'; +import type {Navigation} from '../navigation'; + +const KeyCodes = BlocklyUtils.KeyCodes; + +/** + * Class for registering a shortcut for the enter action. + */ +export class EnterAction { + constructor( + private navigation: Navigation, + private canCurrentlyEdit: (ws: WorkspaceSvg) => boolean, + ) {} + + /** + * Adds the enter action shortcut to the registry. + */ + install() { + /** + * Enter key: + * + * - On the flyout: press a button or choose a block to place. + * - On a stack: open a block's context menu or field's editor. + * - On the workspace: open the context menu. + */ + ShortcutRegistry.registry.register({ + name: Constants.SHORTCUT_NAMES.EDIT_OR_CONFIRM, + preconditionFn: (workspace) => this.canCurrentlyEdit(workspace), + callback: (workspace, event) => { + event.preventDefault(); + + let flyoutCursor; + let curNode; + let nodeType; + + switch (this.navigation.getState(workspace)) { + case Constants.STATE.WORKSPACE: + this.handleEnterForWS(workspace); + return true; + case Constants.STATE.FLYOUT: + flyoutCursor = this.navigation.getFlyoutCursor(workspace); + if (!flyoutCursor) { + return false; + } + curNode = flyoutCursor.getCurNode(); + nodeType = curNode.getType(); + + switch (nodeType) { + case ASTNode.types.STACK: + this.insertFromFlyout(workspace); + break; + case ASTNode.types.BUTTON: + this.triggerButtonCallback(workspace); + break; + } + + return true; + default: + return false; + } + }, + keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE], + }); + } + + /** + * Handles hitting the enter key on the workspace. + * + * @param workspace The workspace. + */ + private handleEnterForWS(workspace: WorkspaceSvg) { + const cursor = workspace.getCursor(); + if (!cursor) return; + const curNode = cursor.getCurNode(); + const nodeType = curNode.getType(); + if (nodeType === ASTNode.types.FIELD) { + (curNode.getLocation() as Field).showEditor(); + } else if (nodeType === ASTNode.types.BLOCK) { + const block = curNode.getLocation() as Block; + if (!this.tryShowFullBlockFieldEditor(block)) { + const metaKey = navigator.platform.startsWith('Mac') ? 'Cmd' : 'Ctrl'; + const canMoveInHint = `Press right arrow to move in or ${metaKey} + Enter for more options`; + const genericHint = `Press ${metaKey} + Enter for options`; + const hint = + curNode.in()?.getSourceBlock() === block + ? canMoveInHint + : genericHint; + alert(hint); + } + } else if (curNode.isConnection() || nodeType === ASTNode.types.WORKSPACE) { + this.navigation.openToolboxOrFlyout(workspace); + } else if (nodeType === ASTNode.types.STACK) { + console.warn('Cannot mark a stack.'); + } + } + + /** + * Inserts a block from the flyout. + * Tries to find a connection on the block to connect to the marked + * location. If no connection has been marked, or there is not a compatible + * connection then the block is placed on the workspace. + * + * @param workspace The main workspace. The workspace + * the block will be placed on. + */ + private insertFromFlyout(workspace: WorkspaceSvg) { + const stationaryNode = workspace.getCursor()?.getCurNode(); + const newBlock = this.createNewBlock(workspace); + if (!newBlock) return; + if (stationaryNode) { + if ( + !this.navigation.tryToConnectNodes( + workspace, + stationaryNode, + ASTNode.createBlockNode(newBlock)!, + ) + ) { + console.warn( + 'Something went wrong while inserting a block from the flyout.', + ); + } + } + + this.navigation.focusWorkspace(workspace); + workspace.getCursor()!.setCurNode(ASTNode.createBlockNode(newBlock)!); + this.navigation.removeMark(workspace); + } + + /** + * Triggers a flyout button's callback. + * + * @param workspace The main workspace. The workspace + * containing a flyout with a button. + */ + private triggerButtonCallback(workspace: WorkspaceSvg) { + const button = this.navigation + .getFlyoutCursor(workspace)! + .getCurNode() + .getLocation() as FlyoutButton; + const buttonCallback = (workspace as any).flyoutButtonCallbacks.get( + (button as any).callbackKey, + ); + if (typeof buttonCallback === 'function') { + buttonCallback(button); + } else if (!button.isLabel()) { + throw new Error('No callback function found for flyout button.'); + } + } + + /** + * If this block has a full block field then show its editor. + * + * @param block A block. + * @returns True if we showed the editor, false otherwise. + */ + private tryShowFullBlockFieldEditor(block: Block): boolean { + if (block.isSimpleReporter()) { + for (const input of block.inputList) { + for (const field of input.fieldRow) { + // @ts-expect-error isFullBlockField is a protected method. + if (field.isClickable() && field.isFullBlockField()) { + field.showEditor(); + return true; + } + } + } + } + return false; + } + + /** + * Creates a new block based on the current block the flyout cursor is on. + * + * @param workspace The main workspace. The workspace + * the block will be placed on. + * @returns The newly created block. + */ + private createNewBlock(workspace: WorkspaceSvg): BlockSvg | null { + const flyout = workspace.getFlyout(); + if (!flyout || !flyout.isVisible()) { + console.warn( + 'Trying to insert from the flyout when the flyout does not ' + + ' exist or is not visible', + ); + return null; + } + + const curBlock = this.navigation + .getFlyoutCursor(workspace)! + .getCurNode() + .getLocation() as BlockSvg; + if (!curBlock.isEnabled()) { + console.warn("Can't insert a disabled block."); + return null; + } + + const newBlock = flyout.createBlock(curBlock); + // Render to get the sizing right. + newBlock.render(); + // Connections are not tracked when the block is first created. Normally + // there's enough time for them to become tracked in the user's mouse + // movements, but not here. + newBlock.setConnectionTracking(true); + return newBlock; + } + + /** + * Removes the enter action shortcut. + */ + uninstall() { + ShortcutRegistry.registry.unregister( + Constants.SHORTCUT_NAMES.EDIT_OR_CONFIRM, + ); + } +} diff --git a/src/navigation.ts b/src/navigation.ts index e8b14f05..73101dc4 100644 --- a/src/navigation.ts +++ b/src/navigation.ts @@ -573,74 +573,6 @@ export class Navigation { return cursor as FlyoutCursor; } - /** - * Inserts a block from the flyout. - * Tries to find a connection on the block to connect to the marked - * location. If no connection has been marked, or there is not a compatible - * connection then the block is placed on the workspace. - * - * @param workspace The main workspace. The workspace - * the block will be placed on. - */ - insertFromFlyout(workspace: Blockly.WorkspaceSvg) { - const newBlock = this.createNewBlock(workspace); - if (!newBlock) return; - if (this.markedNode) { - if ( - !this.tryToConnectNodes( - workspace, - this.markedNode, - Blockly.ASTNode.createBlockNode(newBlock)!, - ) - ) { - this.warn( - 'Something went wrong while inserting a block from the flyout.', - ); - } - } - - this.focusWorkspace(workspace); - workspace - .getCursor()! - .setCurNode(Blockly.ASTNode.createBlockNode(newBlock)!); - this.removeMark(workspace); - } - - /** - * Creates a new block based on the current block the flyout cursor is on. - * - * @param workspace The main workspace. The workspace - * the block will be placed on. - * @returns The newly created block. - */ - createNewBlock(workspace: Blockly.WorkspaceSvg): Blockly.BlockSvg | null { - const flyout = workspace.getFlyout(); - if (!flyout || !flyout.isVisible()) { - this.warn( - 'Trying to insert from the flyout when the flyout does not ' + - ' exist or is not visible', - ); - return null; - } - - const curBlock = this.getFlyoutCursor(workspace)! - .getCurNode() - .getLocation() as Blockly.BlockSvg; - if (!curBlock.isEnabled()) { - this.warn("Can't insert a disabled block."); - return null; - } - - const newBlock = flyout.createBlock(curBlock); - // Render to get the sizing right. - newBlock.render(); - // Connections are not tracked when the block is first created. Normally - // there's enough time for them to become tracked in the user's mouse - // movements, but not here. - newBlock.setConnectionTracking(true); - return newBlock; - } - /** * Hides the flyout cursor and optionally hides the flyout. * @@ -1143,40 +1075,6 @@ export class Navigation { console.error(msg); } - /** - * Handles hitting the enter key on the workspace. - * - * @param workspace The workspace. - */ - handleEnterForWS(workspace: Blockly.WorkspaceSvg) { - const cursor = workspace.getCursor(); - if (!cursor) return; - const curNode = cursor.getCurNode(); - const nodeType = curNode.getType(); - if (nodeType === Blockly.ASTNode.types.FIELD) { - (curNode.getLocation() as Blockly.Field).showEditor(); - } else if (nodeType === Blockly.ASTNode.types.BLOCK) { - const block = curNode.getLocation() as Blockly.Block; - if (!tryShowFullBlockFieldEditor(block)) { - const metaKey = navigator.platform.startsWith('Mac') ? 'Cmd' : 'Ctrl'; - const canMoveInHint = `Press right arrow to move in or ${metaKey} + Enter for more options`; - const genericHint = `Press ${metaKey} + Enter for options`; - const hint = - curNode.in()?.getSourceBlock() === block - ? canMoveInHint - : genericHint; - alert(hint); - } - } else if ( - curNode.isConnection() || - nodeType === Blockly.ASTNode.types.WORKSPACE - ) { - this.openToolboxOrFlyout(workspace); - } else if (nodeType === Blockly.ASTNode.types.STACK) { - this.warn('Cannot mark a stack.'); - } - } - /** * Save the current cursor location and open the toolbox or flyout * to select and insert a block. @@ -1299,23 +1197,35 @@ export class Navigation { } /** - * Triggers a flyout button's callback. + * Pastes the copied block to the marked location if possible or + * onto the workspace otherwise. * - * @param workspace The main workspace. The workspace - * containing a flyout with a button. + * @param copyData The data to paste into the workspace. + * @param workspace The workspace to paste the data into. + * @returns True if the paste was sucessful, false otherwise. */ - triggerButtonCallback(workspace: Blockly.WorkspaceSvg) { - const button = this.getFlyoutCursor(workspace)! - .getCurNode() - .getLocation() as Blockly.FlyoutButton; - const buttonCallback = (workspace as any).flyoutButtonCallbacks.get( - (button as any).callbackKey, - ); - if (typeof buttonCallback === 'function') { - buttonCallback(button); - } else if (!button.isLabel()) { - throw new Error('No callback function found for flyout button.'); + paste(copyData: Blockly.ICopyData, workspace: Blockly.WorkspaceSvg): boolean { + // Do this before clipoard.paste due to cursor/focus workaround in getCurNode. + const targetNode = workspace.getCursor()?.getCurNode(); + + Blockly.Events.setGroup(true); + const block = Blockly.clipboard.paste( + copyData, + workspace, + ) as Blockly.BlockSvg; + if (block) { + if (targetNode) { + this.tryToConnectNodes( + workspace, + targetNode, + Blockly.ASTNode.createBlockNode(block)!, + ); + } + this.removeMark(workspace); + return true; } + Blockly.Events.setGroup(false); + return false; } /** @@ -1429,24 +1339,3 @@ function fakeEventForConnectionNode(node: Blockly.ASTNode): PointerEvent { clientY: connectionScreenCoords.y + 5, }); } - -/** - * If this block has a full block field then show its editor. - * - * @param block A block. - * @returns True if we showed the editor, false otherwise. - */ -function tryShowFullBlockFieldEditor(block: Blockly.Block): boolean { - if (block.isSimpleReporter()) { - for (const input of block.inputList) { - for (const field of input.fieldRow) { - // @ts-expect-error isFullBlockField is a protected method. - if (field.isClickable() && field.isFullBlockField()) { - field.showEditor(); - return true; - } - } - } - } - return false; -} diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index c09e4021..9ab08380 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -31,6 +31,7 @@ import {DeleteAction} from './actions/delete'; import {InsertAction} from './actions/insert'; import {Clipboard} from './actions/clipboard'; import {WorkspaceMovement} from './actions/ws_movement'; +import {EnterAction} from './actions/enter'; import {DisconnectAction} from './actions/disconnect'; const KeyCodes = BlocklyUtils.KeyCodes; @@ -83,6 +84,12 @@ export class NavigationController { this.canCurrentlyEdit.bind(this), ); + enterAction: EnterAction = new EnterAction( + this.navigation, + this.canCurrentlyEdit.bind(this), + ); + + hasNavigationFocus: boolean = false; navigationFocus: NAVIGATION_FOCUS_MODE = NAVIGATION_FOCUS_MODE.NONE; /** @@ -439,52 +446,6 @@ export class NavigationController { keyCodes: [KeyCodes.RIGHT], }, - /** - * Enter key: - * - * - On the flyout: press a button or choose a block to place. - * - On a stack: open a block's context menu or field's editor. - * - On the workspace: open the context menu. - */ - enter: { - name: Constants.SHORTCUT_NAMES.EDIT_OR_CONFIRM, - preconditionFn: (workspace) => this.canCurrentlyEdit(workspace), - callback: (workspace, event) => { - event.preventDefault(); - - let flyoutCursor; - let curNode; - let nodeType; - - switch (this.navigation.getState(workspace)) { - case Constants.STATE.WORKSPACE: - this.navigation.handleEnterForWS(workspace); - return true; - case Constants.STATE.FLYOUT: - flyoutCursor = this.navigation.getFlyoutCursor(workspace); - if (!flyoutCursor) { - return false; - } - curNode = flyoutCursor.getCurNode(); - nodeType = curNode.getType(); - - switch (nodeType) { - case ASTNode.types.STACK: - this.navigation.insertFromFlyout(workspace); - break; - case ASTNode.types.BUTTON: - this.navigation.triggerButtonCallback(workspace); - break; - } - - return true; - default: - return false; - } - }, - keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE], - }, - /** * Cmd/Ctrl/Alt+Enter key: * @@ -694,6 +655,7 @@ export class NavigationController { this.deleteAction.install(); this.insertAction.install(); this.workspaceMovement.install(); + this.enterAction.install(); this.disconnectAction.install(); this.clipboard.install(); @@ -718,6 +680,7 @@ export class NavigationController { this.disconnectAction.uninstall(); this.clipboard.uninstall(); this.workspaceMovement.uninstall(); + this.enterAction.uninstall(); this.shortcutDialog.uninstall(); this.removeShortcutHandlers();