UNPKG

@blockly/keyboard-navigation

Version:
466 lines (418 loc) 14.8 kB
/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { BlockSvg, IDragger, IDragStrategy, Gesture, RenderedConnection, } from 'blockly'; import { Connection, dragging, getFocusManager, registry, utils, WorkspaceSvg, ShortcutRegistry, } from 'blockly'; import * as Constants from '../constants'; import {Direction, getXYFromDirection} from '../drag_direction'; import {KeyboardDragStrategy} from '../keyboard_drag_strategy'; import {Navigation} from '../navigation'; import {clearMoveHints} from '../hints'; /** * The distance to move an item, in workspace coordinates, when * making an unconstrained move. */ const UNCONSTRAINED_MOVE_DISTANCE = 20; /** * The amount of additional padding to include during a constrained move. */ const CONSTRAINED_ADDITIONAL_PADDING = 70; /** * Identifier for a keyboard shortcut that commits the in-progress move. */ const COMMIT_MOVE_SHORTCUT = 'commitMove'; /** * Low-level code for moving blocks with keyboard shortcuts. */ export class Mover { /** * Map of moves in progress. * * An entry for a given workspace in this map means that the this * Mover is moving a block on that workspace, and will disable * normal cursor movement until the move is complete. */ protected moves: Map<WorkspaceSvg, MoveInfo> = new Map(); /** * The stashed isDragging function, which is replaced at the beginning * of a keyboard drag and reset at the end of a keyboard drag. */ oldIsDragging: (() => boolean) | null = null; /** * The stashed getGesture function, which is replaced at the beginning * of a keyboard drag and reset at the end of a keyboard drag. */ oldGetGesture: ((e: PointerEvent) => Gesture | null) | null = null; /** * The block's base drag strategy, which will be overridden during * keyboard drags and reset at the end of the drag. */ private oldDragStrategy: IDragStrategy | null = null; constructor(protected navigation: Navigation) {} /** * Returns true iff we are able to begin moving the block which * currently has focus on the given workspace. * * @param workspace The workspace to move on. * @param block The block to try to drag. * @returns True iff we can begin a move. */ canMove(workspace: WorkspaceSvg, block: BlockSvg) { return !!( this.navigation.getState(workspace) === Constants.STATE.WORKSPACE && this.navigation.canCurrentlyEdit(workspace) && !this.moves.has(workspace) && // No move in progress. block?.isMovable() ); } /** * Returns true iff we are currently moving a block on the given * workspace. * * @param workspace The workspace we might be moving on. * @returns True iff we are moving. */ isMoving(workspace: WorkspaceSvg) { return ( this.navigation.canCurrentlyEdit(workspace) && this.moves.has(workspace) ); } /** * Start moving the currently-focused item on workspace, if * possible. * * Should only be called if canMove has returned true. * * @param workspace The workspace we might be moving on. * @param block The block to start dragging. * @param insertStartPoint Where to insert the block, or null if the block * already existed. * @returns True iff a move has successfully begun. */ startMove( workspace: WorkspaceSvg, block: BlockSvg, insertStartPoint: RenderedConnection | null, ) { this.patchDragStrategy(block, insertStartPoint); // Begin dragging block. const DraggerClass = registry.getClassFromOptions( registry.Type.BLOCK_DRAGGER, workspace.options, true, ); if (!DraggerClass) throw new Error('no Dragger registered'); const dragger = new DraggerClass(block, workspace); // Set up a blur listener to end the move if the user clicks away const blurListener = () => { this.finishMove(workspace); }; // Record that a move is in progress and start dragging. workspace.setKeyboardMoveInProgress(true); const info = new MoveInfo(block, dragger, blurListener); this.moves.set(workspace, info); // Begin drag. dragger.onDragStart(info.fakePointerEvent('pointerdown')); info.updateTotalDelta(); // In case the block is detached, ensure that it still retains focus // (otherwise dragging will break). getFocusManager().focusNode(block); block.getFocusableElement().addEventListener('blur', blurListener); // Register a keyboard shortcut under the key combos of all existing // keyboard shortcuts that commits the move before allowing the real // shortcut to proceed. This avoids all kinds of fun brokenness when // deleting/copying/otherwise acting on a block in move mode. const shortcutKeys = Object.values(ShortcutRegistry.registry.getRegistry()) .flatMap((shortcut) => shortcut.keyCodes) .filter((keyCode) => { return ( keyCode && ![ utils.KeyCodes.RIGHT, utils.KeyCodes.LEFT, utils.KeyCodes.UP, utils.KeyCodes.DOWN, utils.KeyCodes.ENTER, utils.KeyCodes.ESC, ].includes( typeof keyCode === 'number' ? keyCode : parseInt(`${keyCode.split('+').pop()}`), ) ); }) // Convince TS there aren't undefined values. .filter((keyCode): keyCode is string | number => !!keyCode); const commitMoveShortcut = { name: COMMIT_MOVE_SHORTCUT, preconditionFn: (workspace: WorkspaceSvg) => { return !!this.moves.get(workspace); }, callback: (workspace: WorkspaceSvg) => { this.finishMove(workspace); return false; }, keyCodes: shortcutKeys, allowCollision: true, }; ShortcutRegistry.registry.register(commitMoveShortcut); return true; } /** * Finish moving the currently-focused item on workspace. * * Should only be called if isMoving has returned true. * * @param workspace The workspace on which we are moving. * @returns True iff move successfully finished. */ finishMove(workspace: WorkspaceSvg) { const info = this.preDragEndCleanup(workspace); info.dragger.onDragEnd( info.fakePointerEvent('pointerup'), new utils.Coordinate(0, 0), ); this.postDragEndCleanup(workspace, info); return true; } /** * Abort moving the currently-focused item on workspace. * * Should only be called if isMoving has returned true. * * @param workspace The workspace on which we are moving. * @returns True iff move successfully aborted. */ abortMove(workspace: WorkspaceSvg) { const info = this.preDragEndCleanup(workspace); const dragStrategy = info.block.getDragStrategy() as KeyboardDragStrategy; this.patchDragger( info.dragger as dragging.Dragger, dragStrategy.isNewBlock, ); // Save the position so we can put the cursor in a reasonable spot. // @ts-expect-error Access to private property connectionCandidate. const target = dragStrategy.connectionCandidate?.neighbour; // Prevent the stragegy connecting the block so we just delete one block. // @ts-expect-error Access to private property connectionCandidate. dragStrategy.connectionCandidate = null; info.dragger.onDragEnd( info.fakePointerEvent('pointerup'), info.startLocation, ); if (dragStrategy.isNewBlock && target) { workspace.getCursor()?.setCurNode(target); } this.postDragEndCleanup(workspace, info); return true; } /** * Common clean-up for finish/abort. * * @param workspace The workspace on which we are moving. * @returns The info for the block. */ private preDragEndCleanup(workspace: WorkspaceSvg) { ShortcutRegistry.registry.unregister(COMMIT_MOVE_SHORTCUT); clearMoveHints(workspace); const info = this.moves.get(workspace); if (!info) throw new Error('no move info for workspace'); // Remove the blur listener before ending the drag info.block .getFocusableElement() .removeEventListener('blur', info.blurListener); return info; } /** * Common clean-up for finish/abort. * * @param workspace The workspace on which we are moving. * @param info The info for the block. */ private postDragEndCleanup(workspace: WorkspaceSvg, info: MoveInfo) { this.unpatchDragStrategy(info.block); this.moves.delete(workspace); workspace.setKeyboardMoveInProgress(false); // Delay scroll until after block has finished moving. setTimeout(() => this.scrollCurrentBlockIntoView(workspace), 0); // If the block gets reattached, ensure it retains focus. getFocusManager().focusNode(info.block); } /** * Action to move the item being moved in the given direction, * constrained to valid attachment points (if any). * * @param workspace The workspace to move on. * @param direction The direction to move the dragged item. * @returns True iff this action applies and has been performed. */ moveConstrained(workspace: WorkspaceSvg, direction: Direction) { if (!workspace) return false; const info = this.moves.get(workspace); if (!info) throw new Error('no move info for workspace'); info.dragger.onDrag( info.fakePointerEvent('pointermove', direction), info.totalDelta, ); info.updateTotalDelta(); this.scrollCurrentBlockIntoView(workspace, CONSTRAINED_ADDITIONAL_PADDING); return true; } /** * Action to move the item being moved in the given direction, * without constraint. * * @param workspace The workspace to move on. * @param direction The direction to move the dragged item. * @returns True iff this action applies and has been performed. */ moveUnconstrained(workspace: WorkspaceSvg, direction: Direction): boolean { if (!workspace) return false; const info = this.moves.get(workspace); if (!info) throw new Error('no move info for workspace'); const {x, y} = getXYFromDirection(direction); info.totalDelta.x += x * UNCONSTRAINED_MOVE_DISTANCE * workspace.scale; info.totalDelta.y += y * UNCONSTRAINED_MOVE_DISTANCE * workspace.scale; info.dragger.onDrag(info.fakePointerEvent('pointermove'), info.totalDelta); this.scrollCurrentBlockIntoView(workspace); return true; } /** * Monkeypatch: replace the block's drag strategy and cache the old value. * * @param block The block to patch. * @param insertStartPoint Where to insert the block, or null if the block * already existed. */ private patchDragStrategy( block: BlockSvg, insertStartPoint: RenderedConnection | null, ) { // @ts-expect-error block.dragStrategy is private. this.oldDragStrategy = block.dragStrategy; block.setDragStrategy(new KeyboardDragStrategy(block, insertStartPoint)); } /** * Undo the monkeypatching of the block's drag strategy. * * @param block The block to patch. */ private unpatchDragStrategy(block: BlockSvg) { if (this.oldDragStrategy) { block.setDragStrategy(this.oldDragStrategy); this.oldDragStrategy = null; } } /** * Scrolls the current block into view if one exists. * * @param workspace The workspace to get current block from. * @param padding Amount of spacing to put between the bounds and the edge of * the workspace's viewport. */ private scrollCurrentBlockIntoView(workspace: WorkspaceSvg, padding = 0) { const blockToView = this.moves.get(workspace)?.block; if (blockToView) { const bounds = blockToView.getBoundingRectangleWithoutChildren().clone(); bounds.top -= padding; bounds.bottom += padding; bounds.left -= padding; bounds.right += padding; workspace.scrollBoundsIntoView(bounds); } } /** * Monkeypatch: override either wouldDeleteDraggable or shouldReturnToStart, * based on whether this was an insertion of a new block or a movement of * an existing block. * * @param dragger The dragger to patch. * @param isNewBlock Whether the moving block was created for this action. */ private patchDragger(dragger: dragging.Dragger, isNewBlock: boolean) { if (isNewBlock) { // Monkey patch dragger to trigger delete. // eslint-disable-next-line @typescript-eslint/no-explicit-any (dragger as any).wouldDeleteDraggable = () => true; } else { // Monkey patch dragger to trigger call to draggable.revertDrag. // eslint-disable-next-line @typescript-eslint/no-explicit-any (dragger as any).shouldReturnToStart = () => true; } } } /** * Information about the currently in-progress move for a given * Workspace. */ export class MoveInfo { /** Total distance moved, in workspace units. */ totalDelta = new utils.Coordinate(0, 0); readonly parentNext: Connection | null; readonly parentInput: Connection | null; readonly startLocation: utils.Coordinate; constructor( readonly block: BlockSvg, readonly dragger: IDragger, readonly blurListener: EventListener, ) { this.parentNext = block.previousConnection?.targetConnection ?? null; this.parentInput = block.outputConnection?.targetConnection ?? null; this.startLocation = block.getRelativeToSurfaceXY(); } /** * Create a fake pointer event for dragging. * * @param type Which type of pointer event to create. * @param direction The direction if this movement is a constrained drag. * @returns A synthetic PointerEvent that can be consumed by Blockly's * dragging code. */ fakePointerEvent(type: string, direction?: Direction): PointerEvent { const workspace = this.block.workspace; if (!(workspace instanceof WorkspaceSvg)) throw new TypeError(); const blockCoords = utils.svgMath.wsToScreenCoordinates( workspace, new utils.Coordinate( this.startLocation.x + this.totalDelta.x, this.startLocation.y + this.totalDelta.y, ), ); const tilts = getXYFromDirection(direction); return new PointerEvent(type, { clientX: blockCoords.x, clientY: blockCoords.y, tiltX: tilts.x, tiltY: tilts.y, }); } /** * The keyboard drag may have moved the block to an appropriate location * for a preview. Update the saved delta to reflect the block's new * location, so that it does not jump during the next unconstrained move. */ updateTotalDelta() { const workspace = this.block.workspace; if (!(workspace instanceof WorkspaceSvg)) throw new TypeError(); this.totalDelta = new utils.Coordinate( this.block.relativeCoords.x - this.startLocation.x, this.block.relativeCoords.y - this.startLocation.y, ); } }