Files
blockly/core/keyboard_nav/line_cursor.ts
Aaron Dodson ae22165cbe refactor: Remove INavigable in favor of IFocusableNode. (#9037)
* refactor: Remove INavigable in favor of IFocusableNode.

* chore: Fix JSDoc.

* chore: Address review feedback.
2025-05-13 15:04:49 -07:00

439 lines
13 KiB
TypeScript

/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview The class representing a line cursor.
* A line cursor tries to traverse the blocks and connections on a block as if
* they were lines of code in a text editor. Previous and next traverse previous
* connections, next connections and blocks, while in and out traverse input
* connections and fields.
* @author aschmiedt@google.com (Abby Schmiedt)
*/
import {BlockSvg} from '../block_svg.js';
import {getFocusManager} from '../focus_manager.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {Marker} from './marker.js';
/**
* Class for a line cursor.
*/
export class LineCursor extends Marker {
override type = 'cursor';
/** Locations to try moving the cursor to after a deletion. */
private potentialNodes: IFocusableNode[] | null = null;
/**
* @param workspace The workspace this cursor belongs to.
*/
constructor(protected readonly workspace: WorkspaceSvg) {
super();
}
/**
* Moves the cursor to the next previous connection, next connection or block
* in the pre order traversal. Finds the next node in the pre order traversal.
*
* @returns The next node, or null if the current node is
* not set or there is no next value.
*/
next(): IFocusableNode | null {
const curNode = this.getCurNode();
if (!curNode) {
return null;
}
const newNode = this.getNextNode(
curNode,
(candidate: IFocusableNode | null) => {
return (
candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()
);
},
true,
);
if (newNode) {
this.setCurNode(newNode);
}
return newNode;
}
/**
* Moves the cursor to the next input connection or field
* in the pre order traversal.
*
* @returns The next node, or null if the current node is
* not set or there is no next value.
*/
in(): IFocusableNode | null {
const curNode = this.getCurNode();
if (!curNode) {
return null;
}
const newNode = this.getNextNode(curNode, () => true, true);
if (newNode) {
this.setCurNode(newNode);
}
return newNode;
}
/**
* Moves the cursor to the previous next connection or previous connection in
* the pre order traversal.
*
* @returns The previous node, or null if the current node
* is not set or there is no previous value.
*/
prev(): IFocusableNode | null {
const curNode = this.getCurNode();
if (!curNode) {
return null;
}
const newNode = this.getPreviousNode(
curNode,
(candidate: IFocusableNode | null) => {
return (
candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()
);
},
true,
);
if (newNode) {
this.setCurNode(newNode);
}
return newNode;
}
/**
* Moves the cursor to the previous input connection or field in the pre order
* traversal.
*
* @returns The previous node, or null if the current node
* is not set or there is no previous value.
*/
out(): IFocusableNode | null {
const curNode = this.getCurNode();
if (!curNode) {
return null;
}
const newNode = this.getPreviousNode(curNode, () => true, true);
if (newNode) {
this.setCurNode(newNode);
}
return newNode;
}
/**
* Returns true iff the node to which we would navigate if in() were
* called is the same as the node to which we would navigate if next() were
* called - in effect, if the LineCursor is at the end of the 'current
* line' of the program.
*/
atEndOfLine(): boolean {
const curNode = this.getCurNode();
if (!curNode) return false;
const inNode = this.getNextNode(curNode, () => true, true);
const nextNode = this.getNextNode(
curNode,
(candidate: IFocusableNode | null) => {
return (
candidate instanceof BlockSvg &&
!candidate.outputConnection?.targetBlock()
);
},
true,
);
return inNode === nextNode;
}
/**
* Uses pre order traversal to navigate the Blockly AST. This will allow
* a user to easily navigate the entire Blockly AST without having to go in
* and out levels on the tree.
*
* @param node The current position in the AST.
* @param isValid A function true/false depending on whether the given node
* should be traversed.
* @param visitedNodes A set of previously visited nodes used to avoid cycles.
* @returns The next node in the traversal.
*/
private getNextNodeImpl(
node: IFocusableNode | null,
isValid: (p1: IFocusableNode | null) => boolean,
visitedNodes: Set<IFocusableNode> = new Set<IFocusableNode>(),
): IFocusableNode | null {
if (!node || visitedNodes.has(node)) return null;
let newNode =
this.workspace.getNavigator().getFirstChild(node) ||
this.workspace.getNavigator().getNextSibling(node);
let target = node;
while (target && !newNode) {
const parent = this.workspace.getNavigator().getParent(target);
if (!parent) break;
newNode = this.workspace.getNavigator().getNextSibling(parent);
target = parent;
}
if (isValid(newNode)) return newNode;
if (newNode) {
visitedNodes.add(node);
return this.getNextNodeImpl(newNode, isValid, visitedNodes);
}
return null;
}
/**
* Get the next node in the AST, optionally allowing for loopback.
*
* @param node The current position in the AST.
* @param isValid A function true/false depending on whether the given node
* should be traversed.
* @param loop Whether to loop around to the beginning of the workspace if no
* valid node was found.
* @returns The next node in the traversal.
*/
getNextNode(
node: IFocusableNode | null,
isValid: (p1: IFocusableNode | null) => boolean,
loop: boolean,
): IFocusableNode | null {
if (!node || (!loop && this.getLastNode() === node)) return null;
return this.getNextNodeImpl(node, isValid);
}
/**
* Reverses the pre order traversal in order to find the previous node. This
* will allow a user to easily navigate the entire Blockly AST without having
* to go in and out levels on the tree.
*
* @param node The current position in the AST.
* @param isValid A function true/false depending on whether the given node
* should be traversed.
* @param visitedNodes A set of previously visited nodes used to avoid cycles.
* @returns The previous node in the traversal or null if no previous node
* exists.
*/
private getPreviousNodeImpl(
node: IFocusableNode | null,
isValid: (p1: IFocusableNode | null) => boolean,
visitedNodes: Set<IFocusableNode> = new Set<IFocusableNode>(),
): IFocusableNode | null {
if (!node || visitedNodes.has(node)) return null;
const newNode =
this.getRightMostChild(
this.workspace.getNavigator().getPreviousSibling(node),
node,
) || this.workspace.getNavigator().getParent(node);
if (isValid(newNode)) return newNode;
if (newNode) {
visitedNodes.add(node);
return this.getPreviousNodeImpl(newNode, isValid, visitedNodes);
}
return null;
}
/**
* Get the previous node in the AST, optionally allowing for loopback.
*
* @param node The current position in the AST.
* @param isValid A function true/false depending on whether the given node
* should be traversed.
* @param loop Whether to loop around to the end of the workspace if no valid
* node was found.
* @returns The previous node in the traversal or null if no previous node
* exists.
*/
getPreviousNode(
node: IFocusableNode | null,
isValid: (p1: IFocusableNode | null) => boolean,
loop: boolean,
): IFocusableNode | null {
if (!node || (!loop && this.getFirstNode() === node)) return null;
return this.getPreviousNodeImpl(node, isValid);
}
/**
* Get the right most child of a node.
*
* @param node The node to find the right most child of.
* @returns The right most child of the given node, or the node if no child
* exists.
*/
private getRightMostChild(
node: IFocusableNode | null,
stopIfFound: IFocusableNode,
): IFocusableNode | null {
if (!node) return node;
let newNode = this.workspace.getNavigator().getFirstChild(node);
if (!newNode || newNode === stopIfFound) return node;
for (
let nextNode: IFocusableNode | null = newNode;
nextNode;
nextNode = this.workspace.getNavigator().getNextSibling(newNode)
) {
if (nextNode === stopIfFound) break;
newNode = nextNode;
}
return this.getRightMostChild(newNode, stopIfFound);
}
/**
* Prepare for the deletion of a block by making a list of nodes we
* could move the cursor to afterwards and save it to
* this.potentialNodes.
*
* After the deletion has occurred, call postDelete to move it to
* the first valid node on that list.
*
* The locations to try (in order of preference) are:
*
* - The current location.
* - The connection to which the deleted block is attached.
* - The block connected to the next connection of the deleted block.
* - The parent block of the deleted block.
* - A location on the workspace beneath the deleted block.
*
* N.B.: When block is deleted, all of the blocks conneccted to that
* block's inputs are also deleted, but not blocks connected to its
* next connection.
*
* @param deletedBlock The block that is being deleted.
*/
preDelete(deletedBlock: BlockSvg) {
const curNode = this.getCurNode();
const nodes: IFocusableNode[] = curNode ? [curNode] : [];
// The connection to which the deleted block is attached.
const parentConnection =
deletedBlock.previousConnection?.targetConnection ??
deletedBlock.outputConnection?.targetConnection;
if (parentConnection) {
nodes.push(parentConnection);
}
// The block connected to the next connection of the deleted block.
const nextBlock = deletedBlock.getNextBlock();
if (nextBlock) {
nodes.push(nextBlock);
}
// The parent block of the deleted block.
const parentBlock = deletedBlock.getParent();
if (parentBlock) {
nodes.push(parentBlock);
}
// A location on the workspace beneath the deleted block.
// Move to the workspace.
nodes.push(this.workspace);
this.potentialNodes = nodes;
}
/**
* Move the cursor to the first valid location in
* this.potentialNodes, following a block deletion.
*/
postDelete() {
const nodes = this.potentialNodes;
this.potentialNodes = null;
if (!nodes) throw new Error('must call preDelete first');
for (const node of nodes) {
if (!this.getSourceBlockFromNode(node)?.disposed) {
this.setCurNode(node);
return;
}
}
throw new Error('no valid nodes in this.potentialNodes');
}
/**
* Get the current location of the cursor.
*
* Overrides normal Marker getCurNode to update the current node from the
* selected block. This typically happens via the selection listener but that
* is not called immediately when `Gesture` calls
* `Blockly.common.setSelected`. In particular the listener runs after showing
* the context menu.
*
* @returns The current field, connection, or block the cursor is on.
*/
override getCurNode(): IFocusableNode | null {
this.updateCurNodeFromFocus();
return super.getCurNode();
}
/**
* Set the location of the cursor and draw it.
*
* Overrides normal Marker setCurNode logic to call
* this.drawMarker() instead of this.drawer.draw() directly.
*
* @param newNode The new location of the cursor.
*/
override setCurNode(newNode: IFocusableNode | null) {
super.setCurNode(newNode);
if (isFocusableNode(newNode)) {
getFocusManager().focusNode(newNode);
}
// Try to scroll cursor into view.
if (newNode instanceof BlockSvg) {
newNode.workspace.scrollBoundsIntoView(
newNode.getBoundingRectangleWithoutChildren(),
);
}
}
/**
* Updates the current node to match what's currently focused.
*/
private updateCurNodeFromFocus() {
const focused = getFocusManager().getFocusedNode();
if (focused instanceof BlockSvg) {
const block: BlockSvg | null = focused;
if (block && block.workspace === this.workspace) {
this.setCurNode(block);
}
}
}
/**
* Get the first navigable node on the workspace, or null if none exist.
*
* @returns The first navigable node on the workspace, or null.
*/
getFirstNode(): IFocusableNode | null {
return this.workspace.getNavigator().getFirstChild(this.workspace);
}
/**
* Get the last navigable node on the workspace, or null if none exist.
*
* @returns The last navigable node on the workspace, or null.
*/
getLastNode(): IFocusableNode | null {
const first = this.getFirstNode();
return this.getPreviousNode(first, () => true, true);
}
}
registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor);