mirror of
https://github.com/google/blockly.git
synced 2025-12-16 06:10:12 +01:00
* refactor: Remove INavigable in favor of IFocusableNode. * chore: Fix JSDoc. * chore: Address review feedback.
439 lines
13 KiB
TypeScript
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);
|