@blockly/keyboard-navigation
Version:
A plugin for keyboard navigation.
466 lines (418 loc) • 14.8 kB
text/typescript
/**
* @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,
);
}
}