UNPKG

lexical

Version:

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

1,444 lines (1,366 loc) 471 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'; /** * 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 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_SAFARI = CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent); const IS_IOS = CAN_USE_DOM && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; const IS_ANDROID = CAN_USE_DOM && /Android/.test(navigator.userAgent); // 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_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. * */ function normalizeClassNames(...classNames) { const rval = []; for (const className of classNames) { if (className && typeof className === 'string') { for (const [s] of className.matchAll(/\S+/g)) { rval.push(s); } } } return rval; } /** * 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 = '$'; /** * 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 (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)) { 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); }); } function coerceToJSON(v) { return v; } /** * 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 */ /** The parse function from the StateValueConfig passed to createState */ /** * 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. */ /** * An equality function from the StateValueConfig, with a default of * Object.is. */ /** * 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. */ 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); } } /** * 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 /** * 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. */ /** * 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. */ /** * Create a StateConfig for the given string key and StateValueConfig. * * The key must be locally unique. In dev you wil 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 */ function createState(key, valueConfig) { return new StateConfig(key, valueConfig); } /** * 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]; } /** * 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; } /** * @internal * * Register the config to this node's sharedConfigMap and throw an exception in * `true` when a collision is detected. */ function $checkCollision(node, stateConfig, state) { { const collision = state.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.`); } } } } /** * 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 */ 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. */ /** * @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 */ /** * @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 and garbage collect * it at the next version. * * 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 value configuration. This pass-through is to support scenarios * where multiple versions of the editor code are working in parallel so * an old version of your code doesnt erase metadata that was * set by a newer version of your code. */ /** * @internal * * This sharedConfigMap is preserved across all versions of a given node and * remains writable. It is how keys are resolved to configuration. */ /** * @internal * * The count of known or unknown keys in this state, ignoring the * intersection between the two sets. */ /** * @internal */ constructor(node, sharedConfigMap = new Map(), unknownState = undefined, knownState = new Map(), size = undefined) { this.node = node; this.sharedConfigMap = sharedConfigMap; this.unknownState = unknownState; this.knownState = knownState; const computedSize = size !== undefined ? size : computeSize(sharedConfigMap, unknownState, knownState); { if (!(size === undefined || computedSize === size)) { formatDevErrorMessage(`NodeState: size != computedSize (${String(size)} != ${String(computedSize)})`); } for (const stateConfig of knownState.keys()) { if (!sharedConfigMap.has(stateConfig.key)) { formatDevErrorMessage(`NodeState: sharedConfigMap missing knownState key ${stateConfig.key}`); } } } this.size = computedSize; } /** @internal */ getValue(stateConfig) { const known = this.knownState.get(stateConfig); if (known !== undefined) { return known; } this.sharedConfigMap.set(stateConfig.key, stateConfig); let parsed = stateConfig.defaultValue; if (this.unknownState && stateConfig.key in this.unknownState) { const jsonValue = this.unknownState[stateConfig.key]; if (jsonValue !== undefined) { parsed = stateConfig.parse(jsonValue); } // Only update if the key was unknown this.updateFromKnown(stateConfig, parsed); } return parsed; } /** * @internal * * Used only for advanced use cases, such as collab. The intent here is to * allow you to diff states with a more stable interface than the properties * of this class. */ getInternalState() { return [this.unknownState, this.knownState]; } /** * Encode this NodeState to JSON in the format that its node expects. * This returns `{[NODE_STATE_KEY]?: UnknownStateRecord}` rather than * `UnknownStateRecord | undefined` so that we can support flattening * specific entries in the future when nodes can declare what * their required StateConfigs are. */ toJSON() { const state = { ...this.unknownState }; for (const [stateConfig, v] of this.knownState) { if (stateConfig.isEqual(v, stateConfig.defaultValue)) { delete state[stateConfig.key]; } else { state[stateConfig.key] = stateConfig.unparse(v); } } return undefinedIfEmpty(state) ? { [NODE_STATE_KEY]: state } : {}; } /** * @internal * * A NodeState is writable when the node to update matches * the node associated with the NodeState. This basically * mirrors how the EditorState NodeMap works, but in a * bottom-up organization rather than a top-down organization. * * This allows us to implement the same "copy on write" * pattern for state, without having the state version * update every time the node version changes (e.g. when * its parent or siblings change). * * @param node The node to associate with the state * @returns The next writable state */ getWritable(node) { if (this.node === node) { return this; } const nextKnownState = new Map(this.knownState); const nextUnknownState = cloneUnknownState(this.unknownState); if (nextUnknownState) { // Garbage collection for (const stateConfig of nextKnownState.keys()) { delete nextUnknownState[stateConfig.key]; } } return new NodeState(node, this.sharedConfigMap, undefinedIfEmpty(nextUnknownState), nextKnownState, this.size); } /** @internal */ updateFromKnown(stateConfig, value) { const key = stateConfig.key; this.sharedConfigMap.set(key, stateConfig); const { knownState, unknownState } = this; if (!(knownState.has(stateConfig) || unknownState && key in unknownState)) { this.size++; } knownState.set(stateConfig, value); } /** * @internal * * This is intended for advanced use cases only, such * as collab or dev tools. * * Update a single key value pair from unknown state, * parsing it if the key is known to this node. This is * basically like updateFromJSON, but the effect is * isolated to a single entry. * * @param k The string key from an UnknownStateRecord * @param v The unknown value from an UnknownStateRecord */ updateFromUnknown(k, v) { const stateConfig = this.sharedConfigMap.get(k); if (stateConfig) { this.updateFromKnown(stateConfig, stateConfig.parse(v)); } else { this.unknownState = this.unknownState || {}; if (!(k in this.unknownState)) { this.size++; } this.unknownState[k] = v; } } /** * @internal * * Reset all existing state to default or empty values, * and perform any updates from the given unknownState. * * This is used when initializing a node's state from JSON, * or when resetting a node's state from JSON. * * @param unknownState The new state in serialized form */ updateFromJSON(unknownState) { const { knownState } = this; // Reset all known state to defaults for (const stateConfig of knownState.keys()) { knownState.set(stateConfig, stateConfig.defaultValue); } // Since we are resetting all state to this new record, // the size starts at the number of known keys // and will be updated as we traverse the new state this.size = knownState.size; this.unknownState = {}; if (unknownState) { for (const [k, v] of Object.entries(unknownState)) { this.updateFromUnknown(k, v); } } this.unknownState = undefinedIfEmpty(this.unknownState); } } function computeSize(sharedConfigMap, unknownState, knownState) { let size = knownState.size; if (unknownState) { for (const k in unknownState) { const sharedConfig = sharedConfigMap.get(k); if (!sharedConfig || !knownState.has(sharedConfig)) { size++; } } } return size; } /** * Return obj if it is an object with at least one property, otherwise * return undefined. */ function undefinedIfEmpty(obj) { if (obj) { for (const key in obj) { return obj; } } return undefined; } /** * Return undefined if unknownState is undefined or an empty object, * otherwise return a shallow clone of it. */ function cloneUnknownState(unknownState) { return undefinedIfEmpty(unknownState) && { ...unknownState }; } /** * @internal * * Only for direct use in very advanced integrations, such as lexical-yjs. * Typically you would only use {@link createState}, {@link $getState}, and * {@link $setState}. This is effectively the preamble for {@link $setState}. */ function $getWritableNodeState(node) { const writable = node.getWritable(); const state = writable.__state ? writable.__state.getWritable(writable) : new NodeState(writable); writable.__state = state; return state; } /** * @internal * * This is used to implement LexicalNode.updateFromJSON and is * not intended to be exported from the package. * * @param node any LexicalNode * @param unknownState undefined or a serialized State * @returns A writable version of node, with the state set. */ function $updateStateFromJSON(node, unknownState) { const writable = node.getWritable(); if (unknownState || writable.__state) { $getWritableNodeState(node).updateFromJSON(unknownState); } return writable; } /** * @internal * * Return true if the two nodes have equivalent NodeState, to be used * to determine when TextNode are being merged, not a lot of use cases * otherwise. */ function $nodeStatesAreEquivalent(a, b) { if (a === b) { return true; } if (a && b && a.size !== b.size) { return false; } const keys = new Set(); const hasUnequalMapEntry = (sourceState, otherState) => { for (const [stateConfig, value] of sourceState.knownState) { if (keys.has(stateConfig.key)) { continue; } keys.add(stateConfig.key); const otherValue = otherState ? otherState.getValue(stateConfig) : stateConfig.defaultValue; if (otherValue !== value && !stateConfig.isEqual(otherValue, value)) { return true; } } return false; }; const hasUnequalRecordEntry = (sourceState, otherState) => { const { unknownState } = sourceState; const otherUnknownState = otherState ? otherState.unknownState : undefined; if (unknownState) { for (const [key, value] of Object.entries(unknownState)) { if (keys.has(key)) { continue; } keys.add(key); const otherValue = otherUnknownState ? otherUnknownState[key] : undefined; if (value !== otherValue) { return true; } } } return false; }; return !(a && hasUnequalMapEntry(a, b) || b && hasUnequalMapEntry(b, a) || a && hasUnequalRecordEntry(a, b) || b && hasUnequalRecordEntry(b, a)); } /** * 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 $canSimpleTextNodesBeMerged(node1, node2) { const node1Mode = node1.__mode; const node1Format = node1.__format; const node1Style = node1.__style; const node2Mode = node2.__mode; const node2Format = node2.__format; const node2Style = node2.__style; const node1State = node1.__state; const node2State = node2.__state; return (node1Mode === null || node1Mode === node2Mode) && (node1Format === null || node1Format === node2Format) && (node1Style === null || node1Style === node2Style) && (node1.__state === null || node1State === node2State || $nodeStatesAreEquivalent(node1State, node2State)); } function $mergeTextNodes(node1, node2) { const writableNode1 = node1.mergeWithSibling(node2); const normalizedNodes = getActiveEditor()._normalizedNodes; normalizedNodes.add(node1.__key); normalizedNodes.add(node2.__key); return writableNode1; } function $normalizeTextNode(textNode) { let node = textNode; if (node.__text === '' && node.isSimpleText() && !node.isUnmergeable()) { node.remove(); return; } // Backward let previousNode; while ((previousNode = node.getPreviousSibling()) !== null && $isTextNode(previousNode) && previousNode.isSimpleText() && !previousNode.isUnmergeable()) { if (previousNode.__text === '') { previousNode.remove(); } else if ($canSimpleTextNodesBeMerged(previousNode, node)) { node = $mergeTextNodes(previousNode, node); break; } else { break; } } // Forward let nextNode; while ((nextNode = node.getNextSibling()) !== null && $isTextNode(nextNode) && nextNode.isSimpleText() && !nextNode.isUnmergeable()) { if (nextNode.__text === '') { nextNode.remove(); } else if ($canSimpleTextNodesBeMerged(node, nextNode)) { node = $mergeTextNodes(node, nextNode); break; } else { break; } } } function $normalizeSelection(selection) { $normalizePoint(selection.anchor); $normalizePoint(selection.focus); return selection; } function $normalizePoint(point) { while (point.type === 'element') { const node = point.getNode(); const offset = point.offset; let nextNode; let nextOffsetAtEnd; if (offset === node.getChildrenSize()) { nextNode = node.getChildAtIndex(offset - 1); nextOffsetAtEnd = true; } else { nextNode = node.getChildAtIndex(offset); nextOffsetAtEnd = false; } if ($isTextNode(nextNode)) { point.set(nextNode.__key, nextOffsetAtEnd ? nextNode.getTextContentSize() : 0, 'text', true); break; } else if (!$isElementNode(nextNode)) { break; } point.set(nextNode.__key, nextOffsetAtEnd ? nextNode.getChildrenSize() : 0, 'element', true); } } let subTreeTextContent = ''; let subTreeDirectionedTextContent = ''; let subTreeTextFormat = null; let subTreeTextStyle = ''; let editorTextContent = ''; let activeEditorConfig; let activeEditor$1; let activeEditorNodes; let treatAllNodesAsDirty = false; let activeEditorStateReadOnly = false; let activeMutationListeners; let activeTextDirection = null; let activeDirtyElements; let activeDirtyLeaves; let activePrevNodeMap; let activeNextNodeMap; let activePrevKeyToDOMMap; let mutatedNodes; function destroyNode(key, parentDOM) { const node = activePrevNodeMap.get(key); if (parentDOM !== null) { const dom = getPrevElementByKeyOrThrow(key); if (dom.parentNode === parentDOM) { parentDOM.removeChild(dom); } } // This logic is really important, otherwise we will leak DOM nodes // when their corresponding LexicalNodes are removed from the editor state. if (!activeNextNodeMap.has(key)) { activeEditor$1._keyToDOMMap.delete(key); } if ($isElementNode(node)) { const children = createChildrenArray(node, activePrevNodeMap); destroyChildren(children, 0, children.length - 1, null); } if (node !== undefined) { setMutatedNode(mutatedNodes, activeEditorNodes, activeMutationListeners, node, 'destroyed'); } } function destroyChildren(children, _startIndex, endIndex, dom) { let startIndex = _startIndex; for (; startIndex <= endIndex; ++startIndex) { const child = children[startIndex]; if (child !== undefined) { destroyNode(child, dom); } } } function setTextAlign(domStyle, value) { domStyle.setProperty('text-align', value); } const DEFAULT_INDENT_VALUE = '40px'; function setElementIndent(dom, indent) { const indentClassName = activeEditorConfig.theme.indent; if (typeof indentClassName === 'string') { const elementHasClassName = dom.classList.contains(indentClassName); if (indent > 0 && !elementHasClassName) { dom.classList.add(indentClassName); } else if (indent < 1 && elementHasClassName) { dom.classList.remove(indentClassName); } } const indentationBaseValue = getComputedStyle(dom).getPropertyValue('--lexical-indent-base-value') || DEFAULT_INDENT_VALUE; dom.style.setProperty('padding-inline-start', indent === 0 ? '' : `calc(${indent} * ${indentationBaseValue})`); } function setElementFormat(dom, format) { const domStyle = dom.style; if (format === 0) { setTextAlign(domStyle, ''); } else if (format === IS_ALIGN_LEFT) { setTextAlign(domStyle, 'left'); } else if (format === IS_ALIGN_CENTER) { setTextAlign(domStyle, 'center'); } else if (format === IS_ALIGN_RIGHT) { setTextAlign(domStyle, 'right'); } else if (format === IS_ALIGN_JUSTIFY) { setTextAlign(domStyle, 'justify'); } else if (format === IS_ALIGN_START) { setTextAlign(domStyle, 'start'); } else if (format === IS_ALIGN_END) { setTextAlign(domStyle, 'end'); } } function $createNode(key, slot) { const node = activeNextNodeMap.get(key); if (node === undefined) { { formatDevErrorMessage(`createNode: node does not exist in nodeMap`); } } const dom = node.createDOM(activeEditorConfig, activeEditor$1); storeDOMWithKey(key, dom, activeEditor$1); // This helps preserve the text, and stops spell check tools from // merging or break the spans (which happens if they are missing // this attribute). if ($isTextNode(node)) { dom.setAttribute('data-lexical-text', 'true'); } else if ($isDecoratorNode(node)) { dom.setAttribute('data-lexical-decorator', 'true'); } if ($isElementNode(node)) { const indent = node.__indent; const childrenSize = node.__size; if (indent !== 0) { setElementIndent(dom, indent); } if (childrenSize !== 0) { const endIndex = childrenSize - 1; const children = createChildrenArray(node, activeNextNodeMap); $createChildrenWithDirection(children, endIndex, node, dom); } const format = node.__format; if (format !== 0) { setElementFormat(dom, format); } if (!node.isInline()) { reconcileElementTerminatingLineBreak(null, node, dom); } if ($textContentRequiresDoubleLinebreakAtEnd(node)) { subTreeTextContent += DOUBLE_LINE_BREAK; editorTextContent += DOUBLE_LINE_BREAK; } } else { const text = node.getTextContent(); if ($isDecoratorNode(node)) { const decorator = node.decorate(activeEditor$1, activeEditorConfig); if (decorator !== null) { reconcileDecorator(key, decorator); } // Decorators are always non editable dom.contentEditable = 'false'; } else if ($isTextNode(node)) { if (!node.isDirectionless()) { subTreeDirectionedTextContent += text; } } subTreeTextContent += text; editorTextContent += text; } if (slot !== null) { slot.insertChild(dom); } { // Freeze the node in DEV to prevent accidental mutations Object.freeze(node); } setMutatedNode(mutatedNodes, activeEditorNodes, activeMutationListeners, node, 'created'); return dom; } function $createChildrenWithDirection(children, endIndex, element, dom) { const previousSubTreeDirectionedTextContent = subTreeDirectionedTextContent; subTreeDirectionedTextContent = ''; $createChildren(children, element, 0, endIndex, element.getDOMSlot(dom)); reconcileBlockDirection(element, dom); subTreeDirectionedTextContent = previousSubTreeDirectionedTextContent; } function $createChildren(children, element, _startIndex, endIndex, slot) { const previousSubTreeTextContent = subTreeTextContent; subTreeTextContent = ''; let startIndex = _startIndex; for (; startIndex <= endIndex; ++startIndex) { $createNode(children[startIndex], slot); const node = activeNextNodeMap.get(children[startIndex]); if (node !== null && $isTextNode(node)) { if (subTreeTextFormat === null) { subTreeTextFormat = node.getFormat(); } if (subTreeTextStyle === '') { subTreeTextStyle = node.getStyle(); } } } if ($textContentRequiresDoubleLinebreakAtEnd(element)) { subTreeTextContent += DOUBLE_LINE_BREAK; } const dom = slot.element; dom.__lexicalTextContent = subTreeTextContent; subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; } function isLastChildLineBreakOrDecorator(element, nodeMap) { if (element) { const lastKey = element.__last; if (lastKey) { const node = nodeMap.get(lastKey); if (node) { return $isLineBreakNode(node) ? 'line-break' : $isDecoratorNode(node) && node.isInline() ? 'decorator' : null; } } return 'empty'; } return null; } // If we end an element with a LineBreakNode, then we need to add an additional <br> function reconcileElementTerminatingLineBreak(prevElement, nextElement, dom) { const prevLineBreak = isLastChildLineBreakOrDecorator(prevElement, activePrevNodeMap); const nextLineBreak = isLastChildLineBreakOrDecorator(nextElement, activeNextNodeMap); if (prevLineBreak !== nextLineBreak) { nextElement.getDOMSlot(dom).setManagedLineBreak(nextLineBreak); } } function reconcileTextFormat(element) { if (subTreeTextFormat != null && subTreeTextFormat !== element.__textFormat && !activeEditorStateReadOnly) { element.setTextFormat(subTreeTextFormat); } } function reconcileTextStyle(element) { if (subTreeTextStyle !== '' && subTreeTextStyle !== element.__textStyle && !activeEditorStateReadOnly) { element.setTextStyle(subTreeTextStyle); } } function reconcileBlockDirection(element, dom) { const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent || ''; const previousDirection = dom.__lexicalDir || ''; if (previousSubTreeDirectionTextContent !== subTreeDirectionedTextContent || previousDirection !== activeTextDirection) { const hasEmptyDirectionedTextContent = subTreeDirectionedTextContent === ''; const direction = hasEmptyDirectionedTextContent ? activeTextDirection : getTextDirection(subTreeDirectionedTextContent); if (direction !== previousDirection) { const classList = dom.classList; const theme = activeEditorConfig.theme; let previousDirectionTheme = previousDirection !== null ? theme[previousDirection] : undefined; let nextDirectionTheme = direction !== null ? theme[direction] : undefined; // Remove the old theme classes if they exist if (previousDirectionTheme !== undefined) { if (typeof previousDirectionTheme === 'string') { const classNamesArr = normalizeClassNames(previousDirectionTheme); previousDirectionTheme = theme[previousDirection] = classNamesArr; } // @ts-ignore: intentional classList.remove(...previousDirectionTheme); } if (direction === null || hasEmptyDirectionedTextContent && direction === 'ltr') { // Remove direction dom.removeAttribute('dir'); } else { // Apply the new theme classes if they exist if (nextDirectionTheme !== undefined) { if (typeof nextDirectionTheme === 'string') { const classNamesArr = normalizeClassNames(nextDirectionTheme); // @ts-expect-error: intentional nextDirectionTheme = theme[direction] = classNamesArr; } if (nextDirectionTheme !== undefined) { classList.add(...nextDirectionTheme); } } // Update direction dom.dir = direction; } if (!activeEditorStateReadOnly) { const writableNode = element.getWritable(); writableNode.__dir = direction; } } activeTextDirection = direction; dom.__lexicalDirTextContent = subTreeDirectionedTextContent; dom.__lexicalDir = direction; } } function $reconcileChildrenWithDirection(prevElement, nextElement, dom) { const previousSubTreeDirectionTextContent = subTreeDirectionedTextContent; subTreeDirectionedTextContent = ''; subTreeTextFormat = null; subTreeTextStyle = ''; $reconcileChildren(prevElement, nextElement, nextElement.getDOMSlot(dom)); reconcileBlockDirection(nextElement, dom); reconcileTextFormat(nextElement); reconcileTextStyle(nextElement); subTreeDirectionedTextContent = previousSubTreeDirectionTextContent; } function createChildrenArray(element, nodeMap) { const children = []; let nodeKey = element.__first; while (nodeKey !== null) { const node = nodeMap.get(nodeKey); if (node === undefined) { { formatDevErrorMessage(`createChildrenArray: node does not exist in nodeMap`); } } children.push(nodeKey); nodeKey = node.__next; } return children; } function $reconcileChildren(prevElement, nextElement, slot) { const previousSubTreeTextContent = subTreeTextContent; const prevChildrenSize = prevElement.__size; const nextChildrenSize = nextElement.__size; subTreeTextContent = ''; const dom = slot.element; if (prevChildrenSize === 1 && nextChildrenSi