mirror of
https://github.com/google/blockly.git
synced 2026-01-08 17:40:09 +01:00
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8940 Fixes #8954 Fixes #8955 ### Proposed Changes This updates `LineCursor` to use `FocusManager` rather than selection (principally) as the source of truth. ### Reason for Changes Ensuring that keyboard navigation works correctly with eventual screen reader support requires ensuring that ever navigated component is focused, and this is primarily what `FocusManager` has been designed to do. Since these nodes are already focused, `FocusManager` can be used as the primary source of truth for determining where the user currently has navigated, and where to go next. Previously, `LineCursor` relied on selection for this purpose, but selection is now automatically updated (for blocks) using focus-controlled `focus` and `blur` callbacks. Note that the cursor will still fall back to synchronizing with selection state, though this will be removed once the remaining work to eliminate `MarkerSvg` has concluded (which requires further consideration on the keyboard navigation side viz-a-viz styling and CSS decisions) and once mouse clicks are synchronized with focus management. Note that the changes in this PR are closely tied to https://github.com/google/blockly-keyboard-experimentation/pull/482 as both are necessary in order for the keyboard navigation plugin to correctly work with `FocusManager`. Some other noteworthy changes: - Some special handling exists for flyouts to handle navigating across stacks (per the current cursor design). - `FocusableTreeTraverser` is needed by the keyboard navigation plugin (in https://github.com/google/blockly-keyboard-experimentation/pull/482) so it's now being exported. - `FocusManager` had one bug that's now patched and tested in this PR: it didn't handle the case of the browser completely forcing focus loss. It would continue to maintain active focus even though no tracked elements now hold focus. One such case is the element being deleted, but there are other cases where this can happen (such as with dialog prompts). - `FocusManager` had some issues from #8909 wherein it would overeagerly call tree focus callbacks and slightly mismanage the passive node. Since tests haven't yet been added for these lifecycle callbacks, these cases weren't originally caught (per #8910). - `FocusManager` was updated to move the tracked manager into a static function so that it can be replaced in tests. This was done to facilitate changes to setup_teardown.js to ensure that a unique `FocusManager` exists _per-test_. It's possible for DOM focus state to still bleed across tests, but `FocusManager` largely guarantees eventual consistency. This change prevents a class of focus errors from being possible when running tests. - A number of cursor tests needed to be updated to ensure that a connections are properly rendered (as this is a requirement for focusable nodes, and cursor is now focusing nodes). One test for output connections was changed to use an input connection, instead, since output connections can no longer be navigated to (and aren't rendered, thus are not focusable). It's possible this will need to be changed in the future if we decide to reintroduce support for output connections in cursor, but it seems like a reasonable stopgap. Huge thanks to @rachel-fenichel for helping investigate and providing an alternative for the output connection test. **Current gaps** to be fixed after this PR is merged: - The flyout automatically closes when creating a variable with with keyboard or mouse (I think this is only for the keyboard navigation plugin). I believe this is a regression from previous behavior due to how the navigation plugin is managing state. It would know the flyout should be open and thus ensure it stays open even when things like dialog prompts try to close it with a blur event. However, the new implementation in https://github.com/google/blockly-keyboard-experimentation/pull/482 complicates this since state is now inferred from `FocusManager`, and the flyout _losing_ focus will force it closed. There was a fix introduced in this PR to fix it for keyboard navigation, but fails for clicks because the flyout never receives focus when the create variable button is clicked. It also caused the advanced compilation tests to fail due to a subtle circular dependency from importing `WorkspaceSvg` directly rather than its type. - The flyout, while it stays open, does not automatically update past the first variable being created without closing and reopening it. I'm actually not at all sure why this particular behavior has regressed. ### Test Coverage No new non-`FocusManager` tests have been added. It's certainly possible to add unit tests for the focusable configurations being introduced in this PR, but it may not be highly beneficial. It's largely assumed that the individual implementations should work due to a highly tested FocusManager, and it may be the case that the interactions of the components working together is far more important to verify (that is, the end user flows). The latter is planned to be tackled as part of #8915. Some new `FocusManager` tests were added, but more are still needed and this is tracked as part of #8910. ### Documentation No new documentation should be needed for these changes. ### Additional Information This includes changes that have been pulled from #8875.
812 lines
25 KiB
TypeScript
812 lines
25 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 type {Block} from '../block.js';
|
|
import {BlockSvg} from '../block_svg.js';
|
|
import * as common from '../common.js';
|
|
import type {Connection} from '../connection.js';
|
|
import {ConnectionType} from '../connection_type.js';
|
|
import type {Field} from '../field.js';
|
|
import {getFocusManager} from '../focus_manager.js';
|
|
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
|
|
import * as registry from '../registry.js';
|
|
import type {MarkerSvg} from '../renderers/common/marker_svg.js';
|
|
import type {PathObject} from '../renderers/zelos/path_object.js';
|
|
import {Renderer} from '../renderers/zelos/renderer.js';
|
|
import * as dom from '../utils/dom.js';
|
|
import type {WorkspaceSvg} from '../workspace_svg.js';
|
|
import {ASTNode} from './ast_node.js';
|
|
import {Marker} from './marker.js';
|
|
|
|
/** Options object for LineCursor instances. */
|
|
export interface CursorOptions {
|
|
/**
|
|
* Can the cursor visit all stack connections (next/previous), or
|
|
* (if false) only unconnected next connections?
|
|
*/
|
|
stackConnections: boolean;
|
|
}
|
|
|
|
/** Default options for LineCursor instances. */
|
|
const defaultOptions: CursorOptions = {
|
|
stackConnections: true,
|
|
};
|
|
|
|
/**
|
|
* Class for a line cursor.
|
|
*/
|
|
export class LineCursor extends Marker {
|
|
override type = 'cursor';
|
|
|
|
/** Options for this line cursor. */
|
|
private readonly options: CursorOptions;
|
|
|
|
/** Locations to try moving the cursor to after a deletion. */
|
|
private potentialNodes: ASTNode[] | null = null;
|
|
|
|
/** Whether the renderer is zelos-style. */
|
|
private isZelos = false;
|
|
|
|
/**
|
|
* @param workspace The workspace this cursor belongs to.
|
|
* @param options Cursor options.
|
|
*/
|
|
constructor(
|
|
private readonly workspace: WorkspaceSvg,
|
|
options?: Partial<CursorOptions>,
|
|
) {
|
|
super();
|
|
// Regularise options and apply defaults.
|
|
this.options = {...defaultOptions, ...options};
|
|
|
|
this.isZelos = workspace.getRenderer() instanceof Renderer;
|
|
}
|
|
|
|
/**
|
|
* 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(): ASTNode | null {
|
|
const curNode = this.getCurNode();
|
|
if (!curNode) {
|
|
return null;
|
|
}
|
|
const newNode = this.getNextNode(
|
|
curNode,
|
|
this.validLineNode.bind(this),
|
|
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(): ASTNode | null {
|
|
const curNode = this.getCurNode();
|
|
if (!curNode) {
|
|
return null;
|
|
}
|
|
const newNode = this.getNextNode(
|
|
curNode,
|
|
this.validInLineNode.bind(this),
|
|
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(): ASTNode | null {
|
|
const curNode = this.getCurNode();
|
|
if (!curNode) {
|
|
return null;
|
|
}
|
|
const newNode = this.getPreviousNode(
|
|
curNode,
|
|
this.validLineNode.bind(this),
|
|
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(): ASTNode | null {
|
|
const curNode = this.getCurNode();
|
|
if (!curNode) {
|
|
return null;
|
|
}
|
|
const newNode = this.getPreviousNode(
|
|
curNode,
|
|
this.validInLineNode.bind(this),
|
|
true,
|
|
);
|
|
|
|
if (newNode) {
|
|
this.setCurNode(newNode);
|
|
}
|
|
return newNode;
|
|
}
|
|
|
|
/**
|
|
* Returns true iff the node to which we would navigate if in() were
|
|
* called, which will be a validInLineNode, is also a validLineNode
|
|
* - 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 rightNode = this.getNextNode(
|
|
curNode,
|
|
this.validInLineNode.bind(this),
|
|
false,
|
|
);
|
|
return this.validLineNode(rightNode);
|
|
}
|
|
|
|
/**
|
|
* Returns true iff the given node represents the "beginning of a
|
|
* new line of code" (and thus can be visited by pressing the
|
|
* up/down arrow keys). Specifically, if the node is for:
|
|
*
|
|
* - Any block that is not a value block.
|
|
* - A top-level value block (one that is unconnected).
|
|
* - An unconnected next statement input.
|
|
* - An unconnected 'next' connection - the "blank line at the end".
|
|
* This is to facilitate connecting additional blocks to a
|
|
* stack/substack.
|
|
*
|
|
* If options.stackConnections is true (the default) then allow the
|
|
* cursor to visit all (useful) stack connection by additionally
|
|
* returning true for:
|
|
*
|
|
* - Any next statement input
|
|
* - Any 'next' connection.
|
|
* - An unconnected previous statement input.
|
|
*
|
|
* @param node The AST node to check.
|
|
* @returns True if the node should be visited, false otherwise.
|
|
*/
|
|
protected validLineNode(node: ASTNode | null): boolean {
|
|
if (!node) return false;
|
|
const location = node.getLocation();
|
|
const type = node && node.getType();
|
|
switch (type) {
|
|
case ASTNode.types.BLOCK:
|
|
return !(location as Block).outputConnection?.isConnected();
|
|
case ASTNode.types.INPUT: {
|
|
const connection = location as Connection;
|
|
return (
|
|
connection.type === ConnectionType.NEXT_STATEMENT &&
|
|
(this.options.stackConnections || !connection.isConnected())
|
|
);
|
|
}
|
|
case ASTNode.types.NEXT:
|
|
return (
|
|
this.options.stackConnections ||
|
|
!(location as Connection).isConnected()
|
|
);
|
|
case ASTNode.types.PREVIOUS:
|
|
return (
|
|
this.options.stackConnections &&
|
|
!(location as Connection).isConnected()
|
|
);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true iff the given node can be visited by the cursor when
|
|
* using the left/right arrow keys. Specifically, if the node is
|
|
* any node for which valideLineNode would return true, plus:
|
|
*
|
|
* - Any block.
|
|
* - Any field that is not a full block field.
|
|
* - Any unconnected next or input connection. This is to
|
|
* facilitate connecting additional blocks.
|
|
*
|
|
* @param node The AST node to check whether it is valid.
|
|
* @returns True if the node should be visited, false otherwise.
|
|
*/
|
|
protected validInLineNode(node: ASTNode | null): boolean {
|
|
if (!node) return false;
|
|
if (this.validLineNode(node)) return true;
|
|
const location = node.getLocation();
|
|
const type = node && node.getType();
|
|
switch (type) {
|
|
case ASTNode.types.BLOCK:
|
|
return true;
|
|
case ASTNode.types.INPUT:
|
|
return !(location as Connection).isConnected();
|
|
case ASTNode.types.FIELD: {
|
|
const field = node.getLocation() as Field;
|
|
return !(
|
|
field.getSourceBlock()?.isSimpleReporter() && field.isFullBlockField()
|
|
);
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true iff the given node can be visited by the cursor.
|
|
* Specifically, if the node is any for which validInLineNode would
|
|
* return true, or if it is a workspace node.
|
|
*
|
|
* @param node The AST node to check whether it is valid.
|
|
* @returns True if the node should be visited, false otherwise.
|
|
*/
|
|
protected validNode(node: ASTNode | null): boolean {
|
|
return (
|
|
!!node &&
|
|
(this.validInLineNode(node) || node.getType() === ASTNode.types.WORKSPACE)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @returns The next node in the traversal.
|
|
*/
|
|
private getNextNodeImpl(
|
|
node: ASTNode | null,
|
|
isValid: (p1: ASTNode | null) => boolean,
|
|
): ASTNode | null {
|
|
if (!node) return null;
|
|
let newNode = node.in() || node.next();
|
|
if (isValid(newNode)) return newNode;
|
|
if (newNode) return this.getNextNodeImpl(newNode, isValid);
|
|
|
|
newNode = this.findSiblingOrParentSibling(node.out());
|
|
if (isValid(newNode)) return newNode;
|
|
if (newNode) return this.getNextNodeImpl(newNode, isValid);
|
|
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: ASTNode | null,
|
|
isValid: (p1: ASTNode | null) => boolean,
|
|
loop: boolean,
|
|
): ASTNode | null {
|
|
if (!node) return null;
|
|
|
|
const potential = this.getNextNodeImpl(node, isValid);
|
|
if (potential || !loop) return potential;
|
|
// Loop back.
|
|
const firstNode = this.getFirstNode();
|
|
if (isValid(firstNode)) return firstNode;
|
|
return this.getNextNodeImpl(firstNode, 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.
|
|
* @returns The previous node in the traversal or null if no previous node
|
|
* exists.
|
|
*/
|
|
private getPreviousNodeImpl(
|
|
node: ASTNode | null,
|
|
isValid: (p1: ASTNode | null) => boolean,
|
|
): ASTNode | null {
|
|
if (!node) return null;
|
|
let newNode: ASTNode | null = node.prev();
|
|
|
|
if (newNode) {
|
|
newNode = this.getRightMostChild(newNode);
|
|
} else {
|
|
newNode = node.out();
|
|
}
|
|
|
|
if (isValid(newNode)) return newNode;
|
|
if (newNode) return this.getPreviousNodeImpl(newNode, isValid);
|
|
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: ASTNode | null,
|
|
isValid: (p1: ASTNode | null) => boolean,
|
|
loop: boolean,
|
|
): ASTNode | null {
|
|
if (!node) return null;
|
|
|
|
const potential = this.getPreviousNodeImpl(node, isValid);
|
|
if (potential || !loop) return potential;
|
|
// Loop back.
|
|
const lastNode = this.getLastNode();
|
|
if (isValid(lastNode)) return lastNode;
|
|
return this.getPreviousNodeImpl(lastNode, isValid);
|
|
}
|
|
|
|
/**
|
|
* From the given node find either the next valid sibling or the parent's
|
|
* next sibling.
|
|
*
|
|
* @param node The current position in the AST.
|
|
* @returns The next sibling node, the parent's next sibling, or null.
|
|
*/
|
|
private findSiblingOrParentSibling(node: ASTNode | null): ASTNode | null {
|
|
if (!node) return null;
|
|
const nextNode = node.next();
|
|
if (nextNode) return nextNode;
|
|
return this.findSiblingOrParentSibling(node.out());
|
|
}
|
|
|
|
/**
|
|
* 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: ASTNode): ASTNode | null {
|
|
let newNode = node.in();
|
|
if (!newNode) return node;
|
|
for (
|
|
let nextNode: ASTNode | null = newNode;
|
|
nextNode;
|
|
nextNode = newNode.next()
|
|
) {
|
|
newNode = nextNode;
|
|
}
|
|
return this.getRightMostChild(newNode);
|
|
}
|
|
|
|
/**
|
|
* 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: Block) {
|
|
const curNode = this.getCurNode();
|
|
|
|
const nodes: ASTNode[] = curNode ? [curNode] : [];
|
|
// The connection to which the deleted block is attached.
|
|
const parentConnection =
|
|
deletedBlock.previousConnection?.targetConnection ??
|
|
deletedBlock.outputConnection?.targetConnection;
|
|
if (parentConnection) {
|
|
const parentNode = ASTNode.createConnectionNode(parentConnection);
|
|
if (parentNode) nodes.push(parentNode);
|
|
}
|
|
// The block connected to the next connection of the deleted block.
|
|
const nextBlock = deletedBlock.getNextBlock();
|
|
if (nextBlock) {
|
|
const nextNode = ASTNode.createBlockNode(nextBlock);
|
|
if (nextNode) nodes.push(nextNode);
|
|
}
|
|
// The parent block of the deleted block.
|
|
const parentBlock = deletedBlock.getParent();
|
|
if (parentBlock) {
|
|
const parentNode = ASTNode.createBlockNode(parentBlock);
|
|
if (parentNode) nodes.push(parentNode);
|
|
}
|
|
// A location on the workspace beneath the deleted block.
|
|
// Move to the workspace.
|
|
const curBlock = curNode?.getSourceBlock();
|
|
if (curBlock) {
|
|
const workspaceNode = ASTNode.createWorkspaceNode(
|
|
this.workspace,
|
|
curBlock.getRelativeToSurfaceXY(),
|
|
);
|
|
if (workspaceNode) nodes.push(workspaceNode);
|
|
}
|
|
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.validNode(node) && !node.getSourceBlock()?.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(): ASTNode | null {
|
|
if (!this.updateCurNodeFromFocus()) {
|
|
// Fall back to selection if focus fails to sync. This can happen for
|
|
// non-focusable nodes or for cases when focus may not properly propagate
|
|
// (such as for mouse clicks).
|
|
this.updateCurNodeFromSelection();
|
|
}
|
|
return super.getCurNode();
|
|
}
|
|
|
|
/**
|
|
* Sets the object in charge of drawing the marker.
|
|
*
|
|
* We want to customize drawing, so rather than directly setting the given
|
|
* object, we instead set a wrapper proxy object that passes through all
|
|
* method calls and property accesses except for draw(), which it delegates
|
|
* to the drawMarker() method in this class.
|
|
*
|
|
* @param drawer The object ~in charge of drawing the marker.
|
|
*/
|
|
override setDrawer(drawer: MarkerSvg) {
|
|
const altDraw = function (
|
|
this: LineCursor,
|
|
oldNode: ASTNode | null,
|
|
curNode: ASTNode | null,
|
|
) {
|
|
// Pass the unproxied, raw drawer object so that drawMarker can call its
|
|
// `draw()` method without triggering infinite recursion.
|
|
this.drawMarker(oldNode, curNode, drawer);
|
|
}.bind(this);
|
|
|
|
super.setDrawer(
|
|
new Proxy(drawer, {
|
|
get(target: typeof drawer, prop: keyof typeof drawer) {
|
|
if (prop === 'draw') {
|
|
return altDraw;
|
|
}
|
|
|
|
return target[prop];
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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: ASTNode | null) {
|
|
super.setCurNode(newNode);
|
|
|
|
const newNodeLocation = newNode?.getLocation();
|
|
if (isFocusableNode(newNodeLocation)) {
|
|
getFocusManager().focusNode(newNodeLocation);
|
|
}
|
|
|
|
// Try to scroll cursor into view.
|
|
if (newNode?.getType() === ASTNode.types.BLOCK) {
|
|
const block = newNode.getLocation() as BlockSvg;
|
|
block.workspace.scrollBoundsIntoView(
|
|
block.getBoundingRectangleWithoutChildren(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw this cursor's marker.
|
|
*
|
|
* This is a wrapper around this.drawer.draw (usually implemented by
|
|
* MarkerSvg.prototype.draw) that will, if newNode is a BLOCK node,
|
|
* instead call `setSelected` to select it (if it's a regular block)
|
|
* or `addSelect` (if it's a shadow block, since shadow blocks can't
|
|
* be selected) instead of using the normal drawer logic.
|
|
*
|
|
* TODO(#142): The selection and fake-selection code was originally
|
|
* a hack added for testing on October 28 2024, because the default
|
|
* drawer (MarkerSvg) behaviour in Zelos was to draw a box around
|
|
* the block and all attached child blocks, which was confusing when
|
|
* navigating stacks.
|
|
*
|
|
* Since then we have decided that we probably _do_ in most cases
|
|
* want navigating to a block to select the block, but more
|
|
* particularly that we want navigation to move _focus_. Replace
|
|
* this selection hack with non-hacky changing of focus once that's
|
|
* possible.
|
|
*
|
|
* @param oldNode The previous node.
|
|
* @param curNode The current node.
|
|
* @param realDrawer The object ~in charge of drawing the marker.
|
|
*/
|
|
private drawMarker(
|
|
oldNode: ASTNode | null,
|
|
curNode: ASTNode | null,
|
|
realDrawer: MarkerSvg,
|
|
) {
|
|
// If old node was a block, unselect it or remove fake selection.
|
|
if (oldNode?.getType() === ASTNode.types.BLOCK) {
|
|
const block = oldNode.getLocation() as BlockSvg;
|
|
if (!block.isShadow()) {
|
|
// Selection should already be in sync.
|
|
} else {
|
|
block.removeSelect();
|
|
}
|
|
}
|
|
|
|
if (this.isZelos && oldNode && this.isValueInputConnection(oldNode)) {
|
|
this.hideAtInput(oldNode);
|
|
}
|
|
|
|
const curNodeType = curNode?.getType();
|
|
const isZelosInputConnection =
|
|
this.isZelos && curNode && this.isValueInputConnection(curNode);
|
|
|
|
// If drawing can't be handled locally, just use the drawer.
|
|
if (curNodeType !== ASTNode.types.BLOCK && !isZelosInputConnection) {
|
|
realDrawer.draw(oldNode, curNode);
|
|
return;
|
|
}
|
|
|
|
// Hide any visible marker SVG and instead do some manual rendering.
|
|
realDrawer.hide();
|
|
|
|
if (isZelosInputConnection) {
|
|
this.showAtInput(curNode);
|
|
} else if (curNode && curNodeType === ASTNode.types.BLOCK) {
|
|
const block = curNode.getLocation() as BlockSvg;
|
|
if (!block.isShadow()) {
|
|
// Selection should already be in sync.
|
|
} else {
|
|
block.addSelect();
|
|
block.getParent()?.removeSelect();
|
|
}
|
|
}
|
|
|
|
// Call MarkerSvg.prototype.fireMarkerEvent like
|
|
// MarkerSvg.prototype.draw would (even though it's private).
|
|
(realDrawer as any)?.fireMarkerEvent?.(oldNode, curNode);
|
|
}
|
|
|
|
/**
|
|
* Check whether the node represents a value input connection.
|
|
*
|
|
* @param node The node to check
|
|
* @returns True if the node represents a value input connection.
|
|
*/
|
|
private isValueInputConnection(node: ASTNode) {
|
|
if (node?.getType() !== ASTNode.types.INPUT) return false;
|
|
const connection = node.getLocation() as Connection;
|
|
return connection.type === ConnectionType.INPUT_VALUE;
|
|
}
|
|
|
|
/**
|
|
* Hide the cursor rendering at the given input node.
|
|
*
|
|
* @param node The input node to hide.
|
|
*/
|
|
private hideAtInput(node: ASTNode) {
|
|
const inputConnection = node.getLocation() as Connection;
|
|
const sourceBlock = inputConnection.getSourceBlock() as BlockSvg;
|
|
const input = inputConnection.getParentInput();
|
|
if (input) {
|
|
const pathObject = sourceBlock.pathObject as PathObject;
|
|
const outlinePath = pathObject.getOutlinePath(input.name);
|
|
dom.removeClass(outlinePath, 'inputActiveFocus');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show the cursor rendering at the given input node.
|
|
*
|
|
* @param node The input node to show.
|
|
*/
|
|
private showAtInput(node: ASTNode) {
|
|
const inputConnection = node.getLocation() as Connection;
|
|
const sourceBlock = inputConnection.getSourceBlock() as BlockSvg;
|
|
const input = inputConnection.getParentInput();
|
|
if (input) {
|
|
const pathObject = sourceBlock.pathObject as PathObject;
|
|
const outlinePath = pathObject.getOutlinePath(input.name);
|
|
dom.addClass(outlinePath, 'inputActiveFocus');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the current node to match the selection.
|
|
*
|
|
* Clears the current node if it's on a block but the selection is null.
|
|
* Sets the node to a block if selected for our workspace.
|
|
* For shadow blocks selections the parent is used by default (unless we're
|
|
* already on the shadow block via keyboard) as that's where the visual
|
|
* selection is.
|
|
*/
|
|
private updateCurNodeFromSelection() {
|
|
const curNode = super.getCurNode();
|
|
const selected = common.getSelected();
|
|
|
|
if (selected === null && curNode?.getType() === ASTNode.types.BLOCK) {
|
|
this.setCurNode(null);
|
|
return;
|
|
}
|
|
if (selected?.workspace !== this.workspace) {
|
|
return;
|
|
}
|
|
if (selected instanceof BlockSvg) {
|
|
let block: BlockSvg | null = selected;
|
|
if (selected.isShadow()) {
|
|
// OK if the current node is on the parent OR the shadow block.
|
|
// The former happens for clicks, the latter for keyboard nav.
|
|
if (
|
|
curNode &&
|
|
(curNode.getLocation() === block ||
|
|
curNode.getLocation() === block.getParent())
|
|
) {
|
|
return;
|
|
}
|
|
block = block.getParent();
|
|
}
|
|
if (block) {
|
|
this.setCurNode(ASTNode.createBlockNode(block));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the current node to match what's currently focused.
|
|
*
|
|
* @returns Whether the current node has been set successfully from the
|
|
* current focused node.
|
|
*/
|
|
private updateCurNodeFromFocus(): boolean {
|
|
const focused = getFocusManager().getFocusedNode();
|
|
|
|
if (focused instanceof BlockSvg) {
|
|
const block: BlockSvg | null = focused;
|
|
if (block && block.workspace === this.workspace) {
|
|
if (block.getRootBlock() === block && this.workspace.isFlyout) {
|
|
// This block actually represents a stack. Note that this is needed
|
|
// because ASTNode special cases stack for cross-block navigation.
|
|
this.setCurNode(ASTNode.createStackNode(block));
|
|
} else {
|
|
this.setCurNode(ASTNode.createBlockNode(block));
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get the first navigable node on the workspace, or null if none exist.
|
|
*
|
|
* @returns The first navigable node on the workspace, or null.
|
|
*/
|
|
getFirstNode(): ASTNode | null {
|
|
const topBlocks = this.workspace.getTopBlocks(true);
|
|
if (!topBlocks.length) return null;
|
|
return ASTNode.createTopNode(topBlocks[0]);
|
|
}
|
|
|
|
/**
|
|
* Get the last navigable node on the workspace, or null if none exist.
|
|
*
|
|
* @returns The last navigable node on the workspace, or null.
|
|
*/
|
|
getLastNode(): ASTNode | null {
|
|
// Loop back to last block if it exists.
|
|
const topBlocks = this.workspace.getTopBlocks(true);
|
|
if (!topBlocks.length) return null;
|
|
|
|
// Find the last stack.
|
|
const lastTopBlockNode = ASTNode.createStackNode(
|
|
topBlocks[topBlocks.length - 1],
|
|
);
|
|
let prevNode = lastTopBlockNode;
|
|
let nextNode: ASTNode | null = lastTopBlockNode;
|
|
// Iterate until you fall off the end of the stack.
|
|
while (nextNode) {
|
|
prevNode = nextNode;
|
|
nextNode = this.getNextNode(
|
|
prevNode,
|
|
(node) => {
|
|
return !!node;
|
|
},
|
|
false,
|
|
);
|
|
}
|
|
return prevNode;
|
|
}
|
|
}
|
|
|
|
registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor);
|