UNPKG

@blockly/keyboard-navigation

Version:
855 lines (797 loc) 27.1 kB
/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Holds all methods necessary to use Blockly through the * keyboard. * @author aschmiedt@google.com (Abby Schmiedt) */ import * as Blockly from 'blockly/core'; import * as Constants from './constants'; import { registrationName as cursorRegistrationName, registrationType as cursorRegistrationType, } from './flyout_cursor'; /** * Class that holds all methods necessary for keyboard navigation to work. */ export class Navigation { /** * Wrapper for method that deals with workspace changes. * Used for removing change listener. */ protected wsChangeWrapper: (e: Blockly.Events.Abstract) => void; /** * Wrapper for method that deals with flyout changes. * Used for removing change listener. */ protected flyoutChangeWrapper: (e: Blockly.Events.Abstract) => void; /** * The list of registered workspaces. * Used when removing change listeners in dispose. */ protected workspaces: Blockly.WorkspaceSvg[] = []; /** * Constructor for keyboard navigation. */ constructor() { this.wsChangeWrapper = this.workspaceChangeListener.bind(this); this.flyoutChangeWrapper = this.flyoutChangeListener.bind(this); } /** * Adds all necessary change listeners and markers to a workspace for keyboard * navigation to work. This must be called for keyboard navigation to work * on a workspace. * * @param workspace The workspace to add keyboard navigation to. */ addWorkspace(workspace: Blockly.WorkspaceSvg) { this.workspaces.push(workspace); const flyout = workspace.getFlyout(); workspace.addChangeListener(this.wsChangeWrapper); if (flyout) { this.addFlyout(flyout); } } /** * Removes all keyboard navigation change listeners and markers. * * @param workspace The workspace to remove keyboard navigation from. */ removeWorkspace(workspace: Blockly.WorkspaceSvg) { const workspaceIdx = this.workspaces.indexOf(workspace); const flyout = workspace.getFlyout(); if (workspace.getCursor()) { this.disableKeyboardAccessibility(workspace); } if (workspaceIdx > -1) { this.workspaces.splice(workspaceIdx, 1); } workspace.removeChangeListener(this.wsChangeWrapper); if (flyout) { this.removeFlyout(flyout); } } /** * Gets the navigation state of the current workspace. * * Note that this assumes a workspace with passive focus (including for its * toolbox or flyout) has a state of NOWHERE. * * @param workspace The workspace to get the state of. * @returns The state of the given workspace. */ getState(workspace: Blockly.WorkspaceSvg): Constants.STATE { const focusedTree = Blockly.getFocusManager().getFocusedTree(); if (focusedTree instanceof Blockly.WorkspaceSvg) { if (focusedTree.isFlyout) { return Constants.STATE.FLYOUT; } else { return Constants.STATE.WORKSPACE; } } else if (focusedTree instanceof Blockly.Toolbox) { if (workspace === focusedTree.getWorkspace()) { return Constants.STATE.TOOLBOX; } } else if (focusedTree instanceof Blockly.Flyout) { return Constants.STATE.FLYOUT; } // Either a non-Blockly element currently has DOM focus, or a different // workspace holds it. return Constants.STATE.NOWHERE; } /** * Adds all event listeners and cursors to the flyout that are needed for * keyboard navigation to work. * * @param flyout The flyout to add a cursor and change listeners to. */ addFlyout(flyout: Blockly.IFlyout) { const flyoutWorkspace = flyout.getWorkspace(); flyoutWorkspace.addChangeListener(this.flyoutChangeWrapper); const FlyoutCursorClass = Blockly.registry.getClass( cursorRegistrationType, cursorRegistrationName, ); if (FlyoutCursorClass) { flyoutWorkspace .getMarkerManager() .setCursor(new FlyoutCursorClass(flyout)); } } /** * Removes all change listeners from the flyout that are needed for * keyboard navigation to work. * * @param flyout The flyout to add a cursor and event listeners to. */ removeFlyout(flyout: Blockly.IFlyout) { const flyoutWorkspace = flyout.getWorkspace(); flyoutWorkspace.removeChangeListener(this.flyoutChangeWrapper); } /** * Updates the state of keyboard navigation and the position of the cursor * based on workspace events. * * @param e The Blockly event to process. */ workspaceChangeListener(e: Blockly.Events.Abstract) { if (!e.workspaceId) { return; } const workspace = Blockly.Workspace.getById( e.workspaceId, ) as Blockly.WorkspaceSvg; if (!workspace || !workspace.keyboardAccessibilityMode) { return; } if (e.type === Blockly.Events.BLOCK_CHANGE) { if ((e as Blockly.Events.BlockChange).element === 'mutation') { this.handleBlockMutation(workspace, e as Blockly.Events.BlockChange); } } } /** * Updates the state of keyboard navigation and the position of the cursor * based on events emitted from the flyout's workspace. * * @param e The Blockly event to process. */ flyoutChangeListener(e: Blockly.Events.Abstract) { if (!e.workspaceId) { return; } const flyoutWorkspace = Blockly.Workspace.getById( e.workspaceId, ) as Blockly.WorkspaceSvg | null; const mainWorkspace = flyoutWorkspace?.targetWorkspace; if (!mainWorkspace) { return; } const flyout = mainWorkspace.getFlyout(); if (!flyout) { return; } // This is called for simple toolboxes and for toolboxes that have a flyout // that does not close. Autoclosing flyouts close before we need to focus // the cursor on the block that was clicked. if ( mainWorkspace && mainWorkspace.keyboardAccessibilityMode && !flyout.autoClose ) { if ( e.type === Blockly.Events.CLICK && (e as Blockly.Events.Click).targetType === 'block' ) { const {blockId} = e as Blockly.Events.Click; if (blockId) { const block = flyoutWorkspace.getBlockById(blockId); if (block) { this.handleBlockClickInFlyout(mainWorkspace, block); } } } else if (e.type === Blockly.Events.SELECTED) { const {newElementId} = e as Blockly.Events.Selected; if (newElementId) { const block = flyoutWorkspace.getBlockById(newElementId); if (block) { this.handleBlockClickInFlyout(mainWorkspace, block); } } } } else if ( e.type === Blockly.Events.BLOCK_CREATE && this.getState(mainWorkspace) === Constants.STATE.FLYOUT ) { // When variables are created, that recreates the flyout contents, leaving the // cursor in an invalid state. this.defaultFlyoutCursorIfNeeded(mainWorkspace); } } private isFlyoutItemDisposed( node: Blockly.IFocusableNode, sourceBlock: Blockly.BlockSvg | null, ) { if (sourceBlock?.disposed) { return true; } if (node instanceof Blockly.FlyoutButton) { return node.getSvgRoot().parentNode === null; } return false; } /** * Moves the cursor to the block level when the block the cursor is on * mutates. * * @param workspace The workspace the cursor belongs * to. * @param e The Blockly event to process. */ handleBlockMutation( workspace: Blockly.WorkspaceSvg, e: Blockly.Events.BlockChange, ) { const mutatedBlockId = e.blockId; const cursor = workspace.getCursor(); const block = cursor?.getSourceBlock(); if (block && block.id === mutatedBlockId) { cursor?.setCurNode(block); } } /** * Handles when a user clicks on a block in the flyout by moving the cursor * to that stack of blocks and setting the state of navigation to the flyout. * * @param mainWorkspace The workspace the user clicked on. * @param block The block the user clicked on. */ handleBlockClickInFlyout( mainWorkspace: Blockly.WorkspaceSvg, block: Blockly.BlockSvg, ) { if (!block) { return; } const curNodeBlock = block.isShadow() ? block : block.getParent(); if (curNodeBlock) { this.getFlyoutCursor(mainWorkspace)?.setCurNode(curNodeBlock); } const flyout = mainWorkspace.getFlyout(); if (flyout) { Blockly.getFocusManager().focusTree(flyout.getWorkspace()); } } /** * Move the flyout cursor to the preferred end if unset (as it is initially despite * the types) or on a disposed item. * * @param workspace The workspace. * @param prefer The preferred default position. * @return true if the cursor location was defaulted. */ defaultFlyoutCursorIfNeeded( workspace: Blockly.WorkspaceSvg, prefer: 'first' | 'last' = 'first', ) { const flyout = workspace.getFlyout(); if (!flyout) return false; const flyoutCursor = this.getFlyoutCursor(workspace); if (!flyoutCursor) return false; const curNode = flyoutCursor.getCurNode(); const sourceBlock = flyoutCursor.getSourceBlock(); if (curNode && !this.isFlyoutItemDisposed(curNode, sourceBlock)) return false; const flyoutContents = flyout.getContents(); const defaultFlyoutItem = prefer === 'first' ? flyoutContents[0] : flyoutContents[flyoutContents.length - 1]; if (!defaultFlyoutItem) return false; const defaultFlyoutItemElement = defaultFlyoutItem.getElement(); flyoutCursor.setCurNode(defaultFlyoutItemElement); return true; } /** * Sets the cursor location when focusing the workspace. * Tries the following, in order, stopping after the first success: * - Resume editing by returning the cursor to its previous location, if valid. * - Move the cursor to the top connection point on on the first top block. * - Move the cursor to the default location on the workspace. * * @param workspace The main Blockly workspace. * @param prefer The preferred default position. * @return true if the cursor location was defaulted. */ defaultWorkspaceCursorPositionIfNeeded( workspace: Blockly.WorkspaceSvg, prefer: 'first' | 'last' = 'first', ) { const topBlocks = workspace.getTopBlocks(true); const cursor = workspace.getCursor(); if (!cursor) { return; } const disposed = cursor.getSourceBlock()?.disposed; if (cursor.getCurNode() && !disposed) { // Retain the cursor's previous position since it's set, but only if not // disposed (which can happen when blocks are reloaded). return false; } if (topBlocks.length > 0) { cursor.setCurNode( topBlocks[prefer === 'first' ? 0 : topBlocks.length - 1], ); } else { cursor.setCurNode(workspace); } return true; } /** * Gets the cursor on the flyout's workspace. * * @param workspace The main workspace the flyout is on. * @returns The flyout's cursor or null if no flyout exists. */ getFlyoutCursor(workspace: Blockly.WorkspaceSvg): Blockly.LineCursor | null { const flyout = workspace.getFlyout(); const cursor = flyout ? flyout.getWorkspace().getCursor() : null; return cursor; } /** * Tries to intelligently connect the blocks or connections * represented by the given nodes, based on node types and locations. * * @param stationaryNode The first node to connect. * @param movingBlock The block we're moving. * @returns True if the key was handled; false if something went * wrong. */ findInsertStartPoint( stationaryNode: Blockly.IFocusableNode, movingBlock: Blockly.BlockSvg, ): Blockly.RenderedConnection | null { const movingHasOutput = !!movingBlock.outputConnection; if (stationaryNode instanceof Blockly.Field) { // Can't connect a block to a field, so try going up to the source block. const sourceBlock = stationaryNode.getSourceBlock() as Blockly.BlockSvg; if (!sourceBlock) return null; return this.findInsertStartPoint(sourceBlock, movingBlock); } else if (stationaryNode instanceof Blockly.RenderedConnection) { // Move to the block if we're trying to insert a statement block into // a value connection. if ( !movingHasOutput && stationaryNode.type === Blockly.ConnectionType.INPUT_VALUE ) { const sourceBlock = stationaryNode.getSourceBlock(); if (!sourceBlock) return null; return this.findInsertStartPoint(sourceBlock, movingBlock); } // Connect the moving block to the stationary connection using // the most plausible connection on the moving block. return stationaryNode; } else if (stationaryNode instanceof Blockly.WorkspaceSvg) { return null; } else if (stationaryNode instanceof Blockly.BlockSvg) { // 1. Connect blocks to first compatible input const inputType = movingHasOutput ? Blockly.inputs.inputTypes.VALUE : Blockly.inputs.inputTypes.STATEMENT; const compatibleInputs = stationaryNode.inputList.filter( (input) => input.type === inputType, ); const input = compatibleInputs.length > 0 ? compatibleInputs[0] : null; let connection = input?.connection; if (connection) { if (inputType === Blockly.inputs.inputTypes.STATEMENT) { while (connection.targetBlock()?.nextConnection) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion connection = connection.targetBlock()!.nextConnection!; } } return connection as Blockly.RenderedConnection; } // 2. Connect statement blocks to next connection. if (stationaryNode.nextConnection && !movingHasOutput) { return stationaryNode.nextConnection; } // 3. Output connection. This will wrap around or displace. if (stationaryNode.outputConnection) { // Try to wrap. const target = stationaryNode.outputConnection.targetConnection; if (movingHasOutput && target) { return this.findInsertStartPoint(target, movingBlock); } else if (!movingHasOutput) { // Move to parent if we're trying to insert a statement block. const parent = stationaryNode.getParent(); if (!parent) return null; return this.findInsertStartPoint(parent, movingBlock); } return stationaryNode.outputConnection; } } this.warn(`Unexpected case in findInsertStartPoint ${stationaryNode}.`); return null; } /** * Tries to intelligently connect the blocks or connections * represented by the given nodes, based on node types and locations. * * @param stationaryNode The first node to connect. * @param movingBlock The block we're moving. * @returns True if the connection was successful, false otherwise. */ tryToConnectBlock( stationaryNode: Blockly.IFocusableNode, movingBlock: Blockly.BlockSvg, ): boolean { const destConnection = this.findInsertStartPoint( stationaryNode, movingBlock, ); if (!destConnection) return false; return this.insertBlock(movingBlock, destConnection); } /** * Disconnects the child block from its parent block. No-op if the two given * connections are unrelated. * * @param movingConnection The connection that is being moved. * @param destConnection The connection to be moved to. */ disconnectChild( movingConnection: Blockly.RenderedConnection, destConnection: Blockly.RenderedConnection, ) { const movingBlock = movingConnection.getSourceBlock(); const destBlock = destConnection.getSourceBlock(); let inferiorConnection; if (movingBlock.getRootBlock() === destBlock.getRootBlock()) { if (movingBlock.getDescendants(false).includes(destBlock)) { inferiorConnection = this.getInferiorConnection(destConnection); if (inferiorConnection) { inferiorConnection.disconnect(); } } else { inferiorConnection = this.getInferiorConnection(movingConnection); if (inferiorConnection) { inferiorConnection.disconnect(); } } } } /** * Tries to connect the given connections. * * If the given connections are not compatible try finding compatible * connections on the source blocks of the given connections. * * @param movingConnection The connection that is being moved. * @param destConnection The connection to be moved to. * @returns True if the two connections or their target connections * were connected, false otherwise. */ connect( movingConnection: Blockly.RenderedConnection | null, destConnection: Blockly.RenderedConnection | null, ): boolean { if (!movingConnection || !destConnection) { return false; } const movingInferior = this.getInferiorConnection(movingConnection); const destSuperior = this.getSuperiorConnection(destConnection); const movingSuperior = this.getSuperiorConnection(movingConnection); const destInferior = this.getInferiorConnection(destConnection); if ( movingInferior && destSuperior && this.moveAndConnect(movingInferior, destSuperior) ) { return true; // Try swapping the inferior and superior connections on the blocks. } else if ( movingSuperior && destInferior && this.moveAndConnect(movingSuperior, destInferior) ) { return true; } else if (this.moveAndConnect(movingConnection, destConnection)) { return true; } else { const checker = movingConnection.getConnectionChecker(); const reason = checker.canConnectWithReason( movingConnection, destConnection, false, ); this.warn( 'Connection failed with error: ' + checker.getErrorMessage(reason, movingConnection, destConnection), ); return false; } } /** * Finds the inferior connection on the source block if the given connection * is superior. * * @param connection The connection trying to be connected. * @returns The inferior connection or null if none exists. */ getInferiorConnection( connection: Blockly.RenderedConnection | null, ): Blockly.RenderedConnection | null { if (!connection) { return null; } const block = connection.getSourceBlock() as Blockly.BlockSvg; if (!connection.isSuperior()) { return connection; } else if (block.previousConnection) { return block.previousConnection; } else if (block.outputConnection) { return block.outputConnection; } else { return null; } } /** * Finds a superior connection on the source block if the given connection is * inferior. * * @param connection The connection trying to be connected. * @returns The superior connection or null if none exists. */ getSuperiorConnection( connection: Blockly.RenderedConnection | null, ): Blockly.RenderedConnection | null { if (!connection) { return null; } if (connection.isSuperior()) { return connection; } else if (connection.targetConnection) { return connection.targetConnection; } return null; } /** * Moves the moving connection to the target connection and connects them. * * @param movingConnection The connection that is being moved. * @param destConnection The connection to be moved to. * @returns True if the connections were connected, false otherwise. */ moveAndConnect( movingConnection: Blockly.RenderedConnection | null, destConnection: Blockly.RenderedConnection | null, ): boolean { if (!movingConnection || !destConnection) { return false; } const movingBlock = movingConnection.getSourceBlock(); const checker = movingConnection.getConnectionChecker(); if ( checker.canConnect(movingConnection, destConnection, false) && !destConnection.getSourceBlock().isShadow() ) { this.disconnectChild(movingConnection, destConnection); // Position the root block near the connection so it does not move the // other block when they are connected. if (!destConnection.isSuperior()) { const rootBlock = movingBlock.getRootBlock(); const originalOffsetToTarget = { x: destConnection.x - movingConnection.x, y: destConnection.y - movingConnection.y, }; const originalOffsetInBlock = movingConnection .getOffsetInBlock() .clone(); rootBlock.positionNearConnection( movingConnection, originalOffsetToTarget, originalOffsetInBlock, ); } destConnection.connect(movingConnection); return true; } return false; } /** * Tries to connect the given block to the destination connection, making an * intelligent guess about which connection to use on the moving block. * * @param block The block to move. * @param destConnection The connection to * connect to. * @returns Whether the connection was successful. */ insertBlock( block: Blockly.BlockSvg, destConnection: Blockly.RenderedConnection, ): boolean { switch (destConnection.type) { case Blockly.PREVIOUS_STATEMENT: if (this.connect(block.nextConnection, destConnection)) { return true; } break; case Blockly.NEXT_STATEMENT: if (this.connect(block.previousConnection, destConnection)) { return true; } break; case Blockly.INPUT_VALUE: if (this.connect(block.outputConnection, destConnection)) { return true; } break; case Blockly.OUTPUT_VALUE: for (let i = 0; i < block.inputList.length; i++) { const inputConnection = block.inputList[i].connection; if ( inputConnection && inputConnection.type === Blockly.INPUT_VALUE && this.connect( inputConnection as Blockly.RenderedConnection, destConnection, ) ) { return true; } } // If there are no input values pass the output and destination // connections to connect_ to find a way to connect the two. if ( block.outputConnection && this.connect(block.outputConnection, destConnection) ) { return true; } break; } this.warn('This block can not be inserted at the marked location.'); return false; } /** * Enables accessibility mode. * * @param workspace The workspace to enable keyboard * accessibility mode on. */ enableKeyboardAccessibility(workspace: Blockly.WorkspaceSvg) { if ( this.workspaces.includes(workspace) && !workspace.keyboardAccessibilityMode ) { workspace.keyboardAccessibilityMode = true; } } /** * Disables accessibility mode. * * @param workspace The workspace to disable keyboard * accessibility mode on. */ disableKeyboardAccessibility(workspace: Blockly.WorkspaceSvg) { if ( this.workspaces.includes(workspace) && workspace.keyboardAccessibilityMode ) { workspace.keyboardAccessibilityMode = false; } } /** * Navigation log handler. If loggingCallback is defined, use it. * Otherwise just log to the console.log. * * @param msg The message to log. */ log(msg: string) { console.log(msg); } /** * Navigation warning handler. If loggingCallback is defined, use it. * Otherwise call console.warn. * * @param msg The warning message. */ warn(msg: string) { console.warn(msg); } /** * Navigation error handler. If loggingCallback is defined, use it. * Otherwise call console.error. * * @param msg The error message. */ error(msg: string) { console.error(msg); } /** * Save the current cursor location and open the toolbox or flyout * to select and insert a block. * * @param workspace The active workspace. */ openToolboxOrFlyout(workspace: Blockly.WorkspaceSvg) { const toolbox = workspace.getToolbox(); const flyout = workspace.getFlyout(); if (toolbox) { Blockly.getFocusManager().focusTree(toolbox); } else if (flyout) { Blockly.getFocusManager().focusTree(flyout.getWorkspace()); } } /** * Pastes the copied block to the marked location if possible or * onto the workspace otherwise. * * @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. */ 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.tryToConnectBlock(targetNode, block); } return true; } Blockly.Events.setGroup(false); return false; } /** * Determines whether keyboard navigation should be allowed based on the * current state of the workspace. * * A return value of 'true' generally indicates that either the workspace, * toolbox or flyout has enabled keyboard navigation and is currently in a * state (e.g. focus) that can support keyboard navigation. * * @param workspace the workspace in which keyboard navigation may be allowed. * @returns whether keyboard navigation is currently allowed. */ canCurrentlyNavigate(workspace: Blockly.WorkspaceSvg) { const accessibilityMode = workspace.isFlyout ? workspace.targetWorkspace?.keyboardAccessibilityMode : workspace.keyboardAccessibilityMode; return ( !!accessibilityMode && this.getState(workspace) !== Constants.STATE.NOWHERE && !Blockly.getFocusManager().ephemeralFocusTaken() ); } /** * Determines whether the provided workspace is currently keyboard navigable * and editable. * * For the navigability criteria, see canCurrentlyKeyboardNavigate. * * @param workspace the workspace in which keyboard editing may be allowed. * @returns whether keyboard navigation and editing is currently allowed. */ canCurrentlyEdit(workspace: Blockly.WorkspaceSvg) { return this.canCurrentlyNavigate(workspace) && !workspace.options.readOnly; } /** * Removes the change listeners on all registered workspaces. */ dispose() { for (const workspace of this.workspaces) { this.removeWorkspace(workspace); } } }