UNPKG

lexical

Version:

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

420 lines (407 loc) 15 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 {LexicalPrivateDOM} from './LexicalNode'; import type {ElementNode} from './nodes/LexicalElementNode'; import invariant from '@lexical/internal/invariant'; import {IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from './environment'; import {$getEditor} from './LexicalUtils'; /** * The editor has at most one block cursor element * ({@link LexicalEditor._blockCursorElement}) — a transient, non-lexical * element the selection layer inserts among an ElementNode's children when a * collapsed element selection is adjacent to a node that can't host the caret * (a block decorator, or a non-empty-capable block). Slots must skip it so it * is never mistaken for managed content. There is only ever one, read from the * active editor. */ function $getActiveBlockCursorElement(): HTMLElement | null { return $getEditor()._blockCursorElement; } /** * Base class for DOM slots — a pointer to the content-bearing element of a * node's DOM, plus optional `before` / `after` boundaries marking where the * lexical-managed content sits inside that element. * * For ElementNode children management see {@link ElementDOMSlot}. For * non-Element nodes (TextNode, LineBreakNode, DecoratorNode) the slot still * supports an internal `before` / `after` so subclasses can prepend or * append non-lexical siblings around the content node and the reconciler / * `setTextContent` route the actual content through the slot. * * @experimental */ export class DOMSlot<T extends HTMLElement = HTMLElement> { /** The content-bearing element of the node's DOM. */ readonly element: T; /** Upper boundary: the lexical-managed range ends before this node. */ readonly before: Node | null; /** Lower boundary: the lexical-managed range starts after this node. */ readonly after: Node | null; constructor( element: T, before?: Node | undefined | null, after?: Node | undefined | null, ) { this.element = element; this.before = before || null; this.after = after || null; } /** Return a new slot with `before` updated. */ withBefore(before: Node | undefined | null): DOMSlot<T> { return new DOMSlot(this.element, before, this.after); } /** Return a new slot with `after` updated. */ withAfter(after: Node | undefined | null): DOMSlot<T> { return new DOMSlot(this.element, this.before, after); } /** Return a new slot with `element` updated. */ withElement<ElementType extends HTMLElement>( element: ElementType, ): DOMSlot<ElementType> { if ((this.element as HTMLElement) === element) { return this as unknown as DOMSlot<ElementType>; } return new DOMSlot(element, this.before, this.after); } /** * Insert the given node before `this.before` (if defined) or append it to * `this.element` otherwise. Subclasses may override to respect additional * boundaries (e.g. `ElementDOMSlot` also keeps the managed line break at * the end). */ insertChild(dom: Node): this { const before = this.getInsertionAnchor(); invariant( before === null || before.parentElement === this.element, 'DOMSlot.insertChild: before is not in element', ); this.element.insertBefore(dom, before); return this; } /** * Remove the given child from `this.element`. Throws if it was not a child. */ removeChild(dom: Node): this { invariant( dom.parentElement === this.element, 'DOMSlot.removeChild: dom is not in element', ); this.element.removeChild(dom); return this; } /** * Replace `prevDom` with `dom`. Throws if `prevDom` is not a child. */ replaceChild(dom: Node, prevDom: Node): this { invariant( prevDom.parentElement === this.element, 'DOMSlot.replaceChild: prevDom is not in element', ); this.element.replaceChild(dom, prevDom); return this; } /** * Returns the first managed child (the first node in * `this.element` that is not a non-lexical prelude / decoration), or * `null` if there is none. Subclasses may override to also skip * reconciler-managed scaffolding such as the managed line break. */ getFirstChild(): ChildNode | null { const anchor = this.getFirstChildAnchor(); const firstChild = anchor ? anchor.nextSibling : this.element.firstChild; return firstChild === this.getInsertionAnchor() ? null : firstChild; } /** * @internal * * The leading-boundary counterpart to {@link getInsertionAnchor}: the node * the lexical-managed range starts immediately after (its `nextSibling` is * the first managed child), or `null` when managed children begin at * `this.element.firstChild`. The base slot uses `this.after`; subclasses * extend it to skip leading non-lexical scaffolding (e.g. the block cursor). */ getFirstChildAnchor(): Node | null { return this.after; } /** * Map a DOM selection point landing at or inside `leafDOM` (the node's * keyed DOM) to whether the caret is positioned BEFORE or AFTER the * node in document order. The default implementation derives the * boundary from `this.element`'s index inside `leafDOM`: * * - When `this.element === leafDOM` (no wrap exposed an inner content * element via `withElement`): only a DOM caret directly on * `leafDOM` at offset 0 counts as "before". Matches the historical * decorator rule. * - When `this.element !== leafDOM` (wrap pattern that exposed the * inner content element via `withElement`, e.g. a `<br>` inside a * decoration `<span>`): caret positions at or before the content * element are "before", later positions are "after". Handles * nested wraps by walking each side up to its top-level child of * `leafDOM`. * * Symmetric with {@link ElementDOMSlot.resolveChildIndex}, which * performs the analogous mapping for ElementNode children. Together * they let the slot abstraction own all DOM-offset to lexical-offset * translation. * * @internal */ resolveLeafPosition( leafDOM: HTMLElement, initialDOM: Node, initialOffset: number, ): 'before' | 'after' { if (this.element === leafDOM) { return initialDOM === leafDOM && initialOffset === 0 ? 'before' : 'after'; } const innerChild = $topLevelChildOf(leafDOM, this.element); if (innerChild === null) { return 'after'; } const innerIndex = Array.prototype.indexOf.call( leafDOM.childNodes, innerChild, ); if (innerIndex < 0) { return 'after'; } if (initialDOM === leafDOM) { return initialOffset <= innerIndex ? 'before' : 'after'; } const initialChild = $topLevelChildOf(leafDOM, initialDOM); if (initialChild === null) { return 'after'; } const childIndex = Array.prototype.indexOf.call( leafDOM.childNodes, initialChild, ); return childIndex >= 0 && childIndex <= innerIndex ? 'before' : 'after'; } /** * @internal * * The node managed children are inserted before, or `null` to append. * Subclasses widen this to reserve trailing scaffolding (e.g. * {@link ElementDOMSlot} keeps the managed line break last). */ getInsertionAnchor(): Node | null { return this.before; } } function $topLevelChildOf(parent: HTMLElement, descendant: Node): Node | null { let node: Node | null = descendant; while (node !== null && node.parentNode !== parent) { node = node.parentNode; } return node; } /** * A utility class for managing the DOM children of an ElementNode. * * Extends {@link DOMSlot} with ElementNode-specific scaffolding — the * reconciler-managed line break that keeps empty elements selectable, and * the offset / index resolution helpers needed when mapping DOM selections * onto lexical positions. The base `before` / `after` boundaries and the * children mutation helpers (`insertChild`, `removeChild`, …) live on * {@link DOMSlot}. */ export class ElementDOMSlot< T extends HTMLElement = HTMLElement, > extends DOMSlot<T> { /** Return a new slot with `before` updated, preserving subclass type. */ withBefore(before: Node | undefined | null): ElementDOMSlot<T> { return new ElementDOMSlot(this.element, before, this.after); } /** Return a new slot with `after` updated, preserving subclass type. */ withAfter(after: Node | undefined | null): ElementDOMSlot<T> { return new ElementDOMSlot(this.element, this.before, after); } /** Return a new slot with `element` updated, preserving subclass type. */ withElement<ElementType extends HTMLElement>( element: ElementType, ): ElementDOMSlot<ElementType> { if (this.element === (element as HTMLElement)) { return this as unknown as ElementDOMSlot<ElementType>; } return new ElementDOMSlot(element, this.before, this.after); } /** * @internal */ override getInsertionAnchor(): Node | null { return super.getInsertionAnchor() || this.getManagedLineBreak(); } /** * @internal * * Extends the leading boundary to skip the editor's transient block cursor * when it sits at the head of the managed range (a collapsed element * selection at offset 0), mirroring how {@link getInsertionAnchor} extends * the trailing boundary past the managed line break. Only ElementNodes host * a block cursor among their children, so the base slot stays editor-free. */ override getFirstChildAnchor(): Node | null { const after = super.getFirstChildAnchor(); const firstChild = after ? after.nextSibling : this.element.firstChild; return firstChild !== null && firstChild === $getActiveBlockCursorElement() ? firstChild : after; } /** * @internal */ getManagedLineBreak(): Exclude< LexicalPrivateDOM['__lexicalLineBreak'], undefined > { const element: HTMLElement & LexicalPrivateDOM = this.element; return element.__lexicalLineBreak || null; } /** @internal */ setManagedLineBreak( lineBreakType: null | 'empty' | 'line-break' | 'decorator', ): void { const element: HTMLElement & LexicalPrivateDOM = this.element; element.__lexicalLastChildKind = lineBreakType; if (lineBreakType === null) { this.removeManagedLineBreak(); } else { const webkitHack = lineBreakType === 'decorator' && (IS_APPLE_WEBKIT || IS_IOS || IS_SAFARI); this.insertManagedLineBreak(webkitHack); } } /** @internal */ removeManagedLineBreak(): void { const br = this.getManagedLineBreak(); if (br) { const element: HTMLElement & LexicalPrivateDOM = this.element; const sibling = br.nodeName === 'IMG' ? br.nextSibling : null; if (sibling) { element.removeChild(sibling); } element.removeChild(br); element.__lexicalLineBreak = undefined; } } /** @internal */ insertManagedLineBreak(webkitHack: boolean): void { const prevBreak = this.getManagedLineBreak(); if (prevBreak) { if (webkitHack === (prevBreak.nodeName === 'IMG')) { return; } this.removeManagedLineBreak(); } const element: HTMLElement & LexicalPrivateDOM = this.element; const before = this.before; const br = document.createElement('br'); element.insertBefore(br, before); if (webkitHack) { const img = document.createElement('img'); img.setAttribute('data-lexical-linebreak', 'true'); img.style.setProperty('display', 'inline', 'important'); img.style.setProperty('border', '0px', 'important'); img.style.setProperty('margin', '0px', 'important'); img.alt = ''; element.insertBefore(img, br); element.__lexicalLineBreak = img; } else { element.__lexicalLineBreak = br; } } /** * @internal * * The DOM child index at which the first managed child appears — i.e. the * count of leading non-lexical nodes (the `this.after` region, plus the * block cursor when it sits at the head). Walks forward from the start, * stopping at the first managed child, or at the trailing boundary * (`this.before` / the managed line break via {@link getInsertionAnchor}) * when there are no managed children. */ getFirstChildOffset(): number { const firstChild = this.getFirstChild(); const insertionAnchor = this.getInsertionAnchor(); let i = 0; for ( let node = this.element.firstChild; node !== null && node !== firstChild && node !== insertionAnchor; node = node.nextSibling ) { i++; } return i; } /** * @internal */ resolveChildIndex( element: ElementNode, elementDOM: HTMLElement, initialDOM: Node, initialOffset: number, ): [node: ElementNode, idx: number] { if (initialDOM === this.element) { // Map a raw DOM child index (`initialOffset`) to a lexical child index by // counting the managed children in DOM positions // `[firstChildOffset, initialOffset)`, skipping the editor's block cursor // when it is interleaved between two block children (it occupies a DOM // slot but is not a lexical child). `firstChildOffset` already accounts // for leading scaffolding (the `this.after` region and a head cursor); // the clamp keeps the result within the element's lexical range. const firstChildOffset = this.getFirstChildOffset(); const blockCursor = $getActiveBlockCursorElement(); const childNodes = this.element.childNodes; const limit = Math.min(initialOffset, childNodes.length); let idx = 0; for (let i = firstChildOffset; i < limit; i++) { if (childNodes[i] !== blockCursor) { idx++; } } return [element, Math.min(idx, element.getChildrenSize())]; } // The resolved offset must be before or after the children const initialPath = indexPath(elementDOM, initialDOM); initialPath.push(initialOffset); const elementPath = indexPath(elementDOM, this.element); let offset = element.getIndexWithinParent(); for (let i = 0; i < elementPath.length; i++) { const target = initialPath[i]; const source = elementPath[i]; if (target === undefined || target < source) { break; } else if (target > source) { offset += 1; break; } } return [element.getParentOrThrow(), offset]; } } export function indexPath(root: HTMLElement, child: Node): number[] { const path: number[] = []; let node: Node | null = child; for (; node !== root && node !== null; node = node.parentNode) { let i = 0; for ( let sibling = node.previousSibling; sibling !== null; sibling = sibling.previousSibling ) { i++; } path.push(i); } invariant(node === root, 'indexPath: root is not a parent of child'); return path.reverse(); }