UNPKG

lexical

Version:

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

1,291 lines (1,243 loc) • 433 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ /** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ 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' }; /** * 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); }); } /** * 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; return (node1Mode === null || node1Mode === node2Mode) && (node1Format === null || node1Format === node2Format) && (node1Style === null || node1Style === node2Style); } 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); } } /** * 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. * */ 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) { { throw Error(`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); element.setTextStyle(subTreeTextStyle); } } 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) { { throw Error(`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 && nextChildrenSize === 1) { const prevFirstChildKey = prevElement.__first; const nextFirstChildKey = nextElement.__first; if (prevFirstChildKey === nextFirstChildKey) { $reconcileNode(prevFirstChildKey, dom); } else { const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey); const replacementDOM = $createNode(nextFirstChildKey, null); try { dom.replaceChild(replacementDOM, lastDOM); } catch (error) { if (typeof error === 'object' && error != null) { const msg = `${error.toString()} Parent: ${dom.tagName}, new child: {tag: ${replacementDOM.tagName} key: ${nextFirstChildKey}}, old child: {tag: ${lastDOM.tagName}, key: ${prevFirstChildKey}}.`; throw new Error(msg); } else { throw error; } } destroyNode(prevFirstChildKey, null); } const nextChildNode = activeNextNodeMap.get(nextFirstChildKey); if ($isTextNode(nextChildNode)) { if (subTreeTextFormat === null) { subTreeTextFormat = nextChildNode.getFormat(); } if (subTreeTextStyle === '') { subTreeTextStyle = nextChildNode.getStyle(); } } } else { const prevChildren = createChildrenArray(prevElement, activePrevNodeMap); const nextChildren = createChildrenArray(nextElement, activeNextNodeMap); if (!(prevChildren.length === prevChildrenSize)) { throw Error(`$reconcileChildren: prevChildren.length !== prevChildrenSize`); } if (!(nextChildren.length === nextChildrenSize)) { throw Error(`$reconcileChildren: nextChildren.length !== nextChildrenSize`); } if (prevChildrenSize === 0) { if (nextChildrenSize !== 0) { $createChildren(nextChildren, nextElement, 0, nextChildrenSize - 1, slot); } } else if (nextChildrenSize === 0) { if (prevChildrenSize !== 0) { const canUseFastPath = slot.after == null && slot.before == null && slot.element.__lexicalLineBreak == null; destroyChildren(prevChildren, 0, prevChildrenSize - 1, canUseFastPath ? null : dom); if (canUseFastPath) { // Fast path for removing DOM nodes dom.textContent = ''; } } } else { $reconcileNodeChildren(nextElement, prevChildren, nextChildren, prevChildrenSize, nextChildrenSize, slot); } } if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) { subTreeTextContent += DOUBLE_LINE_BREAK; } dom.__lexicalTextContent = subTreeTextContent; subTreeTextContent = previousSubTreeTextContent + subTreeTextContent; } function $reconcileNode(key, parentDOM) { const prevNode = activePrevNodeMap.get(key); let nextNode = activeNextNodeMap.get(key); if (prevNode === undefined || nextNode === undefined) { { throw Error(`reconcileNode: prevNode or nextNode does not exist in nodeMap`); } } const isDirty = treatAllNodesAsDirty || activeDirtyLeaves.has(key) || activeDirtyElements.has(key); const dom = getElementByKeyOrThrow(activeEditor$1, key); // If the node key points to the same instance in both states // and isn't dirty, we just update the text content cache // and return the existing DOM Node. if (prevNode === nextNode && !isDirty) { if ($isElementNode(prevNode)) { const previousSubTreeTextContent = dom.__lexicalTextContent; if (previousSubTreeTextContent !== undefined) { subTreeTextContent += previousSubTreeTextContent; editorTextContent += previousSubTreeTextContent; } const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent; if (previousSubTreeDirectionTextContent !== undefined) { subTreeDirectionedTextContent += previousSubTreeDirectionTextContent; } } else { const text = prevNode.getTextContent(); if ($isTextNode(prevNode) && !prevNode.isDirectionless()) { subTreeDirectionedTextContent += text; } editorTextContent += text; subTreeTextContent += text; } return dom; } // If the node key doesn't point to the same instance in both maps, // it means it were cloned. If they're also dirty, we mark them as mutated. if (prevNode !== nextNode && isDirty) { setMutatedNode(mutatedNodes, activeEditorNodes, activeMutationListeners, nextNode, 'updated'); } // Update node. If it returns true, we need to unmount and re-create the node if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) { const replacementDOM = $createNode(key, null); if (parentDOM === null) { { throw Error(`reconcileNode: parentDOM is null`); } } parentDOM.replaceChild(replacementDOM, dom); destroyNode(key, null); return replacementDOM; } if ($isElementNode(prevNode) && $isElementNode(nextNode)) { // Reconcile element children const nextIndent = nextNode.__indent; if (nextIndent !== prevNode.__indent) { setElementIndent(dom, nextIndent); } const nextFormat = nextNode.__format; if (nextFormat !== prevNode.__format) { setElementFormat(dom, nextFormat); } if (isDirty) { $reconcileChildrenWithDirection(prevNode, nextNode, dom); if (!$isRootNode(nextNode) && !nextNode.isInline()) { reconcileElementTerminatingLineBreak(prevNode, nextNode, dom); } } if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) { subTreeTextContent += DOUBLE_LINE_BREAK; editorTextContent += DOUBLE_LINE_BREAK; } } else { const text = nextNode.getTextContent(); if ($isDecoratorNode(nextNode)) { const decorator = nextNode.decorate(activeEditor$1, activeEditorConfig); if (decorator !== null) { reconcileDecorator(key, decorator); } } else if ($isTextNode(nextNode) && !nextNode.isDirectionless()) { // Handle text content, for LTR, LTR cases. subTreeDirectionedTextContent += text; } subTreeTextContent += text; editorTextContent += text; } if (!activeEditorStateReadOnly && $isRootNode(nextNode) && nextNode.__cachedText !== editorTextContent) { // Cache the latest text content. const nextRootNode = nextNode.getWritable(); nextRootNode.__cachedText = editorTextContent; nextNode = nextRootNode; } { // Freeze the node in DEV to prevent accidental mutations Object.freeze(nextNode); } return dom; } function reconcileDecorator(key, decorator) { let pendingDecorators = activeEditor$1._pendingDecorators; const currentDecorators = activeEditor$1._decorators; if (pendingDecorators === null) { if (currentDecorators[key] === decorator) { return; } pendingDecorators = cloneDecorators(activeEditor$1); } pendingDecorators[key] = decorator; } function getNextSibling(element) { let nextSibling = element.nextSibling; if (nextSibling !== null && nextSibling === activeEditor$1._blockCursorElement) { nextSibling = nextSibling.nextSibling; } return nextSibling; } function $reconcileNodeChildren(nextElement, prevChildren, nextChildren, prevChildrenLength, nextChildrenLength, slot) { const prevEndIndex = prevChildrenLength - 1; const nextEndIndex = nextChildrenLength - 1; let prevChildrenSet; let nextChildrenSet; let siblingDOM = slot.getFirstChild(); let prevIndex = 0; let nextIndex = 0; while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) { const prevKey = prevChildren[prevIndex]; const nextKey = nextChildren[nextIndex]; if (prevKey === nextKey) { siblingDOM = getNextSibling($reconcileNode(nextKey, slot.element)); prevIndex++; nextIndex++; } else { if (prevChildrenSet === undefined) { prevChildrenSet = new Set(prevChildren); } if (nextChildrenSet === undefined) { nextChildrenSet = new Set(nextChildren); } const nextHasPrevKey = nextChildrenSet.has(prevKey); const prevHasNextKey = prevChildrenSet.has(nextKey); if (!nextHasPrevKey) { // Remove prev siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey)); destroyNode(prevKey, slot.element); prevIndex++; } else if (!prevHasNextKey) { // Create next $createNode(nextKey, slot.withBefore(siblingDOM)); nextIndex++; } else { // Move next const childDOM = getElementByKeyOrThrow(activeEditor$1, nextKey); if (childDOM === siblingDOM) { siblingDOM = getNextSibling($reconcileNode(nextKey, slot.element)); } else { slot.withBefore(siblingDOM).insertChild(childDOM); $reconcileNode(nextKey, slot.element); } prevIndex++; nextIndex++; } } const node = activeNextNodeMap.get(nextKey); if (node !== null && $isTextNode(node)) { if (subTreeTextFormat === null) { subTreeTextFormat = node.getFormat(); } if (subTreeTextStyle === '') { subTreeTextStyle = node.getStyle(); } } } const appendNewChildren = prevIndex > prevEndIndex; const removeOldChildren = nextIndex > nextEndIndex; if (appendNewChildren && !removeOldChildren) { const previousNode = nextChildren[nextEndIndex + 1]; const insertDOM = previousNode === undefined ? null : activeEditor$1.getElementByKey(previousNode); $createChildren(nextChildren, nextElement, nextIndex, nextEndIndex, slot.withBefore(insertDOM)); } else if (removeOldChildren && !appendNewChildren) { destroyChildren(prevChildren, prevIndex, prevEndIndex, slot.element); } } function $reconcileRoot(prevEditorState, nextEditorState, editor, dirtyType, dirtyElements, dirtyLeaves) { // We cache text content to make retrieval more efficient. // The cache must be rebuilt during reconciliation to account for any changes. subTreeTextContent = ''; editorTextContent = ''; subTreeDirectionedTextContent = ''; // Rather than pass around a load of arguments through the stack recursively // we instead set them as bindings within the scope of the module. treatAllNodesAsDirty = dirtyType === FULL_RECONCILE; activeTextDirection = null; activeEditor$1 = editor; activeEditorConfig = editor._config; activeEditorNodes = editor._nodes; activeMutationListeners = activeEditor$1._listeners.mutation; activeDirtyElements = dirtyElements; activeDirtyLeaves = dirtyLeaves; activePrevNodeMap = prevEditorState._nodeMap; activeNextNodeMap = nextEditorState._nodeMap; activeEditorStateReadOnly = nextEditorState._readOnly; activePrevKeyToDOMMap = new Map(editor._keyToDOMMap); // We keep track of mutated nodes so we can trigger mutation // listeners later in the update cycle. const currentMutatedNodes = new Map(); mutatedNodes = currentMutatedNodes; $reconcileNode('root', null); // We don't want a bunch of void checks throughout the scope // so instead we make it seem that these values are always set. // We also want to make sure we clear them down, otherwise we // can leak memory. // @ts-ignore activeEditor$1 = undefined; // @ts-ignore activeEditorNodes = undefined; // @ts-ignore activeDirtyElements = undefined; // @ts-ignore activeDirtyLeaves = undefined; // @ts-ignore activePrevNodeMap = undefined; // @ts-ignore activeNextNodeMap = undefined; // @ts-ignore activeEditorConfig = undefined; // @ts-ignore activePrevKeyToDOMMap = undefined; // @ts-ignore mutatedNodes = undefined; return currentMutatedNodes; } function storeDOMWithKey(key, dom, editor) { const keyToDOMMap = editor._keyToDOMMap; setNodeKeyOnDOMNode(dom, editor, key); keyToDOMMap.set(key, dom); } function getPrevElementByKeyOrThrow(key) { const element = activePrevKeyToDOMMap.get(key); if (element === undefined) { { throw Error(`Reconciliation: could not find DOM element for node key ${key}`); } } return element; } /** * 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 createCommand(type) { return { type } ; } const SELECTION_CHANGE_COMMAND = createCommand('SELECTION_CHANGE_COMMAND'); const SELECTION_INSERT_CLIPBOARD_NODES_COMMAND = createCommand('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND'); const CLICK_COMMAND = createCommand('CLICK_COMMAND'); const DELETE_CHARACTER_COMMAND = createCommand('DELETE_CHARACTER_COMMAND'); const INSERT_LINE_BREAK_COMMAND = createCommand('INSERT_LINE_BREAK_COMMAND'); const INSERT_PARAGRAPH_COMMAND = createCommand('INSERT_PARAGRAPH_COMMAND'); const CONTROLLED_TEXT_INSERTION_COMMAND = createCommand('CONTROLLED_TEXT_INSERTION_COMMAND'); const PASTE_COMMAND = createCommand('PASTE_COMMAND'); const REMOVE_TEXT_COMMAND = createCommand('REMOVE_TEXT_COMMAND'); const DELETE_WORD_COMMAND = createCommand('DELETE_WORD_COMMAND'); const DELETE_LINE_COMMAND = createCommand('DELETE_LINE_COMMAND'); const FORMAT_TEXT_COMMAND = createCommand('FORMAT_TEXT_COMMAND'); const UNDO_COMMAND = createCommand('UNDO_COMMAND'); const REDO_COMMAND = createCommand('REDO_COMMAND'); const KEY_DOWN_COMMAND = createCommand('KEYDOWN_COMMAND'); const KEY_ARROW_RIGHT_COMMAND = createCommand('KEY_ARROW_RIGHT_COMMAND'); const MOVE_TO_END = createCommand('MOVE_TO_END'); const KEY_ARROW_LEFT_COMMAND = createCommand('KEY_ARROW_LEFT_COMMAND'); const MOVE_TO_START = createCommand('MOVE_TO_START'); const KEY_ARROW_UP_COMMAND = createCommand('KEY_ARROW_UP_COMMAND'); const KEY_ARROW_DOWN_COMMAND = createCommand('KEY_ARROW_DOWN_COMMAND'); const KEY_ENTER_COMMAND = createCommand('KEY_ENTER_COMMAND'); const KEY_SPACE_COMMAND = createCommand('KEY_SPACE_COMMAND'); const KEY_BACKSPACE_COMMAND = createCommand('KEY_BACKSPACE_COMMAND'); const KEY_ESCAPE_COMMAND = createCommand('KEY_ESCAPE_COMMAND'); const KEY_DELETE_COMMAND = createCommand('KEY_DELETE_COMMAND'); const KEY_TAB_COMMAND = createCommand('KEY_TAB_COMMAND'); const INSERT_TAB_COMMAND = createCommand('INSERT_TAB_COMMAND'); const INDENT_CONTENT_COMMAND = createCommand('INDENT_CONTENT_COMMAND'); const OUTDENT_CONTENT_COMMAND = createCommand('OUTDENT_CONTENT_COMMAND'); const DROP_COMMAND = createCommand('DROP_COMMAND'); const FORMAT_ELEMENT_COMMAND = createCommand('FORMAT_ELEMENT_COMMAND'); const DRAGSTART_COMMAND = createCommand('DRAGSTART_COMMAND'); const DRAGOVER_COMMAND = createCommand('DRAGOVER_COMMAND'); const DRAGEND_COMMAND = createCommand('DRAGEND_COMMAND'); const COPY_COMMAND = createCommand('COPY_COMMAND'); const CUT_COMMAND = createCommand('CUT_COMMAND'); const SELECT_ALL_COMMAND = createCommand('SELECT_ALL_COMMAND'); const CLEAR_EDITOR_COMMAND = createCommand('CLEAR_EDITOR_COMMAND'); const CLEAR_HISTORY_COMMAND = createCommand('CLEAR_HISTORY_COMMAND'); const CAN_REDO_COMMAND = createCommand('CAN_REDO_COMMAND'); const CAN_UNDO_COMMAND = createCommand('CAN_UNDO_COMMAND'); const FOCUS_COMMAND = createCommand('FOCUS_COMMAND'); const BLUR_COMMAND = createCommand('BLUR_COMMAND'); const KEY_MODIFIER_COMMAND = createCommand('KEY_MODIFIER_COMMAND'); /** * 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 PASS_THROUGH_COMMAND = Object.freeze({}); const ANDROID_COMPOSITION_LATENCY = 30; const rootElementEvents = [['keydown', onKeyDown], ['pointerdown', onPointerDown], ['compositionstart', onCompositionStart], ['compositionend', onCompositionEnd], ['input', onInput], ['click', onClick], ['cut', PASS_THROUGH_COMMAND], ['copy', PASS_THROUGH_COMMAND], ['dragstart', PASS_THROUGH_COMMAND], ['dragover', PASS_THROUGH_COMMAND], ['dragend', PASS_THROUGH_COMMAND], ['paste', PASS_THROUGH_COMMAND], ['focus', PASS_THROUGH_COMMAND], ['blur', PASS_THROUGH_COMMAND], ['drop', PASS_THROUGH_COMMAND]]; if (CAN_USE_BEFORE_INPUT) { rootElementEvents.push(['beforeinput', (event, editor) => onBeforeInput(event, editor)]); } let lastKeyDownTimeStamp = 0; let lastKeyCode = null; let lastBeforeInputInsertTextTimeStamp = 0; let unprocessedBeforeInputData = null; const rootElementsRegistered = new WeakMap(); let isSelectionChangeFromDOMUpdate = false; let isSelectionChangeFromMouseDown = false; let isInsertLineBreak = false; let isFirefoxEndingComposition = false; let isSafariEndingComposition = false; let safariEndCompositionEventData = ''; let collapsedSelectionFormat = [0, '', 0, 'root', 0]; // This function is used to determine if Lexical should attempt to override // the default browser behavior for insertion of text and use its own internal // heuristics. This is an extremely important function, and makes much of Lexical // work as intended between different browsers and across word, line and character // boundary/formats. It also is important for text replacement, node schemas and // composition mechanics. function $shouldPreventDefaultAndInsertText(selection, domTargetRange, text, timeStamp, isBeforeInput) { const anchor = selection.anchor; const focus = selection.focus; const anchorNode = anchor.getNode(); const editor = getActiveEditor(); const domSelection = getDOMSelection(getWindow(editor)); const domAnchorNode = domSelection !== null ? domSelection.anchorNode : null; const anchorKey = anchor.key; const backingAnchorElement = editor.getElementByKey(anchorKey); const textLength = text.length; return anchorKey !== focus.key || // If we're working with a non-text node. !$isTextNode(anchorNode) || // If we are replacing a range with a single character or grapheme, and not composing. (!isBeforeInput && (!CAN_USE_BEFORE_INPUT || // We check to see if there has been // a recent beforeinput event for "textInput". If there has been one in the last // 50ms then we proceed as normal. However, if there is not, then this is likely // a dangling `input` event caused by execCommand('insertText'). lastBeforeInputInsertTextTimeStamp < timeStamp + 50) || anchorNode.isDirty() && textLength < 2 || // TODO consider if there are other scenarios when multiple code units // should be addressed here doesContainSurrogatePair(text)) && anchor.offset !== focus.offset && !anchorNode.isComposing() || // Any non standard text node. $isTokenOrSegmented(anchorNode) || // If the text length is more than a single character and we're either // dealing with this in "beforeinput" or where the node has already recently // been changed (thus is dirty). anchorNode.isDirty() && textLength > 1 || // If the DOM selection element is not the same as the backing node during beforeinput. (isBeforeInput || !CAN_USE_BEFORE_INPUT) && backingAnchorElement !== null && !anchorNode.isComposing() && domAnchorNode !== getDOMTextNode(backingAnchorElement) || // If TargetRange is not the same as the DOM selection; browser trying to edit random parts // of the editor. domSelection !== null && domTargetRange !== null && (!domTargetRange.collapsed || domTargetRange.startContainer !== domSelection.anchorNode || domTargetRange.startOffset !== domSelection.anchorOffset) || // Check if we're changing from bold to italics, or some other format. anchorNode.getFormat() !== selection.format || anchorNode.getStyle() !== selection.style || // One last set of heuristics to check against. $shouldInsertTextAfterOrBeforeTextNode(selection, anchorNode); } function shouldSkipSelectionChange(domNode, offset) { return isDOMTextNode(domNode) && domNode.nodeValue !== null && offset !== 0 && offset !== domNode.nodeValue.length; } function onSelectionChange(domSelection, editor, isActive) { const { anchorNode: anchor