UNPKG

lexical

Version:

Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.

1,424 lines (1,364 loc) • 47.3 kB
/** * 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 devInvariant from '@lexical/internal/devInvariant'; import invariant from '@lexical/internal/invariant'; import {$getRoot, $isRootOrShadowRoot} from '../LexicalUtils'; import {$isElementNode, ElementNode} from '../nodes/LexicalElementNode'; import {$isRootNode} from '../nodes/LexicalRootNode'; import {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<D extends CaretDirection> = (typeof FLIP_DIRECTION)[D]; /** * 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 = '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, * and 'shadowRoot' will stop at the document root or any shadow root * (per {@link $isRootOrShadowRoot}). */ export type RootMode = 'root' | 'shadowRoot'; const FLIP_DIRECTION = { next: 'previous', previous: 'next', } as const; /** @noInheritDoc */ export interface BaseCaret< T extends LexicalNode, D extends CaretDirection, Type, > extends Iterable<SiblingCaret<LexicalNode, D>> { /** The origin node of this caret, typically this is what you will use in traversals */ readonly origin: T; /** 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; /** 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<LexicalNode, D>; /** * Get a new SiblingCaret with this same node */ getSiblingCaret: () => SiblingCaret<T, D>; /** Remove the getNodeAtCaret() node that this caret is pointing towards, if it exists */ remove: () => this; /** * 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 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. * * @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<LexicalNode>, nodesDirection?: CaretDirection, ) => this; } /** * A RangeSelection expressed as a pair of Carets */ export interface CaretRange< D extends CaretDirection = CaretDirection, > extends Iterable<NodeCaret<D>> { readonly type: 'node-caret-range'; readonly direction: D; anchor: PointCaret<D>; focus: PointCaret<D>; /** 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 this does not include any text slices represented by the anchor and/or * focus. Those are accessed separately from getTextSlices. * * An ElementNode origin will be yielded as a ChildCaret on enter, * and a SiblingCaret on leave. */ iterNodeCarets: (rootMode?: RootMode) => IterableIterator<NodeCaret<D>>; /** * 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 because the slice is entirely represented by the first element. * * `[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<D>; } export interface StepwiseIteratorConfig<State, Stop, Value> { readonly initial: State | Stop; readonly hasNext: (value: State | Stop) => value is State; readonly step: (value: State) => State | Stop; readonly map: (value: State) => Value; } /** * 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 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 SiblingCaret). * * The differences between NodeCaret and PointType are: * - 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. * * 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<D extends CaretDirection = CaretDirection> = | SiblingCaret<LexicalNode, D> | ChildCaret<ElementNode, D>; /** * 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<D extends CaretDirection = CaretDirection> = | TextPointCaret<TextNode, D> | SiblingCaret<LexicalNode, D> | ChildCaret<ElementNode, D>; /** * A SiblingCaret points from an origin LexicalNode towards its next or previous sibling. */ export interface SiblingCaret< T extends LexicalNode = LexicalNode, D extends CaretDirection = CaretDirection, > extends BaseCaret<T, D, 'sibling'> { /** Get a new caret with the latest origin pointer */ getLatest: () => SiblingCaret<T, D>; /** * 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 | ChildCaret<T & ElementNode, D>; /** * 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<ElementNode, D>; /** * Return true if other is a SiblingCaret or TextPointCaret with the same * origin (by node key comparison) and direction. */ isSameNodeCaret: ( other: null | undefined | PointCaret, ) => other is SiblingCaret<T, D> | T extends TextNode ? TextPointCaret<T & TextNode, D> : never; /** * Return true if other is a SiblingCaret with the same * origin (by node key comparison) and direction. */ isSamePointCaret: ( other: null | undefined | PointCaret, ) => other is SiblingCaret<T, D>; /** * 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<FlipDirection<D>>; } /** * A ChildCaret points from an origin ElementNode towards its first or last child. */ export interface ChildCaret< T extends ElementNode = ElementNode, D extends CaretDirection = CaretDirection, > extends BaseCaret<T, D, 'child'> { /** Get a new caret with the latest origin pointer */ getLatest: () => ChildCaret<T, D>; getParentCaret: (mode?: RootMode) => null | SiblingCaret<T, D>; getParentAtCaret: () => T; /** Return this, the ChildCaret is already a child caret of its origin */ getChildCaret: () => this; /** * Return true if other is a ChildCaret with the same * origin (by node key comparison) and direction. */ isSameNodeCaret: ( other: null | undefined | PointCaret, ) => other is ChildCaret<T, D>; /** * Return true if other is a ChildCaret with the same * origin (by node key comparison) and direction. */ isSamePointCaret: ( other: null | undefined | PointCaret, ) => other is ChildCaret<T, D>; /** * 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<FlipDirection<D>>; } /** * 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. * * 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 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 BaseCaret<T, D, 'text'> { /** The offset into the string */ readonly offset: number; /** Get a new caret with the latest origin pointer */ getLatest: () => TextPointCaret<T, D>; /** * 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<ElementNode, D>; /** * Return true if other is a TextPointCaret or SiblingCaret with the same * origin (by node key comparison) and direction. */ isSameNodeCaret: ( other: null | undefined | PointCaret, ) => other is TextPointCaret<T, D> | SiblingCaret<T, D>; /** * Return true if other is a ChildCaret with the same * origin (by node key comparison) and direction. */ isSamePointCaret: ( other: null | undefined | PointCaret, ) => other is TextPointCaret<T, D>; /** * 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<T, FlipDirection<D>>; } /** * 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. */ export interface TextPointCaretSlice< T extends TextNode = TextNode, D extends CaretDirection = CaretDirection, > { readonly type: 'slice'; readonly caret: TextPointCaret<T, D>; 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; /** * 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<T, D>; } /** * A utility type to specify that a CaretRange may have zero, * 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<D extends CaretDirection> = readonly [ anchorSlice: null | TextPointCaretSlice<TextNode, D>, focusSlice: null | TextPointCaretSlice<TextNode, D>, ]; abstract class AbstractCaret< T extends LexicalNode, D extends CaretDirection, Type, > implements BaseCaret<T, D, Type> { abstract readonly type: Type; abstract readonly direction: D; readonly origin: T; abstract getNodeAtCaret(): null | LexicalNode; abstract insert(node: LexicalNode): this; abstract getParentAtCaret(): null | ElementNode; constructor(origin: T) { this.origin = origin; } [Symbol.iterator](): IterableIterator<SiblingCaret<LexicalNode, D>> { return makeStepwiseIterator({ hasNext: $isSiblingCaret, initial: this.getAdjacentCaret(), map: caret => caret, step: (caret: SiblingCaret<LexicalNode, D>) => caret.getAdjacentCaret(), }); } getAdjacentCaret(): null | SiblingCaret<LexicalNode, D> { return $getSiblingCaret(this.getNodeAtCaret(), this.direction); } getSiblingCaret(): SiblingCaret<T, D> { return $getSiblingCaret(this.origin, 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<LexicalNode>, nodesDirection: CaretDirection = 'next', ): this { const nodeIter = nodesDirection === this.direction ? nodes : Array.from(nodes).reverse(); let caret: SiblingCaret<LexicalNode, D> | this = this; const parent = this.getParentAtCaret(); const nodesToRemove = new Map<NodeKey, LexicalNode>(); // 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) { // For some reason `pnpm run tsc-extension` needs this annotation? const target: null | LexicalNode = caret.getNodeAtCaret(); 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); } } else { invariant( target !== null, 'NodeCaret.splice: Underflow of expected nodesToRemove during splice (keys: %s)', Array.from(nodesToRemove).join(' '), ); } } else { caret.insert(node); } caret = $getSiblingCaret(node, this.direction); } for (const node of nodesToRemove.values()) { node.remove(); } return this; } } abstract class AbstractChildCaret< T extends ElementNode, D extends CaretDirection, > extends AbstractCaret<T, D, 'child'> implements ChildCaret<T, D> { readonly type = 'child'; getLatest(): ChildCaret<T, D> { const origin = this.origin.getLatest(); return origin === this.origin ? this : $getChildCaret(origin, this.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 SiblingCaret with this origin, or null if origin is a root according to mode. */ getParentCaret(mode: RootMode = 'root'): null | SiblingCaret<T, D> { return $getSiblingCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, ); } getFlipped(): NodeCaret<FlipDirection<D>> { const dir = flipDirection(this.direction); return ( $getSiblingCaret(this.getNodeAtCaret(), dir) || $getChildCaret(this.origin, dir) ); } getParentAtCaret(): T { return this.origin; } getChildCaret(): this { return this; } isSameNodeCaret( other: null | undefined | PointCaret, ): other is ChildCaret<T, D> { return ( other instanceof AbstractChildCaret && this.direction === other.direction && this.origin.is(other.origin) ); } isSamePointCaret( other: null | undefined | PointCaret, ): other is ChildCaret<T, D> { return this.isSameNodeCaret(other); } } class ChildCaretFirst<T extends ElementNode> extends AbstractChildCaret< 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 ChildCaretLast<T extends ElementNode> extends AbstractChildCaret< 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; /** * 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<D extends CaretDirection>( direction: D, ): FlipDirection<D> { return FLIP_DIRECTION[direction]; } function $filterByMode<T extends ElementNode>( node: T | null, mode: RootMode = 'root', ): T | null { return MODE_PREDICATE[mode](node) ? null : node; } abstract class AbstractSiblingCaret< T extends LexicalNode, D extends CaretDirection, > extends AbstractCaret<T, D, 'sibling'> implements SiblingCaret<T, D> { readonly type = 'sibling'; getLatest(): SiblingCaret<T, D> { 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(); } getChildCaret(): ChildCaret<T & ElementNode, D> | null { return $isElementNode(this.origin) ? $getChildCaret(this.origin, this.direction) : null; } getParentCaret(mode: RootMode = 'root'): SiblingCaret<ElementNode, D> | null { return $getSiblingCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, ); } getFlipped(): NodeCaret<FlipDirection<D>> { const dir = flipDirection(this.direction); return ( $getSiblingCaret(this.getNodeAtCaret(), dir) || $getChildCaret(this.origin.getParentOrThrow(), dir) ); } isSamePointCaret( other: null | undefined | PointCaret, ): other is SiblingCaret<T, D> { return ( other instanceof AbstractSiblingCaret && this.direction === other.direction && this.origin.is(other.origin) ); } isSameNodeCaret( other: null | undefined | PointCaret, ): other is T | SiblingCaret<T, D> extends TextNode ? TextPointCaret<T & TextNode, D> : 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<T, D, 'text'> implements TextPointCaret<T, D> { readonly type = 'text'; readonly offset: number; abstract readonly direction: D; constructor(origin: T, offset: number) { super(origin); this.offset = offset; } getLatest(): TextPointCaret<T, D> { 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 = 'root'): SiblingCaret<ElementNode, D> | null { return $getSiblingCaret( $filterByMode(this.getParentAtCaret(), mode), this.direction, ); } getFlipped(): TextPointCaret<T, FlipDirection<D>> { return $getTextPointCaret( this.origin, flipDirection(this.direction), this.offset, ); } isSamePointCaret( other: null | undefined | PointCaret, ): other is TextPointCaret<T, D> { 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<T, D> | TextPointCaret<T, D> { return ( (other instanceof AbstractSiblingCaret || other instanceof AbstractTextPointCaret) && this.direction === other.direction && this.origin.is(other.origin) ); } getSiblingCaret(): SiblingCaret<T, D> { return $getSiblingCaret(this.origin, this.direction); } } /** * Guard to check if the given caret is specifically a TextPointCaret * * @param caret Any caret * @returns true if it is a TextPointCaret */ export function $isTextPointCaret<D extends CaretDirection>( caret: null | undefined | PointCaret<D>, ): caret is TextPointCaret<TextNode, D> { return caret instanceof AbstractTextPointCaret; } /** * 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<D extends CaretDirection>( caret: null | undefined | PointCaret<D>, ): caret is PointCaret<D> { return caret instanceof AbstractCaret; } /** * Guard to check if the given argument is specifically a SiblingCaret (or TextPointCaret) * * @param caret * @returns true if caret is a SiblingCaret */ export function $isSiblingCaret<D extends CaretDirection>( caret: null | undefined | PointCaret<D>, ): caret is SiblingCaret<LexicalNode, D> { return caret instanceof AbstractSiblingCaret; } /** * Guard to check if the given argument is specifically a ChildCaret * @param caret * @returns true if caret is a ChildCaret */ export function $isChildCaret<D extends CaretDirection>( caret: null | undefined | PointCaret<D>, ): caret is ChildCaret<ElementNode, D> { return caret instanceof AbstractChildCaret; } class SiblingCaretNext<T extends LexicalNode> extends AbstractSiblingCaret< T, 'next' > { readonly direction = 'next'; getNodeAtCaret(): null | LexicalNode { return this.origin.getNextSibling(); } insert(node: LexicalNode): this { this.origin.insertAfter(node); return this; } } class SiblingCaretPrevious<T extends LexicalNode> extends AbstractSiblingCaret< T, 'previous' > { readonly direction = 'previous'; getNodeAtCaret(): null | LexicalNode { return this.origin.getPreviousSibling(); } insert(node: LexicalNode): this { this.origin.insertBefore(node); return this; } } class TextPointCaretNext<T extends TextNode> 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<T extends TextNode> 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, } as const; const CHILD_CTOR = { next: ChildCaretFirst, previous: ChildCaretLast, }; /** * 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 SiblingCaret for this origin and direction */ export function $getSiblingCaret< T extends LexicalNode, D extends CaretDirection, >(origin: T, direction: D): SiblingCaret<T, D>; export function $getSiblingCaret< T extends LexicalNode, D extends CaretDirection, >(origin: null | T, direction: D): null | SiblingCaret<T, D>; export function $getSiblingCaret( origin: null | LexicalNode, direction: CaretDirection, ): null | SiblingCaret<LexicalNode, CaretDirection> { return origin ? new SIBLING_CTOR[direction](origin) : null; } /** * 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 TextPointCaret */ export function $getTextPointCaret< T extends TextNode, D extends CaretDirection, >( origin: T, direction: D, offset: number | CaretDirection, ): TextPointCaret<T, D>; export function $getTextPointCaret< T extends TextNode, D extends CaretDirection, >( origin: null | T, direction: D, offset: number | CaretDirection, ): null | TextPointCaret<T, D>; export function $getTextPointCaret( origin: TextNode | null, direction: CaretDirection, offset: number | CaretDirection, ): null | TextPointCaret<TextNode, CaretDirection> { return origin ? new TEXT_CTOR[direction](origin, $getTextNodeOffset(origin, offset)) : null; } /** * Get a normalized offset into a TextNode given a numeric offset or a * direction for which end of the string to use. Throws in dev 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 * @param mode If 'error' (the default) out of bounds offsets will be an error in dev. Otherwise it will clamp to a valid offset. * @returns An absolute offset into the TextNode string */ export function $getTextNodeOffset( origin: TextNode, offset: number | CaretDirection, mode: 'error' | 'clamp' = 'error', ): number { const size = origin.getTextContentSize(); let numericOffset = offset === 'next' ? size : offset === 'previous' ? 0 : offset; if (numericOffset < 0 || numericOffset > size) { devInvariant( mode === 'clamp', '$getTextNodeOffset: invalid offset %s for size %s at key %s', String(offset), String(size), origin.getKey(), ); // Clamp invalid offsets in prod numericOffset = numericOffset < 0 ? 0 : size; } return numericOffset; } /** * 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 TextPointCaretSlice * but mutation operations will preserve the direction. * * @param caret * @param distance * @returns TextPointCaretSlice */ export function $getTextPointCaretSlice< T extends TextNode, D extends CaretDirection, >(caret: TextPointCaret<T, D>, distance: number): TextPointCaretSlice<T, D> { return new TextPointCaretSliceImpl(caret, distance); } /** * 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 ChildCaret for this origin and direction */ export function $getChildCaret<T extends ElementNode, D extends CaretDirection>( origin: T, direction: D, ): ChildCaret<T, D>; export function $getChildCaret( origin: null | LexicalNode, direction: CaretDirection, ): null | ChildCaret<ElementNode, CaretDirection> { return $isElementNode(origin) ? new CHILD_CTOR[direction](origin) : null; } /** * Gets the ChildCaret if one is possible at this caret origin, otherwise return the caret */ export function $getChildCaretOrSelf<Caret extends PointCaret | null>( caret: Caret, ): Caret | ChildCaret<ElementNode, NonNullable<Caret>['direction']> { 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 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 $getAdjacentChildCaret<D extends CaretDirection>( caret: null | NodeCaret<D>, ): null | NodeCaret<D> { return caret && $getChildCaretOrSelf(caret.getAdjacentCaret()); } class CaretRangeImpl<D extends CaretDirection> implements CaretRange<D> { readonly type = 'node-caret-range'; readonly direction: D; anchor: PointCaret<D>; focus: PointCaret<D>; constructor(anchor: PointCaret<D>, focus: PointCaret<D>, direction: D) { this.anchor = anchor; this.focus = focus; this.direction = direction; } getLatest(): CaretRange<D> { const anchor = this.anchor.getLatest(); const focus = this.focus.getLatest(); return anchor === this.anchor && focus === this.focus ? this : new CaretRangeImpl(anchor, focus, this.direction); } isCollapsed(): boolean { return this.anchor.isSamePointCaret(this.focus); } getTextSlices(): TextPointCaretSliceTuple<D> { const getSlice = (k: 'anchor' | 'focus') => { const caret = this[k].getLatest(); return $isTextPointCaret(caret) ? $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.isSameNodeCaret(focusCaret)) { return [ $getTextPointCaretSlice( anchorCaret, focusCaret.offset - anchorCaret.offset, ), null, ]; } } return [anchorSlice, focusSlice]; } iterNodeCarets(rootMode: RootMode = 'root'): IterableIterator<NodeCaret<D>> { const anchor = $isTextPointCaret(this.anchor) ? this.anchor.getSiblingCaret() : this.anchor.getLatest(); const focus = this.focus.getLatest(); const isTextFocus = $isTextPointCaret(focus); const step = (state: NodeCaret<D>) => state.isSameNodeCaret(focus) ? null : $getAdjacentChildCaret(state) || state.getParentCaret(rootMode); return makeStepwiseIterator({ hasNext: (state: null | NodeCaret<D>): state is NodeCaret<D> => state !== null && !(isTextFocus && focus.isSameNodeCaret(state)), initial: anchor.isSameNodeCaret(focus) ? null : step(anchor), map: state => state, step, }); } [Symbol.iterator](): IterableIterator<NodeCaret<D>> { return this.iterNodeCarets('root'); } } class TextPointCaretSliceImpl< T extends TextNode, D extends CaretDirection, > implements TextPointCaretSlice<T, D> { readonly type = 'slice'; readonly caret: TextPointCaret<T, D>; readonly distance: number; constructor(caret: TextPointCaret<T, D>, 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); } removeTextSlice(): TextPointCaret<T, D> { 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< T extends TextNode, D extends CaretDirection, >( caret: TextPointCaret<T, D>, anchorOrFocus: 'anchor' | 'focus', ): TextPointCaretSlice<T, D> { const {direction, origin} = caret; const offsetB = $getTextNodeOffset( origin, anchorOrFocus === 'focus' ? flipDirection(direction) : direction, ); 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<D extends CaretDirection>( caretOrSlice: | null | undefined | PointCaret<D> | TextPointCaretSlice<TextNode, D>, ): caretOrSlice is TextPointCaretSlice<TextNode, D> { return caretOrSlice instanceof TextPointCaretSliceImpl; } /** * Construct a CaretRange that starts at anchor and goes to the end of the * document in the anchor caret's direction. */ export function $extendCaretToRange<D extends CaretDirection>( anchor: PointCaret<D>, ): CaretRange<D> { return $getCaretRange(anchor, $getSiblingCaret($getRoot(), anchor.direction)); } /** * Construct a collapsed CaretRange that starts and ends at anchor. */ export function $getCollapsedCaretRange<D extends CaretDirection>( anchor: PointCaret<D>, ): CaretRange<D> { return $getCaretRange(anchor, anchor); } /** * 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. * * 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 CaretRange */ export function $getCaretRange<D extends CaretDirection>( anchor: PointCaret<D>, focus: PointCaret<D>, ): CaretRange<D> { invariant( anchor.direction === focus.direction, '$getCaretRange: anchor and focus must be in the same direction', ); return new CaretRangeImpl(anchor, focus, anchor.direction); } /** * 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<State, Stop, Value>( config: StepwiseIteratorConfig<State, Stop, Value>, ): IterableIterator<Value> { const {initial, hasNext, step, map} = config; let state = initial; return { [Symbol.iterator]() { return this; }, next(): IteratorResult<Value> { if (!hasNext(state)) { return {done: true, value: undefined}; } const rval = {done: false, value: map(state)}; state = step(state); return rval; }, }; } function compareNumber(a: number, b: number): -1 | 0 | 1 { return Math.sign(a - b) as -1 | 0 | 1; } /** * A total ordering for `PointCaret<'next'>`, based on * the same order that a {@link CaretRange} would iterate * them. * * For a given origin node: * - ChildCaret comes before SiblingCaret * - TextPointCaret comes before SiblingCaret * * An exception is thrown when a and b do not have any * common ancestor. * * This ordering is a sort of mix of pre-order and post-order * because each ElementNode will show up as a ChildCaret * on 'enter' (pre-order) and a SiblingCaret on 'leave' (post-order). * * @param a * @param b * @returns -1 if a comes before b, 0 if a and b are the same, or 1 if a comes after b */ export function $comparePointCaretNext( a: PointCaret<'next'>, b: PointCaret<'next'>, ): -1 | 0 | 1 { const compare = $getCommonAncestor(a.origin, b.origin); invariant( compare !== null, '$comparePointCaretNext: a (key %s) and b (key %s) do not have a common ancestor', a.origin.getKey(), b.origin.getKey(), ); switch (compare.type) { case 'same': { const aIsText = a.type === 'text'; const bIsText = b.type === 'text'; return aIsText && bIsText ? compareNumber(a.offset, b.offset) : a.type === b.type ? 0 : aIsText ? -1 : bIsText ? 1 : a.type === 'child' ? -1 : 1; } case 'ancestor': { return a.type === 'child' ? -1 : 1; } case 'descendant': { return b.type === 'child' ? 1 : -1; } case 'branch': { return $getCommonAncestorResultBranchOrder(compare); } } } /** * Return the ordering of siblings in a {@link CommonAncestorResultBranch} * @param compare Returns -1 if a precedes b, 1 otherwise */ export function $getCommonAncestorResultBranchOrder< A extends LexicalNode, B extends LexicalNode, >(compare: CommonAncestorResultBranch<A, B>): -1 | 1 { const {a, b} = compare; const aKey = a.__key; const bKey = b.__key; let na: null | LexicalNode = a; let nb: null | LexicalNode = b; for (; na && nb; na = na.getNextSibling(), nb = nb.getNextSibling()) { if (na.__key === bKey) { return -1; } else if (nb.__key === aKey) { return 1; } } return na === null ? 1 : -1; } /** * The two compared nodes are the same */ export interface CommonAncestorResultSame<A extends LexicalNode> { readonly type: 'same'; readonly commonAncestor: A; } /** * Node a was a descendant of node b, and not the same node */ export interface CommonAncestorResultDescendant<B extends ElementNode> { readonly type: 'descendant'; readonly commonAncestor: B; } /** * Node a is an ancestor of node b, and not the same node */ export interface CommonAncestorResultAncestor<A extends ElementNode> { readonly type: 'ancestor'; readonly commonAncestor: A; } /** * Node a and node b have a common ancestor but are on different branches, * the `a` and `b` properties of this result are the ancestors of a and b * that are children of the commonAncestor. Since they are siblings, their * positions are comparable to determine order in the document. */ export interface CommonAncestorResultBranch< A extends LexicalNode, B extends LexicalNode, > { readonly type: 'branch'; readonly commonAncestor: ElementNode; /** The ancestor of `a` that is a child of `commonAncestor` */ readonly a: A | ElementNode; /** The ancestor of `b` that is a child of `commonAncestor` */ readonly b: B | ElementNode; } /** * The result of comparing two nodes that share some common ancestor */ export type CommonAncestorResult< A extends LexicalNode, B extends LexicalNode, > = | CommonAncestorResultSame<A> | CommonAncestorResultAncestor<A & ElementNode> | CommonAncestorResultDescendant<B & ElementNode> | CommonAncestorResultBranch<A, B>; function $isSameNode<T extends LexicalNode>( reference: T, other: LexicalNode, ): other is T { return other.is(reference); } function $initialElementTuple( node: LexicalNode, ): [ElementNode | null, LexicalNode | null] { return $isElementNode(node) ? [node.getLatest(), null] : [node.getParent(), node.getLatest()]; } /** * Find a common ancestor of a and b and return a detailed result object, * or null if there is no common ancestor between the two nodes. * * The result object will have a commonAncestor property, and the other * properties can be used to quickly compare these positions in the tree. * * @param a A LexicalNode * @param b A LexicalNode * @returns A comparison result between the two nodes or null if they have no common ancestor */ export function $getCommonAncestor< A extends LexicalNode, B extends LexicalNode, >(a: A, b: B): null | CommonAncestorResult<A, B> { if (a.is(b)) { return {commonAncestor: a, type: 'same'}; } // Map of parent -> child entries based on a and its ancestors const aMap = new Map<ElementNode, LexicalNode | null>(); for ( let [parent, child] = $initialElementTuple(a); parent; child = parent, parent = parent.getParent() ) { aMap.set(parent, child); } for ( let [parent, child] = $initialElementTuple(b); parent; child = parent, parent = parent.getParent() ) { const aChild = aMap.get(parent); if (aChild === undefined) { // keep going } else if (aChild === null) { // a is the ancestor invariant( $isSameNode(a, parent), '$originComparison: ancestor logic error', ); return {commonAncestor: parent, type: 'ancestor'}; } else if (child === null) { // b is the ancestor invariant( $isSameNode(b, parent), '$originComparison: descendant logic error', ); return {commonAncestor: parent, type: 'descendant'}; } else { invariant( ($isElementNode(aChild) || $isSameNode(a, aChild)) && ($isElementNode(child) || $isSameNode(b, child)) && parent.is(aChild.getParent()) && parent.is(child.getParent()), '$originComparison: branch logic error', ); return { a: aChild, b: child, commonAncestor: parent, type: 'branch', }; } } return null; }