From 531bb8dab12ea055fddca3776da0873c020bf41c Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 3 Jan 2025 08:20:58 -0800 Subject: [PATCH 01/69] WIP lexical caret --- packages/lexical-utils/src/index.ts | 120 ++-- packages/lexical/src/LexicalCaret.ts | 558 ++++++++++++++++++ .../src/__tests__/unit/LexicalCaret.test.ts | 375 ++++++++++++ packages/lexical/src/index.ts | 142 +++-- 4 files changed, 1070 insertions(+), 125 deletions(-) create mode 100644 packages/lexical/src/LexicalCaret.ts create mode 100644 packages/lexical/src/__tests__/unit/LexicalCaret.test.ts diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 0a758f40f2f..774b013297c 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -9,6 +9,9 @@ import { $cloneWithProperties, $createParagraphNode, + $getBreadthCaret, + $getChildCaretAtIndex, + $getDepthCaret, $getPreviousSelection, $getRoot, $getSelection, @@ -18,11 +21,14 @@ import { $isTextNode, $setSelection, $splitNode, + BreadthNodeCaret, + CaretDirection, EditorState, ElementNode, Klass, LexicalEditor, LexicalNode, + NodeCaret, NodeKey, } from 'lexical'; // This underscore postfixing is used as a hotfix so we do not @@ -198,6 +204,18 @@ const iteratorNotDone: (value: T) => Readonly<{done: false; value: T}> = ( value: T, ) => ({done: false, value}); +/** + * Get the adjacent caret in the same direction + * + * @param caret A caret or null + * @returns `caret.getAdjacentCaret()` or `null` + */ +export function $getAdjacentCaret( + caret: null | NodeCaret, +): null | BreadthNodeCaret { + return caret ? caret.getAdjacentCaret() : null; +} + /** * $dfs iterator. Tree traversal is done on the fly as new values are requested with O(1) memory. * @param startNode - The node to start the search, if omitted, it will start at the root node. @@ -208,42 +226,41 @@ export function $dfsIterator( startNode?: LexicalNode, endNode?: LexicalNode, ): DFSIterator { - const start = (startNode || $getRoot()).getLatest(); - const startDepth = $getDepth(start); - const end = endNode; - let node: null | LexicalNode = start; + const root = $getRoot(); + const start = startNode || root; + const startCaret = $isElementNode(start) + ? $getDepthCaret(start, 'next') + : $getBreadthCaret(start, 'previous').getFlipped(); + const startDepth = $getDepth(startCaret.getParentAtCaret()); + const endDepth = endNode == null ? startDepth : 0; + const rootMode = 'root'; + let depth = startDepth; - let isFirstNext = true; + let caret: null | NodeCaret<'next'> = startCaret; const iterator: DFSIterator = { next(): IteratorResult { - if (node === null) { + if (caret === null) { return iteratorDone; } - if (isFirstNext) { - isFirstNext = false; - return iteratorNotDone({depth, node}); + const rval = iteratorNotDone({depth, node: caret.origin}); + if (caret.type === 'depth') { + depth++; } - if (node === end) { - return iteratorDone; + if (caret && caret.origin.is(endNode)) { + caret = null; } - - if ($isElementNode(node) && node.getChildrenSize() > 0) { - node = node.getFirstChild(); - depth++; - } else { - let depthDiff; - [node, depthDiff] = $getNextSiblingOrParentSibling(node) || [null, 0]; - depth += depthDiff; - if (end == null && depth <= startDepth) { - node = null; + let nextCaret = $getAdjacentCaret(caret); + while (caret !== null && nextCaret === null) { + depth--; + caret = depth > endDepth ? caret.getParentCaret(rootMode) : null; + if (caret && caret.origin.is(endNode)) { + caret = null; } + nextCaret = $getAdjacentCaret(caret); } - - if (node === null) { - return iteratorDone; - } - return iteratorNotDone({depth, node}); + caret = nextCaret ? nextCaret.getChildCaret() || nextCaret : null; + return rval; }, [Symbol.iterator](): DFSIterator { return iterator; @@ -542,12 +559,7 @@ export function $insertNodeToNearestRoot(node: T): T { const focusOffset = focus.offset; if ($isRootOrShadowRoot(focusNode)) { - const focusChild = focusNode.getChildAtIndex(focusOffset); - if (focusChild == null) { - focusNode.append(node); - } else { - focusChild.insertBefore(node); - } + $getChildCaretAtIndex(focusNode, focusOffset).insert(node); node.selectNext(); } else { let splitNode: ElementNode; @@ -572,8 +584,7 @@ export function $insertNodeToNearestRoot(node: T): T { const nodes = selection.getNodes(); nodes[nodes.length - 1].getTopLevelElementOrThrow().insertAfter(node); } else { - const root = $getRoot(); - root.append(node); + $getRoot().append(node); } const paragraphNode = $createParagraphNode(); node.insertAfter(paragraphNode); @@ -641,12 +652,7 @@ export function $filter( * @param node Node that needs to be appended */ export function $insertFirst(parent: ElementNode, node: LexicalNode): void { - const firstChild = parent.getFirstChild(); - if (firstChild !== null) { - firstChild.insertBefore(node); - } else { - parent.append(node); - } + $getDepthCaret(parent, 'next').insert(node); } let NEEDS_MANUAL_ZOOM = IS_FIREFOX || !CAN_USE_DOM ? false : undefined; @@ -784,10 +790,7 @@ export function $descendantsMatching( * @returns An iterator of the node's children */ export function $firstToLastIterator(node: ElementNode): Iterable { - return { - [Symbol.iterator]: () => - $childIterator(node.getFirstChild(), (child) => child.getNextSibling()), - }; + return $caretNodeIterator($getDepthCaret(node, 'next')); } /** @@ -799,28 +802,26 @@ export function $firstToLastIterator(node: ElementNode): Iterable { * @returns An iterator of the node's children */ export function $lastToFirstIterator(node: ElementNode): Iterable { - return { - [Symbol.iterator]: () => - $childIterator(node.getLastChild(), (child) => - child.getPreviousSibling(), - ), - }; + return $caretNodeIterator($getDepthCaret(node, 'previous')); } -function $childIterator( - initialNode: LexicalNode | null, - nextNode: (node: LexicalNode) => LexicalNode | null, -): Iterator { - let state = initialNode; +function $caretNodeIterator( + startCaret: NodeCaret, +): IterableIterator { + const iter = startCaret[Symbol.iterator](); const seen = __DEV__ ? new Set() : null; return { + [Symbol.iterator]() { + return this; + }, next() { - if (state === null) { + const step = iter.next(); + if (step.done) { return iteratorDone; } - const rval = iteratorNotDone(state); + const {origin} = step.value; if (__DEV__ && seen !== null) { - const key = state.getKey(); + const key = origin.getKey(); invariant( !seen.has(key), '$childIterator: Cycle detected, node with key %s has already been traversed', @@ -828,8 +829,7 @@ function $childIterator( ); seen.add(key); } - state = nextNode(state); - return rval; + return iteratorNotDone(origin); }, }; } diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts new file mode 100644 index 00000000000..8923ff14e42 --- /dev/null +++ b/packages/lexical/src/LexicalCaret.ts @@ -0,0 +1,558 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {LexicalNode, NodeKey} from './LexicalNode'; +import type {PointType, RangeSelection} from './LexicalSelection'; + +import invariant from 'shared/invariant'; + +import {$getNodeByKeyOrThrow, $isRootOrShadowRoot} from './LexicalUtils'; +import {$isElementNode, type ElementNode} from './nodes/LexicalElementNode'; +import {$isRootNode} from './nodes/LexicalRootNode'; +import {$isTextNode} from './nodes/LexicalTextNode'; + +export type CaretDirection = 'next' | 'previous'; +export type FlipDirection = typeof FLIP_DIRECTION[D]; +export type CaretType = 'breadth' | 'depth'; +export type RootMode = 'root' | 'shadowRoot'; + +const FLIP_DIRECTION = { + next: 'previous', + previous: 'next', +} as const; + +interface BaseNodeCaret + extends Iterable> { + /** The origin node of this caret, typically this is what you will use in traversals */ + readonly origin: T; + /** breadth for a BreadthNodeCaret (pointing at the next or previous sibling) or depth for a DepthNodeCaret (pointing at the first or last child) */ + readonly type: CaretType; + /** next if pointing at the next sibling or first child, previous if pointing at the previous sibling or last child */ + readonly direction: D; + /** Retun true if other is a caret with the same origin (by node key comparion), type, and direction */ + is(other: NodeCaret | null): boolean; + /** + * Get a new NodeCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity. + * For example, given a non-empty parent with a firstChild and lastChild, and a second emptyParent node with no children: + * + * @example + * ``` + * caret.getFlipped().getFlipped().is(caret) === true; + * $getDepthCaret(parent, 'next').getFlipped().is($getBreadthCaret(firstChild, 'previous')) === true; + * $getBreadthCaret(lastChild, 'next').getFlipped().is($getDepthCaret(parent, 'previous')) === true; + * $getBreadthCaret(firstChild, 'next).getFlipped().is($getBreadthCaret(lastChild, 'previous')) === true; + * $getDepthCaret(emptyParent, 'next').getFlipped().is($getDepthCaret(emptyParent, 'previous')) === true; + * ``` + */ + getFlipped(): NodeCaret>; + /** Get the ElementNode that is the logical parent (`origin` for `DepthNodeCaret`, `origin.getParentOrThrow()` for `BreadthNodeCaret`) */ + getParentAtCaret(): ElementNode; + /** Get the node connected to the origin in the caret's direction, or null if there is no node */ + getNodeAtCaret(): null | LexicalNode; + /** Get a new BreadthNodeCaret from getNodeAtCaret() in the same direction. This is used for traversals, but only goes in the breadth (sibling) direction. */ + getAdjacentCaret(): null | BreadthNodeCaret; + /** Remove the getNodeAtCaret() node, if it exists */ + remove(): this; + /** + * Insert a node connected to origin in this direction. + * For a `BreadthNodeCaret` this is `origin.insertAfter(node)` for next, or `origin.insertBefore(node)` for previous. + * For a `DepthNodeCaret` this is `origin.splice(0, 0, [node])` for next or `origin.append(node)` for previous. + */ + insert(node: LexicalNode): this; + /** If getNodeAtCaret() is null then replace it with node, otherwise insert node */ + replaceOrInsert(node: LexicalNode, includeChildren?: boolean): this; + /** + * Splice an iterable (typically an Array) of nodes into this location. + * + * @param deleteCount The number of existing nodes to replace or delete + * @param nodes An iterable of nodes that will be inserted in this location, using replace instead of insert for the first deleteCount nodes + * @param nodesDirection The direction of the nodes iterable, defaults to 'next' + */ + splice( + deleteCount: number, + nodes: Iterable, + nodesDirection?: CaretDirection, + ): this; +} + +/** + * A NodeCaret is the combination of an origin node and a direction + * that points towards where a connected node will be fetched, inserted, + * or replaced. A BreadthNodeCaret points from a node to its next or previous + * sibling, and a DepthNodeCaret points to its first or last child + * (using next or previous as direction, for symmetry with BreadthNodeCaret). + * + * The differences between NodeCaret and PointType are: + * - NodeCaret can only be used to refer to an entire node. A PointType of text type can be used to refer to a specific location inside of a TextNode. + * - NodeCaret stores an origin node, type (breadth or depth), and direction (next or previous). A PointType stores a type (text or element), the key of a node, and an offset within that node. + * - NodeCaret is directional and always refers to a very specific node, eliminating all ambiguity. PointType can refer to the location before or after a node depending on context. + * - NodeCaret is more robust to nearby mutations, as it relies only on a node's direct connections. An element Any change to the count of previous siblings in an element PointType will invalidate it. + * - NodeCaret is designed to work more directly with the internal representation of the document tree, making it suitable for use in traversals without performing any redundant work. + * + * The caret does *not* update in response to any mutations, you should + * not persist it across editor updates, and using a caret after its origin + * node has been removed or replaced may result in runtime errors. + */ +export type NodeCaret = + | BreadthNodeCaret + | DepthNodeCaret; + +/** + * A BreadthNodeCaret points from an origin LexicalNode towards its next or previous sibling. + */ +export interface BreadthNodeCaret< + T extends LexicalNode = LexicalNode, + D extends CaretDirection = CaretDirection, +> extends BaseNodeCaret { + readonly type: 'breadth'; + /** + * If the origin of this node is an ElementNode, return the DepthNodeCaret of this origin in the same direction. + * If the origin is not an ElementNode, this will return null. + */ + getChildCaret(): DepthNodeCaret | null; + /** + * Get the caret in the same direction from the parent of this origin. + * + * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root + * @returns A BreadthNodeCaret with the parent of this origin, or null if the parent is a root according to mode. + */ + getParentCaret(mode: RootMode): null | BreadthNodeCaret; +} + +/** + * A DepthNodeCaret points from an origin ElementNode towards its first or last child. + */ +export interface DepthNodeCaret< + T extends ElementNode = ElementNode, + D extends CaretDirection = CaretDirection, +> extends BaseNodeCaret { + readonly type: 'depth'; + getParentCaret(mode: RootMode): null | BreadthNodeCaret; + getParentAtCaret(): T; +} + +abstract class AbstractCaret + implements BaseNodeCaret +{ + abstract readonly type: CaretType; + abstract readonly direction: D; + readonly origin: T; + abstract getNodeAtCaret(): null | LexicalNode; + abstract insert(node: LexicalNode): this; + abstract getFlipped(): NodeCaret>; + abstract getParentAtCaret(): ElementNode; + constructor(origin: T) { + this.origin = origin; + } + is(other: NodeCaret | null): boolean { + return ( + other !== null && + other.type === this.type && + other.direction === this.direction && + this.origin.is(other.origin) + ); + } + [Symbol.iterator](): Iterator> { + let caret = this.getAdjacentCaret(); + return { + next(): IteratorResult> { + if (!caret) { + return {done: true, value: undefined}; + } + const rval = {done: false, value: caret}; + caret = caret.getAdjacentCaret(); + return rval; + }, + }; + } + getAdjacentCaret(): null | BreadthNodeCaret { + return $getBreadthCaret(this.getNodeAtCaret(), this.direction); + } + remove(): this { + const node = this.getNodeAtCaret(); + if (node) { + node.remove(); + } + return this; + } + replaceOrInsert(node: LexicalNode, includeChildren?: boolean): this { + const target = this.getNodeAtCaret(); + if (node.is(this.origin) || node.is(target)) { + // do nothing + } else if (target === null) { + this.insert(node); + } else { + target.replace(node, includeChildren); + } + return this; + } + splice( + deleteCount: number, + nodes: Iterable, + nodesDirection: CaretDirection = 'next', + ): this { + const nodeIter = + nodesDirection === this.direction ? nodes : Array.from(nodes).reverse(); + let caret: BreadthNodeCaret | this = this; + const parent = this.getParentAtCaret(); + const nodesToRemove = new Map(); + // Find all of the nodes we expect to remove first, so + // we don't have to worry about the cases where there is + // overlap between the nodes to insert and the nodes to + // remove + for ( + let removeCaret = caret.getAdjacentCaret(); + removeCaret !== null && nodesToRemove.size < deleteCount; + removeCaret = removeCaret.getAdjacentCaret() + ) { + const writableNode = removeCaret.origin.getWritable(); + nodesToRemove.set(writableNode.getKey(), writableNode); + } + // TODO: Optimize this to work directly with node internals + for (const node of nodeIter) { + if (nodesToRemove.size > 0) { + const target = caret.getNodeAtCaret(); + invariant( + target !== null, + 'NodeCaret.splice: Underflow of expected nodesToRemove during splice (keys: %s)', + Array.from(nodesToRemove).join(' '), + ); + nodesToRemove.delete(target.getKey()); + nodesToRemove.delete(node.getKey()); + if (target.is(node) || caret.origin.is(node)) { + // do nothing, it's already in the right place + } else { + if (parent.is(node.getParent())) { + // It's a sibling somewhere else in this node, so unparent it first + node.remove(); + } + target.replace(node); + } + } else { + caret.insert(node); + } + caret = $getBreadthCaret(node, this.direction); + } + for (const node of nodesToRemove.values()) { + node.remove(); + } + return this; + } +} + +abstract class AbstractDepthNodeCaret< + T extends ElementNode, + D extends CaretDirection, + > + extends AbstractCaret + implements DepthNodeCaret +{ + readonly type = 'depth'; + /** + * Get the BreadthNodeCaret from this origin in the same direction. + * + * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root + * @returns A BreadthNodeCaret with this origin, or null if origin is a root according to mode. + */ + getParentCaret(mode: RootMode): null | BreadthNodeCaret { + return $getBreadthCaret( + $filterByMode(this.getParentAtCaret(), mode), + this.direction, + ); + } + getFlipped(): NodeCaret { + const dir = FLIP_DIRECTION[this.direction]; + return ( + $getBreadthCaret(this.getNodeAtCaret(), dir) || + $getDepthCaret(this.origin, dir) + ); + } + getParentAtCaret(): T { + return this.origin; + } +} + +class DepthNodeCaretFirst extends AbstractDepthNodeCaret< + T, + 'next' +> { + readonly direction = 'next'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getFirstChild(); + } + insert(node: LexicalNode): this { + this.origin.splice(0, 0, [node]); + return this; + } +} + +class DepthNodeCaretLast extends AbstractDepthNodeCaret< + T, + 'previous' +> { + readonly direction = 'previous'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getLastChild(); + } + insert(node: LexicalNode): this { + this.origin.splice(this.origin.getChildrenSize(), 0, [node]); + return this; + } +} + +const MODE_PREDICATE = { + root: $isRootNode, + shadowRoot: $isRootOrShadowRoot, +} as const; + +function $filterByMode( + node: T | null, + mode: RootMode, +): T | null { + return MODE_PREDICATE[mode](node) ? null : node; +} + +abstract class AbstractBreadthNodeCaret< + T extends LexicalNode, + D extends CaretDirection, + > + extends AbstractCaret + implements BreadthNodeCaret +{ + readonly type = 'breadth'; + getParentAtCaret(): ElementNode { + return this.origin.getParentOrThrow(); + } + getChildCaret(): DepthNodeCaret | null { + return $isElementNode(this.origin) + ? $getDepthCaret(this.origin, this.direction) + : null; + } + getParentCaret(mode: RootMode): BreadthNodeCaret | null { + return $getBreadthCaret( + $filterByMode(this.getParentAtCaret(), mode), + this.direction, + ); + } + getFlipped(): NodeCaret> { + const dir = FLIP_DIRECTION[this.direction]; + return ( + $getBreadthCaret(this.getNodeAtCaret(), dir) || + $getDepthCaret(this.origin.getParentOrThrow(), dir) + ); + } +} + +class BreadthNodeCaretNext< + T extends LexicalNode, +> extends AbstractBreadthNodeCaret { + readonly direction = 'next'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getNextSibling(); + } + insert(node: LexicalNode): this { + this.origin.insertAfter(node); + return this; + } +} + +class BreadthNodeCaretPrevious< + T extends LexicalNode, +> extends AbstractBreadthNodeCaret { + readonly direction = 'previous'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getPreviousSibling(); + } + insert(node: LexicalNode): this { + this.origin.insertBefore(node); + return this; + } +} + +const BREADTH_CTOR = { + next: BreadthNodeCaretNext, + previous: BreadthNodeCaretPrevious, +} as const; + +const DEPTH_CTOR = { + next: DepthNodeCaretFirst, + previous: DepthNodeCaretLast, +}; + +/** + * Get a caret that points at the next or previous sibling of the given origin node. + * + * @param origin The origin node + * @param direction 'next' or 'previous' + * @returns null if origin is null, otherwise a BreadthNodeCaret for this origin and direction + */ +export function $getBreadthCaret< + T extends LexicalNode, + D extends CaretDirection, +>(origin: T, direction: D): BreadthNodeCaret; +export function $getBreadthCaret< + T extends LexicalNode, + D extends CaretDirection, +>(origin: T | null, direction: D): null | BreadthNodeCaret; +export function $getBreadthCaret( + origin: LexicalNode | null, + direction: CaretDirection, +): BreadthNodeCaret | null { + return origin ? new BREADTH_CTOR[direction](origin) : null; +} + +/** + * Get a caret that points at the first or last child of the given origin node, + * which must be an ElementNode. + * + * @param origin The origin ElementNode + * @param direction 'next' for first child or 'previous' for last child + * @returns null if origin is null or not an ElementNode, otherwise a DepthNodeCaret for this origin and direction + */ +export function $getDepthCaret( + node: T, + direction: D, +): DepthNodeCaret; +export function $getDepthCaret( + node: LexicalNode | null, + direction: CaretDirection, +): null | DepthNodeCaret { + return $isElementNode(node) ? new DEPTH_CTOR[direction](node) : null; +} + +/** + * Get a 'next' caret for the child at the given index, or the last + * caret in that node if out of bounds + * + * @param parent An ElementNode + * @param index The index of the origin for the caret + * @returns A next caret with the arrow at that index + */ +export function $getChildCaretAtIndex( + parent: T, + index: number, +): NodeCaret<'next'> { + let caret: NodeCaret<'next'> = $getDepthCaret(parent, 'next'); + for (let i = 0; i < index; i++) { + const nextCaret: null | BreadthNodeCaret = + caret.getAdjacentCaret(); + if (nextCaret === null) { + break; + } + caret = nextCaret; + } + return caret; +} + +class CaretRangeImpl implements CaretRange { + readonly type = 'caret-range'; + readonly direction: D; + anchor: NodeCaret; + focus: NodeCaret; + constructor(anchor: NodeCaret, focus: NodeCaret, direction: D) { + this.anchor = anchor; + this.focus = focus; + this.direction = direction; + } + isCollapsed(): boolean { + return this.anchor.is(this.focus); + } +} + +function $caretRangeFromStartEnd( + startCaret: NodeCaret<'next'>, + endCaret: NodeCaret<'next'>, + direction: CaretDirection, +): CaretRange { + if (direction === 'next') { + return new CaretRangeImpl(startCaret, endCaret, direction); + } else { + return new CaretRangeImpl( + endCaret.getFlipped(), + startCaret.getFlipped(), + direction, + ); + } +} + +/** + * A RangeSelection expressed as a pair of Carets + */ +export interface CaretRange { + readonly type: 'caret-range'; + readonly direction: D; + anchor: NodeCaret; + focus: NodeCaret; + isCollapsed(): boolean; +} + +/** + * Since a NodeCaret can only represent a whole node, when a text PointType + * is encountered the caret will lie before the text node if it is non-empty + * and offset === 0, otherwise it will lie after the node. + * + * @param point + * @returns a NodeCaret for the point + */ +export function $caretFromPoint(point: PointType): NodeCaret<'next'> { + const {type, key, offset} = point; + const node = $getNodeByKeyOrThrow(point.key); + if (type === 'text') { + invariant( + $isTextNode(node), + '$caretFromPoint: Node with type %s and key %s that does not inherit from TextNode encountered for text point', + node.getType(), + key, + ); + return offset === 0 && node.getTextContentSize() > 0 + ? $getBreadthCaret(node, 'next') + : $getBreadthCaret(node, 'previous').getFlipped(); + } + invariant( + $isElementNode(node), + '$caretToPoint: Node with type %s and key %s that does not inherit from ElementNode encountered for element point', + node.getType(), + key, + ); + return $getChildCaretAtIndex(node, point.offset); +} + +/** + * Get a pair of carets for a RangeSelection. Since a NodeCaret can + * only represent a whole node, when a text PointType is encountered + * the caret will be moved to before or after the node depending + * on where the other point lies. + * + * If the focus is before the anchor, then the direction will be + * 'previous', otherwise the direction will be 'next'. + */ +export function $caretRangeFromSelection( + selection: RangeSelection, +): CaretRange { + const direction = selection.isBackward() ? 'previous' : 'next'; + let startCaret: NodeCaret<'next'>; + let endCaret: NodeCaret<'next'>; + if ( + selection.anchor.type === 'text' && + selection.focus.type === 'text' && + selection.anchor.key === selection.focus.key && + selection.anchor.offset !== selection.focus.offset + ) { + const node = $getNodeByKeyOrThrow(selection.anchor.key); + // handle edge case where the start and end are on the same TextNode + startCaret = $getBreadthCaret(node, 'previous').getFlipped(); + endCaret = $getBreadthCaret(node, 'next'); + } else { + const [startPoint, endPoint] = + direction === 'next' + ? [selection.anchor, selection.focus] + : [selection.focus, selection.anchor]; + startCaret = $caretFromPoint(startPoint); + endCaret = $caretFromPoint(endPoint); + } + return $caretRangeFromStartEnd(startCaret, endCaret, direction); +} diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts new file mode 100644 index 00000000000..4bd3027b574 --- /dev/null +++ b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts @@ -0,0 +1,375 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createParagraphNode, + $createTextNode, + $getBreadthCaret, + $getRoot, + BreadthNodeCaret, + DepthNodeCaret, + LexicalNode, + RootNode, + TextNode, +} from 'lexical'; + +import {$getDepthCaret} from '../..'; +import {initializeUnitTest, invariant} from '../utils'; + +describe('LexicalCaret', () => { + initializeUnitTest((testEnv) => { + describe('$getDepthCaret', () => { + for (const direction of ['next', 'previous'] as const) { + test(`direction ${direction}`, async () => { + await testEnv.editor.update( + () => { + const paragraph = $createParagraphNode(); + const root = $getRoot(); + root.clear().append(paragraph); + // Note that the type declarations here would normally be inferred, these are + // used just to demonstrate that inference is working as expected + const caret: DepthNodeCaret = + $getDepthCaret(root, direction); + expect(root.is(caret.origin)).toBe(true); + expect(caret.direction).toBe(direction); + expect(caret.type).toBe('depth'); + expect(paragraph.is(caret.getNodeAtCaret())).toBe(true); + expect(root.is(caret.getParentAtCaret())).toBe(true); + + const flipped = caret.getFlipped(); + expect(flipped).not.toBe(caret); + expect(flipped.getFlipped().is(caret)).toBe(true); + expect(flipped.direction).not.toBe(direction); + expect(flipped.type).toBe('breadth'); + expect(flipped.getNodeAtCaret()).toBe(null); + expect(flipped.getAdjacentCaret()).toBe(null); + for (const mode of ['root', 'shadowRoot'] as const) { + expect(caret.getParentCaret(mode)).toBe(null); + expect(flipped.getParentCaret(mode)).toBe(null); + } + const adjacent: BreadthNodeCaret< + LexicalNode, + typeof direction + > | null = caret.getAdjacentCaret(); + invariant( + adjacent !== null, + 'depth caret of a non-empty element must always have an adjacent caret', + ); + expect(paragraph.is(adjacent.origin)).toBe(true); + expect(adjacent.type).toBe('breadth'); + expect(adjacent.getAdjacentCaret()).toBe(null); + + expect(root.getChildrenSize()).toBe(1); + caret.remove(); + expect(root.isEmpty()).toBe(true); + caret.replaceOrInsert(paragraph); + expect(root.getChildrenSize()).toBe(1); + caret.remove(); + caret.insert(paragraph); + expect(root.getChildrenSize()).toBe(1); + + // When direction === 'next' we are prepending the second node, otherwise we are appending it + const secondParagraph = $createParagraphNode(); + caret.insert(secondParagraph); + expect(root.getChildrenSize()).toBe(2); + const paragraphKeys = [ + paragraph.getKey(), + secondParagraph.getKey(), + ]; + expect(root.getChildrenKeys()).toEqual( + direction === 'next' + ? paragraphKeys.toReversed() + : paragraphKeys, + ); + + caret.splice(2, []); + expect(root.getChildrenSize()).toBe(0); + caret.splice(0, [paragraph, secondParagraph]); + expect(root.getChildrenKeys()).toEqual(paragraphKeys); + caret.splice(0, [secondParagraph, paragraph]); + expect(root.getChildrenKeys()).toEqual( + paragraphKeys.toReversed(), + ); + caret.splice(0, [paragraph, secondParagraph]); + expect(root.getChildrenKeys()).toEqual(paragraphKeys); + caret.splice(2, [secondParagraph, paragraph]); + expect(root.getChildrenKeys()).toEqual( + paragraphKeys.toReversed(), + ); + caret.splice(2, [paragraph, secondParagraph]); + expect(root.getChildrenKeys()).toEqual(paragraphKeys); + caret.splice(20, [paragraph]); + expect(root.getChildrenKeys()).toEqual([paragraph.getKey()]); + caret.splice(-1, [secondParagraph]); + expect(root.getChildrenKeys()).toEqual( + direction === 'next' + ? paragraphKeys.toReversed() + : paragraphKeys, + ); + caret.splice(Infinity, [paragraph, secondParagraph], direction); + expect(root.getChildrenKeys()).toEqual( + direction === 'next' + ? paragraphKeys + : paragraphKeys.toReversed(), + ); + + expect( + Array.from(caret, (nextCaret) => nextCaret.origin.getKey()), + ).toEqual( + direction === 'next' + ? root.getChildrenKeys() + : root.getChildrenKeys().toReversed(), + ); + }, + {discrete: true}, + ); + }); + } + }); + describe('$getBreadthCaret', () => { + for (const direction of ['next', 'previous'] as const) { + test(`direction ${direction}`, async () => { + await testEnv.editor.update( + () => { + const paragraph = $createParagraphNode(); + const tokens = ['-2', '-1', '0', '1', '2'].map((text) => + $createTextNode(text).setMode('token'), + ); + const root = $getRoot(); + root.clear().append(paragraph.append(...tokens)); + const ZERO_INDEX = 2; + const zToken = tokens[ZERO_INDEX]; + const nextToken = + direction === 'next' + ? zToken.getNextSibling() + : zToken.getPreviousSibling(); + invariant(nextToken !== null, 'nextToken must exist'); + // Note that the type declarations here would normally be inferred, these are + // used just to demonstrate that inference is working as expected + const caret: BreadthNodeCaret = + $getBreadthCaret(zToken, direction); + expect(zToken.is(caret.origin)).toBe(true); + expect(caret.direction).toBe(direction); + expect(caret.type).toBe('breadth'); + expect(nextToken.is(caret.getNodeAtCaret())).toBe(true); + expect(paragraph.is(caret.getParentAtCaret())).toBe(true); + + expect( + Array.from( + caret, + (nextCaret) => + (direction === 'next' ? 1 : -1) * + +nextCaret.origin.getTextContent(), + ), + ).toEqual([1, 2]); + + const flipped = caret.getFlipped(); + expect(flipped).not.toBe(caret); + expect(flipped.getFlipped().is(caret)); + expect(flipped.origin.is(caret.getNodeAtCaret())).toBe(true); + expect(flipped.direction).not.toBe(direction); + expect(flipped.type).toBe('breadth'); + expect(zToken.is(flipped.getNodeAtCaret())).toBe(true); + const flippedAdjacent = flipped.getAdjacentCaret(); + invariant( + flippedAdjacent !== null, + 'A flipped BreadthNode always has an adjacent caret because it points back to the origin', + ); + expect(flippedAdjacent.origin.is(caret.origin)).toBe(true); + + for (const mode of ['root', 'shadowRoot'] as const) { + expect( + $getBreadthCaret(paragraph, caret.direction).is( + caret.getParentCaret(mode), + ), + ).toBe(true); + expect( + $getBreadthCaret(paragraph, flipped.direction).is( + flipped.getParentCaret(mode), + ), + ).toBe(true); + } + + const adjacent: BreadthNodeCaret< + LexicalNode, + typeof direction + > | null = caret.getAdjacentCaret(); + invariant(adjacent !== null, 'expecting adjacent caret'); + const offset = direction === 'next' ? 1 : -1; + expect(tokens[ZERO_INDEX + offset].is(adjacent.origin)).toBe( + true, + ); + expect(adjacent.type).toBe('breadth'); + expect(adjacent.origin.getTextContent()).toBe(String(offset)); + + expect(tokens[ZERO_INDEX + offset].isAttached()).toBe(true); + expect( + tokens[ZERO_INDEX + offset].is(caret.getNodeAtCaret()), + ).toBe(true); + expect( + tokens[ZERO_INDEX + offset].is(caret.getNodeAtCaret()), + ).toBe(true); + expect(paragraph.getChildrenSize()).toBe(tokens.length); + caret.remove(); + expect(paragraph.getChildrenSize()).toBe(tokens.length - 1); + expect(tokens[ZERO_INDEX + offset].isAttached()).toBe(false); + expect( + tokens[ZERO_INDEX + 2 * offset].is(caret.getNodeAtCaret()), + ).toBe(true); + expect( + Array.from( + caret, + (nextCaret) => + (direction === 'next' ? 1 : -1) * + +nextCaret.origin.getTextContent(), + ), + ).toEqual([2]); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens + .map((n) => n.getTextContent()) + .filter((t) => t !== String(offset)), + ); + caret.insert(tokens[ZERO_INDEX + offset]); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual(tokens.map((n) => n.getTextContent())); + caret.replaceOrInsert(tokens[ZERO_INDEX + offset]); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual(tokens.map((n) => n.getTextContent())); + + caret.replaceOrInsert($createTextNode('replaced!')); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.map((n, i) => + i === ZERO_INDEX + offset ? 'replaced!' : n.getTextContent(), + ), + ); + caret.replaceOrInsert(tokens[ZERO_INDEX + offset]); + + const abNodes = ['a', 'b'].map((t) => $createTextNode(t)); + caret.splice(0, abNodes); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.flatMap((n, i) => { + if (i !== ZERO_INDEX) { + return [n.getTextContent()]; + } else if (direction === 'next') { + return ['0', 'a', 'b']; + } else { + return ['a', 'b', '0']; + } + }), + ); + abNodes.forEach((n) => n.remove()); + + caret.splice(0, abNodes, 'previous'); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.flatMap((n, i) => { + if (i !== ZERO_INDEX) { + return [n.getTextContent()]; + } else if (direction === 'next') { + return ['0', 'b', 'a']; + } else { + return ['b', 'a', '0']; + } + }), + ); + abNodes.forEach((n) => n.remove()); + + caret.splice(1, abNodes); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.flatMap((n, i) => { + if (i === ZERO_INDEX + offset) { + return []; + } else if (i !== ZERO_INDEX) { + return [n.getTextContent()]; + } else if (direction === 'next') { + return ['0', 'a', 'b']; + } else { + return ['a', 'b', '0']; + } + }), + ); + paragraph.clear().append(...tokens); + + caret.splice(1, abNodes.slice(0, 1)); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + tokens.map((n, i) => + i === ZERO_INDEX + offset ? 'a' : n.getTextContent(), + ), + ); + paragraph.clear().append(...tokens); + + caret.splice(2, abNodes.slice(0, 1)); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + direction === 'next' + ? ['-2', '-1', '0', 'a'] + : ['a', '0', '1', '2'], + ); + paragraph.clear().append(...tokens); + + caret.splice(Infinity, abNodes); + expect( + paragraph + .getLatest() + .getChildren() + .map((node) => node.getTextContent()), + ).toEqual( + direction === 'next' + ? ['-2', '-1', '0', 'a', 'b'] + : ['a', 'b', '0', '1', '2'], + ); + paragraph.clear().append(...tokens); + }, + {discrete: true}, + ); + }); + } + }); + }); +}); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index e83363f44d8..0dc82bbce41 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -6,72 +6,21 @@ * */ -export type {PasteCommandType} from './LexicalCommands'; export type { - CommandListener, - CommandListenerPriority, - CommandPayloadType, - CreateEditorArgs, - EditableListener, - EditorConfig, - EditorSetOptions, - EditorThemeClasses, - EditorThemeClassName, - EditorUpdateOptions, - HTMLConfig, - Klass, - KlassConstructor, - LexicalCommand, - LexicalEditor, - LexicalNodeReplacement, - MutationListener, - NodeMutation, - SerializedEditor, - Spread, - Transform, - UpdateListener, -} from './LexicalEditor'; -export type { - EditorState, - EditorStateReadOptions, - SerializedEditorState, -} from './LexicalEditorState'; -export type { - DOMChildConversion, - DOMConversion, - DOMConversionFn, - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - DOMExportOutputMap, - LexicalNode, - LexicalUpdateJSON, - NodeKey, - NodeMap, - SerializedLexicalNode, -} from './LexicalNode'; -export type { - BaseSelection, - ElementPointType as ElementPoint, - NodeSelection, - Point, - PointType, - RangeSelection, - TextPointType as TextPoint, -} from './LexicalSelection'; -export type { - ElementDOMSlot, - ElementFormatType, - SerializedElementNode, -} from './nodes/LexicalElementNode'; -export type {SerializedRootNode} from './nodes/LexicalRootNode'; -export type { - SerializedTextNode, - TextFormatType, - TextModeType, -} from './nodes/LexicalTextNode'; - -// TODO Move this somewhere else and/or recheck if we still need this + BreadthNodeCaret, + CaretDirection, + CaretType, + DepthNodeCaret, + FlipDirection, + NodeCaret, + RootMode, +} from './LexicalCaret'; +export { + $getBreadthCaret, + $getChildCaretAtIndex, + $getDepthCaret, +} from './LexicalCaret'; +export type {PasteCommandType} from './LexicalCommands'; export { BLUR_COMMAND, CAN_REDO_COMMAND, @@ -132,6 +81,30 @@ export { IS_UNDERLINE, TEXT_TYPE_TO_FORMAT, } from './LexicalConstants'; +export type { + CommandListener, + CommandListenerPriority, + CommandPayloadType, + CreateEditorArgs, + EditableListener, + EditorConfig, + EditorSetOptions, + EditorThemeClasses, + EditorThemeClassName, + EditorUpdateOptions, + HTMLConfig, + Klass, + KlassConstructor, + LexicalCommand, + LexicalEditor, + LexicalNodeReplacement, + MutationListener, + NodeMutation, + SerializedEditor, + Spread, + Transform, + UpdateListener, +} from './LexicalEditor'; export { COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_EDITOR, @@ -140,8 +113,36 @@ export { COMMAND_PRIORITY_NORMAL, createEditor, } from './LexicalEditor'; +export type { + EditorState, + EditorStateReadOptions, + SerializedEditorState, +} from './LexicalEditorState'; export type {EventHandler} from './LexicalEvents'; +export type { + DOMChildConversion, + DOMConversion, + DOMConversionFn, + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + DOMExportOutputMap, + LexicalNode, + LexicalUpdateJSON, + NodeKey, + NodeMap, + SerializedLexicalNode, +} from './LexicalNode'; export {$normalizeSelection as $normalizeSelection__EXPERIMENTAL} from './LexicalNormalization'; +export type { + BaseSelection, + ElementPointType as ElementPoint, + NodeSelection, + Point, + PointType, + RangeSelection, + TextPointType as TextPoint, +} from './LexicalSelection'; export { $createNodeSelection, $createPoint, @@ -205,6 +206,11 @@ export { } from './LexicalUtils'; export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode'; +export type { + ElementDOMSlot, + ElementFormatType, + SerializedElementNode, +} from './nodes/LexicalElementNode'; export {$isElementNode, ElementNode} from './nodes/LexicalElementNode'; export type {SerializedLineBreakNode} from './nodes/LexicalLineBreakNode'; export { @@ -218,7 +224,13 @@ export { $isParagraphNode, ParagraphNode, } from './nodes/LexicalParagraphNode'; +export type {SerializedRootNode} from './nodes/LexicalRootNode'; export {$isRootNode, RootNode} from './nodes/LexicalRootNode'; export type {SerializedTabNode} from './nodes/LexicalTabNode'; export {$createTabNode, $isTabNode, TabNode} from './nodes/LexicalTabNode'; +export type { + SerializedTextNode, + TextFormatType, + TextModeType, +} from './nodes/LexicalTextNode'; export {$createTextNode, $isTextNode, TextNode} from './nodes/LexicalTextNode'; From 23c493bfe9e9afdf7d4423d2c89e8e659eda7fb9 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 7 Jan 2025 09:26:43 -0800 Subject: [PATCH 02/69] wip iterCarets --- packages/lexical/src/LexicalCaret.ts | 59 ++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index 8923ff14e42..10a6a67dfca 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -424,6 +424,20 @@ export function $getDepthCaret( return $isElementNode(node) ? new DEPTH_CTOR[direction](node) : null; } +/** + * Gets the adjacent caret, if not-null and if the origin of the adjacent caret is an ElementNode, then return + * the DepthNodeCaret. This can be used along with the getParentAdjacentCaret method to perform a full DFS + * style traversal of the tree. + * + * @param caret The caret to start at + */ +export function $getAdjacentDepthCaret( + origin: NodeCaret, +): null | NodeCaret { + const caret = origin.getAdjacentCaret(); + return (caret && caret.getChildCaret()) || caret; +} + /** * Get a 'next' caret for the child at the given index, or the last * caret in that node if out of bounds @@ -448,8 +462,10 @@ export function $getChildCaretAtIndex( return caret; } -class CaretRangeImpl implements CaretRange { - readonly type = 'caret-range'; +class NodeCaretRangeImpl + implements NodeCaretRange +{ + readonly type = 'node-caret-range'; readonly direction: D; anchor: NodeCaret; focus: NodeCaret; @@ -461,17 +477,37 @@ class CaretRangeImpl implements CaretRange { isCollapsed(): boolean { return this.anchor.is(this.focus); } + iterCarets(rootMode: RootMode): Iterator> { + let caret = $getAdjacentDepthCaret(this.anchor); + const stopCaret = $getAdjacentDepthCaret(this.focus); + return { + next() { + if (caret === null) { + return {done: true, value: undefined}; + } + const rval = {done: false, value: caret}; + caret = $getAdjacentDepthCaret(caret) || caret.getParentCaret(rootMode); + if (stopCaret && stopCaret.is(caret)) { + caret = null; + } + return rval; + }, + }; + } + [Symbol.iterator](): Iterator> { + return this.iterCarets('root'); + } } function $caretRangeFromStartEnd( startCaret: NodeCaret<'next'>, endCaret: NodeCaret<'next'>, direction: CaretDirection, -): CaretRange { +): NodeCaretRange { if (direction === 'next') { - return new CaretRangeImpl(startCaret, endCaret, direction); + return new NodeCaretRangeImpl(startCaret, endCaret, direction); } else { - return new CaretRangeImpl( + return new NodeCaretRangeImpl( endCaret.getFlipped(), startCaret.getFlipped(), direction, @@ -482,12 +518,19 @@ function $caretRangeFromStartEnd( /** * A RangeSelection expressed as a pair of Carets */ -export interface CaretRange { - readonly type: 'caret-range'; +export interface NodeCaretRange + extends Iterable> { + readonly type: 'node-caret-range'; readonly direction: D; anchor: NodeCaret; focus: NodeCaret; + /** Return true if anchor and focus are the same caret */ isCollapsed(): boolean; + /** + * Iterate the carets between anchor and focus in a pre-order fashion. Note that + * + */ + iterCarets(rootMode: RootMode): Iterator>; } /** @@ -532,7 +575,7 @@ export function $caretFromPoint(point: PointType): NodeCaret<'next'> { */ export function $caretRangeFromSelection( selection: RangeSelection, -): CaretRange { +): NodeCaretRange { const direction = selection.isBackward() ? 'previous' : 'next'; let startCaret: NodeCaret<'next'>; let endCaret: NodeCaret<'next'>; From b45db8eab53797aa50a8a0e5df2fc1c1cfc67a89 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 8 Jan 2025 10:49:00 -0800 Subject: [PATCH 03/69] WIP iterators --- packages/lexical/src/LexicalCaret.ts | 91 +++++++++++++++++++--------- packages/lexical/src/index.ts | 3 + 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index 10a6a67dfca..fc30a93c9d8 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -133,6 +133,8 @@ export interface DepthNodeCaret< readonly type: 'depth'; getParentCaret(mode: RootMode): null | BreadthNodeCaret; getParentAtCaret(): T; + /** Return this, the DepthNode is already a child caret of its origin */ + getChildCaret(): this; } abstract class AbstractCaret @@ -157,17 +159,13 @@ abstract class AbstractCaret ); } [Symbol.iterator](): Iterator> { - let caret = this.getAdjacentCaret(); - return { - next(): IteratorResult> { - if (!caret) { - return {done: true, value: undefined}; - } - const rval = {done: false, value: caret}; - caret = caret.getAdjacentCaret(); - return rval; - }, - }; + return $makeStepwiseIterator({ + initial: this.getAdjacentCaret(), + map: (caret) => caret, + step: (caret: BreadthNodeCaret) => + caret.getAdjacentCaret(), + stop: (v): v is null => v === null, + }); } getAdjacentCaret(): null | BreadthNodeCaret { return $getBreadthCaret(this.getNodeAtCaret(), this.direction); @@ -274,6 +272,9 @@ abstract class AbstractDepthNodeCaret< getParentAtCaret(): T { return this.origin; } + getChildCaret(): this { + return this; + } } class DepthNodeCaretFirst extends AbstractDepthNodeCaret< @@ -424,6 +425,16 @@ export function $getDepthCaret( return $isElementNode(node) ? new DEPTH_CTOR[direction](node) : null; } +/** + * Gets the DepthNodeCaret if one is possible at this caret origin, otherwise return the caret + */ +export function $getChildCaretOrSelf< + D extends CaretDirection, + Null extends null = never, +>(caret: NodeCaret | Null): NodeCaret | Null { + return (caret && caret.getChildCaret()) || caret; +} + /** * Gets the adjacent caret, if not-null and if the origin of the adjacent caret is an ElementNode, then return * the DepthNodeCaret. This can be used along with the getParentAdjacentCaret method to perform a full DFS @@ -434,8 +445,7 @@ export function $getDepthCaret( export function $getAdjacentDepthCaret( origin: NodeCaret, ): null | NodeCaret { - const caret = origin.getAdjacentCaret(); - return (caret && caret.getChildCaret()) || caret; + return $getChildCaretOrSelf(origin.getAdjacentCaret()); } /** @@ -446,8 +456,8 @@ export function $getAdjacentDepthCaret( * @param index The index of the origin for the caret * @returns A next caret with the arrow at that index */ -export function $getChildCaretAtIndex( - parent: T, +export function $getChildCaretAtIndex( + parent: ElementNode, index: number, ): NodeCaret<'next'> { let caret: NodeCaret<'next'> = $getDepthCaret(parent, 'next'); @@ -478,21 +488,17 @@ class NodeCaretRangeImpl return this.anchor.is(this.focus); } iterCarets(rootMode: RootMode): Iterator> { - let caret = $getAdjacentDepthCaret(this.anchor); const stopCaret = $getAdjacentDepthCaret(this.focus); - return { - next() { - if (caret === null) { - return {done: true, value: undefined}; - } - const rval = {done: false, value: caret}; - caret = $getAdjacentDepthCaret(caret) || caret.getParentCaret(rootMode); - if (stopCaret && stopCaret.is(caret)) { - caret = null; - } - return rval; + return $makeStepwiseIterator({ + initial: $getAdjacentDepthCaret(this.anchor), + map: (state) => state, + step: (state: NodeCaret) => { + const caret = + $getAdjacentDepthCaret(state) || state.getParentCaret(rootMode); + return stopCaret && stopCaret.is(caret) ? null : caret; }, - }; + stop: (state): state is null => state === null, + }); } [Symbol.iterator](): Iterator> { return this.iterCarets('root'); @@ -599,3 +605,32 @@ export function $caretRangeFromSelection( } return $caretRangeFromStartEnd(startCaret, endCaret, direction); } + +export interface StepwiseIteratorConfig { + readonly initial: State | Stop; + readonly stop: (value: State | Stop) => value is Stop; + readonly step: (value: State) => State | Stop; + readonly map: (value: State) => Value; +} + +export function $makeStepwiseIterator({ + initial, + stop, + step, + map, +}: StepwiseIteratorConfig): IterableIterator { + let state = initial; + return { + [Symbol.iterator]() { + return this; + }, + next(): IteratorResult { + if (stop(state)) { + return {done: true, value: undefined}; + } + const rval = {done: false, value: map(state)}; + state = step(state); + return rval; + }, + }; +} diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 0dc82bbce41..23f0c2e1665 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -16,6 +16,9 @@ export type { RootMode, } from './LexicalCaret'; export { + $caretFromPoint, + $caretRangeFromSelection, + $getAdjacentDepthCaret, $getBreadthCaret, $getChildCaretAtIndex, $getDepthCaret, From 724c6c550d1a51d6db225327068c35e072a6fac1 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 8 Jan 2025 13:10:58 -0800 Subject: [PATCH 04/69] test --- packages/lexical-utils/src/index.ts | 51 ++++++++++++++-------------- packages/lexical/src/LexicalCaret.ts | 10 +++--- packages/lexical/src/index.ts | 3 ++ 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 774b013297c..d74293a6bde 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -9,8 +9,10 @@ import { $cloneWithProperties, $createParagraphNode, + $getAdjacentDepthCaret, $getBreadthCaret, $getChildCaretAtIndex, + $getChildCaretOrSelf, $getDepthCaret, $getPreviousSelection, $getRoot, @@ -28,6 +30,7 @@ import { Klass, LexicalEditor, LexicalNode, + makeStepwiseIterator, NodeCaret, NodeKey, } from 'lexical'; @@ -226,47 +229,43 @@ export function $dfsIterator( startNode?: LexicalNode, endNode?: LexicalNode, ): DFSIterator { + const rootMode = 'root'; const root = $getRoot(); const start = startNode || root; const startCaret = $isElementNode(start) ? $getDepthCaret(start, 'next') : $getBreadthCaret(start, 'previous').getFlipped(); const startDepth = $getDepth(startCaret.getParentAtCaret()); - const endDepth = endNode == null ? startDepth : 0; - const rootMode = 'root'; + const endCaret = endNode + ? $getChildCaretOrSelf($getBreadthCaret(endNode, 'next')) + : $getAdjacentDepthCaret(startCaret.getParentCaret(rootMode)); let depth = startDepth; - let caret: null | NodeCaret<'next'> = startCaret; - - const iterator: DFSIterator = { - next(): IteratorResult { - if (caret === null) { - return iteratorDone; + return makeStepwiseIterator({ + initial: startCaret, + map: (state) => ({depth, node: state.origin}), + step: (state: NodeCaret<'next'>) => { + if (state.is(endCaret)) { + return null; } - const rval = iteratorNotDone({depth, node: caret.origin}); - if (caret.type === 'depth') { + if (state.type === 'depth') { depth++; } - if (caret && caret.origin.is(endNode)) { - caret = null; - } - let nextCaret = $getAdjacentCaret(caret); - while (caret !== null && nextCaret === null) { + let caret = state; + let nextCaret = $getAdjacentDepthCaret(caret); + while (nextCaret === null) { depth--; - caret = depth > endDepth ? caret.getParentCaret(rootMode) : null; - if (caret && caret.origin.is(endNode)) { - caret = null; + nextCaret = caret.getParentCaret(rootMode); + if (!nextCaret || nextCaret.is(endCaret)) { + return null; } - nextCaret = $getAdjacentCaret(caret); + caret = nextCaret; + nextCaret = $getAdjacentDepthCaret(caret); } - caret = nextCaret ? nextCaret.getChildCaret() || nextCaret : null; - return rval; - }, - [Symbol.iterator](): DFSIterator { - return iterator; + return nextCaret; }, - }; - return iterator; + stop: (state): state is null => state === null, + }); } /** diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index fc30a93c9d8..ce1879cabd0 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -159,7 +159,7 @@ abstract class AbstractCaret ); } [Symbol.iterator](): Iterator> { - return $makeStepwiseIterator({ + return makeStepwiseIterator({ initial: this.getAdjacentCaret(), map: (caret) => caret, step: (caret: BreadthNodeCaret) => @@ -443,9 +443,9 @@ export function $getChildCaretOrSelf< * @param caret The caret to start at */ export function $getAdjacentDepthCaret( - origin: NodeCaret, + origin: null | NodeCaret, ): null | NodeCaret { - return $getChildCaretOrSelf(origin.getAdjacentCaret()); + return origin && $getChildCaretOrSelf(origin.getAdjacentCaret()); } /** @@ -489,7 +489,7 @@ class NodeCaretRangeImpl } iterCarets(rootMode: RootMode): Iterator> { const stopCaret = $getAdjacentDepthCaret(this.focus); - return $makeStepwiseIterator({ + return makeStepwiseIterator({ initial: $getAdjacentDepthCaret(this.anchor), map: (state) => state, step: (state: NodeCaret) => { @@ -613,7 +613,7 @@ export interface StepwiseIteratorConfig { readonly map: (value: State) => Value; } -export function $makeStepwiseIterator({ +export function makeStepwiseIterator({ initial, stop, step, diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 23f0c2e1665..0c122680cbc 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -14,6 +14,7 @@ export type { FlipDirection, NodeCaret, RootMode, + StepwiseIteratorConfig, } from './LexicalCaret'; export { $caretFromPoint, @@ -21,7 +22,9 @@ export { $getAdjacentDepthCaret, $getBreadthCaret, $getChildCaretAtIndex, + $getChildCaretOrSelf, $getDepthCaret, + makeStepwiseIterator, } from './LexicalCaret'; export type {PasteCommandType} from './LexicalCommands'; export { From 7891ca751b23220f036e8e3029b81ebee064a5b4 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 13 Jan 2025 14:02:59 -0800 Subject: [PATCH 05/69] update-packages --- examples/vanilla-js-iframe/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/vanilla-js-iframe/package.json b/examples/vanilla-js-iframe/package.json index 9c2a8775708..9bc40c9703a 100644 --- a/examples/vanilla-js-iframe/package.json +++ b/examples/vanilla-js-iframe/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/vanilla-js-iframe", "private": true, - "version": "0.23.0", + "version": "0.23.1", "type": "module", "scripts": { "dev": "vite", @@ -10,11 +10,11 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/dragon": "0.23.0", - "@lexical/history": "0.23.0", - "@lexical/rich-text": "0.23.0", - "@lexical/utils": "0.23.0", - "lexical": "0.23.0" + "@lexical/dragon": "0.23.1", + "@lexical/history": "0.23.1", + "@lexical/rich-text": "0.23.1", + "@lexical/utils": "0.23.1", + "lexical": "0.23.1" }, "devDependencies": { "typescript": "^5.2.2", From 4591c915da7970a690372100f6d5f94b9f760e84 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 13 Jan 2025 14:11:02 -0800 Subject: [PATCH 06/69] ci-check workaround --- packages/lexical/src/LexicalCaret.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index ce1879cabd0..38e2f3fc1ea 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -213,7 +213,8 @@ abstract class AbstractCaret // TODO: Optimize this to work directly with node internals for (const node of nodeIter) { if (nodesToRemove.size > 0) { - const target = caret.getNodeAtCaret(); + // TODO: For some reason `npm run tsc-extension` needs this annotation? + const target: null | LexicalNode = caret.getNodeAtCaret(); invariant( target !== null, 'NodeCaret.splice: Underflow of expected nodesToRemove during splice (keys: %s)', From f84e1b0c05456f3ad71cc8d4674c874f3b2f658b Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 13 Jan 2025 14:12:57 -0800 Subject: [PATCH 07/69] support old node and its lack of toReversed --- .../lexical/src/__tests__/unit/LexicalCaret.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts index 4bd3027b574..67cb23c133a 100644 --- a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts @@ -83,7 +83,7 @@ describe('LexicalCaret', () => { ]; expect(root.getChildrenKeys()).toEqual( direction === 'next' - ? paragraphKeys.toReversed() + ? [...paragraphKeys].reverse() : paragraphKeys, ); @@ -93,13 +93,13 @@ describe('LexicalCaret', () => { expect(root.getChildrenKeys()).toEqual(paragraphKeys); caret.splice(0, [secondParagraph, paragraph]); expect(root.getChildrenKeys()).toEqual( - paragraphKeys.toReversed(), + [...paragraphKeys].reverse(), ); caret.splice(0, [paragraph, secondParagraph]); expect(root.getChildrenKeys()).toEqual(paragraphKeys); caret.splice(2, [secondParagraph, paragraph]); expect(root.getChildrenKeys()).toEqual( - paragraphKeys.toReversed(), + [...paragraphKeys].reverse(), ); caret.splice(2, [paragraph, secondParagraph]); expect(root.getChildrenKeys()).toEqual(paragraphKeys); @@ -108,14 +108,14 @@ describe('LexicalCaret', () => { caret.splice(-1, [secondParagraph]); expect(root.getChildrenKeys()).toEqual( direction === 'next' - ? paragraphKeys.toReversed() + ? [...paragraphKeys].reverse() : paragraphKeys, ); caret.splice(Infinity, [paragraph, secondParagraph], direction); expect(root.getChildrenKeys()).toEqual( direction === 'next' ? paragraphKeys - : paragraphKeys.toReversed(), + : [...paragraphKeys].reverse(), ); expect( @@ -123,7 +123,7 @@ describe('LexicalCaret', () => { ).toEqual( direction === 'next' ? root.getChildrenKeys() - : root.getChildrenKeys().toReversed(), + : [...root.getChildrenKeys()].reverse(), ); }, {discrete: true}, From 34894832b86378c415ee7a6d0404ef4cc2151c4e Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 13 Jan 2025 14:27:40 -0800 Subject: [PATCH 08/69] $dfs --- packages/lexical-utils/src/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index d74293a6bde..11a388d1c86 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -236,9 +236,11 @@ export function $dfsIterator( ? $getDepthCaret(start, 'next') : $getBreadthCaret(start, 'previous').getFlipped(); const startDepth = $getDepth(startCaret.getParentAtCaret()); - const endCaret = endNode - ? $getChildCaretOrSelf($getBreadthCaret(endNode, 'next')) - : $getAdjacentDepthCaret(startCaret.getParentCaret(rootMode)); + const endCaret = $getAdjacentDepthCaret( + endNode + ? $getChildCaretOrSelf($getBreadthCaret(endNode, 'next')) + : startCaret.getParentCaret(rootMode), + ); let depth = startDepth; return makeStepwiseIterator({ @@ -262,6 +264,9 @@ export function $dfsIterator( caret = nextCaret; nextCaret = $getAdjacentDepthCaret(caret); } + if (nextCaret && nextCaret.is(endCaret)) { + return null; + } return nextCaret; }, stop: (state): state is null => state === null, From 1937dad9531d78630ce659cb7c3ead4ed61b2af7 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 13 Jan 2025 16:04:16 -0800 Subject: [PATCH 09/69] remove @ampproject/rollup-plugin-closure-compiler --- package-lock.json | 234 ---------------------------------------------- package.json | 1 - scripts/build.js | 25 +---- 3 files changed, 4 insertions(+), 256 deletions(-) diff --git a/package-lock.json b/package-lock.json index 423afc85aee..04863bf15dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "yjs": "^13.5.42" }, "devDependencies": { - "@ampproject/rollup-plugin-closure-compiler": "^0.27.0", "@babel/core": "^7.24.5", "@babel/eslint-parser": "^7.24.5", "@babel/plugin-transform-optional-catch-binding": "^7.24.1", @@ -657,121 +656,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-0.2.0.tgz", - "integrity": "sha512-a4EztS9/GOVQjX5Ol+Iz33TFhaXvYBF7aB6D8+Qz0/SCIxOm3UNRhGZiwcCuJ8/Ifc6NCogp3S48kc5hFxRpUw==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "1.0.0", - "sourcemap-codec": "1.4.8" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@ampproject/rollup-plugin-closure-compiler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@ampproject/rollup-plugin-closure-compiler/-/rollup-plugin-closure-compiler-0.27.0.tgz", - "integrity": "sha512-stpAOn2ZZEJuAV39HFw9cnKJYNhEeHtcsoa83orpLDhSxsxSbVEKwHaWlFBaQYpQRSOdapC4eJhJnCzocZxnqg==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "0.2.0", - "acorn": "7.3.1", - "acorn-walk": "7.1.1", - "estree-walker": "2.0.1", - "google-closure-compiler": "20210808.0.0", - "magic-string": "0.25.7", - "uuid": "8.1.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "rollup": ">=1.27" - } - }, - "node_modules/@ampproject/rollup-plugin-closure-compiler/node_modules/google-closure-compiler": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20210808.0.0.tgz", - "integrity": "sha512-+R2+P1tT1lEnDDGk8b+WXfyVZgWjcCK9n1mmZe8pMEzPaPWxqK7GMetLVWnqfTDJ5Q+LRspOiFBv3Is+0yuhCA==", - "dev": true, - "dependencies": { - "chalk": "2.x", - "google-closure-compiler-java": "^20210808.0.0", - "minimist": "1.x", - "vinyl": "2.x", - "vinyl-sourcemaps-apply": "^0.2.0" - }, - "bin": { - "google-closure-compiler": "cli.js" - }, - "engines": { - "node": ">=10" - }, - "optionalDependencies": { - "google-closure-compiler-linux": "^20210808.0.0", - "google-closure-compiler-osx": "^20210808.0.0", - "google-closure-compiler-windows": "^20210808.0.0" - } - }, - "node_modules/@ampproject/rollup-plugin-closure-compiler/node_modules/google-closure-compiler-java": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20210808.0.0.tgz", - "integrity": "sha512-7dEQfBzOdwdjwa/Pq8VAypNBKyWRrOcKjnNYOO9gEg2hjh8XVMeQzTqw4uANfVvvANGdE/JjD+HF6zHVgLRwjg==", - "dev": true - }, - "node_modules/@ampproject/rollup-plugin-closure-compiler/node_modules/google-closure-compiler-linux": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20210808.0.0.tgz", - "integrity": "sha512-byKi5ITUiWRvEIcQo76i1siVnOwrTmG+GNcBG4cJ7x8IE6+4ki9rG5pUe4+DOYHkfk52XU6XHt9aAAgCcFDKpg==", - "cpu": [ - "x64", - "x86" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@ampproject/rollup-plugin-closure-compiler/node_modules/google-closure-compiler-osx": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-osx/-/google-closure-compiler-osx-20210808.0.0.tgz", - "integrity": "sha512-iwyAY6dGj1FrrBdmfwKXkjtTGJnqe8F+9WZbfXxiBjkWLtIsJt2dD1+q7g/sw3w8mdHrGQAdxtDZP/usMwj/Rg==", - "cpu": [ - "x64", - "x86", - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@ampproject/rollup-plugin-closure-compiler/node_modules/google-closure-compiler-windows": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20210808.0.0.tgz", - "integrity": "sha512-VI+UUYwtGWDYwpiixrWRD8EklHgl6PMbiEaHxQSrQbH8PDXytwaOKqmsaH2lWYd5Y/BOZie2MzjY7F5JI69q1w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@ampproject/rollup-plugin-closure-compiler/node_modules/uuid": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz", - "integrity": "sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==", - "dev": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -6754,15 +6638,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-1.0.0.tgz", - "integrity": "sha512-9oLAnygRMi8Q5QkYEU4XWK04B+nuoXoxjRvRxgjuChkLZFBja0YPSgdZ7dZtwhncLBcQe/I/E+fLuk5qxcYVJA==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/set-array": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", @@ -10310,15 +10185,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.1.1.tgz", - "integrity": "sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/address": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/address/-/address-1.2.0.tgz", @@ -18079,12 +17945,6 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/estree-walker": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.1.tgz", - "integrity": "sha512-tF0hv+Yi2Ot1cwj9eYHtxC0jB9bmjacjQs6ZBTj82H8JwUywFuc+7E83NWfNMwHXZc11mjfFcVXPe9gEP4B8dg==", - "dev": true - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -39758,82 +39618,6 @@ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", "dev": true }, - "@ampproject/remapping": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-0.2.0.tgz", - "integrity": "sha512-a4EztS9/GOVQjX5Ol+Iz33TFhaXvYBF7aB6D8+Qz0/SCIxOm3UNRhGZiwcCuJ8/Ifc6NCogp3S48kc5hFxRpUw==", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "1.0.0", - "sourcemap-codec": "1.4.8" - } - }, - "@ampproject/rollup-plugin-closure-compiler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@ampproject/rollup-plugin-closure-compiler/-/rollup-plugin-closure-compiler-0.27.0.tgz", - "integrity": "sha512-stpAOn2ZZEJuAV39HFw9cnKJYNhEeHtcsoa83orpLDhSxsxSbVEKwHaWlFBaQYpQRSOdapC4eJhJnCzocZxnqg==", - "dev": true, - "requires": { - "@ampproject/remapping": "0.2.0", - "acorn": "7.3.1", - "acorn-walk": "7.1.1", - "estree-walker": "2.0.1", - "google-closure-compiler": "20210808.0.0", - "magic-string": "0.25.7", - "uuid": "8.1.0" - }, - "dependencies": { - "google-closure-compiler": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20210808.0.0.tgz", - "integrity": "sha512-+R2+P1tT1lEnDDGk8b+WXfyVZgWjcCK9n1mmZe8pMEzPaPWxqK7GMetLVWnqfTDJ5Q+LRspOiFBv3Is+0yuhCA==", - "dev": true, - "requires": { - "chalk": "2.x", - "google-closure-compiler-java": "^20210808.0.0", - "google-closure-compiler-linux": "^20210808.0.0", - "google-closure-compiler-osx": "^20210808.0.0", - "google-closure-compiler-windows": "^20210808.0.0", - "minimist": "1.x", - "vinyl": "2.x", - "vinyl-sourcemaps-apply": "^0.2.0" - } - }, - "google-closure-compiler-java": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20210808.0.0.tgz", - "integrity": "sha512-7dEQfBzOdwdjwa/Pq8VAypNBKyWRrOcKjnNYOO9gEg2hjh8XVMeQzTqw4uANfVvvANGdE/JjD+HF6zHVgLRwjg==", - "dev": true - }, - "google-closure-compiler-linux": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20210808.0.0.tgz", - "integrity": "sha512-byKi5ITUiWRvEIcQo76i1siVnOwrTmG+GNcBG4cJ7x8IE6+4ki9rG5pUe4+DOYHkfk52XU6XHt9aAAgCcFDKpg==", - "dev": true, - "optional": true - }, - "google-closure-compiler-osx": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-osx/-/google-closure-compiler-osx-20210808.0.0.tgz", - "integrity": "sha512-iwyAY6dGj1FrrBdmfwKXkjtTGJnqe8F+9WZbfXxiBjkWLtIsJt2dD1+q7g/sw3w8mdHrGQAdxtDZP/usMwj/Rg==", - "dev": true, - "optional": true - }, - "google-closure-compiler-windows": { - "version": "20210808.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20210808.0.0.tgz", - "integrity": "sha512-VI+UUYwtGWDYwpiixrWRD8EklHgl6PMbiEaHxQSrQbH8PDXytwaOKqmsaH2lWYd5Y/BOZie2MzjY7F5JI69q1w==", - "dev": true, - "optional": true - }, - "uuid": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz", - "integrity": "sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==", - "dev": true - } - } - }, "@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -43922,12 +43706,6 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, - "@jridgewell/resolve-uri": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-1.0.0.tgz", - "integrity": "sha512-9oLAnygRMi8Q5QkYEU4XWK04B+nuoXoxjRvRxgjuChkLZFBja0YPSgdZ7dZtwhncLBcQe/I/E+fLuk5qxcYVJA==", - "dev": true - }, "@jridgewell/set-array": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", @@ -46494,12 +46272,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==" }, - "acorn-walk": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.1.1.tgz", - "integrity": "sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==", - "dev": true - }, "address": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/address/-/address-1.2.0.tgz", @@ -51895,12 +51667,6 @@ } } }, - "estree-walker": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.1.tgz", - "integrity": "sha512-tF0hv+Yi2Ot1cwj9eYHtxC0jB9bmjacjQs6ZBTj82H8JwUywFuc+7E83NWfNMwHXZc11mjfFcVXPe9gEP4B8dg==", - "dev": true - }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", diff --git a/package.json b/package.json index e2d6f27a456..15d059b8f62 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,6 @@ "size": "npm run build-prod && size-limit" }, "devDependencies": { - "@ampproject/rollup-plugin-closure-compiler": "^0.27.0", "@babel/core": "^7.24.5", "@babel/eslint-parser": "^7.24.5", "@babel/plugin-transform-optional-catch-binding": "^7.24.1", diff --git a/scripts/build.js b/scripts/build.js index ec1b6c470f7..5d7b1045ced 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -18,7 +18,6 @@ const commonjs = require('@rollup/plugin-commonjs'); const replace = require('@rollup/plugin-replace'); const json = require('@rollup/plugin-json'); const alias = require('@rollup/plugin-alias'); -const compiler = require('@ampproject/rollup-plugin-closure-compiler'); const terser = require('@rollup/plugin-terser'); const {exec} = require('child-process-promise'); const {packagesManager} = require('./shared/packagesManager'); @@ -34,19 +33,6 @@ const isRelease = argv.release; const isWWW = argv.www; const extractCodes = argv.codes; -const closureOptions = { - apply_input_source_maps: false, - assume_function_wrapper: true, - compilation_level: 'SIMPLE', - inject_libraries: false, - language_in: 'ECMASCRIPT_2019', - language_out: 'ECMASCRIPT_2019', - process_common_js_modules: false, - rewrite_polyfills: false, - use_types_for_optimization: false, - warning_level: 'QUIET', -}; - const modulePackageMappings = Object.fromEntries( packagesManager.getPublicPackages().flatMap((pkg) => { const pkgName = pkg.getNpmName(); @@ -254,13 +240,10 @@ async function build( isWWW && strictWWWMappings, ), ), - // terser is used for esm builds because - // @ampproject/rollup-plugin-closure-compiler doesn't compile - // `export default function X()` correctly - isProd && - (format === 'esm' - ? terser({ecma: 2019, module: true}) - : compiler(closureOptions)), + // terser is used because @ampproject/rollup-plugin-closure-compiler + // doesn't compile `export default function X()` correctly and hasn't + // been updated since Aug 2021 + isProd && terser({ecma: 2019, module: format === 'esm'}), { renderChunk(source) { // Assets pipeline might use "export" word in the beginning of the line From 741096473dee15ebef380dee4de6e2ec30a01473 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 14 Jan 2025 12:52:03 -0800 Subject: [PATCH 10/69] clean up utils --- packages/lexical-utils/src/index.ts | 158 +++++++++++----------------- 1 file changed, 59 insertions(+), 99 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 11a388d1c86..4e0e0ed13ab 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -23,16 +23,17 @@ import { $isTextNode, $setSelection, $splitNode, - BreadthNodeCaret, - CaretDirection, - EditorState, + type BreadthNodeCaret, + type CaretDirection, + type EditorState, ElementNode, - Klass, - LexicalEditor, - LexicalNode, + type Klass, + type LexicalEditor, + type LexicalNode, makeStepwiseIterator, - NodeCaret, - NodeKey, + type NodeCaret, + type NodeKey, + type RootMode, } from 'lexical'; // This underscore postfixing is used as a hotfix so we do not // export shared types from this module #5918 @@ -172,10 +173,10 @@ export function mediaFileReader( }); } -export type DFSNode = Readonly<{ - depth: number; - node: LexicalNode; -}>; +export interface DFSNode { + readonly depth: number; + readonly node: LexicalNode; +} /** * "Depth-First Search" starts at the root/top node of a tree and goes as far as it can down a branch end @@ -194,19 +195,6 @@ export function $dfs( return Array.from($dfsIterator(startNode, endNode)); } -type DFSIterator = { - next: () => IteratorResult; - [Symbol.iterator]: () => DFSIterator; -}; - -const iteratorDone: Readonly<{done: true; value: void}> = { - done: true, - value: undefined, -}; -const iteratorNotDone: (value: T) => Readonly<{done: false; value: T}> = ( - value: T, -) => ({done: false, value}); - /** * Get the adjacent caret in the same direction * @@ -228,7 +216,7 @@ export function $getAdjacentCaret( export function $dfsIterator( startNode?: LexicalNode, endNode?: LexicalNode, -): DFSIterator { +): IterableIterator { const rootMode = 'root'; const root = $getRoot(); const start = startNode || root; @@ -253,21 +241,12 @@ export function $dfsIterator( if (state.type === 'depth') { depth++; } - let caret = state; - let nextCaret = $getAdjacentDepthCaret(caret); - while (nextCaret === null) { - depth--; - nextCaret = caret.getParentCaret(rootMode); - if (!nextCaret || nextCaret.is(endCaret)) { - return null; - } - caret = nextCaret; - nextCaret = $getAdjacentDepthCaret(caret); - } - if (nextCaret && nextCaret.is(endCaret)) { + const rval = $getNextSiblingOrParentSiblingCaret(state); + if (!rval || rval[0].is(endCaret)) { return null; } - return nextCaret; + depth += rval[1]; + return rval[0]; }, stop: (state): state is null => state === null, }); @@ -284,26 +263,29 @@ export function $dfsIterator( export function $getNextSiblingOrParentSibling( node: LexicalNode, ): null | [LexicalNode, number] { - let node_: null | LexicalNode = node; - // Find immediate sibling or nearest parent sibling - let sibling = null; - let depthDiff = 0; - - while (sibling === null && node_ !== null) { - sibling = node_.getNextSibling(); + const rval = $getNextSiblingOrParentSiblingCaret( + $getBreadthCaret(node, 'next'), + ); + return rval && [rval[0].origin, rval[1]]; +} - if (sibling === null) { - node_ = node_.getParent(); - depthDiff--; - } else { - node_ = sibling; +function $getNextSiblingOrParentSiblingCaret( + startCaret: NodeCaret<'next'>, + rootMode: RootMode = 'root', +): null | [NodeCaret<'next'>, number] { + let depthDiff = 0; + let caret = startCaret; + let nextCaret = $getAdjacentDepthCaret(caret); + while (nextCaret === null) { + depthDiff--; + nextCaret = caret.getParentCaret(rootMode); + if (!nextCaret) { + return null; } + caret = nextCaret; + nextCaret = $getAdjacentDepthCaret(caret); } - - if (node_ === null) { - return null; - } - return [node_, depthDiff]; + return nextCaret && [nextCaret, depthDiff]; } export function $getDepth(node: LexicalNode): number { @@ -319,7 +301,7 @@ export function $getDepth(node: LexicalNode): number { /** * Performs a right-to-left preorder tree traversal. - * From the starting node it goes to the rightmost child, than backtracks to paret and finds new rightmost path. + * From the starting node it goes to the rightmost child, than backtracks to parent and finds new rightmost path. * It will return the next node in traversal sequence after the startingNode. * The traversal is similar to $dfs functions above, but the nodes are visited right-to-left, not left-to-right. * @param startingNode - The node to start the search. @@ -328,24 +310,10 @@ export function $getDepth(node: LexicalNode): number { export function $getNextRightPreorderNode( startingNode: LexicalNode, ): LexicalNode | null { - let node: LexicalNode | null = startingNode; - - if ($isElementNode(node) && node.getChildrenSize() > 0) { - node = node.getLastChild(); - } else { - let sibling = null; - - while (sibling === null && node !== null) { - sibling = node.getPreviousSibling(); - - if (sibling === null) { - node = node.getParent(); - } else { - node = sibling; - } - } - } - return node; + const caret = $getAdjacentDepthCaret( + $getChildCaretOrSelf($getBreadthCaret(startingNode, 'previous')), + ); + return caret && caret.origin; } /** @@ -614,7 +582,7 @@ export function $wrapNodeInElement( } // eslint-disable-next-line @typescript-eslint/no-explicit-any -type ObjectKlass = new (...args: any[]) => T; +export type ObjectKlass = new (...args: any[]) => T; /** * @param object = The instance of the type @@ -738,7 +706,7 @@ function $unwrapAndFilterDescendantsImpl( $unwrapAndFilterDescendantsImpl( node, $predicate, - $onSuccess ? $onSuccess : (child) => node.insertAfter(child), + $onSuccess || ((child) => node.insertAfter(child)), ); } node.remove(); @@ -772,7 +740,7 @@ export function $descendantsMatching( $predicate: (node: LexicalNode) => boolean, ): LexicalNode[] { const result: LexicalNode[] = []; - const stack = [...children].reverse(); + const stack = Array.from(children).reverse(); for (let child = stack.pop(); child !== undefined; child = stack.pop()) { if ($predicate(child)) { result.push(child); @@ -794,7 +762,7 @@ export function $descendantsMatching( * @returns An iterator of the node's children */ export function $firstToLastIterator(node: ElementNode): Iterable { - return $caretNodeIterator($getDepthCaret(node, 'next')); + return $childIterator($getDepthCaret(node, 'next')); } /** @@ -806,24 +774,17 @@ export function $firstToLastIterator(node: ElementNode): Iterable { * @returns An iterator of the node's children */ export function $lastToFirstIterator(node: ElementNode): Iterable { - return $caretNodeIterator($getDepthCaret(node, 'previous')); + return $childIterator($getDepthCaret(node, 'previous')); } -function $caretNodeIterator( +function $childIterator( startCaret: NodeCaret, ): IterableIterator { - const iter = startCaret[Symbol.iterator](); const seen = __DEV__ ? new Set() : null; - return { - [Symbol.iterator]() { - return this; - }, - next() { - const step = iter.next(); - if (step.done) { - return iteratorDone; - } - const {origin} = step.value; + return makeStepwiseIterator({ + initial: startCaret.getAdjacentCaret(), + map: (caret) => { + const origin = caret.origin.getLatest(); if (__DEV__ && seen !== null) { const key = origin.getKey(); invariant( @@ -833,19 +794,18 @@ function $caretNodeIterator( ); seen.add(key); } - return iteratorNotDone(origin); + return origin; }, - }; + step: (caret: BreadthNodeCaret) => caret.getAdjacentCaret(), + stop: (v): v is null => v === null, + }); } /** - * Insert all children before this node, and then remove it. + * Replace this node with its children * * @param node The ElementNode to unwrap and remove */ export function $unwrapNode(node: ElementNode): void { - for (const child of $firstToLastIterator(node)) { - node.insertBefore(child); - } - node.remove(); + $getBreadthCaret(node, 'next').getFlipped().splice(1, node.getChildren()); } From ed62d152d1b6975fe7e68f6c2171ee6b580c47c6 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 15 Jan 2025 17:30:25 -0800 Subject: [PATCH 11/69] TextSlice abstraction --- packages/lexical-utils/src/index.ts | 2 +- packages/lexical/src/LexicalCaret.ts | 307 ++++++++++++++++++--------- 2 files changed, 204 insertions(+), 105 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 4e0e0ed13ab..09c3bfa4c63 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -531,7 +531,7 @@ export function $insertNodeToNearestRoot(node: T): T { const focusOffset = focus.offset; if ($isRootOrShadowRoot(focusNode)) { - $getChildCaretAtIndex(focusNode, focusOffset).insert(node); + $getChildCaretAtIndex(focusNode, focusOffset, 'next').insert(node); node.selectNext(); } else { let splitNode: ElementNode; diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index 38e2f3fc1ea..f9efbcf93ed 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -13,7 +13,7 @@ import invariant from 'shared/invariant'; import {$getNodeByKeyOrThrow, $isRootOrShadowRoot} from './LexicalUtils'; import {$isElementNode, type ElementNode} from './nodes/LexicalElementNode'; import {$isRootNode} from './nodes/LexicalRootNode'; -import {$isTextNode} from './nodes/LexicalTextNode'; +import {$isTextNode, TextNode} from './nodes/LexicalTextNode'; export type CaretDirection = 'next' | 'previous'; export type FlipDirection = typeof FLIP_DIRECTION[D]; @@ -25,16 +25,19 @@ const FLIP_DIRECTION = { previous: 'next', } as const; -interface BaseNodeCaret - extends Iterable> { +export interface BaseNodeCaret< + T extends LexicalNode, + D extends CaretDirection, + Type, +> extends Iterable> { /** The origin node of this caret, typically this is what you will use in traversals */ readonly origin: T; /** breadth for a BreadthNodeCaret (pointing at the next or previous sibling) or depth for a DepthNodeCaret (pointing at the first or last child) */ - readonly type: CaretType; + readonly type: Type; /** next if pointing at the next sibling or first child, previous if pointing at the previous sibling or last child */ readonly direction: D; /** Retun true if other is a caret with the same origin (by node key comparion), type, and direction */ - is(other: NodeCaret | null): boolean; + is: (other: NodeCaret | null) => boolean; /** * Get a new NodeCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity. * For example, given a non-empty parent with a firstChild and lastChild, and a second emptyParent node with no children: @@ -48,23 +51,23 @@ interface BaseNodeCaret * $getDepthCaret(emptyParent, 'next').getFlipped().is($getDepthCaret(emptyParent, 'previous')) === true; * ``` */ - getFlipped(): NodeCaret>; + getFlipped: () => NodeCaret>; /** Get the ElementNode that is the logical parent (`origin` for `DepthNodeCaret`, `origin.getParentOrThrow()` for `BreadthNodeCaret`) */ - getParentAtCaret(): ElementNode; + getParentAtCaret: () => ElementNode; /** Get the node connected to the origin in the caret's direction, or null if there is no node */ - getNodeAtCaret(): null | LexicalNode; + getNodeAtCaret: () => null | LexicalNode; /** Get a new BreadthNodeCaret from getNodeAtCaret() in the same direction. This is used for traversals, but only goes in the breadth (sibling) direction. */ - getAdjacentCaret(): null | BreadthNodeCaret; + getAdjacentCaret: () => null | BreadthNodeCaret; /** Remove the getNodeAtCaret() node, if it exists */ - remove(): this; + remove: () => this; /** * Insert a node connected to origin in this direction. * For a `BreadthNodeCaret` this is `origin.insertAfter(node)` for next, or `origin.insertBefore(node)` for previous. * For a `DepthNodeCaret` this is `origin.splice(0, 0, [node])` for next or `origin.append(node)` for previous. */ - insert(node: LexicalNode): this; + insert: (node: LexicalNode) => this; /** If getNodeAtCaret() is null then replace it with node, otherwise insert node */ - replaceOrInsert(node: LexicalNode, includeChildren?: boolean): this; + replaceOrInsert: (node: LexicalNode, includeChildren?: boolean) => this; /** * Splice an iterable (typically an Array) of nodes into this location. * @@ -72,11 +75,46 @@ interface BaseNodeCaret * @param nodes An iterable of nodes that will be inserted in this location, using replace instead of insert for the first deleteCount nodes * @param nodesDirection The direction of the nodes iterable, defaults to 'next' */ - splice( + splice: ( deleteCount: number, nodes: Iterable, nodesDirection?: CaretDirection, - ): this; + ) => this; +} + +/** + * A RangeSelection expressed as a pair of Carets + */ +export interface NodeCaretRange + extends Iterable> { + readonly type: 'node-caret-range'; + readonly direction: D; + anchor: RangeNodeCaret; + focus: RangeNodeCaret; + /** Return true if anchor and focus are the same caret */ + isCollapsed: () => boolean; + /** + * Iterate the carets between anchor and focus in a pre-order fashion + */ + internalCarets: (rootMode: RootMode) => Iterator>; + /** + * There are between zero and two TextSliceCarets for a NodeCaretRange + * + * 0: Neither anchor nor focus are non-empty TextSliceCarets + * 1: One of anchor or focus are non-empty TextSliceCaret, or of the same origin + * 2: Anchor and focus are both non-empty TextSliceCaret of different origin + */ + textSliceCarets: () => + | [] + | [TextSliceCaret] + | [TextSliceCaret, TextSliceCaret]; +} + +export interface StepwiseIteratorConfig { + readonly initial: State | Stop; + readonly stop: (value: State | Stop) => value is Stop; + readonly step: (value: State) => State | Stop; + readonly map: (value: State) => Value; } /** @@ -101,26 +139,30 @@ export type NodeCaret = | BreadthNodeCaret | DepthNodeCaret; +export type RangeNodeCaret = + | TextSliceCaret + | BreadthNodeCaret + | DepthNodeCaret; + /** * A BreadthNodeCaret points from an origin LexicalNode towards its next or previous sibling. */ export interface BreadthNodeCaret< T extends LexicalNode = LexicalNode, D extends CaretDirection = CaretDirection, -> extends BaseNodeCaret { - readonly type: 'breadth'; +> extends BaseNodeCaret { /** * If the origin of this node is an ElementNode, return the DepthNodeCaret of this origin in the same direction. * If the origin is not an ElementNode, this will return null. */ - getChildCaret(): DepthNodeCaret | null; + getChildCaret: () => null | DepthNodeCaret; /** * Get the caret in the same direction from the parent of this origin. * * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root * @returns A BreadthNodeCaret with the parent of this origin, or null if the parent is a root according to mode. */ - getParentCaret(mode: RootMode): null | BreadthNodeCaret; + getParentCaret: (mode: RootMode) => null | BreadthNodeCaret; } /** @@ -129,18 +171,32 @@ export interface BreadthNodeCaret< export interface DepthNodeCaret< T extends ElementNode = ElementNode, D extends CaretDirection = CaretDirection, -> extends BaseNodeCaret { - readonly type: 'depth'; - getParentCaret(mode: RootMode): null | BreadthNodeCaret; - getParentAtCaret(): T; +> extends BaseNodeCaret { + getParentCaret: (mode: RootMode) => null | BreadthNodeCaret; + getParentAtCaret: () => T; /** Return this, the DepthNode is already a child caret of its origin */ - getChildCaret(): this; + getChildCaret: () => this; } -abstract class AbstractCaret - implements BaseNodeCaret +/** + * A TextSliceCaret is a special case of a BreadthNodeCaret that also carries an index + * pair used for representing partially selected TextNode at the edges of a NodeCaretRange + */ +export interface TextSliceCaret< + T extends TextNode = TextNode, + D extends CaretDirection = CaretDirection, +> extends BreadthNodeCaret { + readonly indexStart: number; + readonly indexEnd: number; +} + +abstract class AbstractCaret< + T extends LexicalNode, + D extends CaretDirection, + Type, +> implements BaseNodeCaret { - abstract readonly type: CaretType; + abstract readonly type: Type; abstract readonly direction: D; readonly origin: T; abstract getNodeAtCaret(): null | LexicalNode; @@ -247,7 +303,7 @@ abstract class AbstractDepthNodeCaret< T extends ElementNode, D extends CaretDirection, > - extends AbstractCaret + extends AbstractCaret implements DepthNodeCaret { readonly type = 'depth'; @@ -322,10 +378,12 @@ abstract class AbstractBreadthNodeCaret< T extends LexicalNode, D extends CaretDirection, > - extends AbstractCaret + extends AbstractCaret implements BreadthNodeCaret { readonly type = 'breadth'; + indexStart?: number; + indexEnd?: number; getParentAtCaret(): ElementNode { return this.origin.getParentOrThrow(); } @@ -349,6 +407,54 @@ abstract class AbstractBreadthNodeCaret< } } +export function $removeTextSlice( + caret: TextSliceCaret, +): BreadthNodeCaret { + const {origin, direction, indexEnd, indexStart} = caret; + const text = caret.origin.getTextContent(); + return $getBreadthCaret( + origin.setTextContent(text.slice(0, indexStart) + text.slice(indexEnd)), + direction, + ); +} + +export function $splitTextSlice( + caret: TextSliceCaret, +): BreadthNodeCaret { + const {origin, indexEnd, indexStart, direction} = caret; + const splits = origin.splitText(indexStart, indexEnd); + const splitIndex = indexStart === 0 ? 0 : 1; + const node = splits[splitIndex]; + invariant( + $isTextNode(node), + '$splitTextSlice: expecting TextNode result from origin.splitText(%s, %s)[%s] with size %s', + String(indexStart), + String(indexEnd), + String(splitIndex), + String(origin.getTextContentSize()), + ); + return $getBreadthCaret(node, direction); +} + +export function $getTextSliceContent< + T extends TextNode, + D extends CaretDirection, +>(caret: TextSliceCaret): string { + return caret.origin.getTextContent().slice(caret.indexStart, caret.indexEnd); +} + +export function $isTextSliceCaret( + caret: NodeCaret, +): caret is TextSliceCaret { + return ( + caret instanceof AbstractBreadthNodeCaret && + $isTextNode(caret.origin) && + typeof caret.indexEnd === 'number' && + typeof caret.indexStart === 'number' && + caret.indexEnd !== caret.indexStart + ); +} + class BreadthNodeCaretNext< T extends LexicalNode, > extends AbstractBreadthNodeCaret { @@ -407,6 +513,29 @@ export function $getBreadthCaret( return origin ? new BREADTH_CTOR[direction](origin) : null; } +export function $getTextSliceCaret< + T extends TextNode, + D extends CaretDirection, +>( + origin: T, + direction: D, + indexStart: number, + indexEnd: number, +): TextSliceCaret { + const size = origin.getTextContentSize(); + invariant( + indexStart >= 0 && indexEnd >= indexStart && indexEnd <= size, + '$getTextSliceCaret: invalid slice with indexStart %s indexEnd %s for size %s', + String(indexStart), + String(indexEnd), + String(size), + ); + return Object.assign($getBreadthCaret(origin, direction), { + indexEnd, + indexStart, + }); +} + /** * Get a caret that points at the first or last child of the given origin node, * which must be an ElementNode. @@ -457,10 +586,11 @@ export function $getAdjacentDepthCaret( * @param index The index of the origin for the caret * @returns A next caret with the arrow at that index */ -export function $getChildCaretAtIndex( +export function $getChildCaretAtIndex( parent: ElementNode, index: number, -): NodeCaret<'next'> { + direction: D, +): NodeCaret { let caret: NodeCaret<'next'> = $getDepthCaret(parent, 'next'); for (let i = 0; i < index; i++) { const nextCaret: null | BreadthNodeCaret = @@ -470,17 +600,26 @@ export function $getChildCaretAtIndex( } caret = nextCaret; } - return caret; + return (direction === 'next' ? caret : caret.getFlipped()) as NodeCaret; } +export type TextSliceCaretTuple = + | [] + | [TextSliceCaret] + | [TextSliceCaret, TextSliceCaret]; + class NodeCaretRangeImpl implements NodeCaretRange { readonly type = 'node-caret-range'; readonly direction: D; - anchor: NodeCaret; - focus: NodeCaret; - constructor(anchor: NodeCaret, focus: NodeCaret, direction: D) { + anchor: RangeNodeCaret; + focus: RangeNodeCaret; + constructor( + anchor: RangeNodeCaret, + focus: RangeNodeCaret, + direction: D, + ) { this.anchor = anchor; this.focus = focus; this.direction = direction; @@ -488,7 +627,20 @@ class NodeCaretRangeImpl isCollapsed(): boolean { return this.anchor.is(this.focus); } - iterCarets(rootMode: RootMode): Iterator> { + textSliceCarets(): TextSliceCaretTuple { + const slices = [this.anchor, this.focus].filter( + $isTextSliceCaret, + ) as TextSliceCaretTuple; + if (slices.length === 2 && slices[0].origin.is(slices[1].origin)) { + const {direction} = this; + const [l, r] = direction === 'next' ? slices : slices.reverse(); + return l.indexStart === r.indexStart + ? [] + : [$getTextSliceCaret(l.origin, direction, l.indexStart, r.indexStart)]; + } + return slices; + } + internalCarets(rootMode: RootMode): Iterator> { const stopCaret = $getAdjacentDepthCaret(this.focus); return makeStepwiseIterator({ initial: $getAdjacentDepthCaret(this.anchor), @@ -502,44 +654,10 @@ class NodeCaretRangeImpl }); } [Symbol.iterator](): Iterator> { - return this.iterCarets('root'); - } -} - -function $caretRangeFromStartEnd( - startCaret: NodeCaret<'next'>, - endCaret: NodeCaret<'next'>, - direction: CaretDirection, -): NodeCaretRange { - if (direction === 'next') { - return new NodeCaretRangeImpl(startCaret, endCaret, direction); - } else { - return new NodeCaretRangeImpl( - endCaret.getFlipped(), - startCaret.getFlipped(), - direction, - ); + return this.internalCarets('root'); } } -/** - * A RangeSelection expressed as a pair of Carets - */ -export interface NodeCaretRange - extends Iterable> { - readonly type: 'node-caret-range'; - readonly direction: D; - anchor: NodeCaret; - focus: NodeCaret; - /** Return true if anchor and focus are the same caret */ - isCollapsed(): boolean; - /** - * Iterate the carets between anchor and focus in a pre-order fashion. Note that - * - */ - iterCarets(rootMode: RootMode): Iterator>; -} - /** * Since a NodeCaret can only represent a whole node, when a text PointType * is encountered the caret will lie before the text node if it is non-empty @@ -548,7 +666,10 @@ export interface NodeCaretRange * @param point * @returns a NodeCaret for the point */ -export function $caretFromPoint(point: PointType): NodeCaret<'next'> { +export function $caretFromPoint( + point: PointType, + direction: D, +): RangeNodeCaret { const {type, key, offset} = point; const node = $getNodeByKeyOrThrow(point.key); if (type === 'text') { @@ -558,9 +679,9 @@ export function $caretFromPoint(point: PointType): NodeCaret<'next'> { node.getType(), key, ); - return offset === 0 && node.getTextContentSize() > 0 - ? $getBreadthCaret(node, 'next') - : $getBreadthCaret(node, 'previous').getFlipped(); + return direction === 'next' + ? $getTextSliceCaret(node, direction, offset, node.getTextContentSize()) + : $getTextSliceCaret(node, direction, 0, offset); } invariant( $isElementNode(node), @@ -568,7 +689,7 @@ export function $caretFromPoint(point: PointType): NodeCaret<'next'> { node.getType(), key, ); - return $getChildCaretAtIndex(node, point.offset); + return $getChildCaretAtIndex(node, point.offset, direction); } /** @@ -583,35 +704,13 @@ export function $caretFromPoint(point: PointType): NodeCaret<'next'> { export function $caretRangeFromSelection( selection: RangeSelection, ): NodeCaretRange { - const direction = selection.isBackward() ? 'previous' : 'next'; - let startCaret: NodeCaret<'next'>; - let endCaret: NodeCaret<'next'>; - if ( - selection.anchor.type === 'text' && - selection.focus.type === 'text' && - selection.anchor.key === selection.focus.key && - selection.anchor.offset !== selection.focus.offset - ) { - const node = $getNodeByKeyOrThrow(selection.anchor.key); - // handle edge case where the start and end are on the same TextNode - startCaret = $getBreadthCaret(node, 'previous').getFlipped(); - endCaret = $getBreadthCaret(node, 'next'); - } else { - const [startPoint, endPoint] = - direction === 'next' - ? [selection.anchor, selection.focus] - : [selection.focus, selection.anchor]; - startCaret = $caretFromPoint(startPoint); - endCaret = $caretFromPoint(endPoint); - } - return $caretRangeFromStartEnd(startCaret, endCaret, direction); -} - -export interface StepwiseIteratorConfig { - readonly initial: State | Stop; - readonly stop: (value: State | Stop) => value is Stop; - readonly step: (value: State) => State | Stop; - readonly map: (value: State) => Value; + const {anchor, focus} = selection; + const direction = focus.isBefore(anchor) ? 'previous' : 'next'; + return new NodeCaretRangeImpl( + $caretFromPoint(anchor, direction), + $caretFromPoint(focus, direction), + direction, + ); } export function makeStepwiseIterator({ From 29302a2550baba2a59b8e95873bd7b18f5a9088d Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 15 Jan 2025 21:37:58 -0800 Subject: [PATCH 12/69] WIP TextSliceCaret --- packages/lexical/src/LexicalCaret.ts | 44 ++++---- .../src/__tests__/unit/LexicalCaret.test.ts | 102 +++++++++++++++++- packages/lexical/src/index.ts | 9 ++ 3 files changed, 132 insertions(+), 23 deletions(-) diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index f9efbcf93ed..4b3e286df33 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -96,7 +96,7 @@ export interface NodeCaretRange /** * Iterate the carets between anchor and focus in a pre-order fashion */ - internalCarets: (rootMode: RootMode) => Iterator>; + internalCarets: (rootMode: RootMode) => IterableIterator>; /** * There are between zero and two TextSliceCarets for a NodeCaretRange * @@ -214,7 +214,7 @@ abstract class AbstractCaret< this.origin.is(other.origin) ); } - [Symbol.iterator](): Iterator> { + [Symbol.iterator](): IterableIterator> { return makeStepwiseIterator({ initial: this.getAdjacentCaret(), map: (caret) => caret, @@ -450,8 +450,7 @@ export function $isTextSliceCaret( caret instanceof AbstractBreadthNodeCaret && $isTextNode(caret.origin) && typeof caret.indexEnd === 'number' && - typeof caret.indexStart === 'number' && - caret.indexEnd !== caret.indexStart + typeof caret.indexStart === 'number' ); } @@ -625,35 +624,36 @@ class NodeCaretRangeImpl this.direction = direction; } isCollapsed(): boolean { - return this.anchor.is(this.focus); + return this.anchor.is(this.focus) && this.textSliceCarets().length === 0; } textSliceCarets(): TextSliceCaretTuple { - const slices = [this.anchor, this.focus].filter( - $isTextSliceCaret, - ) as TextSliceCaretTuple; + const slices = [this.anchor, this.focus].filter($isTextSliceCaret); if (slices.length === 2 && slices[0].origin.is(slices[1].origin)) { const {direction} = this; - const [l, r] = direction === 'next' ? slices : slices.reverse(); - return l.indexStart === r.indexStart + const [k, l, r] = + direction === 'next' + ? (['indexStart', ...slices] as const) + : (['indexEnd', ...slices.reverse()] as const); + return l[k] === r[k] ? [] - : [$getTextSliceCaret(l.origin, direction, l.indexStart, r.indexStart)]; + : [$getTextSliceCaret(l.origin, direction, l[k], r[k])]; } - return slices; + return slices.filter( + (caret) => caret.indexEnd !== caret.indexStart, + ) as TextSliceCaretTuple; } - internalCarets(rootMode: RootMode): Iterator> { - const stopCaret = $getAdjacentDepthCaret(this.focus); + internalCarets(rootMode: RootMode): IterableIterator> { + const step = (state: NodeCaret) => + $getAdjacentDepthCaret(state) || state.getParentCaret(rootMode); + const stopCaret = step(this.focus); return makeStepwiseIterator({ - initial: $getAdjacentDepthCaret(this.anchor), + initial: step(this.anchor), map: (state) => state, - step: (state: NodeCaret) => { - const caret = - $getAdjacentDepthCaret(state) || state.getParentCaret(rootMode); - return stopCaret && stopCaret.is(caret) ? null : caret; - }, - stop: (state): state is null => state === null, + step, + stop: (state): state is null => state === null || state.is(stopCaret), }); } - [Symbol.iterator](): Iterator> { + [Symbol.iterator](): IterableIterator> { return this.internalCarets('root'); } } diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts index 67cb23c133a..2d905b38bed 100644 --- a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts @@ -7,10 +7,13 @@ */ import { + $caretRangeFromSelection, $createParagraphNode, $createTextNode, $getBreadthCaret, + $getDepthCaret, $getRoot, + $isTextSliceCaret, BreadthNodeCaret, DepthNodeCaret, LexicalNode, @@ -18,7 +21,6 @@ import { TextNode, } from 'lexical'; -import {$getDepthCaret} from '../..'; import {initializeUnitTest, invariant} from '../utils'; describe('LexicalCaret', () => { @@ -371,5 +373,103 @@ describe('LexicalCaret', () => { }); } }); + describe('$caretRangeFromSelection', () => { + test('collapsed text point selection', async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + const node = textNodes[1]; + const cases = [ + [() => node.selectStart(), 0], + [() => node.selectEnd(), node.getTextContentSize()], + [() => node.select(3, 3), 3], + ] as const; + for (const [$makeSelection, offset] of cases) { + const key = node.getKey(); + const selection = $makeSelection(); + expect(selection).toMatchObject({ + anchor: {key, offset, type: 'text'}, + focus: {key, offset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + expect(range.isCollapsed()).toBe(true); + invariant( + $isTextSliceCaret(range.anchor), + '$isTextSliceCaret(range.anchor)', + ); + invariant( + $isTextSliceCaret(range.focus), + '$isTextSliceCaret(range.anchor)', + ); + expect(range).toMatchObject({ + anchor: { + indexEnd: textNodes[1].getTextContentSize(), + indexStart: offset, + }, + focus: { + indexEnd: textNodes[1].getTextContentSize(), + indexStart: offset, + }, + }); + expect(range.textSliceCarets()).toEqual([]); + expect([...range.internalCarets('root')]).toEqual([]); + } + }); + }); + test('full text node selection', async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + for (const direction of ['next', 'previous'] as const) { + for (const node of textNodes) { + const key = node.getKey(); + const size = node.getTextContentSize(); + const [anchorOffset, focusOffset] = + direction === 'next' ? [0, size] : [size, 0]; + const selection = node.select(anchorOffset, focusOffset); + expect(selection).toMatchObject({ + anchor: {key, offset: anchorOffset, type: 'text'}, + focus: {key, offset: focusOffset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + invariant( + $isTextSliceCaret(range.anchor), + '$isTextSliceCaret(range.anchor)', + ); + invariant( + $isTextSliceCaret(range.focus), + '$isTextSliceCaret(range.anchor)', + ); + const pt = (offset: number) => + direction === 'next' + ? {indexEnd: size, indexStart: offset} + : {indexEnd: offset, indexStart: 0}; + expect(range).toMatchObject({ + anchor: pt(anchorOffset), + direction, + focus: pt(focusOffset), + }); + expect(range.textSliceCarets()).toMatchObject([ + { + indexEnd: size, + indexStart: 0, + origin: node, + }, + ]); + expect([...range.internalCarets('root')]).toEqual([]); + expect(range.isCollapsed()).toBe(false); + } + } + }); + }); + }); }); }); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 4e79c5ae56f..8332437539e 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -7,14 +7,18 @@ */ export type { + BaseNodeCaret, BreadthNodeCaret, CaretDirection, CaretType, DepthNodeCaret, FlipDirection, NodeCaret, + NodeCaretRange, + RangeNodeCaret, RootMode, StepwiseIteratorConfig, + TextSliceCaret, } from './LexicalCaret'; export { $caretFromPoint, @@ -24,6 +28,11 @@ export { $getChildCaretAtIndex, $getChildCaretOrSelf, $getDepthCaret, + $getTextSliceCaret, + $getTextSliceContent, + $isTextSliceCaret, + $removeTextSlice, + $splitTextSlice, makeStepwiseIterator, } from './LexicalCaret'; export type {PasteCommandType} from './LexicalCommands'; From 32aa889f2ab2b7dabf0cd37f43e48c18e67b8a0d Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 15 Jan 2025 21:54:14 -0800 Subject: [PATCH 13/69] fix docs --- packages/lexical/src/LexicalCaret.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index 4b3e286df33..db6e68f2be1 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -25,6 +25,7 @@ const FLIP_DIRECTION = { previous: 'next', } as const; +/** @noInheritDoc */ export interface BaseNodeCaret< T extends LexicalNode, D extends CaretDirection, @@ -544,14 +545,14 @@ export function $getTextSliceCaret< * @returns null if origin is null or not an ElementNode, otherwise a DepthNodeCaret for this origin and direction */ export function $getDepthCaret( - node: T, + origin: T, direction: D, ): DepthNodeCaret; export function $getDepthCaret( - node: LexicalNode | null, + origin: null | LexicalNode, direction: CaretDirection, ): null | DepthNodeCaret { - return $isElementNode(node) ? new DEPTH_CTOR[direction](node) : null; + return $isElementNode(origin) ? new DEPTH_CTOR[direction](origin) : null; } /** @@ -572,9 +573,9 @@ export function $getChildCaretOrSelf< * @param caret The caret to start at */ export function $getAdjacentDepthCaret( - origin: null | NodeCaret, + caret: null | NodeCaret, ): null | NodeCaret { - return origin && $getChildCaretOrSelf(origin.getAdjacentCaret()); + return caret && $getChildCaretOrSelf(caret.getAdjacentCaret()); } /** From c8d14322790f670311bd79d61a2bae92058d7283 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 19 Jan 2025 07:37:34 -0800 Subject: [PATCH 14/69] WIP --- packages/lexical/src/LexicalCaret.ts | 90 +++++++++++++++++++++++++--- packages/lexical/src/index.ts | 3 + 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index db6e68f2be1..d2bd732dbce 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -320,8 +320,8 @@ abstract class AbstractDepthNodeCaret< this.direction, ); } - getFlipped(): NodeCaret { - const dir = FLIP_DIRECTION[this.direction]; + getFlipped(): NodeCaret> { + const dir = flipDirection(this.direction); return ( $getBreadthCaret(this.getNodeAtCaret(), dir) || $getDepthCaret(this.origin, dir) @@ -368,6 +368,12 @@ const MODE_PREDICATE = { shadowRoot: $isRootOrShadowRoot, } as const; +export function flipDirection( + direction: D, +): FlipDirection { + return FLIP_DIRECTION[direction]; +} + function $filterByMode( node: T | null, mode: RootMode, @@ -400,7 +406,7 @@ abstract class AbstractBreadthNodeCaret< ); } getFlipped(): NodeCaret> { - const dir = FLIP_DIRECTION[this.direction]; + const dir = flipDirection(this.direction); return ( $getBreadthCaret(this.getNodeAtCaret(), dir) || $getDepthCaret(this.origin.getParentOrThrow(), dir) @@ -445,7 +451,7 @@ export function $getTextSliceContent< } export function $isTextSliceCaret( - caret: NodeCaret, + caret: null | undefined | NodeCaret, ): caret is TextSliceCaret { return ( caret instanceof AbstractBreadthNodeCaret && @@ -455,6 +461,24 @@ export function $isTextSliceCaret( ); } +export function $isNodeCaret( + caret: null | undefined | NodeCaret, +) { + return caret instanceof AbstractCaret; +} + +export function $isBreadthNodeCaret( + caret: null | undefined | NodeCaret, +): caret is BreadthNodeCaret { + return caret instanceof AbstractBreadthNodeCaret; +} + +export function $isDepthNodeCaret( + caret: null | undefined | NodeCaret, +): caret is DepthNodeCaret { + return caret instanceof AbstractDepthNodeCaret; +} + class BreadthNodeCaretNext< T extends LexicalNode, > extends AbstractBreadthNodeCaret { @@ -558,10 +582,9 @@ export function $getDepthCaret( /** * Gets the DepthNodeCaret if one is possible at this caret origin, otherwise return the caret */ -export function $getChildCaretOrSelf< - D extends CaretDirection, - Null extends null = never, ->(caret: NodeCaret | Null): NodeCaret | Null { +export function $getChildCaretOrSelf( + caret: Caret, +): NodeCaret['direction']> | (Caret & null) { return (caret && caret.getChildCaret()) || caret; } @@ -693,6 +716,57 @@ export function $caretFromPoint( return $getChildCaretAtIndex(node, point.offset, direction); } +function $normalizeCaretForPoint( + caret: RangeNodeCaret, +): RangeNodeCaret { + if ($isTextSliceCaret(caret)) { + return caret; + } + let current = $getChildCaretOrSelf(caret); + while ($isDepthNodeCaret(current)) { + const next = $getAdjacentDepthCaret(current); + if (!next) { + return current; + } + current = next; + } + const {origin} = current; + if ($isTextNode(origin)) { + const {direction} = caret; + const index = direction === 'next' ? 0 : origin.getTextContentSize(); + return $getTextSliceCaret(origin, direction, index, index); + } + return current; +} + +export function $setPointFromCaret( + point: PointType, + caret: RangeNodeCaret, + normalize = true, +): void { + const normCaret = normalize ? $normalizeCaretForPoint(caret) : caret; + const {origin, direction} = normCaret; + if ($isTextSliceCaret(normCaret)) { + point.set( + origin.getKey(), + normCaret[direction === 'next' ? 'indexEnd' : 'indexStart'], + 'text', + ); + } else if ($isDepthNodeCaret(normCaret)) { + point.set( + origin.getKey(), + direction === 'next' ? 0 : normCaret.origin.getChildrenSize(), + 'element', + ); + } else { + point.set( + origin.getParentOrThrow().getKey(), + origin.getIndexWithinParent(), + 'element', + ); + } +} + /** * Get a pair of carets for a RangeSelection. Since a NodeCaret can * only represent a whole node, when a text PointType is encountered diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 8332437539e..f58ac186cb2 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -30,9 +30,12 @@ export { $getDepthCaret, $getTextSliceCaret, $getTextSliceContent, + $isBreadthNodeCaret, + $isDepthNodeCaret, $isTextSliceCaret, $removeTextSlice, $splitTextSlice, + flipDirection, makeStepwiseIterator, } from './LexicalCaret'; export type {PasteCommandType} from './LexicalCommands'; From 51d279fdc8c82486f36cce534a449e1998ee63c6 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 21 Jan 2025 20:40:05 -0800 Subject: [PATCH 15/69] caret slices --- packages/lexical/src/LexicalCaret.ts | 74 +++++---- .../src/__tests__/unit/LexicalCaret.test.ts | 153 +++++++++++++++++- packages/lexical/src/index.ts | 1 + 3 files changed, 193 insertions(+), 35 deletions(-) diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index d2bd732dbce..3816341d0d1 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -99,16 +99,23 @@ export interface NodeCaretRange */ internalCarets: (rootMode: RootMode) => IterableIterator>; /** - * There are between zero and two TextSliceCarets for a NodeCaretRange + * There are between zero and two non-empty TextSliceCarets for a + * NodeCaretRange. Non-empty is defined by indexEnd > indexStart + * (some text will be in the slice). * * 0: Neither anchor nor focus are non-empty TextSliceCarets * 1: One of anchor or focus are non-empty TextSliceCaret, or of the same origin * 2: Anchor and focus are both non-empty TextSliceCaret of different origin */ - textSliceCarets: () => - | [] - | [TextSliceCaret] - | [TextSliceCaret, TextSliceCaret]; + nonEmptyTextSliceCarets: () => TextSliceCaretTuple; + /** + * There are between zero and two TextSliceCarets for a NodeCaretRange + * + * 0: Neither anchor nor focus are TextSliceCarets + * 1: One of anchor or focus are TextSliceCaret, or of the same origin + * 2: Anchor and focus are both TextSliceCaret of different origin + */ + textSliceCarets: () => TextSliceCaretTuple; } export interface StepwiseIteratorConfig { @@ -627,9 +634,7 @@ export function $getChildCaretAtIndex( } export type TextSliceCaretTuple = - | [] - | [TextSliceCaret] - | [TextSliceCaret, TextSliceCaret]; + readonly TextSliceCaret[] & {length: 0 | 1 | 2}; class NodeCaretRangeImpl implements NodeCaretRange @@ -648,33 +653,45 @@ class NodeCaretRangeImpl this.direction = direction; } isCollapsed(): boolean { - return this.anchor.is(this.focus) && this.textSliceCarets().length === 0; + return ( + this.anchor.is(this.focus) && this.nonEmptyTextSliceCarets().length === 0 + ); + } + nonEmptyTextSliceCarets(): TextSliceCaretTuple { + return this.textSliceCarets().filter( + (caret) => caret.indexEnd > caret.indexStart, + ) as TextSliceCaretTuple; } textSliceCarets(): TextSliceCaretTuple { - const slices = [this.anchor, this.focus].filter($isTextSliceCaret); - if (slices.length === 2 && slices[0].origin.is(slices[1].origin)) { + const {anchor, focus} = this; + if ( + anchor.is(focus) && + $isTextSliceCaret(anchor) && + $isTextSliceCaret(focus) + ) { const {direction} = this; - const [k, l, r] = - direction === 'next' - ? (['indexStart', ...slices] as const) - : (['indexEnd', ...slices.reverse()] as const); - return l[k] === r[k] - ? [] - : [$getTextSliceCaret(l.origin, direction, l[k], r[k])]; + const maxStart = Math.max(anchor.indexStart, focus.indexStart); + return [ + $getTextSliceCaret( + anchor.origin, + direction, + maxStart, + Math.max(maxStart, Math.min(anchor.indexEnd, focus.indexEnd)), + ), + ]; } - return slices.filter( - (caret) => caret.indexEnd !== caret.indexStart, - ) as TextSliceCaretTuple; + return [anchor, focus].filter($isTextSliceCaret) as TextSliceCaretTuple; } internalCarets(rootMode: RootMode): IterableIterator> { const step = (state: NodeCaret) => $getAdjacentDepthCaret(state) || state.getParentCaret(rootMode); - const stopCaret = step(this.focus); + const {anchor, focus} = this; return makeStepwiseIterator({ - initial: step(this.anchor), + initial: anchor.is(focus) ? null : step(anchor), map: (state) => state, step, - stop: (state): state is null => state === null || state.is(stopCaret), + stop: (state: null | RangeNodeCaret): state is null => + state === null || state.is(focus), }); } [Symbol.iterator](): IterableIterator> { @@ -693,6 +710,7 @@ class NodeCaretRangeImpl export function $caretFromPoint( point: PointType, direction: D, + anchorOrFocus: 'anchor' | 'focus', ): RangeNodeCaret { const {type, key, offset} = point; const node = $getNodeByKeyOrThrow(point.key); @@ -703,13 +721,13 @@ export function $caretFromPoint( node.getType(), key, ); - return direction === 'next' + return (direction === 'next') === (anchorOrFocus === 'anchor') ? $getTextSliceCaret(node, direction, offset, node.getTextContentSize()) : $getTextSliceCaret(node, direction, 0, offset); } invariant( $isElementNode(node), - '$caretToPoint: Node with type %s and key %s that does not inherit from ElementNode encountered for element point', + '$caretFromPoint: Node with type %s and key %s that does not inherit from ElementNode encountered for element point', node.getType(), key, ); @@ -782,8 +800,8 @@ export function $caretRangeFromSelection( const {anchor, focus} = selection; const direction = focus.isBefore(anchor) ? 'previous' : 'next'; return new NodeCaretRangeImpl( - $caretFromPoint(anchor, direction), - $caretFromPoint(focus, direction), + $caretFromPoint(anchor, direction, 'anchor'), + $caretFromPoint(focus, direction, 'focus'), direction, ); } diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts index 2d905b38bed..3732e2e0c49 100644 --- a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts @@ -407,15 +407,26 @@ describe('LexicalCaret', () => { ); expect(range).toMatchObject({ anchor: { + direction: 'next', indexEnd: textNodes[1].getTextContentSize(), indexStart: offset, }, focus: { - indexEnd: textNodes[1].getTextContentSize(), - indexStart: offset, + direction: 'next', + indexEnd: offset, + indexStart: 0, }, }); - expect(range.textSliceCarets()).toEqual([]); + expect(range.textSliceCarets()).toMatchObject([ + { + direction: 'next', + indexEnd: offset, + indexStart: offset, + origin: node, + type: 'breadth', + }, + ]); + expect(range.nonEmptyTextSliceCarets()).toEqual([]); expect([...range.internalCarets('root')]).toEqual([]); } }); @@ -448,14 +459,14 @@ describe('LexicalCaret', () => { $isTextSliceCaret(range.focus), '$isTextSliceCaret(range.anchor)', ); - const pt = (offset: number) => - direction === 'next' + const pt = (offset: number, anchorOrFocus: 'anchor' | 'focus') => + (direction === 'next') === (anchorOrFocus === 'anchor') ? {indexEnd: size, indexStart: offset} : {indexEnd: offset, indexStart: 0}; expect(range).toMatchObject({ - anchor: pt(anchorOffset), + anchor: pt(anchorOffset, 'anchor'), direction, - focus: pt(focusOffset), + focus: pt(focusOffset, 'focus'), }); expect(range.textSliceCarets()).toMatchObject([ { @@ -470,6 +481,134 @@ describe('LexicalCaret', () => { } }); }); + test('single text node non-empty selection', async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + for (const node of textNodes) { + // Test all non-empty selections + const size = node.getTextContentSize(); + for (let indexStart = 0; indexStart < size; indexStart++) { + for ( + let indexEnd = indexStart + 1; + indexEnd <= size; + indexEnd++ + ) { + for (const direction of ['next', 'previous'] as const) { + const selection = + direction === 'next' + ? node.select(indexStart, indexEnd) + : node.select(indexEnd, indexStart); + const range = $caretRangeFromSelection(selection); + invariant( + $isTextSliceCaret(range.anchor), + '$isTextSliceCaret(range.anchor)', + ); + invariant( + $isTextSliceCaret(range.focus), + '$isTextSliceCaret(range.anchor)', + ); + expect(range.direction).toBe(direction); + expect(range.textSliceCarets()).toMatchObject([ + {indexEnd, indexStart, origin: node}, + ]); + expect([...range.internalCarets('root')]).toMatchObject([]); + } + } + } + } + }); + }); + test('multiple text node non-empty selection', async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + const selection = $getRoot().select(); + + // test all start and end nodes (where different) + const nodeCount = textNodes.length; + for ( + let indexNodeStart = 0; + indexNodeStart < nodeCount; + indexNodeStart++ + ) { + for ( + let indexNodeEnd = indexNodeStart + 1; + indexNodeEnd < nodeCount; + indexNodeEnd++ + ) { + const startNode = textNodes[indexNodeStart]!; + const endNode = textNodes[indexNodeEnd]!; + for (const indexStart of [0, 1, startNode.getTextContentSize()]) { + for (const indexEnd of [0, 1, endNode.getTextContentSize()]) { + for (const direction of ['next', 'previous'] as const) { + const [anchorNode, anchorOffset, focusNode, focusOffset] = + direction === 'next' + ? [startNode, indexStart, endNode, indexEnd] + : [endNode, indexEnd, startNode, indexStart]; + selection.setTextNodeRange( + anchorNode, + anchorOffset, + focusNode, + focusOffset, + ); + const range = $caretRangeFromSelection(selection); + invariant( + $isTextSliceCaret(range.anchor), + '$isTextSliceCaret(range.anchor)', + ); + invariant( + $isTextSliceCaret(range.focus), + '$isTextSliceCaret(range.anchor)', + ); + expect(range.direction).toBe(direction); + const textSliceCarets = range.textSliceCarets(); + expect(textSliceCarets).toHaveLength(2); + const [anchorSlice, focusSlice] = textSliceCarets; + expect(anchorSlice).toMatchObject({ + direction, + indexEnd: + direction === 'next' + ? anchorNode.getTextContentSize() + : anchorOffset, + indexStart: direction === 'next' ? anchorOffset : 0, + origin: anchorNode, + type: 'breadth', + }); + expect(focusSlice).toMatchObject({ + direction, + indexEnd: + direction === 'next' + ? focusOffset + : focusNode.getTextContentSize(), + indexStart: direction === 'next' ? 0 : focusOffset, + origin: focusNode, + type: 'breadth', + }); + expect([...range.internalCarets('root')]).toMatchObject( + textNodes + .slice(indexNodeStart + 1, indexNodeEnd) + .map((origin) => ({ + direction, + origin, + type: 'breadth', + })), + ); + } + } + } + } + } + }); + }); }); }); }); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index f58ac186cb2..c4f80ec6042 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -19,6 +19,7 @@ export type { RootMode, StepwiseIteratorConfig, TextSliceCaret, + TextSliceCaretTuple, } from './LexicalCaret'; export { $caretFromPoint, From 145620a6a8fcbecb1e7b9a744a06a42087915496 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 25 Jan 2025 23:14:44 -0800 Subject: [PATCH 16/69] $removeCaretText and TextNodeCaret refactor --- packages/lexical/src/LexicalCaret.ts | 472 +++++++++---- packages/lexical/src/LexicalSelection.ts | 3 + .../src/__tests__/unit/LexicalCaret.test.ts | 662 +++++++++++++++--- packages/lexical/src/index.ts | 15 +- 4 files changed, 915 insertions(+), 237 deletions(-) diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index 3816341d0d1..ac886428247 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -10,10 +10,15 @@ import type {PointType, RangeSelection} from './LexicalSelection'; import invariant from 'shared/invariant'; -import {$getNodeByKeyOrThrow, $isRootOrShadowRoot} from './LexicalUtils'; +import { + $getAncestor, + $getNodeByKeyOrThrow, + $isRootOrShadowRoot, + INTERNAL_$isBlock, +} from './LexicalUtils'; import {$isElementNode, type ElementNode} from './nodes/LexicalElementNode'; import {$isRootNode} from './nodes/LexicalRootNode'; -import {$isTextNode, TextNode} from './nodes/LexicalTextNode'; +import {$createTextNode, $isTextNode, TextNode} from './nodes/LexicalTextNode'; export type CaretDirection = 'next' | 'previous'; export type FlipDirection = typeof FLIP_DIRECTION[D]; @@ -103,19 +108,19 @@ export interface NodeCaretRange * NodeCaretRange. Non-empty is defined by indexEnd > indexStart * (some text will be in the slice). * - * 0: Neither anchor nor focus are non-empty TextSliceCarets - * 1: One of anchor or focus are non-empty TextSliceCaret, or of the same origin - * 2: Anchor and focus are both non-empty TextSliceCaret of different origin + * 0: Neither anchor nor focus are non-empty TextNodeCarets + * 1: One of anchor or focus are non-empty TextNodeCaret, or of the same origin + * 2: Anchor and focus are both non-empty TextNodeCaret of different origin */ - nonEmptyTextSliceCarets: () => TextSliceCaretTuple; + getNonEmptyTextSlices: () => TextNodeCaretSliceTuple; /** * There are between zero and two TextSliceCarets for a NodeCaretRange * - * 0: Neither anchor nor focus are TextSliceCarets - * 1: One of anchor or focus are TextSliceCaret, or of the same origin - * 2: Anchor and focus are both TextSliceCaret of different origin + * 0: Neither anchor nor focus are TextNodeCarets + * 1: One of anchor or focus are TextNodeCaret, or of the same origin + * 2: Anchor and focus are both TextNodeCaret of different origin */ - textSliceCarets: () => TextSliceCaretTuple; + getTextSlices: () => TextNodeCaretSliceTuple; } export interface StepwiseIteratorConfig { @@ -148,7 +153,7 @@ export type NodeCaret = | DepthNodeCaret; export type RangeNodeCaret = - | TextSliceCaret + | TextNodeCaret | BreadthNodeCaret | DepthNodeCaret; @@ -159,6 +164,8 @@ export interface BreadthNodeCaret< T extends LexicalNode = LexicalNode, D extends CaretDirection = CaretDirection, > extends BaseNodeCaret { + /** Get a new caret with the latest origin pointer */ + getLatest: () => BreadthNodeCaret; /** * If the origin of this node is an ElementNode, return the DepthNodeCaret of this origin in the same direction. * If the origin is not an ElementNode, this will return null. @@ -180,6 +187,8 @@ export interface DepthNodeCaret< T extends ElementNode = ElementNode, D extends CaretDirection = CaretDirection, > extends BaseNodeCaret { + /** Get a new caret with the latest origin pointer */ + getLatest: () => DepthNodeCaret; getParentCaret: (mode: RootMode) => null | BreadthNodeCaret; getParentAtCaret: () => T; /** Return this, the DepthNode is already a child caret of its origin */ @@ -187,17 +196,48 @@ export interface DepthNodeCaret< } /** - * A TextSliceCaret is a special case of a BreadthNodeCaret that also carries an index - * pair used for representing partially selected TextNode at the edges of a NodeCaretRange + * A TextNodeCaret is a special case of a BreadthNodeCaret that also carries an offset + * used for representing partially selected TextNode at the edges of a NodeCaretRange. + * + * The direction determines which part of the text is adjacent to the caret, if next + * it's all of the text after offset. If previous, it's all of the text before offset. */ -export interface TextSliceCaret< +export interface TextNodeCaret< T extends TextNode = TextNode, D extends CaretDirection = CaretDirection, > extends BreadthNodeCaret { - readonly indexStart: number; - readonly indexEnd: number; + /** Get a new caret with the latest origin pointer */ + getLatest: () => TextNodeCaret; + readonly offset: number; } +/** + * A TextNodeCaretSlice is a wrapper for a TextNodeCaret that carries a size + * representing the amount of text selected from the given caret. A negative + * size means that text before offset is selected, a positive size means that + * text after offset is selected. The offset+size pair is not affected in + * any way by the direction of the caret. + * + * The selected string content can be computed as such: + * + * ``` + * slice.origin.getTextContent().slice( + * Math.min(slice.offset, slice.offset + slice.size), + * Math.max(slice.offset, slice.offset + slice.size), + * ) + * ``` + */ +export interface TextNodeCaretSlice< + T extends TextNode = TextNode, + D extends CaretDirection = CaretDirection, +> { + readonly caret: TextNodeCaret; + readonly size: number; +} + +export type TextNodeCaretSliceTuple = + readonly TextNodeCaretSlice[] & {length: 0 | 1 | 2}; + abstract class AbstractCaret< T extends LexicalNode, D extends CaretDirection, @@ -315,6 +355,12 @@ abstract class AbstractDepthNodeCaret< implements DepthNodeCaret { readonly type = 'depth'; + getLatest(): DepthNodeCaret { + const origin = this.origin.getLatest(); + return origin === this.origin + ? this + : $getDepthCaret(origin, this.direction); + } /** * Get the BreadthNodeCaret from this origin in the same direction. * @@ -396,8 +442,15 @@ abstract class AbstractBreadthNodeCaret< implements BreadthNodeCaret { readonly type = 'breadth'; - indexStart?: number; - indexEnd?: number; + // TextNodeCaret + offset?: number; + getLatest(): BreadthNodeCaret { + const origin = this.origin.getLatest(); + return origin === this.origin + ? this + : $getBreadthCaret(origin, this.direction); + } + getParentAtCaret(): ElementNode { return this.origin.getParentOrThrow(); } @@ -421,53 +474,56 @@ abstract class AbstractBreadthNodeCaret< } } +function $getTextSliceIndices( + slice: TextNodeCaretSlice, +): [indexStart: number, indexEnd: number] { + const { + size, + caret: {offset}, + } = slice; + return [offset, offset + size].sort() as [number, number]; +} + export function $removeTextSlice( - caret: TextSliceCaret, -): BreadthNodeCaret { - const {origin, direction, indexEnd, indexStart} = caret; - const text = caret.origin.getTextContent(); - return $getBreadthCaret( + slice: TextNodeCaretSlice, +): TextNodeCaret { + const { + caret: {origin, direction}, + } = slice; + const [indexStart, indexEnd] = $getTextSliceIndices(slice); + const text = origin.getTextContent(); + return $getTextNodeCaret( origin.setTextContent(text.slice(0, indexStart) + text.slice(indexEnd)), direction, + indexStart, ); } -export function $splitTextSlice( - caret: TextSliceCaret, -): BreadthNodeCaret { - const {origin, indexEnd, indexStart, direction} = caret; - const splits = origin.splitText(indexStart, indexEnd); - const splitIndex = indexStart === 0 ? 0 : 1; - const node = splits[splitIndex]; - invariant( - $isTextNode(node), - '$splitTextSlice: expecting TextNode result from origin.splitText(%s, %s)[%s] with size %s', - String(indexStart), - String(indexEnd), - String(splitIndex), - String(origin.getTextContentSize()), - ); - return $getBreadthCaret(node, direction); -} - export function $getTextSliceContent< T extends TextNode, D extends CaretDirection, ->(caret: TextSliceCaret): string { - return caret.origin.getTextContent().slice(caret.indexStart, caret.indexEnd); +>(slice: TextNodeCaretSlice): string { + return slice.caret.origin + .getTextContent() + .slice(...$getTextSliceIndices(slice)); } -export function $isTextSliceCaret( - caret: null | undefined | NodeCaret, -): caret is TextSliceCaret { +export function $isTextNodeCaret( + caret: null | undefined | RangeNodeCaret, +): caret is TextNodeCaret { return ( caret instanceof AbstractBreadthNodeCaret && $isTextNode(caret.origin) && - typeof caret.indexEnd === 'number' && - typeof caret.indexStart === 'number' + typeof caret.offset === 'number' ); } +export function $isSameTextNodeCaret< + T extends TextNodeCaret, +>(a: T, b: null | undefined | RangeNodeCaret): b is T { + return $isTextNodeCaret(b) && a.is(b) && a.offset === b.offset; +} + export function $isNodeCaret( caret: null | undefined | NodeCaret, ) { @@ -544,29 +600,53 @@ export function $getBreadthCaret( return origin ? new BREADTH_CTOR[direction](origin) : null; } -export function $getTextSliceCaret< - T extends TextNode, - D extends CaretDirection, ->( +function $getLatestTextNodeCaret( + this: TextNodeCaret, +): TextNodeCaret { + const origin = this.origin.getLatest(); + return origin === this.origin + ? this + : $getTextNodeCaret(origin, this.direction, this.offset); +} + +function $getFlippedTextNodeCaret( + this: TextNodeCaret, +): TextNodeCaret> { + return $getTextNodeCaret( + this.origin, + flipDirection(this.direction), + this.offset, + ); +} + +export function $getTextNodeCaret( origin: T, direction: D, - indexStart: number, - indexEnd: number, -): TextSliceCaret { + offset: number | CaretDirection, +): TextNodeCaret { const size = origin.getTextContentSize(); + const numericOffset = + offset === 'next' ? size : offset === 'previous' ? 0 : offset; invariant( - indexStart >= 0 && indexEnd >= indexStart && indexEnd <= size, - '$getTextSliceCaret: invalid slice with indexStart %s indexEnd %s for size %s', - String(indexStart), - String(indexEnd), + numericOffset >= 0 && numericOffset <= size, + '$getTextNodeCaret: invalid offset %s for size %s', + String(offset), String(size), ); return Object.assign($getBreadthCaret(origin, direction), { - indexEnd, - indexStart, + getFlipped: $getFlippedTextNodeCaret, + getLatest: $getLatestTextNodeCaret, + offset: numericOffset, }); } +export function $getTextNodeCaretSlice< + T extends TextNode, + D extends CaretDirection, +>(caret: TextNodeCaret, size: number): TextNodeCaretSlice { + return {caret, size}; +} + /** * Get a caret that points at the first or last child of the given origin node, * which must be an ElementNode. @@ -633,9 +713,6 @@ export function $getChildCaretAtIndex( return (direction === 'next' ? caret : caret.getFlipped()) as NodeCaret; } -export type TextSliceCaretTuple = - readonly TextSliceCaret[] & {length: 0 | 1 | 2}; - class NodeCaretRangeImpl implements NodeCaretRange { @@ -652,46 +729,60 @@ class NodeCaretRangeImpl this.focus = focus; this.direction = direction; } + getLatest(): NodeCaretRange { + const anchor = this.anchor.getLatest(); + const focus = this.focus.getLatest(); + return anchor === this.anchor && focus === this.focus + ? this + : new NodeCaretRangeImpl(anchor, focus, this.direction); + } isCollapsed(): boolean { return ( - this.anchor.is(this.focus) && this.nonEmptyTextSliceCarets().length === 0 + this.anchor.is(this.focus) && + !( + $isTextNodeCaret(this.anchor) && + !$isSameTextNodeCaret(this.anchor, this.focus) + ) ); } - nonEmptyTextSliceCarets(): TextSliceCaretTuple { - return this.textSliceCarets().filter( - (caret) => caret.indexEnd > caret.indexStart, - ) as TextSliceCaretTuple; + getNonEmptyTextSlices(): TextNodeCaretSliceTuple { + return this.getTextSlices().filter( + (slice) => slice.size !== 0, + ) as TextNodeCaretSliceTuple; } - textSliceCarets(): TextSliceCaretTuple { - const {anchor, focus} = this; - if ( - anchor.is(focus) && - $isTextSliceCaret(anchor) && - $isTextSliceCaret(focus) - ) { - const {direction} = this; - const maxStart = Math.max(anchor.indexStart, focus.indexStart); - return [ - $getTextSliceCaret( - anchor.origin, - direction, - maxStart, - Math.max(maxStart, Math.min(anchor.indexEnd, focus.indexEnd)), - ), - ]; + getTextSlices(): TextNodeCaretSliceTuple { + const slices = (['anchor', 'focus'] as const).flatMap((k) => { + const caret = this[k]; + return $isTextNodeCaret(caret) + ? [$getSliceFromTextNodeCaret(caret, k)] + : []; + }); + if (slices.length === 2) { + const [{caret: anchorCaret}, {caret: focusCaret}] = slices; + if (anchorCaret.is(focusCaret)) { + return [ + $getTextNodeCaretSlice( + anchorCaret, + focusCaret.offset - anchorCaret.offset, + ), + ]; + } } - return [anchor, focus].filter($isTextSliceCaret) as TextSliceCaretTuple; + return slices as TextNodeCaretSliceTuple; } internalCarets(rootMode: RootMode): IterableIterator> { - const step = (state: NodeCaret) => - $getAdjacentDepthCaret(state) || state.getParentCaret(rootMode); const {anchor, focus} = this; + const isTextFocus = $isTextNodeCaret(focus); + const step = (state: NodeCaret) => + state.is(focus) + ? null + : $getAdjacentDepthCaret(state) || state.getParentCaret(rootMode); return makeStepwiseIterator({ initial: anchor.is(focus) ? null : step(anchor), map: (state) => state, step, stop: (state: null | RangeNodeCaret): state is null => - state === null || state.is(focus), + state === null || (isTextFocus && focus.is(state)), }); } [Symbol.iterator](): IterableIterator> { @@ -699,18 +790,27 @@ class NodeCaretRangeImpl } } +function $getSliceFromTextNodeCaret< + T extends TextNode, + D extends CaretDirection, +>( + caret: TextNodeCaret, + anchorOrFocus: 'anchor' | 'focus', +): TextNodeCaretSlice { + const offsetB = + (caret.direction === 'next') === (anchorOrFocus === 'anchor') + ? caret.origin.getTextContentSize() + : 0; + return {caret, size: offsetB - caret.offset}; +} + /** - * Since a NodeCaret can only represent a whole node, when a text PointType - * is encountered the caret will lie before the text node if it is non-empty - * and offset === 0, otherwise it will lie after the node. - * * @param point - * @returns a NodeCaret for the point + * @returns a RangeNodeCaret for the point */ export function $caretFromPoint( point: PointType, direction: D, - anchorOrFocus: 'anchor' | 'focus', ): RangeNodeCaret { const {type, key, offset} = point; const node = $getNodeByKeyOrThrow(point.key); @@ -721,9 +821,7 @@ export function $caretFromPoint( node.getType(), key, ); - return (direction === 'next') === (anchorOrFocus === 'anchor') - ? $getTextSliceCaret(node, direction, offset, node.getTextContentSize()) - : $getTextSliceCaret(node, direction, 0, offset); + return $getTextNodeCaret(node, direction, offset); } invariant( $isElementNode(node), @@ -734,52 +832,31 @@ export function $caretFromPoint( return $getChildCaretAtIndex(node, point.offset, direction); } -function $normalizeCaretForPoint( - caret: RangeNodeCaret, -): RangeNodeCaret { - if ($isTextSliceCaret(caret)) { - return caret; - } - let current = $getChildCaretOrSelf(caret); - while ($isDepthNodeCaret(current)) { - const next = $getAdjacentDepthCaret(current); - if (!next) { - return current; - } - current = next; - } - const {origin} = current; - if ($isTextNode(origin)) { - const {direction} = caret; - const index = direction === 'next' ? 0 : origin.getTextContentSize(); - return $getTextSliceCaret(origin, direction, index, index); - } - return current; -} - export function $setPointFromCaret( point: PointType, caret: RangeNodeCaret, - normalize = true, ): void { - const normCaret = normalize ? $normalizeCaretForPoint(caret) : caret; - const {origin, direction} = normCaret; - if ($isTextSliceCaret(normCaret)) { + if ($isTextNodeCaret(caret)) { + point.set(caret.origin.getKey(), caret.offset, 'text'); + } + const {origin, direction} = caret; + const isNext = direction === 'next'; + if ($isDepthNodeCaret(caret)) { point.set( origin.getKey(), - normCaret[direction === 'next' ? 'indexEnd' : 'indexStart'], - 'text', + isNext ? 0 : caret.origin.getChildrenSize(), + 'element', ); - } else if ($isDepthNodeCaret(normCaret)) { + } else if ($isTextNode(origin)) { point.set( origin.getKey(), - direction === 'next' ? 0 : normCaret.origin.getChildrenSize(), - 'element', + isNext ? origin.getTextContentSize() : 0, + 'text', ); } else { point.set( origin.getParentOrThrow().getKey(), - origin.getIndexWithinParent(), + origin.getIndexWithinParent() + (isNext ? 1 : 0), 'element', ); } @@ -800,12 +877,139 @@ export function $caretRangeFromSelection( const {anchor, focus} = selection; const direction = focus.isBefore(anchor) ? 'previous' : 'next'; return new NodeCaretRangeImpl( - $caretFromPoint(anchor, direction, 'anchor'), - $caretFromPoint(focus, direction, 'focus'), + $caretFromPoint(anchor, direction), + $caretFromPoint(focus, direction), direction, ); } +export function $rewindBreadthCaret< + T extends LexicalNode, + D extends CaretDirection, +>(caret: BreadthNodeCaret): NodeCaret { + const {direction, origin} = caret; + // Rotate the direction around the origin and get the adjacent node + const rewindOrigin = $getBreadthCaret( + origin, + flipDirection(direction), + ).getNodeAtCaret(); + return rewindOrigin + ? $getBreadthCaret(rewindOrigin, direction) + : $getDepthCaret(origin.getParentOrThrow(), direction); +} + +export function $removeTextFromCaretRange( + range: NodeCaretRange, +): NodeCaretRange { + if (range.isCollapsed()) { + return range; + } + let anchor = range.anchor; + const {direction} = range; + + // Remove all internal nodes + const canRemove = new Set(); + for (const caret of range.internalCarets('root')) { + if ($isDepthNodeCaret(caret)) { + canRemove.add(caret.origin.getKey()); + } else if ($isBreadthNodeCaret(caret)) { + const {origin} = caret; + if (!$isElementNode(origin) || canRemove.has(origin.getKey())) { + origin.remove(); + } + } + } + // Merge blocks if necessary + const firstBlock = $getAncestor(range.anchor.origin, INTERNAL_$isBlock); + const lastBlock = $getAncestor(range.focus.origin, INTERNAL_$isBlock); + if ( + $isElementNode(lastBlock) && + canRemove.has(lastBlock.getKey()) && + $isElementNode(firstBlock) + ) { + $getDepthCaret(firstBlock, flipDirection(direction)).splice( + 0, + lastBlock.getChildren(), + ); + lastBlock.remove(); + } + // Splice text at the anchor and/or origin. If the text is entirely selected or a token then it is removed. + // Segmented nodes will be copied to a plain text node with the same format and style and set to normal mode. + for (const slice of range.getNonEmptyTextSlices()) { + const {origin} = slice.caret; + const isAnchor = anchor.is(slice.caret); + const contentSize = origin.getTextContentSize(); + const caretBefore = $rewindBreadthCaret( + $getBreadthCaret(origin, direction), + ); + const mode = origin.getMode(); + if (Math.abs(slice.size) === contentSize || mode === 'token') { + caretBefore.remove(); + if (isAnchor) { + anchor = caretBefore; + } + } else { + const nextCaret = $removeTextSlice(slice); + if (isAnchor) { + anchor = nextCaret; + } + if (mode === 'segmented') { + const src = nextCaret.origin; + const plainTextNode = $createTextNode(src.getTextContent()) + .setStyle(src.getStyle()) + .setFormat(src.getFormat()); + caretBefore.replaceOrInsert(plainTextNode); + if (isAnchor) { + anchor = $getTextNodeCaret( + plainTextNode, + nextCaret.direction, + nextCaret.offset, + ); + } + } + } + } + anchor = $normalizeCaret(anchor); + return new NodeCaretRangeImpl(anchor, anchor, direction); +} + +function $getDeepestChildOrSelf( + initialCaret: Caret, +): RangeNodeCaret['direction']> | (Caret & null) { + let caret = $getChildCaretOrSelf(initialCaret); + while ($isDepthNodeCaret(caret)) { + const childNode = caret.getNodeAtCaret(); + if (!$isElementNode(childNode)) { + break; + } + caret = $getDepthCaret(childNode, caret.direction); + } + return (caret && caret.getChildCaret()) || caret; +} + +export function $normalizeCaret( + initialCaret: RangeNodeCaret, +): RangeNodeCaret { + const latestInitialCaret = initialCaret.getLatest(); + if ($isTextNodeCaret(latestInitialCaret)) { + return latestInitialCaret; + } + const {direction} = latestInitialCaret; + const caret = $getDeepestChildOrSelf(latestInitialCaret); + if ($isTextNode(caret.origin)) { + return $getTextNodeCaret(caret.origin, direction, direction); + } + const adjacent = $getDeepestChildOrSelf(caret.getAdjacentCaret()); + if ($isBreadthNodeCaret(adjacent) && $isTextNode(adjacent.origin)) { + return $getTextNodeCaret( + adjacent.origin, + direction, + flipDirection(direction), + ); + } + return caret; +} + export function makeStepwiseIterator({ initial, stop, diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index a071dc3111c..fd9c965c41d 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1134,6 +1134,9 @@ export class RangeSelection implements BaseSelection { * Removes the text in the Selection, adjusting the EditorState accordingly. */ removeText(): void { + // const newRange = $removeTextFromCaretRange($caretRangeFromSelection(this)); + // $setPointFromCaret(this.anchor, newRange.anchor); + // $setPointFromCaret(this.focus, newRange.focus); if (this.isCollapsed()) { return; } diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts index 3732e2e0c49..5faa36370d7 100644 --- a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts @@ -13,7 +13,11 @@ import { $getBreadthCaret, $getDepthCaret, $getRoot, - $isTextSliceCaret, + $isTextNode, + $isTextNodeCaret, + $removeTextFromCaretRange, + $rewindBreadthCaret, + $setPointFromCaret, BreadthNodeCaret, DepthNodeCaret, LexicalNode, @@ -23,10 +27,13 @@ import { import {initializeUnitTest, invariant} from '../utils'; +const DIRECTIONS = ['next', 'previous'] as const; +const BIASES = ['inside', 'outside'] as const; + describe('LexicalCaret', () => { initializeUnitTest((testEnv) => { describe('$getDepthCaret', () => { - for (const direction of ['next', 'previous'] as const) { + for (const direction of DIRECTIONS) { test(`direction ${direction}`, async () => { await testEnv.editor.update( () => { @@ -134,7 +141,7 @@ describe('LexicalCaret', () => { } }); describe('$getBreadthCaret', () => { - for (const direction of ['next', 'previous'] as const) { + for (const direction of DIRECTIONS) { test(`direction ${direction}`, async () => { await testEnv.editor.update( () => { @@ -398,53 +405,53 @@ describe('LexicalCaret', () => { const range = $caretRangeFromSelection(selection); expect(range.isCollapsed()).toBe(true); invariant( - $isTextSliceCaret(range.anchor), - '$isTextSliceCaret(range.anchor)', + $isTextNodeCaret(range.anchor), + '$isTextNodeCaret(range.anchor)', ); invariant( - $isTextSliceCaret(range.focus), - '$isTextSliceCaret(range.anchor)', + $isTextNodeCaret(range.focus), + '$isTextNodeCaret(range.anchor)', ); expect(range).toMatchObject({ anchor: { direction: 'next', - indexEnd: textNodes[1].getTextContentSize(), - indexStart: offset, + offset, }, focus: { direction: 'next', - indexEnd: offset, - indexStart: 0, + offset, }, }); - expect(range.textSliceCarets()).toMatchObject([ + expect(range.getTextSlices()).toMatchObject([ { - direction: 'next', - indexEnd: offset, - indexStart: offset, - origin: node, - type: 'breadth', + caret: { + direction: 'next', + offset, + origin: node, + type: 'breadth', + }, + size: 0, }, ]); - expect(range.nonEmptyTextSliceCarets()).toEqual([]); + expect(range.getNonEmptyTextSlices()).toEqual([]); expect([...range.internalCarets('root')]).toEqual([]); } }); }); - test('full text node selection', async () => { - await testEnv.editor.update(() => { - const textNodes = ['first', 'second', 'third'].map((text) => - $createTextNode(text).setMode('token'), - ); - $getRoot() - .clear() - .append($createParagraphNode().append(...textNodes)); - for (const direction of ['next', 'previous'] as const) { + for (const direction of DIRECTIONS) { + test(`full text node selection (${direction})`, async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); for (const node of textNodes) { const key = node.getKey(); - const size = node.getTextContentSize(); + const textSize = node.getTextContentSize(); const [anchorOffset, focusOffset] = - direction === 'next' ? [0, size] : [size, 0]; + direction === 'next' ? [0, textSize] : [textSize, 0]; const selection = node.select(anchorOffset, focusOffset); expect(selection).toMatchObject({ anchor: {key, offset: anchorOffset, type: 'text'}, @@ -452,35 +459,34 @@ describe('LexicalCaret', () => { }); const range = $caretRangeFromSelection(selection); invariant( - $isTextSliceCaret(range.anchor), - '$isTextSliceCaret(range.anchor)', + $isTextNodeCaret(range.anchor), + '$isTextNodeCaret(range.anchor)', ); invariant( - $isTextSliceCaret(range.focus), - '$isTextSliceCaret(range.anchor)', + $isTextNodeCaret(range.focus), + '$isTextNodeCaret(range.anchor)', ); - const pt = (offset: number, anchorOrFocus: 'anchor' | 'focus') => - (direction === 'next') === (anchorOrFocus === 'anchor') - ? {indexEnd: size, indexStart: offset} - : {indexEnd: offset, indexStart: 0}; expect(range).toMatchObject({ - anchor: pt(anchorOffset, 'anchor'), + anchor: {direction, offset: anchorOffset, origin: node}, direction, - focus: pt(focusOffset, 'focus'), + focus: {direction, offset: focusOffset, origin: node}, }); - expect(range.textSliceCarets()).toMatchObject([ + expect(range.getTextSlices()).toMatchObject([ { - indexEnd: size, - indexStart: 0, - origin: node, + caret: { + direction, + offset: anchorOffset, + origin: node, + }, + size: focusOffset - anchorOffset, }, ]); expect([...range.internalCarets('root')]).toEqual([]); expect(range.isCollapsed()).toBe(false); } - } + }); }); - }); + } test('single text node non-empty selection', async () => { await testEnv.editor.update(() => { const textNodes = ['first', 'second', 'third'].map((text) => @@ -491,30 +497,34 @@ describe('LexicalCaret', () => { .append($createParagraphNode().append(...textNodes)); for (const node of textNodes) { // Test all non-empty selections - const size = node.getTextContentSize(); - for (let indexStart = 0; indexStart < size; indexStart++) { + const textSize = node.getTextContentSize(); + for (let indexStart = 0; indexStart < textSize; indexStart++) { for ( let indexEnd = indexStart + 1; - indexEnd <= size; + indexEnd <= textSize; indexEnd++ ) { - for (const direction of ['next', 'previous'] as const) { + for (const direction of DIRECTIONS) { + const [offset, size] = + direction === 'next' + ? [indexStart, indexEnd - indexStart] + : [indexEnd, indexStart - indexEnd]; const selection = direction === 'next' ? node.select(indexStart, indexEnd) : node.select(indexEnd, indexStart); const range = $caretRangeFromSelection(selection); invariant( - $isTextSliceCaret(range.anchor), - '$isTextSliceCaret(range.anchor)', + $isTextNodeCaret(range.anchor), + '$isTextNodeCaret(range.anchor)', ); invariant( - $isTextSliceCaret(range.focus), - '$isTextSliceCaret(range.anchor)', + $isTextNodeCaret(range.focus), + '$isTextNodeCaret(range.anchor)', ); expect(range.direction).toBe(direction); - expect(range.textSliceCarets()).toMatchObject([ - {indexEnd, indexStart, origin: node}, + expect(range.getTextSlices()).toMatchObject([ + {caret: {direction, offset, origin: node}, size}, ]); expect([...range.internalCarets('root')]).toMatchObject([]); } @@ -523,33 +533,37 @@ describe('LexicalCaret', () => { } }); }); - test('multiple text node non-empty selection', async () => { - await testEnv.editor.update(() => { - const textNodes = ['first', 'second', 'third'].map((text) => - $createTextNode(text).setMode('token'), - ); - $getRoot() - .clear() - .append($createParagraphNode().append(...textNodes)); - const selection = $getRoot().select(); + for (const direction of DIRECTIONS) { + test(`multiple text node non-empty selection (${direction})`, async () => { + await testEnv.editor.update(() => { + const textNodes = ['first', 'second', 'third'].map((text) => + $createTextNode(text).setMode('token'), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + const selection = $getRoot().select(); - // test all start and end nodes (where different) - const nodeCount = textNodes.length; - for ( - let indexNodeStart = 0; - indexNodeStart < nodeCount; - indexNodeStart++ - ) { + // test all start and end nodes (where different) + const nodeCount = textNodes.length; for ( - let indexNodeEnd = indexNodeStart + 1; - indexNodeEnd < nodeCount; - indexNodeEnd++ + let indexNodeStart = 0; + indexNodeStart < nodeCount; + indexNodeStart++ ) { - const startNode = textNodes[indexNodeStart]!; - const endNode = textNodes[indexNodeEnd]!; - for (const indexStart of [0, 1, startNode.getTextContentSize()]) { - for (const indexEnd of [0, 1, endNode.getTextContentSize()]) { - for (const direction of ['next', 'previous'] as const) { + for ( + let indexNodeEnd = indexNodeStart + 1; + indexNodeEnd < nodeCount; + indexNodeEnd++ + ) { + const startNode = textNodes[indexNodeStart]!; + const endNode = textNodes[indexNodeEnd]!; + for (const indexStart of [ + 0, + 1, + startNode.getTextContentSize(), + ]) { + for (const indexEnd of [0, 1, endNode.getTextContentSize()]) { const [anchorNode, anchorOffset, focusNode, focusOffset] = direction === 'next' ? [startNode, indexStart, endNode, indexEnd] @@ -562,36 +576,40 @@ describe('LexicalCaret', () => { ); const range = $caretRangeFromSelection(selection); invariant( - $isTextSliceCaret(range.anchor), - '$isTextSliceCaret(range.anchor)', + $isTextNodeCaret(range.anchor), + '$isTextNodeCaret(range.anchor)', ); invariant( - $isTextSliceCaret(range.focus), - '$isTextSliceCaret(range.anchor)', + $isTextNodeCaret(range.focus), + '$isTextNodeCaret(range.anchor)', ); expect(range.direction).toBe(direction); - const textSliceCarets = range.textSliceCarets(); + const textSliceCarets = range.getTextSlices(); expect(textSliceCarets).toHaveLength(2); const [anchorSlice, focusSlice] = textSliceCarets; expect(anchorSlice).toMatchObject({ - direction, - indexEnd: + caret: { + direction, + offset: anchorOffset, + origin: anchorNode, + type: 'breadth', + }, + size: direction === 'next' - ? anchorNode.getTextContentSize() - : anchorOffset, - indexStart: direction === 'next' ? anchorOffset : 0, - origin: anchorNode, - type: 'breadth', + ? anchorNode.getTextContentSize() - anchorOffset + : 0 - anchorOffset, }); expect(focusSlice).toMatchObject({ - direction, - indexEnd: + caret: { + direction, + offset: focusOffset, + origin: focusNode, + type: 'breadth', + }, + size: direction === 'next' - ? focusOffset - : focusNode.getTextContentSize(), - indexStart: direction === 'next' ? 0 : focusOffset, - origin: focusNode, - type: 'breadth', + ? 0 - focusOffset + : focusNode.getTextContentSize() - focusOffset, }); expect([...range.internalCarets('root')]).toMatchObject( textNodes @@ -606,9 +624,457 @@ describe('LexicalCaret', () => { } } } + }); + }); + } + }); + describe('$removeTextFromCaretRange', () => { + const texts = ['first', 'second', 'third'] as const; + beforeEach(async () => { + await testEnv.editor.update(() => { + // Ensure that the separate texts don't get merged + const textNodes = texts.map((text) => + $createTextNode(text).setStyle(`color: --color-${text}`), + ); + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + }); + }); + test('collapsed text point selection', async () => { + await testEnv.editor.update(() => { + const textNodes = $getRoot().getAllTextNodes(); + const originalText = $getRoot().getTextContent(); + const node = textNodes[1]; + const cases = [ + [() => node.selectStart(), 0], + [() => node.selectEnd(), node.getTextContentSize()], + [() => node.select(3, 3), 3], + ] as const; + for (const [$makeSelection, offset] of cases) { + const key = node.getKey(); + const selection = $makeSelection(); + expect(selection).toMatchObject({ + anchor: {key, offset, type: 'text'}, + focus: {key, offset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + expect(range.isCollapsed()).toBe(true); + invariant( + $isTextNodeCaret(range.anchor), + '$isTextNodeCaret(range.anchor)', + ); + invariant( + $isTextNodeCaret(range.focus), + '$isTextNodeCaret(range.anchor)', + ); + const originalRangeMatch = { + anchor: { + direction: 'next', + offset, + }, + focus: { + direction: 'next', + offset, + }, + } as const; + expect(range).toMatchObject(originalRangeMatch); + expect(range.getTextSlices()).toMatchObject([ + { + caret: { + direction: 'next', + offset, + origin: node, + type: 'breadth', + }, + size: 0, + }, + ]); + expect(range.getNonEmptyTextSlices()).toEqual([]); + expect([...range.internalCarets('root')]).toEqual([]); + expect($removeTextFromCaretRange(range)).toMatchObject( + originalRangeMatch, + ); + expect($getRoot().getTextContent()).toEqual(originalText); } }); }); + describe('full text node internal selection', () => { + for (const direction of DIRECTIONS) { + texts.forEach((text, i) => { + test(`${text} node (${direction})`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const [node] = originalNodes.splice(i, 1); + invariant($isTextNode(node), `Missing TextNode ${i}`); + const key = node.getKey(); + const size = node.getTextContentSize(); + const [anchorOffset, focusOffset] = + direction === 'next' ? [0, size] : [size, 0]; + const selection = node.select(anchorOffset, focusOffset); + expect(selection).toMatchObject({ + anchor: {key, offset: anchorOffset, type: 'text'}, + focus: {key, offset: focusOffset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + invariant( + $isTextNodeCaret(range.anchor), + '$isTextNodeCaret(range.anchor)', + ); + invariant( + $isTextNodeCaret(range.focus), + '$isTextNodeCaret(range.anchor)', + ); + expect(range).toMatchObject({ + anchor: {offset: anchorOffset, origin: node}, + direction, + focus: {offset: focusOffset, origin: node}, + }); + expect(range.getTextSlices()).toMatchObject([ + { + caret: { + offset: + direction === 'next' ? 0 : node.getTextContentSize(), + origin: node, + }, + size: + (direction === 'next' ? 1 : -1) * + node.getTextContentSize(), + }, + ]); + expect([...range.internalCarets('root')]).toEqual([]); + expect(range.isCollapsed()).toBe(false); + const resultRange = $removeTextFromCaretRange(range); + const remainingNodes = $getRoot().getAllTextNodes(); + expect(remainingNodes).toEqual( + originalNodes.map((n) => n.getLatest()), + ); + expect(remainingNodes.map((n) => n.getTextContent())).toEqual( + texts.filter((_v, j) => j !== i), + ); + expect(resultRange.isCollapsed()).toBe(true); + // bias towards the anchor + const adjacentIndex = Math.min( + remainingNodes.length - 1, + Math.max(0, i + (direction === 'next' ? -1 : 0)), + ); + const newOrigin = remainingNodes[adjacentIndex]; + const offset = + direction === 'next' + ? i === 0 + ? 0 + : newOrigin.getTextContentSize() + : i === texts.length - 1 + ? newOrigin.getTextContentSize() + : 0; + const pt = { + direction, + offset, + origin: newOrigin, + type: 'breadth', + }; + expect(resultRange).toMatchObject({ + anchor: pt, + direction, + focus: pt, + type: 'node-caret-range', + }); + }); + }); + }); + } + }); + describe('full text node biased selection', () => { + for (const [direction, [anchorBias, focusBias]] of combinations( + DIRECTIONS, + combinations(BIASES, BIASES), + )) { + if (anchorBias === 'inside' && focusBias === 'inside') { + // These cases are tested above + continue; + } + texts.forEach((text, i) => { + test(`${text} node (${direction} ${anchorBias} ${focusBias})`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const [node] = originalNodes.splice(i, 1); + invariant($isTextNode(node), `Missing TextNode ${i}`); + const size = node.getTextContentSize(); + const [anchorOffset, focusOffset] = + direction === 'next' ? [0, size] : [size, 0]; + // Create the inside selection, will mutate for outside + const selection = node.select(anchorOffset, focusOffset); + const nodeCaret = $getBreadthCaret(node, direction); + if (anchorBias === 'outside') { + $setPointFromCaret( + selection.anchor, + $rewindBreadthCaret(nodeCaret), + ); + if (direction === 'next') { + if (i === 0) { + expect(selection.anchor).toMatchObject({ + key: node.getParentOrThrow().getKey(), + offset: 0, + type: 'element', + }); + } else { + const adj = originalNodes[i - 1]!; + expect(selection.anchor).toMatchObject({ + key: adj.getKey(), + offset: adj.getTextContentSize(), + type: 'text', + }); + } + } else { + if (i === texts.length - 1) { + const parent = node.getParentOrThrow(); + expect(selection.anchor).toMatchObject({ + key: parent.getKey(), + offset: parent.getChildrenSize(), + type: 'element', + }); + } else { + const adj = originalNodes[i]!; + expect(selection.anchor).toMatchObject({ + key: adj.getKey(), + offset: 0, + type: 'text', + }); + } + } + } + if (focusBias === 'outside') { + $setPointFromCaret( + selection.focus, + $getBreadthCaret(node, direction).getFlipped(), + ); + if (direction === 'next') { + if (i === texts.length - 1) { + const parent = node.getParentOrThrow(); + expect(selection.focus).toMatchObject({ + key: parent.getKey(), + offset: parent.getChildrenSize(), + type: 'element', + }); + } else { + const adj = originalNodes[i]!; + expect(selection.focus).toMatchObject({ + key: adj.getKey(), + offset: 0, + type: 'text', + }); + } + } else { + if (i === 0) { + const parent = node.getParentOrThrow(); + expect(selection.focus).toMatchObject({ + key: parent.getKey(), + offset: 0, + type: 'element', + }); + } else { + const adj = originalNodes[i - 1]!; + expect(selection.focus).toMatchObject({ + key: adj.getKey(), + offset: adj.getTextContentSize(), + type: 'text', + }); + } + } + } + const range = $caretRangeFromSelection(selection); + expect(range.isCollapsed()).toBe(false); + expect([...range.internalCarets('root')].length).toBe( + anchorBias === 'outside' && focusBias === 'outside' ? 1 : 0, + ); + expect(range.getNonEmptyTextSlices()).toMatchObject( + anchorBias === 'outside' && focusBias === 'outside' + ? [] + : (anchorBias === 'inside') === (direction === 'next') + ? [{caret: {offset: 0}, size}] + : [{caret: {offset: size}, size: -size}], + ); + const resultRange = $removeTextFromCaretRange(range); + const remainingNodes = $getRoot().getAllTextNodes(); + expect(remainingNodes.map((n) => n.getTextContent())).toEqual( + texts.filter((_v, j) => j !== i), + ); + expect(remainingNodes).toEqual( + originalNodes.map((n) => n.getLatest()), + ); + expect(resultRange.isCollapsed()).toBe(true); + // bias towards the anchor + const adjacentIndex = Math.min( + remainingNodes.length - 1, + Math.max(0, i + (direction === 'next' ? -1 : 0)), + ); + const newOrigin = remainingNodes[adjacentIndex]; + const offset = + (direction === 'next' && i !== 0) || + (direction === 'previous' && i === texts.length - 1) + ? newOrigin.getTextContentSize() + : 0; + expect(resultRange).toMatchObject({ + anchor: { + direction, + offset, + origin: newOrigin, + type: 'breadth', + }, + direction, + focus: { + direction, + offset, + origin: newOrigin, + type: 'breadth', + }, + type: 'node-caret-range', + }); + }); + }); + }); + } + }); + + // test('single text node non-empty selection', async () => { + // await testEnv.editor.update(() => { + // const textNodes = ['first', 'second', 'third'].map((text) => + // $createTextNode(text).setMode('token'), + // ); + // $getRoot() + // .clear() + // .append($createParagraphNode().append(...textNodes)); + // for (const node of textNodes) { + // // Test all non-empty selections + // const size = node.getTextContentSize(); + // for (let indexStart = 0; indexStart < size; indexStart++) { + // for ( + // let indexEnd = indexStart + 1; + // indexEnd <= size; + // indexEnd++ + // ) { + // for (const direction of DIRECTIONS) { + // const selection = + // direction === 'next' + // ? node.select(indexStart, indexEnd) + // : node.select(indexEnd, indexStart); + // const range = $caretRangeFromSelection(selection); + // invariant( + // $isTextSliceCaret(range.anchor), + // '$isTextSliceCaret(range.anchor)', + // ); + // invariant( + // $isTextSliceCaret(range.focus), + // '$isTextSliceCaret(range.anchor)', + // ); + // expect(range.direction).toBe(direction); + // expect(range.textSliceCarets()).toMatchObject([ + // {indexEnd, indexStart, origin: node}, + // ]); + // expect([...range.internalCarets('root')]).toMatchObject([]); + // } + // } + // } + // } + // }); + // }); + // test('multiple text node non-empty selection', async () => { + // await testEnv.editor.update(() => { + // const textNodes = ['first', 'second', 'third'].map((text) => + // $createTextNode(text).setMode('token'), + // ); + // $getRoot() + // .clear() + // .append($createParagraphNode().append(...textNodes)); + // const selection = $getRoot().select(); + + // // test all start and end nodes (where different) + // const nodeCount = textNodes.length; + // for ( + // let indexNodeStart = 0; + // indexNodeStart < nodeCount; + // indexNodeStart++ + // ) { + // for ( + // let indexNodeEnd = indexNodeStart + 1; + // indexNodeEnd < nodeCount; + // indexNodeEnd++ + // ) { + // const startNode = textNodes[indexNodeStart]!; + // const endNode = textNodes[indexNodeEnd]!; + // for (const indexStart of [0, 1, startNode.getTextContentSize()]) { + // for (const indexEnd of [0, 1, endNode.getTextContentSize()]) { + // for (const direction of DIRECTIONS) { + // const [anchorNode, anchorOffset, focusNode, focusOffset] = + // direction === 'next' + // ? [startNode, indexStart, endNode, indexEnd] + // : [endNode, indexEnd, startNode, indexStart]; + // selection.setTextNodeRange( + // anchorNode, + // anchorOffset, + // focusNode, + // focusOffset, + // ); + // const range = $caretRangeFromSelection(selection); + // invariant( + // $isTextSliceCaret(range.anchor), + // '$isTextSliceCaret(range.anchor)', + // ); + // invariant( + // $isTextSliceCaret(range.focus), + // '$isTextSliceCaret(range.anchor)', + // ); + // expect(range.direction).toBe(direction); + // const textSliceCarets = range.textSliceCarets(); + // expect(textSliceCarets).toHaveLength(2); + // const [anchorSlice, focusSlice] = textSliceCarets; + // expect(anchorSlice).toMatchObject({ + // direction, + // indexEnd: + // direction === 'next' + // ? anchorNode.getTextContentSize() + // : anchorOffset, + // indexStart: direction === 'next' ? anchorOffset : 0, + // origin: anchorNode, + // type: 'breadth', + // }); + // expect(focusSlice).toMatchObject({ + // direction, + // indexEnd: + // direction === 'next' + // ? focusOffset + // : focusNode.getTextContentSize(), + // indexStart: direction === 'next' ? 0 : focusOffset, + // origin: focusNode, + // type: 'breadth', + // }); + // expect([...range.internalCarets('root')]).toMatchObject( + // textNodes + // .slice(indexNodeStart + 1, indexNodeEnd) + // .map((origin) => ({ + // direction, + // origin, + // type: 'breadth', + // })), + // ); + // } + // } + // } + // } + // } + // }); + // }); }); }); }); + +function* combinations( + as: Iterable, + bs: Iterable, +): Iterable<[A, B]> { + for (const a of as) { + for (const b of bs) { + yield [a, b]; + } + } +} diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index c4f80ec6042..b3e8273ee88 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -18,8 +18,9 @@ export type { RangeNodeCaret, RootMode, StepwiseIteratorConfig, - TextSliceCaret, - TextSliceCaretTuple, + TextNodeCaret, + TextNodeCaretSlice, + TextNodeCaretSliceTuple, } from './LexicalCaret'; export { $caretFromPoint, @@ -29,13 +30,17 @@ export { $getChildCaretAtIndex, $getChildCaretOrSelf, $getDepthCaret, - $getTextSliceCaret, + $getTextNodeCaret, + $getTextNodeCaretSlice, $getTextSliceContent, $isBreadthNodeCaret, $isDepthNodeCaret, - $isTextSliceCaret, + $isSameTextNodeCaret, + $isTextNodeCaret, + $removeTextFromCaretRange, $removeTextSlice, - $splitTextSlice, + $rewindBreadthCaret, + $setPointFromCaret, flipDirection, makeStepwiseIterator, } from './LexicalCaret'; From 64fdec3a55b2b0fb71667904f6d40a9787279316 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 26 Jan 2025 17:29:49 -0800 Subject: [PATCH 17/69] more tests --- packages/lexical/src/LexicalCaret.ts | 17 +-- .../src/__tests__/unit/LexicalCaret.test.ts | 110 +++++++++++------- packages/lexical/src/index.ts | 1 + 3 files changed, 79 insertions(+), 49 deletions(-) diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/LexicalCaret.ts index ac886428247..05810884ee4 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/LexicalCaret.ts @@ -863,10 +863,7 @@ export function $setPointFromCaret( } /** - * Get a pair of carets for a RangeSelection. Since a NodeCaret can - * only represent a whole node, when a text PointType is encountered - * the caret will be moved to before or after the node depending - * on where the other point lies. + * Get a pair of carets for a RangeSelection. * * If the focus is before the anchor, then the direction will be * 'previous', otherwise the direction will be 'next'. @@ -876,13 +873,19 @@ export function $caretRangeFromSelection( ): NodeCaretRange { const {anchor, focus} = selection; const direction = focus.isBefore(anchor) ? 'previous' : 'next'; - return new NodeCaretRangeImpl( + return $getCaretRange( $caretFromPoint(anchor, direction), $caretFromPoint(focus, direction), - direction, ); } +export function $getCaretRange( + anchor: RangeNodeCaret, + focus: RangeNodeCaret, +) { + return new NodeCaretRangeImpl(anchor, focus, anchor.direction); +} + export function $rewindBreadthCaret< T extends LexicalNode, D extends CaretDirection, @@ -970,7 +973,7 @@ export function $removeTextFromCaretRange( } } anchor = $normalizeCaret(anchor); - return new NodeCaretRangeImpl(anchor, anchor, direction); + return $getCaretRange(anchor, anchor); } function $getDeepestChildOrSelf( diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts index 5faa36370d7..436ba0584ce 100644 --- a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts @@ -11,8 +11,11 @@ import { $createParagraphNode, $createTextNode, $getBreadthCaret, + $getCaretRange, $getDepthCaret, $getRoot, + $getTextNodeCaret, + $getTextSliceContent, $isTextNode, $isTextNodeCaret, $removeTextFromCaretRange, @@ -936,48 +939,71 @@ describe('LexicalCaret', () => { } }); - // test('single text node non-empty selection', async () => { - // await testEnv.editor.update(() => { - // const textNodes = ['first', 'second', 'third'].map((text) => - // $createTextNode(text).setMode('token'), - // ); - // $getRoot() - // .clear() - // .append($createParagraphNode().append(...textNodes)); - // for (const node of textNodes) { - // // Test all non-empty selections - // const size = node.getTextContentSize(); - // for (let indexStart = 0; indexStart < size; indexStart++) { - // for ( - // let indexEnd = indexStart + 1; - // indexEnd <= size; - // indexEnd++ - // ) { - // for (const direction of DIRECTIONS) { - // const selection = - // direction === 'next' - // ? node.select(indexStart, indexEnd) - // : node.select(indexEnd, indexStart); - // const range = $caretRangeFromSelection(selection); - // invariant( - // $isTextSliceCaret(range.anchor), - // '$isTextSliceCaret(range.anchor)', - // ); - // invariant( - // $isTextSliceCaret(range.focus), - // '$isTextSliceCaret(range.anchor)', - // ); - // expect(range.direction).toBe(direction); - // expect(range.textSliceCarets()).toMatchObject([ - // {indexEnd, indexStart, origin: node}, - // ]); - // expect([...range.internalCarets('root')]).toMatchObject([]); - // } - // } - // } - // } - // }); - // }); + const EDGE_OFFSETS = [ + [0, 1], + [1, 1], + [1, 0], + ] as const; + describe('single text node non-empty partial selection', () => { + for (const [ + direction, + [anchorEdgeOffset, focusEdgeOffset], + ] of combinations(DIRECTIONS, EDGE_OFFSETS)) { + test(`${direction} ${anchorEdgeOffset}:${-focusEdgeOffset}`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const i = 0; + const text = texts[i]; + const node = originalNodes[i]; + invariant($isTextNode(node), `Missing TextNode 0`); + const size = node.getTextContentSize(); + const anchor = $getTextNodeCaret( + node, + direction, + direction === 'next' + ? anchorEdgeOffset + : size - anchorEdgeOffset, + ); + const focus = $getTextNodeCaret( + node, + direction, + direction === 'next' ? size - focusEdgeOffset : focusEdgeOffset, + ); + const [offsetStart, offsetEnd] = [ + anchor.offset, + focus.offset, + ].sort(); + const range = $getCaretRange(anchor, focus); + const slices = range.getNonEmptyTextSlices(); + expect([...range.internalCarets('root')]).toEqual([]); + expect(slices.length).toBe(1); + const [slice] = slices; + expect(slice.size).toBe( + (direction === 'next' ? 1 : -1) * + (size - anchorEdgeOffset - focusEdgeOffset), + ); + expect($getTextSliceContent(slice)).toBe( + text.slice(offsetStart, offsetEnd), + ); + const resultRange = $removeTextFromCaretRange(range); + expect(resultRange.isCollapsed()).toBe(true); + expect(resultRange.anchor).toMatchObject({ + direction, + offset: offsetStart, + origin: node.getLatest(), + }); + const remainingNodes = $getRoot().getAllTextNodes(); + expect(remainingNodes).toHaveLength(texts.length); + expect(remainingNodes.map((n) => n.getTextContent())).toEqual( + texts.map((v, j) => + i === j ? v.slice(0, offsetStart) + v.slice(offsetEnd) : v, + ), + ); + }); + }); + } + }); + // test('multiple text node non-empty selection', async () => { // await testEnv.editor.update(() => { // const textNodes = ['first', 'second', 'third'].map((text) => diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index b3e8273ee88..fe451abde84 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -27,6 +27,7 @@ export { $caretRangeFromSelection, $getAdjacentDepthCaret, $getBreadthCaret, + $getCaretRange, $getChildCaretAtIndex, $getChildCaretOrSelf, $getDepthCaret, From a1395617a062feaf3d65af6afdfc28d3405b3b8a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 26 Jan 2025 22:07:58 -0800 Subject: [PATCH 18/69] more tests --- .../src/__tests__/unit/LexicalCaret.test.ts | 285 ++++++++++++------ 1 file changed, 188 insertions(+), 97 deletions(-) diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts index 436ba0584ce..b70ff52de2a 100644 --- a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts @@ -21,6 +21,7 @@ import { $removeTextFromCaretRange, $rewindBreadthCaret, $setPointFromCaret, + $setSelection, BreadthNodeCaret, DepthNodeCaret, LexicalNode, @@ -33,6 +34,26 @@ import {initializeUnitTest, invariant} from '../utils'; const DIRECTIONS = ['next', 'previous'] as const; const BIASES = ['inside', 'outside'] as const; +function combinations(as: Iterable, bs: Iterable): [A, B][] { + const rval: [A, B][] = []; + for (const a of as) { + for (const b of bs) { + rval.push([a, b]); + } + } + return rval; +} + +function startOfNode(size: number) { + return 0; +} +function endOfNode(size: number) { + return size; +} +function insideNode(size: number) { + return 1; +} + describe('LexicalCaret', () => { initializeUnitTest((testEnv) => { describe('$getDepthCaret', () => { @@ -699,6 +720,7 @@ describe('LexicalCaret', () => { originalRangeMatch, ); expect($getRoot().getTextContent()).toEqual(originalText); + $setSelection(null); } }); }); @@ -748,6 +770,7 @@ describe('LexicalCaret', () => { expect([...range.internalCarets('root')]).toEqual([]); expect(range.isCollapsed()).toBe(false); const resultRange = $removeTextFromCaretRange(range); + $setSelection(null); const remainingNodes = $getRoot().getAllTextNodes(); expect(remainingNodes).toEqual( originalNodes.map((n) => n.getLatest()), @@ -898,6 +921,7 @@ describe('LexicalCaret', () => { : [{caret: {offset: size}, size: -size}], ); const resultRange = $removeTextFromCaretRange(range); + $setSelection(null); const remainingNodes = $getRoot().getAllTextNodes(); expect(remainingNodes.map((n) => n.getTextContent())).toEqual( texts.filter((_v, j) => j !== i), @@ -986,6 +1010,7 @@ describe('LexicalCaret', () => { text.slice(offsetStart, offsetEnd), ); const resultRange = $removeTextFromCaretRange(range); + $setSelection(null); expect(resultRange.isCollapsed()).toBe(true); expect(resultRange.anchor).toMatchObject({ direction, @@ -1004,103 +1029,169 @@ describe('LexicalCaret', () => { } }); - // test('multiple text node non-empty selection', async () => { - // await testEnv.editor.update(() => { - // const textNodes = ['first', 'second', 'third'].map((text) => - // $createTextNode(text).setMode('token'), - // ); - // $getRoot() - // .clear() - // .append($createParagraphNode().append(...textNodes)); - // const selection = $getRoot().select(); - - // // test all start and end nodes (where different) - // const nodeCount = textNodes.length; - // for ( - // let indexNodeStart = 0; - // indexNodeStart < nodeCount; - // indexNodeStart++ - // ) { - // for ( - // let indexNodeEnd = indexNodeStart + 1; - // indexNodeEnd < nodeCount; - // indexNodeEnd++ - // ) { - // const startNode = textNodes[indexNodeStart]!; - // const endNode = textNodes[indexNodeEnd]!; - // for (const indexStart of [0, 1, startNode.getTextContentSize()]) { - // for (const indexEnd of [0, 1, endNode.getTextContentSize()]) { - // for (const direction of DIRECTIONS) { - // const [anchorNode, anchorOffset, focusNode, focusOffset] = - // direction === 'next' - // ? [startNode, indexStart, endNode, indexEnd] - // : [endNode, indexEnd, startNode, indexStart]; - // selection.setTextNodeRange( - // anchorNode, - // anchorOffset, - // focusNode, - // focusOffset, - // ); - // const range = $caretRangeFromSelection(selection); - // invariant( - // $isTextSliceCaret(range.anchor), - // '$isTextSliceCaret(range.anchor)', - // ); - // invariant( - // $isTextSliceCaret(range.focus), - // '$isTextSliceCaret(range.anchor)', - // ); - // expect(range.direction).toBe(direction); - // const textSliceCarets = range.textSliceCarets(); - // expect(textSliceCarets).toHaveLength(2); - // const [anchorSlice, focusSlice] = textSliceCarets; - // expect(anchorSlice).toMatchObject({ - // direction, - // indexEnd: - // direction === 'next' - // ? anchorNode.getTextContentSize() - // : anchorOffset, - // indexStart: direction === 'next' ? anchorOffset : 0, - // origin: anchorNode, - // type: 'breadth', - // }); - // expect(focusSlice).toMatchObject({ - // direction, - // indexEnd: - // direction === 'next' - // ? focusOffset - // : focusNode.getTextContentSize(), - // indexStart: direction === 'next' ? 0 : focusOffset, - // origin: focusNode, - // type: 'breadth', - // }); - // expect([...range.internalCarets('root')]).toMatchObject( - // textNodes - // .slice(indexNodeStart + 1, indexNodeEnd) - // .map((origin) => ({ - // direction, - // origin, - // type: 'breadth', - // })), - // ); - // } - // } - // } - // } - // } - // }); - // }); + describe('multiple text node selection', () => { + const OFFSETS = [startOfNode, insideNode, endOfNode]; + const NODE_PAIRS = [ + [0, 1], + [0, 2], + [1, 2], + ] as const; + for (const [ + direction, + [[nodeIndexStart, nodeIndexEnd], [startFn, endFn]], + ] of combinations( + DIRECTIONS, + combinations(NODE_PAIRS, combinations(OFFSETS, OFFSETS)), + )) { + test(`${direction} ${texts[nodeIndexStart]} ${startFn.name} ${texts[nodeIndexEnd]} ${endFn.name}`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const startNode = originalNodes[nodeIndexStart]; + const endNode = originalNodes[nodeIndexEnd]; + expect(startNode !== endNode).toBe(true); + invariant($isTextNode(startNode), 'text node'); + invariant($isTextNode(endNode), 'text node'); + expect(startNode.isBefore(endNode)).toBe(true); + const startCaret = $getTextNodeCaret( + startNode, + direction, + startFn(startNode.getTextContentSize()), + ); + const endCaret = $getTextNodeCaret( + endNode, + direction, + endFn(endNode.getTextContentSize()), + ); + const [anchor, focus] = + direction === 'next' + ? [startCaret, endCaret] + : [endCaret, startCaret]; + const range = $getCaretRange(anchor, focus); + expect([...range.internalCarets('root')]).toHaveLength( + Math.max(0, nodeIndexEnd - nodeIndexStart - 1), + ); + const slices = range.getTextSlices(); + expect(slices).toHaveLength(2); + expect(slices.map($getTextSliceContent)).toEqual( + direction === 'next' + ? [ + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + ] + : [ + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + ], + ); + const resultRange = $removeTextFromCaretRange(range); + if (direction === 'next') { + if (anchor.offset !== 0) { + // Part of the anchor remains + expect(resultRange).toMatchObject({ + anchor: { + offset: anchor.offset, + origin: anchor.origin.getLatest(), + }, + }); + } else if (nodeIndexStart > 0) { + // The anchor was removed so bias towards the previous node + const prevNode = + originalNodes[nodeIndexStart - 1].getLatest(); + expect(resultRange).toMatchObject({ + anchor: { + offset: prevNode.getTextContentSize(), + origin: prevNode, + }, + }); + } else if (focus.offset !== texts[nodeIndexEnd].length) { + // The focus was not deleted and there is no previous node + // so the new anchor will be set to the focus origin + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd].getLatest(), + }, + }); + } else if (nodeIndexEnd !== texts.length - 1) { + // The anchor was at the start and the focus was removed + // but there is another text node to use as the anchor caret + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd + 1].getLatest(), + }, + }); + } else { + // All text has been removed so we have to use a depth caret + expect(resultRange).toMatchObject({ + anchor: { + direction, + origin: $getRoot().getFirstChild(), + type: 'depth', + }, + }); + } + } else { + invariant(direction === 'previous', 'exhaustiveness check'); + if (anchor.offset !== texts[nodeIndexEnd].length) { + // Part of the anchor remains + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: anchor.origin.getLatest(), + }, + }); + } else if (nodeIndexEnd < texts.length - 1) { + // The anchor was removed so bias towards the next node + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd + 1].getLatest(), + }, + }); + } else if (focus.offset !== 0) { + // The focus was not deleted and there is no next node + // so the new anchor will be set to the focus origin + expect(resultRange).toMatchObject({ + anchor: { + offset: focus.offset, + origin: focus.origin.getLatest(), + }, + }); + } else if (nodeIndexStart > 0) { + // The anchor was at the end and the focus was removed + // but there is another text node to use as the anchor caret + const prevNode = + originalNodes[nodeIndexStart - 1].getLatest(); + expect(resultRange).toMatchObject({ + anchor: { + offset: prevNode.getTextContentSize(), + origin: prevNode, + }, + }); + } else { + // All text has been removed so we have to use a depth caret + expect(resultRange).toMatchObject({ + anchor: { + direction, + origin: $getRoot().getFirstChild(), + type: 'depth', + }, + }); + } + } + }); + }); + } + }); }); }); }); - -function* combinations( - as: Iterable, - bs: Iterable, -): Iterable<[A, B]> { - for (const a of as) { - for (const b of bs) { - yield [a, b]; - } - } -} From f8c0781a6f01965cfbbf61818d1ee17cee3701a2 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 27 Jan 2025 08:23:17 -0800 Subject: [PATCH 19/69] Finish tests for multiple nodes in one paragraph --- .../src/__tests__/unit/LexicalCaret.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts index b70ff52de2a..20dab9e6158 100644 --- a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts @@ -1188,6 +1188,46 @@ describe('LexicalCaret', () => { }); } } + const remainingNodes = $getRoot().getAllTextNodes(); + let newIndex = 0; + for ( + let originalIndex = 0; + originalIndex < originalNodes.length; + originalIndex++ + ) { + const originalText = texts[originalIndex]; + const originalNode = originalNodes[originalIndex]; + let deleted: boolean; + if (originalIndex === nodeIndexStart) { + deleted = startCaret.offset === 0; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(0, startCaret.offset), + ); + } + } else if ( + originalIndex > nodeIndexStart && + originalIndex < nodeIndexEnd + ) { + deleted = true; + } else if (originalIndex === nodeIndexEnd) { + deleted = endCaret.offset === originalText.length; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(endCaret.offset), + ); + } + } else { + deleted = false; + expect(originalNode.getTextContent()).toBe(originalText); + } + expect(originalNode.isAttached()).toBe(!deleted); + if (!deleted) { + expect(originalNode.is(remainingNodes[newIndex])).toBe(true); + } + newIndex += deleted ? 0 : 1; + } + expect(remainingNodes).toHaveLength(newIndex); }); }); } From 8754a46131ded582fe3d55cb650035034ad662f9 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 27 Jan 2025 08:56:16 -0800 Subject: [PATCH 20/69] cases for multiple blocks --- .../src/__tests__/unit/LexicalCaret.test.ts | 1270 ++++++++++------- 1 file changed, 737 insertions(+), 533 deletions(-) diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts index 20dab9e6158..a64aaf26978 100644 --- a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts @@ -654,583 +654,787 @@ describe('LexicalCaret', () => { }); describe('$removeTextFromCaretRange', () => { const texts = ['first', 'second', 'third'] as const; - beforeEach(async () => { - await testEnv.editor.update(() => { - // Ensure that the separate texts don't get merged - const textNodes = texts.map((text) => - $createTextNode(text).setStyle(`color: --color-${text}`), - ); - $getRoot() - .clear() - .append($createParagraphNode().append(...textNodes)); - }); - }); - test('collapsed text point selection', async () => { - await testEnv.editor.update(() => { - const textNodes = $getRoot().getAllTextNodes(); - const originalText = $getRoot().getTextContent(); - const node = textNodes[1]; - const cases = [ - [() => node.selectStart(), 0], - [() => node.selectEnd(), node.getTextContentSize()], - [() => node.select(3, 3), 3], - ] as const; - for (const [$makeSelection, offset] of cases) { - const key = node.getKey(); - const selection = $makeSelection(); - expect(selection).toMatchObject({ - anchor: {key, offset, type: 'text'}, - focus: {key, offset, type: 'text'}, - }); - const range = $caretRangeFromSelection(selection); - expect(range.isCollapsed()).toBe(true); - invariant( - $isTextNodeCaret(range.anchor), - '$isTextNodeCaret(range.anchor)', - ); - invariant( - $isTextNodeCaret(range.focus), - '$isTextNodeCaret(range.anchor)', + describe('single block', () => { + beforeEach(async () => { + await testEnv.editor.update(() => { + // Ensure that the separate texts don't get merged + const textNodes = texts.map((text) => + $createTextNode(text).setStyle(`color: --color-${text}`), ); - const originalRangeMatch = { - anchor: { - direction: 'next', - offset, - }, - focus: { - direction: 'next', - offset, - }, - } as const; - expect(range).toMatchObject(originalRangeMatch); - expect(range.getTextSlices()).toMatchObject([ - { - caret: { + $getRoot() + .clear() + .append($createParagraphNode().append(...textNodes)); + }); + }); + test('collapsed text point selection', async () => { + await testEnv.editor.update(() => { + const textNodes = $getRoot().getAllTextNodes(); + const originalText = $getRoot().getTextContent(); + const node = textNodes[1]; + const cases = [ + [() => node.selectStart(), 0], + [() => node.selectEnd(), node.getTextContentSize()], + [() => node.select(3, 3), 3], + ] as const; + for (const [$makeSelection, offset] of cases) { + const key = node.getKey(); + const selection = $makeSelection(); + expect(selection).toMatchObject({ + anchor: {key, offset, type: 'text'}, + focus: {key, offset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + expect(range.isCollapsed()).toBe(true); + invariant( + $isTextNodeCaret(range.anchor), + '$isTextNodeCaret(range.anchor)', + ); + invariant( + $isTextNodeCaret(range.focus), + '$isTextNodeCaret(range.anchor)', + ); + const originalRangeMatch = { + anchor: { direction: 'next', offset, - origin: node, - type: 'breadth', }, - size: 0, - }, - ]); - expect(range.getNonEmptyTextSlices()).toEqual([]); - expect([...range.internalCarets('root')]).toEqual([]); - expect($removeTextFromCaretRange(range)).toMatchObject( - originalRangeMatch, - ); - expect($getRoot().getTextContent()).toEqual(originalText); - $setSelection(null); + focus: { + direction: 'next', + offset, + }, + } as const; + expect(range).toMatchObject(originalRangeMatch); + expect(range.getTextSlices()).toMatchObject([ + { + caret: { + direction: 'next', + offset, + origin: node, + type: 'breadth', + }, + size: 0, + }, + ]); + expect(range.getNonEmptyTextSlices()).toEqual([]); + expect([...range.internalCarets('root')]).toEqual([]); + expect($removeTextFromCaretRange(range)).toMatchObject( + originalRangeMatch, + ); + expect($getRoot().getTextContent()).toEqual(originalText); + $setSelection(null); + } + }); + }); + describe('full text node internal selection', () => { + for (const direction of DIRECTIONS) { + texts.forEach((text, i) => { + test(`${text} node (${direction})`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const [node] = originalNodes.splice(i, 1); + invariant($isTextNode(node), `Missing TextNode ${i}`); + const key = node.getKey(); + const size = node.getTextContentSize(); + const [anchorOffset, focusOffset] = + direction === 'next' ? [0, size] : [size, 0]; + const selection = node.select(anchorOffset, focusOffset); + expect(selection).toMatchObject({ + anchor: {key, offset: anchorOffset, type: 'text'}, + focus: {key, offset: focusOffset, type: 'text'}, + }); + const range = $caretRangeFromSelection(selection); + invariant( + $isTextNodeCaret(range.anchor), + '$isTextNodeCaret(range.anchor)', + ); + invariant( + $isTextNodeCaret(range.focus), + '$isTextNodeCaret(range.anchor)', + ); + expect(range).toMatchObject({ + anchor: {offset: anchorOffset, origin: node}, + direction, + focus: {offset: focusOffset, origin: node}, + }); + expect(range.getTextSlices()).toMatchObject([ + { + caret: { + offset: + direction === 'next' ? 0 : node.getTextContentSize(), + origin: node, + }, + size: + (direction === 'next' ? 1 : -1) * + node.getTextContentSize(), + }, + ]); + expect([...range.internalCarets('root')]).toEqual([]); + expect(range.isCollapsed()).toBe(false); + const resultRange = $removeTextFromCaretRange(range); + $setSelection(null); + const remainingNodes = $getRoot().getAllTextNodes(); + expect(remainingNodes).toEqual( + originalNodes.map((n) => n.getLatest()), + ); + expect(remainingNodes.map((n) => n.getTextContent())).toEqual( + texts.filter((_v, j) => j !== i), + ); + expect(resultRange.isCollapsed()).toBe(true); + // bias towards the anchor + const adjacentIndex = Math.min( + remainingNodes.length - 1, + Math.max(0, i + (direction === 'next' ? -1 : 0)), + ); + const newOrigin = remainingNodes[adjacentIndex]; + const offset = + direction === 'next' + ? i === 0 + ? 0 + : newOrigin.getTextContentSize() + : i === texts.length - 1 + ? newOrigin.getTextContentSize() + : 0; + const pt = { + direction, + offset, + origin: newOrigin, + type: 'breadth', + }; + expect(resultRange).toMatchObject({ + anchor: pt, + direction, + focus: pt, + type: 'node-caret-range', + }); + }); + }); + }); } }); - }); - describe('full text node internal selection', () => { - for (const direction of DIRECTIONS) { - texts.forEach((text, i) => { - test(`${text} node (${direction})`, async () => { + describe('full text node biased selection', () => { + for (const [direction, [anchorBias, focusBias]] of combinations( + DIRECTIONS, + combinations(BIASES, BIASES), + )) { + if (anchorBias === 'inside' && focusBias === 'inside') { + // These cases are tested above + continue; + } + texts.forEach((text, i) => { + test(`${text} node (${direction} ${anchorBias} ${focusBias})`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const [node] = originalNodes.splice(i, 1); + invariant($isTextNode(node), `Missing TextNode ${i}`); + const size = node.getTextContentSize(); + const [anchorOffset, focusOffset] = + direction === 'next' ? [0, size] : [size, 0]; + // Create the inside selection, will mutate for outside + const selection = node.select(anchorOffset, focusOffset); + const nodeCaret = $getBreadthCaret(node, direction); + if (anchorBias === 'outside') { + $setPointFromCaret( + selection.anchor, + $rewindBreadthCaret(nodeCaret), + ); + if (direction === 'next') { + if (i === 0) { + expect(selection.anchor).toMatchObject({ + key: node.getParentOrThrow().getKey(), + offset: 0, + type: 'element', + }); + } else { + const adj = originalNodes[i - 1]!; + expect(selection.anchor).toMatchObject({ + key: adj.getKey(), + offset: adj.getTextContentSize(), + type: 'text', + }); + } + } else { + if (i === texts.length - 1) { + const parent = node.getParentOrThrow(); + expect(selection.anchor).toMatchObject({ + key: parent.getKey(), + offset: parent.getChildrenSize(), + type: 'element', + }); + } else { + const adj = originalNodes[i]!; + expect(selection.anchor).toMatchObject({ + key: adj.getKey(), + offset: 0, + type: 'text', + }); + } + } + } + if (focusBias === 'outside') { + $setPointFromCaret( + selection.focus, + $getBreadthCaret(node, direction).getFlipped(), + ); + if (direction === 'next') { + if (i === texts.length - 1) { + const parent = node.getParentOrThrow(); + expect(selection.focus).toMatchObject({ + key: parent.getKey(), + offset: parent.getChildrenSize(), + type: 'element', + }); + } else { + const adj = originalNodes[i]!; + expect(selection.focus).toMatchObject({ + key: adj.getKey(), + offset: 0, + type: 'text', + }); + } + } else { + if (i === 0) { + const parent = node.getParentOrThrow(); + expect(selection.focus).toMatchObject({ + key: parent.getKey(), + offset: 0, + type: 'element', + }); + } else { + const adj = originalNodes[i - 1]!; + expect(selection.focus).toMatchObject({ + key: adj.getKey(), + offset: adj.getTextContentSize(), + type: 'text', + }); + } + } + } + const range = $caretRangeFromSelection(selection); + expect(range.isCollapsed()).toBe(false); + expect([...range.internalCarets('root')].length).toBe( + anchorBias === 'outside' && focusBias === 'outside' ? 1 : 0, + ); + expect(range.getNonEmptyTextSlices()).toMatchObject( + anchorBias === 'outside' && focusBias === 'outside' + ? [] + : (anchorBias === 'inside') === (direction === 'next') + ? [{caret: {offset: 0}, size}] + : [{caret: {offset: size}, size: -size}], + ); + const resultRange = $removeTextFromCaretRange(range); + $setSelection(null); + const remainingNodes = $getRoot().getAllTextNodes(); + expect(remainingNodes.map((n) => n.getTextContent())).toEqual( + texts.filter((_v, j) => j !== i), + ); + expect(remainingNodes).toEqual( + originalNodes.map((n) => n.getLatest()), + ); + expect(resultRange.isCollapsed()).toBe(true); + // bias towards the anchor + const adjacentIndex = Math.min( + remainingNodes.length - 1, + Math.max(0, i + (direction === 'next' ? -1 : 0)), + ); + const newOrigin = remainingNodes[adjacentIndex]; + const offset = + (direction === 'next' && i !== 0) || + (direction === 'previous' && i === texts.length - 1) + ? newOrigin.getTextContentSize() + : 0; + expect(resultRange).toMatchObject({ + anchor: { + direction, + offset, + origin: newOrigin, + type: 'breadth', + }, + direction, + focus: { + direction, + offset, + origin: newOrigin, + type: 'breadth', + }, + type: 'node-caret-range', + }); + }); + }); + }); + } + }); + + const EDGE_OFFSETS = [ + [0, 1], + [1, 1], + [1, 0], + ] as const; + describe('single text node non-empty partial selection', () => { + for (const [ + direction, + [anchorEdgeOffset, focusEdgeOffset], + ] of combinations(DIRECTIONS, EDGE_OFFSETS)) { + test(`${direction} ${anchorEdgeOffset}:${-focusEdgeOffset}`, async () => { await testEnv.editor.update(() => { const originalNodes = $getRoot().getAllTextNodes(); - const [node] = originalNodes.splice(i, 1); - invariant($isTextNode(node), `Missing TextNode ${i}`); - const key = node.getKey(); + const i = 0; + const text = texts[i]; + const node = originalNodes[i]; + invariant($isTextNode(node), `Missing TextNode 0`); const size = node.getTextContentSize(); - const [anchorOffset, focusOffset] = - direction === 'next' ? [0, size] : [size, 0]; - const selection = node.select(anchorOffset, focusOffset); - expect(selection).toMatchObject({ - anchor: {key, offset: anchorOffset, type: 'text'}, - focus: {key, offset: focusOffset, type: 'text'}, - }); - const range = $caretRangeFromSelection(selection); - invariant( - $isTextNodeCaret(range.anchor), - '$isTextNodeCaret(range.anchor)', - ); - invariant( - $isTextNodeCaret(range.focus), - '$isTextNodeCaret(range.anchor)', + const anchor = $getTextNodeCaret( + node, + direction, + direction === 'next' + ? anchorEdgeOffset + : size - anchorEdgeOffset, ); - expect(range).toMatchObject({ - anchor: {offset: anchorOffset, origin: node}, + const focus = $getTextNodeCaret( + node, direction, - focus: {offset: focusOffset, origin: node}, - }); - expect(range.getTextSlices()).toMatchObject([ - { - caret: { - offset: - direction === 'next' ? 0 : node.getTextContentSize(), - origin: node, - }, - size: - (direction === 'next' ? 1 : -1) * - node.getTextContentSize(), - }, - ]); + direction === 'next' + ? size - focusEdgeOffset + : focusEdgeOffset, + ); + const [offsetStart, offsetEnd] = [ + anchor.offset, + focus.offset, + ].sort(); + const range = $getCaretRange(anchor, focus); + const slices = range.getNonEmptyTextSlices(); expect([...range.internalCarets('root')]).toEqual([]); - expect(range.isCollapsed()).toBe(false); - const resultRange = $removeTextFromCaretRange(range); - $setSelection(null); - const remainingNodes = $getRoot().getAllTextNodes(); - expect(remainingNodes).toEqual( - originalNodes.map((n) => n.getLatest()), + expect(slices.length).toBe(1); + const [slice] = slices; + expect(slice.size).toBe( + (direction === 'next' ? 1 : -1) * + (size - anchorEdgeOffset - focusEdgeOffset), ); - expect(remainingNodes.map((n) => n.getTextContent())).toEqual( - texts.filter((_v, j) => j !== i), + expect($getTextSliceContent(slice)).toBe( + text.slice(offsetStart, offsetEnd), ); + const resultRange = $removeTextFromCaretRange(range); + $setSelection(null); expect(resultRange.isCollapsed()).toBe(true); - // bias towards the anchor - const adjacentIndex = Math.min( - remainingNodes.length - 1, - Math.max(0, i + (direction === 'next' ? -1 : 0)), - ); - const newOrigin = remainingNodes[adjacentIndex]; - const offset = - direction === 'next' - ? i === 0 - ? 0 - : newOrigin.getTextContentSize() - : i === texts.length - 1 - ? newOrigin.getTextContentSize() - : 0; - const pt = { - direction, - offset, - origin: newOrigin, - type: 'breadth', - }; - expect(resultRange).toMatchObject({ - anchor: pt, + expect(resultRange.anchor).toMatchObject({ direction, - focus: pt, - type: 'node-caret-range', + offset: offsetStart, + origin: node.getLatest(), }); + const remainingNodes = $getRoot().getAllTextNodes(); + expect(remainingNodes).toHaveLength(texts.length); + expect(remainingNodes.map((n) => n.getTextContent())).toEqual( + texts.map((v, j) => + i === j ? v.slice(0, offsetStart) + v.slice(offsetEnd) : v, + ), + ); }); }); - }); - } - }); - describe('full text node biased selection', () => { - for (const [direction, [anchorBias, focusBias]] of combinations( - DIRECTIONS, - combinations(BIASES, BIASES), - )) { - if (anchorBias === 'inside' && focusBias === 'inside') { - // These cases are tested above - continue; } - texts.forEach((text, i) => { - test(`${text} node (${direction} ${anchorBias} ${focusBias})`, async () => { + }); + + describe('multiple text node selection', () => { + const OFFSETS = [startOfNode, insideNode, endOfNode]; + const NODE_PAIRS = [ + [0, 1], + [0, 2], + [1, 2], + ] as const; + for (const [ + direction, + [[nodeIndexStart, nodeIndexEnd], [startFn, endFn]], + ] of combinations( + DIRECTIONS, + combinations(NODE_PAIRS, combinations(OFFSETS, OFFSETS)), + )) { + test(`${direction} ${texts[nodeIndexStart]} ${startFn.name} ${texts[nodeIndexEnd]} ${endFn.name}`, async () => { await testEnv.editor.update(() => { const originalNodes = $getRoot().getAllTextNodes(); - const [node] = originalNodes.splice(i, 1); - invariant($isTextNode(node), `Missing TextNode ${i}`); - const size = node.getTextContentSize(); - const [anchorOffset, focusOffset] = - direction === 'next' ? [0, size] : [size, 0]; - // Create the inside selection, will mutate for outside - const selection = node.select(anchorOffset, focusOffset); - const nodeCaret = $getBreadthCaret(node, direction); - if (anchorBias === 'outside') { - $setPointFromCaret( - selection.anchor, - $rewindBreadthCaret(nodeCaret), - ); - if (direction === 'next') { - if (i === 0) { - expect(selection.anchor).toMatchObject({ - key: node.getParentOrThrow().getKey(), + const startNode = originalNodes[nodeIndexStart]; + const endNode = originalNodes[nodeIndexEnd]; + expect(startNode !== endNode).toBe(true); + invariant($isTextNode(startNode), 'text node'); + invariant($isTextNode(endNode), 'text node'); + expect(startNode.isBefore(endNode)).toBe(true); + const startCaret = $getTextNodeCaret( + startNode, + direction, + startFn(startNode.getTextContentSize()), + ); + const endCaret = $getTextNodeCaret( + endNode, + direction, + endFn(endNode.getTextContentSize()), + ); + const [anchor, focus] = + direction === 'next' + ? [startCaret, endCaret] + : [endCaret, startCaret]; + const range = $getCaretRange(anchor, focus); + expect([...range.internalCarets('root')]).toHaveLength( + Math.max(0, nodeIndexEnd - nodeIndexStart - 1), + ); + const slices = range.getTextSlices(); + expect(slices).toHaveLength(2); + expect(slices.map($getTextSliceContent)).toEqual( + direction === 'next' + ? [ + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + ] + : [ + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + ], + ); + const resultRange = $removeTextFromCaretRange(range); + if (direction === 'next') { + if (anchor.offset !== 0) { + // Part of the anchor remains + expect(resultRange).toMatchObject({ + anchor: { + offset: anchor.offset, + origin: anchor.origin.getLatest(), + }, + }); + } else if (nodeIndexStart > 0) { + // The anchor was removed so bias towards the previous node + const prevNode = + originalNodes[nodeIndexStart - 1].getLatest(); + expect(resultRange).toMatchObject({ + anchor: { + offset: prevNode.getTextContentSize(), + origin: prevNode, + }, + }); + } else if (focus.offset !== texts[nodeIndexEnd].length) { + // The focus was not deleted and there is no previous node + // so the new anchor will be set to the focus origin + expect(resultRange).toMatchObject({ + anchor: { offset: 0, - type: 'element', - }); - } else { - const adj = originalNodes[i - 1]!; - expect(selection.anchor).toMatchObject({ - key: adj.getKey(), - offset: adj.getTextContentSize(), - type: 'text', - }); - } + origin: originalNodes[nodeIndexEnd].getLatest(), + }, + }); + } else if (nodeIndexEnd !== texts.length - 1) { + // The anchor was at the start and the focus was removed + // but there is another text node to use as the anchor caret + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd + 1].getLatest(), + }, + }); } else { - if (i === texts.length - 1) { - const parent = node.getParentOrThrow(); - expect(selection.anchor).toMatchObject({ - key: parent.getKey(), - offset: parent.getChildrenSize(), - type: 'element', - }); - } else { - const adj = originalNodes[i]!; - expect(selection.anchor).toMatchObject({ - key: adj.getKey(), + // All text has been removed so we have to use a depth caret + expect(resultRange).toMatchObject({ + anchor: { + direction, + origin: $getRoot().getFirstChild(), + type: 'depth', + }, + }); + } + } else { + invariant(direction === 'previous', 'exhaustiveness check'); + if (anchor.offset !== texts[nodeIndexEnd].length) { + // Part of the anchor remains + expect(resultRange).toMatchObject({ + anchor: { offset: 0, - type: 'text', - }); - } + origin: anchor.origin.getLatest(), + }, + }); + } else if (nodeIndexEnd < texts.length - 1) { + // The anchor was removed so bias towards the next node + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd + 1].getLatest(), + }, + }); + } else if (focus.offset !== 0) { + // The focus was not deleted and there is no next node + // so the new anchor will be set to the focus origin + expect(resultRange).toMatchObject({ + anchor: { + offset: focus.offset, + origin: focus.origin.getLatest(), + }, + }); + } else if (nodeIndexStart > 0) { + // The anchor was at the end and the focus was removed + // but there is another text node to use as the anchor caret + const prevNode = + originalNodes[nodeIndexStart - 1].getLatest(); + expect(resultRange).toMatchObject({ + anchor: { + offset: prevNode.getTextContentSize(), + origin: prevNode, + }, + }); + } else { + // All text has been removed so we have to use a depth caret + expect(resultRange).toMatchObject({ + anchor: { + direction, + origin: $getRoot().getFirstChild(), + type: 'depth', + }, + }); } } - if (focusBias === 'outside') { - $setPointFromCaret( - selection.focus, - $getBreadthCaret(node, direction).getFlipped(), - ); - if (direction === 'next') { - if (i === texts.length - 1) { - const parent = node.getParentOrThrow(); - expect(selection.focus).toMatchObject({ - key: parent.getKey(), - offset: parent.getChildrenSize(), - type: 'element', - }); - } else { - const adj = originalNodes[i]!; - expect(selection.focus).toMatchObject({ - key: adj.getKey(), - offset: 0, - type: 'text', - }); + const remainingNodes = $getRoot().getAllTextNodes(); + let newIndex = 0; + for ( + let originalIndex = 0; + originalIndex < originalNodes.length; + originalIndex++ + ) { + const originalText = texts[originalIndex]; + const originalNode = originalNodes[originalIndex]; + let deleted: boolean; + if (originalIndex === nodeIndexStart) { + deleted = startCaret.offset === 0; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(0, startCaret.offset), + ); } - } else { - if (i === 0) { - const parent = node.getParentOrThrow(); - expect(selection.focus).toMatchObject({ - key: parent.getKey(), - offset: 0, - type: 'element', - }); - } else { - const adj = originalNodes[i - 1]!; - expect(selection.focus).toMatchObject({ - key: adj.getKey(), - offset: adj.getTextContentSize(), - type: 'text', - }); + } else if ( + originalIndex > nodeIndexStart && + originalIndex < nodeIndexEnd + ) { + deleted = true; + } else if (originalIndex === nodeIndexEnd) { + deleted = endCaret.offset === originalText.length; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(endCaret.offset), + ); } + } else { + deleted = false; + expect(originalNode.getTextContent()).toBe(originalText); + } + expect(originalNode.isAttached()).toBe(!deleted); + if (!deleted) { + expect(originalNode.is(remainingNodes[newIndex])).toBe( + true, + ); } + newIndex += deleted ? 0 : 1; } - const range = $caretRangeFromSelection(selection); - expect(range.isCollapsed()).toBe(false); - expect([...range.internalCarets('root')].length).toBe( - anchorBias === 'outside' && focusBias === 'outside' ? 1 : 0, - ); - expect(range.getNonEmptyTextSlices()).toMatchObject( - anchorBias === 'outside' && focusBias === 'outside' - ? [] - : (anchorBias === 'inside') === (direction === 'next') - ? [{caret: {offset: 0}, size}] - : [{caret: {offset: size}, size: -size}], - ); - const resultRange = $removeTextFromCaretRange(range); - $setSelection(null); - const remainingNodes = $getRoot().getAllTextNodes(); - expect(remainingNodes.map((n) => n.getTextContent())).toEqual( - texts.filter((_v, j) => j !== i), - ); - expect(remainingNodes).toEqual( - originalNodes.map((n) => n.getLatest()), - ); - expect(resultRange.isCollapsed()).toBe(true); - // bias towards the anchor - const adjacentIndex = Math.min( - remainingNodes.length - 1, - Math.max(0, i + (direction === 'next' ? -1 : 0)), - ); - const newOrigin = remainingNodes[adjacentIndex]; - const offset = - (direction === 'next' && i !== 0) || - (direction === 'previous' && i === texts.length - 1) - ? newOrigin.getTextContentSize() - : 0; - expect(resultRange).toMatchObject({ - anchor: { - direction, - offset, - origin: newOrigin, - type: 'breadth', - }, - direction, - focus: { - direction, - offset, - origin: newOrigin, - type: 'breadth', - }, - type: 'node-caret-range', - }); + expect(remainingNodes).toHaveLength(newIndex); }); }); - }); - } + } + }); }); - - const EDGE_OFFSETS = [ - [0, 1], - [1, 1], - [1, 0], - ] as const; - describe('single text node non-empty partial selection', () => { - for (const [ - direction, - [anchorEdgeOffset, focusEdgeOffset], - ] of combinations(DIRECTIONS, EDGE_OFFSETS)) { - test(`${direction} ${anchorEdgeOffset}:${-focusEdgeOffset}`, async () => { - await testEnv.editor.update(() => { - const originalNodes = $getRoot().getAllTextNodes(); - const i = 0; - const text = texts[i]; - const node = originalNodes[i]; - invariant($isTextNode(node), `Missing TextNode 0`); - const size = node.getTextContentSize(); - const anchor = $getTextNodeCaret( - node, - direction, - direction === 'next' - ? anchorEdgeOffset - : size - anchorEdgeOffset, - ); - const focus = $getTextNodeCaret( - node, - direction, - direction === 'next' ? size - focusEdgeOffset : focusEdgeOffset, - ); - const [offsetStart, offsetEnd] = [ - anchor.offset, - focus.offset, - ].sort(); - const range = $getCaretRange(anchor, focus); - const slices = range.getNonEmptyTextSlices(); - expect([...range.internalCarets('root')]).toEqual([]); - expect(slices.length).toBe(1); - const [slice] = slices; - expect(slice.size).toBe( - (direction === 'next' ? 1 : -1) * - (size - anchorEdgeOffset - focusEdgeOffset), - ); - expect($getTextSliceContent(slice)).toBe( - text.slice(offsetStart, offsetEnd), - ); - const resultRange = $removeTextFromCaretRange(range); - $setSelection(null); - expect(resultRange.isCollapsed()).toBe(true); - expect(resultRange.anchor).toMatchObject({ - direction, - offset: offsetStart, - origin: node.getLatest(), - }); - const remainingNodes = $getRoot().getAllTextNodes(); - expect(remainingNodes).toHaveLength(texts.length); - expect(remainingNodes.map((n) => n.getTextContent())).toEqual( - texts.map((v, j) => - i === j ? v.slice(0, offsetStart) + v.slice(offsetEnd) : v, - ), + describe('multiple blocks', () => { + beforeEach(async () => { + await testEnv.editor.update(() => { + // Ensure that the separate texts don't get merged + const textNodes = texts.map((text) => + $createTextNode(text).setStyle(`color: --color-${text}`), + ); + $getRoot() + .clear() + .append( + ...textNodes.map((node) => $createParagraphNode().append(node)), ); - }); }); - } - }); - - describe('multiple text node selection', () => { - const OFFSETS = [startOfNode, insideNode, endOfNode]; - const NODE_PAIRS = [ - [0, 1], - [0, 2], - [1, 2], - ] as const; - for (const [ - direction, - [[nodeIndexStart, nodeIndexEnd], [startFn, endFn]], - ] of combinations( - DIRECTIONS, - combinations(NODE_PAIRS, combinations(OFFSETS, OFFSETS)), - )) { - test(`${direction} ${texts[nodeIndexStart]} ${startFn.name} ${texts[nodeIndexEnd]} ${endFn.name}`, async () => { - await testEnv.editor.update(() => { - const originalNodes = $getRoot().getAllTextNodes(); - const startNode = originalNodes[nodeIndexStart]; - const endNode = originalNodes[nodeIndexEnd]; - expect(startNode !== endNode).toBe(true); - invariant($isTextNode(startNode), 'text node'); - invariant($isTextNode(endNode), 'text node'); - expect(startNode.isBefore(endNode)).toBe(true); - const startCaret = $getTextNodeCaret( - startNode, - direction, - startFn(startNode.getTextContentSize()), - ); - const endCaret = $getTextNodeCaret( - endNode, - direction, - endFn(endNode.getTextContentSize()), - ); - const [anchor, focus] = - direction === 'next' - ? [startCaret, endCaret] - : [endCaret, startCaret]; - const range = $getCaretRange(anchor, focus); - expect([...range.internalCarets('root')]).toHaveLength( - Math.max(0, nodeIndexEnd - nodeIndexStart - 1), - ); - const slices = range.getTextSlices(); - expect(slices).toHaveLength(2); - expect(slices.map($getTextSliceContent)).toEqual( - direction === 'next' - ? [ - startCaret.origin - .getTextContent() - .slice(startCaret.offset), - endCaret.origin - .getTextContent() - .slice(0, endCaret.offset), - ] - : [ - endCaret.origin - .getTextContent() - .slice(0, endCaret.offset), - startCaret.origin - .getTextContent() - .slice(startCaret.offset), - ], - ); - const resultRange = $removeTextFromCaretRange(range); - if (direction === 'next') { - if (anchor.offset !== 0) { - // Part of the anchor remains - expect(resultRange).toMatchObject({ - anchor: { - offset: anchor.offset, - origin: anchor.origin.getLatest(), - }, - }); - } else if (nodeIndexStart > 0) { - // The anchor was removed so bias towards the previous node - const prevNode = - originalNodes[nodeIndexStart - 1].getLatest(); - expect(resultRange).toMatchObject({ - anchor: { - offset: prevNode.getTextContentSize(), - origin: prevNode, - }, - }); - } else if (focus.offset !== texts[nodeIndexEnd].length) { - // The focus was not deleted and there is no previous node - // so the new anchor will be set to the focus origin - expect(resultRange).toMatchObject({ - anchor: { - offset: 0, - origin: originalNodes[nodeIndexEnd].getLatest(), - }, - }); - } else if (nodeIndexEnd !== texts.length - 1) { - // The anchor was at the start and the focus was removed - // but there is another text node to use as the anchor caret - expect(resultRange).toMatchObject({ - anchor: { - offset: 0, - origin: originalNodes[nodeIndexEnd + 1].getLatest(), - }, - }); - } else { - // All text has been removed so we have to use a depth caret - expect(resultRange).toMatchObject({ - anchor: { - direction, - origin: $getRoot().getFirstChild(), - type: 'depth', - }, - }); - } - } else { - invariant(direction === 'previous', 'exhaustiveness check'); - if (anchor.offset !== texts[nodeIndexEnd].length) { - // Part of the anchor remains - expect(resultRange).toMatchObject({ - anchor: { - offset: 0, - origin: anchor.origin.getLatest(), - }, - }); - } else if (nodeIndexEnd < texts.length - 1) { - // The anchor was removed so bias towards the next node - expect(resultRange).toMatchObject({ - anchor: { - offset: 0, - origin: originalNodes[nodeIndexEnd + 1].getLatest(), - }, - }); - } else if (focus.offset !== 0) { - // The focus was not deleted and there is no next node - // so the new anchor will be set to the focus origin - expect(resultRange).toMatchObject({ - anchor: { - offset: focus.offset, - origin: focus.origin.getLatest(), - }, - }); - } else if (nodeIndexStart > 0) { - // The anchor was at the end and the focus was removed - // but there is another text node to use as the anchor caret - const prevNode = - originalNodes[nodeIndexStart - 1].getLatest(); - expect(resultRange).toMatchObject({ - anchor: { - offset: prevNode.getTextContentSize(), - origin: prevNode, - }, - }); + }); + describe('multiple text node selection', () => { + const OFFSETS = [startOfNode, insideNode, endOfNode]; + const NODE_PAIRS = [ + [0, 1], + [0, 2], + [1, 2], + ] as const; + for (const [ + direction, + [[nodeIndexStart, nodeIndexEnd], [startFn, endFn]], + ] of combinations( + DIRECTIONS, + combinations(NODE_PAIRS, combinations(OFFSETS, OFFSETS)), + )) { + test(`${direction} ${texts[nodeIndexStart]} ${startFn.name} ${texts[nodeIndexEnd]} ${endFn.name}`, async () => { + await testEnv.editor.update(() => { + const originalNodes = $getRoot().getAllTextNodes(); + const startNode = originalNodes[nodeIndexStart]; + const endNode = originalNodes[nodeIndexEnd]; + expect(startNode !== endNode).toBe(true); + invariant($isTextNode(startNode), 'text node'); + invariant($isTextNode(endNode), 'text node'); + expect(startNode.isBefore(endNode)).toBe(true); + const startCaret = $getTextNodeCaret( + startNode, + direction, + startFn(startNode.getTextContentSize()), + ); + const endCaret = $getTextNodeCaret( + endNode, + direction, + endFn(endNode.getTextContentSize()), + ); + const [anchor, focus] = + direction === 'next' + ? [startCaret, endCaret] + : [endCaret, startCaret]; + const range = $getCaretRange(anchor, focus); + // TODO compute the expected internal carets + // expect([...range.internalCarets('root')]).toHaveLength( + // Math.max(0, nodeIndexEnd - nodeIndexStart - 1), + // ); + const slices = range.getTextSlices(); + expect(slices).toHaveLength(2); + expect(slices.map($getTextSliceContent)).toEqual( + direction === 'next' + ? [ + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + ] + : [ + endCaret.origin + .getTextContent() + .slice(0, endCaret.offset), + startCaret.origin + .getTextContent() + .slice(startCaret.offset), + ], + ); + const originalAnchorParent = anchor.getParentAtCaret(); + const originalFocusParent = focus.getParentAtCaret(); + const resultRange = $removeTextFromCaretRange(range); + if (direction === 'next') { + if (anchor.offset !== 0) { + // Part of the anchor remains + expect(resultRange).toMatchObject({ + anchor: { + offset: anchor.offset, + origin: anchor.origin.getLatest(), + }, + }); + } else if (focus.offset !== texts[nodeIndexEnd].length) { + // The focus was not deleted and there is no previous node + // so the new anchor will be set to the focus origin + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd].getLatest(), + }, + }); + } else { + // The anchor and focus were removed + // so we have an empty paragraph at the anchor + expect(resultRange).toMatchObject({ + anchor: { + direction, + origin: originalAnchorParent.getLatest(), + type: 'depth', + }, + }); + } } else { - // All text has been removed so we have to use a depth caret - expect(resultRange).toMatchObject({ - anchor: { - direction, - origin: $getRoot().getFirstChild(), - type: 'depth', - }, - }); - } - } - const remainingNodes = $getRoot().getAllTextNodes(); - let newIndex = 0; - for ( - let originalIndex = 0; - originalIndex < originalNodes.length; - originalIndex++ - ) { - const originalText = texts[originalIndex]; - const originalNode = originalNodes[originalIndex]; - let deleted: boolean; - if (originalIndex === nodeIndexStart) { - deleted = startCaret.offset === 0; - if (!deleted) { - expect(originalNode.getTextContent()).toBe( - originalText.slice(0, startCaret.offset), - ); + invariant(direction === 'previous', 'exhaustiveness check'); + if (anchor.offset !== texts[nodeIndexEnd].length) { + // Part of the anchor remains + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: anchor.origin.getLatest(), + }, + }); + } else if (focus.offset !== 0) { + // The focus was not removed + // so the new anchor will be set to the focus origin + expect(resultRange).toMatchObject({ + anchor: { + offset: focus.offset, + origin: focus.origin.getLatest(), + }, + }); + } else { + // All text has been removed so we have to use a depth caret + // at the anchor paragraph + expect(resultRange).toMatchObject({ + anchor: { + direction, + origin: originalAnchorParent.getLatest(), + type: 'depth', + }, + }); } - } else if ( - originalIndex > nodeIndexStart && - originalIndex < nodeIndexEnd + } + // Check that the containing block is always that of the anchor + expect(resultRange.anchor.getParentAtCaret().getLatest()).toBe( + originalAnchorParent.getLatest(), + ); + // Check that the focus parent has always been removed + expect(originalFocusParent.isAttached()).toBe(false); + // Check that the focus has been removed or moved to the anchor parent + expect( + !focus.origin.isAttached() || + originalAnchorParent.is(focus.origin.getParent()), + ).toBe(true); + const remainingNodes = $getRoot().getAllTextNodes(); + let newIndex = 0; + for ( + let originalIndex = 0; + originalIndex < originalNodes.length; + originalIndex++ ) { - deleted = true; - } else if (originalIndex === nodeIndexEnd) { - deleted = endCaret.offset === originalText.length; + const originalText = texts[originalIndex]; + const originalNode = originalNodes[originalIndex]; + let deleted: boolean; + if (originalIndex === nodeIndexStart) { + deleted = startCaret.offset === 0; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(0, startCaret.offset), + ); + } + } else if ( + originalIndex > nodeIndexStart && + originalIndex < nodeIndexEnd + ) { + deleted = true; + } else if (originalIndex === nodeIndexEnd) { + deleted = endCaret.offset === originalText.length; + if (!deleted) { + expect(originalNode.getTextContent()).toBe( + originalText.slice(endCaret.offset), + ); + } + } else { + deleted = false; + expect(originalNode.getTextContent()).toBe(originalText); + } + expect(originalNode.isAttached()).toBe(!deleted); if (!deleted) { - expect(originalNode.getTextContent()).toBe( - originalText.slice(endCaret.offset), + expect(originalNode.is(remainingNodes[newIndex])).toBe( + true, ); } - } else { - deleted = false; - expect(originalNode.getTextContent()).toBe(originalText); + newIndex += deleted ? 0 : 1; } - expect(originalNode.isAttached()).toBe(!deleted); - if (!deleted) { - expect(originalNode.is(remainingNodes[newIndex])).toBe(true); - } - newIndex += deleted ? 0 : 1; - } - expect(remainingNodes).toHaveLength(newIndex); + expect(remainingNodes).toHaveLength(newIndex); + }); }); - }); - } + } + }); }); }); }); From 04f750e8981e9de3dfb62ad0c3c9019cf5431b83 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 27 Jan 2025 09:38:49 -0800 Subject: [PATCH 21/69] reorganize into two modules --- .../lexical/src/{ => caret}/LexicalCaret.ts | 277 +------------- .../lexical/src/caret/LexicalCaretUtils.ts | 355 ++++++++++++++++++ packages/lexical/src/index.ts | 18 +- 3 files changed, 370 insertions(+), 280 deletions(-) rename packages/lexical/src/{ => caret}/LexicalCaret.ts (75%) create mode 100644 packages/lexical/src/caret/LexicalCaretUtils.ts diff --git a/packages/lexical/src/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts similarity index 75% rename from packages/lexical/src/LexicalCaret.ts rename to packages/lexical/src/caret/LexicalCaret.ts index 05810884ee4..d1b756207b9 100644 --- a/packages/lexical/src/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -5,20 +5,14 @@ * LICENSE file in the root directory of this source tree. * */ -import type {LexicalNode, NodeKey} from './LexicalNode'; -import type {PointType, RangeSelection} from './LexicalSelection'; +import type {LexicalNode, NodeKey} from '../LexicalNode'; import invariant from 'shared/invariant'; -import { - $getAncestor, - $getNodeByKeyOrThrow, - $isRootOrShadowRoot, - INTERNAL_$isBlock, -} from './LexicalUtils'; -import {$isElementNode, type ElementNode} from './nodes/LexicalElementNode'; -import {$isRootNode} from './nodes/LexicalRootNode'; -import {$createTextNode, $isTextNode, TextNode} from './nodes/LexicalTextNode'; +import {$isRootOrShadowRoot} from '../LexicalUtils'; +import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; +import {$isRootNode} from '../nodes/LexicalRootNode'; +import {$isTextNode, TextNode} from '../nodes/LexicalTextNode'; export type CaretDirection = 'next' | 'previous'; export type FlipDirection = typeof FLIP_DIRECTION[D]; @@ -474,40 +468,6 @@ abstract class AbstractBreadthNodeCaret< } } -function $getTextSliceIndices( - slice: TextNodeCaretSlice, -): [indexStart: number, indexEnd: number] { - const { - size, - caret: {offset}, - } = slice; - return [offset, offset + size].sort() as [number, number]; -} - -export function $removeTextSlice( - slice: TextNodeCaretSlice, -): TextNodeCaret { - const { - caret: {origin, direction}, - } = slice; - const [indexStart, indexEnd] = $getTextSliceIndices(slice); - const text = origin.getTextContent(); - return $getTextNodeCaret( - origin.setTextContent(text.slice(0, indexStart) + text.slice(indexEnd)), - direction, - indexStart, - ); -} - -export function $getTextSliceContent< - T extends TextNode, - D extends CaretDirection, ->(slice: TextNodeCaretSlice): string { - return slice.caret.origin - .getTextContent() - .slice(...$getTextSliceIndices(slice)); -} - export function $isTextNodeCaret( caret: null | undefined | RangeNodeCaret, ): caret is TextNodeCaret { @@ -688,31 +648,6 @@ export function $getAdjacentDepthCaret( return caret && $getChildCaretOrSelf(caret.getAdjacentCaret()); } -/** - * Get a 'next' caret for the child at the given index, or the last - * caret in that node if out of bounds - * - * @param parent An ElementNode - * @param index The index of the origin for the caret - * @returns A next caret with the arrow at that index - */ -export function $getChildCaretAtIndex( - parent: ElementNode, - index: number, - direction: D, -): NodeCaret { - let caret: NodeCaret<'next'> = $getDepthCaret(parent, 'next'); - for (let i = 0; i < index; i++) { - const nextCaret: null | BreadthNodeCaret = - caret.getAdjacentCaret(); - if (nextCaret === null) { - break; - } - caret = nextCaret; - } - return (direction === 'next' ? caret : caret.getFlipped()) as NodeCaret; -} - class NodeCaretRangeImpl implements NodeCaretRange { @@ -804,81 +739,6 @@ function $getSliceFromTextNodeCaret< return {caret, size: offsetB - caret.offset}; } -/** - * @param point - * @returns a RangeNodeCaret for the point - */ -export function $caretFromPoint( - point: PointType, - direction: D, -): RangeNodeCaret { - const {type, key, offset} = point; - const node = $getNodeByKeyOrThrow(point.key); - if (type === 'text') { - invariant( - $isTextNode(node), - '$caretFromPoint: Node with type %s and key %s that does not inherit from TextNode encountered for text point', - node.getType(), - key, - ); - return $getTextNodeCaret(node, direction, offset); - } - invariant( - $isElementNode(node), - '$caretFromPoint: Node with type %s and key %s that does not inherit from ElementNode encountered for element point', - node.getType(), - key, - ); - return $getChildCaretAtIndex(node, point.offset, direction); -} - -export function $setPointFromCaret( - point: PointType, - caret: RangeNodeCaret, -): void { - if ($isTextNodeCaret(caret)) { - point.set(caret.origin.getKey(), caret.offset, 'text'); - } - const {origin, direction} = caret; - const isNext = direction === 'next'; - if ($isDepthNodeCaret(caret)) { - point.set( - origin.getKey(), - isNext ? 0 : caret.origin.getChildrenSize(), - 'element', - ); - } else if ($isTextNode(origin)) { - point.set( - origin.getKey(), - isNext ? origin.getTextContentSize() : 0, - 'text', - ); - } else { - point.set( - origin.getParentOrThrow().getKey(), - origin.getIndexWithinParent() + (isNext ? 1 : 0), - 'element', - ); - } -} - -/** - * Get a pair of carets for a RangeSelection. - * - * If the focus is before the anchor, then the direction will be - * 'previous', otherwise the direction will be 'next'. - */ -export function $caretRangeFromSelection( - selection: RangeSelection, -): NodeCaretRange { - const {anchor, focus} = selection; - const direction = focus.isBefore(anchor) ? 'previous' : 'next'; - return $getCaretRange( - $caretFromPoint(anchor, direction), - $caretFromPoint(focus, direction), - ); -} - export function $getCaretRange( anchor: RangeNodeCaret, focus: RangeNodeCaret, @@ -886,133 +746,6 @@ export function $getCaretRange( return new NodeCaretRangeImpl(anchor, focus, anchor.direction); } -export function $rewindBreadthCaret< - T extends LexicalNode, - D extends CaretDirection, ->(caret: BreadthNodeCaret): NodeCaret { - const {direction, origin} = caret; - // Rotate the direction around the origin and get the adjacent node - const rewindOrigin = $getBreadthCaret( - origin, - flipDirection(direction), - ).getNodeAtCaret(); - return rewindOrigin - ? $getBreadthCaret(rewindOrigin, direction) - : $getDepthCaret(origin.getParentOrThrow(), direction); -} - -export function $removeTextFromCaretRange( - range: NodeCaretRange, -): NodeCaretRange { - if (range.isCollapsed()) { - return range; - } - let anchor = range.anchor; - const {direction} = range; - - // Remove all internal nodes - const canRemove = new Set(); - for (const caret of range.internalCarets('root')) { - if ($isDepthNodeCaret(caret)) { - canRemove.add(caret.origin.getKey()); - } else if ($isBreadthNodeCaret(caret)) { - const {origin} = caret; - if (!$isElementNode(origin) || canRemove.has(origin.getKey())) { - origin.remove(); - } - } - } - // Merge blocks if necessary - const firstBlock = $getAncestor(range.anchor.origin, INTERNAL_$isBlock); - const lastBlock = $getAncestor(range.focus.origin, INTERNAL_$isBlock); - if ( - $isElementNode(lastBlock) && - canRemove.has(lastBlock.getKey()) && - $isElementNode(firstBlock) - ) { - $getDepthCaret(firstBlock, flipDirection(direction)).splice( - 0, - lastBlock.getChildren(), - ); - lastBlock.remove(); - } - // Splice text at the anchor and/or origin. If the text is entirely selected or a token then it is removed. - // Segmented nodes will be copied to a plain text node with the same format and style and set to normal mode. - for (const slice of range.getNonEmptyTextSlices()) { - const {origin} = slice.caret; - const isAnchor = anchor.is(slice.caret); - const contentSize = origin.getTextContentSize(); - const caretBefore = $rewindBreadthCaret( - $getBreadthCaret(origin, direction), - ); - const mode = origin.getMode(); - if (Math.abs(slice.size) === contentSize || mode === 'token') { - caretBefore.remove(); - if (isAnchor) { - anchor = caretBefore; - } - } else { - const nextCaret = $removeTextSlice(slice); - if (isAnchor) { - anchor = nextCaret; - } - if (mode === 'segmented') { - const src = nextCaret.origin; - const plainTextNode = $createTextNode(src.getTextContent()) - .setStyle(src.getStyle()) - .setFormat(src.getFormat()); - caretBefore.replaceOrInsert(plainTextNode); - if (isAnchor) { - anchor = $getTextNodeCaret( - plainTextNode, - nextCaret.direction, - nextCaret.offset, - ); - } - } - } - } - anchor = $normalizeCaret(anchor); - return $getCaretRange(anchor, anchor); -} - -function $getDeepestChildOrSelf( - initialCaret: Caret, -): RangeNodeCaret['direction']> | (Caret & null) { - let caret = $getChildCaretOrSelf(initialCaret); - while ($isDepthNodeCaret(caret)) { - const childNode = caret.getNodeAtCaret(); - if (!$isElementNode(childNode)) { - break; - } - caret = $getDepthCaret(childNode, caret.direction); - } - return (caret && caret.getChildCaret()) || caret; -} - -export function $normalizeCaret( - initialCaret: RangeNodeCaret, -): RangeNodeCaret { - const latestInitialCaret = initialCaret.getLatest(); - if ($isTextNodeCaret(latestInitialCaret)) { - return latestInitialCaret; - } - const {direction} = latestInitialCaret; - const caret = $getDeepestChildOrSelf(latestInitialCaret); - if ($isTextNode(caret.origin)) { - return $getTextNodeCaret(caret.origin, direction, direction); - } - const adjacent = $getDeepestChildOrSelf(caret.getAdjacentCaret()); - if ($isBreadthNodeCaret(adjacent) && $isTextNode(adjacent.origin)) { - return $getTextNodeCaret( - adjacent.origin, - direction, - flipDirection(direction), - ); - } - return caret; -} - export function makeStepwiseIterator({ initial, stop, diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts new file mode 100644 index 00000000000..5212b5e98dd --- /dev/null +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -0,0 +1,355 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {LexicalNode, NodeKey} from '../LexicalNode'; +import type {PointType, RangeSelection} from '../LexicalSelection'; +import type { + BreadthNodeCaret, + CaretDirection, + NodeCaret, + NodeCaretRange, + RangeNodeCaret, + TextNodeCaret, + TextNodeCaretSlice, +} from './LexicalCaret'; + +import invariant from 'shared/invariant'; + +import { + $getAncestor, + $getNodeByKeyOrThrow, + INTERNAL_$isBlock, +} from '../LexicalUtils'; +import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; +import { + $createTextNode, + $isTextNode, + type TextNode, +} from '../nodes/LexicalTextNode'; +import { + $getBreadthCaret, + $getCaretRange, + $getChildCaretOrSelf, + $getDepthCaret, + $getTextNodeCaret, + $isBreadthNodeCaret, + $isDepthNodeCaret, + $isTextNodeCaret, + flipDirection, +} from './LexicalCaret'; + +/** + * @param point + * @returns a RangeNodeCaret for the point + */ +export function $caretFromPoint( + point: PointType, + direction: D, +): RangeNodeCaret { + const {type, key, offset} = point; + const node = $getNodeByKeyOrThrow(point.key); + if (type === 'text') { + invariant( + $isTextNode(node), + '$caretFromPoint: Node with type %s and key %s that does not inherit from TextNode encountered for text point', + node.getType(), + key, + ); + return $getTextNodeCaret(node, direction, offset); + } + invariant( + $isElementNode(node), + '$caretFromPoint: Node with type %s and key %s that does not inherit from ElementNode encountered for element point', + node.getType(), + key, + ); + return $getChildCaretAtIndex(node, point.offset, direction); +} + +/** + * Update the given point in-place from the RangeNodeCaret + * + * @param point the point to set + * @param caret the caret to set the point from + */ +export function $setPointFromCaret( + point: PointType, + caret: RangeNodeCaret, +): void { + if ($isTextNodeCaret(caret)) { + point.set(caret.origin.getKey(), caret.offset, 'text'); + } + const {origin, direction} = caret; + const isNext = direction === 'next'; + if ($isDepthNodeCaret(caret)) { + point.set( + origin.getKey(), + isNext ? 0 : caret.origin.getChildrenSize(), + 'element', + ); + } else if ($isTextNode(origin)) { + point.set( + origin.getKey(), + isNext ? origin.getTextContentSize() : 0, + 'text', + ); + } else { + point.set( + origin.getParentOrThrow().getKey(), + origin.getIndexWithinParent() + (isNext ? 1 : 0), + 'element', + ); + } +} + +/** + * Get a pair of carets for a RangeSelection. + * + * If the focus is before the anchor, then the direction will be + * 'previous', otherwise the direction will be 'next'. + */ +export function $caretRangeFromSelection( + selection: RangeSelection, +): NodeCaretRange { + const {anchor, focus} = selection; + const direction = focus.isBefore(anchor) ? 'previous' : 'next'; + return $getCaretRange( + $caretFromPoint(anchor, direction), + $caretFromPoint(focus, direction), + ); +} + +/** + * Given a BreadthNodeCaret we can always compute a caret that points to the + * origin of that caret in the same direction. The adjacent caret of the + * returned caret will be equivalent to the given caret. + * + * @example + * ```ts + * breadthCaret.is($rewindBreadthCaret(breadthCaret).getAdjacentCaret()) + * ``` + * + * @param caret The caret to "rewind" + * @returns A new caret (DepthNodeCaret or BreadthNodeCaret) with the same direction + */ +export function $rewindBreadthCaret< + T extends LexicalNode, + D extends CaretDirection, +>(caret: BreadthNodeCaret): NodeCaret { + const {direction, origin} = caret; + // Rotate the direction around the origin and get the adjacent node + const rewindOrigin = $getBreadthCaret( + origin, + flipDirection(direction), + ).getNodeAtCaret(); + return rewindOrigin + ? $getBreadthCaret(rewindOrigin, direction) + : $getDepthCaret(origin.getParentOrThrow(), direction); +} + +/** + * Remove all text and nodes in the given range. The block containing the + * focus will be removed and merged with the anchor's block if they are + * not the same. + * + * @param range The range to remove text and nodes from + * @returns The new collapsed range + */ +export function $removeTextFromCaretRange( + range: NodeCaretRange, +): NodeCaretRange { + if (range.isCollapsed()) { + return range; + } + let anchor = range.anchor; + const {direction} = range; + + // Remove all internal nodes + const canRemove = new Set(); + for (const caret of range.internalCarets('root')) { + if ($isDepthNodeCaret(caret)) { + canRemove.add(caret.origin.getKey()); + } else if ($isBreadthNodeCaret(caret)) { + const {origin} = caret; + if (!$isElementNode(origin) || canRemove.has(origin.getKey())) { + origin.remove(); + } + } + } + // Merge blocks if necessary + const firstBlock = $getAncestor(range.anchor.origin, INTERNAL_$isBlock); + const lastBlock = $getAncestor(range.focus.origin, INTERNAL_$isBlock); + if ( + $isElementNode(lastBlock) && + canRemove.has(lastBlock.getKey()) && + $isElementNode(firstBlock) + ) { + $getDepthCaret(firstBlock, flipDirection(direction)).splice( + 0, + lastBlock.getChildren(), + ); + lastBlock.remove(); + } + // Splice text at the anchor and/or origin. If the text is entirely selected or a token then it is removed. + // Segmented nodes will be copied to a plain text node with the same format and style and set to normal mode. + for (const slice of range.getNonEmptyTextSlices()) { + const {origin} = slice.caret; + const isAnchor = anchor.is(slice.caret); + const contentSize = origin.getTextContentSize(); + const caretBefore = $rewindBreadthCaret( + $getBreadthCaret(origin, direction), + ); + const mode = origin.getMode(); + if (Math.abs(slice.size) === contentSize || mode === 'token') { + caretBefore.remove(); + if (isAnchor) { + anchor = caretBefore; + } + } else { + const nextCaret = $removeTextSlice(slice); + if (isAnchor) { + anchor = nextCaret; + } + if (mode === 'segmented') { + const src = nextCaret.origin; + const plainTextNode = $createTextNode(src.getTextContent()) + .setStyle(src.getStyle()) + .setFormat(src.getFormat()); + caretBefore.replaceOrInsert(plainTextNode); + if (isAnchor) { + anchor = $getTextNodeCaret( + plainTextNode, + nextCaret.direction, + nextCaret.offset, + ); + } + } + } + } + anchor = $normalizeCaret(anchor); + return $getCaretRange(anchor, anchor); +} + +/** + * Return the deepest DepthNodeCaret that has initialCaret's origin + * as an ancestor, or initialCaret if the origin is not an ElementNode + * or is already the deepest DepthNodeCaret. + * + * This is generally used when normalizing because there is + * "zero distance" between these locations. + * + * @param initialCaret + * @returns Either a deeper DepthNodeCaret or the given initialCaret + */ +function $getDeepestChildOrSelf( + initialCaret: Caret, +): RangeNodeCaret['direction']> | (Caret & null) { + let caret = $getChildCaretOrSelf(initialCaret); + while ($isDepthNodeCaret(caret)) { + const childNode = caret.getNodeAtCaret(); + if (!$isElementNode(childNode)) { + break; + } + caret = $getDepthCaret(childNode, caret.direction); + } + return (caret && caret.getChildCaret()) || caret; +} + +/** + * Normalize a caret to the deepest equivalent RangeNodeCaret. + * This will return a TextNodeCaret with the offset set according + * to the direction if given a caret with a TextNode origin + * or a caret with an ElementNode origin with the deepest DepthNode + * having an adjacent TextNode. + * + * If given a TextNodeCaret, it will be returned, as no normalization + * is required when an offset is already present. + * + * @param initialCaret + * @returns The normalized RangeNodeCaret + */ +export function $normalizeCaret( + initialCaret: RangeNodeCaret, +): RangeNodeCaret { + const latestInitialCaret = initialCaret.getLatest(); + if ($isTextNodeCaret(latestInitialCaret)) { + return latestInitialCaret; + } + const {direction} = latestInitialCaret; + const caret = $getDeepestChildOrSelf(latestInitialCaret); + if ($isTextNode(caret.origin)) { + return $getTextNodeCaret(caret.origin, direction, direction); + } + const adjacent = $getDeepestChildOrSelf(caret.getAdjacentCaret()); + if ($isBreadthNodeCaret(adjacent) && $isTextNode(adjacent.origin)) { + return $getTextNodeCaret( + adjacent.origin, + direction, + flipDirection(direction), + ); + } + return caret; +} + +function $getTextSliceIndices( + slice: TextNodeCaretSlice, +): [indexStart: number, indexEnd: number] { + const { + size, + caret: {offset}, + } = slice; + return [offset, offset + size].sort() as [number, number]; +} + +export function $removeTextSlice( + slice: TextNodeCaretSlice, +): TextNodeCaret { + const { + caret: {origin, direction}, + } = slice; + const [indexStart, indexEnd] = $getTextSliceIndices(slice); + const text = origin.getTextContent(); + return $getTextNodeCaret( + origin.setTextContent(text.slice(0, indexStart) + text.slice(indexEnd)), + direction, + indexStart, + ); +} + +export function $getTextSliceContent< + T extends TextNode, + D extends CaretDirection, +>(slice: TextNodeCaretSlice): string { + return slice.caret.origin + .getTextContent() + .slice(...$getTextSliceIndices(slice)); +} + +/** + * Get a 'next' caret for the child at the given index, or the last + * caret in that node if out of bounds + * + * @param parent An ElementNode + * @param index The index of the origin for the caret + * @returns A next caret with the arrow at that index + */ +export function $getChildCaretAtIndex( + parent: ElementNode, + index: number, + direction: D, +): NodeCaret { + let caret: NodeCaret<'next'> = $getDepthCaret(parent, 'next'); + for (let i = 0; i < index; i++) { + const nextCaret: null | BreadthNodeCaret = + caret.getAdjacentCaret(); + if (nextCaret === null) { + break; + } + caret = nextCaret; + } + return (direction === 'next' ? caret : caret.getFlipped()) as NodeCaret; +} diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index fe451abde84..e307977d2ad 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -21,30 +21,32 @@ export type { TextNodeCaret, TextNodeCaretSlice, TextNodeCaretSliceTuple, -} from './LexicalCaret'; +} from './caret/LexicalCaret'; export { - $caretFromPoint, - $caretRangeFromSelection, $getAdjacentDepthCaret, $getBreadthCaret, $getCaretRange, - $getChildCaretAtIndex, $getChildCaretOrSelf, $getDepthCaret, $getTextNodeCaret, $getTextNodeCaretSlice, - $getTextSliceContent, $isBreadthNodeCaret, $isDepthNodeCaret, $isSameTextNodeCaret, $isTextNodeCaret, + flipDirection, + makeStepwiseIterator, +} from './caret/LexicalCaret'; +export { + $caretFromPoint, + $caretRangeFromSelection, + $getChildCaretAtIndex, + $getTextSliceContent, $removeTextFromCaretRange, $removeTextSlice, $rewindBreadthCaret, $setPointFromCaret, - flipDirection, - makeStepwiseIterator, -} from './LexicalCaret'; +} from './caret/LexicalCaretUtils'; export type {PasteCommandType} from './LexicalCommands'; export { BLUR_COMMAND, From 34ef1bc505370df6fb401125a7407f1cfcffbbaf Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 27 Jan 2025 13:23:34 -0800 Subject: [PATCH 22/69] refactor tests and fix $setPointFromCaret --- .../lexical/src/caret/LexicalCaretUtils.ts | 53 +++++++++++-------- .../__tests__/unit/LexicalCaret.test.ts | 45 +++++++++++++++- 2 files changed, 74 insertions(+), 24 deletions(-) rename packages/lexical/src/{ => caret}/__tests__/unit/LexicalCaret.test.ts (96%) diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 5212b5e98dd..893d297d231 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -82,27 +82,28 @@ export function $setPointFromCaret( ): void { if ($isTextNodeCaret(caret)) { point.set(caret.origin.getKey(), caret.offset, 'text'); - } - const {origin, direction} = caret; - const isNext = direction === 'next'; - if ($isDepthNodeCaret(caret)) { - point.set( - origin.getKey(), - isNext ? 0 : caret.origin.getChildrenSize(), - 'element', - ); - } else if ($isTextNode(origin)) { - point.set( - origin.getKey(), - isNext ? origin.getTextContentSize() : 0, - 'text', - ); } else { - point.set( - origin.getParentOrThrow().getKey(), - origin.getIndexWithinParent() + (isNext ? 1 : 0), - 'element', - ); + const {origin, direction} = caret; + const isNext = direction === 'next'; + if ($isDepthNodeCaret(caret)) { + point.set( + origin.getKey(), + isNext ? 0 : caret.origin.getChildrenSize(), + 'element', + ); + } else if ($isTextNode(origin)) { + point.set( + origin.getKey(), + isNext ? origin.getTextContentSize() : 0, + 'text', + ); + } else { + point.set( + origin.getParentOrThrow().getKey(), + origin.getIndexWithinParent() + (isNext ? 1 : 0), + 'element', + ); + } } } @@ -194,8 +195,11 @@ export function $removeTextFromCaretRange( ); lastBlock.remove(); } - // Splice text at the anchor and/or origin. If the text is entirely selected or a token then it is removed. - // Segmented nodes will be copied to a plain text node with the same format and style and set to normal mode. + // Splice text at the anchor and/or origin. + // If the text is entirely selected then it is removed. + // If it's a token with a non-empty selection then it is removed. + // Segmented nodes will be copied to a plain text node with the same format + // and style and set to normal mode. for (const slice of range.getNonEmptyTextSlices()) { const {origin} = slice.caret; const isAnchor = anchor.is(slice.caret); @@ -204,7 +208,10 @@ export function $removeTextFromCaretRange( $getBreadthCaret(origin, direction), ); const mode = origin.getMode(); - if (Math.abs(slice.size) === contentSize || mode === 'token') { + if ( + Math.abs(slice.size) === contentSize || + (mode === 'token' && slice.size !== 0) + ) { caretBefore.remove(); if (isAnchor) { anchor = caretBefore; diff --git a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts similarity index 96% rename from packages/lexical/src/__tests__/unit/LexicalCaret.test.ts rename to packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index a64aaf26978..fb8b26dbcf3 100644 --- a/packages/lexical/src/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -9,6 +9,7 @@ import { $caretRangeFromSelection, $createParagraphNode, + $createRangeSelection, $createTextNode, $getBreadthCaret, $getCaretRange, @@ -29,7 +30,7 @@ import { TextNode, } from 'lexical'; -import {initializeUnitTest, invariant} from '../utils'; +import {initializeUnitTest, invariant} from '../../../__tests__/utils'; const DIRECTIONS = ['next', 'previous'] as const; const BIASES = ['inside', 'outside'] as const; @@ -666,6 +667,48 @@ describe('LexicalCaret', () => { .append($createParagraphNode().append(...textNodes)); }); }); + test('remove first TextNode with second in token mode', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + const originalNodes = $getRoot().getAllTextNodes(); + const [leadingText, trailingTokenText] = originalNodes; + trailingTokenText.setMode('token'); + sel.anchor.set(leadingText.getKey(), 0, 'text'); + sel.focus.set( + leadingText.getKey(), + leadingText.getTextContentSize(), + 'text', + ); + const direction = 'next'; + const range = $caretRangeFromSelection(sel); + expect(range).toMatchObject({ + anchor: {direction, offset: 0, origin: leadingText}, + focus: { + direction, + offset: leadingText.getTextContentSize(), + origin: leadingText, + }, + }); + const resultRange = $removeTextFromCaretRange(range); + $setPointFromCaret(sel.anchor, resultRange.anchor); + $setPointFromCaret(sel.focus, resultRange.focus); + expect(leadingText.isAttached()).toBe(false); + expect(trailingTokenText.isAttached()).toBe(true); + expect($getRoot().getAllTextNodes()).toHaveLength(2); + expect(resultRange.isCollapsed()).toBe(true); + expect(sel.isCollapsed()).toBe(true); + expect(sel.anchor.key).toBe(trailingTokenText.getKey()); + expect(sel.anchor.offset).toBe(0); + expect(resultRange.anchor).toMatchObject({ + direction, + offset: 0, + origin: trailingTokenText.getLatest(), + }); + }, + {discrete: true}, + ); + }); test('collapsed text point selection', async () => { await testEnv.editor.update(() => { const textNodes = $getRoot().getAllTextNodes(); From 7f5950ab493d252a8d1081dae95bcf3f5be61e5f Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 27 Jan 2025 21:33:55 -0800 Subject: [PATCH 23/69] Replace RangeSelection.removeText() --- packages/lexical/src/LexicalSelection.ts | 82 ++---------- .../__tests__/unit/LexicalSelection.test.ts | 2 + .../lexical/src/caret/LexicalCaretUtils.ts | 59 ++++++--- .../caret/__tests__/unit/LexicalCaret.test.ts | 121 +++++++++++++++++- packages/lexical/src/index.ts | 2 + 5 files changed, 171 insertions(+), 95 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index fd9c965c41d..3dc12f66fae 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -15,6 +15,7 @@ import type {TextFormatType} from './nodes/LexicalTextNode'; import invariant from 'shared/invariant'; import { + $caretRangeFromSelection, $createLineBreakNode, $createParagraphNode, $createTextNode, @@ -23,7 +24,9 @@ import { $isLineBreakNode, $isRootNode, $isTextNode, + $removeTextFromCaretRange, $setSelection, + $updateRangeSelectionFromCaretRange, SELECTION_CHANGE_COMMAND, TextNode, } from '.'; @@ -1134,80 +1137,11 @@ export class RangeSelection implements BaseSelection { * Removes the text in the Selection, adjusting the EditorState accordingly. */ removeText(): void { - // const newRange = $removeTextFromCaretRange($caretRangeFromSelection(this)); - // $setPointFromCaret(this.anchor, newRange.anchor); - // $setPointFromCaret(this.focus, newRange.focus); - if (this.isCollapsed()) { - return; - } - const {anchor, focus} = this; - const selectedNodes = this.getNodes(); - const firstPoint = this.isBackward() ? focus : anchor; - const lastPoint = this.isBackward() ? anchor : focus; - let firstNode = firstPoint.getNode(); - let lastNode = lastPoint.getNode(); - const firstBlock = $getAncestor(firstNode, INTERNAL_$isBlock); - const lastBlock = $getAncestor(lastNode, INTERNAL_$isBlock); - // If a token is partially selected then move the selection to cover the whole selection - if ( - $isTextNode(firstNode) && - firstNode.isToken() && - firstPoint.offset < firstNode.getTextContentSize() - ) { - firstPoint.set(firstNode.getKey(), 0, 'text'); - } - if (lastPoint.offset > 0 && $isTextNode(lastNode) && lastNode.isToken()) { - lastPoint.set(lastNode.getKey(), lastNode.getTextContentSize(), 'text'); - } - - for (const node of selectedNodes) { - if ( - !$hasAncestor(firstNode, node) && - !$hasAncestor(lastNode, node) && - node.getKey() !== firstNode.getKey() && - node.getKey() !== lastNode.getKey() - ) { - node.remove(); - } - } - - const fixText = (node: TextNode, del: number) => { - if (node.getTextContent() === '') { - node.remove(); - } else if (del !== 0 && $isTokenOrSegmented(node)) { - const textNode = $createTextNode(node.getTextContent()); - textNode.setFormat(node.getFormat()); - textNode.setStyle(node.getStyle()); - return node.replace(textNode); - } - }; - if (firstNode === lastNode && $isTextNode(firstNode)) { - const del = Math.abs(focus.offset - anchor.offset); - firstNode.spliceText(firstPoint.offset, del, '', true); - fixText(firstNode, del); - return; - } - if ($isTextNode(firstNode)) { - const del = firstNode.getTextContentSize() - firstPoint.offset; - firstNode.spliceText(firstPoint.offset, del, ''); - firstNode = fixText(firstNode, del) || firstNode; - } - if ($isTextNode(lastNode)) { - lastNode.spliceText(0, lastPoint.offset, ''); - lastNode = fixText(lastNode, lastPoint.offset) || lastNode; - } - if (firstNode.isAttached() && $isTextNode(firstNode)) { - firstNode.selectEnd(); - } else if (lastNode.isAttached() && $isTextNode(lastNode)) { - lastNode.selectStart(); - } - - // Merge blocks - const bothElem = $isElementNode(firstBlock) && $isElementNode(lastBlock); - if (bothElem && firstBlock !== lastBlock) { - firstBlock.append(...lastBlock.getChildren()); - lastBlock.remove(); - lastPoint.set(firstPoint.key, firstPoint.offset, firstPoint.type); + const isCurrentSelection = $getSelection() === this; + const newRange = $removeTextFromCaretRange($caretRangeFromSelection(this)); + $updateRangeSelectionFromCaretRange(this, newRange); + if (isCurrentSelection && $getSelection() !== this) { + $setSelection(this); } } diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index f0116549f43..3ea3b7525ab 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -764,7 +764,9 @@ describe('LexicalSelection tests', () => { 'text', ); $setSelection(sel); + expect($getSelection()).toBe(sel); sel.removeText(); + expect($getSelection()).toBe(sel); expect(leadingText.isAttached()).toBe(true); expect(trailingSegmentedText.isAttached()).toBe(false); const allTextNodes = $getRoot().getAllTextNodes(); diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 893d297d231..fa65bfa442e 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -6,7 +6,6 @@ * */ import type {LexicalNode, NodeKey} from '../LexicalNode'; -import type {PointType, RangeSelection} from '../LexicalSelection'; import type { BreadthNodeCaret, CaretDirection, @@ -19,9 +18,17 @@ import type { import invariant from 'shared/invariant'; +import { + $createRangeSelection, + $getSelection, + $isRangeSelection, + type PointType, + type RangeSelection, +} from '../LexicalSelection'; import { $getAncestor, $getNodeByKeyOrThrow, + $setSelection, INTERNAL_$isBlock, } from '../LexicalUtils'; import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; @@ -107,6 +114,34 @@ export function $setPointFromCaret( } } +/** + * Set a RangeSelection on the editor from the given NodeCaretRange + * + * @returns The new RangeSelection + */ +export function $setSelectionFromCaretRange( + caretRange: NodeCaretRange, +): RangeSelection { + const currentSelection = $getSelection(); + const selection = $isRangeSelection(currentSelection) + ? currentSelection + : $createRangeSelection(); + $updateRangeSelectionFromCaretRange(selection, caretRange); + $setSelection(selection); + return selection; +} + +/** + * Update the points of a RangeSelection based on the given RangeNodeCaret. + */ +export function $updateRangeSelectionFromCaretRange( + selection: RangeSelection, + caretRange: NodeCaretRange, +): void { + $setPointFromCaret(selection.anchor, caretRange.anchor); + $setPointFromCaret(selection.focus, caretRange.focus); +} + /** * Get a pair of carets for a RangeSelection. * @@ -282,24 +317,18 @@ function $getDeepestChildOrSelf( export function $normalizeCaret( initialCaret: RangeNodeCaret, ): RangeNodeCaret { - const latestInitialCaret = initialCaret.getLatest(); - if ($isTextNodeCaret(latestInitialCaret)) { - return latestInitialCaret; + const caret = initialCaret.getLatest(); + const {direction} = caret; + if ($isTextNodeCaret(caret)) { + return caret; } - const {direction} = latestInitialCaret; - const caret = $getDeepestChildOrSelf(latestInitialCaret); if ($isTextNode(caret.origin)) { return $getTextNodeCaret(caret.origin, direction, direction); } const adjacent = $getDeepestChildOrSelf(caret.getAdjacentCaret()); - if ($isBreadthNodeCaret(adjacent) && $isTextNode(adjacent.origin)) { - return $getTextNodeCaret( - adjacent.origin, - direction, - flipDirection(direction), - ); - } - return caret; + return $isBreadthNodeCaret(adjacent) && $isTextNode(adjacent.origin) + ? $getTextNodeCaret(adjacent.origin, direction, flipDirection(direction)) + : caret; } function $getTextSliceIndices( @@ -309,7 +338,7 @@ function $getTextSliceIndices( size, caret: {offset}, } = slice; - return [offset, offset + size].sort() as [number, number]; + return [offset, offset + size].sort((a, b) => a - b) as [number, number]; } export function $removeTextSlice( diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index fb8b26dbcf3..0a58e0df075 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -6,6 +6,7 @@ * */ +import {$createLinkNode} from '@lexical/link'; import { $caretRangeFromSelection, $createParagraphNode, @@ -15,6 +16,7 @@ import { $getCaretRange, $getDepthCaret, $getRoot, + $getSelection, $getTextNodeCaret, $getTextSliceContent, $isTextNode, @@ -23,6 +25,7 @@ import { $rewindBreadthCaret, $setPointFromCaret, $setSelection, + $setSelectionFromCaretRange, BreadthNodeCaret, DepthNodeCaret, LexicalNode, @@ -30,7 +33,11 @@ import { TextNode, } from 'lexical'; -import {initializeUnitTest, invariant} from '../../../__tests__/utils'; +import { + $assertRangeSelection, + initializeUnitTest, + invariant, +} from '../../../__tests__/utils'; const DIRECTIONS = ['next', 'previous'] as const; const BIASES = ['inside', 'outside'] as const; @@ -655,9 +662,71 @@ describe('LexicalCaret', () => { }); describe('$removeTextFromCaretRange', () => { const texts = ['first', 'second', 'third'] as const; + describe('ported LexicalSelection tests', () => { + test('remove partial initial TextNode and partial segmented TextNode', () => { + let leadingText: TextNode; + let trailingSegmentedText: TextNode; + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + leadingText = $createTextNode('leading text'); + trailingSegmentedText = + $createTextNode('segmented text').setMode('segmented'); + $getRoot() + .clear() + .append( + $createParagraphNode().append( + leadingText, + trailingSegmentedText, + ), + ); + sel.anchor.set(leadingText.getKey(), 'lead'.length, 'text'); + sel.focus.set( + trailingSegmentedText.getKey(), + 'segmented '.length, + 'text', + ); + $setSelection(sel); + const resultRange = $removeTextFromCaretRange( + $caretRangeFromSelection(sel), + ); + $setSelectionFromCaretRange(resultRange); + expect(resultRange).toMatchObject({ + anchor: { + offset: 'lead'.length, + origin: leadingText.getLatest(), + }, + direction: 'next', + }); + expect(leadingText.isAttached()).toBe(true); + expect(trailingSegmentedText.isAttached()).toBe(false); + const allTextNodes = $getRoot().getAllTextNodes(); + // These should get merged in reconciliation + expect(allTextNodes.map((node) => node.getTextContent())).toEqual( + ['lead', 'text'], + ); + const selection = $assertRangeSelection($getSelection()); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(leadingText.getKey()); + expect(selection.anchor.offset).toBe('lead'.length); + }, + {discrete: true}, + ); + // Reconciliation has happened + testEnv.editor.getEditorState().read(() => { + const allTextNodes = $getRoot().getAllTextNodes(); + // These should get merged in reconciliation + expect(allTextNodes.map((node) => node.getTextContent())).toEqual([ + 'leadtext', + ]); + expect(leadingText.isAttached()).toBe(true); + expect(trailingSegmentedText.isAttached()).toBe(false); + }); + }); + }); describe('single block', () => { - beforeEach(async () => { - await testEnv.editor.update(() => { + beforeEach(() => { + testEnv.editor.update(() => { // Ensure that the separate texts don't get merged const textNodes = texts.map((text) => $createTextNode(text).setStyle(`color: --color-${text}`), @@ -682,6 +751,7 @@ describe('LexicalCaret', () => { ); const direction = 'next'; const range = $caretRangeFromSelection(sel); + $setSelection(sel); expect(range).toMatchObject({ anchor: {direction, offset: 0, origin: leadingText}, focus: { @@ -691,8 +761,7 @@ describe('LexicalCaret', () => { }, }); const resultRange = $removeTextFromCaretRange(range); - $setPointFromCaret(sel.anchor, resultRange.anchor); - $setPointFromCaret(sel.focus, resultRange.focus); + $setSelectionFromCaretRange(resultRange); expect(leadingText.isAttached()).toBe(false); expect(trailingTokenText.isAttached()).toBe(true); expect($getRoot().getAllTextNodes()).toHaveLength(2); @@ -1042,7 +1111,7 @@ describe('LexicalCaret', () => { const [offsetStart, offsetEnd] = [ anchor.offset, focus.offset, - ].sort(); + ].sort((a, b) => a - b); const range = $getCaretRange(anchor, focus); const slices = range.getNonEmptyTextSlices(); expect([...range.internalCarets('root')]).toEqual([]); @@ -1482,3 +1551,43 @@ describe('LexicalCaret', () => { }); }); }); + +describe('LexicalSelectionHelpers', () => { + initializeUnitTest((testEnv) => { + describe('with a fully-selected text node preceded by an inline element', () => { + test('a single text node', async () => { + await testEnv.editor.update(() => { + const root = $getRoot(); + + const paragraph = $createParagraphNode(); + root.append(paragraph); + + const link = $createLinkNode('https://'); + link.append($createTextNode('link')); + paragraph.append(link); + + const text = $createTextNode('Existing text...'); + paragraph.append(text); + + const range = $getCaretRange( + $getTextNodeCaret(text, 'next', 0), + $getTextNodeCaret(text, 'next', 'next'), + ); + const newRange = $removeTextFromCaretRange(range); + expect(newRange).toMatchObject({ + anchor: { + direction: 'next', + origin: link.getLatest(), + type: 'breadth', + }, + }); + newRange.focus.insert($createTextNode('foo')); + }); + + expect(testEnv.innerHTML).toBe( + '', + ); + }); + }); + }); +}); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index e307977d2ad..c0fcbbd7e0b 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -46,6 +46,8 @@ export { $removeTextSlice, $rewindBreadthCaret, $setPointFromCaret, + $setSelectionFromCaretRange, + $updateRangeSelectionFromCaretRange, } from './caret/LexicalCaretUtils'; export type {PasteCommandType} from './LexicalCommands'; export { From 20dae4542cc514ed85f3a21a7999ca894aad9273 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 28 Jan 2025 12:22:57 -0800 Subject: [PATCH 24/69] WIP selection fixes --- .../__tests__/unit/LexicalSelection.test.tsx | 3 +- .../unit/LexicalSelectionHelpers.test.ts | 6 +- .../lexical-selection/src/lexical-node.ts | 168 ++++++------------ packages/lexical-utils/src/index.ts | 13 +- packages/lexical/src/LexicalSelection.ts | 90 +++++++--- .../__tests__/unit/LexicalSelection.test.ts | 146 ++++++++++++++- packages/lexical/src/nodes/LexicalTextNode.ts | 90 +++++++--- 7 files changed, 333 insertions(+), 183 deletions(-) diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx index 121d13fe3ec..7e67e54e04b 100644 --- a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx +++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx @@ -1290,7 +1290,8 @@ describe('LexicalSelection tests', () => { paragraph.append(elementNode); elementNode.append(text); - const selectedNodes = $getSelection()!.getNodes(); + const selection = $getSelection()!; + const selectedNodes = selection.getNodes(); expect(selectedNodes.length).toBe(1); expect(selectedNodes[0].getKey()).toBe(text.getKey()); diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts index 01390ed7180..1f5c2f16568 100644 --- a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts +++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts @@ -3155,11 +3155,11 @@ describe('$patchStyleText', () => { type: 'text', }); - const selection = $getSelection(); + const selection = $getSelection()!; - $patchStyleText(selection!, {'font-size': '11px'}); + $patchStyleText(selection, {'font-size': '11px'}); - const [newAnchor, newFocus] = selection!.getStartEndPoints()!; + const [newAnchor, newFocus] = selection.getStartEndPoints()!; const newAnchorNode: LexicalNode = newAnchor.getNode(); expect(newAnchorNode.getTextContent()).toBe('sec'); diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts index 22074d1fd6e..5aa93ea2752 100644 --- a/packages/lexical-selection/src/lexical-node.ts +++ b/packages/lexical-selection/src/lexical-node.ts @@ -19,6 +19,7 @@ import { BaseSelection, LexicalEditor, LexicalNode, + NodeKey, Point, RangeSelection, TextNode, @@ -302,134 +303,67 @@ export function $forEachSelectedTextNode( fn: (textNode: TextNode) => void, ): void { const selection = $getSelection(); - if (!$isRangeSelection(selection)) { + if (!selection) { return; } - const selectedNodes = selection.getNodes(); - const selectedNodesLength = selectedNodes.length; - const {anchor, focus} = selection; - - const lastIndex = selectedNodesLength - 1; - let firstNode = selectedNodes[0]; - let lastNode = selectedNodes[lastIndex]; - const firstNodeText = firstNode.getTextContent(); - const firstNodeTextLength = firstNodeText.length; - const focusOffset = focus.offset; - let anchorOffset = anchor.offset; - const isBefore = anchor.isBefore(focus); - let startOffset = isBefore ? anchorOffset : focusOffset; - let endOffset = isBefore ? focusOffset : anchorOffset; - const startType = isBefore ? anchor.type : focus.type; - const endType = isBefore ? focus.type : anchor.type; - const endKey = isBefore ? focus.key : anchor.key; + const slicedTextNodes = new Map< + NodeKey, + [startIndex: number, endIndex: number] + >(); + const getSliceIndices = ( + node: TextNode, + ): [startIndex: number, endIndex: number] => + slicedTextNodes.get(node.getKey()) || [0, node.getTextContentSize()]; - // This is the case where the user only selected the very end of the - // first node so we don't want to include it in the formatting change. - if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) { - const nextSibling = firstNode.getNextSibling(); + if ($isRangeSelection(selection)) { + const {anchor, focus} = selection; + const isBackwards = focus.isBefore(anchor); + const [startPoint, endPoint] = isBackwards + ? [focus, anchor] + : [anchor, focus]; - if ($isTextNode(nextSibling)) { - // we basically make the second node the firstNode, changing offsets accordingly - anchorOffset = 0; - startOffset = 0; - firstNode = nextSibling; + if (startPoint.type === 'text' && startPoint.offset > 0) { + const endIndex = getSliceIndices(startPoint.getNode())[1]; + slicedTextNodes.set(startPoint.key, [ + Math.min(startPoint.offset, endIndex), + endIndex, + ]); } - } - - // This is the case where we only selected a single node - if (selectedNodes.length === 1) { - if ($isTextNode(firstNode) && firstNode.canHaveFormat()) { - startOffset = - startType === 'element' - ? 0 - : anchorOffset > focusOffset - ? focusOffset - : anchorOffset; - endOffset = - endType === 'element' - ? firstNodeTextLength - : anchorOffset > focusOffset - ? anchorOffset - : focusOffset; - - // No actual text is selected, so do nothing. - if (startOffset === endOffset) { - return; - } - - // The entire node is selected or a token/segment, so just format it - if ( - $isTokenOrSegmented(firstNode) || - (startOffset === 0 && endOffset === firstNodeTextLength) - ) { - fn(firstNode); - firstNode.select(startOffset, endOffset); - } else { - // The node is partially selected, so split it into two nodes - // and style the selected one. - const splitNodes = firstNode.splitText(startOffset, endOffset); - const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1]; - fn(replacement); - replacement.select(0, endOffset - startOffset); - } - } // multiple nodes selected. - } else { - if ( - $isTextNode(firstNode) && - startOffset < firstNode.getTextContentSize() && - firstNode.canHaveFormat() - ) { - if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) { - // the entire first node isn't selected and it isn't a token or segmented, so split it - firstNode = firstNode.splitText(startOffset)[1]; - startOffset = 0; - if (isBefore) { - anchor.set(firstNode.getKey(), startOffset, 'text'); - } else { - focus.set(firstNode.getKey(), startOffset, 'text'); - } + if (endPoint.type === 'text') { + const [startIndex, size] = getSliceIndices(endPoint.getNode()); + if (endPoint.offset < size) { + slicedTextNodes.set(endPoint.key, [ + startIndex, + Math.max(startIndex, endPoint.offset), + ]); } - - fn(firstNode as TextNode); } + } - if ($isTextNode(lastNode) && lastNode.canHaveFormat()) { - const lastNodeText = lastNode.getTextContent(); - const lastNodeTextLength = lastNodeText.length; - - // The last node might not actually be the end node - // - // If not, assume the last node is fully-selected unless the end offset is - // zero. - if (lastNode.__key !== endKey && endOffset !== 0) { - endOffset = lastNodeTextLength; - } - - // if the entire last node isn't selected and it isn't a token or segmented, split it - if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) { - [lastNode] = lastNode.splitText(endOffset); - } - - if (endOffset !== 0 || endType === 'element') { - fn(lastNode as TextNode); - } + const selectedNodes = selection.getNodes(); + for (const selectedNode of selectedNodes) { + if (!($isTextNode(selectedNode) && selectedNode.canHaveFormat())) { + continue; + } + const [startOffset, endOffset] = getSliceIndices(selectedNode); + // No actual text is selected, so do nothing. + if (endOffset === startOffset) { + continue; } - // style all the text nodes in between - for (let i = 1; i < lastIndex; i++) { - const selectedNode = selectedNodes[i]; - const selectedNodeKey = selectedNode.getKey(); - - if ( - $isTextNode(selectedNode) && - selectedNode.canHaveFormat() && - selectedNodeKey !== firstNode.getKey() && - selectedNodeKey !== lastNode.getKey() && - !selectedNode.isToken() - ) { - fn(selectedNode as TextNode); - } + // The entire node is selected or a token/segment, so just format it + if ( + $isTokenOrSegmented(selectedNode) || + (startOffset === 0 && endOffset === selectedNode.getTextContentSize()) + ) { + fn(selectedNode); + } else { + // The node is partially selected, so split it into two or three nodes + // and style the selected one. + const splitNodes = selectedNode.splitText(startOffset, endOffset); + const replacement = splitNodes[startOffset === 0 ? 0 : 1]; + fn(replacement); } } } diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 09c3bfa4c63..d6afb821d1f 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -269,10 +269,10 @@ export function $getNextSiblingOrParentSibling( return rval && [rval[0].origin, rval[1]]; } -function $getNextSiblingOrParentSiblingCaret( - startCaret: NodeCaret<'next'>, +function $getNextSiblingOrParentSiblingCaret( + startCaret: NodeCaret, rootMode: RootMode = 'root', -): null | [NodeCaret<'next'>, number] { +): null | [NodeCaret, number] { let depthDiff = 0; let caret = startCaret; let nextCaret = $getAdjacentDepthCaret(caret); @@ -310,10 +310,11 @@ export function $getDepth(node: LexicalNode): number { export function $getNextRightPreorderNode( startingNode: LexicalNode, ): LexicalNode | null { - const caret = $getAdjacentDepthCaret( - $getChildCaretOrSelf($getBreadthCaret(startingNode, 'previous')), + const startCaret = $getChildCaretOrSelf( + $getBreadthCaret(startingNode, 'previous'), ); - return caret && caret.origin; + const next = $getNextSiblingOrParentSiblingCaret(startCaret, 'root'); + return next && next[0].origin; } /** diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 3dc12f66fae..5231896a80b 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -496,33 +496,61 @@ export class RangeSelection implements BaseSelection { const isBefore = anchor.isBefore(focus); const firstPoint = isBefore ? anchor : focus; const lastPoint = isBefore ? focus : anchor; - let firstNode = firstPoint.getNode(); - let lastNode = lastPoint.getNode(); - const overselectedFirstNode = - $isElementNode(firstNode) && - firstPoint.offset > 0 && - firstPoint.offset >= firstNode.getChildrenSize(); - const startOffset = firstPoint.offset; - const endOffset = lastPoint.offset; - - if ($isElementNode(firstNode)) { - const firstNodeDescendant = - firstNode.getDescendantByIndex(startOffset); - firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode; - } - if ($isElementNode(lastNode)) { - let lastNodeDescendant = - lastNode.getDescendantByIndex(endOffset); - // We don't want to over-select, as node selection infers the child before - // the last descendant, not including that descendant. - if ( - lastNodeDescendant !== null && - lastNodeDescendant !== firstNode && - lastNode.getChildAtIndex(endOffset) === lastNodeDescendant - ) { - lastNodeDescendant = lastNodeDescendant.getPreviousSibling(); + const firstPointNode = firstPoint.getNode(); + const lastPointNode = lastPoint.getNode(); + let firstNode: LexicalNode = firstPointNode; + let lastNode: LexicalNode = lastPointNode; + let overselectedFirstNode = false; + const overselectedLastNodes = new Set(); + + if ($isElementNode(firstPointNode)) { + overselectedFirstNode = + firstPoint.offset > 0 && + firstPoint.offset >= firstPointNode.getChildrenSize(); + firstNode = + firstPointNode.getDescendantByIndex(firstPoint.offset) || + firstPointNode; + } + if ($isElementNode(lastPointNode)) { + const lastPointChild = lastPointNode.getChildAtIndex(lastPoint.offset); + if (lastPointChild) { + overselectedLastNodes.add(lastPointChild.getKey()); + lastNode = + ($isElementNode(lastPointChild) && + lastPointChild.getFirstDescendant()) || + lastPointChild; + for ( + let overselected: LexicalNode | null = lastNode; + overselected && !overselected.is(lastPointChild); + overselected = overselected.getParent() + ) { + overselectedLastNodes.add(overselected.getKey()); + } + } else { + const beforeChild = + lastPoint.offset > 0 && + lastPointNode.getChildAtIndex(lastPoint.offset - 1); + if (beforeChild) { + // This case is not an overselection + lastNode = + ($isElementNode(beforeChild) && beforeChild.getLastDescendant()) || + beforeChild; + } else { + // It's the last node and we have to find something at or after lastNode + // and mark all of the ancestors inbetween as overselected + lastNode = firstNode; + let parent = lastPointNode.getParent(); + for (; parent !== null; parent = parent.getParent()) { + overselectedLastNodes.add(parent.getKey()); + const parentSibling = parent.getNextSibling(); + if (parentSibling) { + lastNode = parentSibling; + break; + } + } + overselectedLastNodes.add(lastNode.getKey()); + } } - lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode; } let nodes: Array; @@ -534,8 +562,16 @@ export class RangeSelection implements BaseSelection { nodes = [firstNode]; } } else { - nodes = firstNode.getNodesBetween(lastNode); // Prevent over-selection due to the edge case of getDescendantByIndex always returning something #6974 + nodes = firstNode.getNodesBetween(lastNode); + if (overselectedLastNodes.size > 0) { + while ( + nodes.length > 0 && + overselectedLastNodes.has(nodes[nodes.length - 1].getKey()) + ) { + nodes.pop(); + } + } if (overselectedFirstNode) { const deleteCount = nodes.findIndex( (node) => !node.is(firstNode) && !node.isBefore(firstNode), diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index 3ea3b7525ab..9070e3ffe75 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -6,7 +6,13 @@ * */ -import {$createLinkNode, $isLinkNode} from '@lexical/link'; +import {$createLinkNode, $isLinkNode, LinkNode} from '@lexical/link'; +import { + $createListItemNode, + $createListNode, + ListItemNode, + ListNode, +} from '@lexical/list'; import { $createParagraphNode, $createRangeSelection, @@ -15,6 +21,7 @@ import { $getSelection, $isParagraphNode, $isTextNode, + $selectAll, $setSelection, createEditor, ElementNode, @@ -837,3 +844,140 @@ describe('Regression tests for #6701', () => { ); }); }); + +describe('getNodes()', () => { + initializeUnitTest((testEnv) => { + let paragraphNode: ParagraphNode; + let paragraphText: TextNode; + let linkNode: LinkNode; + let linkText: TextNode; + let listNode: ListNode; + let listItemText1: TextNode; + let listItemText2: TextNode; + let listItem1: ListItemNode; + let listItem2: ListItemNode; + + beforeEach(() => { + testEnv.editor.update(() => { + paragraphText = $createTextNode('paragraph text'); + linkText = $createTextNode('link text'); + linkNode = $createLinkNode().append(linkText); + paragraphNode = $createParagraphNode().append(paragraphText, linkNode); + listItemText1 = $createTextNode('item 1'); + listItemText2 = $createTextNode('item 2'); + listItem1 = $createListItemNode().append(listItemText1); + listItem2 = $createListItemNode().append(listItemText2); + listNode = $createListNode('bullet').append(listItem1, listItem2); + $getRoot().clear().append(paragraphNode, listNode); + }); + }); + test('$selectAll() - normalized text', () => { + testEnv.editor.update( + () => { + const selection = $selectAll(); + // Normalized to the text nodes + expect(selection).toMatchObject({ + anchor: {key: paragraphText.getKey(), offset: 0, type: 'text'}, + focus: { + key: listItemText2.getKey(), + offset: listItemText2.getTextContentSize(), + type: 'text', + }, + }); + expect(selection.getNodes()).toEqual([ + paragraphText, + linkNode, + linkText, + // The parent paragraphNode comes after its children because the + // selection started inside of it at paragraphText + paragraphNode, + listNode, + listItem1, + listItemText1, + listItem2, + listItemText2, + ]); + }, + {discrete: true}, + ); + }); + test('Manual select all without normalization', () => { + testEnv.editor.update( + () => { + const selection = $createRangeSelection(); + selection.anchor.set('root', 0, 'element'); + selection.focus.set('root', $getRoot().getChildrenSize(), 'element'); + expect(selection.getNodes()).toEqual([ + paragraphText, + linkNode, + linkText, + // The parent paragraphNode comes later because there is + // an implicit normalization in the beginning of getNodes + // to work around… something? See the getDescendantByIndex usage. + paragraphNode, + listNode, + listItem1, + listItemText1, + listItem2, + listItemText2, + ]); + }, + {discrete: true}, + ); + }); + test('select only the paragraph (not normalized)', () => { + testEnv.editor.update( + () => { + const selection = paragraphNode.select( + 0, + paragraphNode.getChildrenSize(), + ); + expect(selection).toMatchObject({ + anchor: {key: paragraphNode.getKey(), offset: 0, type: 'element'}, + focus: { + key: paragraphNode.getKey(), + offset: paragraphNode.getChildrenSize(), + type: 'element', + }, + }); + // The selection doesn't visit outside of the paragraph + expect(selection.getNodes()).toEqual([ + paragraphText, + linkNode, + linkText, + ]); + }, + {discrete: true}, + ); + }); + test('select around the paragraph (not normalized)', () => { + testEnv.editor.update( + () => { + const selection = $createRangeSelection(); + selection.anchor.set( + 'root', + paragraphNode.getIndexWithinParent(), + 'element', + ); + selection.focus.set( + 'root', + paragraphNode.getIndexWithinParent() + 1, + 'element', + ); + expect(selection).toMatchObject({ + anchor: {key: 'root', offset: 0, type: 'element'}, + focus: {key: 'root', offset: 1, type: 'element'}, + }); + // The selection shouldn't visit outside of the paragraph + expect(selection.getNodes()).toEqual([ + paragraphText, + linkNode, + linkText, + paragraphNode, + ]); + }, + {discrete: true}, + ); + }); + }); +}); diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index 15d6e4afb6e..1dbb1d28232 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -21,7 +21,11 @@ import type { NodeKey, SerializedLexicalNode, } from '../LexicalNode'; -import type {BaseSelection, RangeSelection} from '../LexicalSelection'; +import type { + BaseSelection, + RangeSelection, + TextPointType, +} from '../LexicalSelection'; import type {ElementNode} from './LexicalElementNode'; import {IS_FIREFOX} from 'shared/environment'; @@ -957,6 +961,22 @@ export class TextNode extends LexicalNode { const detail = self.__detail; let hasReplacedSelf = false; + // Prepare to handle selection + let startTextPoint: TextPointType | null = null; + let endTextPoint: TextPointType | null = null; + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const [startPoint, endPoint] = selection.isBackward() + ? [selection.focus, selection.anchor] + : [selection.anchor, selection.focus]; + if (startPoint.type === 'text' && startPoint.key === key) { + startTextPoint = startPoint; + } + if (endPoint.type === 'text' && endPoint.key === key) { + endTextPoint = endPoint; + } + } + if (self.isSegmented()) { // Create a new TextNode writableNode = $createTextNode(firstPart); @@ -970,9 +990,6 @@ export class TextNode extends LexicalNode { writableNode.__text = firstPart; } - // Handle selection - const selection = $getSelection(); - // Then handle all other parts const splitNodes: TextNode[] = [writableNode]; let textSize = firstPart.length; @@ -986,37 +1003,54 @@ export class TextNode extends LexicalNode { sibling.__detail = detail; const siblingKey = sibling.__key; const nextTextSize = textSize + partSize; - - if ($isRangeSelection(selection)) { - const anchor = selection.anchor; - const focus = selection.focus; - + if (compositionKey === key) { + $setCompositionKey(siblingKey); + } + textSize = nextTextSize; + splitNodes.push(sibling); + } + if (startTextPoint || endTextPoint) { + const originalStartOffset = startTextPoint ? startTextPoint.offset : null; + const originalEndOffset = endTextPoint ? endTextPoint.offset : null; + let startOffset = 0; + for (const node of splitNodes) { + const endOffset = startOffset + node.getTextContentSize(); if ( - anchor.key === key && - anchor.type === 'text' && - anchor.offset > textSize && - anchor.offset <= nextTextSize + startTextPoint !== null && + originalStartOffset !== null && + originalStartOffset <= endOffset && + originalStartOffset >= startOffset ) { - anchor.key = siblingKey; - anchor.offset -= textSize; - selection.dirty = true; + // Bias the start point to move to the new node + startTextPoint.set( + node.getKey(), + originalStartOffset - startOffset, + 'text', + ); + if (originalStartOffset < endOffset) { + // The start isn't on a border so we can stop checking + startTextPoint = null; + } } if ( - focus.key === key && - focus.type === 'text' && - focus.offset > textSize && - focus.offset <= nextTextSize + endTextPoint !== null && + originalEndOffset !== null && + originalEndOffset <= endOffset && + originalEndOffset >= startOffset ) { - focus.key = siblingKey; - focus.offset -= textSize; - selection.dirty = true; + endTextPoint.set( + node.getKey(), + originalEndOffset - startOffset, + 'text', + ); + // Bias the end to remain on the same node, only consider + // the next node if it's collapsed with the start on the end node + if (startTextPoint === null || originalEndOffset < endOffset) { + break; + } } + startOffset = endOffset; } - if (compositionKey === key) { - $setCompositionKey(siblingKey); - } - textSize = nextTextSize; - splitNodes.push(sibling); } // Insert the nodes into the parent's children From eb02346f02c615b24453b51b25b9683ab03c7f6c Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 28 Jan 2025 14:25:58 -0800 Subject: [PATCH 25/69] fix under-selection --- packages/lexical/src/LexicalNode.ts | 7 +++---- packages/lexical/src/LexicalSelection.ts | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 105b1487ec1..4bf603f15b0 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -381,11 +381,10 @@ export class LexicalNode { const firstPoint = targetSelection.isBackward() ? targetSelection.focus : targetSelection.anchor; - const firstElement = firstPoint.getNode() as ElementNode; if ( - firstPoint.offset === firstElement.getChildrenSize() && - firstElement.is(parentNode) && - firstElement.getLastChildOrThrow().is(this) + parentNode.is(firstPoint.getNode()) && + firstPoint.offset === parentNode.getChildrenSize() && + this.is(parentNode.getLastChild()) ) { return false; } diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 5231896a80b..e4c9cae90e6 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -538,7 +538,6 @@ export class RangeSelection implements BaseSelection { } else { // It's the last node and we have to find something at or after lastNode // and mark all of the ancestors inbetween as overselected - lastNode = firstNode; let parent = lastPointNode.getParent(); for (; parent !== null; parent = parent.getParent()) { overselectedLastNodes.add(parent.getKey()); From f28c774354d8a0d732d81b0aee35a1977539f2c6 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 28 Jan 2025 16:36:45 -0800 Subject: [PATCH 26/69] work around more selection edge cases --- .../__tests__/unit/LexicalSelection.test.tsx | 6 +- .../lexical-selection/src/range-selection.ts | 70 +++++++++--------- packages/lexical/src/LexicalSelection.ts | 4 +- .../__tests__/unit/LexicalSelection.test.ts | 72 +++++++++++++++++-- 4 files changed, 106 insertions(+), 46 deletions(-) diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx index 7e67e54e04b..bc2755f24dd 100644 --- a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx +++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx @@ -2757,7 +2757,11 @@ describe('LexicalSelection tests', () => { expect(rootChildren[1].__type).toBe('heading'); expect(rootChildren.length).toBe(2); const sel = $getSelection()!; - expect(sel.getNodes().length).toBe(2); + expect(sel).toMatchObject({ + anchor: {key: rootChildren[0].__key, offset: 0, type: 'element'}, + focus: {key: rootChildren[1].__key, offset: 0, type: 'element'}, + }); + expect(sel.getNodes()).toEqual(rootChildren); }); }); diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts index 6dc33ca750a..8fa88f49460 100644 --- a/packages/lexical-selection/src/range-selection.ts +++ b/packages/lexical-selection/src/range-selection.ts @@ -18,9 +18,9 @@ import type { import {TableSelection} from '@lexical/table'; import { + $createRangeSelection, $getAdjacentNode, $getPreviousSelection, - $getRoot, $getSelection, $hasAncestor, $isDecoratorNode, @@ -49,49 +49,45 @@ export function $setBlocksType( if (selection === null) { return; } + // Selections tend to not include their containing blocks so we effectively + // expand it here const anchorAndFocus = selection.getStartEndPoints(); - const anchor = anchorAndFocus ? anchorAndFocus[0] : null; - const isCollapsedSelection = - selection.is($getSelection()) && selection.isCollapsed(); - - if (anchor !== null && anchor.key === 'root') { - const element = createElement(); - const root = $getRoot(); - const firstChild = root.getFirstChild(); - - if (firstChild) { - firstChild.replace(element, true); - } else { - root.append(element); + const blockMap = new Map(); + let newSelection: RangeSelection | null = null; + if (anchorAndFocus) { + const [anchor, focus] = anchorAndFocus; + newSelection = $createRangeSelection(); + newSelection.anchor.set(anchor.key, anchor.offset, anchor.type); + newSelection.focus.set(focus.key, focus.offset, focus.type); + const anchorBlock = $getAncestor(anchor.getNode(), INTERNAL_$isBlock); + const focusBlock = $getAncestor(focus.getNode(), INTERNAL_$isBlock); + if ($isElementNode(anchorBlock)) { + blockMap.set(anchorBlock.getKey(), anchorBlock); } - if (isCollapsedSelection) { - element.select(); + if ($isElementNode(focusBlock)) { + blockMap.set(focusBlock.getKey(), focusBlock); } - return; } - - const nodes = selection - .getNodes() - .filter(INTERNAL_$isBlock) - .filter($isElementNode); - const firstSelectedBlock = anchor - ? $getAncestor(anchor.getNode(), INTERNAL_$isBlock) - : null; - if ( - $isElementNode(firstSelectedBlock) && - !nodes.find((node) => node.is(firstSelectedBlock)) - ) { - nodes.push(firstSelectedBlock); + for (const node of selection.getNodes()) { + if ($isElementNode(node) && INTERNAL_$isBlock(node)) { + blockMap.set(node.getKey(), node); + } } - for (const node of nodes) { - const targetElement = createElement(); - targetElement.setFormat(node.getFormatType()); - targetElement.setIndent(node.getIndent()); - node.replace(targetElement, true); - if (node.is(firstSelectedBlock) && isCollapsedSelection) { - targetElement.select(); + for (const [key, node] of blockMap) { + const element = createElement(); + node.replace(element, true); + if (newSelection) { + if (key === newSelection.anchor.key) { + newSelection.anchor.key = element.getKey(); + } + if (key === newSelection.focus.key) { + newSelection.focus.key = element.getKey(); + } } } + if (newSelection && selection.is($getSelection())) { + $setSelection(newSelection); + } } function isPointAttached(point: Point): boolean { diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index e4c9cae90e6..59da0402f0d 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -547,7 +547,9 @@ export class RangeSelection implements BaseSelection { break; } } - overselectedLastNodes.add(lastNode.getKey()); + if (!(lastPointNode.isEmpty() && lastPointNode.is(lastNode))) { + overselectedLastNodes.add(lastNode.getKey()); + } } } } diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index 9070e3ffe75..41828c523a2 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -856,6 +856,7 @@ describe('getNodes()', () => { let listItemText2: TextNode; let listItem1: ListItemNode; let listItem2: ListItemNode; + let emptyParagraph: ParagraphNode; beforeEach(() => { testEnv.editor.update(() => { @@ -868,21 +869,18 @@ describe('getNodes()', () => { listItem1 = $createListItemNode().append(listItemText1); listItem2 = $createListItemNode().append(listItemText2); listNode = $createListNode('bullet').append(listItem1, listItem2); - $getRoot().clear().append(paragraphNode, listNode); + emptyParagraph = $createParagraphNode(); + $getRoot().clear().append(paragraphNode, listNode, emptyParagraph); }); }); - test('$selectAll() - normalized text', () => { + test('$selectAll()', () => { testEnv.editor.update( () => { const selection = $selectAll(); // Normalized to the text nodes expect(selection).toMatchObject({ anchor: {key: paragraphText.getKey(), offset: 0, type: 'text'}, - focus: { - key: listItemText2.getKey(), - offset: listItemText2.getTextContentSize(), - type: 'text', - }, + focus: {key: emptyParagraph.getKey(), offset: 0, type: 'element'}, }); expect(selection.getNodes()).toEqual([ paragraphText, @@ -896,11 +894,45 @@ describe('getNodes()', () => { listItemText1, listItem2, listItemText2, + emptyParagraph, ]); }, {discrete: true}, ); }); + test('$selectAll() after removing empty paragraph', () => { + testEnv.editor.update( + () => { + emptyParagraph.remove(); + const selection = $selectAll(); + // Normalized to the text nodes + expect(selection).toMatchObject({ + anchor: {key: paragraphText.getKey(), offset: 0, type: 'text'}, + focus: { + key: listItemText2.getKey(), + offset: listItemText2.getTextContentSize(), + type: 'text', + }, + }); + expect(selection.getNodes()).toEqual( + [ + paragraphText, + linkNode, + linkText, + // The parent paragraphNode comes after its children because the + // selection started inside of it at paragraphText + paragraphNode, + listNode, + listItem1, + listItemText1, + listItem2, + listItemText2, + ].map((n) => n.getLatest()), + ); + }, + {discrete: true}, + ); + }); test('Manual select all without normalization', () => { testEnv.editor.update( () => { @@ -920,6 +952,32 @@ describe('getNodes()', () => { listItemText1, listItem2, listItemText2, + emptyParagraph, + ]); + }, + {discrete: true}, + ); + }); + test('Manual select all from first text to last empty paragraph', () => { + testEnv.editor.update( + () => { + const selection = $createRangeSelection(); + selection.anchor.set(paragraphText.getKey(), 0, 'text'); + selection.focus.set(emptyParagraph.getKey(), 0, 'element'); + expect(selection.getNodes()).toEqual([ + paragraphText, + linkNode, + linkText, + // The parent paragraphNode comes later because there is + // an implicit normalization in the beginning of getNodes + // to work around… something? See the getDescendantByIndex usage. + paragraphNode, + listNode, + listItem1, + listItemText1, + listItem2, + listItemText2, + emptyParagraph, ]); }, {discrete: true}, From 9826108341623e3a96cd5472c6beaaf6658c3186 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 28 Jan 2025 20:27:51 -0800 Subject: [PATCH 27/69] Preserve indent and format in $setBlocksType --- .../__tests__/unit/LexicalSelection.test.tsx | 25 ++++++++------- packages/lexical-selection/src/index.ts | 2 ++ .../lexical-selection/src/range-selection.ts | 32 +++++++++++++++---- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx index bc2755f24dd..b65d09f0a10 100644 --- a/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx +++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelection.test.tsx @@ -3013,15 +3013,17 @@ describe('LexicalSelection tests', () => { const root = $getRoot(); const ul1 = $createListNode('bullet'); const text1 = $createTextNode('1'); - const li1 = $createListItemNode().append(text1); + const li1 = $createListItemNode(); const li1_wrapper = $createListItemNode(); const ul2 = $createListNode('bullet'); const text1_1 = $createTextNode('1.1'); - const li1_1 = $createListItemNode().append(text1_1); - ul1.append(li1, li1_wrapper); - li1_wrapper.append(ul2); - ul2.append(li1_1); - root.append(ul1); + const li1_1 = $createListItemNode(); + root.append( + ul1.append( + li1.append(text1), + li1_wrapper.append(ul2.append(li1_1.append(text1_1))), + ), + ); const selection = $createRangeSelection(); $setSelection(selection); @@ -3045,7 +3047,7 @@ describe('LexicalSelection tests', () => { ); }); - test('Nested list with listItem twice indented from his father', async () => { + test('Nested list with listItem twice indented from its parent', async () => { const testEditor = createTestEditor(); const element = document.createElement('div'); testEditor.setRootElement(element); @@ -3056,11 +3058,10 @@ describe('LexicalSelection tests', () => { const li1_wrapper = $createListItemNode(); const ul2 = $createListNode('bullet'); const text1_1 = $createTextNode('1.1'); - const li1_1 = $createListItemNode().append(text1_1); - ul1.append(li1_wrapper); - li1_wrapper.append(ul2); - ul2.append(li1_1); - root.append(ul1); + const li1_1 = $createListItemNode(); + root.append( + ul1.append(li1_wrapper.append(ul2.append(li1_1.append(text1_1)))), + ); const selection = $createRangeSelection(); $setSelection(selection); diff --git a/packages/lexical-selection/src/index.ts b/packages/lexical-selection/src/index.ts index 2fd6becf09b..e406206fd93 100644 --- a/packages/lexical-selection/src/index.ts +++ b/packages/lexical-selection/src/index.ts @@ -15,6 +15,7 @@ import { $trimTextContentFromAnchor, } from './lexical-node'; import { + $copyBlockFormatIndent, $getSelectionStyleValueForProperty, $isParentElementRTL, $moveCaretSelection, @@ -47,6 +48,7 @@ export { export const trimTextContentFromAnchor = $trimTextContentFromAnchor; export { + $copyBlockFormatIndent, $getSelectionStyleValueForProperty, $isParentElementRTL, $moveCaretSelection, diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts index 8fa88f49460..9d6189872e3 100644 --- a/packages/lexical-selection/src/range-selection.ts +++ b/packages/lexical-selection/src/range-selection.ts @@ -37,14 +37,33 @@ import invariant from 'shared/invariant'; import {getStyleObjectFromCSS} from './utils'; +export function $copyBlockFormatIndent( + srcNode: ElementNode, + destNode: ElementNode, +): void { + const format = srcNode.getFormatType(); + const indent = srcNode.getIndent(); + if (format !== destNode.getFormatType()) { + destNode.setFormat(format); + } + if (indent !== destNode.getIndent()) { + destNode.setIndent(indent); + } +} + /** * Converts all nodes in the selection that are of one block type to another. * @param selection - The selected blocks to be converted. - * @param createElement - The function that creates the node. eg. $createParagraphNode. + * @param $createElement - The function that creates the node. eg. $createParagraphNode. + * @param $afterCreateElement - The function that updates the new node based on the previous one ($copyBlockFormatIndent by default) */ -export function $setBlocksType( +export function $setBlocksType( selection: BaseSelection | null, - createElement: () => ElementNode, + $createElement: () => T, + $afterCreateElement: ( + prevNodeSrc: ElementNode, + newNodeDest: T, + ) => void = $copyBlockFormatIndent, ): void { if (selection === null) { return; @@ -73,9 +92,10 @@ export function $setBlocksType( blockMap.set(node.getKey(), node); } } - for (const [key, node] of blockMap) { - const element = createElement(); - node.replace(element, true); + for (const [key, prevNode] of blockMap) { + const element = $createElement(); + $afterCreateElement(prevNode, element); + prevNode.replace(element, true); if (newSelection) { if (key === newSelection.anchor.key) { newSelection.anchor.key = element.getKey(); From cfa3a7cacb6fc9e1148bb8566204b6377a07bdef Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 28 Jan 2025 21:40:11 -0800 Subject: [PATCH 28/69] handle disappearing anchors --- .../lexical/src/caret/LexicalCaretUtils.ts | 49 ++++++++++++++----- .../caret/__tests__/unit/LexicalCaret.test.ts | 49 +++++++++++++++++++ 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index fa65bfa442e..8183c7d3714 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -12,6 +12,7 @@ import type { NodeCaret, NodeCaretRange, RangeNodeCaret, + RootMode, TextNodeCaret, TextNodeCaretSlice, } from './LexicalCaret'; @@ -187,6 +188,21 @@ export function $rewindBreadthCaret< : $getDepthCaret(origin.getParentOrThrow(), direction); } +function $getAnchorCandidates( + anchor: NodeCaret, + rootMode: RootMode = 'root', +): [NodeCaret, ...NodeCaret[]] { + const carets: [NodeCaret, ...NodeCaret[]] = [anchor]; + for ( + let parent = anchor.getParentCaret(rootMode); + parent !== null; + parent = parent.getParentCaret(rootMode) + ) { + carets.push($rewindBreadthCaret(parent)); + } + return carets; +} + /** * Remove all text and nodes in the given range. The block containing the * focus will be removed and merged with the anchor's block if they are @@ -201,7 +217,7 @@ export function $removeTextFromCaretRange( if (range.isCollapsed()) { return range; } - let anchor = range.anchor; + let anchorCandidates = $getAnchorCandidates(range.anchor); const {direction} = range; // Remove all internal nodes @@ -237,7 +253,7 @@ export function $removeTextFromCaretRange( // and style and set to normal mode. for (const slice of range.getNonEmptyTextSlices()) { const {origin} = slice.caret; - const isAnchor = anchor.is(slice.caret); + const isAnchor = anchorCandidates[0].is(slice.caret); const contentSize = origin.getTextContentSize(); const caretBefore = $rewindBreadthCaret( $getBreadthCaret(origin, direction), @@ -247,14 +263,14 @@ export function $removeTextFromCaretRange( Math.abs(slice.size) === contentSize || (mode === 'token' && slice.size !== 0) ) { - caretBefore.remove(); if (isAnchor) { - anchor = caretBefore; + anchorCandidates = $getAnchorCandidates(caretBefore); } + caretBefore.remove(); } else { const nextCaret = $removeTextSlice(slice); if (isAnchor) { - anchor = nextCaret; + anchorCandidates = $getAnchorCandidates(nextCaret); } if (mode === 'segmented') { const src = nextCaret.origin; @@ -263,17 +279,28 @@ export function $removeTextFromCaretRange( .setFormat(src.getFormat()); caretBefore.replaceOrInsert(plainTextNode); if (isAnchor) { - anchor = $getTextNodeCaret( - plainTextNode, - nextCaret.direction, - nextCaret.offset, + anchorCandidates = $getAnchorCandidates( + $getTextNodeCaret( + plainTextNode, + nextCaret.direction, + nextCaret.offset, + ), ); } } } } - anchor = $normalizeCaret(anchor); - return $getCaretRange(anchor, anchor); + for (const caret of anchorCandidates) { + if (caret.origin.isAttached()) { + const anchor = $normalizeCaret(caret); + return $getCaretRange(anchor, anchor); + } + } + invariant( + false, + '$removeTextFromCaretRange: selection was lost, could not find a new anchor given candidates with keys: %s', + JSON.stringify(anchorCandidates.map((n) => n.origin.__key)), + ); } /** diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 0a58e0df075..c85c31efb7e 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -736,6 +736,55 @@ describe('LexicalCaret', () => { .append($createParagraphNode().append(...textNodes)); }); }); + test('remove second TextNode when wrapped in a LinkNode that will become empty', () => { + testEnv.editor.update( + () => { + const sel = $createRangeSelection(); + const originalNodes = $getRoot().getAllTextNodes(); + const [leadingText, trailingLinkText] = originalNodes; + const linkWrapper = $createLinkNode('https://lexical.dev'); + trailingLinkText.replace(linkWrapper); + linkWrapper.append(trailingLinkText); + sel.anchor.set(trailingLinkText.getKey(), 0, 'text'); + sel.focus.set( + trailingLinkText.getKey(), + trailingLinkText.getTextContentSize(), + 'text', + ); + const direction = 'next'; + const range = $caretRangeFromSelection(sel); + $setSelection(sel); + expect(range).toMatchObject({ + anchor: { + direction, + offset: 0, + origin: trailingLinkText.getLatest(), + }, + focus: { + direction, + offset: trailingLinkText.getTextContentSize(), + origin: trailingLinkText.getLatest(), + }, + }); + const resultRange = $removeTextFromCaretRange(range); + $setSelectionFromCaretRange(resultRange); + expect(leadingText.isAttached()).toBe(true); + expect(trailingLinkText.isAttached()).toBe(false); + expect($getRoot().getAllTextNodes()).toHaveLength(2); + expect(resultRange.isCollapsed()).toBe(true); + expect(sel.isCollapsed()).toBe(true); + expect(sel.anchor.getNode()).toBe(leadingText.getLatest()); + expect(sel.anchor.key).toBe(leadingText.getKey()); + expect(sel.anchor.offset).toBe(leadingText.getTextContentSize()); + expect(resultRange.anchor).toMatchObject({ + direction, + offset: leadingText.getTextContentSize(), + origin: leadingText.getLatest(), + }); + }, + {discrete: true}, + ); + }); test('remove first TextNode with second in token mode', () => { testEnv.editor.update( () => { From 225cccacf46016214c4f400f179319726822839d Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 29 Jan 2025 07:45:10 -0800 Subject: [PATCH 29/69] fix block merge order --- packages/lexical/src/caret/LexicalCaretUtils.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 8183c7d3714..0a9af389484 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -233,13 +233,20 @@ export function $removeTextFromCaretRange( } } // Merge blocks if necessary - const firstBlock = $getAncestor(range.anchor.origin, INTERNAL_$isBlock); - const lastBlock = $getAncestor(range.focus.origin, INTERNAL_$isBlock); + const anchorBlock = $getAncestor(range.anchor.origin, INTERNAL_$isBlock); + const focusBlock = $getAncestor(range.focus.origin, INTERNAL_$isBlock); if ( - $isElementNode(lastBlock) && - canRemove.has(lastBlock.getKey()) && - $isElementNode(firstBlock) + $isElementNode(focusBlock) && + canRemove.has(focusBlock.getKey()) && + $isElementNode(anchorBlock) ) { + // always merge blocks later in the document with + // blocks earlier in the document + const [firstBlock, lastBlock] = + direction === 'next' + ? [anchorBlock, focusBlock] + : [focusBlock, anchorBlock]; + $getDepthCaret(firstBlock, flipDirection(direction)).splice( 0, lastBlock.getChildren(), From 3b657dcb353b6e032c3d16cf5af21c885deea765 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 29 Jan 2025 08:24:03 -0800 Subject: [PATCH 30/69] fix splice order --- packages/lexical/src/caret/LexicalCaretUtils.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 0a9af389484..e28c22f3af6 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -247,10 +247,7 @@ export function $removeTextFromCaretRange( ? [anchorBlock, focusBlock] : [focusBlock, anchorBlock]; - $getDepthCaret(firstBlock, flipDirection(direction)).splice( - 0, - lastBlock.getChildren(), - ); + $getDepthCaret(firstBlock, 'previous').splice(0, lastBlock.getChildren()); lastBlock.remove(); } // Splice text at the anchor and/or origin. From e0e5076754432266bdde7c694b4e596c8f840659 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 29 Jan 2025 08:31:05 -0800 Subject: [PATCH 31/69] fix tests for block merge order --- .../src/caret/__tests__/unit/LexicalCaret.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index c85c31efb7e..44003c1d716 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -1476,8 +1476,8 @@ describe('LexicalCaret', () => { .slice(startCaret.offset), ], ); - const originalAnchorParent = anchor.getParentAtCaret(); - const originalFocusParent = focus.getParentAtCaret(); + const originalStartParent = startCaret.getParentAtCaret(); + const originalEndParent = endCaret.getParentAtCaret(); const resultRange = $removeTextFromCaretRange(range); if (direction === 'next') { if (anchor.offset !== 0) { @@ -1503,7 +1503,7 @@ describe('LexicalCaret', () => { expect(resultRange).toMatchObject({ anchor: { direction, - origin: originalAnchorParent.getLatest(), + origin: originalStartParent.getLatest(), type: 'depth', }, }); @@ -1533,7 +1533,7 @@ describe('LexicalCaret', () => { expect(resultRange).toMatchObject({ anchor: { direction, - origin: originalAnchorParent.getLatest(), + origin: originalStartParent.getLatest(), type: 'depth', }, }); @@ -1541,14 +1541,14 @@ describe('LexicalCaret', () => { } // Check that the containing block is always that of the anchor expect(resultRange.anchor.getParentAtCaret().getLatest()).toBe( - originalAnchorParent.getLatest(), + originalStartParent.getLatest(), ); // Check that the focus parent has always been removed - expect(originalFocusParent.isAttached()).toBe(false); + expect(originalEndParent.isAttached()).toBe(false); // Check that the focus has been removed or moved to the anchor parent expect( !focus.origin.isAttached() || - originalAnchorParent.is(focus.origin.getParent()), + originalStartParent.is(focus.origin.getParent()), ).toBe(true); const remainingNodes = $getRoot().getAllTextNodes(); let newIndex = 0; From ea4acc4dea27f98af704e4f36d6ca4bdafbabc2f Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 29 Jan 2025 14:40:19 -0800 Subject: [PATCH 32/69] clean up unit test --- packages/lexical/src/caret/LexicalCaret.ts | 199 +++++++++++++++--- .../lexical/src/caret/LexicalCaretUtils.ts | 120 ++++++++--- .../caret/__tests__/unit/LexicalCaret.test.ts | 182 ++++++---------- packages/lexical/src/index.ts | 2 +- 4 files changed, 328 insertions(+), 175 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index d1b756207b9..7d3164e411b 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -14,9 +14,26 @@ import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; import {$isRootNode} from '../nodes/LexicalRootNode'; import {$isTextNode, TextNode} from '../nodes/LexicalTextNode'; +/** + * The direction of a caret, 'next' points towards the end of the document + * and 'previous' points towards the beginning + */ export type CaretDirection = 'next' | 'previous'; +/** + * A type utility to flip next and previous + */ export type FlipDirection = typeof FLIP_DIRECTION[D]; +/** + * A breadth caret type points from a LexicalNode origin to its next or previous sibling, + * and a depth caret type points from an ElementNode origin to its first or last child. + */ export type CaretType = 'breadth' | 'depth'; +/** + * The RootMode is specified in all caret traversals where the traversal can go up + * towards the root. 'root' means that it will stop at the document root, + * and 'shadowRoot' will stop at the document root or any shadow root + * (per {@link $isRootOrShadowRoot}). + */ export type RootMode = 'root' | 'shadowRoot'; const FLIP_DIRECTION = { @@ -36,7 +53,12 @@ export interface BaseNodeCaret< readonly type: Type; /** next if pointing at the next sibling or first child, previous if pointing at the previous sibling or last child */ readonly direction: D; - /** Retun true if other is a caret with the same origin (by node key comparion), type, and direction */ + /** + * Retun true if other is a caret with the same origin (by node key comparion), type, and direction. + * + * Note that this will not check the offset of a TextNodeCaret because it is otherwise indistinguishable + * from a BreadthNodeCaret. Use {@link $isSameTextNodeCaret} for that specific scenario. + */ is: (other: NodeCaret | null) => boolean; /** * Get a new NodeCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity. @@ -86,11 +108,11 @@ export interface BaseNodeCaret< * A RangeSelection expressed as a pair of Carets */ export interface NodeCaretRange - extends Iterable> { + extends Iterable> { readonly type: 'node-caret-range'; readonly direction: D; - anchor: RangeNodeCaret; - focus: RangeNodeCaret; + anchor: PointNodeCaret; + focus: PointNodeCaret; /** Return true if anchor and focus are the same caret */ isCollapsed: () => boolean; /** @@ -146,7 +168,15 @@ export type NodeCaret = | BreadthNodeCaret | DepthNodeCaret; -export type RangeNodeCaret = +/** + * A PointNodeCaret is a NodeCaret that also includes a specialized + * TextNodeCaret type which refers to a specific offset of a TextNode. + * This type is separate because it is not relevant to general node traversal + * so it doesn't make sense to have it show up except when defining + * a NodeCaretRange and in those cases there will be at most two of them only + * at the boundaries. + */ +export type PointNodeCaret = | TextNodeCaret | BreadthNodeCaret | DepthNodeCaret; @@ -190,11 +220,17 @@ export interface DepthNodeCaret< } /** - * A TextNodeCaret is a special case of a BreadthNodeCaret that also carries an offset - * used for representing partially selected TextNode at the edges of a NodeCaretRange. + * A TextNodeCaret is a special case of a BreadthNodeCaret that also carries + * an offset used for representing partially selected TextNode at the edges + * of a NodeCaretRange. + * + * The direction determines which part of the text is adjacent to the caret, + * if next it's all of the text after offset. If previous, it's all of the + * text before offset. * - * The direction determines which part of the text is adjacent to the caret, if next - * it's all of the text after offset. If previous, it's all of the text before offset. + * While this can be used in place of any BreadthNodeCaret of a TextNode, + * the offset into the text will be ignored except in contexts that + * specifically use the TextNodeCaret or PointNodeCaret types. */ export interface TextNodeCaret< T extends TextNode = TextNode, @@ -206,13 +242,14 @@ export interface TextNodeCaret< } /** - * A TextNodeCaretSlice is a wrapper for a TextNodeCaret that carries a size - * representing the amount of text selected from the given caret. A negative - * size means that text before offset is selected, a positive size means that - * text after offset is selected. The offset+size pair is not affected in - * any way by the direction of the caret. + * A TextNodeCaretSlice is a wrapper for a TextNodeCaret that carries a signed + * size representing the direction and amount of text selected from the given + * caret. A negative size means that text before offset is selected, a + * positive size means that text after offset is selected. The offset+size + * pair is not affected in any way by the direction of the caret. * - * The selected string content can be computed as such: + * The selected string content can be computed as such + * (see also {@link $getTextSliceContent}): * * ``` * slice.origin.getTextContent().slice( @@ -229,6 +266,10 @@ export interface TextNodeCaretSlice< readonly size: number; } +/** + * A utility type to specify that a NodeCaretRange may have zero, + * one, or two associated TextNodeCaretSlice. + */ export type TextNodeCaretSliceTuple = readonly TextNodeCaretSlice[] & {length: 0 | 1 | 2}; @@ -415,6 +456,16 @@ const MODE_PREDICATE = { shadowRoot: $isRootOrShadowRoot, } as const; +/** + * Flip a direction ('next' -> 'previous'; 'previous' -> 'next'). + * + * Note that TypeScript can't prove that FlipDirection is its own + * inverse (but if you have a concrete 'next' or 'previous' it will + * simplify accordingly). + * + * @param direction A direction + * @returns The opposite direction + */ export function flipDirection( direction: D, ): FlipDirection { @@ -468,8 +519,14 @@ abstract class AbstractBreadthNodeCaret< } } +/** + * Guard to check if the given caret is specifically a TextNodeCaret + * + * @param caret Any caret + * @returns true if it is a TextNodeCaret + */ export function $isTextNodeCaret( - caret: null | undefined | RangeNodeCaret, + caret: null | undefined | PointNodeCaret, ): caret is TextNodeCaret { return ( caret instanceof AbstractBreadthNodeCaret && @@ -478,24 +535,49 @@ export function $isTextNodeCaret( ); } +/** + * Guard to check the equivalence of TextNodeCaret + * + * @param a The caret known to be a TextNodeCaret + * @param b Any caret + * @returns true if b is a TextNodeCaret with the same origin, direction and offset as a + */ export function $isSameTextNodeCaret< T extends TextNodeCaret, ->(a: T, b: null | undefined | RangeNodeCaret): b is T { +>(a: T, b: null | undefined | PointNodeCaret): b is T { return $isTextNodeCaret(b) && a.is(b) && a.offset === b.offset; } +/** + * Guard to check if the given argument is any type of caret + * + * @param caret + * @returns true if caret is any type of caret + */ export function $isNodeCaret( - caret: null | undefined | NodeCaret, -) { + caret: null | undefined | PointNodeCaret, +): caret is PointNodeCaret { return caret instanceof AbstractCaret; } +/** + * Guard to check if the given argument is specifically a BreadthNodeCaret (or TextNodeCaret) + * + * @param caret + * @returns true if caret is a BreadthNodeCaret + */ export function $isBreadthNodeCaret( caret: null | undefined | NodeCaret, ): caret is BreadthNodeCaret { return caret instanceof AbstractBreadthNodeCaret; } +/** + * Guard to check if the given argument is specifically a DepthNodeCaret + + * @param caret + * @returns true if caret is a DepthNodeCaret + */ export function $isDepthNodeCaret( caret: null | undefined | NodeCaret, ): caret is DepthNodeCaret { @@ -579,6 +661,14 @@ function $getFlippedTextNodeCaret( ); } +/** + * Construct a TextNodeCaret + * + * @param origin The TextNode + * @param direction The direction (next points to the end of the text, previous points to the beginning) + * @param offset The offset into the text in absolute positive string coordinates (0 is the start) + * @returns a TextNodeCaret + */ export function $getTextNodeCaret( origin: T, direction: D, @@ -600,6 +690,17 @@ export function $getTextNodeCaret( }); } +/** + * Construct a TextNodeCaretSlice given a TextNodeCaret and a signed size. The + * size should be negative to slice text before the caret's offset, and positive + * to slice text after the offset. The direction of the caret itself is not + * relevant to the string coordinates when working with a TextNodeCaretSlice + * but mutation operations will preserve the direction. + * + * @param caret + * @param size + * @returns TextNodeCaretSlice + */ export function $getTextNodeCaretSlice< T extends TextNode, D extends CaretDirection, @@ -653,11 +754,11 @@ class NodeCaretRangeImpl { readonly type = 'node-caret-range'; readonly direction: D; - anchor: RangeNodeCaret; - focus: RangeNodeCaret; + anchor: PointNodeCaret; + focus: PointNodeCaret; constructor( - anchor: RangeNodeCaret, - focus: RangeNodeCaret, + anchor: PointNodeCaret, + focus: PointNodeCaret, direction: D, ) { this.anchor = anchor; @@ -716,7 +817,7 @@ class NodeCaretRangeImpl initial: anchor.is(focus) ? null : step(anchor), map: (state) => state, step, - stop: (state: null | RangeNodeCaret): state is null => + stop: (state: null | PointNodeCaret): state is null => state === null || (isTextFocus && focus.is(state)), }); } @@ -739,19 +840,53 @@ function $getSliceFromTextNodeCaret< return {caret, size: offsetB - caret.offset}; } +/** + * Construct a NodeCaretRange from anchor and focus carets pointing in the + * same direction. In order to get the expected behavior, + * the anchor must point towards the focus or be the same point. + * + * In the 'next' direction the anchor should be at or before the + * focus in the document. In the 'previous' direction the anchor + * should be at or after the focus in the document + * (similar to a backwards RangeSelection). + * + * @param anchor + * @param focus + * @returns a NodeCaretRange + */ export function $getCaretRange( - anchor: RangeNodeCaret, - focus: RangeNodeCaret, + anchor: PointNodeCaret, + focus: PointNodeCaret, ) { + invariant( + anchor.direction === focus.direction, + '$getCaretRange: anchor and focus must be in the same direction', + ); return new NodeCaretRangeImpl(anchor, focus, anchor.direction); } -export function makeStepwiseIterator({ - initial, - stop, - step, - map, -}: StepwiseIteratorConfig): IterableIterator { +/** + * A generalized utility for creating a stepwise iterator + * based on: + * + * - an initial state + * - a stop guard that returns true if the iteration is over, this + * is typically used to detect a sentinel value such as null or + * undefined from the state but may return true for other conditions + * as well + * - a step function that advances the state (this will be called + * after map each time next() is called to prepare the next state) + * - a map function that will be called that may transform the state + * before returning it. It will only be called once for each next() + * call when stop(state) === false + * + * @param config + * @returns An IterableIterator + */ +export function makeStepwiseIterator( + config: StepwiseIteratorConfig, +): IterableIterator { + const {initial, stop, step, map} = config; let state = initial; return { [Symbol.iterator]() { diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index e28c22f3af6..b087dde704d 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -11,7 +11,7 @@ import type { CaretDirection, NodeCaret, NodeCaretRange, - RangeNodeCaret, + PointNodeCaret, RootMode, TextNodeCaret, TextNodeCaretSlice, @@ -52,12 +52,12 @@ import { /** * @param point - * @returns a RangeNodeCaret for the point + * @returns a PointNodeCaret for the point */ export function $caretFromPoint( point: PointType, direction: D, -): RangeNodeCaret { +): PointNodeCaret { const {type, key, offset} = point; const node = $getNodeByKeyOrThrow(point.key); if (type === 'text') { @@ -79,14 +79,14 @@ export function $caretFromPoint( } /** - * Update the given point in-place from the RangeNodeCaret + * Update the given point in-place from the PointNodeCaret * * @param point the point to set * @param caret the caret to set the point from */ export function $setPointFromCaret( point: PointType, - caret: RangeNodeCaret, + caret: PointNodeCaret, ): void { if ($isTextNodeCaret(caret)) { point.set(caret.origin.getKey(), caret.offset, 'text'); @@ -133,7 +133,7 @@ export function $setSelectionFromCaretRange( } /** - * Update the points of a RangeSelection based on the given RangeNodeCaret. + * Update the points of a RangeSelection based on the given PointNodeCaret. */ export function $updateRangeSelectionFromCaretRange( selection: RangeSelection, @@ -204,19 +204,22 @@ function $getAnchorCandidates( } /** - * Remove all text and nodes in the given range. The block containing the - * focus will be removed and merged with the anchor's block if they are - * not the same. + * Remove all text and nodes in the given range. If the range spans multiple + * blocks then the remaining contents of the later block will be merged with + * the earlier block. * * @param range The range to remove text and nodes from - * @returns The new collapsed range + * @returns The new collapsed range (biased towards the earlier node) */ export function $removeTextFromCaretRange( - range: NodeCaretRange, + initialRange: NodeCaretRange, ): NodeCaretRange { - if (range.isCollapsed()) { - return range; + if (initialRange.isCollapsed()) { + return initialRange; } + // Always process removals in document order + const range = $getCaretRangeInDirection(initialRange, 'next'); + let anchorCandidates = $getAnchorCandidates(range.anchor); const {direction} = range; @@ -296,7 +299,10 @@ export function $removeTextFromCaretRange( } for (const caret of anchorCandidates) { if (caret.origin.isAttached()) { - const anchor = $normalizeCaret(caret); + const anchor = $getCaretInDirection( + $normalizeCaret(caret), + initialRange.direction, + ); return $getCaretRange(anchor, anchor); } } @@ -318,9 +324,9 @@ export function $removeTextFromCaretRange( * @param initialCaret * @returns Either a deeper DepthNodeCaret or the given initialCaret */ -function $getDeepestChildOrSelf( +function $getDeepestChildOrSelf( initialCaret: Caret, -): RangeNodeCaret['direction']> | (Caret & null) { +): PointNodeCaret['direction']> | (Caret & null) { let caret = $getChildCaretOrSelf(initialCaret); while ($isDepthNodeCaret(caret)) { const childNode = caret.getNodeAtCaret(); @@ -333,7 +339,7 @@ function $getDeepestChildOrSelf( } /** - * Normalize a caret to the deepest equivalent RangeNodeCaret. + * Normalize a caret to the deepest equivalent PointNodeCaret. * This will return a TextNodeCaret with the offset set according * to the direction if given a caret with a TextNode origin * or a caret with an ElementNode origin with the deepest DepthNode @@ -343,11 +349,11 @@ function $getDeepestChildOrSelf( * is required when an offset is already present. * * @param initialCaret - * @returns The normalized RangeNodeCaret + * @returns The normalized PointNodeCaret */ export function $normalizeCaret( - initialCaret: RangeNodeCaret, -): RangeNodeCaret { + initialCaret: PointNodeCaret, +): PointNodeCaret { const caret = initialCaret.getLatest(); const {direction} = caret; if ($isTextNodeCaret(caret)) { @@ -362,6 +368,10 @@ export function $normalizeCaret( : caret; } +/** + * @param slice a TextNodeCaretSlice + * @returns absolute coordinates into the text (for use with text.slice(...)) + */ function $getTextSliceIndices( slice: TextNodeCaretSlice, ): [indexStart: number, indexEnd: number] { @@ -369,9 +379,64 @@ function $getTextSliceIndices( size, caret: {offset}, } = slice; - return [offset, offset + size].sort((a, b) => a - b) as [number, number]; + const offsetB = offset + size; + return offsetB < offset ? [offsetB, offset] : [offset, offsetB]; +} + +/** + * Return the caret if it's in the given direction, otherwise return + * caret.getFlipped(). + * + * @param caret Any PointNodeCaret + * @param direction The desired direction + * @returns A PointNodeCaret in direction + */ +export function $getCaretInDirection( + caret: PointNodeCaret, + direction: D, +): PointNodeCaret { + return ( + caret.direction === direction ? caret : caret.getFlipped() + ) as PointNodeCaret; +} + +/** + * Return the range if it's in the given direction, otherwise + * construct a new range using a flipped focus as the anchor + * and a flipped anchor as the focus. This transformation + * preserves the section of the document that it's working + * with, but reverses the order of iteration. + * + * @param range Any NodeCaretRange + * @param direction The desired direction + * @returns A NodeCaretRange in direction + */ +export function $getCaretRangeInDirection( + range: NodeCaretRange, + direction: D, +): NodeCaretRange { + if (range.direction === direction) { + return range as NodeCaretRange; + } + return $getCaretRange( + // focus and anchor get flipped here + $getCaretInDirection(range.focus, direction), + $getCaretInDirection(range.anchor, direction), + ); } +/** + * Remove the slice of text from the contained caret, returning a new + * TextNodeCaret without the wrapper (since the size would be zero). + * + * Note that this is a lower-level utility that does not have any specific + * behavior for 'segmented' or 'token' modes and it will not remove + * an empty TextNode. + * + * @param slice The slice to mutate + * @returns The inner TextNodeCaret with the same offset and direction + * and the latest TextNode origin after mutation + */ export function $removeTextSlice( slice: TextNodeCaretSlice, ): TextNodeCaret { @@ -387,10 +452,15 @@ export function $removeTextSlice( ); } -export function $getTextSliceContent< - T extends TextNode, - D extends CaretDirection, ->(slice: TextNodeCaretSlice): string { +/** + * Read the text from a TextNodeSlice + * + * @param slice The slice to read + * @returns The text represented by the slice + */ +export function $getTextSliceContent( + slice: TextNodeCaretSlice, +): string { return slice.caret.origin .getTextContent() .slice(...$getTextSliceIndices(slice)); diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 44003c1d716..9b0939abb08 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -941,20 +941,10 @@ describe('LexicalCaret', () => { texts.filter((_v, j) => j !== i), ); expect(resultRange.isCollapsed()).toBe(true); - // bias towards the anchor - const adjacentIndex = Math.min( - remainingNodes.length - 1, - Math.max(0, i + (direction === 'next' ? -1 : 0)), - ); + // bias towards the start + const adjacentIndex = Math.max(0, i - 1); const newOrigin = remainingNodes[adjacentIndex]; - const offset = - direction === 'next' - ? i === 0 - ? 0 - : newOrigin.getTextContentSize() - : i === texts.length - 1 - ? newOrigin.getTextContentSize() - : 0; + const offset = i === 0 ? 0 : newOrigin.getTextContentSize(); const pt = { direction, offset, @@ -1092,17 +1082,10 @@ describe('LexicalCaret', () => { originalNodes.map((n) => n.getLatest()), ); expect(resultRange.isCollapsed()).toBe(true); - // bias towards the anchor - const adjacentIndex = Math.min( - remainingNodes.length - 1, - Math.max(0, i + (direction === 'next' ? -1 : 0)), - ); + // bias towards the start + const adjacentIndex = Math.max(0, i - 1); const newOrigin = remainingNodes[adjacentIndex]; - const offset = - (direction === 'next' && i !== 0) || - (direction === 'previous' && i === texts.length - 1) - ? newOrigin.getTextContentSize() - : 0; + const offset = i === 0 ? 0 : newOrigin.getTextContentSize(); expect(resultRange).toMatchObject({ anchor: { direction, @@ -1256,101 +1239,55 @@ describe('LexicalCaret', () => { ], ); const resultRange = $removeTextFromCaretRange(range); - if (direction === 'next') { - if (anchor.offset !== 0) { - // Part of the anchor remains - expect(resultRange).toMatchObject({ - anchor: { - offset: anchor.offset, - origin: anchor.origin.getLatest(), - }, - }); - } else if (nodeIndexStart > 0) { - // The anchor was removed so bias towards the previous node - const prevNode = - originalNodes[nodeIndexStart - 1].getLatest(); - expect(resultRange).toMatchObject({ - anchor: { - offset: prevNode.getTextContentSize(), - origin: prevNode, - }, - }); - } else if (focus.offset !== texts[nodeIndexEnd].length) { - // The focus was not deleted and there is no previous node - // so the new anchor will be set to the focus origin - expect(resultRange).toMatchObject({ - anchor: { - offset: 0, - origin: originalNodes[nodeIndexEnd].getLatest(), - }, - }); - } else if (nodeIndexEnd !== texts.length - 1) { - // The anchor was at the start and the focus was removed - // but there is another text node to use as the anchor caret - expect(resultRange).toMatchObject({ - anchor: { - offset: 0, - origin: originalNodes[nodeIndexEnd + 1].getLatest(), - }, - }); - } else { - // All text has been removed so we have to use a depth caret - expect(resultRange).toMatchObject({ - anchor: { - direction, - origin: $getRoot().getFirstChild(), - type: 'depth', - }, - }); - } + expect(resultRange).toMatchObject({ + anchor: {direction}, + direction, + focus: {direction}, + }); + if (startCaret.offset !== 0) { + // Part of the start remains + expect(resultRange).toMatchObject({ + anchor: { + offset: startCaret.offset, + origin: startCaret.origin.getLatest(), + }, + }); + } else if (nodeIndexStart > 0) { + // The anchor was removed so bias towards the previous node + const prevNode = + originalNodes[nodeIndexStart - 1].getLatest(); + expect(resultRange).toMatchObject({ + anchor: { + offset: prevNode.getTextContentSize(), + origin: prevNode, + }, + }); + } else if (endCaret.offset !== texts[nodeIndexEnd].length) { + // The focus was not deleted and there is no previous node + // so the new anchor will be set to the focus origin + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd].getLatest(), + }, + }); + } else if (nodeIndexEnd !== texts.length - 1) { + // The anchor was at the start and the focus was removed + // but there is another text node to use as the anchor caret + expect(resultRange).toMatchObject({ + anchor: { + offset: 0, + origin: originalNodes[nodeIndexEnd + 1].getLatest(), + }, + }); } else { - invariant(direction === 'previous', 'exhaustiveness check'); - if (anchor.offset !== texts[nodeIndexEnd].length) { - // Part of the anchor remains - expect(resultRange).toMatchObject({ - anchor: { - offset: 0, - origin: anchor.origin.getLatest(), - }, - }); - } else if (nodeIndexEnd < texts.length - 1) { - // The anchor was removed so bias towards the next node - expect(resultRange).toMatchObject({ - anchor: { - offset: 0, - origin: originalNodes[nodeIndexEnd + 1].getLatest(), - }, - }); - } else if (focus.offset !== 0) { - // The focus was not deleted and there is no next node - // so the new anchor will be set to the focus origin - expect(resultRange).toMatchObject({ - anchor: { - offset: focus.offset, - origin: focus.origin.getLatest(), - }, - }); - } else if (nodeIndexStart > 0) { - // The anchor was at the end and the focus was removed - // but there is another text node to use as the anchor caret - const prevNode = - originalNodes[nodeIndexStart - 1].getLatest(); - expect(resultRange).toMatchObject({ - anchor: { - offset: prevNode.getTextContentSize(), - origin: prevNode, - }, - }); - } else { - // All text has been removed so we have to use a depth caret - expect(resultRange).toMatchObject({ - anchor: { - direction, - origin: $getRoot().getFirstChild(), - type: 'depth', - }, - }); - } + // All text has been removed so we have to use a depth caret + expect(resultRange).toMatchObject({ + anchor: { + origin: $getRoot().getFirstChild(), + type: 'depth', + }, + }); } const remainingNodes = $getRoot().getAllTextNodes(); let newIndex = 0; @@ -1484,18 +1421,22 @@ describe('LexicalCaret', () => { // Part of the anchor remains expect(resultRange).toMatchObject({ anchor: { + direction, offset: anchor.offset, origin: anchor.origin.getLatest(), }, + direction, }); } else if (focus.offset !== texts[nodeIndexEnd].length) { // The focus was not deleted and there is no previous node // so the new anchor will be set to the focus origin expect(resultRange).toMatchObject({ anchor: { + direction, offset: 0, origin: originalNodes[nodeIndexEnd].getLatest(), }, + direction, }); } else { // The anchor and focus were removed @@ -1506,26 +1447,32 @@ describe('LexicalCaret', () => { origin: originalStartParent.getLatest(), type: 'depth', }, + direction, }); } } else { + return; invariant(direction === 'previous', 'exhaustiveness check'); if (anchor.offset !== texts[nodeIndexEnd].length) { // Part of the anchor remains expect(resultRange).toMatchObject({ anchor: { + direction, offset: 0, origin: anchor.origin.getLatest(), }, + direction, }); } else if (focus.offset !== 0) { // The focus was not removed // so the new anchor will be set to the focus origin expect(resultRange).toMatchObject({ anchor: { + direction, offset: focus.offset, origin: focus.origin.getLatest(), }, + direction, }); } else { // All text has been removed so we have to use a depth caret @@ -1536,6 +1483,7 @@ describe('LexicalCaret', () => { origin: originalStartParent.getLatest(), type: 'depth', }, + direction, }); } } diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index c0fcbbd7e0b..c75e044603d 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -15,7 +15,7 @@ export type { FlipDirection, NodeCaret, NodeCaretRange, - RangeNodeCaret, + PointNodeCaret, RootMode, StepwiseIteratorConfig, TextNodeCaret, From e01e001806594855a75dc6a960a8cefaf0f99abe Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 29 Jan 2025 22:31:44 -0800 Subject: [PATCH 33/69] Modify reverseDfs implementation to be compliant with expectations --- .../__tests__/unit/LexicalNodeHelpers.test.ts | 157 ++++++++++-------- packages/lexical-utils/src/index.ts | 20 +-- 2 files changed, 95 insertions(+), 82 deletions(-) diff --git a/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts b/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts index 734f2636dfa..dcd8f52f6de 100644 --- a/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts +++ b/packages/lexical-utils/src/__tests__/unit/LexicalNodeHelpers.test.ts @@ -12,7 +12,9 @@ import { $getNodeByKey, $getRoot, $isElementNode, + ElementNode, LexicalEditor, + LexicalNode, NodeKey, } from 'lexical'; import { @@ -21,15 +23,24 @@ import { invariant, } from 'lexical/src/__tests__/utils'; -import {$dfs, $getNextSiblingOrParentSibling, $reverseDfs} from '../..'; +import { + $dfs, + $firstToLastIterator, + $getNextSiblingOrParentSibling, + $lastToFirstIterator, + $reverseDfs, +} from '../..'; + +interface DFSKeyPair { + depth: number; + node: NodeKey; +} describe('LexicalNodeHelpers tests', () => { initializeUnitTest((testEnv) => { describe('dfs order', () => { - let expectedKeys: Array<{ - depth: number; - node: NodeKey; - }> = []; + let expectedKeys: DFSKeyPair[]; + let reverseExpectedKeys: DFSKeyPair[]; /** * R @@ -38,6 +49,8 @@ describe('LexicalNodeHelpers tests', () => { * T1 T2 T3 T6 * * DFS: R, P1, B1, T1, B2, T2, T3, P2, T4, T5, B3, T6 + * + * Reverse DFS: R, P2, B3, T6, T5, T4, P1, B2, T3, T2, B1, T1 */ beforeEach(async () => { const editor: LexicalEditor = testEnv.editor; @@ -72,56 +85,56 @@ describe('LexicalNodeHelpers tests', () => { block3.append(text6); - expectedKeys = [ - { - depth: 0, - node: root.getKey(), - }, - { - depth: 1, - node: paragraph1.getKey(), - }, - { - depth: 2, - node: block1.getKey(), - }, - { - depth: 3, - node: text1.getKey(), - }, - { - depth: 2, - node: block2.getKey(), - }, - { - depth: 3, - node: text2.getKey(), - }, - { - depth: 3, - node: text3.getKey(), - }, - { - depth: 1, - node: paragraph2.getKey(), - }, - { - depth: 2, - node: text4.getKey(), - }, - { - depth: 2, - node: text5.getKey(), - }, - { - depth: 2, - node: block3.getKey(), - }, - { - depth: 3, - node: text6.getKey(), - }, - ]; + function* keysForNode( + depth: number, + node: LexicalNode, + $getChildren: (element: ElementNode) => Iterable, + ): Iterable { + yield {depth, node: node.getKey()}; + if ($isElementNode(node)) { + const childDepth = depth + 1; + for (const child of $getChildren(node)) { + yield* keysForNode(childDepth, child, $getChildren); + } + } + } + + expectedKeys = [...keysForNode(0, root, $firstToLastIterator)]; + reverseExpectedKeys = [...keysForNode(0, root, $lastToFirstIterator)]; + // R, P1, B1, T1, B2, T2, T3, P2, T4, T5, B3, T6 + expect(expectedKeys).toEqual( + [ + root, + paragraph1, + block1, + text1, + block2, + text2, + text3, + paragraph2, + text4, + text5, + block3, + text6, + ].map((n) => ({depth: n.getParentKeys().length, node: n.getKey()})), + ); + // R, P2, B3, T6, T5, T4, P1, B2, T3, T2, B1, T1 + expect(reverseExpectedKeys).toEqual( + [ + root, + paragraph2, + block3, + text6, + text5, + text4, + paragraph1, + block2, + text3, + text2, + block1, + text1, + ].map((n) => ({depth: n.getParentKeys().length, node: n.getKey()})), + ); }); }); @@ -150,12 +163,12 @@ describe('LexicalNodeHelpers tests', () => { test('Reverse DFS node order', async () => { const editor: LexicalEditor = testEnv.editor; editor.getEditorState().read(() => { - const expectedNodes = expectedKeys - .map(({depth, node: nodeKey}) => ({ + const expectedNodes = reverseExpectedKeys.map( + ({depth, node: nodeKey}) => ({ depth, node: $getNodeByKey(nodeKey)!.getLatest(), - })) - .reverse(); + }), + ); const first = expectedNodes[0]; const second = expectedNodes[1]; @@ -167,9 +180,7 @@ describe('LexicalNodeHelpers tests', () => { expectedNodes.slice(1, expectedNodes.length - 1), ); expect($reverseDfs()).toEqual(expectedNodes); - expect($reverseDfs($getRoot().getLastDescendant()!)).toEqual( - expectedNodes, - ); + expect($reverseDfs($getRoot())).toEqual(expectedNodes); }); }); }); @@ -206,6 +217,8 @@ describe('LexicalNodeHelpers tests', () => { const block3 = $createTestElementNode(); invariant($isElementNode(block1)); + // this will (only) change the latest state of block1 + // all other nodes will be the same version block1.append(block3); expect($dfs(root!)).toEqual([ @@ -265,28 +278,30 @@ describe('LexicalNodeHelpers tests', () => { const block3 = $createTestElementNode(); invariant($isElementNode(block1)); + // this will (only) change the latest state of block1 + // all other nodes will be the same version block1.append(block3); expect($reverseDfs()).toEqual([ { - depth: 2, - node: block2!.getLatest(), + depth: 0, + node: root!.getLatest(), }, { - depth: 3, - node: block3.getLatest(), + depth: 1, + node: paragraph!.getLatest(), }, { depth: 2, - node: block1.getLatest(), + node: block2!.getLatest(), }, { - depth: 1, - node: paragraph!.getLatest(), + depth: 2, + node: block1.getLatest(), }, { - depth: 0, - node: root!.getLatest(), + depth: 3, + node: block3.getLatest(), }, ]); }); diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index cb1153c3e66..733af0af858 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -207,13 +207,12 @@ export function $getAdjacentCaret( ): null | BreadthNodeCaret { return caret ? caret.getAdjacentCaret() : null; } + /** - * A function which will return exactly the reversed order of $dfs. That means that the tree is traversed - * from right to left, starting at the leaf and working towards the root. - * @param startNode - The node to start the search. If omitted, it will start at the last leaf node in the tree. - * @param endNode - The node to end the search. If omitted, it will work backwards all the way to the root node - * @returns An array of objects of all the nodes found by the search, including their depth into the tree. - * \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the start node). + * $dfs iterator (right to left). Tree traversal is done on the fly as new values are requested with O(1) memory. + * @param startNode - The node to start the search, if omitted, it will start at the root node. + * @param endNode - The node to end the search, if omitted, it will find all descendants of the startingNode. + * @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node). */ export function $reverseDfs( startNode?: LexicalNode, @@ -223,7 +222,7 @@ export function $reverseDfs( } /** - * $dfs iterator. Tree traversal is done on the fly as new values are requested with O(1) memory. + * $dfs iterator (left to right). Tree traversal is done on the fly as new values are requested with O(1) memory. * @param startNode - The node to start the search, if omitted, it will start at the root node. * @param endNode - The node to end the search, if omitted, it will find all descendants of the startingNode. * @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node). @@ -340,10 +339,9 @@ export function $getNextRightPreorderNode( } /** - * An iterator which will traverse the tree in exactly the reversed order of $dfsIterator. Tree traversal is done - * on the fly as new values are requested with O(1) memory. - * @param startNode - The node to start the search. If omitted, it will start at the last leaf node in the tree. - * @param endNode - The node to end the search. If omitted, it will work backwards all the way to the root node + * $dfs iterator (right to left). Tree traversal is done on the fly as new values are requested with O(1) memory. + * @param startNode - The node to start the search, if omitted, it will start at the root node. + * @param endNode - The node to end the search, if omitted, it will find all descendants of the startingNode. * @returns An iterator, each yielded value is a DFSNode. It will always return at least 1 node (the start node). */ export function $reverseDfsIterator( From 9dc52c9f735db3a04cb86dcdeaab3043c0648772 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 30 Jan 2025 00:11:24 -0800 Subject: [PATCH 34/69] expand the caret range to handle focus boundary --- .../lexical/src/caret/LexicalCaretUtils.ts | 53 ++++++++++++++++++- .../caret/__tests__/unit/LexicalCaret.test.ts | 34 ++++++++++++ packages/lexical/src/index.ts | 2 + 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index b087dde704d..a59a2387e64 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -17,6 +17,7 @@ import type { TextNodeCaretSlice, } from './LexicalCaret'; +import {$getAdjacentCaret} from '@lexical/utils'; import invariant from 'shared/invariant'; import { @@ -46,6 +47,7 @@ import { $getTextNodeCaret, $isBreadthNodeCaret, $isDepthNodeCaret, + $isSameTextNodeCaret, $isTextNodeCaret, flipDirection, } from './LexicalCaret'; @@ -218,7 +220,9 @@ export function $removeTextFromCaretRange( return initialRange; } // Always process removals in document order - const range = $getCaretRangeInDirection(initialRange, 'next'); + const range = $getExpandedCaretRange( + $getCaretRangeInDirection(initialRange, 'next'), + ); let anchorCandidates = $getAnchorCandidates(range.anchor); const {direction} = range; @@ -425,6 +429,53 @@ export function $getCaretRangeInDirection( ); } +/** + * Expand a range's focus away from the anchor towards the + * top of the tree so long as it doesn't have any adjacent + * siblings. + * + * @param range + * @param rootMode + * @returns + */ +export function $getExpandedCaretRange( + range: NodeCaretRange, + rootMode: RootMode = 'root', +): NodeCaretRange { + return $getCaretRange(range.anchor, $getExpandedCaret(range.focus, rootMode)); +} + +/** + * Move a caret upwards towards a root so long as it does not have any adjacent caret + * + * @param caret + * @param rootMode + * @returns + */ +export function $getExpandedCaret( + caret: PointNodeCaret, + rootMode: RootMode = 'root', +): PointNodeCaret { + if ( + $isTextNodeCaret(caret) && + !$isSameTextNodeCaret( + caret, + $getTextNodeCaret(caret.origin, caret.direction, caret.direction), + ) + ) { + return caret; + } + let nextCaret = caret; + while (!$getAdjacentCaret(nextCaret)) { + const nextParent = nextCaret.getParentCaret(rootMode); + if (!nextParent) { + break; + } + nextCaret = nextParent; + } + return nextCaret; +} + /** * Remove the slice of text from the contained caret, returning a new * TextNodeCaret without the wrapper (since the size would be zero). diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 9b0939abb08..d396e0e65a7 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -7,6 +7,7 @@ */ import {$createLinkNode} from '@lexical/link'; +import {$createListItemNode, $createListNode} from '@lexical/list'; import { $caretRangeFromSelection, $createParagraphNode, @@ -23,6 +24,7 @@ import { $isTextNodeCaret, $removeTextFromCaretRange, $rewindBreadthCaret, + $selectAll, $setPointFromCaret, $setSelection, $setSelectionFromCaretRange, @@ -35,6 +37,7 @@ import { import { $assertRangeSelection, + $createTestDecoratorNode, initializeUnitTest, invariant, } from '../../../__tests__/utils'; @@ -662,6 +665,37 @@ describe('LexicalCaret', () => { }); describe('$removeTextFromCaretRange', () => { const texts = ['first', 'second', 'third'] as const; + describe('ported File e2e tests', () => { + test('$selectAll() with nesting and a trailing decorator', () => { + testEnv.editor.update( + () => { + const paragraphNode = $createParagraphNode().append( + $createTextNode('Hello').setFormat('bold'), + $createTextNode('World'), + ); + const listNode = $createListNode('number').append( + $createListItemNode().append($createTextNode('one')), + $createListItemNode().append($createTextNode('two')), + $createListItemNode().append($createTestDecoratorNode()), + ); + $getRoot().clear().append(paragraphNode, listNode); + expect($getRoot().getChildrenSize()).toBe(2); + const range = $caretRangeFromSelection($selectAll()); + const resultRange = $removeTextFromCaretRange(range); + expect($getRoot().getAllTextNodes()).toEqual([]); + expect($getRoot().getChildren()).toEqual([paragraphNode]); + expect(resultRange).toMatchObject({ + anchor: { + direction: 'next', + origin: paragraphNode, + type: 'depth', + }, + }); + }, + {discrete: true}, + ); + }); + }); describe('ported LexicalSelection tests', () => { test('remove partial initial TextNode and partial segmented TextNode', () => { let leadingText: TextNode; diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 21477841f30..5c889a0762a 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -43,6 +43,8 @@ export { $getCaretInDirection, $getCaretRangeInDirection, $getChildCaretAtIndex, + $getExpandedCaret, + $getExpandedCaretRange, $getTextSliceContent, $removeTextFromCaretRange, $removeTextSlice, From f5e6f217b44d93f89a977430e90c2a3f756eb6a9 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 30 Jan 2025 01:15:00 -0800 Subject: [PATCH 35/69] Fix splitText bias --- .../caret/__tests__/unit/LexicalCaret.test.ts | 32 +++++++ packages/lexical/src/nodes/LexicalTextNode.ts | 83 ++++++++++--------- 2 files changed, 75 insertions(+), 40 deletions(-) diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index d396e0e65a7..ef1f549e76a 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -8,6 +8,7 @@ import {$createLinkNode} from '@lexical/link'; import {$createListItemNode, $createListNode} from '@lexical/list'; +import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; import { $caretRangeFromSelection, $createParagraphNode, @@ -665,6 +666,37 @@ describe('LexicalCaret', () => { }); describe('$removeTextFromCaretRange', () => { const texts = ['first', 'second', 'third'] as const; + describe('ported Headings e2e tests', () => { + test('Pressing return in the middle of a heading creates a new heading below', () => { + testEnv.editor.update( + () => { + const initialTextNode = $createTextNode('[before][after]'); + const headingNode = $createHeadingNode().append(initialTextNode); + $getRoot().clear().append(headingNode); + const newHeadingNode = initialTextNode + .select('[before]'.length, '[before]'.length) + .insertParagraph(); + expect( + $getRoot() + .getAllTextNodes() + .map((n) => n.getTextContent()), + ).toEqual(['[before]', '[after]']); + invariant($isHeadingNode(newHeadingNode), 'paragraph inserted'); + expect($getRoot().getChildren()).toEqual([ + headingNode, + newHeadingNode, + ]); + expect(initialTextNode.getTextContent()).toBe('[before]'); + expect(initialTextNode.getParent()).toBe(headingNode); + const newTextNodes = newHeadingNode.getAllTextNodes(); + expect(newTextNodes).toHaveLength(1); + invariant($isTextNode(newTextNodes[0]), 'new text node created'); + expect(newTextNodes[0].getTextContent()).toBe('[after]'); + }, + {discrete: true}, + ); + }); + }); describe('ported File e2e tests', () => { test('$selectAll() with nesting and a trailing decorator', () => { testEnv.editor.update( diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index 1dbb1d28232..38538181805 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -1009,48 +1009,51 @@ export class TextNode extends LexicalNode { textSize = nextTextSize; splitNodes.push(sibling); } - if (startTextPoint || endTextPoint) { - const originalStartOffset = startTextPoint ? startTextPoint.offset : null; - const originalEndOffset = endTextPoint ? endTextPoint.offset : null; - let startOffset = 0; - for (const node of splitNodes) { - const endOffset = startOffset + node.getTextContentSize(); - if ( - startTextPoint !== null && - originalStartOffset !== null && - originalStartOffset <= endOffset && - originalStartOffset >= startOffset - ) { - // Bias the start point to move to the new node - startTextPoint.set( - node.getKey(), - originalStartOffset - startOffset, - 'text', - ); - if (originalStartOffset < endOffset) { - // The start isn't on a border so we can stop checking - startTextPoint = null; - } - } - if ( - endTextPoint !== null && - originalEndOffset !== null && - originalEndOffset <= endOffset && - originalEndOffset >= startOffset - ) { - endTextPoint.set( - node.getKey(), - originalEndOffset - startOffset, - 'text', - ); - // Bias the end to remain on the same node, only consider - // the next node if it's collapsed with the start on the end node - if (startTextPoint === null || originalEndOffset < endOffset) { - break; - } + + // Move the selection to the best location in the split string. + // The end point is always left-biased, and the start point is + // generally left biased unless the end point would land on a + // later node in the split in which case it will prefer the start + // of that node so they will tend to be on the same node. + const originalStartOffset = startTextPoint ? startTextPoint.offset : null; + const originalEndOffset = endTextPoint ? endTextPoint.offset : null; + let startOffset = 0; + for (const node of splitNodes) { + if (!(startTextPoint || endTextPoint)) { + break; + } + const endOffset = startOffset + node.getTextContentSize(); + if ( + startTextPoint !== null && + originalStartOffset !== null && + originalStartOffset <= endOffset && + originalStartOffset >= startOffset + ) { + // Set the start point to the first valid node + startTextPoint.set( + node.getKey(), + originalStartOffset - startOffset, + 'text', + ); + if (originalStartOffset < endOffset) { + // The start isn't on a border so we can stop checking + startTextPoint = null; } - startOffset = endOffset; } + if ( + endTextPoint !== null && + originalEndOffset !== null && + originalEndOffset <= endOffset && + originalEndOffset >= startOffset + ) { + endTextPoint.set( + node.getKey(), + originalEndOffset - startOffset, + 'text', + ); + break; + } + startOffset = endOffset; } // Insert the nodes into the parent's children From 2bc076af4bf613d6b29a9a0ebb681e99adc065e1 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 30 Jan 2025 01:24:18 -0800 Subject: [PATCH 36/69] Fix dependency cycle --- packages/lexical/src/caret/LexicalCaretUtils.ts | 3 +-- scripts/build.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index a59a2387e64..ed6228c8800 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -17,7 +17,6 @@ import type { TextNodeCaretSlice, } from './LexicalCaret'; -import {$getAdjacentCaret} from '@lexical/utils'; import invariant from 'shared/invariant'; import { @@ -466,7 +465,7 @@ export function $getExpandedCaret( return caret; } let nextCaret = caret; - while (!$getAdjacentCaret(nextCaret)) { + while (!nextCaret.getAdjacentCaret()) { const nextParent = nextCaret.getParentCaret(rootMode); if (!nextParent) { break; diff --git a/scripts/build.js b/scripts/build.js index e92f9376e99..59a979cfe7c 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -135,7 +135,7 @@ async function build( if ( typeof modulePkgName === 'string' && !( - modulePkgName in pkg.packageJson.dependencies || + modulePkgName in (pkg.packageJson.dependencies || {}) || modulePkgName === pkg.getNpmName() ) ) { From 7c118a50401e610a0127f4051c0ffa4c711dc0c7 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 30 Jan 2025 23:35:46 -0800 Subject: [PATCH 37/69] wip table example --- packages/lexical-utils/src/index.ts | 27 +-- packages/lexical/src/caret/LexicalCaret.ts | 6 +- .../lexical/src/caret/LexicalCaretUtils.ts | 195 +++++++++--------- .../caret/__tests__/unit/LexicalCaret.test.ts | 42 ++++ packages/lexical/src/index.ts | 3 +- 5 files changed, 146 insertions(+), 127 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 733af0af858..88140b20220 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -10,6 +10,7 @@ import { $cloneWithProperties, $createParagraphNode, $getAdjacentDepthCaret, + $getAdjacentSiblingOrParentSiblingCaret, $getBreadthCaret, $getChildCaretAtIndex, $getChildCaretOrSelf, @@ -34,7 +35,6 @@ import { makeStepwiseIterator, type NodeCaret, type NodeKey, - type RootMode, } from 'lexical'; // This underscore postfixing is used as a hotfix so we do not // export shared types from this module #5918 @@ -262,7 +262,7 @@ function $dfsCaretIterator( if (state.type === 'depth') { depth++; } - const rval = $getNextSiblingOrParentSiblingCaret(state); + const rval = $getAdjacentSiblingOrParentSiblingCaret(state); if (!rval || rval[0].is(endCaret)) { return null; } @@ -284,31 +284,12 @@ function $dfsCaretIterator( export function $getNextSiblingOrParentSibling( node: LexicalNode, ): null | [LexicalNode, number] { - const rval = $getNextSiblingOrParentSiblingCaret( + const rval = $getAdjacentSiblingOrParentSiblingCaret( $getBreadthCaret(node, 'next'), ); return rval && [rval[0].origin, rval[1]]; } -function $getNextSiblingOrParentSiblingCaret( - startCaret: NodeCaret, - rootMode: RootMode = 'root', -): null | [NodeCaret, number] { - let depthDiff = 0; - let caret = startCaret; - let nextCaret = $getAdjacentDepthCaret(caret); - while (nextCaret === null) { - depthDiff--; - nextCaret = caret.getParentCaret(rootMode); - if (!nextCaret) { - return null; - } - caret = nextCaret; - nextCaret = $getAdjacentDepthCaret(caret); - } - return nextCaret && [nextCaret, depthDiff]; -} - export function $getDepth(node: LexicalNode): number { let innerNode: LexicalNode | null = node; let depth = 0; @@ -334,7 +315,7 @@ export function $getNextRightPreorderNode( const startCaret = $getChildCaretOrSelf( $getBreadthCaret(startingNode, 'previous'), ); - const next = $getNextSiblingOrParentSiblingCaret(startCaret, 'root'); + const next = $getAdjacentSiblingOrParentSiblingCaret(startCaret, 'root'); return next && next[0].origin; } diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 7d3164e411b..9d6a0301bc3 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -730,9 +730,9 @@ export function $getDepthCaret( /** * Gets the DepthNodeCaret if one is possible at this caret origin, otherwise return the caret */ -export function $getChildCaretOrSelf( +export function $getChildCaretOrSelf( caret: Caret, -): NodeCaret['direction']> | (Caret & null) { +): PointNodeCaret['direction']> | (Caret & null) { return (caret && caret.getChildCaret()) || caret; } @@ -857,7 +857,7 @@ function $getSliceFromTextNodeCaret< export function $getCaretRange( anchor: PointNodeCaret, focus: PointNodeCaret, -) { +): NodeCaretRange { invariant( anchor.direction === focus.direction, '$getCaretRange: anchor and focus must be in the same direction', diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index ed6228c8800..968853c452d 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -39,14 +39,13 @@ import { type TextNode, } from '../nodes/LexicalTextNode'; import { + $getAdjacentDepthCaret, $getBreadthCaret, $getCaretRange, - $getChildCaretOrSelf, $getDepthCaret, $getTextNodeCaret, $isBreadthNodeCaret, $isDepthNodeCaret, - $isSameTextNodeCaret, $isTextNodeCaret, flipDirection, } from './LexicalCaret'; @@ -193,9 +192,12 @@ function $getAnchorCandidates( anchor: NodeCaret, rootMode: RootMode = 'root', ): [NodeCaret, ...NodeCaret[]] { + // These candidates will be the anchor itself, the pointer to the anchor (if different), and then any parents of that const carets: [NodeCaret, ...NodeCaret[]] = [anchor]; for ( - let parent = anchor.getParentCaret(rootMode); + let parent = $isDepthNodeCaret(anchor) + ? anchor.getParentCaret(rootMode) + : anchor; parent !== null; parent = parent.getParentCaret(rootMode) ) { @@ -219,89 +221,101 @@ export function $removeTextFromCaretRange( return initialRange; } // Always process removals in document order - const range = $getExpandedCaretRange( - $getCaretRangeInDirection(initialRange, 'next'), - ); + const rootMode = 'root'; + const nextDirection = 'next'; + const range = $getCaretRangeInDirection(initialRange, nextDirection); - let anchorCandidates = $getAnchorCandidates(range.anchor); - const {direction} = range; + const anchorCandidates = $getAnchorCandidates(range.anchor, rootMode); + const focusCandidates = $getAnchorCandidates( + range.focus.getFlipped(), + rootMode, + ); - // Remove all internal nodes + // Mark the start of each ElementNode const canRemove = new Set(); - for (const caret of range.internalCarets('root')) { + // Mark the end of each ElementNode + const canExpand = new Set(); + // Remove all internal nodes + for (const caret of range.internalCarets(rootMode)) { if ($isDepthNodeCaret(caret)) { canRemove.add(caret.origin.getKey()); } else if ($isBreadthNodeCaret(caret)) { const {origin} = caret; - if (!$isElementNode(origin) || canRemove.has(origin.getKey())) { + if ($isElementNode(origin) && !canRemove.has(origin.getKey())) { + // The anchor is inside this element + canExpand.add(origin.getKey()); + } else { origin.remove(); } } } - // Merge blocks if necessary - const anchorBlock = $getAncestor(range.anchor.origin, INTERNAL_$isBlock); - const focusBlock = $getAncestor(range.focus.origin, INTERNAL_$isBlock); - if ( - $isElementNode(focusBlock) && - canRemove.has(focusBlock.getKey()) && - $isElementNode(anchorBlock) - ) { - // always merge blocks later in the document with - // blocks earlier in the document - const [firstBlock, lastBlock] = - direction === 'next' - ? [anchorBlock, focusBlock] - : [focusBlock, anchorBlock]; - $getDepthCaret(firstBlock, 'previous').splice(0, lastBlock.getChildren()); - lastBlock.remove(); - } // Splice text at the anchor and/or origin. // If the text is entirely selected then it is removed. // If it's a token with a non-empty selection then it is removed. // Segmented nodes will be copied to a plain text node with the same format // and style and set to normal mode. - for (const slice of range.getNonEmptyTextSlices()) { + for (const slice of range.getTextSlices()) { const {origin} = slice.caret; - const isAnchor = anchorCandidates[0].is(slice.caret); const contentSize = origin.getTextContentSize(); const caretBefore = $rewindBreadthCaret( - $getBreadthCaret(origin, direction), + $getBreadthCaret(origin, nextDirection), ); const mode = origin.getMode(); if ( Math.abs(slice.size) === contentSize || (mode === 'token' && slice.size !== 0) ) { - if (isAnchor) { - anchorCandidates = $getAnchorCandidates(caretBefore); - } + // anchorCandidates[1] should still be valid, it is caretBefore caretBefore.remove(); - } else { - const nextCaret = $removeTextSlice(slice); - if (isAnchor) { - anchorCandidates = $getAnchorCandidates(nextCaret); - } + } else if (slice.size !== 0) { + let nextCaret = $removeTextSlice(slice); if (mode === 'segmented') { const src = nextCaret.origin; const plainTextNode = $createTextNode(src.getTextContent()) .setStyle(src.getStyle()) .setFormat(src.getFormat()); caretBefore.replaceOrInsert(plainTextNode); - if (isAnchor) { - anchorCandidates = $getAnchorCandidates( - $getTextNodeCaret( - plainTextNode, - nextCaret.direction, - nextCaret.offset, - ), - ); - } + nextCaret = $getTextNodeCaret( + plainTextNode, + nextDirection, + nextCaret.offset, + ); + } + if (anchorCandidates[0].is(slice.caret)) { + anchorCandidates[0] = nextCaret; } } } - for (const caret of anchorCandidates) { - if (caret.origin.isAttached()) { + + for (const candidates of [anchorCandidates, focusCandidates]) { + const deleteCount = candidates.findIndex((caret) => + caret.origin.isAttached(), + ); + candidates.splice(0, deleteCount); + } + + const anchorCandidate = anchorCandidates.find((v) => v.origin.isAttached()); + const focusCandidate = focusCandidates.find((v) => v.origin.isAttached()); + + // Merge blocks if necessary + const anchorBlock = + anchorCandidate && $getAncestor(anchorCandidate.origin, INTERNAL_$isBlock); + const focusBlock = + focusCandidate && $getAncestor(focusCandidate.origin, INTERNAL_$isBlock); + if ( + $isElementNode(focusBlock) && + canRemove.has(focusBlock.getKey()) && + $isElementNode(anchorBlock) + ) { + // always merge blocks later in the document with + // blocks earlier in the document + $getDepthCaret(anchorBlock, 'previous').splice(0, focusBlock.getChildren()); + focusBlock.remove(); + } + + for (const caret of [anchorCandidate, focusCandidate]) { + if (caret && caret.origin.isAttached()) { const anchor = $getCaretInDirection( $normalizeCaret(caret), initialRange.direction, @@ -330,7 +344,8 @@ export function $removeTextFromCaretRange( function $getDeepestChildOrSelf( initialCaret: Caret, ): PointNodeCaret['direction']> | (Caret & null) { - let caret = $getChildCaretOrSelf(initialCaret); + let caret: PointNodeCaret['direction']> | (Caret & null) = + initialCaret; while ($isDepthNodeCaret(caret)) { const childNode = caret.getNodeAtCaret(); if (!$isElementNode(childNode)) { @@ -368,7 +383,7 @@ export function $normalizeCaret( const adjacent = $getDeepestChildOrSelf(caret.getAdjacentCaret()); return $isBreadthNodeCaret(adjacent) && $isTextNode(adjacent.origin) ? $getTextNodeCaret(adjacent.origin, direction, flipDirection(direction)) - : caret; + : $getDeepestChildOrSelf(caret); } /** @@ -428,53 +443,6 @@ export function $getCaretRangeInDirection( ); } -/** - * Expand a range's focus away from the anchor towards the - * top of the tree so long as it doesn't have any adjacent - * siblings. - * - * @param range - * @param rootMode - * @returns - */ -export function $getExpandedCaretRange( - range: NodeCaretRange, - rootMode: RootMode = 'root', -): NodeCaretRange { - return $getCaretRange(range.anchor, $getExpandedCaret(range.focus, rootMode)); -} - -/** - * Move a caret upwards towards a root so long as it does not have any adjacent caret - * - * @param caret - * @param rootMode - * @returns - */ -export function $getExpandedCaret( - caret: PointNodeCaret, - rootMode: RootMode = 'root', -): PointNodeCaret { - if ( - $isTextNodeCaret(caret) && - !$isSameTextNodeCaret( - caret, - $getTextNodeCaret(caret.origin, caret.direction, caret.direction), - ) - ) { - return caret; - } - let nextCaret = caret; - while (!nextCaret.getAdjacentCaret()) { - const nextParent = nextCaret.getParentCaret(rootMode); - if (!nextParent) { - break; - } - nextCaret = nextParent; - } - return nextCaret; -} - /** * Remove the slice of text from the contained caret, returning a new * TextNodeCaret without the wrapper (since the size would be zero). @@ -540,3 +508,32 @@ export function $getChildCaretAtIndex( } return (direction === 'next' ? caret : caret.getFlipped()) as NodeCaret; } + +/** + * Returns the Node sibling when this exists, otherwise the closest parent sibling. For example + * R -> P -> T1, T2 + * -> P2 + * returns T2 for node T1, P2 for node T2, and null for node P2. + * @param node LexicalNode. + * @returns An array (tuple) containing the found Lexical node and the depth difference, or null, if this node doesn't exist. + */ +export function $getAdjacentSiblingOrParentSiblingCaret< + D extends CaretDirection, +>( + startCaret: NodeCaret, + rootMode: RootMode = 'root', +): null | [NodeCaret, number] { + let depthDiff = 0; + let caret = startCaret; + let nextCaret = $getAdjacentDepthCaret(caret); + while (nextCaret === null) { + depthDiff--; + nextCaret = caret.getParentCaret(rootMode); + if (!nextCaret) { + return null; + } + caret = nextCaret; + nextCaret = $getAdjacentDepthCaret(caret); + } + return nextCaret && [nextCaret, depthDiff]; +} diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index ef1f549e76a..e877f618ec1 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -9,6 +9,11 @@ import {$createLinkNode} from '@lexical/link'; import {$createListItemNode, $createListNode} from '@lexical/list'; import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; +import { + $createTableCellNode, + $createTableNode, + $createTableRowNode, +} from '@lexical/table'; import { $caretRangeFromSelection, $createParagraphNode, @@ -728,6 +733,43 @@ describe('LexicalCaret', () => { ); }); }); + describe('ported Table e2e tests', () => { + test('Can delete all with range selection anchored in table', () => { + testEnv.editor.update( + () => { + const tableNode = $createTableNode().append( + $createTableRowNode().append( + $createTableCellNode().append( + $createParagraphNode().append($createTextNode('cell 1')), + ), + $createTableCellNode().append( + $createParagraphNode().append($createTextNode('cell 2')), + ), + ), + ); + const paragraphNode = $createParagraphNode().append( + $createTextNode('paragraph 2'), + ); + $getRoot().clear().append(tableNode, paragraphNode); + const selection = $selectAll(); + // The table plug-in would normally do this normalization + selection.anchor.set('root', 0, 'element'); + const range = $caretRangeFromSelection(selection); + const resultRange = $removeTextFromCaretRange(range); + expect($getRoot().getAllTextNodes()).toEqual([]); + expect($getRoot().getChildren()).toEqual([paragraphNode]); + expect(resultRange).toMatchObject({ + anchor: { + direction: 'next', + origin: paragraphNode, + type: 'depth', + }, + }); + }, + {discrete: true}, + ); + }); + }); describe('ported LexicalSelection tests', () => { test('remove partial initial TextNode and partial segmented TextNode', () => { let leadingText: TextNode; diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 5c889a0762a..222e0b1b5fc 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -40,11 +40,10 @@ export { export { $caretFromPoint, $caretRangeFromSelection, + $getAdjacentSiblingOrParentSiblingCaret, $getCaretInDirection, $getCaretRangeInDirection, $getChildCaretAtIndex, - $getExpandedCaret, - $getExpandedCaretRange, $getTextSliceContent, $removeTextFromCaretRange, $removeTextSlice, From cdf7dbd8ebd2b9ffd2cb273505c95c11818863c1 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 31 Jan 2025 08:36:37 -0800 Subject: [PATCH 38/69] $normalizeCaret --- packages/lexical/src/caret/LexicalCaret.ts | 4 ++-- packages/lexical/src/caret/LexicalCaretUtils.ts | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 9d6a0301bc3..654b18d696c 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -567,7 +567,7 @@ export function $isNodeCaret( * @returns true if caret is a BreadthNodeCaret */ export function $isBreadthNodeCaret( - caret: null | undefined | NodeCaret, + caret: null | undefined | PointNodeCaret, ): caret is BreadthNodeCaret { return caret instanceof AbstractBreadthNodeCaret; } @@ -579,7 +579,7 @@ export function $isBreadthNodeCaret( * @returns true if caret is a DepthNodeCaret */ export function $isDepthNodeCaret( - caret: null | undefined | NodeCaret, + caret: null | undefined | PointNodeCaret, ): caret is DepthNodeCaret { return caret instanceof AbstractDepthNodeCaret; } diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 968853c452d..ccd64ea9c15 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -9,6 +9,7 @@ import type {LexicalNode, NodeKey} from '../LexicalNode'; import type { BreadthNodeCaret, CaretDirection, + DepthNodeCaret, NodeCaret, NodeCaretRange, PointNodeCaret, @@ -341,11 +342,11 @@ export function $removeTextFromCaretRange( * @param initialCaret * @returns Either a deeper DepthNodeCaret or the given initialCaret */ -function $getDeepestChildOrSelf( - initialCaret: Caret, -): PointNodeCaret['direction']> | (Caret & null) { - let caret: PointNodeCaret['direction']> | (Caret & null) = - initialCaret; +function $getDeepestChildOrSelf< + D extends CaretDirection, + Caret extends PointNodeCaret | null, +>(initialCaret: Caret): DepthNodeCaret | Caret { + let caret: DepthNodeCaret | Caret = initialCaret; while ($isDepthNodeCaret(caret)) { const childNode = caret.getNodeAtCaret(); if (!$isElementNode(childNode)) { @@ -353,7 +354,7 @@ function $getDeepestChildOrSelf( } caret = $getDepthCaret(childNode, caret.direction); } - return (caret && caret.getChildCaret()) || caret; + return caret; } /** From 7eb46f71f66a74b61759f6f7242a4e12325a7ee9 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 31 Jan 2025 10:03:10 -0800 Subject: [PATCH 39/69] $normalizeCaret --- packages/lexical/src/caret/LexicalCaret.ts | 35 +++++++--- .../lexical/src/caret/LexicalCaretUtils.ts | 65 ++++++++++--------- 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 654b18d696c..f6d4ec11c11 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -674,6 +674,26 @@ export function $getTextNodeCaret( direction: D, offset: number | CaretDirection, ): TextNodeCaret { + return Object.assign($getBreadthCaret(origin, direction), { + getFlipped: $getFlippedTextNodeCaret, + getLatest: $getLatestTextNodeCaret, + offset: $getTextNodeOffset(origin, offset), + }); +} + +/** + * Get a normalized offset into a TextNode given a numeric offset or a + * direction for which end of the string to use. Throws if the offset + * is not in the bounds of the text content size. + * + * @param origin a TextNode + * @param offset An absolute offset into the TextNode string, or a direction for which end to use as the offset + * @returns An absolute offset into the TextNode string + */ +export function $getTextNodeOffset( + origin: TextNode, + offset: number | CaretDirection, +): number { const size = origin.getTextContentSize(); const numericOffset = offset === 'next' ? size : offset === 'previous' ? 0 : offset; @@ -683,11 +703,7 @@ export function $getTextNodeCaret( String(offset), String(size), ); - return Object.assign($getBreadthCaret(origin, direction), { - getFlipped: $getFlippedTextNodeCaret, - getLatest: $getLatestTextNodeCaret, - offset: numericOffset, - }); + return numericOffset; } /** @@ -833,10 +849,11 @@ function $getSliceFromTextNodeCaret< caret: TextNodeCaret, anchorOrFocus: 'anchor' | 'focus', ): TextNodeCaretSlice { - const offsetB = - (caret.direction === 'next') === (anchorOrFocus === 'anchor') - ? caret.origin.getTextContentSize() - : 0; + const {direction, origin} = caret; + const offsetB = $getTextNodeOffset( + origin, + anchorOrFocus === 'focus' ? flipDirection(direction) : direction, + ); return {caret, size: offsetB - caret.offset}; } diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index ccd64ea9c15..737697e576f 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -45,6 +45,7 @@ import { $getCaretRange, $getDepthCaret, $getTextNodeCaret, + $getTextNodeOffset, $isBreadthNodeCaret, $isDepthNodeCaret, $isTextNodeCaret, @@ -89,21 +90,15 @@ export function $setPointFromCaret( point: PointType, caret: PointNodeCaret, ): void { - if ($isTextNodeCaret(caret)) { - point.set(caret.origin.getKey(), caret.offset, 'text'); - } else { - const {origin, direction} = caret; - const isNext = direction === 'next'; - if ($isDepthNodeCaret(caret)) { + const {origin, direction} = caret; + const isNext = direction === 'next'; + if ($isBreadthNodeCaret(caret)) { + if ($isTextNode(origin)) { point.set( origin.getKey(), - isNext ? 0 : caret.origin.getChildrenSize(), - 'element', - ); - } else if ($isTextNode(origin)) { - point.set( - origin.getKey(), - isNext ? origin.getTextContentSize() : 0, + $isTextNodeCaret(caret) + ? caret.offset + : $getTextNodeOffset(origin, direction), 'text', ); } else { @@ -113,6 +108,16 @@ export function $setPointFromCaret( 'element', ); } + } else { + invariant( + $isDepthNodeCaret(caret) && $isElementNode(origin), + '$setPointFromCaret: exhaustiveness check', + ); + point.set( + origin.getKey(), + isNext ? 0 : origin.getChildrenSize(), + 'element', + ); } } @@ -343,16 +348,19 @@ export function $removeTextFromCaretRange( * @returns Either a deeper DepthNodeCaret or the given initialCaret */ function $getDeepestChildOrSelf< - D extends CaretDirection, - Caret extends PointNodeCaret | null, ->(initialCaret: Caret): DepthNodeCaret | Caret { - let caret: DepthNodeCaret | Caret = initialCaret; + Caret extends null | PointNodeCaret, +>( + initialCaret: Caret, +): DepthNodeCaret['direction']> | Caret { + let caret: + | DepthNodeCaret['direction']> + | Caret = initialCaret; while ($isDepthNodeCaret(caret)) { - const childNode = caret.getNodeAtCaret(); - if (!$isElementNode(childNode)) { + const adjacent = $getAdjacentDepthCaret(caret); + if (!$isDepthNodeCaret(adjacent)) { break; } - caret = $getDepthCaret(childNode, caret.direction); + caret = adjacent; } return caret; } @@ -373,18 +381,17 @@ function $getDeepestChildOrSelf< export function $normalizeCaret( initialCaret: PointNodeCaret, ): PointNodeCaret { - const caret = initialCaret.getLatest(); + const caret = $getDeepestChildOrSelf(initialCaret.getLatest()); const {direction} = caret; - if ($isTextNodeCaret(caret)) { - return caret; - } if ($isTextNode(caret.origin)) { - return $getTextNodeCaret(caret.origin, direction, direction); + return $isTextNodeCaret(caret) + ? caret + : $getTextNodeCaret(caret.origin, direction, direction); } - const adjacent = $getDeepestChildOrSelf(caret.getAdjacentCaret()); - return $isBreadthNodeCaret(adjacent) && $isTextNode(adjacent.origin) - ? $getTextNodeCaret(adjacent.origin, direction, flipDirection(direction)) - : $getDeepestChildOrSelf(caret); + const adj = caret.getAdjacentCaret(); + return $isBreadthNodeCaret(adj) && $isTextNode(adj.origin) + ? $getTextNodeCaret(adj.origin, direction, flipDirection(direction)) + : caret; } /** From 19e43f2c5137ed22ca9649525dfb79f4c2427522 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 31 Jan 2025 11:04:48 -0800 Subject: [PATCH 40/69] change link test selection normalization expectations --- .../__tests__/e2e/Links.spec.mjs | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Links.spec.mjs b/packages/lexical-playground/__tests__/e2e/Links.spec.mjs index ece9a617486..98fa7139d48 100644 --- a/packages/lexical-playground/__tests__/e2e/Links.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Links.spec.mjs @@ -1714,7 +1714,9 @@ test.describe.parallel('Links', () => { ); await assertSelection(page, { anchorOffset: 0, - anchorPath: [0, 1], + // Previous to #7046 NodeCaret the selection anchor would've been + // inside the tag but now it's normalized to the text + anchorPath: [0, 1, 0, 0], focusOffset: 5, focusPath: [0, 1, 0, 0], }); @@ -1739,21 +1741,14 @@ test.describe.parallel('Links', () => { `, ); - if (browserName === 'webkit') { - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 1, 0, 0], - focusOffset: 5, - focusPath: [0, 1, 0, 0], - }); - } else { - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [0, 1, 0, 0], - focusOffset: 5, - focusPath: [0, 1, 0, 0], - }); - } + // Previous to #7046 NodeCaret the selection anchor would've been + // inside the tag but now it's normalized to the text + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 1, 0, 0], + focusOffset: 5, + focusPath: [0, 1, 0, 0], + }); // unlink await click(page, '.link'); @@ -1819,21 +1814,12 @@ test.describe.parallel('Links', () => { `, ); - if (browserName === 'chromium') { - await assertSelection(page, { - anchorOffset: 5, - anchorPath: [0, 1, 0, 0], - focusOffset: 0, - focusPath: [0, 1], - }); - } else { - await assertSelection(page, { - anchorOffset: 5, - anchorPath: [0, 1, 0, 0], - focusOffset: 0, - focusPath: [0, 1], - }); - } + await assertSelection(page, { + anchorOffset: 5, + anchorPath: [0, 1, 0, 0], + focusOffset: 0, + focusPath: [0, 1, 0, 0], + }); await setURL(page, 'facebook.com'); From 8cf7d44db3bc3789f7bc826a8783b417f8db5773 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 31 Jan 2025 12:26:39 -0800 Subject: [PATCH 41/69] $ensureForwardRangeSelection --- packages/lexical-selection/src/index.ts | 41 ++++--------------- .../lexical-selection/src/lexical-node.ts | 37 +++++++++++++---- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/packages/lexical-selection/src/index.ts b/packages/lexical-selection/src/index.ts index e406206fd93..e5bad689aba 100644 --- a/packages/lexical-selection/src/index.ts +++ b/packages/lexical-selection/src/index.ts @@ -6,15 +6,18 @@ * */ -import { +import {$trimTextContentFromAnchor} from './lexical-node'; + +export { $addNodeStyle, + $ensureForwardRangeSelection, $forEachSelectedTextNode, $isAtNodeEnd, $patchStyleText, $sliceSelectedTextNodeContent, $trimTextContentFromAnchor, } from './lexical-node'; -import { +export { $copyBlockFormatIndent, $getSelectionStyleValueForProperty, $isParentElementRTL, @@ -24,43 +27,15 @@ import { $shouldOverrideDefaultCharacterSelection, $wrapNodes, } from './range-selection'; -import { +export { createDOMRange, createRectsFromDOMRange, getCSSFromStyleObject, getStyleObjectFromCSS, } from './utils'; - +/** @deprecated renamed to {@link $trimTextContentFromAnchor} by @lexical/eslint-plugin rules-of-lexical */ +export const trimTextContentFromAnchor = $trimTextContentFromAnchor; export { /** @deprecated moved to the lexical package */ $cloneWithProperties, /** @deprecated moved to the lexical package */ $selectAll, } from 'lexical'; - -export { - $addNodeStyle, - $forEachSelectedTextNode, - $isAtNodeEnd, - $patchStyleText, - $sliceSelectedTextNodeContent, - $trimTextContentFromAnchor, -}; -/** @deprecated renamed to {@link $trimTextContentFromAnchor} by @lexical/eslint-plugin rules-of-lexical */ -export const trimTextContentFromAnchor = $trimTextContentFromAnchor; - -export { - $copyBlockFormatIndent, - $getSelectionStyleValueForProperty, - $isParentElementRTL, - $moveCaretSelection, - $moveCharacter, - $setBlocksType, - $shouldOverrideDefaultCharacterSelection, - $wrapNodes, -}; - -export { - createDOMRange, - createRectsFromDOMRange, - getCSSFromStyleObject, - getStyleObjectFromCSS, -}; diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts index 5aa93ea2752..a5ee1d33698 100644 --- a/packages/lexical-selection/src/lexical-node.ts +++ b/packages/lexical-selection/src/lexical-node.ts @@ -264,7 +264,7 @@ function $patchStyle( } return styles; }, - {...prevStyles} || {}, + {...prevStyles}, ); const newCSSText = getCSSFromStyleObject(newStyles); target.setStyle(newCSSText); @@ -290,13 +290,17 @@ export function $patchStyleText( ) => string) >, ): void { - if (selection.isCollapsed() && $isRangeSelection(selection)) { - $patchStyle(selection, patch); - } else { - $forEachSelectedTextNode((textNode) => { - $patchStyle(textNode, patch); - }); + if ($isRangeSelection(selection)) { + // Prior to #7046 this would have been a side-effect of $patchStyle, + // so we do this for test compatibility + $ensureForwardRangeSelection(selection); + if (selection.isCollapsed()) { + return $patchStyle(selection, patch); + } } + $forEachSelectedTextNode((textNode) => { + $patchStyle(textNode, patch); + }); } export function $forEachSelectedTextNode( @@ -367,3 +371,22 @@ export function $forEachSelectedTextNode( } } } + +/** + * Ensure that the given RangeSelection is not backwards. If it + * is backwards, then the anchor and focus points will be swapped + * in-place. Ensuring that the selection is a writable RangeSelection + * is the responsibility of the caller (e.g. in a read-only context + * you will want to clone $getSelection() before using this). + * + * @param selection a writable RangeSelection + */ +export function $ensureForwardRangeSelection(selection: RangeSelection): void { + if (selection.isBackward()) { + const {anchor, focus} = selection; + // stash for the in-place swap + const {key, offset, type} = anchor; + anchor.set(focus.key, focus.offset, focus.type); + focus.set(key, offset, type); + } +} From 59e3c6730aac208359eb39afe93c77ef133f1a81 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 31 Jan 2025 14:28:51 -0800 Subject: [PATCH 42/69] Conditional forward range selection --- .../lexical-selection/src/lexical-node.ts | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts index a5ee1d33698..69f9822631b 100644 --- a/packages/lexical-selection/src/lexical-node.ts +++ b/packages/lexical-selection/src/lexical-node.ts @@ -241,7 +241,15 @@ export function $addNodeStyle(node: TextNode): void { CSS_TO_STYLES.set(CSSText, styles); } -function $patchStyle( +/** + * Applies the provided styles to the given TextNodes or collapsed RangeSelection. + * Will update partially selected TextNodes by splitting the TextNode and applying + * the styles to the appropriate one. + * + * @param target - The TextNode or collapsed RangeSelection to apply the styles to + * @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value. + */ +export function $patchStyle( target: TextNode | RangeSelection, patch: Record< string, @@ -250,8 +258,12 @@ function $patchStyle( | ((currentStyleValue: string | null, _target: typeof target) => string) >, ): void { + invariant( + target instanceof TextNode || target.isCollapsed(), + '$patchStyle must only be called with a TextNode or collapsed RangeSelection', + ); const prevStyles = getStyleObjectFromCSS( - 'getStyle' in target ? target.getStyle() : target.style, + target instanceof TextNode ? target.getStyle() : target.style, ); const newStyles = Object.entries(patch).reduce>( (styles, [key, value]) => { @@ -290,13 +302,8 @@ export function $patchStyleText( ) => string) >, ): void { - if ($isRangeSelection(selection)) { - // Prior to #7046 this would have been a side-effect of $patchStyle, - // so we do this for test compatibility - $ensureForwardRangeSelection(selection); - if (selection.isCollapsed()) { - return $patchStyle(selection, patch); - } + if ($isRangeSelection(selection) && selection.isCollapsed()) { + return $patchStyle(selection, patch); } $forEachSelectedTextNode((textNode) => { $patchStyle(textNode, patch); @@ -370,6 +377,17 @@ export function $forEachSelectedTextNode( fn(replacement); } } + // Prior to NodeCaret #7046 this would have been a side-effect + // so we do this for test compatibility. + // TODO: we may want to consider simplifying by removing this + if ( + $isRangeSelection(selection) && + selection.anchor.type === 'text' && + selection.focus.type === 'text' && + selection.anchor.key === selection.focus.key + ) { + $ensureForwardRangeSelection(selection); + } } /** From 5a7c8c7340d55d500a91dd7c0f53bec5f3c5b087 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 31 Jan 2025 19:19:14 -0800 Subject: [PATCH 43/69] renames --- .../lexical-utils/flow/LexicalUtils.js.flow | 2 +- packages/lexical-utils/src/index.ts | 13 +- packages/lexical/src/caret/LexicalCaret.ts | 213 +++++++++--------- .../lexical/src/caret/LexicalCaretUtils.ts | 74 +++--- .../caret/__tests__/unit/LexicalCaret.test.ts | 150 +++++------- packages/lexical/src/index.ts | 14 +- 6 files changed, 220 insertions(+), 246 deletions(-) diff --git a/packages/lexical-utils/flow/LexicalUtils.js.flow b/packages/lexical-utils/flow/LexicalUtils.js.flow index 958dd8acfa7..c6b0dd95a36 100644 --- a/packages/lexical-utils/flow/LexicalUtils.js.flow +++ b/packages/lexical-utils/flow/LexicalUtils.js.flow @@ -47,7 +47,7 @@ declare export function $dfsIterator( declare export function $getNextSiblingOrParentSibling( node: LexicalNode, ): null | [LexicalNode, number]; -declare export function $getDepth(node: LexicalNode): number; +declare export function $getDepth(node: null | LexicalNode): number; declare export function $getNextRightPreorderNode( startingNode: LexicalNode, ): LexicalNode | null; diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 88140b20220..63128635dd2 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -290,14 +290,15 @@ export function $getNextSiblingOrParentSibling( return rval && [rval[0].origin, rval[1]]; } -export function $getDepth(node: LexicalNode): number { - let innerNode: LexicalNode | null = node; - let depth = 0; - - while ((innerNode = innerNode.getParent()) !== null) { +export function $getDepth(node: null | LexicalNode): number { + let depth = -1; + for ( + let innerNode = node; + innerNode !== null; + innerNode = innerNode.getParent() + ) { depth++; } - return depth; } diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index f6d4ec11c11..932830f7a55 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -56,8 +56,8 @@ export interface BaseNodeCaret< /** * Retun true if other is a caret with the same origin (by node key comparion), type, and direction. * - * Note that this will not check the offset of a TextNodeCaret because it is otherwise indistinguishable - * from a BreadthNodeCaret. Use {@link $isSameTextNodeCaret} for that specific scenario. + * Note that this will not check the offset of a TextPointCaret because it is otherwise indistinguishable + * from a BreadthNodeCaret. Use {@link $isSameTextPointCaret} for that specific scenario. */ is: (other: NodeCaret | null) => boolean; /** @@ -74,8 +74,8 @@ export interface BaseNodeCaret< * ``` */ getFlipped: () => NodeCaret>; - /** Get the ElementNode that is the logical parent (`origin` for `DepthNodeCaret`, `origin.getParentOrThrow()` for `BreadthNodeCaret`) */ - getParentAtCaret: () => ElementNode; + /** Get the ElementNode that is the logical parent (`origin` for `DepthNodeCaret`, `origin.getParent()` for `BreadthNodeCaret`) */ + getParentAtCaret: () => null | ElementNode; /** Get the node connected to the origin in the caret's direction, or null if there is no node */ getNodeAtCaret: () => null | LexicalNode; /** Get a new BreadthNodeCaret from getNodeAtCaret() in the same direction. This is used for traversals, but only goes in the breadth (sibling) direction. */ @@ -116,27 +116,29 @@ export interface NodeCaretRange /** Return true if anchor and focus are the same caret */ isCollapsed: () => boolean; /** - * Iterate the carets between anchor and focus in a pre-order fashion + * Iterate the carets between anchor and focus in a pre-order fashion, node + * that this does not include any text slices represented by the anchor and/or + * focus. Those are accessed separately from getTextSlices. */ - internalCarets: (rootMode: RootMode) => IterableIterator>; + iterNodeCarets: (rootMode: RootMode) => IterableIterator>; /** * There are between zero and two non-empty TextSliceCarets for a * NodeCaretRange. Non-empty is defined by indexEnd > indexStart * (some text will be in the slice). * - * 0: Neither anchor nor focus are non-empty TextNodeCarets - * 1: One of anchor or focus are non-empty TextNodeCaret, or of the same origin - * 2: Anchor and focus are both non-empty TextNodeCaret of different origin + * 0: Neither anchor nor focus are non-empty TextPointCarets + * 1: One of anchor or focus are non-empty TextPointCaret, or of the same origin + * 2: Anchor and focus are both non-empty TextPointCaret of different origin */ - getNonEmptyTextSlices: () => TextNodeCaretSliceTuple; + getNonEmptyTextSlices: () => TextPointCaretSliceTuple; /** * There are between zero and two TextSliceCarets for a NodeCaretRange * - * 0: Neither anchor nor focus are TextNodeCarets - * 1: One of anchor or focus are TextNodeCaret, or of the same origin - * 2: Anchor and focus are both TextNodeCaret of different origin + * 0: Neither anchor nor focus are TextPointCarets + * 1: One of anchor or focus are TextPointCaret, or of the same origin + * 2: Anchor and focus are both TextPointCaret of different origin */ - getTextSlices: () => TextNodeCaretSliceTuple; + getTextSlices: () => TextPointCaretSliceTuple; } export interface StepwiseIteratorConfig { @@ -170,14 +172,14 @@ export type NodeCaret = /** * A PointNodeCaret is a NodeCaret that also includes a specialized - * TextNodeCaret type which refers to a specific offset of a TextNode. + * TextPointCaret type which refers to a specific offset of a TextNode. * This type is separate because it is not relevant to general node traversal * so it doesn't make sense to have it show up except when defining * a NodeCaretRange and in those cases there will be at most two of them only * at the boundaries. */ export type PointNodeCaret = - | TextNodeCaret + | TextPointCaret | BreadthNodeCaret | DepthNodeCaret; @@ -220,7 +222,7 @@ export interface DepthNodeCaret< } /** - * A TextNodeCaret is a special case of a BreadthNodeCaret that also carries + * A TextPointCaret is a special case of a BreadthNodeCaret that also carries * an offset used for representing partially selected TextNode at the edges * of a NodeCaretRange. * @@ -230,22 +232,22 @@ export interface DepthNodeCaret< * * While this can be used in place of any BreadthNodeCaret of a TextNode, * the offset into the text will be ignored except in contexts that - * specifically use the TextNodeCaret or PointNodeCaret types. + * specifically use the TextPointCaret or PointNodeCaret types. */ -export interface TextNodeCaret< +export interface TextPointCaret< T extends TextNode = TextNode, D extends CaretDirection = CaretDirection, > extends BreadthNodeCaret { /** Get a new caret with the latest origin pointer */ - getLatest: () => TextNodeCaret; + getLatest: () => TextPointCaret; readonly offset: number; } /** - * A TextNodeCaretSlice is a wrapper for a TextNodeCaret that carries a signed - * size representing the direction and amount of text selected from the given - * caret. A negative size means that text before offset is selected, a - * positive size means that text after offset is selected. The offset+size + * A TextPointCaretSlice is a wrapper for a TextPointCaret that carries a signed + * distance representing the direction and amount of text selected from the given + * caret. A negative distance means that text before offset is selected, a + * positive distance means that text after offset is selected. The offset+distance * pair is not affected in any way by the direction of the caret. * * The selected string content can be computed as such @@ -253,25 +255,25 @@ export interface TextNodeCaret< * * ``` * slice.origin.getTextContent().slice( - * Math.min(slice.offset, slice.offset + slice.size), - * Math.max(slice.offset, slice.offset + slice.size), + * Math.min(slice.offset, slice.offset + slice.distance), + * Math.max(slice.offset, slice.offset + slice.distance), * ) * ``` */ -export interface TextNodeCaretSlice< +export interface TextPointCaretSlice< T extends TextNode = TextNode, D extends CaretDirection = CaretDirection, > { - readonly caret: TextNodeCaret; - readonly size: number; + readonly caret: TextPointCaret; + readonly distance: number; } /** * A utility type to specify that a NodeCaretRange may have zero, - * one, or two associated TextNodeCaretSlice. + * one, or two associated TextPointCaretSlice. */ -export type TextNodeCaretSliceTuple = - readonly TextNodeCaretSlice[] & {length: 0 | 1 | 2}; +export type TextPointCaretSliceTuple = + readonly TextPointCaretSlice[] & {length: 0 | 1 | 2}; abstract class AbstractCaret< T extends LexicalNode, @@ -285,7 +287,7 @@ abstract class AbstractCaret< abstract getNodeAtCaret(): null | LexicalNode; abstract insert(node: LexicalNode): this; abstract getFlipped(): NodeCaret>; - abstract getParentAtCaret(): ElementNode; + abstract getParentAtCaret(): null | ElementNode; constructor(origin: T) { this.origin = origin; } @@ -354,21 +356,25 @@ abstract class AbstractCaret< if (nodesToRemove.size > 0) { // TODO: For some reason `npm run tsc-extension` needs this annotation? const target: null | LexicalNode = caret.getNodeAtCaret(); - invariant( - target !== null, - 'NodeCaret.splice: Underflow of expected nodesToRemove during splice (keys: %s)', - Array.from(nodesToRemove).join(' '), - ); - nodesToRemove.delete(target.getKey()); - nodesToRemove.delete(node.getKey()); - if (target.is(node) || caret.origin.is(node)) { - // do nothing, it's already in the right place - } else { - if (parent.is(node.getParent())) { - // It's a sibling somewhere else in this node, so unparent it first - node.remove(); + if (target) { + nodesToRemove.delete(target.getKey()); + nodesToRemove.delete(node.getKey()); + if (target.is(node) || caret.origin.is(node)) { + // do nothing, it's already in the right place + } else { + const nodeParent = node.getParent(); + if (nodeParent && nodeParent.is(parent)) { + // It's a sibling somewhere else in this node, so unparent it first + node.remove(); + } + target.replace(node); } - target.replace(node); + } else { + invariant( + target !== null, + 'NodeCaret.splice: Underflow of expected nodesToRemove during splice (keys: %s)', + Array.from(nodesToRemove).join(' '), + ); } } else { caret.insert(node); @@ -487,7 +493,7 @@ abstract class AbstractBreadthNodeCaret< implements BreadthNodeCaret { readonly type = 'breadth'; - // TextNodeCaret + // TextPointCaret offset?: number; getLatest(): BreadthNodeCaret { const origin = this.origin.getLatest(); @@ -495,9 +501,8 @@ abstract class AbstractBreadthNodeCaret< ? this : $getBreadthCaret(origin, this.direction); } - - getParentAtCaret(): ElementNode { - return this.origin.getParentOrThrow(); + getParentAtCaret(): null | ElementNode { + return this.origin.getParent(); } getChildCaret(): DepthNodeCaret | null { return $isElementNode(this.origin) @@ -520,14 +525,14 @@ abstract class AbstractBreadthNodeCaret< } /** - * Guard to check if the given caret is specifically a TextNodeCaret + * Guard to check if the given caret is specifically a TextPointCaret * * @param caret Any caret - * @returns true if it is a TextNodeCaret + * @returns true if it is a TextPointCaret */ -export function $isTextNodeCaret( +export function $isTextPointCaret( caret: null | undefined | PointNodeCaret, -): caret is TextNodeCaret { +): caret is TextPointCaret { return ( caret instanceof AbstractBreadthNodeCaret && $isTextNode(caret.origin) && @@ -536,16 +541,16 @@ export function $isTextNodeCaret( } /** - * Guard to check the equivalence of TextNodeCaret + * Guard to check the equivalence of TextPointCaret * - * @param a The caret known to be a TextNodeCaret + * @param a The caret known to be a TextPointCaret * @param b Any caret - * @returns true if b is a TextNodeCaret with the same origin, direction and offset as a + * @returns true if b is a TextPointCaret with the same origin, direction and offset as a */ -export function $isSameTextNodeCaret< - T extends TextNodeCaret, +export function $isSameTextPointCaret< + T extends TextPointCaret, >(a: T, b: null | undefined | PointNodeCaret): b is T { - return $isTextNodeCaret(b) && a.is(b) && a.offset === b.offset; + return $isTextPointCaret(b) && a.is(b) && a.offset === b.offset; } /** @@ -561,7 +566,7 @@ export function $isNodeCaret( } /** - * Guard to check if the given argument is specifically a BreadthNodeCaret (or TextNodeCaret) + * Guard to check if the given argument is specifically a BreadthNodeCaret (or TextPointCaret) * * @param caret * @returns true if caret is a BreadthNodeCaret @@ -642,19 +647,20 @@ export function $getBreadthCaret( return origin ? new BREADTH_CTOR[direction](origin) : null; } -function $getLatestTextNodeCaret( - this: TextNodeCaret, -): TextNodeCaret { +function $getLatestTextPointCaret( + this: TextPointCaret, +): TextPointCaret { const origin = this.origin.getLatest(); return origin === this.origin ? this - : $getTextNodeCaret(origin, this.direction, this.offset); + : $getTextPointCaret(origin, this.direction, this.offset); } -function $getFlippedTextNodeCaret( - this: TextNodeCaret, -): TextNodeCaret> { - return $getTextNodeCaret( +function $getFlippedTextPointCaret< + T extends TextNode, + D extends CaretDirection, +>(this: TextPointCaret): TextPointCaret> { + return $getTextPointCaret( this.origin, flipDirection(this.direction), this.offset, @@ -662,21 +668,24 @@ function $getFlippedTextNodeCaret( } /** - * Construct a TextNodeCaret + * Construct a TextPointCaret * * @param origin The TextNode * @param direction The direction (next points to the end of the text, previous points to the beginning) * @param offset The offset into the text in absolute positive string coordinates (0 is the start) - * @returns a TextNodeCaret + * @returns a TextPointCaret */ -export function $getTextNodeCaret( +export function $getTextPointCaret< + T extends TextNode, + D extends CaretDirection, +>( origin: T, direction: D, offset: number | CaretDirection, -): TextNodeCaret { +): TextPointCaret { return Object.assign($getBreadthCaret(origin, direction), { - getFlipped: $getFlippedTextNodeCaret, - getLatest: $getLatestTextNodeCaret, + getFlipped: $getFlippedTextPointCaret, + getLatest: $getLatestTextPointCaret, offset: $getTextNodeOffset(origin, offset), }); } @@ -699,7 +708,7 @@ export function $getTextNodeOffset( offset === 'next' ? size : offset === 'previous' ? 0 : offset; invariant( numericOffset >= 0 && numericOffset <= size, - '$getTextNodeCaret: invalid offset %s for size %s', + '$getTextPointCaret: invalid offset %s for size %s', String(offset), String(size), ); @@ -707,21 +716,21 @@ export function $getTextNodeOffset( } /** - * Construct a TextNodeCaretSlice given a TextNodeCaret and a signed size. The - * size should be negative to slice text before the caret's offset, and positive + * Construct a TextPointCaretSlice given a TextPointCaret and a signed distance. The + * distance should be negative to slice text before the caret's offset, and positive * to slice text after the offset. The direction of the caret itself is not - * relevant to the string coordinates when working with a TextNodeCaretSlice + * relevant to the string coordinates when working with a TextPointCaretSlice * but mutation operations will preserve the direction. * * @param caret - * @param size - * @returns TextNodeCaretSlice + * @param distance + * @returns TextPointCaretSlice */ -export function $getTextNodeCaretSlice< +export function $getTextPointCaretSlice< T extends TextNode, D extends CaretDirection, ->(caret: TextNodeCaret, size: number): TextNodeCaretSlice { - return {caret, size}; +>(caret: TextPointCaret, distance: number): TextPointCaretSlice { + return {caret, distance}; } /** @@ -792,39 +801,39 @@ class NodeCaretRangeImpl return ( this.anchor.is(this.focus) && !( - $isTextNodeCaret(this.anchor) && - !$isSameTextNodeCaret(this.anchor, this.focus) + $isTextPointCaret(this.anchor) && + !$isSameTextPointCaret(this.anchor, this.focus) ) ); } - getNonEmptyTextSlices(): TextNodeCaretSliceTuple { + getNonEmptyTextSlices(): TextPointCaretSliceTuple { return this.getTextSlices().filter( - (slice) => slice.size !== 0, - ) as TextNodeCaretSliceTuple; + (slice) => slice.distance !== 0, + ) as TextPointCaretSliceTuple; } - getTextSlices(): TextNodeCaretSliceTuple { + getTextSlices(): TextPointCaretSliceTuple { const slices = (['anchor', 'focus'] as const).flatMap((k) => { const caret = this[k]; - return $isTextNodeCaret(caret) - ? [$getSliceFromTextNodeCaret(caret, k)] + return $isTextPointCaret(caret) + ? [$getSliceFromTextPointCaret(caret, k)] : []; }); if (slices.length === 2) { const [{caret: anchorCaret}, {caret: focusCaret}] = slices; if (anchorCaret.is(focusCaret)) { return [ - $getTextNodeCaretSlice( + $getTextPointCaretSlice( anchorCaret, focusCaret.offset - anchorCaret.offset, ), ]; } } - return slices as TextNodeCaretSliceTuple; + return slices as TextPointCaretSliceTuple; } - internalCarets(rootMode: RootMode): IterableIterator> { + iterNodeCarets(rootMode: RootMode): IterableIterator> { const {anchor, focus} = this; - const isTextFocus = $isTextNodeCaret(focus); + const isTextFocus = $isTextPointCaret(focus); const step = (state: NodeCaret) => state.is(focus) ? null @@ -838,23 +847,23 @@ class NodeCaretRangeImpl }); } [Symbol.iterator](): IterableIterator> { - return this.internalCarets('root'); + return this.iterNodeCarets('root'); } } -function $getSliceFromTextNodeCaret< +function $getSliceFromTextPointCaret< T extends TextNode, D extends CaretDirection, >( - caret: TextNodeCaret, + caret: TextPointCaret, anchorOrFocus: 'anchor' | 'focus', -): TextNodeCaretSlice { +): TextPointCaretSlice { const {direction, origin} = caret; const offsetB = $getTextNodeOffset( origin, anchorOrFocus === 'focus' ? flipDirection(direction) : direction, ); - return {caret, size: offsetB - caret.offset}; + return {caret, distance: offsetB - caret.offset}; } /** diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 737697e576f..ab591b5eb2a 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -14,8 +14,8 @@ import type { NodeCaretRange, PointNodeCaret, RootMode, - TextNodeCaret, - TextNodeCaretSlice, + TextPointCaret, + TextPointCaretSlice, } from './LexicalCaret'; import invariant from 'shared/invariant'; @@ -44,11 +44,11 @@ import { $getBreadthCaret, $getCaretRange, $getDepthCaret, - $getTextNodeCaret, $getTextNodeOffset, + $getTextPointCaret, $isBreadthNodeCaret, $isDepthNodeCaret, - $isTextNodeCaret, + $isTextPointCaret, flipDirection, } from './LexicalCaret'; @@ -69,7 +69,7 @@ export function $caretFromPoint( node.getType(), key, ); - return $getTextNodeCaret(node, direction, offset); + return $getTextPointCaret(node, direction, offset); } invariant( $isElementNode(node), @@ -96,7 +96,7 @@ export function $setPointFromCaret( if ($isTextNode(origin)) { point.set( origin.getKey(), - $isTextNodeCaret(caret) + $isTextPointCaret(caret) ? caret.offset : $getTextNodeOffset(origin, direction), 'text', @@ -238,23 +238,23 @@ export function $removeTextFromCaretRange( ); // Mark the start of each ElementNode - const canRemove = new Set(); - // Mark the end of each ElementNode - const canExpand = new Set(); - // Remove all internal nodes - for (const caret of range.internalCarets(rootMode)) { + const seenStart = new Set(); + // Queue removals since removing the only child can cascade to having + // a parent remove itself which will affect iteration + const removedNodes: LexicalNode[] = []; + for (const caret of range.iterNodeCarets(rootMode)) { if ($isDepthNodeCaret(caret)) { - canRemove.add(caret.origin.getKey()); + seenStart.add(caret.origin.getKey()); } else if ($isBreadthNodeCaret(caret)) { const {origin} = caret; - if ($isElementNode(origin) && !canRemove.has(origin.getKey())) { - // The anchor is inside this element - canExpand.add(origin.getKey()); - } else { - origin.remove(); + if (!$isElementNode(origin) || seenStart.has(origin.getKey())) { + removedNodes.push(origin); } } } + for (const node of removedNodes) { + node.remove(); + } // Splice text at the anchor and/or origin. // If the text is entirely selected then it is removed. @@ -269,12 +269,12 @@ export function $removeTextFromCaretRange( ); const mode = origin.getMode(); if ( - Math.abs(slice.size) === contentSize || - (mode === 'token' && slice.size !== 0) + Math.abs(slice.distance) === contentSize || + (mode === 'token' && slice.distance !== 0) ) { // anchorCandidates[1] should still be valid, it is caretBefore caretBefore.remove(); - } else if (slice.size !== 0) { + } else if (slice.distance !== 0) { let nextCaret = $removeTextSlice(slice); if (mode === 'segmented') { const src = nextCaret.origin; @@ -282,7 +282,7 @@ export function $removeTextFromCaretRange( .setStyle(src.getStyle()) .setFormat(src.getFormat()); caretBefore.replaceOrInsert(plainTextNode); - nextCaret = $getTextNodeCaret( + nextCaret = $getTextPointCaret( plainTextNode, nextDirection, nextCaret.offset, @@ -311,7 +311,7 @@ export function $removeTextFromCaretRange( focusCandidate && $getAncestor(focusCandidate.origin, INTERNAL_$isBlock); if ( $isElementNode(focusBlock) && - canRemove.has(focusBlock.getKey()) && + seenStart.has(focusBlock.getKey()) && $isElementNode(anchorBlock) ) { // always merge blocks later in the document with @@ -367,12 +367,12 @@ function $getDeepestChildOrSelf< /** * Normalize a caret to the deepest equivalent PointNodeCaret. - * This will return a TextNodeCaret with the offset set according + * This will return a TextPointCaret with the offset set according * to the direction if given a caret with a TextNode origin * or a caret with an ElementNode origin with the deepest DepthNode * having an adjacent TextNode. * - * If given a TextNodeCaret, it will be returned, as no normalization + * If given a TextPointCaret, it will be returned, as no normalization * is required when an offset is already present. * * @param initialCaret @@ -384,28 +384,28 @@ export function $normalizeCaret( const caret = $getDeepestChildOrSelf(initialCaret.getLatest()); const {direction} = caret; if ($isTextNode(caret.origin)) { - return $isTextNodeCaret(caret) + return $isTextPointCaret(caret) ? caret - : $getTextNodeCaret(caret.origin, direction, direction); + : $getTextPointCaret(caret.origin, direction, direction); } const adj = caret.getAdjacentCaret(); return $isBreadthNodeCaret(adj) && $isTextNode(adj.origin) - ? $getTextNodeCaret(adj.origin, direction, flipDirection(direction)) + ? $getTextPointCaret(adj.origin, direction, flipDirection(direction)) : caret; } /** - * @param slice a TextNodeCaretSlice + * @param slice a TextPointCaretSlice * @returns absolute coordinates into the text (for use with text.slice(...)) */ function $getTextSliceIndices( - slice: TextNodeCaretSlice, + slice: TextPointCaretSlice, ): [indexStart: number, indexEnd: number] { const { - size, + distance, caret: {offset}, } = slice; - const offsetB = offset + size; + const offsetB = offset + distance; return offsetB < offset ? [offsetB, offset] : [offset, offsetB]; } @@ -453,25 +453,25 @@ export function $getCaretRangeInDirection( /** * Remove the slice of text from the contained caret, returning a new - * TextNodeCaret without the wrapper (since the size would be zero). + * TextPointCaret without the wrapper (since the size would be zero). * * Note that this is a lower-level utility that does not have any specific * behavior for 'segmented' or 'token' modes and it will not remove * an empty TextNode. * * @param slice The slice to mutate - * @returns The inner TextNodeCaret with the same offset and direction + * @returns The inner TextPointCaret with the same offset and direction * and the latest TextNode origin after mutation */ export function $removeTextSlice( - slice: TextNodeCaretSlice, -): TextNodeCaret { + slice: TextPointCaretSlice, +): TextPointCaret { const { caret: {origin, direction}, } = slice; const [indexStart, indexEnd] = $getTextSliceIndices(slice); const text = origin.getTextContent(); - return $getTextNodeCaret( + return $getTextPointCaret( origin.setTextContent(text.slice(0, indexStart) + text.slice(indexEnd)), direction, indexStart, @@ -485,7 +485,7 @@ export function $removeTextSlice( * @returns The text represented by the slice */ export function $getTextSliceContent( - slice: TextNodeCaretSlice, + slice: TextPointCaretSlice, ): string { return slice.caret.origin .getTextContent() diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index e877f618ec1..0d799337594 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -24,10 +24,10 @@ import { $getDepthCaret, $getRoot, $getSelection, - $getTextNodeCaret, + $getTextPointCaret, $getTextSliceContent, $isTextNode, - $isTextNodeCaret, + $isTextPointCaret, $removeTextFromCaretRange, $rewindBreadthCaret, $selectAll, @@ -446,12 +446,12 @@ describe('LexicalCaret', () => { const range = $caretRangeFromSelection(selection); expect(range.isCollapsed()).toBe(true); invariant( - $isTextNodeCaret(range.anchor), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', ); invariant( - $isTextNodeCaret(range.focus), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', ); expect(range).toMatchObject({ anchor: { @@ -471,11 +471,11 @@ describe('LexicalCaret', () => { origin: node, type: 'breadth', }, - size: 0, + distance: 0, }, ]); expect(range.getNonEmptyTextSlices()).toEqual([]); - expect([...range.internalCarets('root')]).toEqual([]); + expect([...range.iterNodeCarets('root')]).toEqual([]); } }); }); @@ -500,12 +500,12 @@ describe('LexicalCaret', () => { }); const range = $caretRangeFromSelection(selection); invariant( - $isTextNodeCaret(range.anchor), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', ); invariant( - $isTextNodeCaret(range.focus), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', ); expect(range).toMatchObject({ anchor: {direction, offset: anchorOffset, origin: node}, @@ -519,10 +519,10 @@ describe('LexicalCaret', () => { offset: anchorOffset, origin: node, }, - size: focusOffset - anchorOffset, + distance: focusOffset - anchorOffset, }, ]); - expect([...range.internalCarets('root')]).toEqual([]); + expect([...range.iterNodeCarets('root')]).toEqual([]); expect(range.isCollapsed()).toBe(false); } }); @@ -556,18 +556,18 @@ describe('LexicalCaret', () => { : node.select(indexEnd, indexStart); const range = $caretRangeFromSelection(selection); invariant( - $isTextNodeCaret(range.anchor), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', ); invariant( - $isTextNodeCaret(range.focus), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', ); expect(range.direction).toBe(direction); expect(range.getTextSlices()).toMatchObject([ - {caret: {direction, offset, origin: node}, size}, + {caret: {direction, offset, origin: node}, distance: size}, ]); - expect([...range.internalCarets('root')]).toMatchObject([]); + expect([...range.iterNodeCarets('root')]).toMatchObject([]); } } } @@ -617,12 +617,12 @@ describe('LexicalCaret', () => { ); const range = $caretRangeFromSelection(selection); invariant( - $isTextNodeCaret(range.anchor), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', ); invariant( - $isTextNodeCaret(range.focus), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', ); expect(range.direction).toBe(direction); const textSliceCarets = range.getTextSlices(); @@ -635,7 +635,7 @@ describe('LexicalCaret', () => { origin: anchorNode, type: 'breadth', }, - size: + distance: direction === 'next' ? anchorNode.getTextContentSize() - anchorOffset : 0 - anchorOffset, @@ -647,12 +647,12 @@ describe('LexicalCaret', () => { origin: focusNode, type: 'breadth', }, - size: + distance: direction === 'next' ? 0 - focusOffset : focusNode.getTextContentSize() - focusOffset, }); - expect([...range.internalCarets('root')]).toMatchObject( + expect([...range.iterNodeCarets('root')]).toMatchObject( textNodes .slice(indexNodeStart + 1, indexNodeEnd) .map((origin) => ({ @@ -955,12 +955,12 @@ describe('LexicalCaret', () => { const range = $caretRangeFromSelection(selection); expect(range.isCollapsed()).toBe(true); invariant( - $isTextNodeCaret(range.anchor), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', ); invariant( - $isTextNodeCaret(range.focus), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', ); const originalRangeMatch = { anchor: { @@ -981,11 +981,11 @@ describe('LexicalCaret', () => { origin: node, type: 'breadth', }, - size: 0, + distance: 0, }, ]); expect(range.getNonEmptyTextSlices()).toEqual([]); - expect([...range.internalCarets('root')]).toEqual([]); + expect([...range.iterNodeCarets('root')]).toEqual([]); expect($removeTextFromCaretRange(range)).toMatchObject( originalRangeMatch, ); @@ -1013,12 +1013,12 @@ describe('LexicalCaret', () => { }); const range = $caretRangeFromSelection(selection); invariant( - $isTextNodeCaret(range.anchor), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.anchor), + '$isTextPointCaret(range.anchor)', ); invariant( - $isTextNodeCaret(range.focus), - '$isTextNodeCaret(range.anchor)', + $isTextPointCaret(range.focus), + '$isTextPointCaret(range.anchor)', ); expect(range).toMatchObject({ anchor: {offset: anchorOffset, origin: node}, @@ -1032,12 +1032,12 @@ describe('LexicalCaret', () => { direction === 'next' ? 0 : node.getTextContentSize(), origin: node, }, - size: + distance: (direction === 'next' ? 1 : -1) * node.getTextContentSize(), }, ]); - expect([...range.internalCarets('root')]).toEqual([]); + expect([...range.iterNodeCarets('root')]).toEqual([]); expect(range.isCollapsed()).toBe(false); const resultRange = $removeTextFromCaretRange(range); $setSelection(null); @@ -1170,15 +1170,15 @@ describe('LexicalCaret', () => { } const range = $caretRangeFromSelection(selection); expect(range.isCollapsed()).toBe(false); - expect([...range.internalCarets('root')].length).toBe( + expect([...range.iterNodeCarets('root')].length).toBe( anchorBias === 'outside' && focusBias === 'outside' ? 1 : 0, ); expect(range.getNonEmptyTextSlices()).toMatchObject( anchorBias === 'outside' && focusBias === 'outside' ? [] : (anchorBias === 'inside') === (direction === 'next') - ? [{caret: {offset: 0}, size}] - : [{caret: {offset: size}, size: -size}], + ? [{caret: {offset: 0}, distance: size}] + : [{caret: {offset: size}, distance: -size}], ); const resultRange = $removeTextFromCaretRange(range); $setSelection(null); @@ -1234,14 +1234,14 @@ describe('LexicalCaret', () => { const node = originalNodes[i]; invariant($isTextNode(node), `Missing TextNode 0`); const size = node.getTextContentSize(); - const anchor = $getTextNodeCaret( + const anchor = $getTextPointCaret( node, direction, direction === 'next' ? anchorEdgeOffset : size - anchorEdgeOffset, ); - const focus = $getTextNodeCaret( + const focus = $getTextPointCaret( node, direction, direction === 'next' @@ -1254,10 +1254,10 @@ describe('LexicalCaret', () => { ].sort((a, b) => a - b); const range = $getCaretRange(anchor, focus); const slices = range.getNonEmptyTextSlices(); - expect([...range.internalCarets('root')]).toEqual([]); + expect([...range.iterNodeCarets('root')]).toEqual([]); expect(slices.length).toBe(1); const [slice] = slices; - expect(slice.size).toBe( + expect(slice.distance).toBe( (direction === 'next' ? 1 : -1) * (size - anchorEdgeOffset - focusEdgeOffset), ); @@ -1307,12 +1307,12 @@ describe('LexicalCaret', () => { invariant($isTextNode(startNode), 'text node'); invariant($isTextNode(endNode), 'text node'); expect(startNode.isBefore(endNode)).toBe(true); - const startCaret = $getTextNodeCaret( + const startCaret = $getTextPointCaret( startNode, direction, startFn(startNode.getTextContentSize()), ); - const endCaret = $getTextNodeCaret( + const endCaret = $getTextPointCaret( endNode, direction, endFn(endNode.getTextContentSize()), @@ -1322,7 +1322,7 @@ describe('LexicalCaret', () => { ? [startCaret, endCaret] : [endCaret, startCaret]; const range = $getCaretRange(anchor, focus); - expect([...range.internalCarets('root')]).toHaveLength( + expect([...range.iterNodeCarets('root')]).toHaveLength( Math.max(0, nodeIndexEnd - nodeIndexStart - 1), ); const slices = range.getTextSlices(); @@ -1481,12 +1481,12 @@ describe('LexicalCaret', () => { invariant($isTextNode(startNode), 'text node'); invariant($isTextNode(endNode), 'text node'); expect(startNode.isBefore(endNode)).toBe(true); - const startCaret = $getTextNodeCaret( + const startCaret = $getTextPointCaret( startNode, direction, startFn(startNode.getTextContentSize()), ); - const endCaret = $getTextNodeCaret( + const endCaret = $getTextPointCaret( endNode, direction, endFn(endNode.getTextContentSize()), @@ -1497,7 +1497,7 @@ describe('LexicalCaret', () => { : [endCaret, startCaret]; const range = $getCaretRange(anchor, focus); // TODO compute the expected internal carets - // expect([...range.internalCarets('root')]).toHaveLength( + // expect([...range.iterNodeCarets('root')]).toHaveLength( // Math.max(0, nodeIndexEnd - nodeIndexStart - 1), // ); const slices = range.getTextSlices(); @@ -1521,8 +1521,8 @@ describe('LexicalCaret', () => { .slice(startCaret.offset), ], ); - const originalStartParent = startCaret.getParentAtCaret(); - const originalEndParent = endCaret.getParentAtCaret(); + const originalStartParent = startCaret.getParentAtCaret()!; + const originalEndParent = endCaret.getParentAtCaret()!; const resultRange = $removeTextFromCaretRange(range); if (direction === 'next') { if (anchor.offset !== 0) { @@ -1558,45 +1558,9 @@ describe('LexicalCaret', () => { direction, }); } - } else { - return; - invariant(direction === 'previous', 'exhaustiveness check'); - if (anchor.offset !== texts[nodeIndexEnd].length) { - // Part of the anchor remains - expect(resultRange).toMatchObject({ - anchor: { - direction, - offset: 0, - origin: anchor.origin.getLatest(), - }, - direction, - }); - } else if (focus.offset !== 0) { - // The focus was not removed - // so the new anchor will be set to the focus origin - expect(resultRange).toMatchObject({ - anchor: { - direction, - offset: focus.offset, - origin: focus.origin.getLatest(), - }, - direction, - }); - } else { - // All text has been removed so we have to use a depth caret - // at the anchor paragraph - expect(resultRange).toMatchObject({ - anchor: { - direction, - origin: originalStartParent.getLatest(), - type: 'depth', - }, - direction, - }); - } } // Check that the containing block is always that of the anchor - expect(resultRange.anchor.getParentAtCaret().getLatest()).toBe( + expect(resultRange.anchor.getParentAtCaret()!.getLatest()).toBe( originalStartParent.getLatest(), ); // Check that the focus parent has always been removed @@ -1675,8 +1639,8 @@ describe('LexicalSelectionHelpers', () => { paragraph.append(text); const range = $getCaretRange( - $getTextNodeCaret(text, 'next', 0), - $getTextNodeCaret(text, 'next', 'next'), + $getTextPointCaret(text, 'next', 0), + $getTextPointCaret(text, 'next', 'next'), ); const newRange = $removeTextFromCaretRange(range); expect(newRange).toMatchObject({ diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 222e0b1b5fc..256f1528fb5 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -18,9 +18,9 @@ export type { PointNodeCaret, RootMode, StepwiseIteratorConfig, - TextNodeCaret, - TextNodeCaretSlice, - TextNodeCaretSliceTuple, + TextPointCaret, + TextPointCaretSlice, + TextPointCaretSliceTuple, } from './caret/LexicalCaret'; export { $getAdjacentDepthCaret, @@ -28,12 +28,12 @@ export { $getCaretRange, $getChildCaretOrSelf, $getDepthCaret, - $getTextNodeCaret, - $getTextNodeCaretSlice, + $getTextPointCaret, + $getTextPointCaretSlice, $isBreadthNodeCaret, $isDepthNodeCaret, - $isSameTextNodeCaret, - $isTextNodeCaret, + $isSameTextPointCaret, + $isTextPointCaret, flipDirection, makeStepwiseIterator, } from './caret/LexicalCaret'; From 524a1d20dd3a6508b956d85fda57ddd4ee0332f9 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 1 Feb 2025 00:56:54 -0800 Subject: [PATCH 44/69] Start on docs --- .../docs/concepts/traversals.md | 86 +++++++++++++++++++ packages/lexical-website/sidebars.js | 1 + 2 files changed, 87 insertions(+) create mode 100644 packages/lexical-website/docs/concepts/traversals.md diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md new file mode 100644 index 00000000000..cac89b39b24 --- /dev/null +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -0,0 +1,86 @@ +# Node Traversals with NodeCaret + +NodeCaret offers a unified and efficient way for traversing the document +tree, making it much easier to correctly implement traversals and avoid +edge cases around empty nodes and collapsed selections. + +These new low-level functions were all designed to work together as a +fully featured relatively lightweight API to use in the core to +allow us to gradually address some edge cases and then simplify and shrink +the code. We expect higher-level utilities to be developed and shipped +in @lexical/utils or another module at a later date. The current overhead +should be less than 3kB in a production environment. + +## Concepts + +The core concept with `NodeCaret` is that you can represent any specific +point in the document by using an `origin` node, a `direction` that +points towards an adjacent node (`next` or `previous`), and a `type` +to specify whether the arrow points towards a sibling (`breadth`) or +towards a child (`depth`). + +All of these types have a `D` type parameter that must be a `CaretDirection`, so you +can not accidentally mix up `next` and `previous` carets. Many of them +also have a `T` type parameter that encodes the type of the `origin` node. + +`BreadthCaretNode` can use any `LexicalNode` as an origin +* Constructed with `$getBreadthCaret(origin, direction)` +* The `next` direction points towards the right (`origin.getNextSibling()`, `origin.insertAfter(…)`) +* The `previous` direction points towards the left (`origin.getPreviousSibling()`, `origin.insertBefore(…)`) + +`DepthCaretNode` can use any `ElementNode` as an origin +* Constructed with `$getDepthCaret(origin, direction)` +* The `next` direction points towards the first child (`origin.getFirstChild()`, `origin.splice(0, 0, …)`) +* The `previous` direction points towards the last child (`origin.getLastChild()`, `origin.append(…)`) + +`NodeCaret` is any `BreadthCaretNode` or any `DepthCaretNode` +* Constructed with `$getChildCaretOrSelf($getBreadthCaret(origin, direction))` + +`TextPointCaret` is a specialized `BreadthNodeCaret` with any `TextNode` origin and an `offset` property +* Constructed with `$getTextPointCaret(origin, direction, offset)` +* The `offset` property is an absolute index into the string +* The `next` direction implies all text content after `offset` +* The `previous` direction implies all text content before `offset` + +`PointNodeCaret` is any `TextPointCaret`, `BreadthNodeCaret` or `DepthNodeCaret` +* Because `TextPointCaret` is a subclass of `BreadthNodeCaret`, this type is + really just here to document that the function will not ignore + `TextPointCaret` + +`TextPointCaretSlice` is a wrapper for `TextPointCaret` that provides a signed `distance` +* Constructed with `$getTextPointCaretSlice(caret, distance)` +* `Math.min(caret.offset, caret.offset + distance)` refers to the start offset of the slice +* `Math.max(caret.offset, caret.offset + distance)` refers to the end offset of the slice +* The `direction` of the caret is generally ignored when working with a + `TextPointCaretSlice`, the slice is in absolute string coordinates. + +TODO `NodeCaretRange` `$getCaretRange` + +## History + +Before NodeCaret, Lexical's core API offered a relatively low-level DOM-like +interface for working with nodes and traversing them. It has accumulated +many functions over time for performing various kinds of traversals around +the tree (finding ancestors, children, depth, siblings, etc.), but most of +them are not implemented in a way that makes them easy to combine +efficiently, and many of them have edge cases that are difficult to avoid +and can't really be addressed without breaking compatibility. + +Many of these functions also have a lot of edge cases, particularly around +assuming the reference nodes are inclusive. Many are also left-to-right +biased, don't offer an iterative version that can be aborted early or +consumed on the fly, etc. + +Refactoring many of these to use something like `PointType` would almost +be sufficient for many of these use cases, but the representation of +that type is inefficient and error-prone as any mutation to the tree +requires that each point be manually recomputed. `PointType` is also +directionless, forcing a specific left-to-right bias into most APIs. +`RangeSelection` can be used in many cases because a direction can +be inferred from any two different points, but that collapses with +a single point. It's also impractical to use `RangeSelection` +concurrently with mutations due to the problems with `PointType`. + +NodeCaret was born out of frustration with these APIs and a desire +to unify it all in a coherent way to simplify and reduce errors in +the core. diff --git a/packages/lexical-website/sidebars.js b/packages/lexical-website/sidebars.js index 523933de4c2..6a47818203a 100644 --- a/packages/lexical-website/sidebars.js +++ b/packages/lexical-website/sidebars.js @@ -50,6 +50,7 @@ const sidebars = { 'concepts/history', 'concepts/serialization', 'concepts/dom-events', + 'concepts/traversals', ], label: 'Concepts', type: 'category', From 651ddae31795db6650fa1b135db1f93cd373cb0a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 1 Feb 2025 10:40:48 -0800 Subject: [PATCH 45/69] WIP docs --- .../docs/concepts/traversals.md | 142 +++++++++++++++--- 1 file changed, 125 insertions(+), 17 deletions(-) diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index cac89b39b24..4627c1ce9ec 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -23,18 +23,80 @@ All of these types have a `D` type parameter that must be a `CaretDirection`, so can not accidentally mix up `next` and `previous` carets. Many of them also have a `T` type parameter that encodes the type of the `origin` node. -`BreadthCaretNode` can use any `LexicalNode` as an origin -* Constructed with `$getBreadthCaret(origin, direction)` -* The `next` direction points towards the right (`origin.getNextSibling()`, `origin.insertAfter(…)`) -* The `previous` direction points towards the left (`origin.getPreviousSibling()`, `origin.insertBefore(…)`) +:::note -`DepthCaretNode` can use any `ElementNode` as an origin -* Constructed with `$getDepthCaret(origin, direction)` -* The `next` direction points towards the first child (`origin.getFirstChild()`, `origin.splice(0, 0, …)`) -* The `previous` direction points towards the last child (`origin.getLastChild()`, `origin.append(…)`) +The methods of a caret are designed to operate on nodes attached to the `origin` +in the designated direction, not the `origin` itself. For example, this code is +a no-op because it will attach a node to the `origin`, and then remove the node +that was just attached. -`NodeCaret` is any `BreadthCaretNode` or any `DepthCaretNode` -* Constructed with `$getChildCaretOrSelf($getBreadthCaret(origin, direction))` +```ts +// The origin is unaffected (other than being marked dirty) +caret.insert($createTextNode('no-op')).remove(); +``` + +:::warning + +Carets are immutable, and designed for low-level usage. There is no attempt +for carets to automatically update based on changes to the document +(this is a common source of bugs when working with `RangeSelection`). +Functions and methods that work with carets and are expected to change the +structure of the document will always return a possibly new caret. + +The `origin` of a caret is the exact version of the object that it was +constructed with, all accessor methods on that origin will generally call +`origin.getLatest()` so the operations will see the latest version. + +::: + +### NodeCaret + +`NodeCaret` is any `BreadthNodeCaret` or any `DepthNodeCaret` +* Typically constructed with `$getChildCaretOrSelf($getBreadthCaret(origin, direction))` + which returns a `DepthNodeCaret` when the origin is an `ElementNode` + +### BreadthNodeCaret + +`BreadthNodeCaret` is a caret that points towards a sibling of the origin + +* Constructed with `$getBreadthCaret(origin: LexicalNode, direction: CaretDirection)` +* The `next` direction points towards the right +* The `previous` direction points towards the left + +| | → direction: `'next'` | ← direction: `'previous'` | +|------------------------|---------------------------|-------------------------------| +| `getParentAtCaret()` | `origin.getParent()` | `origin.getParent()` | +| `getNodeAtCaret()` | `origin.getNextSibling()` | `origin.getPreviousSibling()` | +| `insert(node)` | `origin.insertAfter(node)`| `origin.insertBefore(node)` | + +### DepthNodeCaret + +`DepthNodeCaret` is a caret that points towards the first or last child of the origin + +* Constructed with `$getDepthCaret(origin: ElementNode, direction: CaretDirection)` +* The `next` direction points towards the first child +* The `previous` direction points towards the last child + +| | ↘ direction: `'next'` | ↙ direction: `'previous'` | +|------------------------|----------------------------|-------------------------------| +| `getParentAtCaret()` | `origin` | `origin` | +| `getNodeAtCaret()` | `origin.getFirstChild()` | `origin.getLastChild()` | +| `insert(node)` | `origin.splice(0, 0, node)`| `origin.append(node)` | + +### PointNodeCaret + +`PointNodeCaret` is any `TextPointCaret`, `BreadthNodeCaret` or `DepthNodeCaret`. This +type can be used to represent any point in the document that `PointType` can represent. + +:::tip + +Because `TextPointCaret` is a subclass of `BreadthNodeCaret`, this type is +really just used to document that the function will not ignore +`TextPointCaret` + +::: + +### TextPointCaret `TextPointCaret` is a specialized `BreadthNodeCaret` with any `TextNode` origin and an `offset` property * Constructed with `$getTextPointCaret(origin, direction, offset)` @@ -42,19 +104,65 @@ also have a `T` type parameter that encodes the type of the `origin` node. * The `next` direction implies all text content after `offset` * The `previous` direction implies all text content before `offset` -`PointNodeCaret` is any `TextPointCaret`, `BreadthNodeCaret` or `DepthNodeCaret` -* Because `TextPointCaret` is a subclass of `BreadthNodeCaret`, this type is - really just here to document that the function will not ignore - `TextPointCaret` -`TextPointCaretSlice` is a wrapper for `TextPointCaret` that provides a signed `distance` +:::warning + +Since `TextPointCaret` is a specialization of `BreadthNodeCaret`, the offset will be ignored +by functions that are not also specialized to handle it. + +::: + +### TextPointCaretSlice + +`TextPointCaretSlice` is a wrapper for `TextPointCaret` that provides a signed `distance`, +it is just a data structure and has no methods. + * Constructed with `$getTextPointCaretSlice(caret, distance)` * `Math.min(caret.offset, caret.offset + distance)` refers to the start offset of the slice * `Math.max(caret.offset, caret.offset + distance)` refers to the end offset of the slice * The `direction` of the caret is generally ignored when working with a - `TextPointCaretSlice`, the slice is in absolute string coordinates. + `TextPointCaretSlice`, the slice is in absolute string coordinates + +### NodeCaretRange + +`NodeCaretRange` contains a pair of `PointNodeCaret` that are in the same direction. It +is equivalent in purpose to a `RangeSelection`. + +* Constructed with `$getCaretRange(anchor, focus)` or `$caretRangeFromSelection(selection)` +* The `anchor` is the start of the range, generally where the selection originated, + and it is "anchored" in place because when a selection grows or shrinks only the + `focus` will be moved +* The `focus` is the end of the range, where the blinking cursor is, it's the current + focus of the user +* Anchor and focus must point in the same direction. The `anchor` points towards the first + node *in the range* and the focus points towards the first node *not in the range* + +## Traversal Strategies + +### Adjacent Caret Traversals + +The lowest level building block for traversals with NodeCaret is the adjacent caret +traversal, which is supported directly by methods of NodeCaret. + +`getAdjacentCaret()` - Gets a `BreadthNodeCaret` for the node attached to + `origin` in direction. If there is no attached node, it will return `null` + +`getParentCaret(rootMode)` - Gets a `BreadthNodeCaret` for the parent node + of `origin` in the same direction. If there is no parent node, or the parent + is a root according to `rootMode`, then it will return `null`. `rootMode` + may be `'root'` to only return `null` for `RootNode` or `'shadowRoot'` to + return `null` for `RootNode` or any `ElementNode` parent where + `isShadowRoot()` returns true + +`getChildCaret()` - Gets a `DepthNodeCaret` for this origin, or `null` if the + origin is not an `ElementNode`. Will return `this` if the caret is already + a `DepthNodeCaret` + +### Depth First Caret Traversals + +`$getAdjacentSiblingOrParentSiblingCaret(caret)` -TODO `NodeCaretRange` `$getCaretRange` +`$getAdjacentDepthCaret(caret)` ## History From 6136ba357fbbfdf2c03855b5694b2e201691d410 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 1 Feb 2025 11:16:55 -0800 Subject: [PATCH 46/69] Fix nested admonition --- packages/lexical-website/docs/concepts/traversals.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index 4627c1ce9ec..5ee743218cd 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -23,7 +23,7 @@ All of these types have a `D` type parameter that must be a `CaretDirection`, so can not accidentally mix up `next` and `previous` carets. Many of them also have a `T` type parameter that encodes the type of the `origin` node. -:::note +:::tip The methods of a caret are designed to operate on nodes attached to the `origin` in the designated direction, not the `origin` itself. For example, this code is @@ -35,6 +35,8 @@ that was just attached. caret.insert($createTextNode('no-op')).remove(); ``` +::: + :::warning Carets are immutable, and designed for low-level usage. There is no attempt From 8666aa4b425a6fe9a04360a51977597515a677d2 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 1 Feb 2025 11:51:28 -0800 Subject: [PATCH 47/69] notes on future directions --- .../docs/concepts/traversals.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index 5ee743218cd..d7b886ee101 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -166,6 +166,34 @@ traversal, which is supported directly by methods of NodeCaret. `$getAdjacentDepthCaret(caret)` +## Future Direction + +It's expected that higher-level abstractions will be built on top of this +outside of the core, either in @lexical/utils or a separate companion package. +This is just designed to be the lowest-level layer with a consistent and +type-safe interface. That sort of abstraction will probably look a little bit +like cheerio or jQuery, but for working with Lexical documents. It is not +expected that more abstractions will be added to the core. + +In order to reduce code size and eliminate bugs, more of the core will be +refactored to use NodeCaret internally. + +Once this happens, it's possible that the internal structure of PointType +and/or RangeSelection may change to accommodate NodeCaret, as it is more +resilient to document changes (only changes that directly affect the +orgin node will "break" the point). A simple version of this would be to +create a caret any time that the point changes, and use that caret +as a fallback if the selection would otherwise be lost. + +It may be the case that NodeCaret will become the lowest level API, working +directly with private LexicalNode/ElementNode internals. When/if that happens, +the methods on LexicalNode will remain for backwards compatibility, +but overriding them will not be supported. It isn't particularly safe to +override them as-is anyway, and these overrides are frequently the +root cause of bugs (e.g. parents that remove themselves after an operation +on a child, causing the point to be lost unless the caller was sophisticated +enough to store the array of parents). + ## History Before NodeCaret, Lexical's core API offered a relatively low-level DOM-like From 885b3003a6592fb89c16b4bb06c4cdfe0e6cdcbe Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 10:24:31 -0800 Subject: [PATCH 48/69] Start on renaming --- packages/lexical-utils/src/index.ts | 6 +- .../docs/concepts/traversals.md | 84 +++++-- packages/lexical/src/caret/LexicalCaret.ts | 235 +++++++++--------- .../lexical/src/caret/LexicalCaretUtils.ts | 101 ++++---- .../caret/__tests__/unit/LexicalCaret.test.ts | 12 +- packages/lexical/src/index.ts | 14 +- 6 files changed, 246 insertions(+), 206 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 63128635dd2..f5d197cd627 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -25,7 +25,7 @@ import { $rewindBreadthCaret, $setSelection, $splitNode, - type BreadthNodeCaret, + type BreadthCaret, type CaretDirection, type EditorState, ElementNode, @@ -204,7 +204,7 @@ export function $dfs( */ export function $getAdjacentCaret( caret: null | NodeCaret, -): null | BreadthNodeCaret { +): null | BreadthCaret { return caret ? caret.getAdjacentCaret() : null; } @@ -813,7 +813,7 @@ function $childIterator( } return origin; }, - step: (caret: BreadthNodeCaret) => caret.getAdjacentCaret(), + step: (caret: BreadthCaret) => caret.getAdjacentCaret(), stop: (v): v is null => v === null, }); } diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index d7b886ee101..c18415a33fc 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -53,13 +53,13 @@ constructed with, all accessor methods on that origin will generally call ### NodeCaret -`NodeCaret` is any `BreadthNodeCaret` or any `DepthNodeCaret` +`NodeCaret` is any `BreadthCaret` or any `DepthCaret` * Typically constructed with `$getChildCaretOrSelf($getBreadthCaret(origin, direction))` - which returns a `DepthNodeCaret` when the origin is an `ElementNode` + which returns a `DepthCaret` when the origin is an `ElementNode` -### BreadthNodeCaret +### BreadthCaret -`BreadthNodeCaret` is a caret that points towards a sibling of the origin +`BreadthCaret` is a caret that points towards a sibling of the origin * Constructed with `$getBreadthCaret(origin: LexicalNode, direction: CaretDirection)` * The `next` direction points towards the right @@ -71,9 +71,9 @@ constructed with, all accessor methods on that origin will generally call | `getNodeAtCaret()` | `origin.getNextSibling()` | `origin.getPreviousSibling()` | | `insert(node)` | `origin.insertAfter(node)`| `origin.insertBefore(node)` | -### DepthNodeCaret +### DepthCaret -`DepthNodeCaret` is a caret that points towards the first or last child of the origin +`DepthCaret` is a caret that points towards the first or last child of the origin * Constructed with `$getDepthCaret(origin: ElementNode, direction: CaretDirection)` * The `next` direction points towards the first child @@ -85,14 +85,14 @@ constructed with, all accessor methods on that origin will generally call | `getNodeAtCaret()` | `origin.getFirstChild()` | `origin.getLastChild()` | | `insert(node)` | `origin.splice(0, 0, node)`| `origin.append(node)` | -### PointNodeCaret +### PointCaret -`PointNodeCaret` is any `TextPointCaret`, `BreadthNodeCaret` or `DepthNodeCaret`. This +`PointCaret` is any `TextPointCaret`, `BreadthCaret` or `DepthCaret`. This type can be used to represent any point in the document that `PointType` can represent. :::tip -Because `TextPointCaret` is a subclass of `BreadthNodeCaret`, this type is +Because `TextPointCaret` is a subclass of `BreadthCaret`, this type is really just used to document that the function will not ignore `TextPointCaret` @@ -100,7 +100,7 @@ really just used to document that the function will not ignore ### TextPointCaret -`TextPointCaret` is a specialized `BreadthNodeCaret` with any `TextNode` origin and an `offset` property +`TextPointCaret` is a specialized `BreadthCaret` with any `TextNode` origin and an `offset` property * Constructed with `$getTextPointCaret(origin, direction, offset)` * The `offset` property is an absolute index into the string * The `next` direction implies all text content after `offset` @@ -109,7 +109,7 @@ really just used to document that the function will not ignore :::warning -Since `TextPointCaret` is a specialization of `BreadthNodeCaret`, the offset will be ignored +Since `TextPointCaret` is a specialization of `BreadthCaret`, the offset will be ignored by functions that are not also specialized to handle it. ::: @@ -125,9 +125,9 @@ it is just a data structure and has no methods. * The `direction` of the caret is generally ignored when working with a `TextPointCaretSlice`, the slice is in absolute string coordinates -### NodeCaretRange +### CaretRange -`NodeCaretRange` contains a pair of `PointNodeCaret` that are in the same direction. It +`CaretRange` contains a pair of `PointCaret` that are in the same direction. It is equivalent in purpose to a `RangeSelection`. * Constructed with `$getCaretRange(anchor, focus)` or `$caretRangeFromSelection(selection)` @@ -146,25 +146,71 @@ is equivalent in purpose to a `RangeSelection`. The lowest level building block for traversals with NodeCaret is the adjacent caret traversal, which is supported directly by methods of NodeCaret. -`getAdjacentCaret()` - Gets a `BreadthNodeCaret` for the node attached to +`getAdjacentCaret()` - Gets a `BreadthCaret` for the node attached to `origin` in direction. If there is no attached node, it will return `null` -`getParentCaret(rootMode)` - Gets a `BreadthNodeCaret` for the parent node +`getParentCaret(rootMode)` - Gets a `BreadthCaret` for the parent node of `origin` in the same direction. If there is no parent node, or the parent is a root according to `rootMode`, then it will return `null`. `rootMode` may be `'root'` to only return `null` for `RootNode` or `'shadowRoot'` to return `null` for `RootNode` or any `ElementNode` parent where `isShadowRoot()` returns true -`getChildCaret()` - Gets a `DepthNodeCaret` for this origin, or `null` if the +`getChildCaret()` - Gets a `DepthCaret` for this origin, or `null` if the origin is not an `ElementNode`. Will return `this` if the caret is already - a `DepthNodeCaret` + a `DepthCaret` + +For example, iterating all siblings: + +```ts +// Note that NodeCaret already implements Iterable> in this +// way, so this function is not very useful. You can just use startCaret as +// the iterable. +function *iterSiblings( + startCaret: NodeCaret +): Iterable> { + for ( + let caret = startCaret.getAdjacentCaret(); + caret !== null; + caret = caret.getAdjacentCaret() + ) { + yield caret; + } +} +``` ### Depth First Caret Traversals -`$getAdjacentSiblingOrParentSiblingCaret(caret)` +The strategy to do a depth-first caret traversal is to use an adjacent caret +traversal and immediately use a `DepthCaret` any time that an `ElementNode` +origin is encountered. This strategy yields all possible carets, but each +ElementNode in the traversal may be yielded once or twice (a `DepthNode` on +enter, and a `BreadthNode` on leave). Allowing you to see whether an +`ElementNode` is partially included in the range or not is one of the +reasons that this abstraction exists. + + +```ts +function *iterAllNodes( + startCaret: NodeCaret, + endCaret = startCaret.getParentCaret('root') +): Iterable> { + for ( + let caret = startCaret.getAdjacentCaret(); + caret !== null; + caret = caret.getAdjacentCaret() + ) { + + } +} + +// This is in getNodes() style where it's very hard to tell if the ElementNode +// partially or completely included +``` + +`$getAdjacentDepthCaret(caret)` - `$getChildCaretOrSelf(caret?.getAdjacentCaret())` -`$getAdjacentDepthCaret(caret)` +`$getAdjacentSiblingOrParentSiblingCaret(caret)` - ## Future Direction diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 932830f7a55..34ec45fbce4 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -42,14 +42,14 @@ const FLIP_DIRECTION = { } as const; /** @noInheritDoc */ -export interface BaseNodeCaret< +export interface BaseCaret< T extends LexicalNode, D extends CaretDirection, Type, -> extends Iterable> { +> extends Iterable> { /** The origin node of this caret, typically this is what you will use in traversals */ readonly origin: T; - /** breadth for a BreadthNodeCaret (pointing at the next or previous sibling) or depth for a DepthNodeCaret (pointing at the first or last child) */ + /** breadth for a BreadthCaret (pointing at the next or previous sibling) or depth for a DepthCaret (pointing at the first or last child) */ readonly type: Type; /** next if pointing at the next sibling or first child, previous if pointing at the previous sibling or last child */ readonly direction: D; @@ -57,7 +57,7 @@ export interface BaseNodeCaret< * Retun true if other is a caret with the same origin (by node key comparion), type, and direction. * * Note that this will not check the offset of a TextPointCaret because it is otherwise indistinguishable - * from a BreadthNodeCaret. Use {@link $isSameTextPointCaret} for that specific scenario. + * from a BreadthCaret. Use {@link $isSameTextPointCaret} for that specific scenario. */ is: (other: NodeCaret | null) => boolean; /** @@ -74,18 +74,18 @@ export interface BaseNodeCaret< * ``` */ getFlipped: () => NodeCaret>; - /** Get the ElementNode that is the logical parent (`origin` for `DepthNodeCaret`, `origin.getParent()` for `BreadthNodeCaret`) */ + /** Get the ElementNode that is the logical parent (`origin` for `DepthCaret`, `origin.getParent()` for `BreadthCaret`) */ getParentAtCaret: () => null | ElementNode; /** Get the node connected to the origin in the caret's direction, or null if there is no node */ getNodeAtCaret: () => null | LexicalNode; - /** Get a new BreadthNodeCaret from getNodeAtCaret() in the same direction. This is used for traversals, but only goes in the breadth (sibling) direction. */ - getAdjacentCaret: () => null | BreadthNodeCaret; + /** Get a new BreadthCaret from getNodeAtCaret() in the same direction. This is used for traversals, but only goes in the breadth (sibling) direction. */ + getAdjacentCaret: () => null | BreadthCaret; /** Remove the getNodeAtCaret() node, if it exists */ remove: () => this; /** * Insert a node connected to origin in this direction. - * For a `BreadthNodeCaret` this is `origin.insertAfter(node)` for next, or `origin.insertBefore(node)` for previous. - * For a `DepthNodeCaret` this is `origin.splice(0, 0, [node])` for next or `origin.append(node)` for previous. + * For a `BreadthCaret` this is `origin.insertAfter(node)` for next, or `origin.insertBefore(node)` for previous. + * For a `DepthCaret` this is `origin.splice(0, 0, [node])` for next or `origin.append(node)` for previous. */ insert: (node: LexicalNode) => this; /** If getNodeAtCaret() is null then replace it with node, otherwise insert node */ @@ -107,12 +107,12 @@ export interface BaseNodeCaret< /** * A RangeSelection expressed as a pair of Carets */ -export interface NodeCaretRange - extends Iterable> { +export interface CaretRange + extends Iterable> { readonly type: 'node-caret-range'; readonly direction: D; - anchor: PointNodeCaret; - focus: PointNodeCaret; + anchor: PointCaret; + focus: PointCaret; /** Return true if anchor and focus are the same caret */ isCollapsed: () => boolean; /** @@ -123,7 +123,7 @@ export interface NodeCaretRange iterNodeCarets: (rootMode: RootMode) => IterableIterator>; /** * There are between zero and two non-empty TextSliceCarets for a - * NodeCaretRange. Non-empty is defined by indexEnd > indexStart + * CaretRange. Non-empty is defined by indexEnd > indexStart * (some text will be in the slice). * * 0: Neither anchor nor focus are non-empty TextPointCarets @@ -132,7 +132,7 @@ export interface NodeCaretRange */ getNonEmptyTextSlices: () => TextPointCaretSliceTuple; /** - * There are between zero and two TextSliceCarets for a NodeCaretRange + * There are between zero and two TextSliceCarets for a CaretRange * * 0: Neither anchor nor focus are TextPointCarets * 1: One of anchor or focus are TextPointCaret, or of the same origin @@ -151,9 +151,9 @@ export interface StepwiseIteratorConfig { /** * A NodeCaret is the combination of an origin node and a direction * that points towards where a connected node will be fetched, inserted, - * or replaced. A BreadthNodeCaret points from a node to its next or previous - * sibling, and a DepthNodeCaret points to its first or last child - * (using next or previous as direction, for symmetry with BreadthNodeCaret). + * or replaced. A BreadthCaret points from a node to its next or previous + * sibling, and a DepthCaret points to its first or last child + * (using next or previous as direction, for symmetry with BreadthCaret). * * The differences between NodeCaret and PointType are: * - NodeCaret can only be used to refer to an entire node. A PointType of text type can be used to refer to a specific location inside of a TextNode. @@ -167,77 +167,77 @@ export interface StepwiseIteratorConfig { * node has been removed or replaced may result in runtime errors. */ export type NodeCaret = - | BreadthNodeCaret - | DepthNodeCaret; + | BreadthCaret + | DepthCaret; /** - * A PointNodeCaret is a NodeCaret that also includes a specialized + * A PointCaret is a NodeCaret that also includes a specialized * TextPointCaret type which refers to a specific offset of a TextNode. * This type is separate because it is not relevant to general node traversal * so it doesn't make sense to have it show up except when defining - * a NodeCaretRange and in those cases there will be at most two of them only + * a CaretRange and in those cases there will be at most two of them only * at the boundaries. */ -export type PointNodeCaret = +export type PointCaret = | TextPointCaret - | BreadthNodeCaret - | DepthNodeCaret; + | BreadthCaret + | DepthCaret; /** - * A BreadthNodeCaret points from an origin LexicalNode towards its next or previous sibling. + * A BreadthCaret points from an origin LexicalNode towards its next or previous sibling. */ -export interface BreadthNodeCaret< +export interface BreadthCaret< T extends LexicalNode = LexicalNode, D extends CaretDirection = CaretDirection, -> extends BaseNodeCaret { +> extends BaseCaret { /** Get a new caret with the latest origin pointer */ - getLatest: () => BreadthNodeCaret; + getLatest: () => BreadthCaret; /** - * If the origin of this node is an ElementNode, return the DepthNodeCaret of this origin in the same direction. + * If the origin of this node is an ElementNode, return the DepthCaret of this origin in the same direction. * If the origin is not an ElementNode, this will return null. */ - getChildCaret: () => null | DepthNodeCaret; + getChildCaret: () => null | DepthCaret; /** * Get the caret in the same direction from the parent of this origin. * * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root - * @returns A BreadthNodeCaret with the parent of this origin, or null if the parent is a root according to mode. + * @returns A BreadthCaret with the parent of this origin, or null if the parent is a root according to mode. */ - getParentCaret: (mode: RootMode) => null | BreadthNodeCaret; + getParentCaret: (mode: RootMode) => null | BreadthCaret; } /** - * A DepthNodeCaret points from an origin ElementNode towards its first or last child. + * A DepthCaret points from an origin ElementNode towards its first or last child. */ -export interface DepthNodeCaret< +export interface DepthCaret< T extends ElementNode = ElementNode, D extends CaretDirection = CaretDirection, -> extends BaseNodeCaret { +> extends BaseCaret { /** Get a new caret with the latest origin pointer */ - getLatest: () => DepthNodeCaret; - getParentCaret: (mode: RootMode) => null | BreadthNodeCaret; + getLatest: () => DepthCaret; + getParentCaret: (mode: RootMode) => null | BreadthCaret; getParentAtCaret: () => T; /** Return this, the DepthNode is already a child caret of its origin */ getChildCaret: () => this; } /** - * A TextPointCaret is a special case of a BreadthNodeCaret that also carries + * A TextPointCaret is a special case of a BreadthCaret that also carries * an offset used for representing partially selected TextNode at the edges - * of a NodeCaretRange. + * of a CaretRange. * * The direction determines which part of the text is adjacent to the caret, * if next it's all of the text after offset. If previous, it's all of the * text before offset. * - * While this can be used in place of any BreadthNodeCaret of a TextNode, + * While this can be used in place of any BreadthCaret of a TextNode, * the offset into the text will be ignored except in contexts that - * specifically use the TextPointCaret or PointNodeCaret types. + * specifically use the TextPointCaret or PointCaret types. */ export interface TextPointCaret< T extends TextNode = TextNode, D extends CaretDirection = CaretDirection, -> extends BreadthNodeCaret { +> extends BreadthCaret { /** Get a new caret with the latest origin pointer */ getLatest: () => TextPointCaret; readonly offset: number; @@ -269,7 +269,7 @@ export interface TextPointCaretSlice< } /** - * A utility type to specify that a NodeCaretRange may have zero, + * A utility type to specify that a CaretRange may have zero, * one, or two associated TextPointCaretSlice. */ export type TextPointCaretSliceTuple = @@ -279,7 +279,7 @@ abstract class AbstractCaret< T extends LexicalNode, D extends CaretDirection, Type, -> implements BaseNodeCaret +> implements BaseCaret { abstract readonly type: Type; abstract readonly direction: D; @@ -299,16 +299,15 @@ abstract class AbstractCaret< this.origin.is(other.origin) ); } - [Symbol.iterator](): IterableIterator> { + [Symbol.iterator](): IterableIterator> { return makeStepwiseIterator({ initial: this.getAdjacentCaret(), map: (caret) => caret, - step: (caret: BreadthNodeCaret) => - caret.getAdjacentCaret(), + step: (caret: BreadthCaret) => caret.getAdjacentCaret(), stop: (v): v is null => v === null, }); } - getAdjacentCaret(): null | BreadthNodeCaret { + getAdjacentCaret(): null | BreadthCaret { return $getBreadthCaret(this.getNodeAtCaret(), this.direction); } remove(): this { @@ -336,7 +335,7 @@ abstract class AbstractCaret< ): this { const nodeIter = nodesDirection === this.direction ? nodes : Array.from(nodes).reverse(); - let caret: BreadthNodeCaret | this = this; + let caret: BreadthCaret | this = this; const parent = this.getParentAtCaret(); const nodesToRemove = new Map(); // Find all of the nodes we expect to remove first, so @@ -388,27 +387,27 @@ abstract class AbstractCaret< } } -abstract class AbstractDepthNodeCaret< +abstract class AbstractDepthCaret< T extends ElementNode, D extends CaretDirection, > extends AbstractCaret - implements DepthNodeCaret + implements DepthCaret { readonly type = 'depth'; - getLatest(): DepthNodeCaret { + getLatest(): DepthCaret { const origin = this.origin.getLatest(); return origin === this.origin ? this : $getDepthCaret(origin, this.direction); } /** - * Get the BreadthNodeCaret from this origin in the same direction. + * Get the BreadthCaret from this origin in the same direction. * * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root - * @returns A BreadthNodeCaret with this origin, or null if origin is a root according to mode. + * @returns A BreadthCaret with this origin, or null if origin is a root according to mode. */ - getParentCaret(mode: RootMode): null | BreadthNodeCaret { + getParentCaret(mode: RootMode): null | BreadthCaret { return $getBreadthCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, @@ -429,7 +428,7 @@ abstract class AbstractDepthNodeCaret< } } -class DepthNodeCaretFirst extends AbstractDepthNodeCaret< +class DepthCaretFirst extends AbstractDepthCaret< T, 'next' > { @@ -443,7 +442,7 @@ class DepthNodeCaretFirst extends AbstractDepthNodeCaret< } } -class DepthNodeCaretLast extends AbstractDepthNodeCaret< +class DepthCaretLast extends AbstractDepthCaret< T, 'previous' > { @@ -485,17 +484,17 @@ function $filterByMode( return MODE_PREDICATE[mode](node) ? null : node; } -abstract class AbstractBreadthNodeCaret< +abstract class AbstractBreadthCaret< T extends LexicalNode, D extends CaretDirection, > extends AbstractCaret - implements BreadthNodeCaret + implements BreadthCaret { readonly type = 'breadth'; // TextPointCaret offset?: number; - getLatest(): BreadthNodeCaret { + getLatest(): BreadthCaret { const origin = this.origin.getLatest(); return origin === this.origin ? this @@ -504,12 +503,12 @@ abstract class AbstractBreadthNodeCaret< getParentAtCaret(): null | ElementNode { return this.origin.getParent(); } - getChildCaret(): DepthNodeCaret | null { + getChildCaret(): DepthCaret | null { return $isElementNode(this.origin) ? $getDepthCaret(this.origin, this.direction) : null; } - getParentCaret(mode: RootMode): BreadthNodeCaret | null { + getParentCaret(mode: RootMode): BreadthCaret | null { return $getBreadthCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, @@ -531,10 +530,10 @@ abstract class AbstractBreadthNodeCaret< * @returns true if it is a TextPointCaret */ export function $isTextPointCaret( - caret: null | undefined | PointNodeCaret, + caret: null | undefined | PointCaret, ): caret is TextPointCaret { return ( - caret instanceof AbstractBreadthNodeCaret && + caret instanceof AbstractBreadthCaret && $isTextNode(caret.origin) && typeof caret.offset === 'number' ); @@ -549,7 +548,7 @@ export function $isTextPointCaret( */ export function $isSameTextPointCaret< T extends TextPointCaret, ->(a: T, b: null | undefined | PointNodeCaret): b is T { +>(a: T, b: null | undefined | PointCaret): b is T { return $isTextPointCaret(b) && a.is(b) && a.offset === b.offset; } @@ -560,38 +559,39 @@ export function $isSameTextPointCaret< * @returns true if caret is any type of caret */ export function $isNodeCaret( - caret: null | undefined | PointNodeCaret, -): caret is PointNodeCaret { + caret: null | undefined | PointCaret, +): caret is PointCaret { return caret instanceof AbstractCaret; } /** - * Guard to check if the given argument is specifically a BreadthNodeCaret (or TextPointCaret) + * Guard to check if the given argument is specifically a BreadthCaret (or TextPointCaret) * * @param caret - * @returns true if caret is a BreadthNodeCaret + * @returns true if caret is a BreadthCaret */ -export function $isBreadthNodeCaret( - caret: null | undefined | PointNodeCaret, -): caret is BreadthNodeCaret { - return caret instanceof AbstractBreadthNodeCaret; +export function $isBreadthCaret( + caret: null | undefined | PointCaret, +): caret is BreadthCaret { + return caret instanceof AbstractBreadthCaret; } /** - * Guard to check if the given argument is specifically a DepthNodeCaret + * Guard to check if the given argument is specifically a DepthCaret * @param caret - * @returns true if caret is a DepthNodeCaret + * @returns true if caret is a DepthCaret */ -export function $isDepthNodeCaret( - caret: null | undefined | PointNodeCaret, -): caret is DepthNodeCaret { - return caret instanceof AbstractDepthNodeCaret; +export function $isDepthCaret( + caret: null | undefined | PointCaret, +): caret is DepthCaret { + return caret instanceof AbstractDepthCaret; } -class BreadthNodeCaretNext< - T extends LexicalNode, -> extends AbstractBreadthNodeCaret { +class BreadthCaretNext extends AbstractBreadthCaret< + T, + 'next' +> { readonly direction = 'next'; getNodeAtCaret(): null | LexicalNode { return this.origin.getNextSibling(); @@ -602,9 +602,10 @@ class BreadthNodeCaretNext< } } -class BreadthNodeCaretPrevious< - T extends LexicalNode, -> extends AbstractBreadthNodeCaret { +class BreadthCaretPrevious extends AbstractBreadthCaret< + T, + 'previous' +> { readonly direction = 'previous'; getNodeAtCaret(): null | LexicalNode { return this.origin.getPreviousSibling(); @@ -616,13 +617,13 @@ class BreadthNodeCaretPrevious< } const BREADTH_CTOR = { - next: BreadthNodeCaretNext, - previous: BreadthNodeCaretPrevious, + next: BreadthCaretNext, + previous: BreadthCaretPrevious, } as const; const DEPTH_CTOR = { - next: DepthNodeCaretFirst, - previous: DepthNodeCaretLast, + next: DepthCaretFirst, + previous: DepthCaretLast, }; /** @@ -630,20 +631,20 @@ const DEPTH_CTOR = { * * @param origin The origin node * @param direction 'next' or 'previous' - * @returns null if origin is null, otherwise a BreadthNodeCaret for this origin and direction + * @returns null if origin is null, otherwise a BreadthCaret for this origin and direction */ export function $getBreadthCaret< T extends LexicalNode, D extends CaretDirection, ->(origin: T, direction: D): BreadthNodeCaret; +>(origin: T, direction: D): BreadthCaret; export function $getBreadthCaret< T extends LexicalNode, D extends CaretDirection, ->(origin: T | null, direction: D): null | BreadthNodeCaret; +>(origin: T | null, direction: D): null | BreadthCaret; export function $getBreadthCaret( origin: LexicalNode | null, direction: CaretDirection, -): BreadthNodeCaret | null { +): BreadthCaret | null { return origin ? new BREADTH_CTOR[direction](origin) : null; } @@ -739,31 +740,31 @@ export function $getTextPointCaretSlice< * * @param origin The origin ElementNode * @param direction 'next' for first child or 'previous' for last child - * @returns null if origin is null or not an ElementNode, otherwise a DepthNodeCaret for this origin and direction + * @returns null if origin is null or not an ElementNode, otherwise a DepthCaret for this origin and direction */ export function $getDepthCaret( origin: T, direction: D, -): DepthNodeCaret; +): DepthCaret; export function $getDepthCaret( origin: null | LexicalNode, direction: CaretDirection, -): null | DepthNodeCaret { +): null | DepthCaret { return $isElementNode(origin) ? new DEPTH_CTOR[direction](origin) : null; } /** - * Gets the DepthNodeCaret if one is possible at this caret origin, otherwise return the caret + * Gets the DepthCaret if one is possible at this caret origin, otherwise return the caret */ -export function $getChildCaretOrSelf( +export function $getChildCaretOrSelf( caret: Caret, -): PointNodeCaret['direction']> | (Caret & null) { +): PointCaret['direction']> | (Caret & null) { return (caret && caret.getChildCaret()) || caret; } /** * Gets the adjacent caret, if not-null and if the origin of the adjacent caret is an ElementNode, then return - * the DepthNodeCaret. This can be used along with the getParentAdjacentCaret method to perform a full DFS + * the DepthCaret. This can be used along with the getParentAdjacentCaret method to perform a full DFS * style traversal of the tree. * * @param caret The caret to start at @@ -774,28 +775,22 @@ export function $getAdjacentDepthCaret( return caret && $getChildCaretOrSelf(caret.getAdjacentCaret()); } -class NodeCaretRangeImpl - implements NodeCaretRange -{ +class CaretRangeImpl implements CaretRange { readonly type = 'node-caret-range'; readonly direction: D; - anchor: PointNodeCaret; - focus: PointNodeCaret; - constructor( - anchor: PointNodeCaret, - focus: PointNodeCaret, - direction: D, - ) { + anchor: PointCaret; + focus: PointCaret; + constructor(anchor: PointCaret, focus: PointCaret, direction: D) { this.anchor = anchor; this.focus = focus; this.direction = direction; } - getLatest(): NodeCaretRange { + getLatest(): CaretRange { const anchor = this.anchor.getLatest(); const focus = this.focus.getLatest(); return anchor === this.anchor && focus === this.focus ? this - : new NodeCaretRangeImpl(anchor, focus, this.direction); + : new CaretRangeImpl(anchor, focus, this.direction); } isCollapsed(): boolean { return ( @@ -842,7 +837,7 @@ class NodeCaretRangeImpl initial: anchor.is(focus) ? null : step(anchor), map: (state) => state, step, - stop: (state: null | PointNodeCaret): state is null => + stop: (state: null | PointCaret): state is null => state === null || (isTextFocus && focus.is(state)), }); } @@ -867,7 +862,7 @@ function $getSliceFromTextPointCaret< } /** - * Construct a NodeCaretRange from anchor and focus carets pointing in the + * Construct a CaretRange from anchor and focus carets pointing in the * same direction. In order to get the expected behavior, * the anchor must point towards the focus or be the same point. * @@ -878,17 +873,17 @@ function $getSliceFromTextPointCaret< * * @param anchor * @param focus - * @returns a NodeCaretRange + * @returns a CaretRange */ export function $getCaretRange( - anchor: PointNodeCaret, - focus: PointNodeCaret, -): NodeCaretRange { + anchor: PointCaret, + focus: PointCaret, +): CaretRange { invariant( anchor.direction === focus.direction, '$getCaretRange: anchor and focus must be in the same direction', ); - return new NodeCaretRangeImpl(anchor, focus, anchor.direction); + return new CaretRangeImpl(anchor, focus, anchor.direction); } /** diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index ab591b5eb2a..8e02c2af2cc 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -7,12 +7,12 @@ */ import type {LexicalNode, NodeKey} from '../LexicalNode'; import type { - BreadthNodeCaret, + BreadthCaret, CaretDirection, - DepthNodeCaret, + CaretRange, + DepthCaret, NodeCaret, - NodeCaretRange, - PointNodeCaret, + PointCaret, RootMode, TextPointCaret, TextPointCaretSlice, @@ -46,20 +46,20 @@ import { $getDepthCaret, $getTextNodeOffset, $getTextPointCaret, - $isBreadthNodeCaret, - $isDepthNodeCaret, + $isBreadthCaret, + $isDepthCaret, $isTextPointCaret, flipDirection, } from './LexicalCaret'; /** * @param point - * @returns a PointNodeCaret for the point + * @returns a PointCaret for the point */ export function $caretFromPoint( point: PointType, direction: D, -): PointNodeCaret { +): PointCaret { const {type, key, offset} = point; const node = $getNodeByKeyOrThrow(point.key); if (type === 'text') { @@ -81,18 +81,18 @@ export function $caretFromPoint( } /** - * Update the given point in-place from the PointNodeCaret + * Update the given point in-place from the PointCaret * * @param point the point to set * @param caret the caret to set the point from */ export function $setPointFromCaret( point: PointType, - caret: PointNodeCaret, + caret: PointCaret, ): void { const {origin, direction} = caret; const isNext = direction === 'next'; - if ($isBreadthNodeCaret(caret)) { + if ($isBreadthCaret(caret)) { if ($isTextNode(origin)) { point.set( origin.getKey(), @@ -110,7 +110,7 @@ export function $setPointFromCaret( } } else { invariant( - $isDepthNodeCaret(caret) && $isElementNode(origin), + $isDepthCaret(caret) && $isElementNode(origin), '$setPointFromCaret: exhaustiveness check', ); point.set( @@ -122,12 +122,12 @@ export function $setPointFromCaret( } /** - * Set a RangeSelection on the editor from the given NodeCaretRange + * Set a RangeSelection on the editor from the given CaretRange * * @returns The new RangeSelection */ export function $setSelectionFromCaretRange( - caretRange: NodeCaretRange, + caretRange: CaretRange, ): RangeSelection { const currentSelection = $getSelection(); const selection = $isRangeSelection(currentSelection) @@ -139,11 +139,11 @@ export function $setSelectionFromCaretRange( } /** - * Update the points of a RangeSelection based on the given PointNodeCaret. + * Update the points of a RangeSelection based on the given PointCaret. */ export function $updateRangeSelectionFromCaretRange( selection: RangeSelection, - caretRange: NodeCaretRange, + caretRange: CaretRange, ): void { $setPointFromCaret(selection.anchor, caretRange.anchor); $setPointFromCaret(selection.focus, caretRange.focus); @@ -157,7 +157,7 @@ export function $updateRangeSelectionFromCaretRange( */ export function $caretRangeFromSelection( selection: RangeSelection, -): NodeCaretRange { +): CaretRange { const {anchor, focus} = selection; const direction = focus.isBefore(anchor) ? 'previous' : 'next'; return $getCaretRange( @@ -167,7 +167,7 @@ export function $caretRangeFromSelection( } /** - * Given a BreadthNodeCaret we can always compute a caret that points to the + * Given a BreadthCaret we can always compute a caret that points to the * origin of that caret in the same direction. The adjacent caret of the * returned caret will be equivalent to the given caret. * @@ -177,12 +177,12 @@ export function $caretRangeFromSelection( * ``` * * @param caret The caret to "rewind" - * @returns A new caret (DepthNodeCaret or BreadthNodeCaret) with the same direction + * @returns A new caret (DepthCaret or BreadthCaret) with the same direction */ export function $rewindBreadthCaret< T extends LexicalNode, D extends CaretDirection, ->(caret: BreadthNodeCaret): NodeCaret { +>(caret: BreadthCaret): NodeCaret { const {direction, origin} = caret; // Rotate the direction around the origin and get the adjacent node const rewindOrigin = $getBreadthCaret( @@ -201,7 +201,7 @@ function $getAnchorCandidates( // These candidates will be the anchor itself, the pointer to the anchor (if different), and then any parents of that const carets: [NodeCaret, ...NodeCaret[]] = [anchor]; for ( - let parent = $isDepthNodeCaret(anchor) + let parent = $isDepthCaret(anchor) ? anchor.getParentCaret(rootMode) : anchor; parent !== null; @@ -221,8 +221,8 @@ function $getAnchorCandidates( * @returns The new collapsed range (biased towards the earlier node) */ export function $removeTextFromCaretRange( - initialRange: NodeCaretRange, -): NodeCaretRange { + initialRange: CaretRange, +): CaretRange { if (initialRange.isCollapsed()) { return initialRange; } @@ -243,9 +243,9 @@ export function $removeTextFromCaretRange( // a parent remove itself which will affect iteration const removedNodes: LexicalNode[] = []; for (const caret of range.iterNodeCarets(rootMode)) { - if ($isDepthNodeCaret(caret)) { + if ($isDepthCaret(caret)) { seenStart.add(caret.origin.getKey()); - } else if ($isBreadthNodeCaret(caret)) { + } else if ($isBreadthCaret(caret)) { const {origin} = caret; if (!$isElementNode(origin) || seenStart.has(origin.getKey())) { removedNodes.push(origin); @@ -337,27 +337,26 @@ export function $removeTextFromCaretRange( } /** - * Return the deepest DepthNodeCaret that has initialCaret's origin + * Return the deepest DepthCaret that has initialCaret's origin * as an ancestor, or initialCaret if the origin is not an ElementNode - * or is already the deepest DepthNodeCaret. + * or is already the deepest DepthCaret. * * This is generally used when normalizing because there is * "zero distance" between these locations. * * @param initialCaret - * @returns Either a deeper DepthNodeCaret or the given initialCaret + * @returns Either a deeper DepthCaret or the given initialCaret */ function $getDeepestChildOrSelf< - Caret extends null | PointNodeCaret, + Caret extends null | PointCaret, >( initialCaret: Caret, -): DepthNodeCaret['direction']> | Caret { - let caret: - | DepthNodeCaret['direction']> - | Caret = initialCaret; - while ($isDepthNodeCaret(caret)) { +): DepthCaret['direction']> | Caret { + let caret: DepthCaret['direction']> | Caret = + initialCaret; + while ($isDepthCaret(caret)) { const adjacent = $getAdjacentDepthCaret(caret); - if (!$isDepthNodeCaret(adjacent)) { + if (!$isDepthCaret(adjacent)) { break; } caret = adjacent; @@ -366,7 +365,7 @@ function $getDeepestChildOrSelf< } /** - * Normalize a caret to the deepest equivalent PointNodeCaret. + * Normalize a caret to the deepest equivalent PointCaret. * This will return a TextPointCaret with the offset set according * to the direction if given a caret with a TextNode origin * or a caret with an ElementNode origin with the deepest DepthNode @@ -376,11 +375,11 @@ function $getDeepestChildOrSelf< * is required when an offset is already present. * * @param initialCaret - * @returns The normalized PointNodeCaret + * @returns The normalized PointCaret */ export function $normalizeCaret( - initialCaret: PointNodeCaret, -): PointNodeCaret { + initialCaret: PointCaret, +): PointCaret { const caret = $getDeepestChildOrSelf(initialCaret.getLatest()); const {direction} = caret; if ($isTextNode(caret.origin)) { @@ -389,7 +388,7 @@ export function $normalizeCaret( : $getTextPointCaret(caret.origin, direction, direction); } const adj = caret.getAdjacentCaret(); - return $isBreadthNodeCaret(adj) && $isTextNode(adj.origin) + return $isBreadthCaret(adj) && $isTextNode(adj.origin) ? $getTextPointCaret(adj.origin, direction, flipDirection(direction)) : caret; } @@ -413,17 +412,17 @@ function $getTextSliceIndices( * Return the caret if it's in the given direction, otherwise return * caret.getFlipped(). * - * @param caret Any PointNodeCaret + * @param caret Any PointCaret * @param direction The desired direction - * @returns A PointNodeCaret in direction + * @returns A PointCaret in direction */ export function $getCaretInDirection( - caret: PointNodeCaret, + caret: PointCaret, direction: D, -): PointNodeCaret { +): PointCaret { return ( caret.direction === direction ? caret : caret.getFlipped() - ) as PointNodeCaret; + ) as PointCaret; } /** @@ -433,16 +432,16 @@ export function $getCaretInDirection( * preserves the section of the document that it's working * with, but reverses the order of iteration. * - * @param range Any NodeCaretRange + * @param range Any CaretRange * @param direction The desired direction - * @returns A NodeCaretRange in direction + * @returns A CaretRange in direction */ export function $getCaretRangeInDirection( - range: NodeCaretRange, + range: CaretRange, direction: D, -): NodeCaretRange { +): CaretRange { if (range.direction === direction) { - return range as NodeCaretRange; + return range as CaretRange; } return $getCaretRange( // focus and anchor get flipped here @@ -507,7 +506,7 @@ export function $getChildCaretAtIndex( ): NodeCaret { let caret: NodeCaret<'next'> = $getDepthCaret(parent, 'next'); for (let i = 0; i < index; i++) { - const nextCaret: null | BreadthNodeCaret = + const nextCaret: null | BreadthCaret = caret.getAdjacentCaret(); if (nextCaret === null) { break; diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 0d799337594..72db37c6ee6 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -34,8 +34,8 @@ import { $setPointFromCaret, $setSelection, $setSelectionFromCaretRange, - BreadthNodeCaret, - DepthNodeCaret, + BreadthCaret, + DepthCaret, LexicalNode, RootNode, TextNode, @@ -83,7 +83,7 @@ describe('LexicalCaret', () => { root.clear().append(paragraph); // Note that the type declarations here would normally be inferred, these are // used just to demonstrate that inference is working as expected - const caret: DepthNodeCaret = + const caret: DepthCaret = $getDepthCaret(root, direction); expect(root.is(caret.origin)).toBe(true); expect(caret.direction).toBe(direction); @@ -102,7 +102,7 @@ describe('LexicalCaret', () => { expect(caret.getParentCaret(mode)).toBe(null); expect(flipped.getParentCaret(mode)).toBe(null); } - const adjacent: BreadthNodeCaret< + const adjacent: BreadthCaret< LexicalNode, typeof direction > | null = caret.getAdjacentCaret(); @@ -201,7 +201,7 @@ describe('LexicalCaret', () => { invariant(nextToken !== null, 'nextToken must exist'); // Note that the type declarations here would normally be inferred, these are // used just to demonstrate that inference is working as expected - const caret: BreadthNodeCaret = + const caret: BreadthCaret = $getBreadthCaret(zToken, direction); expect(zToken.is(caret.origin)).toBe(true); expect(caret.direction).toBe(direction); @@ -245,7 +245,7 @@ describe('LexicalCaret', () => { ).toBe(true); } - const adjacent: BreadthNodeCaret< + const adjacent: BreadthCaret< LexicalNode, typeof direction > | null = caret.getAdjacentCaret(); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 256f1528fb5..46a52f94354 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -7,15 +7,15 @@ */ export type { - BaseNodeCaret, - BreadthNodeCaret, + BaseCaret, + BreadthCaret, CaretDirection, + CaretRange, CaretType, - DepthNodeCaret, + DepthCaret, FlipDirection, NodeCaret, - NodeCaretRange, - PointNodeCaret, + PointCaret, RootMode, StepwiseIteratorConfig, TextPointCaret, @@ -30,8 +30,8 @@ export { $getDepthCaret, $getTextPointCaret, $getTextPointCaretSlice, - $isBreadthNodeCaret, - $isDepthNodeCaret, + $isBreadthCaret, + $isDepthCaret, $isSameTextPointCaret, $isTextPointCaret, flipDirection, From 18b81e464c121270b41eb12cdf4fb6282b3f97be Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 10:26:14 -0800 Subject: [PATCH 49/69] rename depth -> child --- packages/lexical-utils/src/index.ts | 16 ++-- .../docs/concepts/traversals.md | 24 ++--- packages/lexical/src/caret/LexicalCaret.ts | 90 +++++++++---------- .../lexical/src/caret/LexicalCaretUtils.ts | 44 ++++----- .../caret/__tests__/unit/LexicalCaret.test.ts | 20 ++--- packages/lexical/src/index.ts | 8 +- 6 files changed, 101 insertions(+), 101 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index f5d197cd627..7f7b44a8985 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -9,12 +9,12 @@ import { $cloneWithProperties, $createParagraphNode, - $getAdjacentDepthCaret, + $getAdjacentChildCaret, $getAdjacentSiblingOrParentSiblingCaret, $getBreadthCaret, + $getChildCaret, $getChildCaretAtIndex, $getChildCaretOrSelf, - $getDepthCaret, $getPreviousSelection, $getRoot, $getSelection, @@ -243,10 +243,10 @@ function $dfsCaretIterator( const root = $getRoot(); const start = startNode || root; const startCaret = $isElementNode(start) - ? $getDepthCaret(start, direction) + ? $getChildCaret(start, direction) : $rewindBreadthCaret($getBreadthCaret(start, direction)); const startDepth = $getDepth(startCaret.getParentAtCaret()); - const endCaret = $getAdjacentDepthCaret( + const endCaret = $getAdjacentChildCaret( endNode ? $getChildCaretOrSelf($getBreadthCaret(endNode, direction)) : startCaret.getParentCaret(rootMode), @@ -259,7 +259,7 @@ function $dfsCaretIterator( if (state.is(endCaret)) { return null; } - if (state.type === 'depth') { + if (state.type === 'child') { depth++; } const rval = $getAdjacentSiblingOrParentSiblingCaret(state); @@ -641,7 +641,7 @@ export function $filter( * @param node Node that needs to be appended */ export function $insertFirst(parent: ElementNode, node: LexicalNode): void { - $getDepthCaret(parent, 'next').insert(node); + $getChildCaret(parent, 'next').insert(node); } let NEEDS_MANUAL_ZOOM = IS_FIREFOX || !CAN_USE_DOM ? false : undefined; @@ -779,7 +779,7 @@ export function $descendantsMatching( * @returns An iterator of the node's children */ export function $firstToLastIterator(node: ElementNode): Iterable { - return $childIterator($getDepthCaret(node, 'next')); + return $childIterator($getChildCaret(node, 'next')); } /** @@ -791,7 +791,7 @@ export function $firstToLastIterator(node: ElementNode): Iterable { * @returns An iterator of the node's children */ export function $lastToFirstIterator(node: ElementNode): Iterable { - return $childIterator($getDepthCaret(node, 'previous')); + return $childIterator($getChildCaret(node, 'previous')); } function $childIterator( diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index c18415a33fc..096160b79f9 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -17,7 +17,7 @@ The core concept with `NodeCaret` is that you can represent any specific point in the document by using an `origin` node, a `direction` that points towards an adjacent node (`next` or `previous`), and a `type` to specify whether the arrow points towards a sibling (`breadth`) or -towards a child (`depth`). +towards a child (`child`). All of these types have a `D` type parameter that must be a `CaretDirection`, so you can not accidentally mix up `next` and `previous` carets. Many of them @@ -53,9 +53,9 @@ constructed with, all accessor methods on that origin will generally call ### NodeCaret -`NodeCaret` is any `BreadthCaret` or any `DepthCaret` +`NodeCaret` is any `BreadthCaret` or any `ChildCaret` * Typically constructed with `$getChildCaretOrSelf($getBreadthCaret(origin, direction))` - which returns a `DepthCaret` when the origin is an `ElementNode` + which returns a `ChildCaret` when the origin is an `ElementNode` ### BreadthCaret @@ -71,11 +71,11 @@ constructed with, all accessor methods on that origin will generally call | `getNodeAtCaret()` | `origin.getNextSibling()` | `origin.getPreviousSibling()` | | `insert(node)` | `origin.insertAfter(node)`| `origin.insertBefore(node)` | -### DepthCaret +### ChildCaret -`DepthCaret` is a caret that points towards the first or last child of the origin +`ChildCaret` is a caret that points towards the first or last child of the origin -* Constructed with `$getDepthCaret(origin: ElementNode, direction: CaretDirection)` +* Constructed with `$getChildCaret(origin: ElementNode, direction: CaretDirection)` * The `next` direction points towards the first child * The `previous` direction points towards the last child @@ -87,7 +87,7 @@ constructed with, all accessor methods on that origin will generally call ### PointCaret -`PointCaret` is any `TextPointCaret`, `BreadthCaret` or `DepthCaret`. This +`PointCaret` is any `TextPointCaret`, `BreadthCaret` or `ChildCaret`. This type can be used to represent any point in the document that `PointType` can represent. :::tip @@ -156,9 +156,9 @@ traversal, which is supported directly by methods of NodeCaret. return `null` for `RootNode` or any `ElementNode` parent where `isShadowRoot()` returns true -`getChildCaret()` - Gets a `DepthCaret` for this origin, or `null` if the +`getChildCaret()` - Gets a `ChildCaret` for this origin, or `null` if the origin is not an `ElementNode`. Will return `this` if the caret is already - a `DepthCaret` + a `ChildCaret` For example, iterating all siblings: @@ -182,9 +182,9 @@ function *iterSiblings( ### Depth First Caret Traversals The strategy to do a depth-first caret traversal is to use an adjacent caret -traversal and immediately use a `DepthCaret` any time that an `ElementNode` +traversal and immediately use a `ChildCaret` any time that an `ElementNode` origin is encountered. This strategy yields all possible carets, but each -ElementNode in the traversal may be yielded once or twice (a `DepthNode` on +ElementNode in the traversal may be yielded once or twice (a `ChildCaret` on enter, and a `BreadthNode` on leave). Allowing you to see whether an `ElementNode` is partially included in the range or not is one of the reasons that this abstraction exists. @@ -208,7 +208,7 @@ function *iterAllNodes( // partially or completely included ``` -`$getAdjacentDepthCaret(caret)` - `$getChildCaretOrSelf(caret?.getAdjacentCaret())` +`$getAdjacentChildCaret(caret)` - `$getChildCaretOrSelf(caret?.getAdjacentCaret())` `$getAdjacentSiblingOrParentSiblingCaret(caret)` - diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 34ec45fbce4..fe720b256b0 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -27,7 +27,7 @@ export type FlipDirection = typeof FLIP_DIRECTION[D]; * A breadth caret type points from a LexicalNode origin to its next or previous sibling, * and a depth caret type points from an ElementNode origin to its first or last child. */ -export type CaretType = 'breadth' | 'depth'; +export type CaretType = 'breadth' | 'child'; /** * The RootMode is specified in all caret traversals where the traversal can go up * towards the root. 'root' means that it will stop at the document root, @@ -49,7 +49,7 @@ export interface BaseCaret< > extends Iterable> { /** The origin node of this caret, typically this is what you will use in traversals */ readonly origin: T; - /** breadth for a BreadthCaret (pointing at the next or previous sibling) or depth for a DepthCaret (pointing at the first or last child) */ + /** breadth for a BreadthCaret (pointing at the next or previous sibling) or depth for a ChildCaret (pointing at the first or last child) */ readonly type: Type; /** next if pointing at the next sibling or first child, previous if pointing at the previous sibling or last child */ readonly direction: D; @@ -67,14 +67,14 @@ export interface BaseCaret< * @example * ``` * caret.getFlipped().getFlipped().is(caret) === true; - * $getDepthCaret(parent, 'next').getFlipped().is($getBreadthCaret(firstChild, 'previous')) === true; - * $getBreadthCaret(lastChild, 'next').getFlipped().is($getDepthCaret(parent, 'previous')) === true; + * $getChildCaret(parent, 'next').getFlipped().is($getBreadthCaret(firstChild, 'previous')) === true; + * $getBreadthCaret(lastChild, 'next').getFlipped().is($getChildCaret(parent, 'previous')) === true; * $getBreadthCaret(firstChild, 'next).getFlipped().is($getBreadthCaret(lastChild, 'previous')) === true; - * $getDepthCaret(emptyParent, 'next').getFlipped().is($getDepthCaret(emptyParent, 'previous')) === true; + * $getChildCaret(emptyParent, 'next').getFlipped().is($getChildCaret(emptyParent, 'previous')) === true; * ``` */ getFlipped: () => NodeCaret>; - /** Get the ElementNode that is the logical parent (`origin` for `DepthCaret`, `origin.getParent()` for `BreadthCaret`) */ + /** Get the ElementNode that is the logical parent (`origin` for `ChildCaret`, `origin.getParent()` for `BreadthCaret`) */ getParentAtCaret: () => null | ElementNode; /** Get the node connected to the origin in the caret's direction, or null if there is no node */ getNodeAtCaret: () => null | LexicalNode; @@ -85,7 +85,7 @@ export interface BaseCaret< /** * Insert a node connected to origin in this direction. * For a `BreadthCaret` this is `origin.insertAfter(node)` for next, or `origin.insertBefore(node)` for previous. - * For a `DepthCaret` this is `origin.splice(0, 0, [node])` for next or `origin.append(node)` for previous. + * For a `ChildCaret` this is `origin.splice(0, 0, [node])` for next or `origin.append(node)` for previous. */ insert: (node: LexicalNode) => this; /** If getNodeAtCaret() is null then replace it with node, otherwise insert node */ @@ -152,7 +152,7 @@ export interface StepwiseIteratorConfig { * A NodeCaret is the combination of an origin node and a direction * that points towards where a connected node will be fetched, inserted, * or replaced. A BreadthCaret points from a node to its next or previous - * sibling, and a DepthCaret points to its first or last child + * sibling, and a ChildCaret points to its first or last child * (using next or previous as direction, for symmetry with BreadthCaret). * * The differences between NodeCaret and PointType are: @@ -168,7 +168,7 @@ export interface StepwiseIteratorConfig { */ export type NodeCaret = | BreadthCaret - | DepthCaret; + | ChildCaret; /** * A PointCaret is a NodeCaret that also includes a specialized @@ -181,7 +181,7 @@ export type NodeCaret = export type PointCaret = | TextPointCaret | BreadthCaret - | DepthCaret; + | ChildCaret; /** * A BreadthCaret points from an origin LexicalNode towards its next or previous sibling. @@ -193,10 +193,10 @@ export interface BreadthCaret< /** Get a new caret with the latest origin pointer */ getLatest: () => BreadthCaret; /** - * If the origin of this node is an ElementNode, return the DepthCaret of this origin in the same direction. + * If the origin of this node is an ElementNode, return the ChildCaret of this origin in the same direction. * If the origin is not an ElementNode, this will return null. */ - getChildCaret: () => null | DepthCaret; + getChildCaret: () => null | ChildCaret; /** * Get the caret in the same direction from the parent of this origin. * @@ -207,17 +207,17 @@ export interface BreadthCaret< } /** - * A DepthCaret points from an origin ElementNode towards its first or last child. + * A ChildCaret points from an origin ElementNode towards its first or last child. */ -export interface DepthCaret< +export interface ChildCaret< T extends ElementNode = ElementNode, D extends CaretDirection = CaretDirection, -> extends BaseCaret { +> extends BaseCaret { /** Get a new caret with the latest origin pointer */ - getLatest: () => DepthCaret; + getLatest: () => ChildCaret; getParentCaret: (mode: RootMode) => null | BreadthCaret; getParentAtCaret: () => T; - /** Return this, the DepthNode is already a child caret of its origin */ + /** Return this, the ChildCaret is already a child caret of its origin */ getChildCaret: () => this; } @@ -387,19 +387,19 @@ abstract class AbstractCaret< } } -abstract class AbstractDepthCaret< +abstract class AbstractChildCaret< T extends ElementNode, D extends CaretDirection, > - extends AbstractCaret - implements DepthCaret + extends AbstractCaret + implements ChildCaret { - readonly type = 'depth'; - getLatest(): DepthCaret { + readonly type = 'child'; + getLatest(): ChildCaret { const origin = this.origin.getLatest(); return origin === this.origin ? this - : $getDepthCaret(origin, this.direction); + : $getChildCaret(origin, this.direction); } /** * Get the BreadthCaret from this origin in the same direction. @@ -417,7 +417,7 @@ abstract class AbstractDepthCaret< const dir = flipDirection(this.direction); return ( $getBreadthCaret(this.getNodeAtCaret(), dir) || - $getDepthCaret(this.origin, dir) + $getChildCaret(this.origin, dir) ); } getParentAtCaret(): T { @@ -428,7 +428,7 @@ abstract class AbstractDepthCaret< } } -class DepthCaretFirst extends AbstractDepthCaret< +class ChildCaretFirst extends AbstractChildCaret< T, 'next' > { @@ -442,7 +442,7 @@ class DepthCaretFirst extends AbstractDepthCaret< } } -class DepthCaretLast extends AbstractDepthCaret< +class ChildCaretLast extends AbstractChildCaret< T, 'previous' > { @@ -503,9 +503,9 @@ abstract class AbstractBreadthCaret< getParentAtCaret(): null | ElementNode { return this.origin.getParent(); } - getChildCaret(): DepthCaret | null { + getChildCaret(): ChildCaret | null { return $isElementNode(this.origin) - ? $getDepthCaret(this.origin, this.direction) + ? $getChildCaret(this.origin, this.direction) : null; } getParentCaret(mode: RootMode): BreadthCaret | null { @@ -518,7 +518,7 @@ abstract class AbstractBreadthCaret< const dir = flipDirection(this.direction); return ( $getBreadthCaret(this.getNodeAtCaret(), dir) || - $getDepthCaret(this.origin.getParentOrThrow(), dir) + $getChildCaret(this.origin.getParentOrThrow(), dir) ); } } @@ -577,15 +577,15 @@ export function $isBreadthCaret( } /** - * Guard to check if the given argument is specifically a DepthCaret + * Guard to check if the given argument is specifically a ChildCaret * @param caret - * @returns true if caret is a DepthCaret + * @returns true if caret is a ChildCaret */ -export function $isDepthCaret( +export function $isChildCaret( caret: null | undefined | PointCaret, -): caret is DepthCaret { - return caret instanceof AbstractDepthCaret; +): caret is ChildCaret { + return caret instanceof AbstractChildCaret; } class BreadthCaretNext extends AbstractBreadthCaret< @@ -622,8 +622,8 @@ const BREADTH_CTOR = { } as const; const DEPTH_CTOR = { - next: DepthCaretFirst, - previous: DepthCaretLast, + next: ChildCaretFirst, + previous: ChildCaretLast, }; /** @@ -740,21 +740,21 @@ export function $getTextPointCaretSlice< * * @param origin The origin ElementNode * @param direction 'next' for first child or 'previous' for last child - * @returns null if origin is null or not an ElementNode, otherwise a DepthCaret for this origin and direction + * @returns null if origin is null or not an ElementNode, otherwise a ChildCaret for this origin and direction */ -export function $getDepthCaret( +export function $getChildCaret( origin: T, direction: D, -): DepthCaret; -export function $getDepthCaret( +): ChildCaret; +export function $getChildCaret( origin: null | LexicalNode, direction: CaretDirection, -): null | DepthCaret { +): null | ChildCaret { return $isElementNode(origin) ? new DEPTH_CTOR[direction](origin) : null; } /** - * Gets the DepthCaret if one is possible at this caret origin, otherwise return the caret + * Gets the ChildCaret if one is possible at this caret origin, otherwise return the caret */ export function $getChildCaretOrSelf( caret: Caret, @@ -764,12 +764,12 @@ export function $getChildCaretOrSelf( /** * Gets the adjacent caret, if not-null and if the origin of the adjacent caret is an ElementNode, then return - * the DepthCaret. This can be used along with the getParentAdjacentCaret method to perform a full DFS + * the ChildCaret. This can be used along with the getParentAdjacentCaret method to perform a full DFS * style traversal of the tree. * * @param caret The caret to start at */ -export function $getAdjacentDepthCaret( +export function $getAdjacentChildCaret( caret: null | NodeCaret, ): null | NodeCaret { return caret && $getChildCaretOrSelf(caret.getAdjacentCaret()); @@ -832,7 +832,7 @@ class CaretRangeImpl implements CaretRange { const step = (state: NodeCaret) => state.is(focus) ? null - : $getAdjacentDepthCaret(state) || state.getParentCaret(rootMode); + : $getAdjacentChildCaret(state) || state.getParentCaret(rootMode); return makeStepwiseIterator({ initial: anchor.is(focus) ? null : step(anchor), map: (state) => state, diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 8e02c2af2cc..b379ab1d18f 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -10,7 +10,7 @@ import type { BreadthCaret, CaretDirection, CaretRange, - DepthCaret, + ChildCaret, NodeCaret, PointCaret, RootMode, @@ -40,14 +40,14 @@ import { type TextNode, } from '../nodes/LexicalTextNode'; import { - $getAdjacentDepthCaret, + $getAdjacentChildCaret, $getBreadthCaret, $getCaretRange, - $getDepthCaret, + $getChildCaret, $getTextNodeOffset, $getTextPointCaret, $isBreadthCaret, - $isDepthCaret, + $isChildCaret, $isTextPointCaret, flipDirection, } from './LexicalCaret'; @@ -110,7 +110,7 @@ export function $setPointFromCaret( } } else { invariant( - $isDepthCaret(caret) && $isElementNode(origin), + $isChildCaret(caret) && $isElementNode(origin), '$setPointFromCaret: exhaustiveness check', ); point.set( @@ -177,7 +177,7 @@ export function $caretRangeFromSelection( * ``` * * @param caret The caret to "rewind" - * @returns A new caret (DepthCaret or BreadthCaret) with the same direction + * @returns A new caret (ChildCaret or BreadthCaret) with the same direction */ export function $rewindBreadthCaret< T extends LexicalNode, @@ -191,7 +191,7 @@ export function $rewindBreadthCaret< ).getNodeAtCaret(); return rewindOrigin ? $getBreadthCaret(rewindOrigin, direction) - : $getDepthCaret(origin.getParentOrThrow(), direction); + : $getChildCaret(origin.getParentOrThrow(), direction); } function $getAnchorCandidates( @@ -201,7 +201,7 @@ function $getAnchorCandidates( // These candidates will be the anchor itself, the pointer to the anchor (if different), and then any parents of that const carets: [NodeCaret, ...NodeCaret[]] = [anchor]; for ( - let parent = $isDepthCaret(anchor) + let parent = $isChildCaret(anchor) ? anchor.getParentCaret(rootMode) : anchor; parent !== null; @@ -243,7 +243,7 @@ export function $removeTextFromCaretRange( // a parent remove itself which will affect iteration const removedNodes: LexicalNode[] = []; for (const caret of range.iterNodeCarets(rootMode)) { - if ($isDepthCaret(caret)) { + if ($isChildCaret(caret)) { seenStart.add(caret.origin.getKey()); } else if ($isBreadthCaret(caret)) { const {origin} = caret; @@ -316,7 +316,7 @@ export function $removeTextFromCaretRange( ) { // always merge blocks later in the document with // blocks earlier in the document - $getDepthCaret(anchorBlock, 'previous').splice(0, focusBlock.getChildren()); + $getChildCaret(anchorBlock, 'previous').splice(0, focusBlock.getChildren()); focusBlock.remove(); } @@ -337,26 +337,26 @@ export function $removeTextFromCaretRange( } /** - * Return the deepest DepthCaret that has initialCaret's origin + * Return the deepest ChildCaret that has initialCaret's origin * as an ancestor, or initialCaret if the origin is not an ElementNode - * or is already the deepest DepthCaret. + * or is already the deepest ChildCaret. * * This is generally used when normalizing because there is * "zero distance" between these locations. * * @param initialCaret - * @returns Either a deeper DepthCaret or the given initialCaret + * @returns Either a deeper ChildCaret or the given initialCaret */ function $getDeepestChildOrSelf< Caret extends null | PointCaret, >( initialCaret: Caret, -): DepthCaret['direction']> | Caret { - let caret: DepthCaret['direction']> | Caret = +): ChildCaret['direction']> | Caret { + let caret: ChildCaret['direction']> | Caret = initialCaret; - while ($isDepthCaret(caret)) { - const adjacent = $getAdjacentDepthCaret(caret); - if (!$isDepthCaret(adjacent)) { + while ($isChildCaret(caret)) { + const adjacent = $getAdjacentChildCaret(caret); + if (!$isChildCaret(adjacent)) { break; } caret = adjacent; @@ -368,7 +368,7 @@ function $getDeepestChildOrSelf< * Normalize a caret to the deepest equivalent PointCaret. * This will return a TextPointCaret with the offset set according * to the direction if given a caret with a TextNode origin - * or a caret with an ElementNode origin with the deepest DepthNode + * or a caret with an ElementNode origin with the deepest ChildCaret * having an adjacent TextNode. * * If given a TextPointCaret, it will be returned, as no normalization @@ -504,7 +504,7 @@ export function $getChildCaretAtIndex( index: number, direction: D, ): NodeCaret { - let caret: NodeCaret<'next'> = $getDepthCaret(parent, 'next'); + let caret: NodeCaret<'next'> = $getChildCaret(parent, 'next'); for (let i = 0; i < index; i++) { const nextCaret: null | BreadthCaret = caret.getAdjacentCaret(); @@ -532,7 +532,7 @@ export function $getAdjacentSiblingOrParentSiblingCaret< ): null | [NodeCaret, number] { let depthDiff = 0; let caret = startCaret; - let nextCaret = $getAdjacentDepthCaret(caret); + let nextCaret = $getAdjacentChildCaret(caret); while (nextCaret === null) { depthDiff--; nextCaret = caret.getParentCaret(rootMode); @@ -540,7 +540,7 @@ export function $getAdjacentSiblingOrParentSiblingCaret< return null; } caret = nextCaret; - nextCaret = $getAdjacentDepthCaret(caret); + nextCaret = $getAdjacentChildCaret(caret); } return nextCaret && [nextCaret, depthDiff]; } diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 72db37c6ee6..eca4454904a 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -21,7 +21,7 @@ import { $createTextNode, $getBreadthCaret, $getCaretRange, - $getDepthCaret, + $getChildCaret, $getRoot, $getSelection, $getTextPointCaret, @@ -35,7 +35,7 @@ import { $setSelection, $setSelectionFromCaretRange, BreadthCaret, - DepthCaret, + ChildCaret, LexicalNode, RootNode, TextNode, @@ -73,7 +73,7 @@ function insideNode(size: number) { describe('LexicalCaret', () => { initializeUnitTest((testEnv) => { - describe('$getDepthCaret', () => { + describe('$getChildCaret', () => { for (const direction of DIRECTIONS) { test(`direction ${direction}`, async () => { await testEnv.editor.update( @@ -83,11 +83,11 @@ describe('LexicalCaret', () => { root.clear().append(paragraph); // Note that the type declarations here would normally be inferred, these are // used just to demonstrate that inference is working as expected - const caret: DepthCaret = - $getDepthCaret(root, direction); + const caret: ChildCaret = + $getChildCaret(root, direction); expect(root.is(caret.origin)).toBe(true); expect(caret.direction).toBe(direction); - expect(caret.type).toBe('depth'); + expect(caret.type).toBe('child'); expect(paragraph.is(caret.getNodeAtCaret())).toBe(true); expect(root.is(caret.getParentAtCaret())).toBe(true); @@ -725,7 +725,7 @@ describe('LexicalCaret', () => { anchor: { direction: 'next', origin: paragraphNode, - type: 'depth', + type: 'child', }, }); }, @@ -762,7 +762,7 @@ describe('LexicalCaret', () => { anchor: { direction: 'next', origin: paragraphNode, - type: 'depth', + type: 'child', }, }); }, @@ -1393,7 +1393,7 @@ describe('LexicalCaret', () => { expect(resultRange).toMatchObject({ anchor: { origin: $getRoot().getFirstChild(), - type: 'depth', + type: 'child', }, }); } @@ -1553,7 +1553,7 @@ describe('LexicalCaret', () => { anchor: { direction, origin: originalStartParent.getLatest(), - type: 'depth', + type: 'child', }, direction, }); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 46a52f94354..6c64402a478 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -12,7 +12,7 @@ export type { CaretDirection, CaretRange, CaretType, - DepthCaret, + ChildCaret, FlipDirection, NodeCaret, PointCaret, @@ -23,15 +23,15 @@ export type { TextPointCaretSliceTuple, } from './caret/LexicalCaret'; export { - $getAdjacentDepthCaret, + $getAdjacentChildCaret, $getBreadthCaret, $getCaretRange, + $getChildCaret, $getChildCaretOrSelf, - $getDepthCaret, $getTextPointCaret, $getTextPointCaretSlice, $isBreadthCaret, - $isDepthCaret, + $isChildCaret, $isSameTextPointCaret, $isTextPointCaret, flipDirection, From 9d86fa266353c3c950160be35c9dcd1f6daeb939 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 10:35:13 -0800 Subject: [PATCH 50/69] breadth -> sibling rename --- packages/lexical-utils/src/index.ts | 20 +-- .../docs/concepts/traversals.md | 22 +-- packages/lexical/src/caret/LexicalCaret.ts | 160 ++++++++---------- .../lexical/src/caret/LexicalCaretUtils.ts | 34 ++-- .../caret/__tests__/unit/LexicalCaret.test.ts | 66 ++++---- packages/lexical/src/index.ts | 8 +- 6 files changed, 151 insertions(+), 159 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 7f7b44a8985..8a72615a6d2 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -11,21 +11,20 @@ import { $createParagraphNode, $getAdjacentChildCaret, $getAdjacentSiblingOrParentSiblingCaret, - $getBreadthCaret, $getChildCaret, $getChildCaretAtIndex, $getChildCaretOrSelf, $getPreviousSelection, $getRoot, $getSelection, + $getSiblingCaret, $isElementNode, $isRangeSelection, $isRootOrShadowRoot, $isTextNode, - $rewindBreadthCaret, + $rewindSiblingCaret, $setSelection, $splitNode, - type BreadthCaret, type CaretDirection, type EditorState, ElementNode, @@ -35,6 +34,7 @@ import { makeStepwiseIterator, type NodeCaret, type NodeKey, + type SiblingCaret, } from 'lexical'; // This underscore postfixing is used as a hotfix so we do not // export shared types from this module #5918 @@ -204,7 +204,7 @@ export function $dfs( */ export function $getAdjacentCaret( caret: null | NodeCaret, -): null | BreadthCaret { +): null | SiblingCaret { return caret ? caret.getAdjacentCaret() : null; } @@ -244,11 +244,11 @@ function $dfsCaretIterator( const start = startNode || root; const startCaret = $isElementNode(start) ? $getChildCaret(start, direction) - : $rewindBreadthCaret($getBreadthCaret(start, direction)); + : $rewindSiblingCaret($getSiblingCaret(start, direction)); const startDepth = $getDepth(startCaret.getParentAtCaret()); const endCaret = $getAdjacentChildCaret( endNode - ? $getChildCaretOrSelf($getBreadthCaret(endNode, direction)) + ? $getChildCaretOrSelf($getSiblingCaret(endNode, direction)) : startCaret.getParentCaret(rootMode), ); let depth = startDepth; @@ -285,7 +285,7 @@ export function $getNextSiblingOrParentSibling( node: LexicalNode, ): null | [LexicalNode, number] { const rval = $getAdjacentSiblingOrParentSiblingCaret( - $getBreadthCaret(node, 'next'), + $getSiblingCaret(node, 'next'), ); return rval && [rval[0].origin, rval[1]]; } @@ -314,7 +314,7 @@ export function $getNextRightPreorderNode( startingNode: LexicalNode, ): LexicalNode | null { const startCaret = $getChildCaretOrSelf( - $getBreadthCaret(startingNode, 'previous'), + $getSiblingCaret(startingNode, 'previous'), ); const next = $getAdjacentSiblingOrParentSiblingCaret(startCaret, 'root'); return next && next[0].origin; @@ -813,7 +813,7 @@ function $childIterator( } return origin; }, - step: (caret: BreadthCaret) => caret.getAdjacentCaret(), + step: (caret: SiblingCaret) => caret.getAdjacentCaret(), stop: (v): v is null => v === null, }); } @@ -824,7 +824,7 @@ function $childIterator( * @param node The ElementNode to unwrap and remove */ export function $unwrapNode(node: ElementNode): void { - $rewindBreadthCaret($getBreadthCaret(node, 'next')).splice( + $rewindSiblingCaret($getSiblingCaret(node, 'next')).splice( 1, node.getChildren(), ); diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index 096160b79f9..24255119053 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -53,15 +53,15 @@ constructed with, all accessor methods on that origin will generally call ### NodeCaret -`NodeCaret` is any `BreadthCaret` or any `ChildCaret` -* Typically constructed with `$getChildCaretOrSelf($getBreadthCaret(origin, direction))` +`NodeCaret` is any `SiblingCaret` or any `ChildCaret` +* Typically constructed with `$getChildCaretOrSelf($getSiblingCaret(origin, direction))` which returns a `ChildCaret` when the origin is an `ElementNode` -### BreadthCaret +### SiblingCaret -`BreadthCaret` is a caret that points towards a sibling of the origin +`SiblingCaret` is a caret that points towards a sibling of the origin -* Constructed with `$getBreadthCaret(origin: LexicalNode, direction: CaretDirection)` +* Constructed with `$getSiblingCaret(origin: LexicalNode, direction: CaretDirection)` * The `next` direction points towards the right * The `previous` direction points towards the left @@ -87,12 +87,12 @@ constructed with, all accessor methods on that origin will generally call ### PointCaret -`PointCaret` is any `TextPointCaret`, `BreadthCaret` or `ChildCaret`. This +`PointCaret` is any `TextPointCaret`, `SiblingCaret` or `ChildCaret`. This type can be used to represent any point in the document that `PointType` can represent. :::tip -Because `TextPointCaret` is a subclass of `BreadthCaret`, this type is +Because `TextPointCaret` is a subclass of `SiblingCaret`, this type is really just used to document that the function will not ignore `TextPointCaret` @@ -100,7 +100,7 @@ really just used to document that the function will not ignore ### TextPointCaret -`TextPointCaret` is a specialized `BreadthCaret` with any `TextNode` origin and an `offset` property +`TextPointCaret` is a specialized `SiblingCaret` with any `TextNode` origin and an `offset` property * Constructed with `$getTextPointCaret(origin, direction, offset)` * The `offset` property is an absolute index into the string * The `next` direction implies all text content after `offset` @@ -109,7 +109,7 @@ really just used to document that the function will not ignore :::warning -Since `TextPointCaret` is a specialization of `BreadthCaret`, the offset will be ignored +Since `TextPointCaret` is a specialization of `SiblingCaret`, the offset will be ignored by functions that are not also specialized to handle it. ::: @@ -146,10 +146,10 @@ is equivalent in purpose to a `RangeSelection`. The lowest level building block for traversals with NodeCaret is the adjacent caret traversal, which is supported directly by methods of NodeCaret. -`getAdjacentCaret()` - Gets a `BreadthCaret` for the node attached to +`getAdjacentCaret()` - Gets a `SiblingCaret` for the node attached to `origin` in direction. If there is no attached node, it will return `null` -`getParentCaret(rootMode)` - Gets a `BreadthCaret` for the parent node +`getParentCaret(rootMode)` - Gets a `SiblingCaret` for the parent node of `origin` in the same direction. If there is no parent node, or the parent is a root according to `rootMode`, then it will return `null`. `rootMode` may be `'root'` to only return `null` for `RootNode` or `'shadowRoot'` to diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index fe720b256b0..b873e3f5559 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -24,10 +24,10 @@ export type CaretDirection = 'next' | 'previous'; */ export type FlipDirection = typeof FLIP_DIRECTION[D]; /** - * A breadth caret type points from a LexicalNode origin to its next or previous sibling, - * and a depth caret type points from an ElementNode origin to its first or last child. + * A sibling caret type points from a LexicalNode origin to its next or previous sibling, + * and a child caret type points from an ElementNode origin to its first or last child. */ -export type CaretType = 'breadth' | 'child'; +export type CaretType = 'sibling' | 'child'; /** * The RootMode is specified in all caret traversals where the traversal can go up * towards the root. 'root' means that it will stop at the document root, @@ -46,10 +46,10 @@ export interface BaseCaret< T extends LexicalNode, D extends CaretDirection, Type, -> extends Iterable> { +> extends Iterable> { /** The origin node of this caret, typically this is what you will use in traversals */ readonly origin: T; - /** breadth for a BreadthCaret (pointing at the next or previous sibling) or depth for a ChildCaret (pointing at the first or last child) */ + /** sibling for a SiblingCaret (pointing at the next or previous sibling) or child for a ChildCaret (pointing at the first or last child) */ readonly type: Type; /** next if pointing at the next sibling or first child, previous if pointing at the previous sibling or last child */ readonly direction: D; @@ -57,7 +57,7 @@ export interface BaseCaret< * Retun true if other is a caret with the same origin (by node key comparion), type, and direction. * * Note that this will not check the offset of a TextPointCaret because it is otherwise indistinguishable - * from a BreadthCaret. Use {@link $isSameTextPointCaret} for that specific scenario. + * from a SiblingCaret. Use {@link $isSameTextPointCaret} for that specific scenario. */ is: (other: NodeCaret | null) => boolean; /** @@ -67,24 +67,24 @@ export interface BaseCaret< * @example * ``` * caret.getFlipped().getFlipped().is(caret) === true; - * $getChildCaret(parent, 'next').getFlipped().is($getBreadthCaret(firstChild, 'previous')) === true; - * $getBreadthCaret(lastChild, 'next').getFlipped().is($getChildCaret(parent, 'previous')) === true; - * $getBreadthCaret(firstChild, 'next).getFlipped().is($getBreadthCaret(lastChild, 'previous')) === true; + * $getChildCaret(parent, 'next').getFlipped().is($getSiblingCaret(firstChild, 'previous')) === true; + * $getSiblingCaret(lastChild, 'next').getFlipped().is($getChildCaret(parent, 'previous')) === true; + * $getSiblingCaret(firstChild, 'next).getFlipped().is($getSiblingCaret(lastChild, 'previous')) === true; * $getChildCaret(emptyParent, 'next').getFlipped().is($getChildCaret(emptyParent, 'previous')) === true; * ``` */ getFlipped: () => NodeCaret>; - /** Get the ElementNode that is the logical parent (`origin` for `ChildCaret`, `origin.getParent()` for `BreadthCaret`) */ + /** Get the ElementNode that is the logical parent (`origin` for `ChildCaret`, `origin.getParent()` for `SiblingCaret`) */ getParentAtCaret: () => null | ElementNode; /** Get the node connected to the origin in the caret's direction, or null if there is no node */ getNodeAtCaret: () => null | LexicalNode; - /** Get a new BreadthCaret from getNodeAtCaret() in the same direction. This is used for traversals, but only goes in the breadth (sibling) direction. */ - getAdjacentCaret: () => null | BreadthCaret; + /** Get a new SiblingCaret from getNodeAtCaret() in the same direction. */ + getAdjacentCaret: () => null | SiblingCaret; /** Remove the getNodeAtCaret() node, if it exists */ remove: () => this; /** * Insert a node connected to origin in this direction. - * For a `BreadthCaret` this is `origin.insertAfter(node)` for next, or `origin.insertBefore(node)` for previous. + * For a `SiblingCaret` this is `origin.insertAfter(node)` for next, or `origin.insertBefore(node)` for previous. * For a `ChildCaret` this is `origin.splice(0, 0, [node])` for next or `origin.append(node)` for previous. */ insert: (node: LexicalNode) => this; @@ -116,21 +116,14 @@ export interface CaretRange /** Return true if anchor and focus are the same caret */ isCollapsed: () => boolean; /** - * Iterate the carets between anchor and focus in a pre-order fashion, node + * Iterate the carets between anchor and focus in a pre-order fashion, note * that this does not include any text slices represented by the anchor and/or * focus. Those are accessed separately from getTextSlices. - */ - iterNodeCarets: (rootMode: RootMode) => IterableIterator>; - /** - * There are between zero and two non-empty TextSliceCarets for a - * CaretRange. Non-empty is defined by indexEnd > indexStart - * (some text will be in the slice). * - * 0: Neither anchor nor focus are non-empty TextPointCarets - * 1: One of anchor or focus are non-empty TextPointCaret, or of the same origin - * 2: Anchor and focus are both non-empty TextPointCaret of different origin + * An ElementNode origin will be yielded as a ChildCaret on enter, + * and a SiblingCaret on leave. */ - getNonEmptyTextSlices: () => TextPointCaretSliceTuple; + iterNodeCarets: (rootMode: RootMode) => IterableIterator>; /** * There are between zero and two TextSliceCarets for a CaretRange * @@ -151,13 +144,13 @@ export interface StepwiseIteratorConfig { /** * A NodeCaret is the combination of an origin node and a direction * that points towards where a connected node will be fetched, inserted, - * or replaced. A BreadthCaret points from a node to its next or previous + * or replaced. A SiblingCaret points from a node to its next or previous * sibling, and a ChildCaret points to its first or last child - * (using next or previous as direction, for symmetry with BreadthCaret). + * (using next or previous as direction, for symmetry with SiblingCaret). * * The differences between NodeCaret and PointType are: * - NodeCaret can only be used to refer to an entire node. A PointType of text type can be used to refer to a specific location inside of a TextNode. - * - NodeCaret stores an origin node, type (breadth or depth), and direction (next or previous). A PointType stores a type (text or element), the key of a node, and an offset within that node. + * - NodeCaret stores an origin node, type (sibling or child), and direction (next or previous). A PointType stores a type (text or element), the key of a node, and an offset within that node. * - NodeCaret is directional and always refers to a very specific node, eliminating all ambiguity. PointType can refer to the location before or after a node depending on context. * - NodeCaret is more robust to nearby mutations, as it relies only on a node's direct connections. An element Any change to the count of previous siblings in an element PointType will invalidate it. * - NodeCaret is designed to work more directly with the internal representation of the document tree, making it suitable for use in traversals without performing any redundant work. @@ -167,7 +160,7 @@ export interface StepwiseIteratorConfig { * node has been removed or replaced may result in runtime errors. */ export type NodeCaret = - | BreadthCaret + | SiblingCaret | ChildCaret; /** @@ -180,18 +173,18 @@ export type NodeCaret = */ export type PointCaret = | TextPointCaret - | BreadthCaret + | SiblingCaret | ChildCaret; /** - * A BreadthCaret points from an origin LexicalNode towards its next or previous sibling. + * A SiblingCaret points from an origin LexicalNode towards its next or previous sibling. */ -export interface BreadthCaret< +export interface SiblingCaret< T extends LexicalNode = LexicalNode, D extends CaretDirection = CaretDirection, -> extends BaseCaret { +> extends BaseCaret { /** Get a new caret with the latest origin pointer */ - getLatest: () => BreadthCaret; + getLatest: () => SiblingCaret; /** * If the origin of this node is an ElementNode, return the ChildCaret of this origin in the same direction. * If the origin is not an ElementNode, this will return null. @@ -201,9 +194,9 @@ export interface BreadthCaret< * Get the caret in the same direction from the parent of this origin. * * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root - * @returns A BreadthCaret with the parent of this origin, or null if the parent is a root according to mode. + * @returns A SiblingCaret with the parent of this origin, or null if the parent is a root according to mode. */ - getParentCaret: (mode: RootMode) => null | BreadthCaret; + getParentCaret: (mode: RootMode) => null | SiblingCaret; } /** @@ -215,14 +208,14 @@ export interface ChildCaret< > extends BaseCaret { /** Get a new caret with the latest origin pointer */ getLatest: () => ChildCaret; - getParentCaret: (mode: RootMode) => null | BreadthCaret; + getParentCaret: (mode: RootMode) => null | SiblingCaret; getParentAtCaret: () => T; /** Return this, the ChildCaret is already a child caret of its origin */ getChildCaret: () => this; } /** - * A TextPointCaret is a special case of a BreadthCaret that also carries + * A TextPointCaret is a special case of a SiblingCaret that also carries * an offset used for representing partially selected TextNode at the edges * of a CaretRange. * @@ -230,14 +223,14 @@ export interface ChildCaret< * if next it's all of the text after offset. If previous, it's all of the * text before offset. * - * While this can be used in place of any BreadthCaret of a TextNode, + * While this can be used in place of any SiblingCaret of a TextNode, * the offset into the text will be ignored except in contexts that * specifically use the TextPointCaret or PointCaret types. */ export interface TextPointCaret< T extends TextNode = TextNode, D extends CaretDirection = CaretDirection, -> extends BreadthCaret { +> extends SiblingCaret { /** Get a new caret with the latest origin pointer */ getLatest: () => TextPointCaret; readonly offset: number; @@ -299,16 +292,16 @@ abstract class AbstractCaret< this.origin.is(other.origin) ); } - [Symbol.iterator](): IterableIterator> { + [Symbol.iterator](): IterableIterator> { return makeStepwiseIterator({ initial: this.getAdjacentCaret(), map: (caret) => caret, - step: (caret: BreadthCaret) => caret.getAdjacentCaret(), + step: (caret: SiblingCaret) => caret.getAdjacentCaret(), stop: (v): v is null => v === null, }); } - getAdjacentCaret(): null | BreadthCaret { - return $getBreadthCaret(this.getNodeAtCaret(), this.direction); + getAdjacentCaret(): null | SiblingCaret { + return $getSiblingCaret(this.getNodeAtCaret(), this.direction); } remove(): this { const node = this.getNodeAtCaret(); @@ -335,7 +328,7 @@ abstract class AbstractCaret< ): this { const nodeIter = nodesDirection === this.direction ? nodes : Array.from(nodes).reverse(); - let caret: BreadthCaret | this = this; + let caret: SiblingCaret | this = this; const parent = this.getParentAtCaret(); const nodesToRemove = new Map(); // Find all of the nodes we expect to remove first, so @@ -378,7 +371,7 @@ abstract class AbstractCaret< } else { caret.insert(node); } - caret = $getBreadthCaret(node, this.direction); + caret = $getSiblingCaret(node, this.direction); } for (const node of nodesToRemove.values()) { node.remove(); @@ -402,13 +395,13 @@ abstract class AbstractChildCaret< : $getChildCaret(origin, this.direction); } /** - * Get the BreadthCaret from this origin in the same direction. + * Get the SiblingCaret from this origin in the same direction. * * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root - * @returns A BreadthCaret with this origin, or null if origin is a root according to mode. + * @returns A SiblingCaret with this origin, or null if origin is a root according to mode. */ - getParentCaret(mode: RootMode): null | BreadthCaret { - return $getBreadthCaret( + getParentCaret(mode: RootMode): null | SiblingCaret { + return $getSiblingCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, ); @@ -416,7 +409,7 @@ abstract class AbstractChildCaret< getFlipped(): NodeCaret> { const dir = flipDirection(this.direction); return ( - $getBreadthCaret(this.getNodeAtCaret(), dir) || + $getSiblingCaret(this.getNodeAtCaret(), dir) || $getChildCaret(this.origin, dir) ); } @@ -484,21 +477,21 @@ function $filterByMode( return MODE_PREDICATE[mode](node) ? null : node; } -abstract class AbstractBreadthCaret< +abstract class AbstractSiblingCaret< T extends LexicalNode, D extends CaretDirection, > - extends AbstractCaret - implements BreadthCaret + extends AbstractCaret + implements SiblingCaret { - readonly type = 'breadth'; + readonly type = 'sibling'; // TextPointCaret offset?: number; - getLatest(): BreadthCaret { + getLatest(): SiblingCaret { const origin = this.origin.getLatest(); return origin === this.origin ? this - : $getBreadthCaret(origin, this.direction); + : $getSiblingCaret(origin, this.direction); } getParentAtCaret(): null | ElementNode { return this.origin.getParent(); @@ -508,8 +501,8 @@ abstract class AbstractBreadthCaret< ? $getChildCaret(this.origin, this.direction) : null; } - getParentCaret(mode: RootMode): BreadthCaret | null { - return $getBreadthCaret( + getParentCaret(mode: RootMode): SiblingCaret | null { + return $getSiblingCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, ); @@ -517,7 +510,7 @@ abstract class AbstractBreadthCaret< getFlipped(): NodeCaret> { const dir = flipDirection(this.direction); return ( - $getBreadthCaret(this.getNodeAtCaret(), dir) || + $getSiblingCaret(this.getNodeAtCaret(), dir) || $getChildCaret(this.origin.getParentOrThrow(), dir) ); } @@ -533,7 +526,7 @@ export function $isTextPointCaret( caret: null | undefined | PointCaret, ): caret is TextPointCaret { return ( - caret instanceof AbstractBreadthCaret && + caret instanceof AbstractSiblingCaret && $isTextNode(caret.origin) && typeof caret.offset === 'number' ); @@ -565,15 +558,15 @@ export function $isNodeCaret( } /** - * Guard to check if the given argument is specifically a BreadthCaret (or TextPointCaret) + * Guard to check if the given argument is specifically a SiblingCaret (or TextPointCaret) * * @param caret - * @returns true if caret is a BreadthCaret + * @returns true if caret is a SiblingCaret */ -export function $isBreadthCaret( +export function $isSiblingCaret( caret: null | undefined | PointCaret, -): caret is BreadthCaret { - return caret instanceof AbstractBreadthCaret; +): caret is SiblingCaret { + return caret instanceof AbstractSiblingCaret; } /** @@ -588,7 +581,7 @@ export function $isChildCaret( return caret instanceof AbstractChildCaret; } -class BreadthCaretNext extends AbstractBreadthCaret< +class SiblingCaretNext extends AbstractSiblingCaret< T, 'next' > { @@ -602,7 +595,7 @@ class BreadthCaretNext extends AbstractBreadthCaret< } } -class BreadthCaretPrevious extends AbstractBreadthCaret< +class SiblingCaretPrevious extends AbstractSiblingCaret< T, 'previous' > { @@ -616,12 +609,12 @@ class BreadthCaretPrevious extends AbstractBreadthCaret< } } -const BREADTH_CTOR = { - next: BreadthCaretNext, - previous: BreadthCaretPrevious, +const SIBLING_CTOR = { + next: SiblingCaretNext, + previous: SiblingCaretPrevious, } as const; -const DEPTH_CTOR = { +const CHILD_CTOR = { next: ChildCaretFirst, previous: ChildCaretLast, }; @@ -631,21 +624,21 @@ const DEPTH_CTOR = { * * @param origin The origin node * @param direction 'next' or 'previous' - * @returns null if origin is null, otherwise a BreadthCaret for this origin and direction + * @returns null if origin is null, otherwise a SiblingCaret for this origin and direction */ -export function $getBreadthCaret< +export function $getSiblingCaret< T extends LexicalNode, D extends CaretDirection, ->(origin: T, direction: D): BreadthCaret; -export function $getBreadthCaret< +>(origin: T, direction: D): SiblingCaret; +export function $getSiblingCaret< T extends LexicalNode, D extends CaretDirection, ->(origin: T | null, direction: D): null | BreadthCaret; -export function $getBreadthCaret( +>(origin: T | null, direction: D): null | SiblingCaret; +export function $getSiblingCaret( origin: LexicalNode | null, direction: CaretDirection, -): BreadthCaret | null { - return origin ? new BREADTH_CTOR[direction](origin) : null; +): SiblingCaret | null { + return origin ? new SIBLING_CTOR[direction](origin) : null; } function $getLatestTextPointCaret( @@ -684,7 +677,7 @@ export function $getTextPointCaret< direction: D, offset: number | CaretDirection, ): TextPointCaret { - return Object.assign($getBreadthCaret(origin, direction), { + return Object.assign($getSiblingCaret(origin, direction), { getFlipped: $getFlippedTextPointCaret, getLatest: $getLatestTextPointCaret, offset: $getTextNodeOffset(origin, offset), @@ -750,7 +743,7 @@ export function $getChildCaret( origin: null | LexicalNode, direction: CaretDirection, ): null | ChildCaret { - return $isElementNode(origin) ? new DEPTH_CTOR[direction](origin) : null; + return $isElementNode(origin) ? new CHILD_CTOR[direction](origin) : null; } /** @@ -801,11 +794,6 @@ class CaretRangeImpl implements CaretRange { ) ); } - getNonEmptyTextSlices(): TextPointCaretSliceTuple { - return this.getTextSlices().filter( - (slice) => slice.distance !== 0, - ) as TextPointCaretSliceTuple; - } getTextSlices(): TextPointCaretSliceTuple { const slices = (['anchor', 'focus'] as const).flatMap((k) => { const caret = this[k]; diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index b379ab1d18f..2ba426559b4 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -7,13 +7,13 @@ */ import type {LexicalNode, NodeKey} from '../LexicalNode'; import type { - BreadthCaret, CaretDirection, CaretRange, ChildCaret, NodeCaret, PointCaret, RootMode, + SiblingCaret, TextPointCaret, TextPointCaretSlice, } from './LexicalCaret'; @@ -41,13 +41,13 @@ import { } from '../nodes/LexicalTextNode'; import { $getAdjacentChildCaret, - $getBreadthCaret, $getCaretRange, $getChildCaret, + $getSiblingCaret, $getTextNodeOffset, $getTextPointCaret, - $isBreadthCaret, $isChildCaret, + $isSiblingCaret, $isTextPointCaret, flipDirection, } from './LexicalCaret'; @@ -92,7 +92,7 @@ export function $setPointFromCaret( ): void { const {origin, direction} = caret; const isNext = direction === 'next'; - if ($isBreadthCaret(caret)) { + if ($isSiblingCaret(caret)) { if ($isTextNode(origin)) { point.set( origin.getKey(), @@ -167,30 +167,30 @@ export function $caretRangeFromSelection( } /** - * Given a BreadthCaret we can always compute a caret that points to the + * Given a SiblingCaret we can always compute a caret that points to the * origin of that caret in the same direction. The adjacent caret of the * returned caret will be equivalent to the given caret. * * @example * ```ts - * breadthCaret.is($rewindBreadthCaret(breadthCaret).getAdjacentCaret()) + * SiblingCaret.is($rewindSiblingCaret(SiblingCaret).getAdjacentCaret()) * ``` * * @param caret The caret to "rewind" - * @returns A new caret (ChildCaret or BreadthCaret) with the same direction + * @returns A new caret (ChildCaret or SiblingCaret) with the same direction */ -export function $rewindBreadthCaret< +export function $rewindSiblingCaret< T extends LexicalNode, D extends CaretDirection, ->(caret: BreadthCaret): NodeCaret { +>(caret: SiblingCaret): NodeCaret { const {direction, origin} = caret; // Rotate the direction around the origin and get the adjacent node - const rewindOrigin = $getBreadthCaret( + const rewindOrigin = $getSiblingCaret( origin, flipDirection(direction), ).getNodeAtCaret(); return rewindOrigin - ? $getBreadthCaret(rewindOrigin, direction) + ? $getSiblingCaret(rewindOrigin, direction) : $getChildCaret(origin.getParentOrThrow(), direction); } @@ -207,7 +207,7 @@ function $getAnchorCandidates( parent !== null; parent = parent.getParentCaret(rootMode) ) { - carets.push($rewindBreadthCaret(parent)); + carets.push($rewindSiblingCaret(parent)); } return carets; } @@ -245,7 +245,7 @@ export function $removeTextFromCaretRange( for (const caret of range.iterNodeCarets(rootMode)) { if ($isChildCaret(caret)) { seenStart.add(caret.origin.getKey()); - } else if ($isBreadthCaret(caret)) { + } else if ($isSiblingCaret(caret)) { const {origin} = caret; if (!$isElementNode(origin) || seenStart.has(origin.getKey())) { removedNodes.push(origin); @@ -264,8 +264,8 @@ export function $removeTextFromCaretRange( for (const slice of range.getTextSlices()) { const {origin} = slice.caret; const contentSize = origin.getTextContentSize(); - const caretBefore = $rewindBreadthCaret( - $getBreadthCaret(origin, nextDirection), + const caretBefore = $rewindSiblingCaret( + $getSiblingCaret(origin, nextDirection), ); const mode = origin.getMode(); if ( @@ -388,7 +388,7 @@ export function $normalizeCaret( : $getTextPointCaret(caret.origin, direction, direction); } const adj = caret.getAdjacentCaret(); - return $isBreadthCaret(adj) && $isTextNode(adj.origin) + return $isSiblingCaret(adj) && $isTextNode(adj.origin) ? $getTextPointCaret(adj.origin, direction, flipDirection(direction)) : caret; } @@ -506,7 +506,7 @@ export function $getChildCaretAtIndex( ): NodeCaret { let caret: NodeCaret<'next'> = $getChildCaret(parent, 'next'); for (let i = 0; i < index; i++) { - const nextCaret: null | BreadthCaret = + const nextCaret: null | SiblingCaret = caret.getAdjacentCaret(); if (nextCaret === null) { break; diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index eca4454904a..e8512a8d2cc 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -19,25 +19,25 @@ import { $createParagraphNode, $createRangeSelection, $createTextNode, - $getBreadthCaret, $getCaretRange, $getChildCaret, $getRoot, $getSelection, + $getSiblingCaret, $getTextPointCaret, $getTextSliceContent, $isTextNode, $isTextPointCaret, $removeTextFromCaretRange, - $rewindBreadthCaret, + $rewindSiblingCaret, $selectAll, $setPointFromCaret, $setSelection, $setSelectionFromCaretRange, - BreadthCaret, ChildCaret, LexicalNode, RootNode, + SiblingCaret, TextNode, } from 'lexical'; @@ -95,14 +95,14 @@ describe('LexicalCaret', () => { expect(flipped).not.toBe(caret); expect(flipped.getFlipped().is(caret)).toBe(true); expect(flipped.direction).not.toBe(direction); - expect(flipped.type).toBe('breadth'); + expect(flipped.type).toBe('sibling'); expect(flipped.getNodeAtCaret()).toBe(null); expect(flipped.getAdjacentCaret()).toBe(null); for (const mode of ['root', 'shadowRoot'] as const) { expect(caret.getParentCaret(mode)).toBe(null); expect(flipped.getParentCaret(mode)).toBe(null); } - const adjacent: BreadthCaret< + const adjacent: SiblingCaret< LexicalNode, typeof direction > | null = caret.getAdjacentCaret(); @@ -111,7 +111,7 @@ describe('LexicalCaret', () => { 'depth caret of a non-empty element must always have an adjacent caret', ); expect(paragraph.is(adjacent.origin)).toBe(true); - expect(adjacent.type).toBe('breadth'); + expect(adjacent.type).toBe('sibling'); expect(adjacent.getAdjacentCaret()).toBe(null); expect(root.getChildrenSize()).toBe(1); @@ -181,7 +181,7 @@ describe('LexicalCaret', () => { }); } }); - describe('$getBreadthCaret', () => { + describe('$getSiblingCaret', () => { for (const direction of DIRECTIONS) { test(`direction ${direction}`, async () => { await testEnv.editor.update( @@ -201,11 +201,11 @@ describe('LexicalCaret', () => { invariant(nextToken !== null, 'nextToken must exist'); // Note that the type declarations here would normally be inferred, these are // used just to demonstrate that inference is working as expected - const caret: BreadthCaret = - $getBreadthCaret(zToken, direction); + const caret: SiblingCaret = + $getSiblingCaret(zToken, direction); expect(zToken.is(caret.origin)).toBe(true); expect(caret.direction).toBe(direction); - expect(caret.type).toBe('breadth'); + expect(caret.type).toBe('sibling'); expect(nextToken.is(caret.getNodeAtCaret())).toBe(true); expect(paragraph.is(caret.getParentAtCaret())).toBe(true); @@ -223,7 +223,7 @@ describe('LexicalCaret', () => { expect(flipped.getFlipped().is(caret)); expect(flipped.origin.is(caret.getNodeAtCaret())).toBe(true); expect(flipped.direction).not.toBe(direction); - expect(flipped.type).toBe('breadth'); + expect(flipped.type).toBe('sibling'); expect(zToken.is(flipped.getNodeAtCaret())).toBe(true); const flippedAdjacent = flipped.getAdjacentCaret(); invariant( @@ -234,18 +234,18 @@ describe('LexicalCaret', () => { for (const mode of ['root', 'shadowRoot'] as const) { expect( - $getBreadthCaret(paragraph, caret.direction).is( + $getSiblingCaret(paragraph, caret.direction).is( caret.getParentCaret(mode), ), ).toBe(true); expect( - $getBreadthCaret(paragraph, flipped.direction).is( + $getSiblingCaret(paragraph, flipped.direction).is( flipped.getParentCaret(mode), ), ).toBe(true); } - const adjacent: BreadthCaret< + const adjacent: SiblingCaret< LexicalNode, typeof direction > | null = caret.getAdjacentCaret(); @@ -254,7 +254,7 @@ describe('LexicalCaret', () => { expect(tokens[ZERO_INDEX + offset].is(adjacent.origin)).toBe( true, ); - expect(adjacent.type).toBe('breadth'); + expect(adjacent.type).toBe('sibling'); expect(adjacent.origin.getTextContent()).toBe(String(offset)); expect(tokens[ZERO_INDEX + offset].isAttached()).toBe(true); @@ -469,12 +469,11 @@ describe('LexicalCaret', () => { direction: 'next', offset, origin: node, - type: 'breadth', + type: 'sibling', }, distance: 0, }, ]); - expect(range.getNonEmptyTextSlices()).toEqual([]); expect([...range.iterNodeCarets('root')]).toEqual([]); } }); @@ -633,7 +632,7 @@ describe('LexicalCaret', () => { direction, offset: anchorOffset, origin: anchorNode, - type: 'breadth', + type: 'sibling', }, distance: direction === 'next' @@ -645,7 +644,7 @@ describe('LexicalCaret', () => { direction, offset: focusOffset, origin: focusNode, - type: 'breadth', + type: 'sibling', }, distance: direction === 'next' @@ -658,7 +657,7 @@ describe('LexicalCaret', () => { .map((origin) => ({ direction, origin, - type: 'breadth', + type: 'sibling', })), ); } @@ -979,12 +978,11 @@ describe('LexicalCaret', () => { direction: 'next', offset, origin: node, - type: 'breadth', + type: 'sibling', }, distance: 0, }, ]); - expect(range.getNonEmptyTextSlices()).toEqual([]); expect([...range.iterNodeCarets('root')]).toEqual([]); expect($removeTextFromCaretRange(range)).toMatchObject( originalRangeMatch, @@ -1057,7 +1055,7 @@ describe('LexicalCaret', () => { direction, offset, origin: newOrigin, - type: 'breadth', + type: 'sibling', }; expect(resultRange).toMatchObject({ anchor: pt, @@ -1090,11 +1088,11 @@ describe('LexicalCaret', () => { direction === 'next' ? [0, size] : [size, 0]; // Create the inside selection, will mutate for outside const selection = node.select(anchorOffset, focusOffset); - const nodeCaret = $getBreadthCaret(node, direction); + const nodeCaret = $getSiblingCaret(node, direction); if (anchorBias === 'outside') { $setPointFromCaret( selection.anchor, - $rewindBreadthCaret(nodeCaret), + $rewindSiblingCaret(nodeCaret), ); if (direction === 'next') { if (i === 0) { @@ -1132,7 +1130,7 @@ describe('LexicalCaret', () => { if (focusBias === 'outside') { $setPointFromCaret( selection.focus, - $getBreadthCaret(node, direction).getFlipped(), + $getSiblingCaret(node, direction).getFlipped(), ); if (direction === 'next') { if (i === texts.length - 1) { @@ -1173,7 +1171,11 @@ describe('LexicalCaret', () => { expect([...range.iterNodeCarets('root')].length).toBe( anchorBias === 'outside' && focusBias === 'outside' ? 1 : 0, ); - expect(range.getNonEmptyTextSlices()).toMatchObject( + expect( + range + .getTextSlices() + .filter((slice) => slice.distance !== 0), + ).toMatchObject( anchorBias === 'outside' && focusBias === 'outside' ? [] : (anchorBias === 'inside') === (direction === 'next') @@ -1199,14 +1201,14 @@ describe('LexicalCaret', () => { direction, offset, origin: newOrigin, - type: 'breadth', + type: 'sibling', }, direction, focus: { direction, offset, origin: newOrigin, - type: 'breadth', + type: 'sibling', }, type: 'node-caret-range', }); @@ -1253,7 +1255,9 @@ describe('LexicalCaret', () => { focus.offset, ].sort((a, b) => a - b); const range = $getCaretRange(anchor, focus); - const slices = range.getNonEmptyTextSlices(); + const slices = range + .getTextSlices() + .filter((slice) => slice.distance !== 0); expect([...range.iterNodeCarets('root')]).toEqual([]); expect(slices.length).toBe(1); const [slice] = slices; @@ -1647,7 +1651,7 @@ describe('LexicalSelectionHelpers', () => { anchor: { direction: 'next', origin: link.getLatest(), - type: 'breadth', + type: 'sibling', }, }); newRange.focus.insert($createTextNode('foo')); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 6c64402a478..f5eda8b4752 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -8,7 +8,6 @@ export type { BaseCaret, - BreadthCaret, CaretDirection, CaretRange, CaretType, @@ -17,6 +16,7 @@ export type { NodeCaret, PointCaret, RootMode, + SiblingCaret, StepwiseIteratorConfig, TextPointCaret, TextPointCaretSlice, @@ -24,15 +24,15 @@ export type { } from './caret/LexicalCaret'; export { $getAdjacentChildCaret, - $getBreadthCaret, $getCaretRange, $getChildCaret, $getChildCaretOrSelf, + $getSiblingCaret, $getTextPointCaret, $getTextPointCaretSlice, - $isBreadthCaret, $isChildCaret, $isSameTextPointCaret, + $isSiblingCaret, $isTextPointCaret, flipDirection, makeStepwiseIterator, @@ -47,7 +47,7 @@ export { $getTextSliceContent, $removeTextFromCaretRange, $removeTextSlice, - $rewindBreadthCaret, + $rewindSiblingCaret, $setPointFromCaret, $setSelectionFromCaretRange, $updateRangeSelectionFromCaretRange, From 15396f85f99ed975cb433397e503a94ee102c28c Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 10:43:18 -0800 Subject: [PATCH 51/69] find and replace case fix --- packages/lexical/src/caret/LexicalCaretUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 2ba426559b4..ebf1fb65ede 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -173,7 +173,7 @@ export function $caretRangeFromSelection( * * @example * ```ts - * SiblingCaret.is($rewindSiblingCaret(SiblingCaret).getAdjacentCaret()) + * siblingCaret.is($rewindSiblingCaret(siblingCaret).getAdjacentCaret()) * ``` * * @param caret The caret to "rewind" From 4a7d68f350de6eeaf425b6c741ecfeca08015b72 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 11:41:02 -0800 Subject: [PATCH 52/69] more renames and polish --- .../docs/concepts/traversals.md | 95 ++++++++++++++++--- packages/lexical/src/caret/LexicalCaret.ts | 66 ++++++++++--- .../lexical/src/caret/LexicalCaretUtils.ts | 31 +----- .../caret/__tests__/unit/LexicalCaret.test.ts | 9 +- packages/lexical/src/index.ts | 2 +- 5 files changed, 140 insertions(+), 63 deletions(-) diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index 24255119053..b0510923697 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -128,7 +128,8 @@ it is just a data structure and has no methods. ### CaretRange `CaretRange` contains a pair of `PointCaret` that are in the same direction. It -is equivalent in purpose to a `RangeSelection`. +is equivalent in purpose to a `RangeSelection`, and is what you would generally +use for depth first traversals. * Constructed with `$getCaretRange(anchor, focus)` or `$caretRangeFromSelection(selection)` * The `anchor` is the start of the range, generally where the selection originated, @@ -138,6 +139,16 @@ is equivalent in purpose to a `RangeSelection`. focus of the user * Anchor and focus must point in the same direction. The `anchor` points towards the first node *in the range* and the focus points towards the first node *not in the range* +* The `getTextSlices()` method is essential to handle the literal edge cases where + the anchor and/or focus are a `TextPointCaret`. These edges are *not* included + in the default caret iteration of the `CaretRange`. + +:::warning + +If you are iterating a `CaretRange` you must consider the `getTextSlices()` separately, +or use the `iterSlicesAndCarets()` method which will include them. + +::: ## Traversal Strategies @@ -166,9 +177,12 @@ For example, iterating all siblings: // Note that NodeCaret already implements Iterable> in this // way, so this function is not very useful. You can just use startCaret as // the iterable. -function *iterSiblings( +function *$iterSiblings( startCaret: NodeCaret -): Iterable> { +): Iterable> { + // Note that we start at the adjacent caret. The start caret + // points away from the origin node, so we do not want to + // trick ourselves into thinking that that origin is included. for ( let caret = startCaret.getAdjacentCaret(); caret !== null; @@ -185,32 +199,83 @@ The strategy to do a depth-first caret traversal is to use an adjacent caret traversal and immediately use a `ChildCaret` any time that an `ElementNode` origin is encountered. This strategy yields all possible carets, but each ElementNode in the traversal may be yielded once or twice (a `ChildCaret` on -enter, and a `BreadthNode` on leave). Allowing you to see whether an +enter, and a `SiblingCaret` on leave). Allowing you to see whether an `ElementNode` is partially included in the range or not is one of the reasons that this abstraction exists. - ```ts -function *iterAllNodes( - startCaret: NodeCaret, - endCaret = startCaret.getParentCaret('root') +function *$iterCaretsDepthFirst( + startCaret: NodeCaret ): Iterable> { + function step(prevCaret: NodeCaret): null | NodeCaret { + // Get the adjacent SiblingCaret + const nextCaret = prevCaret.getAdjacent(); + return ( + // If there is a sibling, try and get a ChildCaret from it + (nextCaret && nextCaret.getChildCaret()) || + // Return the sibling if there is one + nextCaret || + // Return a SiblingCaret of the parent, if there is one + prevCaret.getParentCaret('root') + ); + } + // You may add an additional check here, usually some specific + // caret to terminate the iteration with (such as the parent caret + // of startCaret): + // + // `caret !== null || caret.is(endCaret)` + // for ( - let caret = startCaret.getAdjacentCaret(); + let caret = step(startCaret); caret !== null; - caret = caret.getAdjacentCaret() + caret = step(caret) ) { - + yield caret; } } +``` -// This is in getNodes() style where it's very hard to tell if the ElementNode -// partially or completely included +Normally this type of iteration would be done from a `CaretRange`, where you +would specify a precise end caret (focus). + +```ts +function $iterCaretsDepthFirst( + startCaret: NodeCaret, + endCaret?: NodeCaret, +): Iterable> { + return $getCaretRange( + startCaret, + // Use the root as the default end caret, but you might choose + // to use startCaret.getParentCaret('root') for example + endCaret || $getBreadthNode($getRoot(), startCaret.direction) + ); +} +``` + +To get all nodes that are entirely selected between two carets: + +```ts +function *$iterNodesDepthFirst( + startCaret: NodeCaret, + endCaret?: NodeCaret, +): Iterable> { + const seen = new Set(); + for (const caret of $iterCaretsDepthFirst(startCaret, endCaret)) { + const {origin} = caret; + if ($isChildCaret(caret)) { + seen.add(origin.getKey()); + } else if (!$isElementNode(origin) || seen.has(origin.getKey())) { + // If the origin is an element and we have not seen it as a ChildCaret + // then it was not entirely in the CaretRange + yield origin; + } + } +} ``` -`$getAdjacentChildCaret(caret)` - `$getChildCaretOrSelf(caret?.getAdjacentCaret())` +### Handling TextPointSlice + -`$getAdjacentSiblingOrParentSiblingCaret(caret)` - ## Future Direction diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index b873e3f5559..efab7e35d3f 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -242,23 +242,26 @@ export interface TextPointCaret< * caret. A negative distance means that text before offset is selected, a * positive distance means that text after offset is selected. The offset+distance * pair is not affected in any way by the direction of the caret. - * - * The selected string content can be computed as such - * (see also {@link $getTextSliceContent}): - * - * ``` - * slice.origin.getTextContent().slice( - * Math.min(slice.offset, slice.offset + slice.distance), - * Math.max(slice.offset, slice.offset + slice.distance), - * ) - * ``` */ export interface TextPointCaretSlice< T extends TextNode = TextNode, D extends CaretDirection = CaretDirection, > { + readonly type: 'slice'; readonly caret: TextPointCaret; readonly distance: number; + /** + * @returns absolute coordinates into the text (for use with `text.slice(...)`) + */ + getSliceIndices: () => [startIndex: number, endIndex: number]; + /** + * @returns The text represented by the slice + */ + getTextContent: () => string; + /** + * @returns The size of the text represented by the slice + */ + getTextContentSize: () => number; } /** @@ -724,7 +727,7 @@ export function $getTextPointCaretSlice< T extends TextNode, D extends CaretDirection, >(caret: TextPointCaret, distance: number): TextPointCaretSlice { - return {caret, distance}; + return new TextPointCaretSliceImpl(caret, distance); } /** @@ -834,6 +837,35 @@ class CaretRangeImpl implements CaretRange { } } +class TextPointCaretSliceImpl + implements TextPointCaretSlice +{ + readonly type = 'slice'; + readonly caret: TextPointCaret; + readonly distance: number; + constructor(caret: TextPointCaret, distance: number) { + this.caret = caret; + this.distance = distance; + } + getSliceIndices(): [startIndex: number, endIndex: number] { + const { + distance, + caret: {offset}, + } = this; + const offsetB = offset + distance; + return offsetB < offset ? [offsetB, offset] : [offset, offsetB]; + } + + getTextContent(): string { + const [startIndex, endIndex] = this.getSliceIndices(); + return this.caret.origin.getTextContent().slice(startIndex, endIndex); + } + + getTextContentSize(): number { + return Math.abs(this.distance); + } +} + function $getSliceFromTextPointCaret< T extends TextNode, D extends CaretDirection, @@ -846,7 +878,17 @@ function $getSliceFromTextPointCaret< origin, anchorOrFocus === 'focus' ? flipDirection(direction) : direction, ); - return {caret, distance: offsetB - caret.offset}; + return $getTextPointCaretSlice(caret, offsetB - caret.offset); +} + +export function $isTextPointCaretSlice( + caretOrSlice: + | null + | undefined + | PointCaret + | TextPointCaretSlice, +) { + return caretOrSlice instanceof TextPointCaretSliceImpl; } /** diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index ebf1fb65ede..20a06fbf9a9 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -393,21 +393,6 @@ export function $normalizeCaret( : caret; } -/** - * @param slice a TextPointCaretSlice - * @returns absolute coordinates into the text (for use with text.slice(...)) - */ -function $getTextSliceIndices( - slice: TextPointCaretSlice, -): [indexStart: number, indexEnd: number] { - const { - distance, - caret: {offset}, - } = slice; - const offsetB = offset + distance; - return offsetB < offset ? [offsetB, offset] : [offset, offsetB]; -} - /** * Return the caret if it's in the given direction, otherwise return * caret.getFlipped(). @@ -468,7 +453,7 @@ export function $removeTextSlice( const { caret: {origin, direction}, } = slice; - const [indexStart, indexEnd] = $getTextSliceIndices(slice); + const [indexStart, indexEnd] = slice.getSliceIndices(); const text = origin.getTextContent(); return $getTextPointCaret( origin.setTextContent(text.slice(0, indexStart) + text.slice(indexEnd)), @@ -477,20 +462,6 @@ export function $removeTextSlice( ); } -/** - * Read the text from a TextNodeSlice - * - * @param slice The slice to read - * @returns The text represented by the slice - */ -export function $getTextSliceContent( - slice: TextPointCaretSlice, -): string { - return slice.caret.origin - .getTextContent() - .slice(...$getTextSliceIndices(slice)); -} - /** * Get a 'next' caret for the child at the given index, or the last * caret in that node if out of bounds diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index e8512a8d2cc..5c097fa8910 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -25,7 +25,6 @@ import { $getSelection, $getSiblingCaret, $getTextPointCaret, - $getTextSliceContent, $isTextNode, $isTextPointCaret, $removeTextFromCaretRange, @@ -228,7 +227,7 @@ describe('LexicalCaret', () => { const flippedAdjacent = flipped.getAdjacentCaret(); invariant( flippedAdjacent !== null, - 'A flipped BreadthNode always has an adjacent caret because it points back to the origin', + 'A flipped SiblingCaret always has an adjacent caret because it points back to the origin', ); expect(flippedAdjacent.origin.is(caret.origin)).toBe(true); @@ -1265,7 +1264,7 @@ describe('LexicalCaret', () => { (direction === 'next' ? 1 : -1) * (size - anchorEdgeOffset - focusEdgeOffset), ); - expect($getTextSliceContent(slice)).toBe( + expect(slice.getTextContent()).toBe( text.slice(offsetStart, offsetEnd), ); const resultRange = $removeTextFromCaretRange(range); @@ -1331,7 +1330,7 @@ describe('LexicalCaret', () => { ); const slices = range.getTextSlices(); expect(slices).toHaveLength(2); - expect(slices.map($getTextSliceContent)).toEqual( + expect(slices.map((slice) => slice.getTextContent())).toEqual( direction === 'next' ? [ startCaret.origin @@ -1506,7 +1505,7 @@ describe('LexicalCaret', () => { // ); const slices = range.getTextSlices(); expect(slices).toHaveLength(2); - expect(slices.map($getTextSliceContent)).toEqual( + expect(slices.map((slice) => slice.getTextContent())).toEqual( direction === 'next' ? [ startCaret.origin diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index f5eda8b4752..4a57d75b8c0 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -34,6 +34,7 @@ export { $isSameTextPointCaret, $isSiblingCaret, $isTextPointCaret, + $isTextPointCaretSlice, flipDirection, makeStepwiseIterator, } from './caret/LexicalCaret'; @@ -44,7 +45,6 @@ export { $getCaretInDirection, $getCaretRangeInDirection, $getChildCaretAtIndex, - $getTextSliceContent, $removeTextFromCaretRange, $removeTextSlice, $rewindSiblingCaret, From 071537bf7a827922092de465aaafc22cfb2588e5 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 12:26:45 -0800 Subject: [PATCH 53/69] simplify further --- .../docs/concepts/traversals.md | 7 +++- packages/lexical/src/caret/LexicalCaret.ts | 33 ++++++++++++++++- .../lexical/src/caret/LexicalCaretUtils.ts | 37 +------------------ .../caret/__tests__/unit/LexicalCaret.test.ts | 5 +-- packages/lexical/src/index.ts | 1 - 5 files changed, 40 insertions(+), 43 deletions(-) diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index b0510923697..42366823a9f 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -145,8 +145,11 @@ use for depth first traversals. :::warning -If you are iterating a `CaretRange` you must consider the `getTextSlices()` separately, -or use the `iterSlicesAndCarets()` method which will include them. +If you are iterating a `CaretRange` you must consider the `getTextSlices()` +separately, they are not included in the iteration. This is so you don't have +to consider `TextPointCaretSlice` at every step. They are literal edge cases +that can only be at the start and/or end and typically have special +treatment (splitting instead of removing, for example). ::: diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index efab7e35d3f..3047a0698ec 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -262,6 +262,18 @@ export interface TextPointCaretSlice< * @returns The size of the text represented by the slice */ getTextContentSize: () => number; + /** + * Remove the slice of text from the contained caret, returning a new + * TextPointCaret without the wrapper (since the size would be zero). + * + * Note that this is a lower-level utility that does not have any specific + * behavior for 'segmented' or 'token' modes and it will not remove + * an empty TextNode. + * + * @returns The inner TextPointCaret with the same offset and direction + * and the latest TextNode origin after mutation + */ + removeTextSlice(): TextPointCaret; } /** @@ -864,6 +876,19 @@ class TextPointCaretSliceImpl getTextContentSize(): number { return Math.abs(this.distance); } + + removeTextSlice(): TextPointCaret { + const { + caret: {origin, direction}, + } = this; + const [indexStart, indexEnd] = this.getSliceIndices(); + const text = origin.getTextContent(); + return $getTextPointCaret( + origin.setTextContent(text.slice(0, indexStart) + text.slice(indexEnd)), + direction, + indexStart, + ); + } } function $getSliceFromTextPointCaret< @@ -881,13 +906,19 @@ function $getSliceFromTextPointCaret< return $getTextPointCaretSlice(caret, offsetB - caret.offset); } +/** + * Guard to check for a TextPointCaretSlice + * + * @param caretOrSlice A caret or slice + * @returns true if caretOrSlice is a TextPointCaretSlice + */ export function $isTextPointCaretSlice( caretOrSlice: | null | undefined | PointCaret | TextPointCaretSlice, -) { +): caretOrSlice is TextPointCaretSlice { return caretOrSlice instanceof TextPointCaretSliceImpl; } diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 20a06fbf9a9..47ea3ccc0a8 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -14,8 +14,6 @@ import type { PointCaret, RootMode, SiblingCaret, - TextPointCaret, - TextPointCaretSlice, } from './LexicalCaret'; import invariant from 'shared/invariant'; @@ -34,11 +32,7 @@ import { INTERNAL_$isBlock, } from '../LexicalUtils'; import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; -import { - $createTextNode, - $isTextNode, - type TextNode, -} from '../nodes/LexicalTextNode'; +import {$createTextNode, $isTextNode} from '../nodes/LexicalTextNode'; import { $getAdjacentChildCaret, $getCaretRange, @@ -275,7 +269,7 @@ export function $removeTextFromCaretRange( // anchorCandidates[1] should still be valid, it is caretBefore caretBefore.remove(); } else if (slice.distance !== 0) { - let nextCaret = $removeTextSlice(slice); + let nextCaret = slice.removeTextSlice(); if (mode === 'segmented') { const src = nextCaret.origin; const plainTextNode = $createTextNode(src.getTextContent()) @@ -435,33 +429,6 @@ export function $getCaretRangeInDirection( ); } -/** - * Remove the slice of text from the contained caret, returning a new - * TextPointCaret without the wrapper (since the size would be zero). - * - * Note that this is a lower-level utility that does not have any specific - * behavior for 'segmented' or 'token' modes and it will not remove - * an empty TextNode. - * - * @param slice The slice to mutate - * @returns The inner TextPointCaret with the same offset and direction - * and the latest TextNode origin after mutation - */ -export function $removeTextSlice( - slice: TextPointCaretSlice, -): TextPointCaret { - const { - caret: {origin, direction}, - } = slice; - const [indexStart, indexEnd] = slice.getSliceIndices(); - const text = origin.getTextContent(); - return $getTextPointCaret( - origin.setTextContent(text.slice(0, indexStart) + text.slice(indexEnd)), - direction, - indexStart, - ); -} - /** * Get a 'next' caret for the child at the given index, or the last * caret in that node if out of bounds diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 5c097fa8910..44a15296bb8 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -1499,10 +1499,7 @@ describe('LexicalCaret', () => { ? [startCaret, endCaret] : [endCaret, startCaret]; const range = $getCaretRange(anchor, focus); - // TODO compute the expected internal carets - // expect([...range.iterNodeCarets('root')]).toHaveLength( - // Math.max(0, nodeIndexEnd - nodeIndexStart - 1), - // ); + // TODO check [...range] carets const slices = range.getTextSlices(); expect(slices).toHaveLength(2); expect(slices.map((slice) => slice.getTextContent())).toEqual( diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 4a57d75b8c0..3da47b0c33b 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -46,7 +46,6 @@ export { $getCaretRangeInDirection, $getChildCaretAtIndex, $removeTextFromCaretRange, - $removeTextSlice, $rewindSiblingCaret, $setPointFromCaret, $setSelectionFromCaretRange, From a49bbaef4cb2b19a4117c6b886f9f258634e6e1d Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 14:04:11 -0800 Subject: [PATCH 54/69] simplify getTextSlices --- packages/lexical/src/caret/LexicalCaret.ts | 39 ++++++++++++------- .../lexical/src/caret/LexicalCaretUtils.ts | 3 ++ .../caret/__tests__/unit/LexicalCaret.test.ts | 36 ++++++++++++----- 3 files changed, 55 insertions(+), 23 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 3047a0698ec..9a0bf8a3861 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -125,11 +125,14 @@ export interface CaretRange */ iterNodeCarets: (rootMode: RootMode) => IterableIterator>; /** - * There are between zero and two TextSliceCarets for a CaretRange + * There are between zero and two non-null TextSliceCarets for a CaretRange. + * Note that when anchor and focus share an origin node the second element + * will be null becaues the slice is entirely represented by the first element. * - * 0: Neither anchor nor focus are TextPointCarets - * 1: One of anchor or focus are TextPointCaret, or of the same origin - * 2: Anchor and focus are both TextPointCaret of different origin + * `[slice, slice]`: anchor and focus are TextPointCaret with distinct origin nodes + * `[slice, null]`: anchor is a TextPointCaret + * `[null, slice]`: focus is a TextPointCaret + * `[null, null]`: Neither anchor nor focus are TextPointCarets */ getTextSlices: () => TextPointCaretSliceTuple; } @@ -278,10 +281,14 @@ export interface TextPointCaretSlice< /** * A utility type to specify that a CaretRange may have zero, - * one, or two associated TextPointCaretSlice. + * one, or two associated TextPointCaretSlice. If the anchor + * and focus are on the same node, the anchorSlice will contain + * the slice and focusSlie will be null. */ -export type TextPointCaretSliceTuple = - readonly TextPointCaretSlice[] & {length: 0 | 1 | 2}; +export type TextPointCaretSliceTuple = readonly [ + anchorSlice: null | TextPointCaretSlice, + focusSlice: null | TextPointCaretSlice, +]; abstract class AbstractCaret< T extends LexicalNode, @@ -810,24 +817,28 @@ class CaretRangeImpl implements CaretRange { ); } getTextSlices(): TextPointCaretSliceTuple { - const slices = (['anchor', 'focus'] as const).flatMap((k) => { + const getSlice = (k: 'anchor' | 'focus') => { const caret = this[k]; return $isTextPointCaret(caret) - ? [$getSliceFromTextPointCaret(caret, k)] - : []; - }); - if (slices.length === 2) { - const [{caret: anchorCaret}, {caret: focusCaret}] = slices; + ? $getSliceFromTextPointCaret(caret, k) + : null; + }; + const anchorSlice = getSlice('anchor'); + const focusSlice = getSlice('focus'); + if (anchorSlice && focusSlice) { + const {caret: anchorCaret} = anchorSlice; + const {caret: focusCaret} = focusSlice; if (anchorCaret.is(focusCaret)) { return [ $getTextPointCaretSlice( anchorCaret, focusCaret.offset - anchorCaret.offset, ), + null, ]; } } - return slices as TextPointCaretSliceTuple; + return [anchorSlice, focusSlice]; } iterNodeCarets(rootMode: RootMode): IterableIterator> { const {anchor, focus} = this; diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 47ea3ccc0a8..48813106007 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -256,6 +256,9 @@ export function $removeTextFromCaretRange( // Segmented nodes will be copied to a plain text node with the same format // and style and set to normal mode. for (const slice of range.getTextSlices()) { + if (!slice) { + continue; + } const {origin} = slice.caret; const contentSize = origin.getTextContentSize(); const caretBefore = $rewindSiblingCaret( diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 44a15296bb8..2dc643ac5f1 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -27,6 +27,7 @@ import { $getTextPointCaret, $isTextNode, $isTextPointCaret, + $isTextPointCaretSlice, $removeTextFromCaretRange, $rewindSiblingCaret, $selectAll, @@ -462,7 +463,9 @@ describe('LexicalCaret', () => { offset, }, }); - expect(range.getTextSlices()).toMatchObject([ + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ { caret: { direction: 'next', @@ -510,7 +513,9 @@ describe('LexicalCaret', () => { direction, focus: {direction, offset: focusOffset, origin: node}, }); - expect(range.getTextSlices()).toMatchObject([ + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ { caret: { direction, @@ -562,7 +567,9 @@ describe('LexicalCaret', () => { '$isTextPointCaret(range.anchor)', ); expect(range.direction).toBe(direction); - expect(range.getTextSlices()).toMatchObject([ + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ {caret: {direction, offset, origin: node}, distance: size}, ]); expect([...range.iterNodeCarets('root')]).toMatchObject([]); @@ -623,7 +630,9 @@ describe('LexicalCaret', () => { '$isTextPointCaret(range.anchor)', ); expect(range.direction).toBe(direction); - const textSliceCarets = range.getTextSlices(); + const textSliceCarets = range + .getTextSlices() + .filter($isTextPointCaretSlice); expect(textSliceCarets).toHaveLength(2); const [anchorSlice, focusSlice] = textSliceCarets; expect(anchorSlice).toMatchObject({ @@ -971,7 +980,9 @@ describe('LexicalCaret', () => { }, } as const; expect(range).toMatchObject(originalRangeMatch); - expect(range.getTextSlices()).toMatchObject([ + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ { caret: { direction: 'next', @@ -1022,7 +1033,9 @@ describe('LexicalCaret', () => { direction, focus: {offset: focusOffset, origin: node}, }); - expect(range.getTextSlices()).toMatchObject([ + expect( + range.getTextSlices().filter($isTextPointCaretSlice), + ).toMatchObject([ { caret: { offset: @@ -1173,7 +1186,7 @@ describe('LexicalCaret', () => { expect( range .getTextSlices() - .filter((slice) => slice.distance !== 0), + .filter((slice) => slice && slice.distance !== 0), ).toMatchObject( anchorBias === 'outside' && focusBias === 'outside' ? [] @@ -1256,6 +1269,7 @@ describe('LexicalCaret', () => { const range = $getCaretRange(anchor, focus); const slices = range .getTextSlices() + .filter($isTextPointCaretSlice) .filter((slice) => slice.distance !== 0); expect([...range.iterNodeCarets('root')]).toEqual([]); expect(slices.length).toBe(1); @@ -1328,7 +1342,9 @@ describe('LexicalCaret', () => { expect([...range.iterNodeCarets('root')]).toHaveLength( Math.max(0, nodeIndexEnd - nodeIndexStart - 1), ); - const slices = range.getTextSlices(); + const slices = range + .getTextSlices() + .filter($isTextPointCaretSlice); expect(slices).toHaveLength(2); expect(slices.map((slice) => slice.getTextContent())).toEqual( direction === 'next' @@ -1500,7 +1516,9 @@ describe('LexicalCaret', () => { : [endCaret, startCaret]; const range = $getCaretRange(anchor, focus); // TODO check [...range] carets - const slices = range.getTextSlices(); + const slices = range + .getTextSlices() + .filter($isTextPointCaretSlice); expect(slices).toHaveLength(2); expect(slices.map((slice) => slice.getTextContent())).toEqual( direction === 'next' From f45240b2083d7b4d38bc4a81b70072cc8c358059 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 14:10:17 -0800 Subject: [PATCH 55/69] clarify $getChildCaretAtIndex --- packages/lexical/src/caret/LexicalCaretUtils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 48813106007..54fba403917 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -433,12 +433,12 @@ export function $getCaretRangeInDirection( } /** - * Get a 'next' caret for the child at the given index, or the last - * caret in that node if out of bounds + * Get a caret pointing at the child at the given index, or the last + * caret in that node if out of bounds. * * @param parent An ElementNode * @param index The index of the origin for the caret - * @returns A next caret with the arrow at that index + * @returns A caret pointing towards the node at that index */ export function $getChildCaretAtIndex( parent: ElementNode, @@ -454,7 +454,7 @@ export function $getChildCaretAtIndex( } caret = nextCaret; } - return (direction === 'next' ? caret : caret.getFlipped()) as NodeCaret; + return $getCaretInDirection(caret, direction); } /** From 01c43513f62c3bd37abef4b5c6e86f341aa4c5a5 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 16:41:35 -0800 Subject: [PATCH 56/69] Big refactor for more safety with TextPointCaret --- packages/lexical-utils/src/index.ts | 4 +- .../docs/concepts/traversals.md | 28 +- packages/lexical/src/caret/LexicalCaret.ts | 356 +++++++++++++----- .../lexical/src/caret/LexicalCaretUtils.ts | 50 ++- .../caret/__tests__/unit/LexicalCaret.test.ts | 27 +- packages/lexical/src/index.ts | 1 - 6 files changed, 323 insertions(+), 143 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 8a72615a6d2..d40900a9849 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -256,14 +256,14 @@ function $dfsCaretIterator( initial: startCaret, map: (state) => ({depth, node: state.origin}), step: (state: NodeCaret<'next'>) => { - if (state.is(endCaret)) { + if (state.isSameNodeCaret(endCaret)) { return null; } if (state.type === 'child') { depth++; } const rval = $getAdjacentSiblingOrParentSiblingCaret(state); - if (!rval || rval[0].is(endCaret)) { + if (!rval || rval[0].isSameNodeCaret(endCaret)) { return null; } depth += rval[1]; diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index 42366823a9f..fb03f267022 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -57,6 +57,14 @@ constructed with, all accessor methods on that origin will generally call * Typically constructed with `$getChildCaretOrSelf($getSiblingCaret(origin, direction))` which returns a `ChildCaret` when the origin is an `ElementNode` +:::tip + +This type does not include `TextPointCaret` or `TextPointCaretSlice`, +so you will not have to consider those edge cases when you see this +more specific type. + +::: + ### SiblingCaret `SiblingCaret` is a caret that points towards a sibling of the origin @@ -90,14 +98,6 @@ constructed with, all accessor methods on that origin will generally call `PointCaret` is any `TextPointCaret`, `SiblingCaret` or `ChildCaret`. This type can be used to represent any point in the document that `PointType` can represent. -:::tip - -Because `TextPointCaret` is a subclass of `SiblingCaret`, this type is -really just used to document that the function will not ignore -`TextPointCaret` - -::: - ### TextPointCaret `TextPointCaret` is a specialized `SiblingCaret` with any `TextNode` origin and an `offset` property @@ -106,14 +106,6 @@ really just used to document that the function will not ignore * The `next` direction implies all text content after `offset` * The `previous` direction implies all text content before `offset` - -:::warning - -Since `TextPointCaret` is a specialization of `SiblingCaret`, the offset will be ignored -by functions that are not also specialized to handle it. - -::: - ### TextPointCaretSlice `TextPointCaretSlice` is a wrapper for `TextPointCaret` that provides a signed `distance`, @@ -276,10 +268,6 @@ function *$iterNodesDepthFirst( } ``` -### Handling TextPointSlice - - - ## Future Direction It's expected that higher-level abstractions will be built on top of this diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 9a0bf8a3861..82b23cb53dd 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -12,7 +12,7 @@ import invariant from 'shared/invariant'; import {$isRootOrShadowRoot} from '../LexicalUtils'; import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; import {$isRootNode} from '../nodes/LexicalRootNode'; -import {$isTextNode, TextNode} from '../nodes/LexicalTextNode'; +import {TextNode} from '../nodes/LexicalTextNode'; /** * The direction of a caret, 'next' points towards the end of the document @@ -53,33 +53,16 @@ export interface BaseCaret< readonly type: Type; /** next if pointing at the next sibling or first child, previous if pointing at the previous sibling or last child */ readonly direction: D; - /** - * Retun true if other is a caret with the same origin (by node key comparion), type, and direction. - * - * Note that this will not check the offset of a TextPointCaret because it is otherwise indistinguishable - * from a SiblingCaret. Use {@link $isSameTextPointCaret} for that specific scenario. - */ - is: (other: NodeCaret | null) => boolean; - /** - * Get a new NodeCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity. - * For example, given a non-empty parent with a firstChild and lastChild, and a second emptyParent node with no children: - * - * @example - * ``` - * caret.getFlipped().getFlipped().is(caret) === true; - * $getChildCaret(parent, 'next').getFlipped().is($getSiblingCaret(firstChild, 'previous')) === true; - * $getSiblingCaret(lastChild, 'next').getFlipped().is($getChildCaret(parent, 'previous')) === true; - * $getSiblingCaret(firstChild, 'next).getFlipped().is($getSiblingCaret(lastChild, 'previous')) === true; - * $getChildCaret(emptyParent, 'next').getFlipped().is($getChildCaret(emptyParent, 'previous')) === true; - * ``` - */ - getFlipped: () => NodeCaret>; /** Get the ElementNode that is the logical parent (`origin` for `ChildCaret`, `origin.getParent()` for `SiblingCaret`) */ getParentAtCaret: () => null | ElementNode; /** Get the node connected to the origin in the caret's direction, or null if there is no node */ getNodeAtCaret: () => null | LexicalNode; /** Get a new SiblingCaret from getNodeAtCaret() in the same direction. */ getAdjacentCaret: () => null | SiblingCaret; + /** + * Get a new SiblingCaret with this same node + */ + getSiblingCaret: () => SiblingCaret; /** Remove the getNodeAtCaret() node, if it exists */ remove: () => this; /** @@ -200,6 +183,36 @@ export interface SiblingCaret< * @returns A SiblingCaret with the parent of this origin, or null if the parent is a root according to mode. */ getParentCaret: (mode: RootMode) => null | SiblingCaret; + /** + * Retun true if other is a SiblingCaret or TextPointCaret with the same + * origin (by node key comparion) and direction. + */ + isSameNodeCaret: ( + other: null | undefined | PointCaret, + ) => other is SiblingCaret | T extends TextNode + ? TextPointCaret + : never; + /** + * Retun true if other is a SiblingCaret with the same + * origin (by node key comparion) and direction. + */ + isSamePointCaret: ( + other: null | undefined | PointCaret, + ) => other is SiblingCaret; + /** + * Get a new NodeCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity. + * For example, given a non-empty parent with a firstChild and lastChild, and a second emptyParent node with no children: + * + * @example + * ``` + * caret.getFlipped().getFlipped().is(caret) === true; + * $getChildCaret(parent, 'next').getFlipped().is($getSiblingCaret(firstChild, 'previous')) === true; + * $getSiblingCaret(lastChild, 'next').getFlipped().is($getChildCaret(parent, 'previous')) === true; + * $getSiblingCaret(firstChild, 'next).getFlipped().is($getSiblingCaret(lastChild, 'previous')) === true; + * $getChildCaret(emptyParent, 'next').getFlipped().is($getChildCaret(emptyParent, 'previous')) === true; + * ``` + */ + getFlipped: () => NodeCaret>; } /** @@ -215,6 +228,34 @@ export interface ChildCaret< getParentAtCaret: () => T; /** Return this, the ChildCaret is already a child caret of its origin */ getChildCaret: () => this; + /** + * Retun true if other is a ChildCaret with the same + * origin (by node key comparion) and direction. + */ + isSameNodeCaret: ( + other: null | undefined | PointCaret, + ) => other is ChildCaret; + /** + * Retun true if other is a ChildCaret with the same + * origin (by node key comparion) and direction. + */ + isSamePointCaret: ( + other: null | undefined | PointCaret, + ) => other is ChildCaret; + /** + * Get a new NodeCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity. + * For example, given a non-empty parent with a firstChild and lastChild, and a second emptyParent node with no children: + * + * @example + * ``` + * caret.getFlipped().getFlipped().is(caret) === true; + * $getChildCaret(parent, 'next').getFlipped().is($getSiblingCaret(firstChild, 'previous')) === true; + * $getSiblingCaret(lastChild, 'next').getFlipped().is($getChildCaret(parent, 'previous')) === true; + * $getSiblingCaret(firstChild, 'next).getFlipped().is($getSiblingCaret(lastChild, 'previous')) === true; + * $getChildCaret(emptyParent, 'next').getFlipped().is($getChildCaret(emptyParent, 'previous')) === true; + * ``` + */ + getFlipped: () => NodeCaret>; } /** @@ -233,10 +274,46 @@ export interface ChildCaret< export interface TextPointCaret< T extends TextNode = TextNode, D extends CaretDirection = CaretDirection, -> extends SiblingCaret { +> extends BaseCaret { + /** The offset into the string */ + readonly offset: number; /** Get a new caret with the latest origin pointer */ getLatest: () => TextPointCaret; - readonly offset: number; + /** + * A TextPointCaret can not have a ChildCaret. + */ + getChildCaret: () => null; + /** + * Get the caret in the same direction from the parent of this origin. + * + * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root + * @returns A SiblingCaret with the parent of this origin, or null if the parent is a root according to mode. + */ + getParentCaret: (mode: RootMode) => null | SiblingCaret; + /** + * Retun true if other is a TextPointCaret or SiblingCaret with the same + * origin (by node key comparion) and direction. + */ + isSameNodeCaret: ( + other: null | undefined | PointCaret, + ) => other is TextPointCaret | SiblingCaret; + /** + * Retun true if other is a ChildCaret with the same + * origin (by node key comparion) and direction. + */ + isSamePointCaret: ( + other: null | undefined | PointCaret, + ) => other is TextPointCaret; + /** + * Get a new TextPointCaret with the head and tail of its directional arrow flipped, such that flipping twice is the identity. + * For a TextPointCaret this merely flips the direction because the arrow is internal to the node. + * + * @example + * ``` + * caret.getFlipped().getFlipped().is(caret) === true; + * ``` + */ + getFlipped: () => TextPointCaret>; } /** @@ -301,7 +378,6 @@ abstract class AbstractCaret< readonly origin: T; abstract getNodeAtCaret(): null | LexicalNode; abstract insert(node: LexicalNode): this; - abstract getFlipped(): NodeCaret>; abstract getParentAtCaret(): null | ElementNode; constructor(origin: T) { this.origin = origin; @@ -325,6 +401,9 @@ abstract class AbstractCaret< getAdjacentCaret(): null | SiblingCaret { return $getSiblingCaret(this.getNodeAtCaret(), this.direction); } + getSiblingCaret(): SiblingCaret { + return $getSiblingCaret(this.origin, this.direction); + } remove(): this { const node = this.getNodeAtCaret(); if (node) { @@ -441,6 +520,20 @@ abstract class AbstractChildCaret< getChildCaret(): this { return this; } + isSameNodeCaret( + other: null | undefined | PointCaret, + ): other is ChildCaret { + return ( + other instanceof AbstractChildCaret && + this.direction === other.direction && + this.origin.is(other.origin) + ); + } + isSamePointCaret( + other: null | undefined | PointCaret, + ): other is ChildCaret { + return this.isSameNodeCaret(other); + } } class ChildCaretFirst extends AbstractChildCaret< @@ -507,14 +600,15 @@ abstract class AbstractSiblingCaret< implements SiblingCaret { readonly type = 'sibling'; - // TextPointCaret - offset?: number; getLatest(): SiblingCaret { const origin = this.origin.getLatest(); return origin === this.origin ? this : $getSiblingCaret(origin, this.direction); } + getSiblingCaret(): this { + return this; + } getParentAtCaret(): null | ElementNode { return this.origin.getParent(); } @@ -536,8 +630,92 @@ abstract class AbstractSiblingCaret< $getChildCaret(this.origin.getParentOrThrow(), dir) ); } + isSamePointCaret( + other: null | undefined | PointCaret, + ): other is SiblingCaret { + return ( + other instanceof AbstractSiblingCaret && + this.direction === other.direction && + this.origin.is(other.origin) + ); + } + isSameNodeCaret( + other: null | undefined | PointCaret, + ): other is T | SiblingCaret extends TextNode + ? TextPointCaret + : never { + return ( + (other instanceof AbstractSiblingCaret || + other instanceof AbstractTextPointCaret) && + this.direction === other.direction && + this.origin.is(other.origin) + ); + } } +abstract class AbstractTextPointCaret< + T extends TextNode, + D extends CaretDirection, + > + extends AbstractCaret + implements TextPointCaret +{ + readonly type = 'text'; + readonly offset: number; + abstract readonly direction: D; + constructor(origin: T, offset: number) { + super(origin); + this.offset = offset; + } + getLatest(): TextPointCaret { + const origin = this.origin.getLatest(); + return origin === this.origin + ? this + : $getTextPointCaret(origin, this.direction, this.offset); + } + getParentAtCaret(): null | ElementNode { + return this.origin.getParent(); + } + getChildCaret(): null { + return null; + } + getParentCaret(mode: RootMode): SiblingCaret | null { + return $getSiblingCaret( + $filterByMode(this.getParentAtCaret(), mode), + this.direction, + ); + } + getFlipped(): TextPointCaret> { + return $getTextPointCaret( + this.origin, + flipDirection(this.direction), + this.offset, + ); + } + isSamePointCaret( + other: null | undefined | PointCaret, + ): other is TextPointCaret { + return ( + other instanceof AbstractTextPointCaret && + this.direction === other.direction && + this.origin.is(other.origin) && + this.offset === other.offset + ); + } + isSameNodeCaret( + other: null | undefined | PointCaret, + ): other is SiblingCaret | TextPointCaret { + return ( + (other instanceof AbstractSiblingCaret || + other instanceof AbstractTextPointCaret) && + this.direction === other.direction && + this.origin.is(other.origin) + ); + } + getSiblingCaret(): SiblingCaret { + return $getSiblingCaret(this.origin, this.direction); + } +} /** * Guard to check if the given caret is specifically a TextPointCaret * @@ -547,24 +725,7 @@ abstract class AbstractSiblingCaret< export function $isTextPointCaret( caret: null | undefined | PointCaret, ): caret is TextPointCaret { - return ( - caret instanceof AbstractSiblingCaret && - $isTextNode(caret.origin) && - typeof caret.offset === 'number' - ); -} - -/** - * Guard to check the equivalence of TextPointCaret - * - * @param a The caret known to be a TextPointCaret - * @param b Any caret - * @returns true if b is a TextPointCaret with the same origin, direction and offset as a - */ -export function $isSameTextPointCaret< - T extends TextPointCaret, ->(a: T, b: null | undefined | PointCaret): b is T { - return $isTextPointCaret(b) && a.is(b) && a.offset === b.offset; + return caret instanceof AbstractTextPointCaret; } /** @@ -631,6 +792,39 @@ class SiblingCaretPrevious extends AbstractSiblingCaret< } } +class TextPointCaretNext extends AbstractTextPointCaret< + T, + 'next' +> { + readonly direction = 'next'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getNextSibling(); + } + insert(node: LexicalNode): this { + this.origin.insertAfter(node); + return this; + } +} + +class TextPointCaretPrevious extends AbstractTextPointCaret< + T, + 'previous' +> { + readonly direction = 'previous'; + getNodeAtCaret(): null | LexicalNode { + return this.origin.getPreviousSibling(); + } + insert(node: LexicalNode): this { + this.origin.insertBefore(node); + return this; + } +} + +const TEXT_CTOR = { + next: TextPointCaretNext, + previous: TextPointCaretPrevious, +} as const; + const SIBLING_CTOR = { next: SiblingCaretNext, previous: SiblingCaretPrevious, @@ -655,34 +849,14 @@ export function $getSiblingCaret< export function $getSiblingCaret< T extends LexicalNode, D extends CaretDirection, ->(origin: T | null, direction: D): null | SiblingCaret; +>(origin: null | T, direction: D): null | SiblingCaret; export function $getSiblingCaret( - origin: LexicalNode | null, + origin: null | LexicalNode, direction: CaretDirection, -): SiblingCaret | null { +): null | SiblingCaret { return origin ? new SIBLING_CTOR[direction](origin) : null; } -function $getLatestTextPointCaret( - this: TextPointCaret, -): TextPointCaret { - const origin = this.origin.getLatest(); - return origin === this.origin - ? this - : $getTextPointCaret(origin, this.direction, this.offset); -} - -function $getFlippedTextPointCaret< - T extends TextNode, - D extends CaretDirection, ->(this: TextPointCaret): TextPointCaret> { - return $getTextPointCaret( - this.origin, - flipDirection(this.direction), - this.offset, - ); -} - /** * Construct a TextPointCaret * @@ -698,12 +872,23 @@ export function $getTextPointCaret< origin: T, direction: D, offset: number | CaretDirection, -): TextPointCaret { - return Object.assign($getSiblingCaret(origin, direction), { - getFlipped: $getFlippedTextPointCaret, - getLatest: $getLatestTextPointCaret, - offset: $getTextNodeOffset(origin, offset), - }); +): TextPointCaret; +export function $getTextPointCaret< + T extends TextNode, + D extends CaretDirection, +>( + origin: null | T, + direction: D, + offset: number | CaretDirection, +): null | TextPointCaret; +export function $getTextPointCaret( + origin: TextNode | null, + direction: CaretDirection, + offset: number | CaretDirection, +): null | TextPointCaret { + return origin + ? new TEXT_CTOR[direction](origin, $getTextNodeOffset(origin, offset)) + : null; } /** @@ -773,7 +958,7 @@ export function $getChildCaret( */ export function $getChildCaretOrSelf( caret: Caret, -): PointCaret['direction']> | (Caret & null) { +): Caret | ChildCaret['direction']> { return (caret && caret.getChildCaret()) || caret; } @@ -808,13 +993,7 @@ class CaretRangeImpl implements CaretRange { : new CaretRangeImpl(anchor, focus, this.direction); } isCollapsed(): boolean { - return ( - this.anchor.is(this.focus) && - !( - $isTextPointCaret(this.anchor) && - !$isSameTextPointCaret(this.anchor, this.focus) - ) - ); + return this.anchor.isSamePointCaret(this.focus); } getTextSlices(): TextPointCaretSliceTuple { const getSlice = (k: 'anchor' | 'focus') => { @@ -828,7 +1007,7 @@ class CaretRangeImpl implements CaretRange { if (anchorSlice && focusSlice) { const {caret: anchorCaret} = anchorSlice; const {caret: focusCaret} = focusSlice; - if (anchorCaret.is(focusCaret)) { + if (anchorCaret.isSameNodeCaret(focusCaret)) { return [ $getTextPointCaretSlice( anchorCaret, @@ -841,18 +1020,21 @@ class CaretRangeImpl implements CaretRange { return [anchorSlice, focusSlice]; } iterNodeCarets(rootMode: RootMode): IterableIterator> { - const {anchor, focus} = this; + const anchor = $isTextPointCaret(this.anchor) + ? this.anchor.getSiblingCaret() + : this.anchor; + const {focus} = this; const isTextFocus = $isTextPointCaret(focus); const step = (state: NodeCaret) => - state.is(focus) + state.isSameNodeCaret(focus) ? null : $getAdjacentChildCaret(state) || state.getParentCaret(rootMode); return makeStepwiseIterator({ - initial: anchor.is(focus) ? null : step(anchor), + initial: anchor.isSameNodeCaret(focus) ? null : step(anchor), map: (state) => state, step, stop: (state: null | PointCaret): state is null => - state === null || (isTextFocus && focus.is(state)), + state === null || (isTextFocus && focus.isSameNodeCaret(state)), }); } [Symbol.iterator](): IterableIterator> { diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 54fba403917..3b8fde79f42 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -14,6 +14,7 @@ import type { PointCaret, RootMode, SiblingCaret, + TextPointCaret, } from './LexicalCaret'; import invariant from 'shared/invariant'; @@ -32,7 +33,11 @@ import { INTERNAL_$isBlock, } from '../LexicalUtils'; import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; -import {$createTextNode, $isTextNode} from '../nodes/LexicalTextNode'; +import { + $createTextNode, + $isTextNode, + type TextNode, +} from '../nodes/LexicalTextNode'; import { $getAdjacentChildCaret, $getCaretRange, @@ -86,15 +91,11 @@ export function $setPointFromCaret( ): void { const {origin, direction} = caret; const isNext = direction === 'next'; - if ($isSiblingCaret(caret)) { + if ($isTextPointCaret(caret)) { + point.set(origin.getKey(), caret.offset, 'text'); + } else if ($isSiblingCaret(caret)) { if ($isTextNode(origin)) { - point.set( - origin.getKey(), - $isTextPointCaret(caret) - ? caret.offset - : $getTextNodeOffset(origin, direction), - 'text', - ); + point.set(origin.getKey(), $getTextNodeOffset(origin, direction), 'text'); } else { point.set( origin.getParentOrThrow().getKey(), @@ -189,15 +190,15 @@ export function $rewindSiblingCaret< } function $getAnchorCandidates( - anchor: NodeCaret, + anchor: PointCaret, rootMode: RootMode = 'root', -): [NodeCaret, ...NodeCaret[]] { +): [PointCaret, ...NodeCaret[]] { // These candidates will be the anchor itself, the pointer to the anchor (if different), and then any parents of that - const carets: [NodeCaret, ...NodeCaret[]] = [anchor]; + const carets: [PointCaret, ...NodeCaret[]] = [anchor]; for ( let parent = $isChildCaret(anchor) ? anchor.getParentCaret(rootMode) - : anchor; + : anchor.getSiblingCaret(); parent !== null; parent = parent.getParentCaret(rootMode) ) { @@ -285,7 +286,7 @@ export function $removeTextFromCaretRange( nextCaret.offset, ); } - if (anchorCandidates[0].is(slice.caret)) { + if (anchorCandidates[0].isSameNodeCaret(slice.caret)) { anchorCandidates[0] = nextCaret; } } @@ -398,13 +399,22 @@ export function $normalizeCaret( * @param direction The desired direction * @returns A PointCaret in direction */ -export function $getCaretInDirection( - caret: PointCaret, +export function $getCaretInDirection< + Caret extends PointCaret, + D extends CaretDirection, +>( + caret: Caret, direction: D, -): PointCaret { - return ( - caret.direction === direction ? caret : caret.getFlipped() - ) as PointCaret; +): + | NodeCaret + | (Caret extends TextPointCaret + ? TextPointCaret + : never) { + return (caret.direction === direction ? caret : caret.getFlipped()) as + | NodeCaret + | (Caret extends TextPointCaret + ? TextPointCaret + : never); } /** diff --git a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts index 2dc643ac5f1..b5d01bf6107 100644 --- a/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts +++ b/packages/lexical/src/caret/__tests__/unit/LexicalCaret.test.ts @@ -93,7 +93,7 @@ describe('LexicalCaret', () => { const flipped = caret.getFlipped(); expect(flipped).not.toBe(caret); - expect(flipped.getFlipped().is(caret)).toBe(true); + expect(flipped.getFlipped().isSameNodeCaret(caret)).toBe(true); expect(flipped.direction).not.toBe(direction); expect(flipped.type).toBe('sibling'); expect(flipped.getNodeAtCaret()).toBe(null); @@ -220,7 +220,7 @@ describe('LexicalCaret', () => { const flipped = caret.getFlipped(); expect(flipped).not.toBe(caret); - expect(flipped.getFlipped().is(caret)); + expect(flipped.getFlipped().isSameNodeCaret(caret)); expect(flipped.origin.is(caret.getNodeAtCaret())).toBe(true); expect(flipped.direction).not.toBe(direction); expect(flipped.type).toBe('sibling'); @@ -234,14 +234,15 @@ describe('LexicalCaret', () => { for (const mode of ['root', 'shadowRoot'] as const) { expect( - $getSiblingCaret(paragraph, caret.direction).is( + $getSiblingCaret(paragraph, caret.direction).isSameNodeCaret( caret.getParentCaret(mode), ), ).toBe(true); expect( - $getSiblingCaret(paragraph, flipped.direction).is( - flipped.getParentCaret(mode), - ), + $getSiblingCaret( + paragraph, + flipped.direction, + ).isSameNodeCaret(flipped.getParentCaret(mode)), ).toBe(true); } @@ -471,7 +472,7 @@ describe('LexicalCaret', () => { direction: 'next', offset, origin: node, - type: 'sibling', + type: 'text', }, distance: 0, }, @@ -640,7 +641,7 @@ describe('LexicalCaret', () => { direction, offset: anchorOffset, origin: anchorNode, - type: 'sibling', + type: 'text', }, distance: direction === 'next' @@ -652,7 +653,7 @@ describe('LexicalCaret', () => { direction, offset: focusOffset, origin: focusNode, - type: 'sibling', + type: 'text', }, distance: direction === 'next' @@ -988,7 +989,7 @@ describe('LexicalCaret', () => { direction: 'next', offset, origin: node, - type: 'sibling', + type: 'text', }, distance: 0, }, @@ -1067,7 +1068,7 @@ describe('LexicalCaret', () => { direction, offset, origin: newOrigin, - type: 'sibling', + type: 'text', }; expect(resultRange).toMatchObject({ anchor: pt, @@ -1213,14 +1214,14 @@ describe('LexicalCaret', () => { direction, offset, origin: newOrigin, - type: 'sibling', + type: 'text', }, direction, focus: { direction, offset, origin: newOrigin, - type: 'sibling', + type: 'text', }, type: 'node-caret-range', }); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 3da47b0c33b..9609d23820b 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -31,7 +31,6 @@ export { $getTextPointCaret, $getTextPointCaretSlice, $isChildCaret, - $isSameTextPointCaret, $isSiblingCaret, $isTextPointCaret, $isTextPointCaretSlice, From f58da4e323c8f83442c6a9d9fce2255d4450ccca Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 21:02:00 -0800 Subject: [PATCH 57/69] clean up copy --- packages/lexical-utils/src/index.ts | 34 +++++++++++++++++-- packages/lexical/src/caret/LexicalCaret.ts | 38 ++++++++++++---------- packages/lexical/src/index.ts | 1 - 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index d40900a9849..ab59ea2c326 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -10,7 +10,6 @@ import { $cloneWithProperties, $createParagraphNode, $getAdjacentChildCaret, - $getAdjacentSiblingOrParentSiblingCaret, $getChildCaret, $getChildCaretAtIndex, $getChildCaretOrSelf, @@ -18,6 +17,7 @@ import { $getRoot, $getSelection, $getSiblingCaret, + $isChildCaret, $isElementNode, $isRangeSelection, $isRootOrShadowRoot, @@ -34,6 +34,7 @@ import { makeStepwiseIterator, type NodeCaret, type NodeKey, + RootMode, type SiblingCaret, } from 'lexical'; // This underscore postfixing is used as a hotfix so we do not @@ -259,7 +260,7 @@ function $dfsCaretIterator( if (state.isSameNodeCaret(endCaret)) { return null; } - if (state.type === 'child') { + if ($isChildCaret(state)) { depth++; } const rval = $getAdjacentSiblingOrParentSiblingCaret(state); @@ -829,3 +830,32 @@ export function $unwrapNode(node: ElementNode): void { node.getChildren(), ); } + +/** + * Returns the Node sibling when this exists, otherwise the closest parent sibling. For example + * R -> P -> T1, T2 + * -> P2 + * returns T2 for node T1, P2 for node T2, and null for node P2. + * @param node LexicalNode. + * @returns An array (tuple) containing the found Lexical node and the depth difference, or null, if this node doesn't exist. + */ +export function $getAdjacentSiblingOrParentSiblingCaret< + D extends CaretDirection, +>( + startCaret: NodeCaret, + rootMode: RootMode = 'root', +): null | [NodeCaret, number] { + let depthDiff = 0; + let caret = startCaret; + let nextCaret = $getAdjacentChildCaret(caret); + while (nextCaret === null) { + depthDiff--; + nextCaret = caret.getParentCaret(rootMode); + if (!nextCaret) { + return null; + } + caret = nextCaret; + nextCaret = $getAdjacentChildCaret(caret); + } + return nextCaret && [nextCaret, depthDiff]; +} diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 82b23cb53dd..f8ced146de4 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -63,15 +63,15 @@ export interface BaseCaret< * Get a new SiblingCaret with this same node */ getSiblingCaret: () => SiblingCaret; - /** Remove the getNodeAtCaret() node, if it exists */ + /** Remove the getNodeAtCaret() node that this caret is pointing towards, if it exists */ remove: () => this; /** - * Insert a node connected to origin in this direction. + * Insert a node connected to origin in this direction (before the node that this caret is pointing towards, if any existed). * For a `SiblingCaret` this is `origin.insertAfter(node)` for next, or `origin.insertBefore(node)` for previous. * For a `ChildCaret` this is `origin.splice(0, 0, [node])` for next or `origin.append(node)` for previous. */ insert: (node: LexicalNode) => this; - /** If getNodeAtCaret() is null then replace it with node, otherwise insert node */ + /** If getNodeAtCaret() is not null then replace it with node, otherwise insert node */ replaceOrInsert: (node: LexicalNode, includeChildren?: boolean) => this; /** * Splice an iterable (typically an Array) of nodes into this location. @@ -106,7 +106,7 @@ export interface CaretRange * An ElementNode origin will be yielded as a ChildCaret on enter, * and a SiblingCaret on leave. */ - iterNodeCarets: (rootMode: RootMode) => IterableIterator>; + iterNodeCarets: (rootMode?: RootMode) => IterableIterator>; /** * There are between zero and two non-null TextSliceCarets for a CaretRange. * Note that when anchor and focus share an origin node the second element @@ -135,9 +135,9 @@ export interface StepwiseIteratorConfig { * (using next or previous as direction, for symmetry with SiblingCaret). * * The differences between NodeCaret and PointType are: - * - NodeCaret can only be used to refer to an entire node. A PointType of text type can be used to refer to a specific location inside of a TextNode. - * - NodeCaret stores an origin node, type (sibling or child), and direction (next or previous). A PointType stores a type (text or element), the key of a node, and an offset within that node. - * - NodeCaret is directional and always refers to a very specific node, eliminating all ambiguity. PointType can refer to the location before or after a node depending on context. + * - NodeCaret can only be used to refer to an entire node (PointCaret is used when a full analog is needed). A PointType of text type can be used to refer to a specific location inside of a TextNode. + * - NodeCaret stores an origin node, type (sibling or child), and direction (next or previous). A PointType stores a type (text or element), the key of a node, and a text or child offset within that node. + * - NodeCaret is directional and always refers to a very specific node, eliminating all ambiguity. PointType can refer to the location before or at a node depending on context. * - NodeCaret is more robust to nearby mutations, as it relies only on a node's direct connections. An element Any change to the count of previous siblings in an element PointType will invalidate it. * - NodeCaret is designed to work more directly with the internal representation of the document tree, making it suitable for use in traversals without performing any redundant work. * @@ -150,12 +150,16 @@ export type NodeCaret = | ChildCaret; /** - * A PointCaret is a NodeCaret that also includes a specialized + * A PointCaret is a NodeCaret that also includes a * TextPointCaret type which refers to a specific offset of a TextNode. * This type is separate because it is not relevant to general node traversal * so it doesn't make sense to have it show up except when defining * a CaretRange and in those cases there will be at most two of them only * at the boundaries. + * + * The addition of TextPointCaret allows this type to represent any location + * that is representable by PointType, as the TextPointCaret refers to a + * specific offset within a TextNode. */ export type PointCaret = | TextPointCaret @@ -182,7 +186,7 @@ export interface SiblingCaret< * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root * @returns A SiblingCaret with the parent of this origin, or null if the parent is a root according to mode. */ - getParentCaret: (mode: RootMode) => null | SiblingCaret; + getParentCaret: (mode?: RootMode) => null | SiblingCaret; /** * Retun true if other is a SiblingCaret or TextPointCaret with the same * origin (by node key comparion) and direction. @@ -224,7 +228,7 @@ export interface ChildCaret< > extends BaseCaret { /** Get a new caret with the latest origin pointer */ getLatest: () => ChildCaret; - getParentCaret: (mode: RootMode) => null | SiblingCaret; + getParentCaret: (mode?: RootMode) => null | SiblingCaret; getParentAtCaret: () => T; /** Return this, the ChildCaret is already a child caret of its origin */ getChildCaret: () => this; @@ -289,7 +293,7 @@ export interface TextPointCaret< * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root * @returns A SiblingCaret with the parent of this origin, or null if the parent is a root according to mode. */ - getParentCaret: (mode: RootMode) => null | SiblingCaret; + getParentCaret: (mode?: RootMode) => null | SiblingCaret; /** * Retun true if other is a TextPointCaret or SiblingCaret with the same * origin (by node key comparion) and direction. @@ -447,7 +451,7 @@ abstract class AbstractCaret< // TODO: Optimize this to work directly with node internals for (const node of nodeIter) { if (nodesToRemove.size > 0) { - // TODO: For some reason `npm run tsc-extension` needs this annotation? + // For some reason `npm run tsc-extension` needs this annotation? const target: null | LexicalNode = caret.getNodeAtCaret(); if (target) { nodesToRemove.delete(target.getKey()); @@ -501,7 +505,7 @@ abstract class AbstractChildCaret< * @param mode 'root' to return null at the root, 'shadowRoot' to return null at the root or any shadow root * @returns A SiblingCaret with this origin, or null if origin is a root according to mode. */ - getParentCaret(mode: RootMode): null | SiblingCaret { + getParentCaret(mode: RootMode = 'root'): null | SiblingCaret { return $getSiblingCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, @@ -587,7 +591,7 @@ export function flipDirection( function $filterByMode( node: T | null, - mode: RootMode, + mode: RootMode = 'root', ): T | null { return MODE_PREDICATE[mode](node) ? null : node; } @@ -617,7 +621,7 @@ abstract class AbstractSiblingCaret< ? $getChildCaret(this.origin, this.direction) : null; } - getParentCaret(mode: RootMode): SiblingCaret | null { + getParentCaret(mode: RootMode = 'root'): SiblingCaret | null { return $getSiblingCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, @@ -679,7 +683,7 @@ abstract class AbstractTextPointCaret< getChildCaret(): null { return null; } - getParentCaret(mode: RootMode): SiblingCaret | null { + getParentCaret(mode: RootMode = 'root'): SiblingCaret | null { return $getSiblingCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, @@ -1019,7 +1023,7 @@ class CaretRangeImpl implements CaretRange { } return [anchorSlice, focusSlice]; } - iterNodeCarets(rootMode: RootMode): IterableIterator> { + iterNodeCarets(rootMode: RootMode = 'root'): IterableIterator> { const anchor = $isTextPointCaret(this.anchor) ? this.anchor.getSiblingCaret() : this.anchor; diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 9609d23820b..c9a074ab0ac 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -40,7 +40,6 @@ export { export { $caretFromPoint, $caretRangeFromSelection, - $getAdjacentSiblingOrParentSiblingCaret, $getCaretInDirection, $getCaretRangeInDirection, $getChildCaretAtIndex, From 2e52712b93228eba235918606cbbcc353afc34a1 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 21:19:57 -0800 Subject: [PATCH 58/69] tighten up text --- .../docs/concepts/traversals.md | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index fb03f267022..02635e10ef1 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -100,23 +100,41 @@ type can be used to represent any point in the document that `PointType` can rep ### TextPointCaret -`TextPointCaret` is a specialized `SiblingCaret` with any `TextNode` origin and an `offset` property +`TextPointCaret` is basically a `SiblingCaret` with a `TextNode` origin and an `offset` property * Constructed with `$getTextPointCaret(origin, direction, offset)` * The `offset` property is an absolute index into the string * The `next` direction implies all text content after `offset` * The `previous` direction implies all text content before `offset` +* All methods that are also present on `SiblingCaret` behave in the same way ### TextPointCaretSlice -`TextPointCaretSlice` is a wrapper for `TextPointCaret` that provides a signed `distance`, -it is just a data structure and has no methods. +`TextPointCaretSlice` is a wrapper for `TextPointCaret` that provides a signed `distance`. * Constructed with `$getTextPointCaretSlice(caret, distance)` +* There are convenience methods like `removeTextSlice()` and `getTextContent()`, + so it's not generally necessary to know the implementation details here * `Math.min(caret.offset, caret.offset + distance)` refers to the start offset of the slice * `Math.max(caret.offset, caret.offset + distance)` refers to the end offset of the slice * The `direction` of the caret is generally ignored when working with a `TextPointCaretSlice`, the slice is in absolute string coordinates +:::info + +The property name `distance` was chosen because `length` and `size` are +commonly used on other data structures in JavaScript and Lexical, and they +are overwhelmingly non-negative. While most uses of `distance` are also +non-negative, in some contexts such as computer graphics it is not uncommon +to use +[Signed distance functions](https://en.wikipedia.org/wiki/Signed_distance_function) +where the distance metric is signed. + +In SDF terms, the subset of the space is `\[offset, ∞)`. Any coordinate less +than the `offset` boundary is a negative distance; otherise the distance is +non-negative. + +::: + ### CaretRange `CaretRange` contains a pair of `PointCaret` that are in the same direction. It From 2cb9ee219a0869f3bcf2fbe0f3fb2ef20a5dd921 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 22:49:44 -0800 Subject: [PATCH 59/69] Fix #7081 --- packages/lexical/src/LexicalSelection.ts | 9 +++++ .../__tests__/unit/LexicalSelection.test.ts | 35 +++++++++++++++++++ .../lexical/src/caret/LexicalCaretUtils.ts | 11 ++++-- packages/lexical/src/index.ts | 4 +++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 59da0402f0d..837a32bb32b 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -15,6 +15,7 @@ import type {TextFormatType} from './nodes/LexicalTextNode'; import invariant from 'shared/invariant'; import { + $caretFromPoint, $caretRangeFromSelection, $createLineBreakNode, $createParagraphNode, @@ -24,7 +25,9 @@ import { $isLineBreakNode, $isRootNode, $isTextNode, + $normalizeCaret, $removeTextFromCaretRange, + $setPointFromCaret, $setSelection, $updateRangeSelectionFromCaretRange, SELECTION_CHANGE_COMMAND, @@ -846,6 +849,12 @@ export class RangeSelection implements BaseSelection { style, ); } + if (endPoint.type === 'element') { + $setPointFromCaret( + endPoint, + $normalizeCaret($caretFromPoint(endPoint, 'next')), + ); + } const startOffset = firstPoint.offset; let endOffset = endPoint.offset; const selectedNodes = this.getNodes(); diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index 41828c523a2..350fb321b90 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -14,6 +14,7 @@ import { ListNode, } from '@lexical/list'; import { + $createLineBreakNode, $createParagraphNode, $createRangeSelection, $createTextNode, @@ -1039,3 +1040,37 @@ describe('getNodes()', () => { }); }); }); + +describe('Regression #7081', () => { + initializeUnitTest((testEnv) => { + test('Firefox selection & paste before linebreak', () => { + testEnv.editor.update( + () => { + const textNode = + $createTextNode('XXXX').setStyle(`color: --color-test`); + const paragraphNode = $createParagraphNode(); + $getRoot() + .clear() + .append( + paragraphNode.append( + $createTextNode('ID: '), + textNode, + $createLineBreakNode(), + $createTextNode('aa'), + ), + ); + const selection = textNode.select(0); + selection.focus.set( + paragraphNode.getKey(), + 1 + textNode.getIndexWithinParent(), + 'element', + ); + selection.insertText('123'); + expect(textNode.isAttached()).toBe(true); + expect(textNode.getTextContent()).toBe('123'); + }, + {discrete: true}, + ); + }); + }); +}); diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 3b8fde79f42..7412819908c 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -213,10 +213,14 @@ function $getAnchorCandidates( * the earlier block. * * @param range The range to remove text and nodes from + * @param sliceMode If 'preserveEmptyTextPointCaret' it will leave an empty TextPointCaret at the anchor for insert if one exists, otherwise empty slices will be removed * @returns The new collapsed range (biased towards the earlier node) */ export function $removeTextFromCaretRange( initialRange: CaretRange, + sliceMode: + | 'removeEmptySlices' + | 'preserveEmptyTextSliceCaret' = 'removeEmptySlices', ): CaretRange { if (initialRange.isCollapsed()) { return initialRange; @@ -224,6 +228,7 @@ export function $removeTextFromCaretRange( // Always process removals in document order const rootMode = 'root'; const nextDirection = 'next'; + let sliceState = sliceMode; const range = $getCaretRangeInDirection(initialRange, nextDirection); const anchorCandidates = $getAnchorCandidates(range.anchor, rootMode); @@ -252,7 +257,7 @@ export function $removeTextFromCaretRange( } // Splice text at the anchor and/or origin. - // If the text is entirely selected then it is removed. + // If the text is entirely selected then it is removed (unless it is the first slice and sliceMode is preserveEmptyTextSliceCaret). // If it's a token with a non-empty selection then it is removed. // Segmented nodes will be copied to a plain text node with the same format // and style and set to normal mode. @@ -267,12 +272,14 @@ export function $removeTextFromCaretRange( ); const mode = origin.getMode(); if ( - Math.abs(slice.distance) === contentSize || + (Math.abs(slice.distance) === contentSize && + sliceState === 'removeEmptySlices') || (mode === 'token' && slice.distance !== 0) ) { // anchorCandidates[1] should still be valid, it is caretBefore caretBefore.remove(); } else if (slice.distance !== 0) { + sliceState = 'removeEmptySlices'; let nextCaret = slice.removeTextSlice(); if (mode === 'segmented') { const src = nextCaret.origin; diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index c9a074ab0ac..c72e969a5ae 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -28,9 +28,11 @@ export { $getChildCaret, $getChildCaretOrSelf, $getSiblingCaret, + $getTextNodeOffset, $getTextPointCaret, $getTextPointCaretSlice, $isChildCaret, + $isNodeCaret, $isSiblingCaret, $isTextPointCaret, $isTextPointCaretSlice, @@ -40,9 +42,11 @@ export { export { $caretFromPoint, $caretRangeFromSelection, + $getAdjacentSiblingOrParentSiblingCaret, $getCaretInDirection, $getCaretRangeInDirection, $getChildCaretAtIndex, + $normalizeCaret, $removeTextFromCaretRange, $rewindSiblingCaret, $setPointFromCaret, From 15d7245062de17b89a0a523d3a4d08eca162c822 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 23:25:58 -0800 Subject: [PATCH 60/69] Regression test for #7076 --- .../unit/LexicalTableSelection.test.tsx | 226 ++++++------------ packages/lexical-table/src/index.ts | 1 + 2 files changed, 70 insertions(+), 157 deletions(-) diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx index dace11e3e0a..ec0f7f0557f 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableSelection.test.tsx @@ -6,173 +6,85 @@ * */ -import {$createTableSelection} from '@lexical/table'; +import {$patchStyleText} from '@lexical/selection'; +import { + $computeTableMapSkipCellCheck, + $createTableCellNode, + $createTableNode, + $createTableRowNode, + $createTableSelectionFrom, + $isTableSelection, + TableMapType, + TableNode, + TableSelection, +} from '@lexical/table'; import { $createParagraphNode, $createTextNode, $getRoot, + $getSelection, $setSelection, - EditorState, - type LexicalEditor, - ParagraphNode, - RootNode, - TextNode, } from 'lexical'; -import {createTestEditor} from 'lexical/src/__tests__/utils'; -import {createRef, useEffect, useMemo} from 'react'; -import {createRoot, Root} from 'react-dom/client'; -import * as ReactTestUtils from 'shared/react-test-utils'; +import {initializeUnitTest} from 'lexical/src/__tests__/utils'; describe('table selection', () => { - let originalText: TextNode; - let parsedParagraph: ParagraphNode; - let parsedRoot: RootNode; - let parsedText: TextNode; - let paragraphKey: string; - let textKey: string; - let parsedEditorState: EditorState; - let reactRoot: Root; - let container: HTMLDivElement | null = null; - let editor: LexicalEditor | null = null; - - beforeEach(() => { - container = document.createElement('div'); - reactRoot = createRoot(container); - document.body.appendChild(container); - }); - - function useLexicalEditor( - rootElementRef: React.RefObject, - onError?: () => void, - ) { - const editorInHook = useMemo( - () => - createTestEditor({ - nodes: [], - onError: onError || jest.fn(), - theme: { - text: { - bold: 'editor-text-bold', - italic: 'editor-text-italic', - underline: 'editor-text-underline', - }, - }, - }), - [onError], - ); - - useEffect(() => { - const rootElement = rootElementRef.current; - - editorInHook.setRootElement(rootElement); - }, [rootElementRef, editorInHook]); - - return editorInHook; - } - - function init(onError?: () => void) { - const ref = createRef(); - - function TestBase() { - editor = useLexicalEditor(ref, onError); - - return
; - } - - ReactTestUtils.act(() => { - reactRoot.render(); - }); - } - - async function update(fn: () => void) { - editor!.update(fn); - - return Promise.resolve().then(); - } - - beforeEach(async () => { - init(); - - await update(() => { - const paragraph = $createParagraphNode(); - originalText = $createTextNode('Hello world'); - const selection = $createTableSelection(); - selection.set( - originalText.getKey(), - originalText.getKey(), - originalText.getKey(), - ); - $setSelection(selection); - paragraph.append(originalText); - $getRoot().append(paragraph); + initializeUnitTest((testEnv) => { + let tableNode: TableNode; + let tableMap: TableMapType; + let tableSelection: TableSelection; + + beforeEach(() => { + testEnv.editor.update(() => { + tableNode = $createTableNode(); + $getRoot() + .clear() + .append( + tableNode.append( + ...Array.from({length: 2}, (_0, row) => + $createTableRowNode().append( + ...Array.from({length: 2}, (_1, col) => + $createTableCellNode().append( + $createParagraphNode().append( + $createTextNode(`${col},${row}`), + ), + ), + ), + ), + ), + ), + ); + tableMap = $computeTableMapSkipCellCheck(tableNode, null, null)[0]; + tableSelection = $createTableSelectionFrom( + tableNode, + tableMap.at(0)!.at(0)!.cell, + tableMap.at(-1)!.at(-1)!.cell, + ); + $setSelection(tableSelection); + }); }); - const stringifiedEditorState = JSON.stringify( - editor!.getEditorState().toJSON(), - ); - - parsedEditorState = editor!.parseEditorState(stringifiedEditorState); - parsedEditorState.read(() => { - parsedRoot = $getRoot(); - parsedParagraph = parsedRoot.getFirstChild()!; - paragraphKey = parsedParagraph.getKey(); - parsedText = parsedParagraph.getFirstChild()!; - textKey = parsedText.getKey(); - }); - }); - - it('Parses the nodes of a stringified editor state', async () => { - expect(parsedRoot).toEqual({ - __cachedText: null, - __dir: 'ltr', - __first: paragraphKey, - __format: 0, - __indent: 0, - __key: 'root', - __last: paragraphKey, - __next: null, - __parent: null, - __prev: null, - __size: 1, - __style: '', - __textFormat: 0, - __textStyle: '', - __type: 'root', - }); - expect(parsedParagraph).toEqual({ - __dir: 'ltr', - __first: textKey, - __format: 0, - __indent: 0, - __key: paragraphKey, - __last: textKey, - __next: null, - __parent: 'root', - __prev: null, - __size: 1, - __style: '', - __textFormat: 0, - __textStyle: '', - __type: 'paragraph', - }); - expect(parsedText).toEqual({ - __detail: 0, - __format: 0, - __key: textKey, - __mode: 0, - __next: null, - __parent: paragraphKey, - __prev: null, - __style: '', - __text: 'Hello world', - __type: 'text', + describe('regression #7076', () => { + test('$patchStyleText works on a TableSelection', () => { + testEnv.editor.update( + () => { + const length = 4; + expect( + $getRoot() + .getAllTextNodes() + .map((node) => node.getStyle()), + ).toEqual(Array.from({length}, () => '')); + expect($isTableSelection($getSelection())).toBe(true); + $patchStyleText($getSelection()!, {color: 'red'}); + expect($isTableSelection($getSelection())).toBe(true); + expect( + $getRoot() + .getAllTextNodes() + .map((node) => node.getStyle()), + ).toEqual(Array.from({length}, () => 'color: red;')); + }, + {discrete: true}, + ); + }); }); }); - - it('Parses the text content of the editor state', async () => { - expect(parsedEditorState.read(() => $getRoot().__cachedText)).toBe(null); - expect(parsedEditorState.read(() => $getRoot().getTextContent())).toBe( - 'Hello world', - ); - }); }); diff --git a/packages/lexical-table/src/index.ts b/packages/lexical-table/src/index.ts index c4fe6ace096..83d249d5400 100644 --- a/packages/lexical-table/src/index.ts +++ b/packages/lexical-table/src/index.ts @@ -48,6 +48,7 @@ export type { } from './LexicalTableSelection'; export { $createTableSelection, + $createTableSelectionFrom, $isTableSelection, } from './LexicalTableSelection'; export type {HTMLTableElementWithWithTableSelectionState} from './LexicalTableSelectionHelpers'; From 8fb7c6ea95f29d53137bf1315be7b2c7cd1c97c3 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 23:34:06 -0800 Subject: [PATCH 61/69] simplify $forEachSelectedTextNode --- .../lexical-selection/src/lexical-node.ts | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts index 69f9822631b..81d3c90ca93 100644 --- a/packages/lexical-selection/src/lexical-node.ts +++ b/packages/lexical-selection/src/lexical-node.ts @@ -6,6 +6,7 @@ * */ import { + $caretRangeFromSelection, $createTextNode, $getCharacterOffsets, $getNodeByKey, @@ -328,26 +329,12 @@ export function $forEachSelectedTextNode( slicedTextNodes.get(node.getKey()) || [0, node.getTextContentSize()]; if ($isRangeSelection(selection)) { - const {anchor, focus} = selection; - const isBackwards = focus.isBefore(anchor); - const [startPoint, endPoint] = isBackwards - ? [focus, anchor] - : [anchor, focus]; - - if (startPoint.type === 'text' && startPoint.offset > 0) { - const endIndex = getSliceIndices(startPoint.getNode())[1]; - slicedTextNodes.set(startPoint.key, [ - Math.min(startPoint.offset, endIndex), - endIndex, - ]); - } - if (endPoint.type === 'text') { - const [startIndex, size] = getSliceIndices(endPoint.getNode()); - if (endPoint.offset < size) { - slicedTextNodes.set(endPoint.key, [ - startIndex, - Math.max(startIndex, endPoint.offset), - ]); + for (const slice of $caretRangeFromSelection(selection).getTextSlices()) { + if (slice) { + slicedTextNodes.set( + slice.caret.origin.getKey(), + slice.getSliceIndices(), + ); } } } From 54d97d4d81caa7da3b7deb2d0c33ab1b94b11722 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 2 Feb 2025 23:46:15 -0800 Subject: [PATCH 62/69] remove vestigial is method --- packages/lexical/src/caret/LexicalCaret.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index f8ced146de4..0b85bd46646 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -386,14 +386,6 @@ abstract class AbstractCaret< constructor(origin: T) { this.origin = origin; } - is(other: NodeCaret | null): boolean { - return ( - other !== null && - other.type === this.type && - other.direction === this.direction && - this.origin.is(other.origin) - ); - } [Symbol.iterator](): IterableIterator> { return makeStepwiseIterator({ initial: this.getAdjacentCaret(), From b70aed56b27963fddaed528a10700a335660cb0b Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 3 Feb 2025 22:52:49 -0800 Subject: [PATCH 63/69] Add terminology section --- .../docs/concepts/traversals.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index 02635e10ef1..7d8d6128e56 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -342,3 +342,61 @@ concurrently with mutations due to the problems with `PointType`. NodeCaret was born out of frustration with these APIs and a desire to unify it all in a coherent way to simplify and reduce errors in the core. + +## Terminology + +### Caret + +The term Caret was chosen because it is concise and specific term +specific to a point in a text document. Lexical is "an extensible text editor +framework" so it makes sense that navigation in the document would use +terms relevant to text. Most other terms such as Cursor or Point +already have meanings in Lexical and/or are less specific. + +See also: +- [Caret](https://developer.mozilla.org/en-US/docs/Glossary/Caret) +- [Caret navigation](https://en.wikipedia.org/wiki/Caret_navigation) + +### Origin + +The origin is the reference node for a NodeCaret. Absolute coordinates +are determined by combining this origin node and an "arrow" that points +towards to where the adjacent node is (or could be). The "arrow" is +determined by the `direction` and `type` of the caret. + +In a way this "arrow" is considered to be something like a unit vector +to indicate the direction, and adding it to an origin allows you to specify +an absolute location relative to that origin. Unlike the other coordinate +systems available in Lexical, it does not need recomputing whenever +siblings or a parent changes, so long as the origin node is still attached. + +See also: +- [Origin](https://en.wikipedia.org/wiki/Origin_(mathematics)) +- [Unit Vector](https://en.wikipedia.org/wiki/Unit_vector) + +### ChildCaret / SiblingCaret + +These were chosen because they match the existing methods on `ElementNode` +and `LexicalNode` (`getFirstChild`, `getNextSibling`, etc.) + +### Direction + +`'next'` and `'previous'` were chosen for direction mostly to match the +existing methods such as `getNextSibling()` that exist in DOM and in Lexical. +Using other words such as `'left'` and `'right'` would be ambiguous since +text direction can be bidirectional and already uses the terms left-to-right +and right-to-left. + +### Distance + +The property name `distance` was chosen for `TextPointCaretSlice` because +`length` and `size` are commonly used on other data structures in JavaScript +and Lexical, and they are overwhelmingly non-negative. While most uses of +`distance` are also non-negative, in some contexts such as computer graphics +it is not uncommon to use +[Signed distance functions](https://en.wikipedia.org/wiki/Signed_distance_function) +where the distance metric is signed. + +In SDF terms, the subset of the space is `\[offset, ∞)`. Any coordinate less +than the `offset` boundary is a negative distance; otherise the distance is +non-negative. From b2c6c18789915cee1b49a457190ec4eb5d20e8e1 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Mon, 3 Feb 2025 22:53:07 -0800 Subject: [PATCH 64/69] further cleanup of splitText --- packages/lexical/src/nodes/LexicalTextNode.ts | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index 38538181805..6f7f2312140 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -931,26 +931,29 @@ export class TextNode extends LexicalNode { errorOnReadOnly(); const self = this.getLatest(); const textContent = self.getTextContent(); + if (textContent === '') { + return []; + } const key = self.__key; const compositionKey = $getCompositionKey(); - const offsetsSet = new Set(splitOffsets); - const parts = []; const textLength = textContent.length; - let string = ''; - for (let i = 0; i < textLength; i++) { - if (string !== '' && offsetsSet.has(i)) { - parts.push(string); - string = ''; + splitOffsets.sort((a, b) => a - b); + splitOffsets.push(textLength); + const parts = []; + const splitOffsetsLength = splitOffsets.length; + for ( + let start = 0, offsetIndex = 0; + start < textLength && offsetIndex <= splitOffsetsLength; + offsetIndex++ + ) { + const end = splitOffsets[offsetIndex]; + if (end > start) { + parts.push(textContent.slice(start, end)); + start = end; } - string += textContent[i]; - } - if (string !== '') { - parts.push(string); } const partsLength = parts.length; - if (partsLength === 0) { - return []; - } else if (parts[0] === textContent) { + if (partsLength === 1) { return [self]; } const firstPart = parts[0]; @@ -997,7 +1000,7 @@ export class TextNode extends LexicalNode { for (let i = 1; i < partsLength; i++) { const part = parts[i]; const partSize = part.length; - const sibling = $createTextNode(part).getWritable(); + const sibling = $createTextNode(part); sibling.__format = format; sibling.__style = style; sibling.__detail = detail; From b0526b7e097ec8401750a4114fdec3dc428800aa Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 4 Feb 2025 15:45:43 -0800 Subject: [PATCH 65/69] Ensure getNodes() returns at most one node for a collapsed selection --- packages/lexical/src/LexicalSelection.ts | 11 ++++++++++- .../src/__tests__/unit/LexicalSelection.test.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 837a32bb32b..dec66f12560 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -559,7 +559,7 @@ export class RangeSelection implements BaseSelection { let nodes: Array; - if (firstNode.is(lastNode)) { + if (firstNode.is(lastNode) || this.isCollapsed()) { if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) { nodes = []; } else { @@ -583,6 +583,15 @@ export class RangeSelection implements BaseSelection { nodes.splice(0, deleteCount); } } + if (__DEV__) { + if (this.isCollapsed() && nodes.length > 1) { + invariant( + false, + 'RangeSelection.getNodes() returned %s > 1 nodes in a collapsed selection', + String(nodes.length), + ); + } + } if (!isCurrentlyReadOnlyMode()) { this._cachedNodes = nodes; } diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index 350fb321b90..0c6416f7956 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -1038,6 +1038,23 @@ describe('getNodes()', () => { {discrete: true}, ); }); + test('select an empty ListItemNode (collapsed)', () => { + testEnv.editor.update( + () => { + const emptyListItem = $createListItemNode(); + listItem2.insertBefore(emptyListItem); + const selection = $createRangeSelection(); + selection.anchor.set(emptyListItem.getKey(), 0, 'element'); + selection.focus.set(emptyListItem.getKey(), 0, 'element'); + expect(selection).toMatchObject({ + anchor: {key: emptyListItem.getKey(), offset: 0, type: 'element'}, + focus: {key: emptyListItem.getKey(), offset: 0, type: 'element'}, + }); + expect(selection.getNodes()).toEqual([emptyListItem]); + }, + {discrete: true}, + ); + }); }); }); From e5142eb30d9fd5f4047d7f8ed9f58ce5832a9e4a Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 7 Feb 2025 15:32:04 -0800 Subject: [PATCH 66/69] add more docs & test the docs --- .../docs/concepts/traversals.md | 115 +++++- packages/lexical/src/caret/LexicalCaret.ts | 2 +- .../__tests__/unit/docs-traversals.test.ts | 330 ++++++++++++++++++ 3 files changed, 438 insertions(+), 9 deletions(-) create mode 100644 packages/lexical/src/caret/__tests__/unit/docs-traversals.test.ts diff --git a/packages/lexical-website/docs/concepts/traversals.md b/packages/lexical-website/docs/concepts/traversals.md index 7d8d6128e56..6440b0cbf0f 100644 --- a/packages/lexical-website/docs/concepts/traversals.md +++ b/packages/lexical-website/docs/concepts/traversals.md @@ -11,6 +11,8 @@ the code. We expect higher-level utilities to be developed and shipped in @lexical/utils or another module at a later date. The current overhead should be less than 3kB in a production environment. +The NodeCaret API was introduced in lexical v0.25.0. + ## Concepts The core concept with `NodeCaret` is that you can represent any specific @@ -129,7 +131,7 @@ to use [Signed distance functions](https://en.wikipedia.org/wiki/Signed_distance_function) where the distance metric is signed. -In SDF terms, the subset of the space is `\[offset, ∞)`. Any coordinate less +In SDF terms, the subset of the space is `[offset, ∞)`. Any coordinate less than the `offset` boundary is a negative distance; otherise the distance is non-negative. @@ -165,6 +167,8 @@ treatment (splitting instead of removing, for example). ## Traversal Strategies + + ### Adjacent Caret Traversals The lowest level building block for traversals with NodeCaret is the adjacent caret @@ -192,7 +196,7 @@ For example, iterating all siblings: // the iterable. function *$iterSiblings( startCaret: NodeCaret -): Iterable> { +): Iterable> { // Note that we start at the adjacent caret. The start caret // points away from the origin node, so we do not want to // trick ourselves into thinking that that origin is included. @@ -206,6 +210,62 @@ function *$iterSiblings( } ``` +### Examples + +Given the following document tree, here are some examples of using the +adjacent node traversal: + +Root +* Paragraph A + * Text A1 + * Link A2 + * Text A3 + * Text A4 +* Paragraph B + * Text B1 +* Paragraph C + +```ts +// The root does not have sibling nodes +const carets = [...$getSiblingCaret($getRoot(), 'next')]; +expect(carets).toEqual([]); +``` + +```ts +// The adjacent node to a ChildNode is its first or last child +// and is always a SiblingNode. It does not traverse deeper. +const carets = [...$getChildCaret($getRoot(), 'next')]; + +// next starts at the first child +expect(carets).toEqual([ + $getSiblingCaret(paragraphA, 'next'), + $getSiblingCaret(paragraphB, 'next'), + $getSiblingCaret(paragraphC, 'next'), +]); + +// previous starts at the last child +const prevCarets = [...$getChildCaret($getRoot(), 'previous')]; +expect(prevCarets).toEqual([ + $getSiblingCaret(paragraphC, 'previous'), + $getSiblingCaret(paragraphB, 'previous'), + $getSiblingCaret(paragraphA, 'previous'), +]); +``` + +```ts +// The iteration starts at the node where the head of the "arrow" +// is pointing, which is away from the origin (the tail of the "arrow"). +const carets = [...$getSiblingCaret(paragraphB, 'next')]; +expect(carets).toEqual([ + $getSiblingCaret(paragraphC, 'next'), +]); + +const prevCarets = [...$getSiblingCaret(paragraphB, 'previous')]; +expect(prevCarets).toEqual([ + $getSiblingCaret(paragraphA, 'previous'), +]); +``` + ### Depth First Caret Traversals The strategy to do a depth-first caret traversal is to use an adjacent caret @@ -222,7 +282,7 @@ function *$iterCaretsDepthFirst( ): Iterable> { function step(prevCaret: NodeCaret): null | NodeCaret { // Get the adjacent SiblingCaret - const nextCaret = prevCaret.getAdjacent(); + const nextCaret = prevCaret.getAdjacentCaret(); return ( // If there is a sibling, try and get a ChildCaret from it (nextCaret && nextCaret.getChildCaret()) || @@ -260,7 +320,7 @@ function $iterCaretsDepthFirst( startCaret, // Use the root as the default end caret, but you might choose // to use startCaret.getParentCaret('root') for example - endCaret || $getBreadthNode($getRoot(), startCaret.direction) + endCaret || $getSiblingCaret($getRoot(), startCaret.direction) ); } ``` @@ -270,10 +330,10 @@ To get all nodes that are entirely selected between two carets: ```ts function *$iterNodesDepthFirst( startCaret: NodeCaret, - endCaret?: NodeCaret, -): Iterable> { + endCaret: NodeCaret = $getChildCaret($getRoot(), startCaret.direction), +): Iterable { const seen = new Set(); - for (const caret of $iterCaretsDepthFirst(startCaret, endCaret)) { + for (const caret of $getCaretRange(startCaret, endCaret)) { const {origin} = caret; if ($isChildCaret(caret)) { seen.add(origin.getKey()); @@ -286,6 +346,45 @@ function *$iterNodesDepthFirst( } ``` +### Examples + +Given the following document tree, here are some examples of using the +depth-first node traversal (with a `CaretRange`): + +Root +* Paragraph A + * Text A1 + * Link A2 + * Text A3 + * Text A4 +* Paragraph B + * Text B1 +* Paragraph C + +```ts +// A full traversal of the document from root +const carets = [...$getCaretRange( + // Start with the arrow pointing towards the first child of root + $getChildCaret($getRoot(), 'next'), + // End when the arrow points away from root + $getSiblingCaret($getRoot(), 'next'), +)]; +expect(carets).toEqual([ + $getChildCaret(paragraphA, 'next'), // enter Paragraph A + $getSiblingCaret(textA1, 'next'), + $getChildCaret(linkA2, 'next'), // enter Link A2 + $getSiblingCaret(textA3, 'next'), + $getSiblingCaret(linkA2, 'next'), // leave Link A2 + $getSiblingCaret(textA4, 'next'), + $getSiblingCaret(paragraphA, 'next'), // leave Paragraph A + $getChildCaret(paragraphB, 'next'), // enter Paragraph B + $getSiblingCaret(textB1, 'next'), + $getSiblingCaret(paragraphB, 'next'), // leave Paragraph B + $getChildCaret(paragraphC, 'next'), // enter Paragraph C + $getSiblingCaret(paragraphC, 'next'), // leave Paragraph C +]); +``` + ## Future Direction It's expected that higher-level abstractions will be built on top of this @@ -397,6 +496,6 @@ it is not uncommon to use [Signed distance functions](https://en.wikipedia.org/wiki/Signed_distance_function) where the distance metric is signed. -In SDF terms, the subset of the space is `\[offset, ∞)`. Any coordinate less +In SDF terms, the subset of the space is `[offset, ∞)`. Any coordinate less than the `offset` boundary is a negative distance; otherise the distance is non-negative. diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 0b85bd46646..886553d6c21 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -91,7 +91,7 @@ export interface BaseCaret< * A RangeSelection expressed as a pair of Carets */ export interface CaretRange - extends Iterable> { + extends Iterable> { readonly type: 'node-caret-range'; readonly direction: D; anchor: PointCaret; diff --git a/packages/lexical/src/caret/__tests__/unit/docs-traversals.test.ts b/packages/lexical/src/caret/__tests__/unit/docs-traversals.test.ts new file mode 100644 index 00000000000..513b5998dc0 --- /dev/null +++ b/packages/lexical/src/caret/__tests__/unit/docs-traversals.test.ts @@ -0,0 +1,330 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$createLinkNode, LinkNode} from '@lexical/link'; +import { + $createParagraphNode, + $createTextNode, + $getCaretRange, + $getChildCaret, + $getRoot, + $getSiblingCaret, + $isChildCaret, + $isElementNode, + CaretDirection, + LexicalNode, + NodeCaret, + NodeKey, + ParagraphNode, + SiblingCaret, + TextNode, +} from 'lexical'; + +import {initializeUnitTest} from '../../../__tests__/utils'; + +// The tests below here are intended to be basically copied from packages/lexical-website/docs/concepts/traversals.md +describe('traversals.md', () => { + initializeUnitTest((testEnv) => { + describe('Traversal Strategies', () => { + let paragraphA: ParagraphNode; + let textA1: TextNode; + let linkA2: LinkNode; + let textA3: TextNode; + let textA4: TextNode; + let paragraphB: ParagraphNode; + let textB1: TextNode; + let paragraphC: ParagraphNode; + beforeEach(() => { + testEnv.editor.update(() => { + paragraphA = $createParagraphNode(); + textA1 = $createTextNode('Text A1'); + linkA2 = $createLinkNode( + 'https://lexical.dev/docs/concepts/traversals', + ); + textA3 = $createTextNode('Text A3'); + textA4 = $createTextNode('Text A4'); + paragraphB = $createParagraphNode(); + textB1 = $createTextNode('Text B1'); + paragraphC = $createParagraphNode(); + // Root + // * Paragraph A + // * Text A1 + // * Link A2 + // * Text A3 + // * Text A4 + // * Paragraph B + // * Text B1 + // * Paragraph C + $getRoot() + .clear() + .append( + paragraphA.append(textA1, linkA2.append(textA3), textA4), + paragraphB.append(textB1), + paragraphC, + ); + }); + }); + describe('Adjacent Caret Traversals', () => { + test('$iterSiblings', () => { + // Note that NodeCaret already implements Iterable> in this + // way, so this function is not very useful. You can just use startCaret as + // the iterable. + function* $iterSiblings( + startCaret: NodeCaret, + ): Iterable> { + // Note that we start at the adjacent caret. The start caret + // points away from the origin node, so we do not want to + // trick ourselves into thinking that that origin is included. + for ( + let caret = startCaret.getAdjacentCaret(); + caret !== null; + caret = caret.getAdjacentCaret() + ) { + yield caret; + } + } + testEnv.editor.update( + () => { + expect([ + ...$iterSiblings($getChildCaret($getRoot(), 'next')), + ]).toEqual([ + $getSiblingCaret(paragraphA, 'next'), + $getSiblingCaret(paragraphB, 'next'), + $getSiblingCaret(paragraphC, 'next'), + ]); + // iterSiblings is the same as iterating the caret + expect([ + ...$iterSiblings($getChildCaret($getRoot(), 'next')), + ]).toEqual([...$getChildCaret($getRoot(), 'next')]); + }, + {discrete: true}, + ); + }); + test('root has no siblings', () => { + testEnv.editor.update( + () => { + // The root does not have sibling nodes + const carets = [...$getSiblingCaret($getRoot(), 'next')]; + expect(carets).toEqual([]); + }, + {discrete: true}, + ); + }); + test('root has paragraph children', () => { + testEnv.editor.update( + () => { + // The adjacent node to a ChildNode is its first or last child + // and is always a SiblingNode. It does not traverse deeper. + const carets = [...$getChildCaret($getRoot(), 'next')]; + + // next starts at the first child + expect(carets).toEqual([ + $getSiblingCaret(paragraphA, 'next'), + $getSiblingCaret(paragraphB, 'next'), + $getSiblingCaret(paragraphC, 'next'), + ]); + + // previous starts at the last child + const prevCarets = [...$getChildCaret($getRoot(), 'previous')]; + expect(prevCarets).toEqual([ + $getSiblingCaret(paragraphC, 'previous'), + $getSiblingCaret(paragraphB, 'previous'), + $getSiblingCaret(paragraphA, 'previous'), + ]); + }, + {discrete: true}, + ); + }); + test('iteration does not include the origin', () => { + testEnv.editor.update( + () => { + // The iteration starts at the node where the head of the "arrow" + // is pointing, which is away from the origin (the tail of the "arrow"). + const carets = [...$getSiblingCaret(paragraphB, 'next')]; + expect(carets).toEqual([$getSiblingCaret(paragraphC, 'next')]); + + const prevCarets = [...$getSiblingCaret(paragraphB, 'previous')]; + expect(prevCarets).toEqual([ + $getSiblingCaret(paragraphA, 'previous'), + ]); + }, + {discrete: true}, + ); + }); + }); + describe('Depth First Caret Traversals', () => { + describe('$iterCaretsDepthFirst', () => { + test('via generator', () => { + function* $iterCaretsDepthFirst( + startCaret: NodeCaret, + ): Iterable> { + function step(prevCaret: NodeCaret): null | NodeCaret { + // Get the adjacent SiblingCaret + const nextCaret = prevCaret.getAdjacentCaret(); + return ( + // If there is a sibling, try and get a ChildCaret from it + (nextCaret && nextCaret.getChildCaret()) || + // Return the sibling if there is one + nextCaret || + // Return a SiblingCaret of the parent, if there is one + prevCaret.getParentCaret('root') + ); + } + // You may add an additional check here, usually some specific + // caret to terminate the iteration with (such as the parent caret + // of startCaret): + // + // `caret !== null || caret.is(endCaret)` + // + for ( + let caret = step(startCaret); + caret !== null; + caret = step(caret) + ) { + yield caret; + } + } + testEnv.editor.update( + () => { + expect([ + ...$iterCaretsDepthFirst($getChildCaret($getRoot(), 'next')), + ]).toEqual([ + $getChildCaret(paragraphA, 'next'), + $getSiblingCaret(textA1, 'next'), + $getChildCaret(linkA2, 'next'), + $getSiblingCaret(textA3, 'next'), + $getSiblingCaret(linkA2, 'next'), + $getSiblingCaret(textA4, 'next'), + $getSiblingCaret(paragraphA, 'next'), + $getChildCaret(paragraphB, 'next'), + $getSiblingCaret(textB1, 'next'), + $getSiblingCaret(paragraphB, 'next'), + $getChildCaret(paragraphC, 'next'), + $getSiblingCaret(paragraphC, 'next'), + ]); + }, + {discrete: true}, + ); + }); + test('via CaretRange', () => { + function $iterCaretsDepthFirst( + startCaret: NodeCaret, + endCaret?: NodeCaret, + ): Iterable> { + return $getCaretRange( + startCaret, + // Use the root as the default end caret, but you might choose + // to use startCaret.getParentCaret('root') for example + endCaret || $getSiblingCaret($getRoot(), startCaret.direction), + ); + } + testEnv.editor.update( + () => { + expect([ + ...$iterCaretsDepthFirst($getChildCaret($getRoot(), 'next')), + ]).toEqual([ + $getChildCaret(paragraphA, 'next'), + $getSiblingCaret(textA1, 'next'), + $getChildCaret(linkA2, 'next'), + $getSiblingCaret(textA3, 'next'), + $getSiblingCaret(linkA2, 'next'), + $getSiblingCaret(textA4, 'next'), + $getSiblingCaret(paragraphA, 'next'), + $getChildCaret(paragraphB, 'next'), + $getSiblingCaret(textB1, 'next'), + $getSiblingCaret(paragraphB, 'next'), + $getChildCaret(paragraphC, 'next'), + $getSiblingCaret(paragraphC, 'next'), + ]); + }, + {discrete: true}, + ); + }); + }); + describe('$iterNodesDepthFirst', () => { + function* $iterNodesDepthFirst( + startCaret: NodeCaret, + endCaret: NodeCaret = $getChildCaret( + $getRoot(), + startCaret.direction, + ), + ): Iterable { + const seen = new Set(); + for (const caret of $getCaretRange(startCaret, endCaret)) { + const {origin} = caret; + if ($isChildCaret(caret)) { + seen.add(origin.getKey()); + } else if (!$isElementNode(origin) || seen.has(origin.getKey())) { + // If the origin is an element and we have not seen it as a ChildCaret + // then it was not entirely in the CaretRange + yield origin; + } + } + } + test('includes only wholly included nodes', () => { + testEnv.editor.update( + () => { + expect([ + ...$iterNodesDepthFirst( + $getChildCaret(paragraphA, 'next'), + $getChildCaret(paragraphC, 'next'), + ), + ]).toEqual([ + // already starting inside paragraphA + textA1, + // linkA2 is entered here + textA3, + // linkA2 is exited and included + linkA2, + textA4, + // paragraphA is exited but not included because it was never entered + // paragraphB is entered here + textB1, + // paragraphB is exited and included + paragraphB, + // paragraphC is entered but never exited so not included + ]); + }, + {discrete: true}, + ); + }); + }); + test('full traversal', () => { + testEnv.editor.update( + () => { + // A full traversal of the document from root + const carets = [ + ...$getCaretRange( + // Start with the arrow pointing towards the first child of root + $getChildCaret($getRoot(), 'next'), + // End when the arrow points away from root + $getSiblingCaret($getRoot(), 'next'), + ), + ]; + expect(carets).toEqual([ + $getChildCaret(paragraphA, 'next'), // enter Paragraph A + $getSiblingCaret(textA1, 'next'), + $getChildCaret(linkA2, 'next'), // enter Link A2 + $getSiblingCaret(textA3, 'next'), + $getSiblingCaret(linkA2, 'next'), // leave Link A2 + $getSiblingCaret(textA4, 'next'), + $getSiblingCaret(paragraphA, 'next'), // leave Paragraph A + $getChildCaret(paragraphB, 'next'), // enter Paragraph B + $getSiblingCaret(textB1, 'next'), + $getSiblingCaret(paragraphB, 'next'), // leave Paragraph B + $getChildCaret(paragraphC, 'next'), // enter Paragraph C + $getSiblingCaret(paragraphC, 'next'), // leave Paragraph C + ]); + }, + {discrete: true}, + ); + }); + }); + }); + }); +}); From 92b708e59370129b5f185af55217693815a65445 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 7 Feb 2025 17:52:49 -0800 Subject: [PATCH 67/69] flow types for node caret --- packages/lexical/flow/Lexical.js.flow | 165 ++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 32fce6535da..ba3c7abb47f 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -1011,3 +1011,168 @@ export interface SerializedEditorState { export type SerializedEditor = { editorState: SerializedEditorState, }; + +/** + * LexicalCaret + */ +export interface BaseCaret extends Iterable> { + +origin: T; + +type: Type; + +direction: D; + getParentAtCaret(): null | ElementNode; + getNodeAtCaret(): null | LexicalNode; + getAdjacentCaret(): null | SiblingCaret; + getSiblingCaret(): SiblingCaret; + remove(): BaseCaret; // this + insert(node: LexicalNode): BaseCaret; // this + replaceOrInsert(node: LexicalNode, includeChildren?: boolean): BaseCaret; // this + splice(deleteCount: number, nodes: Iterable, nodesDirection?: CaretDirection): BaseCaret; // this +} +export type CaretDirection = 'next' | 'previous'; +type FLIP_DIRECTION = {'next' : 'previous', 'previous': 'next'}; +export interface CaretRange extends Iterable> { + +type: 'node-caret-range'; + +direction: D; + anchor: PointCaret; + focus: PointCaret; + isCollapsed(): boolean; + iterNodeCarets(rootMode?: RootMode): Iterable>; + getTextSlices(): TextPointCaretSliceTuple; +} +export type CaretType = 'sibling' | 'child'; +export interface ChildCaret extends BaseCaret { + getLatest(): ChildCaret; + getParentCaret(mode?: RootMode): null | SiblingCaret; + getParentAtCaret(): T; + getChildCaret(): ChildCaret; + isSameNodeCaret(other: null | void | PointCaret): boolean; // other is ChildCaret; + isSamePointCaret(other: null | void | PointCaret): boolean; // other is ChildCaret; + getFlipped(): NodeCaret>; + // Refine chained types + remove(): ChildCaret; + insert(node: LexicalNode): ChildCaret; + replaceOrInsert(node: LexicalNode, includeChildren?: boolean): ChildCaret; + splice(deleteCount: number, nodes: Iterable, nodesDirection?: CaretDirection): ChildCaret; +} +export type FlipDirection = FLIP_DIRECTION[D]; +export type NodeCaret = ChildCaret | SiblingCaret; +export type PointCaret = ChildCaret | SiblingCaret | TextPointCaret; +export type RootMode = 'root' | 'shadowRoot'; +export interface SiblingCaret extends BaseCaret { + getLatest(): SiblingCaret; + getChildCaret(): null | ChildCaret; + getParentCaret(mode?: RootMode): null | SiblingCaret; + isSameNodeCaret(other: null | void | PointCaret): boolean; // other is SiblingCaret | T extends TextNode ? TextPointCaret : empty; + isSamePointCaret(other: null | void | PointCaret): boolean; // other is SiblingCaret; + getFlipped(): NodeCaret>; + // Refine chained types + remove(): SiblingCaret; + insert(node: LexicalNode): SiblingCaret; + replaceOrInsert(node: LexicalNode, includeChildren?: boolean): SiblingCaret; + splice(deleteCount: number, nodes: Iterable, nodesDirection?: CaretDirection): SiblingCaret; +} +export interface StepwiseIteratorConfig { + +initial: State | Stop; + stop(value: State | Stop): value is Stop; + step(value: State): State | Stop; + map(value: State): Value; +} +export interface TextPointCaret extends BaseCaret { + +offset: number; + getLatest(): TextPointCaret; + getChildCaret(): null; + getParentCaret(): null | SiblingCaret; + isSameNodeCaret(other: null | void | PointCaret): boolean; // other is TextPointCaret | SiblingCaret; + isSamePointCaret(other: null | void | PointCaret): boolean; // other is TextPointCaret; + getFlipped(): TextPointCaret>; +} +export interface TextPointCaretSlice { + +type: 'slice'; + +caret: TextPointCaret; + +distance: number; + getSliceIndices(): [startIndex: number, endIndex: number]; + getTextContent(): string; + getTextContentSize(): number; + removeTextSlice(): TextPointCaret; +} +export type TextPointCaretSliceTuple = [ + +anchorSlice: null | TextPointCaretSlice, + +focusSlice: null | TextPointCaretSlice, +]; +declare export function $getAdjacentChildCaret(caret: null | NodeCaret): null | NodeCaret; +declare export function $getCaretRange(anchor: PointCaret, focus: PointCaret): CaretRange; +declare export function $getChildCaret(origin: T, direction: D): ChildCaret, D> | Extract; +declare export function $getChildCaretOrSelf>(caret: Caret): Caret | ChildCaret['direction']>; +declare export function $getSiblingCaret(origin: T, direction: D): SiblingCaret, D> | Extract; +declare export function $getTextNodeOffset(origin: TextNode, offset: number | CaretDirection): number; +declare export function $getTextPointCaret(origin: T, direction: D, offset: number | CaretDirection): TextPointCaret, D> | Extract; +declare export function $getTextPointCaretSlice(caret: TextPointCaret, distance: number): TextPointCaretSlice; +declare export function $isChildCaret(caret: null | void | PointCaret): caret is ChildCaret; +declare export function $isNodeCaret(caret: null | void | PointCaret): caret is NodeCaret; +declare export function $isSiblingCaret(caret: null | void | PointCaret): caret is SiblingCaret; +declare export function $isTextPointCaret(caret: null | void | PointCaret): caret is TextPointCaret; +declare export function $isTextPointCaretSlice(caret: null | void | PointCaret | TextPointCaretSlice): caret is TextPointCaretSlice; +declare export function flipDirection(direction: D): FlipDirection; +declare export function makeStepwiseIterator( + config: StepwiseIteratorConfig, +): Iterator; +/** + * LexicalCaretUtils + */ +declare export function $caretFromPoint( + point: PointType, + direction: D, +): PointCaret; +declare export function $caretRangeFromSelection( + selection: RangeSelection, +): CaretRange; +declare export function $getAdjacentSiblingOrParentSiblingCaret< + D: CaretDirection, +>( + startCaret: NodeCaret, + rootMode?: RootMode +): null | [NodeCaret, number] +declare export function $getCaretInDirection< + Caret: PointCaret, + D: CaretDirection, +>( + caret: Caret, + direction: D, +): + | NodeCaret + | (Caret extends TextPointCaret + ? TextPointCaret + : empty); +declare export function $getCaretRangeInDirection( + range: CaretRange, + direction: D, +): CaretRange; +declare export function $getChildCaretAtIndex( + parent: ElementNode, + index: number, + direction: D, +): NodeCaret; +declare export function $normalizeCaret( + initialCaret: PointCaret, +): PointCaret; +declare export function $removeTextFromCaretRange( + initialRange: CaretRange, + sliceMode?: + | 'removeEmptySlices' + | 'preserveEmptyTextSliceCaret' +): CaretRange; +declare export function $rewindSiblingCaret< + T: LexicalNode, + D: CaretDirection, +>(caret: SiblingCaret): NodeCaret; +declare export function $setPointFromCaret( + point: PointType, + caret: PointCaret, +): void; +declare export function $setSelectionFromCaretRange( + caretRange: CaretRange, +): RangeSelection; +declare export function $updateRangeSelectionFromCaretRange( + selection: RangeSelection, + caretRange: CaretRange, +): void; From efca772994596831ff6e57e492b5d5862688c0a0 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 7 Feb 2025 18:05:02 -0800 Subject: [PATCH 68/69] update hermes and use boolean instead of guard syntax it does not parse --- package-lock.json | 106 +++++++++++++------------- package.json | 6 +- packages/lexical/flow/Lexical.js.flow | 2 +- 3 files changed, 58 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f127759b34..115454a238b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,8 +69,8 @@ "glob": "^10.4.1", "google-closure-compiler": "^20220202.0.0", "gzip-size": "^6.0.0", - "hermes-parser": "^0.20.1", - "hermes-transform": "^0.20.1", + "hermes-parser": "^0.26.0", + "hermes-transform": "^0.26.0", "husky": "^7.0.1", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", @@ -79,7 +79,7 @@ "minimist": "^1.2.5", "npm-run-all": "^4.1.5", "prettier": "^2.3.2", - "prettier-plugin-hermes-parser": "^0.20.1", + "prettier-plugin-hermes-parser": "^0.26.0", "prettier-plugin-organize-attributes": "^0.0.5", "prettier-plugin-tailwindcss": "^0.4.1", "proper-lockfile": "^4.1.2", @@ -19813,47 +19813,48 @@ } }, "node_modules/hermes-eslint": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/hermes-eslint/-/hermes-eslint-0.20.1.tgz", - "integrity": "sha512-EhdvFV6RkPIJvbqN8oqFZO1oF4NlPWMjhMjCWkUJX1YL1MZMfkF7nSdx6RKTq6xK17yo+Bgv88L21xuH9GtRpw==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/hermes-eslint/-/hermes-eslint-0.26.0.tgz", + "integrity": "sha512-SLMolASQQThPQ372LkA1z0GOSUtJ8LOsLolQnvskRiVfnoU+pVlR69cD75q3aEAncVGoAw+ZX+fFpMsBmVj0Gg==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", - "hermes-estree": "0.20.1", - "hermes-parser": "0.20.1" + "hermes-estree": "0.26.0", + "hermes-parser": "0.26.0" } }, "node_modules/hermes-estree": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.20.1.tgz", - "integrity": "sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.26.0.tgz", + "integrity": "sha512-If1T7lhfXnGlVLbnsmwerNB5cyJm2oIE8TN1UKEq6/OUX1nOGUhjXMpqAwZ1wkkn9Brda0VRyJEWOGT2GgVcAQ==", "dev": true }, "node_modules/hermes-parser": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.20.1.tgz", - "integrity": "sha512-BL5P83cwCogI8D7rrDCgsFY0tdYUtmFP9XaXtl2IQjC+2Xo+4okjfXintlTxcIwl4qeGddEl28Z11kbVIw0aNA==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.26.0.tgz", + "integrity": "sha512-fWT40jJ/BtlzuyiiQS7lzNIlB5h6flVZoN8Jn8v5l987HL5dK9s+/4+py0FaBmeIEROC2zxt5qMLwXFTPLQ7BA==", "dev": true, "dependencies": { - "hermes-estree": "0.20.1" + "hermes-estree": "0.26.0" } }, "node_modules/hermes-transform": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/hermes-transform/-/hermes-transform-0.20.1.tgz", - "integrity": "sha512-gpetyzAQvuLXVWIk8/I2A/ei/5+o8eT+QuSGd8FcWpXoYxVkYjVKLVNE9xKLsEkk2wQ1tXODY5OeOZoaz9jL7Q==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/hermes-transform/-/hermes-transform-0.26.0.tgz", + "integrity": "sha512-R1YFKP/7KuU3e5orhgNZO9vTWzt3KQxK0qxz5majto8RPUNtCC2SFQ9m2lPk4Jwc4lHeAtoKp+2z/UPVN88fRQ==", "dev": true, "dependencies": { "@babel/code-frame": "^7.16.0", "esquery": "^1.4.0", "flow-enums-runtime": "^0.0.6", - "hermes-eslint": "0.20.1", - "hermes-estree": "0.20.1", - "hermes-parser": "0.20.1" + "hermes-eslint": "0.26.0", + "hermes-estree": "0.26.0", + "hermes-parser": "0.26.0", + "string-width": "4.2.3" }, "peerDependencies": { "prettier": "^3.0.0 || ^2.7.1", - "prettier-plugin-hermes-parser": "0.20.1" + "prettier-plugin-hermes-parser": "0.26.0" } }, "node_modules/highlight.js": { @@ -29930,14 +29931,14 @@ } }, "node_modules/prettier-plugin-hermes-parser": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.20.1.tgz", - "integrity": "sha512-T6dfa1++ckTxd3MbLxS6sTv1T3yvTu1drahNt3g34hyCzSwYTKTByocLyhd1A9j9uCUlIPD+ogum7+i1h3+CEw==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.26.0.tgz", + "integrity": "sha512-ajjlx/0OQ+lcZQEnKEUDU7Srr9vw1OoMO6qZDIYmck1u7j9STiCStqb3RG1vE7FripXYAhquuI+oYG8BCTNC4g==", "dev": true, "dependencies": { - "hermes-estree": "0.20.1", - "hermes-parser": "0.20.1", - "prettier-plugin-hermes-parser": "0.20.1" + "hermes-estree": "0.26.0", + "hermes-parser": "0.26.0", + "prettier-plugin-hermes-parser": "0.26.0" }, "peerDependencies": { "prettier": "^3.0.0 || ^2.7.1" @@ -52083,43 +52084,44 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" }, "hermes-eslint": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/hermes-eslint/-/hermes-eslint-0.20.1.tgz", - "integrity": "sha512-EhdvFV6RkPIJvbqN8oqFZO1oF4NlPWMjhMjCWkUJX1YL1MZMfkF7nSdx6RKTq6xK17yo+Bgv88L21xuH9GtRpw==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/hermes-eslint/-/hermes-eslint-0.26.0.tgz", + "integrity": "sha512-SLMolASQQThPQ372LkA1z0GOSUtJ8LOsLolQnvskRiVfnoU+pVlR69cD75q3aEAncVGoAw+ZX+fFpMsBmVj0Gg==", "dev": true, "requires": { "esrecurse": "^4.3.0", - "hermes-estree": "0.20.1", - "hermes-parser": "0.20.1" + "hermes-estree": "0.26.0", + "hermes-parser": "0.26.0" } }, "hermes-estree": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.20.1.tgz", - "integrity": "sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.26.0.tgz", + "integrity": "sha512-If1T7lhfXnGlVLbnsmwerNB5cyJm2oIE8TN1UKEq6/OUX1nOGUhjXMpqAwZ1wkkn9Brda0VRyJEWOGT2GgVcAQ==", "dev": true }, "hermes-parser": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.20.1.tgz", - "integrity": "sha512-BL5P83cwCogI8D7rrDCgsFY0tdYUtmFP9XaXtl2IQjC+2Xo+4okjfXintlTxcIwl4qeGddEl28Z11kbVIw0aNA==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.26.0.tgz", + "integrity": "sha512-fWT40jJ/BtlzuyiiQS7lzNIlB5h6flVZoN8Jn8v5l987HL5dK9s+/4+py0FaBmeIEROC2zxt5qMLwXFTPLQ7BA==", "dev": true, "requires": { - "hermes-estree": "0.20.1" + "hermes-estree": "0.26.0" } }, "hermes-transform": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/hermes-transform/-/hermes-transform-0.20.1.tgz", - "integrity": "sha512-gpetyzAQvuLXVWIk8/I2A/ei/5+o8eT+QuSGd8FcWpXoYxVkYjVKLVNE9xKLsEkk2wQ1tXODY5OeOZoaz9jL7Q==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/hermes-transform/-/hermes-transform-0.26.0.tgz", + "integrity": "sha512-R1YFKP/7KuU3e5orhgNZO9vTWzt3KQxK0qxz5majto8RPUNtCC2SFQ9m2lPk4Jwc4lHeAtoKp+2z/UPVN88fRQ==", "dev": true, "requires": { "@babel/code-frame": "^7.16.0", "esquery": "^1.4.0", "flow-enums-runtime": "^0.0.6", - "hermes-eslint": "0.20.1", - "hermes-estree": "0.20.1", - "hermes-parser": "0.20.1" + "hermes-eslint": "0.26.0", + "hermes-estree": "0.26.0", + "hermes-parser": "0.26.0", + "string-width": "4.2.3" } }, "highlight.js": { @@ -58706,14 +58708,14 @@ "dev": true }, "prettier-plugin-hermes-parser": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.20.1.tgz", - "integrity": "sha512-T6dfa1++ckTxd3MbLxS6sTv1T3yvTu1drahNt3g34hyCzSwYTKTByocLyhd1A9j9uCUlIPD+ogum7+i1h3+CEw==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.26.0.tgz", + "integrity": "sha512-ajjlx/0OQ+lcZQEnKEUDU7Srr9vw1OoMO6qZDIYmck1u7j9STiCStqb3RG1vE7FripXYAhquuI+oYG8BCTNC4g==", "dev": true, "requires": { - "hermes-estree": "0.20.1", - "hermes-parser": "0.20.1", - "prettier-plugin-hermes-parser": "0.20.1" + "hermes-estree": "0.26.0", + "hermes-parser": "0.26.0", + "prettier-plugin-hermes-parser": "0.26.0" } }, "prettier-plugin-organize-attributes": { diff --git a/package.json b/package.json index 535b3e9f0b5..ed690ba2cbb 100644 --- a/package.json +++ b/package.json @@ -163,8 +163,8 @@ "glob": "^10.4.1", "google-closure-compiler": "^20220202.0.0", "gzip-size": "^6.0.0", - "hermes-parser": "^0.20.1", - "hermes-transform": "^0.20.1", + "hermes-parser": "^0.26.0", + "hermes-transform": "^0.26.0", "husky": "^7.0.1", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", @@ -173,7 +173,7 @@ "minimist": "^1.2.5", "npm-run-all": "^4.1.5", "prettier": "^2.3.2", - "prettier-plugin-hermes-parser": "^0.20.1", + "prettier-plugin-hermes-parser": "^0.26.0", "prettier-plugin-organize-attributes": "^0.0.5", "prettier-plugin-tailwindcss": "^0.4.1", "proper-lockfile": "^4.1.2", diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index ba3c7abb47f..5e0c8f46394 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -1073,7 +1073,7 @@ export interface SiblingCaret { +initial: State | Stop; - stop(value: State | Stop): value is Stop; + stop(value: State | Stop): boolean; //value is Stop; step(value: State): State | Stop; map(value: State): Value; } From 92e3c893360b19fb69243adbe26bc3d84e01152e Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 7 Feb 2025 18:23:03 -0800 Subject: [PATCH 69/69] refactor StepwiseIteratorConfig stop to hasNext so the guards do not lie --- packages/lexical-utils/src/index.ts | 5 +++-- packages/lexical/flow/Lexical.js.flow | 6 +++--- packages/lexical/src/caret/LexicalCaret.ts | 12 ++++++------ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index ab59ea2c326..0ffc944d9cc 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -21,6 +21,7 @@ import { $isElementNode, $isRangeSelection, $isRootOrShadowRoot, + $isSiblingCaret, $isTextNode, $rewindSiblingCaret, $setSelection, @@ -254,6 +255,7 @@ function $dfsCaretIterator( ); let depth = startDepth; return makeStepwiseIterator({ + hasNext: (state): state is NodeCaret<'next'> => state !== null, initial: startCaret, map: (state) => ({depth, node: state.origin}), step: (state: NodeCaret<'next'>) => { @@ -270,7 +272,6 @@ function $dfsCaretIterator( depth += rval[1]; return rval[0]; }, - stop: (state): state is null => state === null, }); } @@ -800,6 +801,7 @@ function $childIterator( ): IterableIterator { const seen = __DEV__ ? new Set() : null; return makeStepwiseIterator({ + hasNext: $isSiblingCaret, initial: startCaret.getAdjacentCaret(), map: (caret) => { const origin = caret.origin.getLatest(); @@ -815,7 +817,6 @@ function $childIterator( return origin; }, step: (caret: SiblingCaret) => caret.getAdjacentCaret(), - stop: (v): v is null => v === null, }); } diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 5e0c8f46394..be1ff4225c6 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -1073,9 +1073,9 @@ export interface SiblingCaret { +initial: State | Stop; - stop(value: State | Stop): boolean; //value is Stop; - step(value: State): State | Stop; - map(value: State): Value; + +hasNext: (value: State | Stop) => implies value is State; + +step: (value: State) => State | Stop; + +map: (value: State) => Value; } export interface TextPointCaret extends BaseCaret { +offset: number; diff --git a/packages/lexical/src/caret/LexicalCaret.ts b/packages/lexical/src/caret/LexicalCaret.ts index 886553d6c21..4f35d87ad61 100644 --- a/packages/lexical/src/caret/LexicalCaret.ts +++ b/packages/lexical/src/caret/LexicalCaret.ts @@ -122,7 +122,7 @@ export interface CaretRange export interface StepwiseIteratorConfig { readonly initial: State | Stop; - readonly stop: (value: State | Stop) => value is Stop; + readonly hasNext: (value: State | Stop) => value is State; readonly step: (value: State) => State | Stop; readonly map: (value: State) => Value; } @@ -388,10 +388,10 @@ abstract class AbstractCaret< } [Symbol.iterator](): IterableIterator> { return makeStepwiseIterator({ + hasNext: $isSiblingCaret, initial: this.getAdjacentCaret(), map: (caret) => caret, step: (caret: SiblingCaret) => caret.getAdjacentCaret(), - stop: (v): v is null => v === null, }); } getAdjacentCaret(): null | SiblingCaret { @@ -1026,11 +1026,11 @@ class CaretRangeImpl implements CaretRange { ? null : $getAdjacentChildCaret(state) || state.getParentCaret(rootMode); return makeStepwiseIterator({ + hasNext: (state: null | NodeCaret): state is NodeCaret => + state !== null && !(isTextFocus && focus.isSameNodeCaret(state)), initial: anchor.isSameNodeCaret(focus) ? null : step(anchor), map: (state) => state, step, - stop: (state: null | PointCaret): state is null => - state === null || (isTextFocus && focus.isSameNodeCaret(state)), }); } [Symbol.iterator](): IterableIterator> { @@ -1157,14 +1157,14 @@ export function $getCaretRange( export function makeStepwiseIterator( config: StepwiseIteratorConfig, ): IterableIterator { - const {initial, stop, step, map} = config; + const {initial, hasNext, step, map} = config; let state = initial; return { [Symbol.iterator]() { return this; }, next(): IteratorResult { - if (stop(state)) { + if (!hasNext(state)) { return {done: true, value: undefined}; } const rval = {done: false, value: map(state)};

linkfoo