lexical
Version:
Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.
1,401 lines (1,335 loc) • 771 kB
JavaScript
/**
* 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