1383 lines
		
	
	
		
			44 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			1383 lines
		
	
	
		
			44 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 {LexicalEditor} from './LexicalEditor';
 | ||
| import type {NodeKey} from './LexicalNode';
 | ||
| import type {ElementNode} from './nodes/LexicalElementNode';
 | ||
| import type {TextNode} from './nodes/LexicalTextNode';
 | ||
| 
 | ||
| import {
 | ||
|   CAN_USE_BEFORE_INPUT,
 | ||
|   IS_ANDROID_CHROME,
 | ||
|   IS_APPLE_WEBKIT,
 | ||
|   IS_FIREFOX,
 | ||
|   IS_IOS,
 | ||
|   IS_SAFARI,
 | ||
| } from 'lexical/shared/environment';
 | ||
| import invariant from 'lexical/shared/invariant';
 | ||
| 
 | ||
| import {
 | ||
|   $getPreviousSelection,
 | ||
|   $getRoot,
 | ||
|   $getSelection,
 | ||
|   $isElementNode,
 | ||
|   $isNodeSelection,
 | ||
|   $isRangeSelection,
 | ||
|   $isRootNode,
 | ||
|   $isTextNode,
 | ||
|   $setCompositionKey,
 | ||
|   BLUR_COMMAND,
 | ||
|   CLICK_COMMAND,
 | ||
|   CONTROLLED_TEXT_INSERTION_COMMAND,
 | ||
|   COPY_COMMAND,
 | ||
|   CUT_COMMAND,
 | ||
|   DELETE_CHARACTER_COMMAND,
 | ||
|   DELETE_LINE_COMMAND,
 | ||
|   DELETE_WORD_COMMAND,
 | ||
|   DRAGEND_COMMAND,
 | ||
|   DRAGOVER_COMMAND,
 | ||
|   DRAGSTART_COMMAND,
 | ||
|   DROP_COMMAND,
 | ||
|   FOCUS_COMMAND,
 | ||
|   FORMAT_TEXT_COMMAND,
 | ||
|   INSERT_LINE_BREAK_COMMAND,
 | ||
|   INSERT_PARAGRAPH_COMMAND,
 | ||
|   KEY_ARROW_DOWN_COMMAND,
 | ||
|   KEY_ARROW_LEFT_COMMAND,
 | ||
|   KEY_ARROW_RIGHT_COMMAND,
 | ||
|   KEY_ARROW_UP_COMMAND,
 | ||
|   KEY_BACKSPACE_COMMAND,
 | ||
|   KEY_DELETE_COMMAND,
 | ||
|   KEY_DOWN_COMMAND,
 | ||
|   KEY_ENTER_COMMAND,
 | ||
|   KEY_ESCAPE_COMMAND,
 | ||
|   KEY_SPACE_COMMAND,
 | ||
|   KEY_TAB_COMMAND,
 | ||
|   MOVE_TO_END,
 | ||
|   MOVE_TO_START,
 | ||
|   ParagraphNode,
 | ||
|   PASTE_COMMAND,
 | ||
|   REDO_COMMAND,
 | ||
|   REMOVE_TEXT_COMMAND,
 | ||
|   SELECTION_CHANGE_COMMAND,
 | ||
|   UNDO_COMMAND,
 | ||
| } from '.';
 | ||
| import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
 | ||
| import {
 | ||
|   COMPOSITION_START_CHAR,
 | ||
|   DOM_ELEMENT_TYPE,
 | ||
|   DOM_TEXT_TYPE,
 | ||
|   DOUBLE_LINE_BREAK,
 | ||
|   IS_ALL_FORMATTING,
 | ||
| } from './LexicalConstants';
 | ||
| import {
 | ||
|   $internalCreateRangeSelection,
 | ||
|   RangeSelection,
 | ||
| } from './LexicalSelection';
 | ||
| import {getActiveEditor, updateEditor} from './LexicalUpdates';
 | ||
| import {
 | ||
|   $flushMutations,
 | ||
|   $getNodeByKey,
 | ||
|   $isSelectionCapturedInDecorator,
 | ||
|   $isTokenOrSegmented,
 | ||
|   $setSelection,
 | ||
|   $shouldInsertTextAfterOrBeforeTextNode,
 | ||
|   $updateSelectedTextFromDOM,
 | ||
|   $updateTextNodeFromDOMContent,
 | ||
|   dispatchCommand,
 | ||
|   doesContainGrapheme,
 | ||
|   getAnchorTextFromDOM,
 | ||
|   getDOMSelection,
 | ||
|   getDOMTextNode,
 | ||
|   getEditorPropertyFromDOMNode,
 | ||
|   getEditorsToPropagate,
 | ||
|   getNearestEditorFromDOMNode,
 | ||
|   getWindow,
 | ||
|   isBackspace,
 | ||
|   isBold,
 | ||
|   isCopy,
 | ||
|   isCut,
 | ||
|   isDelete,
 | ||
|   isDeleteBackward,
 | ||
|   isDeleteForward,
 | ||
|   isDeleteLineBackward,
 | ||
|   isDeleteLineForward,
 | ||
|   isDeleteWordBackward,
 | ||
|   isDeleteWordForward,
 | ||
|   isEscape,
 | ||
|   isFirefoxClipboardEvents,
 | ||
|   isItalic,
 | ||
|   isLexicalEditor,
 | ||
|   isLineBreak,
 | ||
|   isModifier,
 | ||
|   isMoveBackward,
 | ||
|   isMoveDown,
 | ||
|   isMoveForward,
 | ||
|   isMoveToEnd,
 | ||
|   isMoveToStart,
 | ||
|   isMoveUp,
 | ||
|   isOpenLineBreak,
 | ||
|   isParagraph,
 | ||
|   isRedo,
 | ||
|   isSelectAll,
 | ||
|   isSelectionWithinEditor,
 | ||
|   isSpace,
 | ||
|   isTab,
 | ||
|   isUnderline,
 | ||
|   isUndo,
 | ||
| } from './LexicalUtils';
 | ||
| 
 | ||
| type RootElementRemoveHandles = Array<() => void>;
 | ||
| type RootElementEvents = Array<
 | ||
|   [
 | ||
|     string,
 | ||
|     Record<string, unknown> | ((event: Event, editor: LexicalEditor) => void),
 | ||
|   ]
 | ||
| >;
 | ||
| const PASS_THROUGH_COMMAND = Object.freeze({});
 | ||
| const ANDROID_COMPOSITION_LATENCY = 30;
 | ||
| const rootElementEvents: 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 as InputEvent, editor),
 | ||
|   ]);
 | ||
| }
 | ||
| 
 | ||
| let lastKeyDownTimeStamp = 0;
 | ||
| let lastKeyCode: null | string = null;
 | ||
| let lastBeforeInputInsertTextTimeStamp = 0;
 | ||
| let unprocessedBeforeInputData: null | string = null;
 | ||
| const rootElementsRegistered = new WeakMap<Document, number>();
 | ||
| let isSelectionChangeFromDOMUpdate = false;
 | ||
| let isSelectionChangeFromMouseDown = false;
 | ||
| let isInsertLineBreak = false;
 | ||
| let isFirefoxEndingComposition = false;
 | ||
| let collapsedSelectionFormat: [number, string, number, NodeKey, number] = [
 | ||
|   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: RangeSelection,
 | ||
|   domTargetRange: null | StaticRange,
 | ||
|   text: string,
 | ||
|   timeStamp: number,
 | ||
|   isBeforeInput: boolean,
 | ||
| ): boolean {
 | ||
|   const anchor = selection.anchor;
 | ||
|   const focus = selection.focus;
 | ||
|   const anchorNode = anchor.getNode();
 | ||
|   const editor = getActiveEditor();
 | ||
|   const domSelection = getDOMSelection(editor._window);
 | ||
|   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) ||
 | ||
|       doesContainGrapheme(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: null | Node,
 | ||
|   offset: number,
 | ||
| ): boolean {
 | ||
|   return (
 | ||
|     domNode !== null &&
 | ||
|     domNode.nodeValue !== null &&
 | ||
|     domNode.nodeType === DOM_TEXT_TYPE &&
 | ||
|     offset !== 0 &&
 | ||
|     offset !== domNode.nodeValue.length
 | ||
|   );
 | ||
| }
 | ||
| 
 | ||
| function onSelectionChange(
 | ||
|   domSelection: Selection,
 | ||
|   editor: LexicalEditor,
 | ||
|   isActive: boolean,
 | ||
| ): void {
 | ||
|   const {
 | ||
|     anchorNode: anchorDOM,
 | ||
|     anchorOffset,
 | ||
|     focusNode: focusDOM,
 | ||
|     focusOffset,
 | ||
|   } = domSelection;
 | ||
|   if (isSelectionChangeFromDOMUpdate) {
 | ||
|     isSelectionChangeFromDOMUpdate = false;
 | ||
| 
 | ||
|     // If native DOM selection is on a DOM element, then
 | ||
|     // we should continue as usual, as Lexical's selection
 | ||
|     // may have normalized to a better child. If the DOM
 | ||
|     // element is a text node, we can safely apply this
 | ||
|     // optimization and skip the selection change entirely.
 | ||
|     // We also need to check if the offset is at the boundary,
 | ||
|     // because in this case, we might need to normalize to a
 | ||
|     // sibling instead.
 | ||
|     if (
 | ||
|       shouldSkipSelectionChange(anchorDOM, anchorOffset) &&
 | ||
|       shouldSkipSelectionChange(focusDOM, focusOffset)
 | ||
|     ) {
 | ||
|       return;
 | ||
|     }
 | ||
|   }
 | ||
|   updateEditor(editor, () => {
 | ||
|     // Non-active editor don't need any extra logic for selection, it only needs update
 | ||
|     // to reconcile selection (set it to null) to ensure that only one editor has non-null selection.
 | ||
|     if (!isActive) {
 | ||
|       $setSelection(null);
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     if (!isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     const selection = $getSelection();
 | ||
| 
 | ||
|     // Update the selection format
 | ||
|     if ($isRangeSelection(selection)) {
 | ||
|       const anchor = selection.anchor;
 | ||
|       const anchorNode = anchor.getNode();
 | ||
| 
 | ||
|       if (selection.isCollapsed()) {
 | ||
|         // Badly interpreted range selection when collapsed - #1482
 | ||
|         if (
 | ||
|           domSelection.type === 'Range' &&
 | ||
|           domSelection.anchorNode === domSelection.focusNode
 | ||
|         ) {
 | ||
|           selection.dirty = true;
 | ||
|         }
 | ||
| 
 | ||
|         // If we have marked a collapsed selection format, and we're
 | ||
|         // within the given time range – then attempt to use that format
 | ||
|         // instead of getting the format from the anchor node.
 | ||
|         const windowEvent = getWindow(editor).event;
 | ||
|         const currentTimeStamp = windowEvent
 | ||
|           ? windowEvent.timeStamp
 | ||
|           : performance.now();
 | ||
|         const [lastFormat, lastStyle, lastOffset, lastKey, timeStamp] =
 | ||
|           collapsedSelectionFormat;
 | ||
| 
 | ||
|         const root = $getRoot();
 | ||
|         const isRootTextContentEmpty =
 | ||
|           editor.isComposing() === false && root.getTextContent() === '';
 | ||
| 
 | ||
|         if (
 | ||
|           currentTimeStamp < timeStamp + 200 &&
 | ||
|           anchor.offset === lastOffset &&
 | ||
|           anchor.key === lastKey
 | ||
|         ) {
 | ||
|           selection.format = lastFormat;
 | ||
|           selection.style = lastStyle;
 | ||
|         } else {
 | ||
|           if (anchor.type === 'text') {
 | ||
|             invariant(
 | ||
|               $isTextNode(anchorNode),
 | ||
|               'Point.getNode() must return TextNode when type is text',
 | ||
|             );
 | ||
|             selection.format = anchorNode.getFormat();
 | ||
|             selection.style = anchorNode.getStyle();
 | ||
|           } else if (anchor.type === 'element' && !isRootTextContentEmpty) {
 | ||
|             const lastNode = anchor.getNode();
 | ||
|             selection.style = '';
 | ||
|             if (
 | ||
|               lastNode instanceof ParagraphNode &&
 | ||
|               lastNode.getChildrenSize() === 0
 | ||
|             ) {
 | ||
|               selection.style = lastNode.getTextStyle();
 | ||
|             } else {
 | ||
|               selection.format = 0;
 | ||
|             }
 | ||
|           }
 | ||
|         }
 | ||
|       } else {
 | ||
|         const anchorKey = anchor.key;
 | ||
|         const focus = selection.focus;
 | ||
|         const focusKey = focus.key;
 | ||
|         const nodes = selection.getNodes();
 | ||
|         const nodesLength = nodes.length;
 | ||
|         const isBackward = selection.isBackward();
 | ||
|         const startOffset = isBackward ? focusOffset : anchorOffset;
 | ||
|         const endOffset = isBackward ? anchorOffset : focusOffset;
 | ||
|         const startKey = isBackward ? focusKey : anchorKey;
 | ||
|         const endKey = isBackward ? anchorKey : focusKey;
 | ||
|         let combinedFormat = IS_ALL_FORMATTING;
 | ||
|         let hasTextNodes = false;
 | ||
|         for (let i = 0; i < nodesLength; i++) {
 | ||
|           const node = nodes[i];
 | ||
|           const textContentSize = node.getTextContentSize();
 | ||
|           if (
 | ||
|             $isTextNode(node) &&
 | ||
|             textContentSize !== 0 &&
 | ||
|             // Exclude empty text nodes at boundaries resulting from user's selection
 | ||
|             !(
 | ||
|               (i === 0 &&
 | ||
|                 node.__key === startKey &&
 | ||
|                 startOffset === textContentSize) ||
 | ||
|               (i === nodesLength - 1 &&
 | ||
|                 node.__key === endKey &&
 | ||
|                 endOffset === 0)
 | ||
|             )
 | ||
|           ) {
 | ||
|             // TODO: what about style?
 | ||
|             hasTextNodes = true;
 | ||
|             combinedFormat &= node.getFormat();
 | ||
|             if (combinedFormat === 0) {
 | ||
|               break;
 | ||
|             }
 | ||
|           }
 | ||
|         }
 | ||
| 
 | ||
|         selection.format = hasTextNodes ? combinedFormat : 0;
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined);
 | ||
|   });
 | ||
| }
 | ||
| 
 | ||
| // This is a work-around is mainly Chrome specific bug where if you select
 | ||
| // the contents of an empty block, you cannot easily unselect anything.
 | ||
| // This results in a tiny selection box that looks buggy/broken. This can
 | ||
| // also help other browsers when selection might "appear" lost, when it
 | ||
| // really isn't.
 | ||
| function onClick(event: PointerEvent, editor: LexicalEditor): void {
 | ||
|   updateEditor(editor, () => {
 | ||
|     const selection = $getSelection();
 | ||
|     const domSelection = getDOMSelection(editor._window);
 | ||
|     const lastSelection = $getPreviousSelection();
 | ||
| 
 | ||
|     if (domSelection) {
 | ||
|       if ($isRangeSelection(selection)) {
 | ||
|         const anchor = selection.anchor;
 | ||
|         const anchorNode = anchor.getNode();
 | ||
| 
 | ||
|         if (
 | ||
|           anchor.type === 'element' &&
 | ||
|           anchor.offset === 0 &&
 | ||
|           selection.isCollapsed() &&
 | ||
|           !$isRootNode(anchorNode) &&
 | ||
|           $getRoot().getChildrenSize() === 1 &&
 | ||
|           anchorNode.getTopLevelElementOrThrow().isEmpty() &&
 | ||
|           lastSelection !== null &&
 | ||
|           selection.is(lastSelection)
 | ||
|         ) {
 | ||
|           domSelection.removeAllRanges();
 | ||
|           selection.dirty = true;
 | ||
|         } else if (event.detail === 3 && !selection.isCollapsed()) {
 | ||
|           // Tripple click causing selection to overflow into the nearest element. In that
 | ||
|           // case visually it looks like a single element content is selected, focus node
 | ||
|           // is actually at the beginning of the next element (if present) and any manipulations
 | ||
|           // with selection (formatting) are affecting second element as well
 | ||
|           const focus = selection.focus;
 | ||
|           const focusNode = focus.getNode();
 | ||
|           if (anchorNode !== focusNode) {
 | ||
|             if ($isElementNode(anchorNode)) {
 | ||
|               anchorNode.select(0);
 | ||
|             } else {
 | ||
|               anchorNode.getParentOrThrow().select(0);
 | ||
|             }
 | ||
|           }
 | ||
|         }
 | ||
|       } else if (event.pointerType === 'touch') {
 | ||
|         // This is used to update the selection on touch devices when the user clicks on text after a
 | ||
|         // node selection. See isSelectionChangeFromMouseDown for the inverse
 | ||
|         const domAnchorNode = domSelection.anchorNode;
 | ||
|         if (domAnchorNode !== null) {
 | ||
|           const nodeType = domAnchorNode.nodeType;
 | ||
|           // If the user is attempting to click selection back onto text, then
 | ||
|           // we should attempt create a range selection.
 | ||
|           // When we click on an empty paragraph node or the end of a paragraph that ends
 | ||
|           // with an image/poll, the nodeType will be ELEMENT_NODE
 | ||
|           if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) {
 | ||
|             const newSelection = $internalCreateRangeSelection(
 | ||
|               lastSelection,
 | ||
|               domSelection,
 | ||
|               editor,
 | ||
|               event,
 | ||
|             );
 | ||
|             $setSelection(newSelection);
 | ||
|           }
 | ||
|         }
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     dispatchCommand(editor, CLICK_COMMAND, event);
 | ||
|   });
 | ||
| }
 | ||
| 
 | ||
| function onPointerDown(event: PointerEvent, editor: LexicalEditor) {
 | ||
|   // TODO implement text drag & drop
 | ||
|   const target = event.target;
 | ||
|   const pointerType = event.pointerType;
 | ||
|   if (target instanceof Node && pointerType !== 'touch') {
 | ||
|     updateEditor(editor, () => {
 | ||
|       // Drag & drop should not recompute selection until mouse up; otherwise the initially
 | ||
|       // selected content is lost.
 | ||
|       if (!$isSelectionCapturedInDecorator(target)) {
 | ||
|         isSelectionChangeFromMouseDown = true;
 | ||
|       }
 | ||
|     });
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| function getTargetRange(event: InputEvent): null | StaticRange {
 | ||
|   if (!event.getTargetRanges) {
 | ||
|     return null;
 | ||
|   }
 | ||
|   const targetRanges = event.getTargetRanges();
 | ||
|   if (targetRanges.length === 0) {
 | ||
|     return null;
 | ||
|   }
 | ||
|   return targetRanges[0];
 | ||
| }
 | ||
| 
 | ||
| function $canRemoveText(
 | ||
|   anchorNode: TextNode | ElementNode,
 | ||
|   focusNode: TextNode | ElementNode,
 | ||
| ): boolean {
 | ||
|   return (
 | ||
|     anchorNode !== focusNode ||
 | ||
|     $isElementNode(anchorNode) ||
 | ||
|     $isElementNode(focusNode) ||
 | ||
|     !anchorNode.isToken() ||
 | ||
|     !focusNode.isToken()
 | ||
|   );
 | ||
| }
 | ||
| 
 | ||
| function isPossiblyAndroidKeyPress(timeStamp: number): boolean {
 | ||
|   return (
 | ||
|     lastKeyCode === 'MediaLast' &&
 | ||
|     timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY
 | ||
|   );
 | ||
| }
 | ||
| 
 | ||
| function onBeforeInput(event: InputEvent, editor: LexicalEditor): void {
 | ||
|   const inputType = event.inputType;
 | ||
|   const targetRange = getTargetRange(event);
 | ||
| 
 | ||
|   // We let the browser do its own thing for composition.
 | ||
|   if (
 | ||
|     inputType === 'deleteCompositionText' ||
 | ||
|     // If we're pasting in FF, we shouldn't get this event
 | ||
|     // as the `paste` event should have triggered, unless the
 | ||
|     // user has dom.event.clipboardevents.enabled disabled in
 | ||
|     // about:config. In that case, we need to process the
 | ||
|     // pasted content in the DOM mutation phase.
 | ||
|     (IS_FIREFOX && isFirefoxClipboardEvents(editor))
 | ||
|   ) {
 | ||
|     return;
 | ||
|   } else if (inputType === 'insertCompositionText') {
 | ||
|     return;
 | ||
|   }
 | ||
| 
 | ||
|   updateEditor(editor, () => {
 | ||
|     const selection = $getSelection();
 | ||
| 
 | ||
|     if (inputType === 'deleteContentBackward') {
 | ||
|       if (selection === null) {
 | ||
|         // Use previous selection
 | ||
|         const prevSelection = $getPreviousSelection();
 | ||
| 
 | ||
|         if (!$isRangeSelection(prevSelection)) {
 | ||
|           return;
 | ||
|         }
 | ||
| 
 | ||
|         $setSelection(prevSelection.clone());
 | ||
|       }
 | ||
| 
 | ||
|       if ($isRangeSelection(selection)) {
 | ||
|         const isSelectionAnchorSameAsFocus =
 | ||
|           selection.anchor.key === selection.focus.key;
 | ||
| 
 | ||
|         if (
 | ||
|           isPossiblyAndroidKeyPress(event.timeStamp) &&
 | ||
|           editor.isComposing() &&
 | ||
|           isSelectionAnchorSameAsFocus
 | ||
|         ) {
 | ||
|           $setCompositionKey(null);
 | ||
|           lastKeyDownTimeStamp = 0;
 | ||
|           // Fixes an Android bug where selection flickers when backspacing
 | ||
|           setTimeout(() => {
 | ||
|             updateEditor(editor, () => {
 | ||
|               $setCompositionKey(null);
 | ||
|             });
 | ||
|           }, ANDROID_COMPOSITION_LATENCY);
 | ||
|           if ($isRangeSelection(selection)) {
 | ||
|             const anchorNode = selection.anchor.getNode();
 | ||
|             anchorNode.markDirty();
 | ||
|             invariant(
 | ||
|               $isTextNode(anchorNode),
 | ||
|               'Anchor node must be a TextNode',
 | ||
|             );
 | ||
|             selection.style = anchorNode.getStyle();
 | ||
|           }
 | ||
|         } else {
 | ||
|           $setCompositionKey(null);
 | ||
|           event.preventDefault();
 | ||
|           // Chromium Android at the moment seems to ignore the preventDefault
 | ||
|           // on 'deleteContentBackward' and still deletes the content. Which leads
 | ||
|           // to multiple deletions. So we let the browser handle the deletion in this case.
 | ||
|           const selectedNodeText = selection.anchor.getNode().getTextContent();
 | ||
|           const hasSelectedAllTextInNode =
 | ||
|             selection.anchor.offset === 0 &&
 | ||
|             selection.focus.offset === selectedNodeText.length;
 | ||
|           const shouldLetBrowserHandleDelete =
 | ||
|             IS_ANDROID_CHROME &&
 | ||
|             isSelectionAnchorSameAsFocus &&
 | ||
|             !hasSelectedAllTextInNode;
 | ||
|           if (!shouldLetBrowserHandleDelete) {
 | ||
|             dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
 | ||
|           }
 | ||
|         }
 | ||
|         return;
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     if (!$isRangeSelection(selection)) {
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     const data = event.data;
 | ||
| 
 | ||
|     // This represents the case when two beforeinput events are triggered at the same time (without a
 | ||
|     // full event loop ending at input). This happens with MacOS with the default keyboard settings,
 | ||
|     // a combination of autocorrection + autocapitalization.
 | ||
|     // Having Lexical run everything in controlled mode would fix the issue without additional code
 | ||
|     // but this would kill the massive performance win from the most common typing event.
 | ||
|     // Alternatively, when this happens we can prematurely update our EditorState based on the DOM
 | ||
|     // content, a job that would usually be the input event's responsibility.
 | ||
|     if (unprocessedBeforeInputData !== null) {
 | ||
|       $updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData);
 | ||
|     }
 | ||
| 
 | ||
|     if (
 | ||
|       (!selection.dirty || unprocessedBeforeInputData !== null) &&
 | ||
|       selection.isCollapsed() &&
 | ||
|       !$isRootNode(selection.anchor.getNode()) &&
 | ||
|       targetRange !== null
 | ||
|     ) {
 | ||
|       selection.applyDOMRange(targetRange);
 | ||
|     }
 | ||
| 
 | ||
|     unprocessedBeforeInputData = null;
 | ||
| 
 | ||
|     const anchor = selection.anchor;
 | ||
|     const focus = selection.focus;
 | ||
|     const anchorNode = anchor.getNode();
 | ||
|     const focusNode = focus.getNode();
 | ||
| 
 | ||
|     if (inputType === 'insertText' || inputType === 'insertTranspose') {
 | ||
|       if (data === '\n') {
 | ||
|         event.preventDefault();
 | ||
|         dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
 | ||
|       } else if (data === DOUBLE_LINE_BREAK) {
 | ||
|         event.preventDefault();
 | ||
|         dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
 | ||
|       } else if (data == null && event.dataTransfer) {
 | ||
|         // Gets around a Safari text replacement bug.
 | ||
|         const text = event.dataTransfer.getData('text/plain');
 | ||
|         event.preventDefault();
 | ||
|         selection.insertRawText(text);
 | ||
|       } else if (
 | ||
|         data != null &&
 | ||
|         $shouldPreventDefaultAndInsertText(
 | ||
|           selection,
 | ||
|           targetRange,
 | ||
|           data,
 | ||
|           event.timeStamp,
 | ||
|           true,
 | ||
|         )
 | ||
|       ) {
 | ||
|         event.preventDefault();
 | ||
|         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
 | ||
|       } else {
 | ||
|         unprocessedBeforeInputData = data;
 | ||
|       }
 | ||
|       lastBeforeInputInsertTextTimeStamp = event.timeStamp;
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     // Prevent the browser from carrying out
 | ||
|     // the input event, so we can control the
 | ||
|     // output.
 | ||
|     event.preventDefault();
 | ||
| 
 | ||
|     switch (inputType) {
 | ||
|       case 'insertFromYank':
 | ||
|       case 'insertFromDrop':
 | ||
|       case 'insertReplacementText': {
 | ||
|         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'insertFromComposition': {
 | ||
|         // This is the end of composition
 | ||
|         $setCompositionKey(null);
 | ||
|         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'insertLineBreak': {
 | ||
|         // Used for Android
 | ||
|         $setCompositionKey(null);
 | ||
|         dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'insertParagraph': {
 | ||
|         // Used for Android
 | ||
|         $setCompositionKey(null);
 | ||
| 
 | ||
|         // Safari does not provide the type "insertLineBreak".
 | ||
|         // So instead, we need to infer it from the keyboard event.
 | ||
|         // We do not apply this logic to iOS to allow newline auto-capitalization
 | ||
|         // work without creating linebreaks when pressing Enter
 | ||
|         if (isInsertLineBreak && !IS_IOS) {
 | ||
|           isInsertLineBreak = false;
 | ||
|           dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
 | ||
|         } else {
 | ||
|           dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
 | ||
|         }
 | ||
| 
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'insertFromPaste':
 | ||
|       case 'insertFromPasteAsQuotation': {
 | ||
|         dispatchCommand(editor, PASTE_COMMAND, event);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'deleteByComposition': {
 | ||
|         if ($canRemoveText(anchorNode, focusNode)) {
 | ||
|           dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
 | ||
|         }
 | ||
| 
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'deleteByDrag':
 | ||
|       case 'deleteByCut': {
 | ||
|         dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'deleteContent': {
 | ||
|         dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'deleteWordBackward': {
 | ||
|         dispatchCommand(editor, DELETE_WORD_COMMAND, true);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'deleteWordForward': {
 | ||
|         dispatchCommand(editor, DELETE_WORD_COMMAND, false);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'deleteHardLineBackward':
 | ||
|       case 'deleteSoftLineBackward': {
 | ||
|         dispatchCommand(editor, DELETE_LINE_COMMAND, true);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'deleteContentForward':
 | ||
|       case 'deleteHardLineForward':
 | ||
|       case 'deleteSoftLineForward': {
 | ||
|         dispatchCommand(editor, DELETE_LINE_COMMAND, false);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'formatStrikeThrough': {
 | ||
|         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough');
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'formatBold': {
 | ||
|         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'formatItalic': {
 | ||
|         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'formatUnderline': {
 | ||
|         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'historyUndo': {
 | ||
|         dispatchCommand(editor, UNDO_COMMAND, undefined);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       case 'historyRedo': {
 | ||
|         dispatchCommand(editor, REDO_COMMAND, undefined);
 | ||
|         break;
 | ||
|       }
 | ||
| 
 | ||
|       default:
 | ||
|       // NO-OP
 | ||
|     }
 | ||
|   });
 | ||
| }
 | ||
| 
 | ||
| function onInput(event: InputEvent, editor: LexicalEditor): void {
 | ||
|   // We don't want the onInput to bubble, in the case of nested editors.
 | ||
|   event.stopPropagation();
 | ||
|   updateEditor(editor, () => {
 | ||
|     const selection = $getSelection();
 | ||
|     const data = event.data;
 | ||
|     const targetRange = getTargetRange(event);
 | ||
| 
 | ||
|     if (
 | ||
|       data != null &&
 | ||
|       $isRangeSelection(selection) &&
 | ||
|       $shouldPreventDefaultAndInsertText(
 | ||
|         selection,
 | ||
|         targetRange,
 | ||
|         data,
 | ||
|         event.timeStamp,
 | ||
|         false,
 | ||
|       )
 | ||
|     ) {
 | ||
|       // Given we're over-riding the default behavior, we will need
 | ||
|       // to ensure to disable composition before dispatching the
 | ||
|       // insertText command for when changing the sequence for FF.
 | ||
|       if (isFirefoxEndingComposition) {
 | ||
|         $onCompositionEndImpl(editor, data);
 | ||
|         isFirefoxEndingComposition = false;
 | ||
|       }
 | ||
|       const anchor = selection.anchor;
 | ||
|       const anchorNode = anchor.getNode();
 | ||
|       const domSelection = getDOMSelection(editor._window);
 | ||
|       if (domSelection === null) {
 | ||
|         return;
 | ||
|       }
 | ||
|       const isBackward = selection.isBackward();
 | ||
|       const startOffset = isBackward
 | ||
|         ? selection.anchor.offset
 | ||
|         : selection.focus.offset;
 | ||
|       const endOffset = isBackward
 | ||
|         ? selection.focus.offset
 | ||
|         : selection.anchor.offset;
 | ||
|       // If the content is the same as inserted, then don't dispatch an insertion.
 | ||
|       // Given onInput doesn't take the current selection (it uses the previous)
 | ||
|       // we can compare that against what the DOM currently says.
 | ||
|       if (
 | ||
|         !CAN_USE_BEFORE_INPUT ||
 | ||
|         selection.isCollapsed() ||
 | ||
|         !$isTextNode(anchorNode) ||
 | ||
|         domSelection.anchorNode === null ||
 | ||
|         anchorNode.getTextContent().slice(0, startOffset) +
 | ||
|           data +
 | ||
|           anchorNode.getTextContent().slice(startOffset + endOffset) !==
 | ||
|           getAnchorTextFromDOM(domSelection.anchorNode)
 | ||
|       ) {
 | ||
|         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
 | ||
|       }
 | ||
| 
 | ||
|       const textLength = data.length;
 | ||
| 
 | ||
|       // Another hack for FF, as it's possible that the IME is still
 | ||
|       // open, even though compositionend has already fired (sigh).
 | ||
|       if (
 | ||
|         IS_FIREFOX &&
 | ||
|         textLength > 1 &&
 | ||
|         event.inputType === 'insertCompositionText' &&
 | ||
|         !editor.isComposing()
 | ||
|       ) {
 | ||
|         selection.anchor.offset -= textLength;
 | ||
|       }
 | ||
| 
 | ||
|       // This ensures consistency on Android.
 | ||
|       if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) {
 | ||
|         lastKeyDownTimeStamp = 0;
 | ||
|         $setCompositionKey(null);
 | ||
|       }
 | ||
|     } else {
 | ||
|       const characterData = data !== null ? data : undefined;
 | ||
|       $updateSelectedTextFromDOM(false, editor, characterData);
 | ||
| 
 | ||
|       // onInput always fires after onCompositionEnd for FF.
 | ||
|       if (isFirefoxEndingComposition) {
 | ||
|         $onCompositionEndImpl(editor, data || undefined);
 | ||
|         isFirefoxEndingComposition = false;
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     // Also flush any other mutations that might have occurred
 | ||
|     // since the change.
 | ||
|     $flushMutations();
 | ||
|   });
 | ||
|   unprocessedBeforeInputData = null;
 | ||
| }
 | ||
| 
 | ||
| function onCompositionStart(
 | ||
|   event: CompositionEvent,
 | ||
|   editor: LexicalEditor,
 | ||
| ): void {
 | ||
|   updateEditor(editor, () => {
 | ||
|     const selection = $getSelection();
 | ||
| 
 | ||
|     if ($isRangeSelection(selection) && !editor.isComposing()) {
 | ||
|       const anchor = selection.anchor;
 | ||
|       const node = selection.anchor.getNode();
 | ||
|       $setCompositionKey(anchor.key);
 | ||
| 
 | ||
|       if (
 | ||
|         // If it has been 30ms since the last keydown, then we should
 | ||
|         // apply the empty space heuristic. We can't do this for Safari,
 | ||
|         // as the keydown fires after composition start.
 | ||
|         event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
 | ||
|         // FF has issues around composing multibyte characters, so we also
 | ||
|         // need to invoke the empty space heuristic below.
 | ||
|         anchor.type === 'element' ||
 | ||
|         !selection.isCollapsed() ||
 | ||
|         ($isTextNode(node) && node.getStyle() !== selection.style)
 | ||
|       ) {
 | ||
|         // We insert a zero width character, ready for the composition
 | ||
|         // to get inserted into the new node we create. If
 | ||
|         // we don't do this, Safari will fail on us because
 | ||
|         // there is no text node matching the selection.
 | ||
|         dispatchCommand(
 | ||
|           editor,
 | ||
|           CONTROLLED_TEXT_INSERTION_COMMAND,
 | ||
|           COMPOSITION_START_CHAR,
 | ||
|         );
 | ||
|       }
 | ||
|     }
 | ||
|   });
 | ||
| }
 | ||
| 
 | ||
| function $onCompositionEndImpl(editor: LexicalEditor, data?: string): void {
 | ||
|   const compositionKey = editor._compositionKey;
 | ||
|   $setCompositionKey(null);
 | ||
| 
 | ||
|   // Handle termination of composition.
 | ||
|   if (compositionKey !== null && data != null) {
 | ||
|     // Composition can sometimes move to an adjacent DOM node when backspacing.
 | ||
|     // So check for the empty case.
 | ||
|     if (data === '') {
 | ||
|       const node = $getNodeByKey(compositionKey);
 | ||
|       const textNode = getDOMTextNode(editor.getElementByKey(compositionKey));
 | ||
| 
 | ||
|       if (
 | ||
|         textNode !== null &&
 | ||
|         textNode.nodeValue !== null &&
 | ||
|         $isTextNode(node)
 | ||
|       ) {
 | ||
|         $updateTextNodeFromDOMContent(
 | ||
|           node,
 | ||
|           textNode.nodeValue,
 | ||
|           null,
 | ||
|           null,
 | ||
|           true,
 | ||
|         );
 | ||
|       }
 | ||
| 
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     // Composition can sometimes be that of a new line. In which case, we need to
 | ||
|     // handle that accordingly.
 | ||
|     if (data[data.length - 1] === '\n') {
 | ||
|       const selection = $getSelection();
 | ||
| 
 | ||
|       if ($isRangeSelection(selection)) {
 | ||
|         // If the last character is a line break, we also need to insert
 | ||
|         // a line break.
 | ||
|         const focus = selection.focus;
 | ||
|         selection.anchor.set(focus.key, focus.offset, focus.type);
 | ||
|         dispatchCommand(editor, KEY_ENTER_COMMAND, null);
 | ||
|         return;
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   $updateSelectedTextFromDOM(true, editor, data);
 | ||
| }
 | ||
| 
 | ||
| function onCompositionEnd(
 | ||
|   event: CompositionEvent,
 | ||
|   editor: LexicalEditor,
 | ||
| ): void {
 | ||
|   // Firefox fires onCompositionEnd before onInput, but Chrome/Webkit,
 | ||
|   // fire onInput before onCompositionEnd. To ensure the sequence works
 | ||
|   // like Chrome/Webkit we use the isFirefoxEndingComposition flag to
 | ||
|   // defer handling of onCompositionEnd in Firefox till we have processed
 | ||
|   // the logic in onInput.
 | ||
|   if (IS_FIREFOX) {
 | ||
|     isFirefoxEndingComposition = true;
 | ||
|   } else {
 | ||
|     updateEditor(editor, () => {
 | ||
|       $onCompositionEndImpl(editor, event.data);
 | ||
|     });
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
 | ||
|   lastKeyDownTimeStamp = event.timeStamp;
 | ||
|   lastKeyCode = event.key;
 | ||
|   if (editor.isComposing()) {
 | ||
|     return;
 | ||
|   }
 | ||
| 
 | ||
|   const {key, shiftKey, ctrlKey, metaKey, altKey} = event;
 | ||
| 
 | ||
|   if (dispatchCommand(editor, KEY_DOWN_COMMAND, event)) {
 | ||
|     return;
 | ||
|   }
 | ||
| 
 | ||
|   if (key == null) {
 | ||
|     return;
 | ||
|   }
 | ||
| 
 | ||
|   if (isMoveForward(key, ctrlKey, altKey, metaKey)) {
 | ||
|     dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event);
 | ||
|   } else if (isMoveToEnd(key, ctrlKey, shiftKey, altKey, metaKey)) {
 | ||
|     dispatchCommand(editor, MOVE_TO_END, event);
 | ||
|   } else if (isMoveBackward(key, ctrlKey, altKey, metaKey)) {
 | ||
|     dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event);
 | ||
|   } else if (isMoveToStart(key, ctrlKey, shiftKey, altKey, metaKey)) {
 | ||
|     dispatchCommand(editor, MOVE_TO_START, event);
 | ||
|   } else if (isMoveUp(key, ctrlKey, metaKey)) {
 | ||
|     dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event);
 | ||
|   } else if (isMoveDown(key, ctrlKey, metaKey)) {
 | ||
|     dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event);
 | ||
|   } else if (isLineBreak(key, shiftKey)) {
 | ||
|     isInsertLineBreak = true;
 | ||
|     dispatchCommand(editor, KEY_ENTER_COMMAND, event);
 | ||
|   } else if (isSpace(key)) {
 | ||
|     dispatchCommand(editor, KEY_SPACE_COMMAND, event);
 | ||
|   } else if (isOpenLineBreak(key, ctrlKey)) {
 | ||
|     event.preventDefault();
 | ||
|     isInsertLineBreak = true;
 | ||
|     dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
 | ||
|   } else if (isParagraph(key, shiftKey)) {
 | ||
|     isInsertLineBreak = false;
 | ||
|     dispatchCommand(editor, KEY_ENTER_COMMAND, event);
 | ||
|   } else if (isDeleteBackward(key, altKey, metaKey, ctrlKey)) {
 | ||
|     if (isBackspace(key)) {
 | ||
|       dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event);
 | ||
|     } else {
 | ||
|       event.preventDefault();
 | ||
|       dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
 | ||
|     }
 | ||
|   } else if (isEscape(key)) {
 | ||
|     dispatchCommand(editor, KEY_ESCAPE_COMMAND, event);
 | ||
|   } else if (isDeleteForward(key, ctrlKey, shiftKey, altKey, metaKey)) {
 | ||
|     if (isDelete(key)) {
 | ||
|       dispatchCommand(editor, KEY_DELETE_COMMAND, event);
 | ||
|     } else {
 | ||
|       event.preventDefault();
 | ||
|       dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
 | ||
|     }
 | ||
|   } else if (isDeleteWordBackward(key, altKey, ctrlKey)) {
 | ||
|     event.preventDefault();
 | ||
|     dispatchCommand(editor, DELETE_WORD_COMMAND, true);
 | ||
|   } else if (isDeleteWordForward(key, altKey, ctrlKey)) {
 | ||
|     event.preventDefault();
 | ||
|     dispatchCommand(editor, DELETE_WORD_COMMAND, false);
 | ||
|   } else if (isDeleteLineBackward(key, metaKey)) {
 | ||
|     event.preventDefault();
 | ||
|     dispatchCommand(editor, DELETE_LINE_COMMAND, true);
 | ||
|   } else if (isDeleteLineForward(key, metaKey)) {
 | ||
|     event.preventDefault();
 | ||
|     dispatchCommand(editor, DELETE_LINE_COMMAND, false);
 | ||
|   } else if (isBold(key, altKey, metaKey, ctrlKey)) {
 | ||
|     event.preventDefault();
 | ||
|     dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
 | ||
|   } else if (isUnderline(key, altKey, metaKey, ctrlKey)) {
 | ||
|     event.preventDefault();
 | ||
|     dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
 | ||
|   } else if (isItalic(key, altKey, metaKey, ctrlKey)) {
 | ||
|     event.preventDefault();
 | ||
|     dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
 | ||
|   } else if (isTab(key, altKey, ctrlKey, metaKey)) {
 | ||
|     dispatchCommand(editor, KEY_TAB_COMMAND, event);
 | ||
|   } else if (isUndo(key, shiftKey, metaKey, ctrlKey)) {
 | ||
|     event.preventDefault();
 | ||
|     dispatchCommand(editor, UNDO_COMMAND, undefined);
 | ||
|   } else if (isRedo(key, shiftKey, metaKey, ctrlKey)) {
 | ||
|     event.preventDefault();
 | ||
|     dispatchCommand(editor, REDO_COMMAND, undefined);
 | ||
|   } else {
 | ||
|     const prevSelection = editor._editorState._selection;
 | ||
|     if ($isNodeSelection(prevSelection)) {
 | ||
|       if (isCopy(key, shiftKey, metaKey, ctrlKey)) {
 | ||
|         event.preventDefault();
 | ||
|         dispatchCommand(editor, COPY_COMMAND, event);
 | ||
|       } else if (isCut(key, shiftKey, metaKey, ctrlKey)) {
 | ||
|         event.preventDefault();
 | ||
|         dispatchCommand(editor, CUT_COMMAND, event);
 | ||
|       } else if (isSelectAll(key, metaKey, ctrlKey)) {
 | ||
|         event.preventDefault();
 | ||
|         dispatchCommand(editor, SELECT_ALL_COMMAND, event);
 | ||
|       }
 | ||
|       // FF does it well (no need to override behavior)
 | ||
|     } else if (!IS_FIREFOX && isSelectAll(key, metaKey, ctrlKey)) {
 | ||
|       event.preventDefault();
 | ||
|       dispatchCommand(editor, SELECT_ALL_COMMAND, event);
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   if (isModifier(ctrlKey, shiftKey, altKey, metaKey)) {
 | ||
|     dispatchCommand(editor, KEY_MODIFIER_COMMAND, event);
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| function getRootElementRemoveHandles(
 | ||
|   rootElement: HTMLElement,
 | ||
| ): RootElementRemoveHandles {
 | ||
|   // @ts-expect-error: internal field
 | ||
|   let eventHandles = rootElement.__lexicalEventHandles;
 | ||
| 
 | ||
|   if (eventHandles === undefined) {
 | ||
|     eventHandles = [];
 | ||
|     // @ts-expect-error: internal field
 | ||
|     rootElement.__lexicalEventHandles = eventHandles;
 | ||
|   }
 | ||
| 
 | ||
|   return eventHandles;
 | ||
| }
 | ||
| 
 | ||
| // Mapping root editors to their active nested editors, contains nested editors
 | ||
| // mapping only, so if root editor is selected map will have no reference to free up memory
 | ||
| const activeNestedEditorsMap: Map<string, LexicalEditor> = new Map();
 | ||
| 
 | ||
| function onDocumentSelectionChange(event: Event): void {
 | ||
|   const target = event.target as null | Element | Document;
 | ||
|   const targetWindow =
 | ||
|     target == null
 | ||
|       ? null
 | ||
|       : target.nodeType === 9
 | ||
|       ? (target as Document).defaultView
 | ||
|       : (target as Element).ownerDocument.defaultView;
 | ||
|   const domSelection = getDOMSelection(targetWindow);
 | ||
|   if (domSelection === null) {
 | ||
|     return;
 | ||
|   }
 | ||
|   const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode);
 | ||
|   if (nextActiveEditor === null) {
 | ||
|     return;
 | ||
|   }
 | ||
| 
 | ||
|   if (isSelectionChangeFromMouseDown) {
 | ||
|     isSelectionChangeFromMouseDown = false;
 | ||
|     updateEditor(nextActiveEditor, () => {
 | ||
|       const lastSelection = $getPreviousSelection();
 | ||
|       const domAnchorNode = domSelection.anchorNode;
 | ||
|       if (domAnchorNode === null) {
 | ||
|         return;
 | ||
|       }
 | ||
|       const nodeType = domAnchorNode.nodeType;
 | ||
|       // If the user is attempting to click selection back onto text, then
 | ||
|       // we should attempt create a range selection.
 | ||
|       // When we click on an empty paragraph node or the end of a paragraph that ends
 | ||
|       // with an image/poll, the nodeType will be ELEMENT_NODE
 | ||
|       if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) {
 | ||
|         return;
 | ||
|       }
 | ||
|       const newSelection = $internalCreateRangeSelection(
 | ||
|         lastSelection,
 | ||
|         domSelection,
 | ||
|         nextActiveEditor,
 | ||
|         event,
 | ||
|       );
 | ||
|       $setSelection(newSelection);
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   // When editor receives selection change event, we're checking if
 | ||
|   // it has any sibling editors (within same parent editor) that were active
 | ||
|   // before, and trigger selection change on it to nullify selection.
 | ||
|   const editors = getEditorsToPropagate(nextActiveEditor);
 | ||
|   const rootEditor = editors[editors.length - 1];
 | ||
|   const rootEditorKey = rootEditor._key;
 | ||
|   const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey);
 | ||
|   const prevActiveEditor = activeNestedEditor || rootEditor;
 | ||
| 
 | ||
|   if (prevActiveEditor !== nextActiveEditor) {
 | ||
|     onSelectionChange(domSelection, prevActiveEditor, false);
 | ||
|   }
 | ||
| 
 | ||
|   onSelectionChange(domSelection, nextActiveEditor, true);
 | ||
| 
 | ||
|   // If newly selected editor is nested, then add it to the map, clean map otherwise
 | ||
|   if (nextActiveEditor !== rootEditor) {
 | ||
|     activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor);
 | ||
|   } else if (activeNestedEditor) {
 | ||
|     activeNestedEditorsMap.delete(rootEditorKey);
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| function stopLexicalPropagation(event: Event): void {
 | ||
|   // We attach a special property to ensure the same event doesn't re-fire
 | ||
|   // for parent editors.
 | ||
|   // @ts-ignore
 | ||
|   event._lexicalHandled = true;
 | ||
| }
 | ||
| 
 | ||
| function hasStoppedLexicalPropagation(event: Event): boolean {
 | ||
|   // @ts-ignore
 | ||
|   const stopped = event._lexicalHandled === true;
 | ||
|   return stopped;
 | ||
| }
 | ||
| 
 | ||
| export type EventHandler = (event: Event, editor: LexicalEditor) => void;
 | ||
| 
 | ||
| export function addRootElementEvents(
 | ||
|   rootElement: HTMLElement,
 | ||
|   editor: LexicalEditor,
 | ||
| ): void {
 | ||
|   // We only want to have a single global selectionchange event handler, shared
 | ||
|   // between all editor instances.
 | ||
|   const doc = rootElement.ownerDocument;
 | ||
|   const documentRootElementsCount = rootElementsRegistered.get(doc);
 | ||
|   if (
 | ||
|     documentRootElementsCount === undefined ||
 | ||
|     documentRootElementsCount < 1
 | ||
|   ) {
 | ||
|     doc.addEventListener('selectionchange', onDocumentSelectionChange);
 | ||
|   }
 | ||
|   rootElementsRegistered.set(doc, (documentRootElementsCount || 0) + 1);
 | ||
| 
 | ||
|   // @ts-expect-error: internal field
 | ||
|   rootElement.__lexicalEditor = editor;
 | ||
|   const removeHandles = getRootElementRemoveHandles(rootElement);
 | ||
| 
 | ||
|   for (let i = 0; i < rootElementEvents.length; i++) {
 | ||
|     const [eventName, onEvent] = rootElementEvents[i];
 | ||
|     const eventHandler =
 | ||
|       typeof onEvent === 'function'
 | ||
|         ? (event: Event) => {
 | ||
|             if (hasStoppedLexicalPropagation(event)) {
 | ||
|               return;
 | ||
|             }
 | ||
|             stopLexicalPropagation(event);
 | ||
|             if (editor.isEditable() || eventName === 'click') {
 | ||
|               onEvent(event, editor);
 | ||
|             }
 | ||
|           }
 | ||
|         : (event: Event) => {
 | ||
|             if (hasStoppedLexicalPropagation(event)) {
 | ||
|               return;
 | ||
|             }
 | ||
|             stopLexicalPropagation(event);
 | ||
|             const isEditable = editor.isEditable();
 | ||
|             switch (eventName) {
 | ||
|               case 'cut':
 | ||
|                 return (
 | ||
|                   isEditable &&
 | ||
|                   dispatchCommand(editor, CUT_COMMAND, event as ClipboardEvent)
 | ||
|                 );
 | ||
| 
 | ||
|               case 'copy':
 | ||
|                 return dispatchCommand(
 | ||
|                   editor,
 | ||
|                   COPY_COMMAND,
 | ||
|                   event as ClipboardEvent,
 | ||
|                 );
 | ||
| 
 | ||
|               case 'paste':
 | ||
|                 return (
 | ||
|                   isEditable &&
 | ||
|                   dispatchCommand(
 | ||
|                     editor,
 | ||
|                     PASTE_COMMAND,
 | ||
|                     event as ClipboardEvent,
 | ||
|                   )
 | ||
|                 );
 | ||
| 
 | ||
|               case 'dragstart':
 | ||
|                 return (
 | ||
|                   isEditable &&
 | ||
|                   dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent)
 | ||
|                 );
 | ||
| 
 | ||
|               case 'dragover':
 | ||
|                 return (
 | ||
|                   isEditable &&
 | ||
|                   dispatchCommand(editor, DRAGOVER_COMMAND, event as DragEvent)
 | ||
|                 );
 | ||
| 
 | ||
|               case 'dragend':
 | ||
|                 return (
 | ||
|                   isEditable &&
 | ||
|                   dispatchCommand(editor, DRAGEND_COMMAND, event as DragEvent)
 | ||
|                 );
 | ||
| 
 | ||
|               case 'focus':
 | ||
|                 return (
 | ||
|                   isEditable &&
 | ||
|                   dispatchCommand(editor, FOCUS_COMMAND, event as FocusEvent)
 | ||
|                 );
 | ||
| 
 | ||
|               case 'blur': {
 | ||
|                 return (
 | ||
|                   isEditable &&
 | ||
|                   dispatchCommand(editor, BLUR_COMMAND, event as FocusEvent)
 | ||
|                 );
 | ||
|               }
 | ||
| 
 | ||
|               case 'drop':
 | ||
|                 return (
 | ||
|                   isEditable &&
 | ||
|                   dispatchCommand(editor, DROP_COMMAND, event as DragEvent)
 | ||
|                 );
 | ||
|             }
 | ||
|           };
 | ||
|     rootElement.addEventListener(eventName, eventHandler);
 | ||
|     removeHandles.push(() => {
 | ||
|       rootElement.removeEventListener(eventName, eventHandler);
 | ||
|     });
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| export function removeRootElementEvents(rootElement: HTMLElement): void {
 | ||
|   const doc = rootElement.ownerDocument;
 | ||
|   const documentRootElementsCount = rootElementsRegistered.get(doc);
 | ||
|   invariant(
 | ||
|     documentRootElementsCount !== undefined,
 | ||
|     'Root element not registered',
 | ||
|   );
 | ||
| 
 | ||
|   // We only want to have a single global selectionchange event handler, shared
 | ||
|   // between all editor instances.
 | ||
|   const newCount = documentRootElementsCount - 1;
 | ||
|   invariant(newCount >= 0, 'Root element count less than 0');
 | ||
|   rootElementsRegistered.set(doc, newCount);
 | ||
|   if (newCount === 0) {
 | ||
|     doc.removeEventListener('selectionchange', onDocumentSelectionChange);
 | ||
|   }
 | ||
| 
 | ||
|   const editor = getEditorPropertyFromDOMNode(rootElement);
 | ||
| 
 | ||
|   if (isLexicalEditor(editor)) {
 | ||
|     cleanActiveNestedEditorsMap(editor);
 | ||
|     // @ts-expect-error: internal field
 | ||
|     rootElement.__lexicalEditor = null;
 | ||
|   } else if (editor) {
 | ||
|     invariant(
 | ||
|       false,
 | ||
|       'Attempted to remove event handlers from a node that does not belong to this build of Lexical',
 | ||
|     );
 | ||
|   }
 | ||
| 
 | ||
|   const removeHandles = getRootElementRemoveHandles(rootElement);
 | ||
| 
 | ||
|   for (let i = 0; i < removeHandles.length; i++) {
 | ||
|     removeHandles[i]();
 | ||
|   }
 | ||
| 
 | ||
|   // @ts-expect-error: internal field
 | ||
|   rootElement.__lexicalEventHandles = [];
 | ||
| }
 | ||
| 
 | ||
| function cleanActiveNestedEditorsMap(editor: LexicalEditor) {
 | ||
|   if (editor._parentEditor !== null) {
 | ||
|     // For nested editor cleanup map if this editor was marked as active
 | ||
|     const editors = getEditorsToPropagate(editor);
 | ||
|     const rootEditor = editors[editors.length - 1];
 | ||
|     const rootEditorKey = rootEditor._key;
 | ||
| 
 | ||
|     if (activeNestedEditorsMap.get(rootEditorKey) === editor) {
 | ||
|       activeNestedEditorsMap.delete(rootEditorKey);
 | ||
|     }
 | ||
|   } else {
 | ||
|     // For top-level editors cleanup map
 | ||
|     activeNestedEditorsMap.delete(editor._key);
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| export function markSelectionChangeFromDOMUpdate(): void {
 | ||
|   isSelectionChangeFromDOMUpdate = true;
 | ||
| }
 | ||
| 
 | ||
| export function markCollapsedSelectionFormat(
 | ||
|   format: number,
 | ||
|   style: string,
 | ||
|   offset: number,
 | ||
|   key: NodeKey,
 | ||
|   timeStamp: number,
 | ||
| ): void {
 | ||
|   collapsedSelectionFormat = [format, style, offset, key, timeStamp];
 | ||
| }
 |