UNPKG

lexical

Version:

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

740 lines (709 loc) • 23.7 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 type { CaretDirection, CaretRange, ChildCaret, NodeCaret, PointCaret, RootMode, SiblingCaret, TextPointCaret, } from './LexicalCaret'; import invariant from '@lexical/internal/invariant'; import { $createRangeSelection, $getSelection, $isRangeSelection, type PointType, type RangeSelection, } from '../LexicalSelection'; import { $copyNode, $getNodeByKeyOrThrow, $isRootOrShadowRoot, $setSelection, INTERNAL_$isBlock, } from '../LexicalUtils'; import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode'; import { $createTextNode, $isTextNode, type TextNode, } from '../nodes/LexicalTextNode'; import { $comparePointCaretNext, $getAdjacentChildCaret, $getCaretRange, $getChildCaret, $getCollapsedCaretRange, $getSiblingCaret, $getTextNodeOffset, $getTextPointCaret, $isChildCaret, $isSiblingCaret, $isTextPointCaret, flipDirection, } from './LexicalCaret'; /** * @param point * @returns a PointCaret for the point */ export function $caretFromPoint<D extends CaretDirection>( point: Pick<PointType, 'type' | 'key' | 'offset'>, direction: D, ): PointCaret<D> { 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 $getTextPointCaret(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 PointCaret * * @param point the point to set * @param caret the caret to set the point from */ export function $setPointFromCaret<D extends CaretDirection>( point: PointType, caret: PointCaret<D>, ): void { const {origin, direction} = caret; const isNext = direction === 'next'; if ($isTextPointCaret(caret)) { point.set(origin.getKey(), caret.offset, 'text'); } else if ($isSiblingCaret(caret)) { if ($isTextNode(origin)) { point.set(origin.getKey(), $getTextNodeOffset(origin, direction), 'text'); } else { point.set( origin.getParentOrThrow().getKey(), origin.getIndexWithinParent() + (isNext ? 1 : 0), 'element', ); } } else { invariant( $isChildCaret(caret) && $isElementNode(origin), '$setPointFromCaret: exhaustiveness check', ); point.set( origin.getKey(), isNext ? 0 : origin.getChildrenSize(), 'element', ); } } /** * Set a RangeSelection on the editor from the given CaretRange * * @returns The new RangeSelection */ export function $setSelectionFromCaretRange( caretRange: CaretRange, ): 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 PointCaret. */ export function $updateRangeSelectionFromCaretRange( selection: RangeSelection, caretRange: CaretRange, ): void { $setPointFromCaret(selection.anchor, caretRange.anchor); $setPointFromCaret(selection.focus, caretRange.focus); } /** * 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, ): CaretRange { const {anchor, focus} = selection; const anchorCaret = $caretFromPoint(anchor, 'next'); const focusCaret = $caretFromPoint(focus, 'next'); const direction = $comparePointCaretNext(anchorCaret, focusCaret) <= 0 ? 'next' : 'previous'; return $getCaretRange( $getCaretInDirection(anchorCaret, direction), $getCaretInDirection(focusCaret, direction), ); } /** * 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 * siblingCaret.is($rewindSiblingCaret(siblingCaret).getAdjacentCaret()) * ``` * * @param caret The caret to "rewind" * @returns A new caret (ChildCaret or SiblingCaret) with the same direction */ export function $rewindSiblingCaret< T extends LexicalNode, D extends CaretDirection, >(caret: SiblingCaret<T, D>): NodeCaret<D> { const {direction, origin} = caret; // Rotate the direction around the origin and get the adjacent node const rewindOrigin = $getSiblingCaret( origin, flipDirection(direction), ).getNodeAtCaret(); return rewindOrigin ? $getSiblingCaret(rewindOrigin, direction) : $getChildCaret(origin.getParentOrThrow(), direction); } function $getAnchorCandidates<D extends CaretDirection>( anchor: PointCaret<D>, rootMode: RootMode = 'root', ): [PointCaret<D>, ...NodeCaret<D>[]] { // These candidates will be the anchor itself, the pointer to the anchor (if different), and then any parents of that const carets: [PointCaret<D>, ...NodeCaret<D>[]] = [anchor]; for ( let parent = $isChildCaret(anchor) ? anchor.getParentCaret(rootMode) : anchor.getSiblingCaret(); parent !== null; parent = parent.getParentCaret(rootMode) ) { carets.push($rewindSiblingCaret(parent)); } return carets; } declare const CaretOriginAttachedBrand: unique symbol; function $isCaretAttached<Caret extends PointCaret<CaretDirection>>( caret: null | undefined | Caret, ): caret is Caret & {[CaretOriginAttachedBrand]: never} { return !!caret && caret.origin.isAttached(); } /** * 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 initialRange 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<D extends CaretDirection>( initialRange: CaretRange<D>, sliceMode: | 'removeEmptySlices' | 'preserveEmptyTextSliceCaret' = 'removeEmptySlices', ): CaretRange<D> { if (initialRange.isCollapsed()) { return initialRange; } // 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); const focusCandidates = $getAnchorCandidates( range.focus.getFlipped(), rootMode, ); // Mark the start of each ElementNode const seenStart = new Set<NodeKey>(); // 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 ($isChildCaret(caret)) { seenStart.add(caret.origin.getKey()); } else if ($isSiblingCaret(caret)) { const {origin} = caret; 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 (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. for (const slice of range.getTextSlices()) { if (!slice) { continue; } const {origin} = slice.caret; const contentSize = origin.getTextContentSize(); const caretBefore = $rewindSiblingCaret( $getSiblingCaret(origin, nextDirection), ); const mode = origin.getMode(); if ( (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(); const sliceOrigin = slice.caret.origin; if (mode === 'segmented') { const src = nextCaret.origin; const plainTextNode = $createTextNode(src.getTextContent()) .setStyle(src.getStyle()) .setFormat(src.getFormat()); caretBefore.replaceOrInsert(plainTextNode); nextCaret = $getTextPointCaret( plainTextNode, nextDirection, nextCaret.offset, ); } if (sliceOrigin.is(anchorCandidates[0].origin)) { anchorCandidates[0] = nextCaret; } if (sliceOrigin.is(focusCandidates[0].origin)) { focusCandidates[0] = nextCaret.getFlipped(); } } } // Find the deepest anchor and focus candidates that are // still attached let anchorCandidate: PointCaret<'next'> | undefined; let focusCandidate: PointCaret<'previous'> | undefined; for (const candidate of anchorCandidates) { if ($isCaretAttached(candidate)) { anchorCandidate = $normalizeCaret(candidate); break; } } for (const candidate of focusCandidates) { if ($isCaretAttached(candidate)) { focusCandidate = $normalizeCaret(candidate); break; } } // Merge blocks if necessary const mergeTargets = $getBlockMergeTargets( anchorCandidate, focusCandidate, seenStart, ); if (mergeTargets) { const [anchorBlock, focusBlock] = mergeTargets; // always merge blocks later in the document with // blocks earlier in the document $getChildCaret(anchorBlock, 'previous').splice(0, focusBlock.getChildren()); // remove empty parent node even if parent node is canBeEmpty let parent = focusBlock.getParent(); focusBlock.remove(true); while (parent && parent.isEmpty()) { const element = parent; parent = parent.getParent(); element.remove(true); } } // note this caret can be in either direction const bestCandidate = [ anchorCandidate, focusCandidate, ...anchorCandidates, ...focusCandidates, ].find($isCaretAttached); if (bestCandidate) { const anchor = $getCaretInDirection( $normalizeCaret(bestCandidate), initialRange.direction, ); return $getCollapsedCaretRange(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)), ); } /** * Determine if the two caret origins are in distinct blocks that * should be merged. * * The returned block pair will be the closest blocks to their * common ancestor, and must be no shadow roots between * the blocks and their respective carets. If two distinct * blocks matching this criteria are not found, this will return * null. */ function $getBlockMergeTargets( anchor: null | undefined | PointCaret<'next'>, focus: null | undefined | PointCaret<'previous'>, seenStart: Set<NodeKey>, ): null | [ElementNode, ElementNode] { if (!anchor || !focus) { return null; } const anchorParent = anchor.getParentAtCaret(); const focusParent = focus.getParentAtCaret(); if (!anchorParent || !focusParent) { return null; } // TODO refactor when we have a better primitive for common ancestor const anchorElements = anchorParent.getParents().reverse(); anchorElements.push(anchorParent); const focusElements = focusParent.getParents().reverse(); focusElements.push(focusParent); const maxLen = Math.min(anchorElements.length, focusElements.length); let commonAncestorCount: number; for ( commonAncestorCount = 0; commonAncestorCount < maxLen && anchorElements[commonAncestorCount] === focusElements[commonAncestorCount]; commonAncestorCount++ ) { // just traverse the ancestors } const $getBlock = ( arr: readonly ElementNode[], predicate: (node: ElementNode) => boolean, ): ElementNode | undefined => { let block: ElementNode | undefined; for (let i = commonAncestorCount; i < arr.length; i++) { const ancestor = arr[i]; if ($isRootOrShadowRoot(ancestor)) { return; } else if (!block && predicate(ancestor)) { block = ancestor; } } return block; }; const anchorBlock = $getBlock(anchorElements, INTERNAL_$isBlock); const focusBlock = anchorBlock && $getBlock( focusElements, node => seenStart.has(node.getKey()) && INTERNAL_$isBlock(node), ); return anchorBlock && focusBlock ? [anchorBlock, focusBlock] : null; } /** * 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 ChildCaret. * * This is generally used when normalizing because there is * "zero distance" between these locations. * * @param initialCaret * @returns Either a deeper ChildCaret or the given initialCaret */ function $getDeepestChildOrSelf< Caret extends null | PointCaret<CaretDirection>, >( initialCaret: Caret, ): ChildCaret<ElementNode, NonNullable<Caret>['direction']> | Caret { let caret: ChildCaret<ElementNode, NonNullable<Caret>['direction']> | Caret = initialCaret; while ($isChildCaret(caret)) { const adjacent = $getAdjacentChildCaret(caret); if (!$isChildCaret(adjacent)) { break; } caret = adjacent; } return caret; } /** * 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 ChildCaret * having an adjacent TextNode. * * If given a TextPointCaret, it will be returned, as no normalization * is required when an offset is already present. * * @param initialCaret * @returns The normalized PointCaret */ export function $normalizeCaret<D extends CaretDirection>( initialCaret: PointCaret<D>, ): PointCaret<D> { const caret = $getDeepestChildOrSelf(initialCaret.getLatest()); const {direction} = caret; if ($isTextNode(caret.origin)) { return $isTextPointCaret(caret) ? caret : $getTextPointCaret(caret.origin, direction, direction); } const adj = caret.getAdjacentCaret(); return $isSiblingCaret(adj) && $isTextNode(adj.origin) ? $getTextPointCaret(adj.origin, direction, flipDirection(direction)) : caret; } declare const PointCaretIsExtendableBrand: unique symbol; /** * Determine whether the TextPointCaret's offset can be extended further without leaving the TextNode. * Returns false if the given caret is not a TextPointCaret or the offset can not be moved further in * direction. * * @param caret A PointCaret * @returns true if caret is a TextPointCaret with an offset that is not at the end of the text given the direction. */ export function $isExtendableTextPointCaret<D extends CaretDirection>( caret: PointCaret<D>, ): caret is TextPointCaret<TextNode, D> & { [PointCaretIsExtendableBrand]: never; } { return ( $isTextPointCaret(caret) && caret.offset !== $getTextNodeOffset(caret.origin, caret.direction) ); } /** * Return the caret if it's in the given direction, otherwise return * caret.getFlipped(). * * @param caret Any PointCaret * @param direction The desired direction * @returns A PointCaret in direction */ export function $getCaretInDirection< Caret extends PointCaret<CaretDirection>, D extends CaretDirection, >( caret: Caret, direction: D, ): | NodeCaret<D> | (Caret extends TextPointCaret<TextNode, CaretDirection> ? TextPointCaret<TextNode, D> : never) { return (caret.direction === direction ? caret : caret.getFlipped()) as | NodeCaret<D> | (Caret extends TextPointCaret<TextNode, CaretDirection> ? TextPointCaret<TextNode, D> : never); } /** * 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 CaretRange * @param direction The desired direction * @returns A CaretRange in direction */ export function $getCaretRangeInDirection<D extends CaretDirection>( range: CaretRange<CaretDirection>, direction: D, ): CaretRange<D> { if (range.direction === direction) { return range as CaretRange<D>; } return $getCaretRange( // focus and anchor get flipped here $getCaretInDirection(range.focus, direction), $getCaretInDirection(range.anchor, direction), ); } /** * 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 caret pointing towards the node at that index */ export function $getChildCaretAtIndex<D extends CaretDirection>( parent: ElementNode, index: number, direction: D, ): NodeCaret<D> { let caret: NodeCaret<'next'> = $getChildCaret(parent, 'next'); for (let i = 0; i < index; i++) { const nextCaret: null | SiblingCaret<LexicalNode, 'next'> = caret.getAdjacentCaret(); if (nextCaret === null) { break; } caret = nextCaret; } return $getCaretInDirection(caret, direction); } /** * 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 startCaret The initial caret * @param rootMode The root mode, 'root' (default) or 'shadowRoot' * @returns An array (tuple) containing the found caret and the depth difference, or null, if this node doesn't exist. */ export function $getAdjacentSiblingOrParentSiblingCaret< D extends CaretDirection, >( startCaret: NodeCaret<D>, rootMode: RootMode = 'root', ): null | [NodeCaret<D>, 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]; } /** * Get the adjacent nodes to initialCaret in the given direction. * * @example * ```ts * expect($getAdjacentNodes($getChildCaret(parent, 'next'))).toEqual(parent.getChildren()); * expect($getAdjacentNodes($getChildCaret(parent, 'previous'))).toEqual(parent.getChildren().reverse()); * expect($getAdjacentNodes($getSiblingCaret(node, 'next'))).toEqual(node.getNextSiblings()); * expect($getAdjacentNodes($getSiblingCaret(node, 'previous'))).toEqual(node.getPreviousSiblings().reverse()); * ``` * * @param initialCaret The caret to start at (the origin will not be included) * @returns An array of siblings. */ export function $getAdjacentNodes( initialCaret: NodeCaret<CaretDirection>, ): LexicalNode[] { const siblings = []; for ( let caret = initialCaret.getAdjacentCaret(); caret; caret = caret.getAdjacentCaret() ) { siblings.push(caret.origin); } return siblings; } export function $splitTextPointCaret<D extends CaretDirection>( textPointCaret: TextPointCaret<TextNode, D>, ): NodeCaret<D> { const {origin, offset, direction} = textPointCaret; if (offset === $getTextNodeOffset(origin, direction)) { return textPointCaret.getSiblingCaret(); } else if (offset === $getTextNodeOffset(origin, flipDirection(direction))) { return $rewindSiblingCaret(textPointCaret.getSiblingCaret()); } const [textNode] = origin.splitText(offset); invariant( $isTextNode(textNode), '$splitTextPointCaret: splitText must return at least one TextNode', ); return $getCaretInDirection($getSiblingCaret(textNode, 'next'), direction); } export interface SplitAtPointCaretNextOptions { /** The function to create the right side of a split ElementNode (default {@link $copyNode}) */ $copyElementNode?: (node: ElementNode) => ElementNode; /** The function to split a TextNode (default {@link $splitTextPointCaret}) */ $splitTextPointCaretNext?: ( caret: TextPointCaret<TextNode, 'next'>, ) => NodeCaret<'next'>; /** If the parent matches rootMode a split will not occur, default is 'shadowRoot' */ rootMode?: RootMode; /** * If `element.canBeEmpty()` and it would create an empty split, this function will be * called with the element and 'first' | 'last'. If it returns false, the empty * split will not be created. Default is `() => true` to always split when possible. */ $shouldSplit?: (node: ElementNode, edge: 'first' | 'last') => boolean; /** * If the destination would create an empty split on both sides, then * remove it instead of splitting. Default `false`. */ removeEmptyDestination?: boolean; } function $alwaysSplit(_node: ElementNode, _edge: 'first' | 'last'): true { return true; } /** * Split a node at a PointCaret and return a NodeCaret at that point, or null if the * node can't be split. This is non-recursive and will only perform at most one split. * * @returns The NodeCaret pointing to the location of the split (or null if a split is not possible) */ export function $splitAtPointCaretNext( pointCaret: PointCaret<'next'>, { $copyElementNode = $copyNode, $splitTextPointCaretNext = $splitTextPointCaret, rootMode = 'shadowRoot', $shouldSplit = $alwaysSplit, removeEmptyDestination = false, }: SplitAtPointCaretNextOptions = {}, ): null | NodeCaret<'next'> { if ($isTextPointCaret(pointCaret)) { return $splitTextPointCaretNext(pointCaret); } const parentCaret = pointCaret.getParentCaret(rootMode); if (parentCaret) { const {origin} = parentCaret; if ($isChildCaret(pointCaret)) { const beforeParentCaret = $rewindSiblingCaret(parentCaret); if (removeEmptyDestination && origin.isEmpty()) { origin.remove(); return beforeParentCaret; } if (!(origin.canBeEmpty() && $shouldSplit(origin, 'first'))) { return beforeParentCaret; } } const siblings = $getAdjacentNodes(pointCaret); if ( siblings.length > 0 || (!removeEmptyDestination && origin.canBeEmpty() && $shouldSplit(origin, 'last')) ) { // Split and insert the siblings into the new tree parentCaret.insert($copyElementNode(origin).splice(0, 0, siblings)); } } return parentCaret; }