313 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
		
		
			
		
	
	
			313 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
|  | /** | ||
|  |  * 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 {TextNode} from '.'; | ||
|  | import type {LexicalEditor} from './LexicalEditor'; | ||
|  | import type {BaseSelection} from './LexicalSelection'; | ||
|  | 
 | ||
|  | import {IS_FIREFOX} from 'lexical/shared/environment'; | ||
|  | 
 | ||
|  | import { | ||
|  |   $getSelection, | ||
|  |   $isDecoratorNode, | ||
|  |   $isElementNode, | ||
|  |   $isTextNode, | ||
|  |   $setSelection, | ||
|  | } from '.'; | ||
|  | import {DOM_TEXT_TYPE} from './LexicalConstants'; | ||
|  | import {updateEditor} from './LexicalUpdates'; | ||
|  | import { | ||
|  |   $getNearestNodeFromDOMNode, | ||
|  |   $getNodeFromDOMNode, | ||
|  |   $updateTextNodeFromDOMContent, | ||
|  |   getDOMSelection, | ||
|  |   getWindow, | ||
|  |   internalGetRoot, | ||
|  |   isFirefoxClipboardEvents, | ||
|  | } 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, | ||
|  |   editor: LexicalEditor, | ||
|  | ): boolean { | ||
|  |   return ( | ||
|  |     // @ts-expect-error: internal field
 | ||
|  |     target.__lexicalLineBreak === dom || | ||
|  |     // @ts-ignore We intentionally add this to the Node.
 | ||
|  |     dom[`__lexicalKey_${editor._key}`] !== 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(editor._window); | ||
|  |   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 { | ||
|  |   return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached(); | ||
|  | } | ||
|  | 
 | ||
|  | export function $flushMutations( | ||
|  |   editor: LexicalEditor, | ||
|  |   mutations: Array<MutationRecord>, | ||
|  |   observer: MutationObserver, | ||
|  | ): void { | ||
|  |   isProcessingMutations = true; | ||
|  |   const shouldFlushTextMutations = | ||
|  |     performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE; | ||
|  | 
 | ||
|  |   try { | ||
|  |     updateEditor(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; | ||
|  |         let targetNode = $getNearestNodeFromDOMNode( | ||
|  |           targetDOM, | ||
|  |           currentEditorState, | ||
|  |         ); | ||
|  | 
 | ||
|  |         if ( | ||
|  |           (targetNode === null && targetDOM !== rootElement) || | ||
|  |           $isDecoratorNode(targetNode) | ||
|  |         ) { | ||
|  |           continue; | ||
|  |         } | ||
|  | 
 | ||
|  |         if (type === 'characterData') { | ||
|  |           // Text mutations are deferred and passed to mutation listeners to be
 | ||
|  |           // processed outside of the Lexical engine.
 | ||
|  |           if ( | ||
|  |             shouldFlushTextMutations && | ||
|  |             $isTextNode(targetNode) && | ||
|  |             shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode) | ||
|  |           ) { | ||
|  |             $handleTextMutation( | ||
|  |               // nodeType === DOM_TEXT_TYPE is a Text DOM node
 | ||
|  |               targetDOM as Text, | ||
|  |               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 && | ||
|  |               (addedDOM.nodeName !== 'BR' || | ||
|  |                 !isManagedLineBreak(addedDOM, parentDOM, editor)) | ||
|  |             ) { | ||
|  |               if (IS_FIREFOX) { | ||
|  |                 const possibleText = | ||
|  |                   (addedDOM as HTMLElement).innerText || 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 ( | ||
|  |                 (removedDOM.nodeName === 'BR' && | ||
|  |                   isManagedLineBreak(removedDOM, targetDOM, editor)) || | ||
|  |                 blockCursorElement === removedDOM | ||
|  |               ) { | ||
|  |                 targetDOM.appendChild(removedDOM); | ||
|  |                 unremovedBRs++; | ||
|  |               } | ||
|  |             } | ||
|  | 
 | ||
|  |             if (removedDOMsLength !== unremovedBRs) { | ||
|  |               if (targetDOM === rootElement) { | ||
|  |                 targetNode = internalGetRoot(currentEditorState); | ||
|  |               } | ||
|  | 
 | ||
|  |               badDOMTargets.set(targetDOM, 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 [targetDOM, targetNode] of badDOMTargets) { | ||
|  |           if ($isElementNode(targetNode)) { | ||
|  |             const childKeys = targetNode.getChildrenKeys(); | ||
|  |             let currentDOM = targetDOM.firstChild; | ||
|  | 
 | ||
|  |             for (let s = 0; s < childKeys.length; s++) { | ||
|  |               const key = childKeys[s]; | ||
|  |               const correctDOM = editor.getElementByKey(key); | ||
|  | 
 | ||
|  |               if (correctDOM === null) { | ||
|  |                 continue; | ||
|  |               } | ||
|  | 
 | ||
|  |               if (currentDOM == null) { | ||
|  |                 targetDOM.appendChild(correctDOM); | ||
|  |                 currentDOM = correctDOM; | ||
|  |               } else if (currentDOM !== correctDOM) { | ||
|  |                 targetDOM.replaceChild(correctDOM, currentDOM); | ||
|  |               } | ||
|  | 
 | ||
|  |               currentDOM = currentDOM.nextSibling; | ||
|  |             } | ||
|  |           } else if ($isTextNode(targetNode)) { | ||
|  |             targetNode.markDirty(); | ||
|  |           } | ||
|  |         } | ||
|  |       } | ||
|  | 
 | ||
|  |       // 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) { | ||
|  |           selection.dirty = true; | ||
|  |           $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); | ||
|  |     }, | ||
|  |   ); | ||
|  | } |