UNPKG

lexical

Version:

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

1,463 lines (1,394 loc) 600 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. * */ 'use strict'; /** * 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' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined'; const documentMode = 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 ? 'getTargetRanges' in new window.InputEvent('input') : false; const IS_IOS = CAN_USE_DOM && /iPad|iPhone|iPod/.test(navigator.userAgent) && !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; } /** * 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() { const after = super.getFirstChildAnchor(); const firstChild = after ? after.nextSibling : this.element.firstChild; return firstChild !== null && firstChild === $getActiveBlockCursorElement() ? firstChild : after; } /** * @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 = 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() { 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.45.0+dev.cjs"` 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.45.0+dev.cjs"` 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.45.0+dev.cjs"; } catch (_unused) { // `process` is not defined in some browser bundles; use the fallback. } const LEXICAL_VERSION = envLexicalVersion ?? '0.45.0+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) { 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)) { $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(); } } 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 nodeMapDelete) { nodeMap.delete(nodeKey); } for (const nodeKey of dirtyLeaves) { const node = nodeMap.get(nodeKey); if (node !== undefined && !node.isAttached()) { if (!prevNodeMap.has(nodeKey)) { dirtyLeaves.delete(nodeKey); } nodeMap.delete(nodeKey); } } } /** * 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.getEditorState().read(() => { const selection = $getSelection(); return selection !== null ? selection.clone() : null; }); } function $handleTextMutation(target, node, editor) { const domSelection = getDOMSelection(getWindow(editor)); let anchorOffset = null; let focusOffset = null; if (domSelection !== null && domSelection.anchorNode === target) { anchorOffset = domSelection.anchorOffset; focusOffset = domSelection.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, rootElement) { 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]; } } else if (dom === rootElement) { return [rootElement, internalGetRoot(editorState)]; } } } 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(); const rootElement = editor.getRootElement(); // 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, rootElement); 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) && // 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); }); } /** * 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 */ /** * 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 `'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 `'direct'` to read the state from a previous version of the node. * * For very advanced use cases, you can expect that '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 'latest' will read the latest version of the 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 = 'latest') { const latestOrDirectNode = version === '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, 'direct')`. * If the values are equal according to `stateConfig.isEqual`, return `null`, * otherwise return `[value, prevValue]`. * * This is useful for implementing updateDOM. Note that the `'direct'` * version argument is used for both nodes. * * @param node Any LexicalNode * @param prevNode A previous version of node * @param stateConfig The configuration of the state to read * @returns `[value, prevValue]` if changed, otherwise `null` */ function $getStateChange(node, prevNode, stateConfig) { const value = $getState(node, stateConfig, 'direct'); const prevValue = $getState(prevNode, stateConfig, 'direct'); return stateConfig.isEqual(value, prevValue) ? null : [value, prevValue]; } /** * Set the state defined by stateConfig on node. Like with `React.useState` * you may directly specify the value or use an updater function that will * be called with the previous value of the state on that node (which will * be the `stateConfig.defaultValue` if not set). * * When an updater function is used, the node will only be marked dirty if * `stateConfig.isEqual(prevValue, value)` is false. * * @example * ```ts * const toggle = createState('toggle', {parse: Boolean}); * // set it direction * $setState(node, counterState, true); * // use an updater * $setState(node, counterState, (prev) => !prev); * ``` * * @param node The LexicalNode to set the state on * @param stateConfig The configuration for this state * @param valueOrUpdater The value or updater function * @returns node */ function $setState(node, stateConfig, valueOrUpdater) { errorOnReadOnly(); let value; if (typeof valueOrUpdater === 'function') { const latest = node.getLatest(); const prevValue = $getState(latest, stateConfig); value = valueOrUpdater(prevValue); if (stateConfig.isEqual(prevValue, value)) { return latest; } } else { value = valueOrUpdater; } const writable = node.getWritable(); const state = $getWritableNodeState(writable); $checkCollision(node, stateConfig, state); state.updateFromKnown(stateConfig, value); return writable; } /** * @internal * * Register the config to this node's sharedConfigMap and throw an exception in * `__DEV__` when a collision is detected. */ function $checkCollision(node, stateConfig, state) { { const collision = state.sharedNodeState.sharedConfigMap.get(stateConfig.key); if (collision !== undefined && collision !== stateConfig) { { formatDevErrorMessage(`$setState: State key collision ${JSON.stringify(stateConfig.key)} detected in ${node.constructor.name} node with type ${node.getType()} and key ${node.getKey()}. Only one StateConfig with a given key should be used on a node.`); } } } } /** * @internal * * Opaque state to be stored on the editor's RegisterNode for use by NodeState */ /** * @internal * * Create the state to store on RegisteredNode */ function createSharedNodeState(nodeConfig) { const sharedConfigMap = new Map(); const flatKeys = new Set(); for (let klass = typeof nodeConfig === 'function' ? nodeConfig : nodeConfig.replace; klass.prototype && klass.prototype.getType !== undefined; klass = Object.getPrototypeOf(klass)) { const { ownNodeConfig } = getStaticNodeConfig(klass); if (ownNodeConfig && ownNodeConfig.stateConfigs) { for (const requiredStateConfig of ownNodeConfig.stateConfigs) { let stateConfig; if ('stateConfig' in requiredStateConfig) { stateConfig = requiredStateConfig.stateConfig; if (requiredStateConfig.flat) { flatKeys.add(stateConfig.key); } } else { stateConfig = requiredStateConfig; } sharedConfigMap.set(stateConfig.key, stateConfig); } } } return { flatKeys, sharedConfigMap }; } /** * @internal * * A Map of string keys to state configurations to be shared across nodes * and/or node versions. */ /** * @internal */ class NodeState { /** * @internal * * Track the (versioned) node that this NodeState was created for, to * facilitate copy-on-write for NodeState. When a LexicalNode is cloned, * it will *reference* the NodeState from its prevNode. From the nextNode * you can continue to read state without copying, but the first $setState * will trigger a copy of the prevNode's NodeState with the node property * updated. */ node; /** * @internal * * State that has already been parsed in a get state, so it is safe. (can be returned with * just a cast since the proof was given before). * * Note that it uses StateConfig, so in addition to (1) the CURRENT VALUE, it has access to * (2) the State key (3) the DEFAULT VALUE and (4) the PARSE FUNCTION */ knownState; /** * @internal * * A copy of serializedNode[NODE_STATE_KEY] that is made when JSON is * imported but has not been parsed yet. * * It stays here until a get state requires us to parse it, and since we * then know the value is safe we move it to knownState. * * Note that since only string keys are used here, we can only allow this * state to pass-through on export or on the next version since there is * no known