UNPKG

lexical

Version:

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

1,401 lines (1,335 loc) 771 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. * */ /** * 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. * */ // Do not require this module directly! Use normal `invariant` calls. function formatDevErrorMessage(message) { throw new Error(message); } /** * 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. * */ const CAN_USE_DOM = typeof window !== 'undefined' && // eslint-disable-next-line no-restricted-syntax typeof window.document !== 'undefined' && // eslint-disable-next-line no-restricted-syntax typeof window.document.createElement !== 'undefined'; const documentMode = // eslint-disable-next-line no-restricted-syntax CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null; const IS_APPLE = CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform); const IS_FIREFOX = CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent); const CAN_USE_BEFORE_INPUT = CAN_USE_DOM && 'InputEvent' in window && !documentMode ? // eslint-disable-next-line no-restricted-syntax 'getTargetRanges' in new window.InputEvent('input') : false; const IS_IOS = CAN_USE_DOM && /iPad|iPhone|iPod/.test(navigator.userAgent) && // eslint-disable-next-line no-restricted-syntax !window.MSStream; const IS_ANDROID = CAN_USE_DOM && /Android/.test(navigator.userAgent); // Exclude Android — Android WebView's UA contains "Version/X.X ... Safari/537.36" // which falsely matches the Safari regex, activating wrong composition code paths. const IS_SAFARI = CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent) && !IS_ANDROID; // Keep these in case we need to use them in the future. // export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform); const IS_CHROME = CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent); // export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode; const IS_ANDROID_CHROME = CAN_USE_DOM && IS_ANDROID && IS_CHROME; const IS_APPLE_WEBKIT = CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && IS_APPLE && !IS_CHROME; /** * 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. * */ // DOM const DOM_ELEMENT_TYPE = 1; const DOM_TEXT_TYPE = 3; const DOM_DOCUMENT_TYPE = 9; const DOM_DOCUMENT_FRAGMENT_TYPE = 11; // Reconciling const NO_DIRTY_NODES = 0; const HAS_DIRTY_NODES = 1; const FULL_RECONCILE = 2; // Text node modes const IS_NORMAL = 0; const IS_TOKEN = 1; const IS_SEGMENTED = 2; // IS_INERT = 3 // Text node formatting const IS_BOLD = 1; const IS_ITALIC = 1 << 1; const IS_STRIKETHROUGH = 1 << 2; const IS_UNDERLINE = 1 << 3; const IS_CODE = 1 << 4; const IS_SUBSCRIPT = 1 << 5; const IS_SUPERSCRIPT = 1 << 6; const IS_HIGHLIGHT = 1 << 7; const IS_LOWERCASE = 1 << 8; const IS_UPPERCASE = 1 << 9; const IS_CAPITALIZE = 1 << 10; const IS_ALL_FORMATTING = IS_BOLD | IS_ITALIC | IS_STRIKETHROUGH | IS_UNDERLINE | IS_CODE | IS_SUBSCRIPT | IS_SUPERSCRIPT | IS_HIGHLIGHT | IS_LOWERCASE | IS_UPPERCASE | IS_CAPITALIZE; // Text node details const IS_DIRECTIONLESS = 1; const IS_UNMERGEABLE = 1 << 1; // Element node formatting const IS_ALIGN_LEFT = 1; const IS_ALIGN_CENTER = 2; const IS_ALIGN_RIGHT = 3; const IS_ALIGN_JUSTIFY = 4; const IS_ALIGN_START = 5; const IS_ALIGN_END = 6; // Reconciliation const NON_BREAKING_SPACE = '\u00A0'; const ZERO_WIDTH_SPACE = '\u200b'; // For iOS/Safari we use a non breaking space, otherwise the cursor appears // overlapping the composed text. const COMPOSITION_SUFFIX = IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT ? NON_BREAKING_SPACE : ZERO_WIDTH_SPACE; const DOUBLE_LINE_BREAK = '\n\n'; // For FF, we need to use a non-breaking space, or it gets composition // in a stuck state. const COMPOSITION_START_CHAR = IS_FIREFOX ? NON_BREAKING_SPACE : COMPOSITION_SUFFIX; const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC'; const LTR = 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' + '\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' + '\uFE00-\uFE6F\uFEFD-\uFFFF'; // eslint-disable-next-line no-misleading-character-class const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']'); // eslint-disable-next-line no-misleading-character-class const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']'); const TEXT_TYPE_TO_FORMAT = { bold: IS_BOLD, capitalize: IS_CAPITALIZE, code: IS_CODE, highlight: IS_HIGHLIGHT, italic: IS_ITALIC, lowercase: IS_LOWERCASE, strikethrough: IS_STRIKETHROUGH, subscript: IS_SUBSCRIPT, superscript: IS_SUPERSCRIPT, underline: IS_UNDERLINE, uppercase: IS_UPPERCASE }; const DETAIL_TYPE_TO_DETAIL = { directionless: IS_DIRECTIONLESS, unmergeable: IS_UNMERGEABLE }; const ELEMENT_TYPE_TO_FORMAT = { center: IS_ALIGN_CENTER, end: IS_ALIGN_END, justify: IS_ALIGN_JUSTIFY, left: IS_ALIGN_LEFT, right: IS_ALIGN_RIGHT, start: IS_ALIGN_START }; const ELEMENT_FORMAT_TO_TYPE = { [IS_ALIGN_CENTER]: 'center', [IS_ALIGN_END]: 'end', [IS_ALIGN_JUSTIFY]: 'justify', [IS_ALIGN_LEFT]: 'left', [IS_ALIGN_RIGHT]: 'right', [IS_ALIGN_START]: 'start' }; const TEXT_MODE_TO_TYPE = { normal: IS_NORMAL, segmented: IS_SEGMENTED, token: IS_TOKEN }; const TEXT_TYPE_TO_MODE = { [IS_NORMAL]: 'normal', [IS_SEGMENTED]: 'segmented', [IS_TOKEN]: 'token' }; const NODE_STATE_KEY = '$'; const PROTOTYPE_CONFIG_METHOD = '$config'; /** * 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() { return $getEditor()._blockCursorElement; } /** * A slot value renders slots-first into its own `[data-lexical-slot]` * container, prepended ahead of the host's linked-list children. The leading * boundary skips these so they are never counted as managed children. */ function isSlotContainerDOM(node) { return node !== null && node.nodeType === 1 && node.hasAttribute('data-lexical-slot'); } /** * 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 */ class DOMSlot { /** The content-bearing element of the node's DOM. */ element; /** Upper boundary: the lexical-managed range ends before this node. */ before; /** Lower boundary: the lexical-managed range starts after this node. */ after; constructor(element, before, after) { this.element = element; this.before = before || null; this.after = after || null; } /** Return a new slot with `before` updated. */ withBefore(before) { return new DOMSlot(this.element, before, this.after); } /** Return a new slot with `after` updated. */ withAfter(after) { return new DOMSlot(this.element, this.before, after); } /** Return a new slot with `element` updated. */ withElement(element) { if (this.element === element) { return this; } 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) { const before = this.getInsertionAnchor(); if (!(before === null || before.parentElement === this.element)) { formatDevErrorMessage(`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) { if (!(dom.parentElement === this.element)) { formatDevErrorMessage(`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, prevDom) { if (!(prevDom.parentElement === this.element)) { formatDevErrorMessage(`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() { 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() { 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, initialDOM, initialOffset) { 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() { return this.before; } } function $topLevelChildOf(parent, descendant) { let node = 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}. */ class ElementDOMSlot extends DOMSlot { /** Return a new slot with `before` updated, preserving subclass type. */ withBefore(before) { return new ElementDOMSlot(this.element, before, this.after); } /** Return a new slot with `after` updated, preserving subclass type. */ withAfter(after) { return new ElementDOMSlot(this.element, this.before, after); } /** Return a new slot with `element` updated, preserving subclass type. */ withElement(element) { if (this.element === element) { return this; } return new ElementDOMSlot(element, this.before, this.after); } /** * @internal */ getInsertionAnchor() { 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. */ getFirstChildAnchor() { let anchor = super.getFirstChildAnchor(); // Advance past the prepended slot containers (a separate channel, not // managed children) so the first slot is never mistaken for the first // child — which would shift every child DOM index by the slot count. let node = anchor ? anchor.nextSibling : this.element.firstChild; while (isSlotContainerDOM(node)) { anchor = node; node = node.nextSibling; } const firstChild = anchor ? anchor.nextSibling : this.element.firstChild; return firstChild !== null && firstChild === $getActiveBlockCursorElement() ? firstChild : anchor; } /** * @internal */ getManagedLineBreak() { const element = this.element; return element.__lexicalLineBreak || null; } /** @internal */ setManagedLineBreak(lineBreakType) { const element = 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() { const br = this.getManagedLineBreak(); if (br) { const element = this.element; const sibling = br.nodeName === 'IMG' ? br.nextSibling : null; if (sibling) { element.removeChild(sibling); } element.removeChild(br); element.__lexicalLineBreak = undefined; } } /** @internal */ insertManagedLineBreak(webkitHack) { const prevBreak = this.getManagedLineBreak(); if (prevBreak) { if (webkitHack === (prevBreak.nodeName === 'IMG')) { return; } this.removeManagedLineBreak(); } const element = this.element; const before = this.before; const br = $getDocument().createElement('br'); br.setAttribute('data-lexical-managed-linebreak', 'true'); element.insertBefore(br, before); if (webkitHack) { const img = $getDocument().createElement('img'); img.setAttribute('data-lexical-managed-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() { 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, elementDOM, initialDOM, initialOffset) { 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]; } } function indexPath(root, child) { const path = []; let node = 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); } if (!(node === root)) { formatDevErrorMessage(`indexPath: root is not a parent of child`); } return path.reverse(); } /** * 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. * */ // `"0.47.0+dev.esm"` is statically replaced with the build-specific // version string in a Rollup build, and a consumer's bundler `define` can // inject it the same way — so the exact `"0.47.0+dev.esm"` member // expression must be preserved for that substitution to match. Reading it // inside a try/catch lets the source be consumed directly (via the `source` // export condition) in a browser bundle, where `process` is undefined and // nothing replaced the reference, without throwing a ReferenceError; it falls // back to the literal below instead. The literal is regenerated by // `pnpm run update-version`. let envLexicalVersion; try { envLexicalVersion = "0.47.0+dev.esm"; } catch (_unused) { // `process` is not defined in some browser bundles; use the fallback. } const LEXICAL_VERSION = envLexicalVersion ?? '"<unknown>+source"'; /** * 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. * */ class DequeSet { _front = new Set(); _back = new Set(); _cache; get size() { return this._front.size + this._back.size; } addBack(v) { delete this._cache; if (!this._front.has(v)) { this._back.add(v); } return this; } addFront(v) { delete this._cache; if (!this._back.has(v)) { this._front.add(v); } return this; } delete(v) { delete this._cache; return this._front.delete(v) || this._back.delete(v); } toArray() { const arr = Array.from(this._front).reverse(); for (const v of this._back) { arr.push(v); } return arr; } toReadonlyArray() { this._cache = this._cache || this.toArray(); return this._cache; } [Symbol.iterator]() { return this.toReadonlyArray()[Symbol.iterator](); } } /** * 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. * */ const TOMBSTONE = null; const GEN_MAP_SIZE_THRESHOLD = 1000; /** * @internal * * Create a copy of the given Map, returning either a fresh Map or a clone * of a copy-on-write GenMap depending on the source type and size. * * - If the source is already a GenMap, returns `map.clone()` (O(1)). * - If the source is a plain Map below the threshold, returns * `new Map(map)` to avoid the GenMap overhead on small docs. * - Otherwise wraps a fresh GenMap around the source. */ function cloneMap(map, minGenMapSize = GEN_MAP_SIZE_THRESHOLD) { if (map instanceof GenMap) { return map.clone(); } if (map.size < minGenMapSize) { return new Map(map); } return new GenMap().init(new Map(map), undefined, map.size); } /** * @internal * * A copy-on-write Map suitable for cloning large collections cheaply. * * Before being written to, a GenMap shares its `_old` and `_nursery` Maps * with the GenMap it was cloned from. On first write it either compacts * (folds `_nursery` into a new `_old`) or shallow-copies `_nursery`, * isolating subsequent writes from sibling clones. * * `_old` is the immutable snapshot from the most recent compaction; * `_nursery` holds writes since the last compaction (deletions stored as * `TOMBSTONE`). `_mutable` tracks whether `_nursery` may be written to * directly or must first be cloned. * * Implements the full `Map<K, V>` interface; methods not documented * individually behave as their native `Map` counterparts. */ class GenMap { _mutable = false; _old = undefined; _nursery = undefined; _size = 0; /** * Returns a new GenMap that initially shares `_old` and `_nursery` * with this one. Marks both as not-mutable so the next write on either * side triggers a copy-on-write of the nursery before mutating. */ clone() { this._mutable = false; return new GenMap().init(this._old, this._nursery, this._size); } init(old, nursery, size) { this._old = old; this._nursery = nursery; this._size = size; return this; } get size() { return this._size; } has(key) { return this.get(key) !== undefined; } /** * Returns the raw value for `key`, including TOMBSTONE for keys deleted * since the last compaction. Used internally to distinguish "missing" * from "deleted" without doing a second lookup. */ getWithTombstone(key) { const v = this._nursery && this._nursery.get(key); if (v !== undefined) { return v; } return this._old && this._old.get(key); } get(key) { const v = this.getWithTombstone(key); return v === TOMBSTONE ? undefined : v; } shouldCompact() { return this._nursery !== undefined && this._nursery.size * 2 > this._size; } /** * Returns the nursery for in-place writes. If this GenMap is currently * sharing its nursery with an ancestor clone, this either compacts (if * the nursery has grown large enough) or makes a shallow copy. */ getNursery() { if (!this._mutable || !this._nursery) { this.compact(); this._nursery = new Map(this._nursery); this._mutable = true; } return this._nursery; } /** * Fold the nursery into a new `_old` snapshot when it has grown large * enough that lookup overhead outweighs the savings from sharing. * Triggered automatically from `getNursery` once `_nursery.size * 2 > * _size`; can be forced via `compact(true)`. */ compact(force = false) { if (this._nursery && this._nursery.size > 0 && (force || this.shouldCompact())) { const compact = new Map(this._old); for (const [k, v] of this._nursery) { if (v !== TOMBSTONE) { compact.set(k, v); } else { compact.delete(k); } } this._old = compact; this._nursery = undefined; } this._mutable = false; return this; } set(key, value) { const v = this.getWithTombstone(key); if (v === value) { return this; } const nursery = this.getNursery(); if (v === TOMBSTONE || v === undefined) { this._size++; if (v === TOMBSTONE) { // Match native Map semantics where `delete(k); set(k, v)` // re-inserts the key at the end of iteration order. nursery.delete(key); } } nursery.set(key, value); return this; } delete(key) { const deleted = this.has(key); if (deleted) { this.getNursery().set(key, TOMBSTONE); this._size--; } return deleted; } getOrInsert(key, defaultValue) { const existing = this.get(key); if (existing !== undefined) { return existing; } this.set(key, defaultValue); return defaultValue; } getOrInsertComputed(key, computer) { const existing = this.get(key); if (existing !== undefined) { return existing; } const value = computer(key); this.set(key, value); return value; } clear() { this._mutable = false; this._old = undefined; this._nursery = undefined; this._size = 0; } *keys() { for (const pair of this.entries()) { yield pair[0]; } } *values() { for (const pair of this.entries()) { yield pair[1]; } } *entries() { const nursery = this._nursery; const old = this._old; if (old) { for (const pair of old) { const k = pair[0]; const v = nursery ? nursery.get(k) : undefined; if (v === TOMBSTONE) { continue; } else if (v !== undefined) { pair[1] = v; } yield pair; } } if (nursery) { for (const pair of nursery) { if (pair[1] !== TOMBSTONE && !(old && old.has(pair[0]))) { yield pair; } } } } forEach(callbackfn, // eslint-disable-next-line @typescript-eslint/no-explicit-any thisArg) { if (thisArg !== undefined) { callbackfn = callbackfn.bind(thisArg); } for (const [k, v] of this.entries()) { callbackfn(v, k, this); } } get [Symbol.toStringTag]() { return 'GenMap'; } [Symbol.iterator]() { return this.entries(); } } /** * 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. * */ function $garbageCollectDetachedDecorators(editor, pendingEditorState) { const currentDecorators = editor._decorators; const pendingDecorators = editor._pendingDecorators; let decorators = pendingDecorators || currentDecorators; const nodeMap = pendingEditorState._nodeMap; let key; for (key in decorators) { if (!nodeMap.has(key)) { if (decorators === currentDecorators) { decorators = cloneDecorators(editor); } delete decorators[key]; } } } function $garbageCollectDetachedDeepChildNodes(node, parentKey, prevNodeMap, nodeMap, nodeMapDelete, dirtyNodes) { if ($isElementNode(node)) { let child = node.getFirstChild(); while (child !== null) { const childKey = child.__key; // TODO Revise condition below, redundant? LexicalNode already cleans up children when moving Nodes if (child.__parent === parentKey) { if ($isElementNode(child) || $isSlotHost(child) && child.__slots !== null) { $garbageCollectDetachedDeepChildNodes(child, childKey, prevNodeMap, nodeMap, nodeMapDelete, dirtyNodes); } // If we have created a node and it was dereferenced, then also // remove it from out dirty nodes Set. if (!prevNodeMap.has(childKey)) { dirtyNodes.delete(childKey); } nodeMapDelete.push(childKey); } child = child.getNextSibling(); } } // Slot nodes are not in the linked-list child channel; reach them through // the slot map, gating on the slot host the mirror of the __parent check. // Slots hang off any host (element or decorator), so this runs regardless // of the host node type. for (const slotKey of $isSlotHost(node) && node.__slots !== null ? node.__slots.values() : []) { const slotNode = nodeMap.get(slotKey); if (slotNode !== undefined && $isSlotChild(slotNode) && slotNode.__slotHost === parentKey) { if ($isElementNode(slotNode) || $isSlotHost(slotNode) && slotNode.__slots !== null) { $garbageCollectDetachedDeepChildNodes(slotNode, slotKey, prevNodeMap, nodeMap, nodeMapDelete, dirtyNodes); } if (!prevNodeMap.has(slotKey)) { dirtyNodes.delete(slotKey); } nodeMapDelete.push(slotKey); } } } function $garbageCollectDetachedNodes(prevEditorState, editorState, dirtyLeaves, dirtyElements) { const prevNodeMap = prevEditorState._nodeMap; const nodeMap = editorState._nodeMap; // Store dirtyElements in a queue for later deletion; deleting dirty subtrees too early will // hinder accessing .__next on child nodes const nodeMapDelete = []; for (const [nodeKey] of dirtyElements) { const node = nodeMap.get(nodeKey); if (node !== undefined) { // Garbage collect node and its children if they exist if (!node.isAttached()) { if ($isElementNode(node)) { $garbageCollectDetachedDeepChildNodes(node, nodeKey, prevNodeMap, nodeMap, nodeMapDelete, dirtyElements); } // If we have created a node and it was dereferenced, then also // remove it from out dirty nodes Set. if (!prevNodeMap.has(nodeKey)) { dirtyElements.delete(nodeKey); } nodeMapDelete.push(nodeKey); } } } for (const nodeKey of dirtyLeaves) { const node = nodeMap.get(nodeKey); if (node !== undefined && !node.isAttached()) { // A decorator host is a leaf, so the element deep-walk above never // reaches its slots; collect them here to avoid orphaning the slot // subtree. Deletion is deferred to the shared queue so the walk can // still read the slot nodes. When a host is in dirtyElements and one of // its slot values is also dirty, the two loops can both push the same // slot subtree key into nodeMapDelete — that redundancy is harmless // because nodeMap.delete is idempotent and the dirtyNodes.delete calls // are too. if ($isSlotHost(node) && node.__slots !== null) { $garbageCollectDetachedDeepChildNodes(node, nodeKey, prevNodeMap, nodeMap, nodeMapDelete, dirtyLeaves); } if (!prevNodeMap.has(nodeKey)) { dirtyLeaves.delete(nodeKey); } nodeMapDelete.push(nodeKey); } } for (const nodeKey of nodeMapDelete) { nodeMap.delete(nodeKey); } // Clear the composition key if it points at a node that just got collected. // Without this, isComposing() keeps reporting true after a remote yjs // update (or any host removal) drops the composing TextNode — most often // observable when the composing node sits inside a slot subtree that gets // collected wholesale via the dual-channel slot GC above. const editor = getActiveEditor(); const compositionKey = editor._compositionKey; if (compositionKey !== null && !nodeMap.has(compositionKey)) { editor._compositionKey = null; } } /** * 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. * */ // The time between a text entry event and the mutation observer firing. const TEXT_MUTATION_VARIANCE = 100; let isProcessingMutations = false; let lastTextEntryTimeStamp = 0; function getIsProcessingMutations() { return isProcessingMutations; } function updateTimeStamp(event) { lastTextEntryTimeStamp = event.timeStamp; } function initTextEntryListener(editor) { if (lastTextEntryTimeStamp === 0) { getWindow(editor).addEventListener('textInput', updateTimeStamp, true); } } function isManagedLineBreak(dom, target, editor) { const isBR = dom.nodeName === 'BR'; const lexicalLineBreak = target.__lexicalLineBreak; return lexicalLineBreak && (dom === lexicalLineBreak || isBR && dom.previousSibling === lexicalLineBreak) || isBR && getNodeKeyFromDOMNode(dom, editor) !== undefined; } function getLastSelection(editor) { return editor.read('latest', () => { const selection = $getSelection(); return selection !== null ? selection.clone() : null; }); } function $handleTextMutation(target, node, editor) { const domSelection = getDOMSelection(getWindow(editor)); const domSelectionPoints = domSelection && getDOMSelectionPoints(domSelection, editor._rootElement); let anchorOffset = null; let focusOffset = null; if (domSelectionPoints !== null && domSelectionPoints.anchorNode === target) { anchorOffset = domSelectionPoints.anchorOffset; focusOffset = domSelectionPoints.focusOffset; } const text = target.nodeValue; if (text !== null) { $updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false); } } function shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode) { if ($isRangeSelection(selection)) { const anchorNode = selection.anchor.getNode(); if (anchorNode.is(targetNode) && selection.format !== anchorNode.getFormat()) { return false; } } return isDOMTextNode(targetDOM) && targetNode.isAttached(); } function $getNearestManagedNodePairFromDOMNode(startingDOM, editor, editorState) { for (let dom = startingDOM; dom && !isDOMUnmanaged(dom); dom = getParentElement(dom)) { const key = getNodeKeyFromDOMNode(dom, editor); if (key !== undefined) { const node = $getNodeByKey(key, editorState); if (node) { // All decorator nodes are unmanaged return $isDecoratorNode(node) || !isHTMLElement(dom) ? undefined : [dom, node]; } } } } function flushMutations(editor, mutations, observer) { isProcessingMutations = true; const shouldFlushTextMutations = performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE; try { updateEditorSync(editor, () => { const selection = $getSelection() || getLastSelection(editor); const badDOMTargets = new Map(); // We use the current editor state, as that reflects what is // actually "on screen". const currentEditorState = editor._editorState; const blockCursorElement = editor._blockCursorElement; let shouldRevertSelection = false; let possibleTextForFirefoxPaste = ''; for (let i = 0; i < mutations.length; i++) { const mutation = mutations[i]; const type = mutation.type; const targetDOM = mutation.target; const pair = $getNearestManagedNodePairFromDOMNode(targetDOM, editor, currentEditorState); if (!pair) { continue; } const [nodeDOM, targetNode] = pair; if (type === 'characterData') { // Text mutations are deferred and passed to mutation listeners to be // processed outside of the Lexical engine. if ( // TODO there is an edge case here if a mutation happens too quickly // after text input, it may never be handled since we do not // track the ignored mutations in any way shouldFlushTextMutations && $isTextNode(targetNode) && isDOMTextNode(targetDOM) && shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)) { $handleTextMutation(targetDOM, targetNode, editor); } } else if (type === 'childList') { shouldRevertSelection = true; // We attempt to "undo" any changes that have occurred outside // of Lexical. We want Lexical's editor state to be source of truth. // To the user, these will look like no-ops. const addedDOMs = mutation.addedNodes; for (let s = 0; s < addedDOMs.length; s++) { const addedDOM = addedDOMs[s]; const node = $getNodeFromDOMNode(addedDOM); const parentDOM = addedDOM.parentNode; if (parentDOM != null && addedDOM !== blockCursorElement && node === null && !isManagedLineBreak(addedDOM, parentDOM, editor) && // @experimental named-slots. Slot containers are keyless // reconciler scaffolding: a flush that observes one being // parked in its host or relocated by an explicit mount must // not evict it as foreign DOM. Gated on the editor slot latch so // a non-slot editor still evicts foreign DOM that happens to // carry a `data-lexical-slot` attribute. !(editor._slotsUsed && isHTMLElement(addedDOM) && addedDOM.hasAttribute('data-lexical-slot')) && // Skip externally-added DOM that's explicitly opted out of // mutation tracking (e.g. an extension-rendered decoration // inside a TextNode's span, like the autocomplete ghost). !isDOMUnmanaged(addedDOM)) { if (IS_FIREFOX) { const possibleText = (isHTMLElement(addedDOM) ? addedDOM.innerText : null) || addedDOM.nodeValue; if (possibleText) { possibleTextForFirefoxPaste += possibleText; } } parentDOM.removeChild(addedDOM); } } const removedDOMs = mutation.removedNodes; const removedDOMsLength = removedDOMs.length; if (removedDOMsLength > 0) { let unremovedBRs = 0; for (let s = 0; s < removedDOMsLength; s++) { const removedDOM = removedDOMs[s]; if (isManagedLineBreak(removedDOM, targetDOM, editor) || blockCursorElement === removedDOM) { targetDOM.appendChild(removedDOM); unremovedBRs++; } } if (removedDOMsLength !== unremovedBRs) { badDOMTargets.set(nodeDOM, targetNode); } } } } // Now we process each of the unique target nodes, attempting // to restore their contents back to the source of truth, which // is Lexical's "current" editor state. This is basically like // an internal revert on the DOM. if (badDOMTargets.size > 0) { for (const [nodeDOM, targetNode] of badDOMTargets) { targetNode.reconcileObservedMutation(nodeDOM, editor); } } // Capture all the mutations made during this function. This // also prevents us having to process them on the next cycle // of onMutation, as these mutations were made by us. const records = observer.takeRecords(); // Check for any random auto-added <br> elements, and remove them. // These get added by the browser when we undo the above mutations // and this can lead to a broken UI. if (records.length > 0) { for (let i = 0; i < records.length; i++) { const record = records[i]; const addedNodes = record.addedNodes; const target = record.target; for (let s = 0; s < addedNodes.length; s++) { const addedDOM = addedNodes[s]; const parentDOM = addedDOM.parentNode; if (parentDOM != null && addedDOM.nodeName === 'BR' && !isManagedLineBreak(addedDOM, target, editor)) { parentDOM.removeChild(addedDOM); } } } // Clear any of those removal mutations observer.takeRecords(); } if (selection !== null) { if (shouldRevertSelection) { $setSelection(selection); } if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) { selection.insertRawText(possibleTextForFirefoxPaste); } } }); } finally { isProcessingMutations = false; } } function flushRootMutations(editor) { const observer = editor._observer; if (observer !== null) { const mutations = observer.takeRecords(); flushMutations(editor, mutations, observer); } } function initMutationObserver(editor) { initTextEntryListener(editor); editor._observer = new MutationObserver((mutations, observer) => { flushMutations(editor, mutations, observer); }); } /** * Read the state directly from the given object without `node.getLatest()`. * Safe to use outside of editor state context or to read a previous version, * equivalent to reading the property directly. */ const NODE_STATE_DIRECT = 'direct'; /** * Use `node.getLatest()` before reading the state, per the lexical convention * of only working with the latest version of a node. */ const NODE_STATE_LATEST = 'latest'; /** * Get the value type (V) from a StateConfig */ /** * Get the key type (K) from a StateConfig */ /** * A value type, or an updater for that value type. For use with * {@link $setState} or any user-defined wrappers around it. */ /** * A type alias to make it easier to define setter methods on your node class * * @example * ```ts * const fooState = createState("foo", { parse: ... }); * class MyClass extends TextNode { * // ... * setFoo(valueOrUpdater: StateValueOrUpdater<typeof fooState>): this { * return $setState(this, fooState, valueOrUpdater); * } * } * ``` */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-enable @typescript-eslint/no-explicit-any */ // Read a node's own config out of its $config() record. Preferentially read it // from the STATIC_NODE_CONFIG accessor (see {@link GetStaticNodeOwnConfig}), // which resolves the most-derived own config directly — including for an // abstract base class keyed by a symbol, which has no string `type` to index by. // A record produced by the {@link BaseStaticNodeConfig} fallback (a node that // declares no `extends`, or a legacy node) sets no accessor; for those we fall // back to resolving the own `type` (see {@link GetStaticNodeType}) and indexing // by it. The own type is read through a mapped type (`{[P in Type]: ...}[Type]`) // so that the indexed access resolves against the concrete key literal rather // than the record's broad string index signature when `T` is still generic. /** * The NodeState JSON produced by this LexicalNode */ /** * Configure a value to be used with StateConfig. * * The value type should be inferred from the definition of parse. * * If the value type is not JSON serializable, then unparse must also be provided. * * Values should be treated as immutable, much like React.useState. Mutating * stored values directly will cause unpredictable behavior, is not supported, * and may trigger errors in the future. * * @example * ```ts * const numberOrNullState = createState('numberOrNull', {parse: (v) => typeof v === 'number' ? v : null}); * // ^? State<'numberOrNull', StateValueConfig<number | null>> * const numberState = createState('number', {parse: (v) => typeof v === 'number' ? v : 0}); * // ^? State<'number', StateValueConfig<number>> * ``` * * Only the parse option is required, it is generally not useful to * override `unparse` or `isEqual`. However, if you are using * non-primitive types such as Array, Object, Date, or something * more exotic then you would want to override this. In these * cases you might want to reach for third party libraries. * * @example * ```ts * const isoDateState = createState('isoDate', { * parse: (v): null | Date => { * const date = typeof v === 'string' ? new Date(v) : null; * return date && !isNaN(date.valueOf()) ? date : null; * } * isEqual: (a, b) => a === b || (a && b && a.valueOf() === b.valueOf()), * unparse: (v) => v && v.toString() * }); * ``` * * You may find it easier to write a parse function using libraries like * zod, valibot, ajv, Effect, TypeBox, etc. perhaps with a wrapper function. */ /** * The return value of {@link createState}, for use with * {@link $getState} and {@link $setState}. */ class StateConfig { /** The string key used when serializing this state to JSON */ key; /** The parse function from the StateValueConfig passed to createState */ parse; /** * The unparse function from the StateValueConfig passed to createState, * with a default that is simply a pass-through that assumes the value is * JSON serializable. */ unparse; /** * An equality function from the StateValueConfig, with a default of * Object.is. */ isEqual; /** * The result of `stateValueConfig.parse(undefined)`, which is computed only * once and used as the default value. When the current value `isEqual` to * the `defaultValue`, it will not be serialized to JSON. */ defaultValue; resetOnCopyNode; constructor(key, stateValueConfig) { this.key = key; this.parse = stateValueConfig.parse.bind(stateValueConfig); this.unparse = (stateValueConfig.unparse || coerceToJSON).bind(stateValueConfig); this.isEqual = (stateValueConfig.isEqual || Object.is).bind(stateValueConfig); this.defaultValue = this.parse(undefined); this.resetOnCopyNode = stateValueConfig.resetOnCopyNode || false; } } /** * For advanced use cases, using this type is not recommended unless * it is required (due to TypeScript's lack of features like * higher-kinded types). * * A {@link StateConfig} type with any key and any value that can be * used in situations where the key and value type can not be known, * such as in a generic constraint when working with a collection of * StateConfig. * * {@link StateConfigKey} and {@link StateConfigValue} will be * useful when this is used as a generic constraint. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any /** * Create a StateConfig for the given string key and StateValueConfig. * * The key must be locally unique. In dev you will get a key collision error * when you use two separate StateConfig on the same node with the same key. * * The returned StateConfig value should be used with {@link $getState} and * {@link $setState}. * * @param key The key to use * @param valueConfig Configuration for the value type * @returns a StateConfig * * @__NO_SIDE_EFFECTS__ */ function createState(key, valueConfig) { return new StateConfig(key, valueConfig); } /** * The accessor for working with node state. This will read the value for the * state on the given node, and will return `stateConfig.defaultValue` if the * state has never been set on this node. * * The `version` parameter is optional and should generally be {@link NODE_STATE_LATEST}, * consistent with the behavior of other node methods and functions, * but for certain use cases such as `updateDOM` you may have a need to * use {@link NODE_STATE_DIRECT} to read the state from a previous version of the node. * * For very advanced use cases, you can expect that {@link NODE_STATE_DIRECT} does not * require an editor state, just like directly accessing other properties * of a node without an accessor (e.g. `textNode.__text`). * * @param node Any LexicalNode * @param stateConfig The configuration of the state to read * @param version The default value {@link NODE_STATE_LATEST} will read the latest version of the node state, {@link NODE_STATE_DIRECT} will read the version that is stored on this LexicalNode which not reflect the version used in the current editor state * @returns The current value from the state, or the default value provided by the configuration. */ function $getState(node, stateConfig, version = NODE_STATE_LATEST) { const latestOrDirectNode = version === NODE_STATE_LATEST ? node.getLatest() : node; const state = latestOrDirectNode.__state; if (state) { $checkCollision(node, stateConfig, state); return state.getValue(stateConfig); } return stateConfig.defaultValue; } /** * Given two versions of a node and a stateConfig, compare their state values * using `$getState(nodeVersion, stateConfig, NODE_STATE_DIRECT)`. * If the values are equal according to `stateConfig.isEqual`, return `null`, * otherwise return `[value, prevValue]`. * * This is us