UNPKG

lexical

Version:

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

327 lines (290 loc) • 10.3 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. * */ import type {LexicalNode, TextNode} from '.'; import type {LexicalEditor} from './LexicalEditor'; import type {EditorState} from './LexicalEditorState'; import type {LexicalPrivateDOM} from './LexicalNode'; import type {BaseSelection} from './LexicalSelection'; import { $getSelection, $isDecoratorNode, $isRangeSelection, $isTextNode, $setSelection, } from '.'; import {IS_FIREFOX} from './environment'; import {updateEditorSync} from './LexicalUpdates'; import { $getNodeByKey, $getNodeFromDOMNode, $updateTextNodeFromDOMContent, getDOMSelection, getNodeKeyFromDOMNode, getParentElement, getWindow, internalGetRoot, isDOMTextNode, isDOMUnmanaged, isFirefoxClipboardEvents, isHTMLElement, } from './LexicalUtils'; // The time between a text entry event and the mutation observer firing. const TEXT_MUTATION_VARIANCE = 100; let isProcessingMutations = false; let lastTextEntryTimeStamp = 0; export function getIsProcessingMutations(): boolean { return isProcessingMutations; } function updateTimeStamp(event: Event) { lastTextEntryTimeStamp = event.timeStamp; } function initTextEntryListener(editor: LexicalEditor): void { if (lastTextEntryTimeStamp === 0) { getWindow(editor).addEventListener('textInput', updateTimeStamp, true); } } function isManagedLineBreak( dom: Node, target: Node & LexicalPrivateDOM, editor: LexicalEditor, ): boolean { 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: LexicalEditor): null | BaseSelection { return editor.getEditorState().read(() => { const selection = $getSelection(); return selection !== null ? selection.clone() : null; }); } function $handleTextMutation( target: Text, node: TextNode, editor: LexicalEditor, ): void { 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: null | BaseSelection, targetDOM: Node, targetNode: TextNode, ): boolean { 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: Node, editor: LexicalEditor, editorState: EditorState, rootElement: HTMLElement | null, ): [HTMLElement, LexicalNode] | undefined { for ( let dom: Node | null = 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: LexicalEditor, mutations: Array<MutationRecord>, observer: MutationObserver, ): void { isProcessingMutations = true; const shouldFlushTextMutations = performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE; try { updateEditorSync(editor, () => { const selection = $getSelection() || getLastSelection(editor); const badDOMTargets = new Map<HTMLElement, LexicalNode>(); const rootElement = editor.getRootElement(); // We use the current editor state, as that reflects what is // actually "on screen". const currentEditorState = editor._editorState; const blockCursorElement = editor._blockCursorElement; let shouldRevertSelection = false; let possibleTextForFirefoxPaste = ''; for (let i = 0; i < mutations.length; i++) { const mutation = mutations[i]; const type = mutation.type; const targetDOM = mutation.target; const pair = $getNearestManagedNodePairFromDOMNode( targetDOM, editor, currentEditorState, rootElement, ); if (!pair) { continue; } const [nodeDOM, targetNode] = pair; if (type === 'characterData') { // Text mutations are deferred and passed to mutation listeners to be // processed outside of the Lexical engine. if ( // TODO there is an edge case here if a mutation happens too quickly // after text input, it may never be handled since we do not // track the ignored mutations in any way shouldFlushTextMutations && $isTextNode(targetNode) && isDOMTextNode(targetDOM) && shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode) ) { $handleTextMutation(targetDOM, targetNode, editor); } } else if (type === 'childList') { shouldRevertSelection = true; // We attempt to "undo" any changes that have occurred outside // of Lexical. We want Lexical's editor state to be source of truth. // To the user, these will look like no-ops. const addedDOMs = mutation.addedNodes; for (let s = 0; s < addedDOMs.length; s++) { const addedDOM = addedDOMs[s]; const node = $getNodeFromDOMNode(addedDOM); const parentDOM = addedDOM.parentNode; if ( parentDOM != null && addedDOM !== blockCursorElement && node === null && !isManagedLineBreak(addedDOM, parentDOM, editor) && // Skip externally-added DOM that's explicitly opted out of // mutation tracking (e.g. an extension-rendered decoration // inside a TextNode's span, like the autocomplete ghost). !isDOMUnmanaged(addedDOM) ) { if (IS_FIREFOX) { const possibleText = (isHTMLElement(addedDOM) ? addedDOM.innerText : null) || addedDOM.nodeValue; if (possibleText) { possibleTextForFirefoxPaste += possibleText; } } parentDOM.removeChild(addedDOM); } } const removedDOMs = mutation.removedNodes; const removedDOMsLength = removedDOMs.length; if (removedDOMsLength > 0) { let unremovedBRs = 0; for (let s = 0; s < removedDOMsLength; s++) { const removedDOM = removedDOMs[s]; if ( isManagedLineBreak(removedDOM, targetDOM, editor) || blockCursorElement === removedDOM ) { targetDOM.appendChild(removedDOM); unremovedBRs++; } } if (removedDOMsLength !== unremovedBRs) { badDOMTargets.set(nodeDOM, targetNode); } } } } // Now we process each of the unique target nodes, attempting // to restore their contents back to the source of truth, which // is Lexical's "current" editor state. This is basically like // an internal revert on the DOM. if (badDOMTargets.size > 0) { for (const [nodeDOM, targetNode] of badDOMTargets) { targetNode.reconcileObservedMutation(nodeDOM, editor); } } // Capture all the mutations made during this function. This // also prevents us having to process them on the next cycle // of onMutation, as these mutations were made by us. const records = observer.takeRecords(); // Check for any random auto-added <br> elements, and remove them. // These get added by the browser when we undo the above mutations // and this can lead to a broken UI. if (records.length > 0) { for (let i = 0; i < records.length; i++) { const record = records[i]; const addedNodes = record.addedNodes; const target = record.target; for (let s = 0; s < addedNodes.length; s++) { const addedDOM = addedNodes[s]; const parentDOM = addedDOM.parentNode; if ( parentDOM != null && addedDOM.nodeName === 'BR' && !isManagedLineBreak(addedDOM, target, editor) ) { parentDOM.removeChild(addedDOM); } } } // Clear any of those removal mutations observer.takeRecords(); } if (selection !== null) { if (shouldRevertSelection) { $setSelection(selection); } if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) { selection.insertRawText(possibleTextForFirefoxPaste); } } }); } finally { isProcessingMutations = false; } } export function flushRootMutations(editor: LexicalEditor): void { const observer = editor._observer; if (observer !== null) { const mutations = observer.takeRecords(); flushMutations(editor, mutations, observer); } } export function initMutationObserver(editor: LexicalEditor): void { initTextEntryListener(editor); editor._observer = new MutationObserver( (mutations: Array<MutationRecord>, observer: MutationObserver) => { flushMutations(editor, mutations, observer); }, ); }