]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/LexicalEvents.ts
Lexical: Imported core lexical libs
[bookstack] / resources / js / wysiwyg / lexical / core / LexicalEvents.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import type {LexicalEditor} from './LexicalEditor';
10 import type {NodeKey} from './LexicalNode';
11 import type {ElementNode} from './nodes/LexicalElementNode';
12 import type {TextNode} from './nodes/LexicalTextNode';
13
14 import {
15   CAN_USE_BEFORE_INPUT,
16   IS_ANDROID_CHROME,
17   IS_APPLE_WEBKIT,
18   IS_FIREFOX,
19   IS_IOS,
20   IS_SAFARI,
21 } from 'lexical/shared/environment';
22 import invariant from 'lexical/shared/invariant';
23
24 import {
25   $getPreviousSelection,
26   $getRoot,
27   $getSelection,
28   $isElementNode,
29   $isNodeSelection,
30   $isRangeSelection,
31   $isRootNode,
32   $isTextNode,
33   $setCompositionKey,
34   BLUR_COMMAND,
35   CLICK_COMMAND,
36   CONTROLLED_TEXT_INSERTION_COMMAND,
37   COPY_COMMAND,
38   CUT_COMMAND,
39   DELETE_CHARACTER_COMMAND,
40   DELETE_LINE_COMMAND,
41   DELETE_WORD_COMMAND,
42   DRAGEND_COMMAND,
43   DRAGOVER_COMMAND,
44   DRAGSTART_COMMAND,
45   DROP_COMMAND,
46   FOCUS_COMMAND,
47   FORMAT_TEXT_COMMAND,
48   INSERT_LINE_BREAK_COMMAND,
49   INSERT_PARAGRAPH_COMMAND,
50   KEY_ARROW_DOWN_COMMAND,
51   KEY_ARROW_LEFT_COMMAND,
52   KEY_ARROW_RIGHT_COMMAND,
53   KEY_ARROW_UP_COMMAND,
54   KEY_BACKSPACE_COMMAND,
55   KEY_DELETE_COMMAND,
56   KEY_DOWN_COMMAND,
57   KEY_ENTER_COMMAND,
58   KEY_ESCAPE_COMMAND,
59   KEY_SPACE_COMMAND,
60   KEY_TAB_COMMAND,
61   MOVE_TO_END,
62   MOVE_TO_START,
63   ParagraphNode,
64   PASTE_COMMAND,
65   REDO_COMMAND,
66   REMOVE_TEXT_COMMAND,
67   SELECTION_CHANGE_COMMAND,
68   UNDO_COMMAND,
69 } from '.';
70 import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
71 import {
72   COMPOSITION_START_CHAR,
73   DOM_ELEMENT_TYPE,
74   DOM_TEXT_TYPE,
75   DOUBLE_LINE_BREAK,
76   IS_ALL_FORMATTING,
77 } from './LexicalConstants';
78 import {
79   $internalCreateRangeSelection,
80   RangeSelection,
81 } from './LexicalSelection';
82 import {getActiveEditor, updateEditor} from './LexicalUpdates';
83 import {
84   $flushMutations,
85   $getNodeByKey,
86   $isSelectionCapturedInDecorator,
87   $isTokenOrSegmented,
88   $setSelection,
89   $shouldInsertTextAfterOrBeforeTextNode,
90   $updateSelectedTextFromDOM,
91   $updateTextNodeFromDOMContent,
92   dispatchCommand,
93   doesContainGrapheme,
94   getAnchorTextFromDOM,
95   getDOMSelection,
96   getDOMTextNode,
97   getEditorPropertyFromDOMNode,
98   getEditorsToPropagate,
99   getNearestEditorFromDOMNode,
100   getWindow,
101   isBackspace,
102   isBold,
103   isCopy,
104   isCut,
105   isDelete,
106   isDeleteBackward,
107   isDeleteForward,
108   isDeleteLineBackward,
109   isDeleteLineForward,
110   isDeleteWordBackward,
111   isDeleteWordForward,
112   isEscape,
113   isFirefoxClipboardEvents,
114   isItalic,
115   isLexicalEditor,
116   isLineBreak,
117   isModifier,
118   isMoveBackward,
119   isMoveDown,
120   isMoveForward,
121   isMoveToEnd,
122   isMoveToStart,
123   isMoveUp,
124   isOpenLineBreak,
125   isParagraph,
126   isRedo,
127   isSelectAll,
128   isSelectionWithinEditor,
129   isSpace,
130   isTab,
131   isUnderline,
132   isUndo,
133 } from './LexicalUtils';
134
135 type RootElementRemoveHandles = Array<() => void>;
136 type RootElementEvents = Array<
137   [
138     string,
139     Record<string, unknown> | ((event: Event, editor: LexicalEditor) => void),
140   ]
141 >;
142 const PASS_THROUGH_COMMAND = Object.freeze({});
143 const ANDROID_COMPOSITION_LATENCY = 30;
144 const rootElementEvents: RootElementEvents = [
145   ['keydown', onKeyDown],
146   ['pointerdown', onPointerDown],
147   ['compositionstart', onCompositionStart],
148   ['compositionend', onCompositionEnd],
149   ['input', onInput],
150   ['click', onClick],
151   ['cut', PASS_THROUGH_COMMAND],
152   ['copy', PASS_THROUGH_COMMAND],
153   ['dragstart', PASS_THROUGH_COMMAND],
154   ['dragover', PASS_THROUGH_COMMAND],
155   ['dragend', PASS_THROUGH_COMMAND],
156   ['paste', PASS_THROUGH_COMMAND],
157   ['focus', PASS_THROUGH_COMMAND],
158   ['blur', PASS_THROUGH_COMMAND],
159   ['drop', PASS_THROUGH_COMMAND],
160 ];
161
162 if (CAN_USE_BEFORE_INPUT) {
163   rootElementEvents.push([
164     'beforeinput',
165     (event, editor) => onBeforeInput(event as InputEvent, editor),
166   ]);
167 }
168
169 let lastKeyDownTimeStamp = 0;
170 let lastKeyCode: null | string = null;
171 let lastBeforeInputInsertTextTimeStamp = 0;
172 let unprocessedBeforeInputData: null | string = null;
173 const rootElementsRegistered = new WeakMap<Document, number>();
174 let isSelectionChangeFromDOMUpdate = false;
175 let isSelectionChangeFromMouseDown = false;
176 let isInsertLineBreak = false;
177 let isFirefoxEndingComposition = false;
178 let collapsedSelectionFormat: [number, string, number, NodeKey, number] = [
179   0,
180   '',
181   0,
182   'root',
183   0,
184 ];
185
186 // This function is used to determine if Lexical should attempt to override
187 // the default browser behavior for insertion of text and use its own internal
188 // heuristics. This is an extremely important function, and makes much of Lexical
189 // work as intended between different browsers and across word, line and character
190 // boundary/formats. It also is important for text replacement, node schemas and
191 // composition mechanics.
192
193 function $shouldPreventDefaultAndInsertText(
194   selection: RangeSelection,
195   domTargetRange: null | StaticRange,
196   text: string,
197   timeStamp: number,
198   isBeforeInput: boolean,
199 ): boolean {
200   const anchor = selection.anchor;
201   const focus = selection.focus;
202   const anchorNode = anchor.getNode();
203   const editor = getActiveEditor();
204   const domSelection = getDOMSelection(editor._window);
205   const domAnchorNode = domSelection !== null ? domSelection.anchorNode : null;
206   const anchorKey = anchor.key;
207   const backingAnchorElement = editor.getElementByKey(anchorKey);
208   const textLength = text.length;
209
210   return (
211     anchorKey !== focus.key ||
212     // If we're working with a non-text node.
213     !$isTextNode(anchorNode) ||
214     // If we are replacing a range with a single character or grapheme, and not composing.
215     (((!isBeforeInput &&
216       (!CAN_USE_BEFORE_INPUT ||
217         // We check to see if there has been
218         // a recent beforeinput event for "textInput". If there has been one in the last
219         // 50ms then we proceed as normal. However, if there is not, then this is likely
220         // a dangling `input` event caused by execCommand('insertText').
221         lastBeforeInputInsertTextTimeStamp < timeStamp + 50)) ||
222       (anchorNode.isDirty() && textLength < 2) ||
223       doesContainGrapheme(text)) &&
224       anchor.offset !== focus.offset &&
225       !anchorNode.isComposing()) ||
226     // Any non standard text node.
227     $isTokenOrSegmented(anchorNode) ||
228     // If the text length is more than a single character and we're either
229     // dealing with this in "beforeinput" or where the node has already recently
230     // been changed (thus is dirty).
231     (anchorNode.isDirty() && textLength > 1) ||
232     // If the DOM selection element is not the same as the backing node during beforeinput.
233     ((isBeforeInput || !CAN_USE_BEFORE_INPUT) &&
234       backingAnchorElement !== null &&
235       !anchorNode.isComposing() &&
236       domAnchorNode !== getDOMTextNode(backingAnchorElement)) ||
237     // If TargetRange is not the same as the DOM selection; browser trying to edit random parts
238     // of the editor.
239     (domSelection !== null &&
240       domTargetRange !== null &&
241       (!domTargetRange.collapsed ||
242         domTargetRange.startContainer !== domSelection.anchorNode ||
243         domTargetRange.startOffset !== domSelection.anchorOffset)) ||
244     // Check if we're changing from bold to italics, or some other format.
245     anchorNode.getFormat() !== selection.format ||
246     anchorNode.getStyle() !== selection.style ||
247     // One last set of heuristics to check against.
248     $shouldInsertTextAfterOrBeforeTextNode(selection, anchorNode)
249   );
250 }
251
252 function shouldSkipSelectionChange(
253   domNode: null | Node,
254   offset: number,
255 ): boolean {
256   return (
257     domNode !== null &&
258     domNode.nodeValue !== null &&
259     domNode.nodeType === DOM_TEXT_TYPE &&
260     offset !== 0 &&
261     offset !== domNode.nodeValue.length
262   );
263 }
264
265 function onSelectionChange(
266   domSelection: Selection,
267   editor: LexicalEditor,
268   isActive: boolean,
269 ): void {
270   const {
271     anchorNode: anchorDOM,
272     anchorOffset,
273     focusNode: focusDOM,
274     focusOffset,
275   } = domSelection;
276   if (isSelectionChangeFromDOMUpdate) {
277     isSelectionChangeFromDOMUpdate = false;
278
279     // If native DOM selection is on a DOM element, then
280     // we should continue as usual, as Lexical's selection
281     // may have normalized to a better child. If the DOM
282     // element is a text node, we can safely apply this
283     // optimization and skip the selection change entirely.
284     // We also need to check if the offset is at the boundary,
285     // because in this case, we might need to normalize to a
286     // sibling instead.
287     if (
288       shouldSkipSelectionChange(anchorDOM, anchorOffset) &&
289       shouldSkipSelectionChange(focusDOM, focusOffset)
290     ) {
291       return;
292     }
293   }
294   updateEditor(editor, () => {
295     // Non-active editor don't need any extra logic for selection, it only needs update
296     // to reconcile selection (set it to null) to ensure that only one editor has non-null selection.
297     if (!isActive) {
298       $setSelection(null);
299       return;
300     }
301
302     if (!isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
303       return;
304     }
305
306     const selection = $getSelection();
307
308     // Update the selection format
309     if ($isRangeSelection(selection)) {
310       const anchor = selection.anchor;
311       const anchorNode = anchor.getNode();
312
313       if (selection.isCollapsed()) {
314         // Badly interpreted range selection when collapsed - #1482
315         if (
316           domSelection.type === 'Range' &&
317           domSelection.anchorNode === domSelection.focusNode
318         ) {
319           selection.dirty = true;
320         }
321
322         // If we have marked a collapsed selection format, and we're
323         // within the given time range â€“ then attempt to use that format
324         // instead of getting the format from the anchor node.
325         const windowEvent = getWindow(editor).event;
326         const currentTimeStamp = windowEvent
327           ? windowEvent.timeStamp
328           : performance.now();
329         const [lastFormat, lastStyle, lastOffset, lastKey, timeStamp] =
330           collapsedSelectionFormat;
331
332         const root = $getRoot();
333         const isRootTextContentEmpty =
334           editor.isComposing() === false && root.getTextContent() === '';
335
336         if (
337           currentTimeStamp < timeStamp + 200 &&
338           anchor.offset === lastOffset &&
339           anchor.key === lastKey
340         ) {
341           selection.format = lastFormat;
342           selection.style = lastStyle;
343         } else {
344           if (anchor.type === 'text') {
345             invariant(
346               $isTextNode(anchorNode),
347               'Point.getNode() must return TextNode when type is text',
348             );
349             selection.format = anchorNode.getFormat();
350             selection.style = anchorNode.getStyle();
351           } else if (anchor.type === 'element' && !isRootTextContentEmpty) {
352             const lastNode = anchor.getNode();
353             selection.style = '';
354             if (
355               lastNode instanceof ParagraphNode &&
356               lastNode.getChildrenSize() === 0
357             ) {
358               selection.format = lastNode.getTextFormat();
359               selection.style = lastNode.getTextStyle();
360             } else {
361               selection.format = 0;
362             }
363           }
364         }
365       } else {
366         const anchorKey = anchor.key;
367         const focus = selection.focus;
368         const focusKey = focus.key;
369         const nodes = selection.getNodes();
370         const nodesLength = nodes.length;
371         const isBackward = selection.isBackward();
372         const startOffset = isBackward ? focusOffset : anchorOffset;
373         const endOffset = isBackward ? anchorOffset : focusOffset;
374         const startKey = isBackward ? focusKey : anchorKey;
375         const endKey = isBackward ? anchorKey : focusKey;
376         let combinedFormat = IS_ALL_FORMATTING;
377         let hasTextNodes = false;
378         for (let i = 0; i < nodesLength; i++) {
379           const node = nodes[i];
380           const textContentSize = node.getTextContentSize();
381           if (
382             $isTextNode(node) &&
383             textContentSize !== 0 &&
384             // Exclude empty text nodes at boundaries resulting from user's selection
385             !(
386               (i === 0 &&
387                 node.__key === startKey &&
388                 startOffset === textContentSize) ||
389               (i === nodesLength - 1 &&
390                 node.__key === endKey &&
391                 endOffset === 0)
392             )
393           ) {
394             // TODO: what about style?
395             hasTextNodes = true;
396             combinedFormat &= node.getFormat();
397             if (combinedFormat === 0) {
398               break;
399             }
400           }
401         }
402
403         selection.format = hasTextNodes ? combinedFormat : 0;
404       }
405     }
406
407     dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined);
408   });
409 }
410
411 // This is a work-around is mainly Chrome specific bug where if you select
412 // the contents of an empty block, you cannot easily unselect anything.
413 // This results in a tiny selection box that looks buggy/broken. This can
414 // also help other browsers when selection might "appear" lost, when it
415 // really isn't.
416 function onClick(event: PointerEvent, editor: LexicalEditor): void {
417   updateEditor(editor, () => {
418     const selection = $getSelection();
419     const domSelection = getDOMSelection(editor._window);
420     const lastSelection = $getPreviousSelection();
421
422     if (domSelection) {
423       if ($isRangeSelection(selection)) {
424         const anchor = selection.anchor;
425         const anchorNode = anchor.getNode();
426
427         if (
428           anchor.type === 'element' &&
429           anchor.offset === 0 &&
430           selection.isCollapsed() &&
431           !$isRootNode(anchorNode) &&
432           $getRoot().getChildrenSize() === 1 &&
433           anchorNode.getTopLevelElementOrThrow().isEmpty() &&
434           lastSelection !== null &&
435           selection.is(lastSelection)
436         ) {
437           domSelection.removeAllRanges();
438           selection.dirty = true;
439         } else if (event.detail === 3 && !selection.isCollapsed()) {
440           // Tripple click causing selection to overflow into the nearest element. In that
441           // case visually it looks like a single element content is selected, focus node
442           // is actually at the beginning of the next element (if present) and any manipulations
443           // with selection (formatting) are affecting second element as well
444           const focus = selection.focus;
445           const focusNode = focus.getNode();
446           if (anchorNode !== focusNode) {
447             if ($isElementNode(anchorNode)) {
448               anchorNode.select(0);
449             } else {
450               anchorNode.getParentOrThrow().select(0);
451             }
452           }
453         }
454       } else if (event.pointerType === 'touch') {
455         // This is used to update the selection on touch devices when the user clicks on text after a
456         // node selection. See isSelectionChangeFromMouseDown for the inverse
457         const domAnchorNode = domSelection.anchorNode;
458         if (domAnchorNode !== null) {
459           const nodeType = domAnchorNode.nodeType;
460           // If the user is attempting to click selection back onto text, then
461           // we should attempt create a range selection.
462           // When we click on an empty paragraph node or the end of a paragraph that ends
463           // with an image/poll, the nodeType will be ELEMENT_NODE
464           if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) {
465             const newSelection = $internalCreateRangeSelection(
466               lastSelection,
467               domSelection,
468               editor,
469               event,
470             );
471             $setSelection(newSelection);
472           }
473         }
474       }
475     }
476
477     dispatchCommand(editor, CLICK_COMMAND, event);
478   });
479 }
480
481 function onPointerDown(event: PointerEvent, editor: LexicalEditor) {
482   // TODO implement text drag & drop
483   const target = event.target;
484   const pointerType = event.pointerType;
485   if (target instanceof Node && pointerType !== 'touch') {
486     updateEditor(editor, () => {
487       // Drag & drop should not recompute selection until mouse up; otherwise the initially
488       // selected content is lost.
489       if (!$isSelectionCapturedInDecorator(target)) {
490         isSelectionChangeFromMouseDown = true;
491       }
492     });
493   }
494 }
495
496 function getTargetRange(event: InputEvent): null | StaticRange {
497   if (!event.getTargetRanges) {
498     return null;
499   }
500   const targetRanges = event.getTargetRanges();
501   if (targetRanges.length === 0) {
502     return null;
503   }
504   return targetRanges[0];
505 }
506
507 function $canRemoveText(
508   anchorNode: TextNode | ElementNode,
509   focusNode: TextNode | ElementNode,
510 ): boolean {
511   return (
512     anchorNode !== focusNode ||
513     $isElementNode(anchorNode) ||
514     $isElementNode(focusNode) ||
515     !anchorNode.isToken() ||
516     !focusNode.isToken()
517   );
518 }
519
520 function isPossiblyAndroidKeyPress(timeStamp: number): boolean {
521   return (
522     lastKeyCode === 'MediaLast' &&
523     timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY
524   );
525 }
526
527 function onBeforeInput(event: InputEvent, editor: LexicalEditor): void {
528   const inputType = event.inputType;
529   const targetRange = getTargetRange(event);
530
531   // We let the browser do its own thing for composition.
532   if (
533     inputType === 'deleteCompositionText' ||
534     // If we're pasting in FF, we shouldn't get this event
535     // as the `paste` event should have triggered, unless the
536     // user has dom.event.clipboardevents.enabled disabled in
537     // about:config. In that case, we need to process the
538     // pasted content in the DOM mutation phase.
539     (IS_FIREFOX && isFirefoxClipboardEvents(editor))
540   ) {
541     return;
542   } else if (inputType === 'insertCompositionText') {
543     return;
544   }
545
546   updateEditor(editor, () => {
547     const selection = $getSelection();
548
549     if (inputType === 'deleteContentBackward') {
550       if (selection === null) {
551         // Use previous selection
552         const prevSelection = $getPreviousSelection();
553
554         if (!$isRangeSelection(prevSelection)) {
555           return;
556         }
557
558         $setSelection(prevSelection.clone());
559       }
560
561       if ($isRangeSelection(selection)) {
562         const isSelectionAnchorSameAsFocus =
563           selection.anchor.key === selection.focus.key;
564
565         if (
566           isPossiblyAndroidKeyPress(event.timeStamp) &&
567           editor.isComposing() &&
568           isSelectionAnchorSameAsFocus
569         ) {
570           $setCompositionKey(null);
571           lastKeyDownTimeStamp = 0;
572           // Fixes an Android bug where selection flickers when backspacing
573           setTimeout(() => {
574             updateEditor(editor, () => {
575               $setCompositionKey(null);
576             });
577           }, ANDROID_COMPOSITION_LATENCY);
578           if ($isRangeSelection(selection)) {
579             const anchorNode = selection.anchor.getNode();
580             anchorNode.markDirty();
581             selection.format = anchorNode.getFormat();
582             invariant(
583               $isTextNode(anchorNode),
584               'Anchor node must be a TextNode',
585             );
586             selection.style = anchorNode.getStyle();
587           }
588         } else {
589           $setCompositionKey(null);
590           event.preventDefault();
591           // Chromium Android at the moment seems to ignore the preventDefault
592           // on 'deleteContentBackward' and still deletes the content. Which leads
593           // to multiple deletions. So we let the browser handle the deletion in this case.
594           const selectedNodeText = selection.anchor.getNode().getTextContent();
595           const hasSelectedAllTextInNode =
596             selection.anchor.offset === 0 &&
597             selection.focus.offset === selectedNodeText.length;
598           const shouldLetBrowserHandleDelete =
599             IS_ANDROID_CHROME &&
600             isSelectionAnchorSameAsFocus &&
601             !hasSelectedAllTextInNode;
602           if (!shouldLetBrowserHandleDelete) {
603             dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
604           }
605         }
606         return;
607       }
608     }
609
610     if (!$isRangeSelection(selection)) {
611       return;
612     }
613
614     const data = event.data;
615
616     // This represents the case when two beforeinput events are triggered at the same time (without a
617     // full event loop ending at input). This happens with MacOS with the default keyboard settings,
618     // a combination of autocorrection + autocapitalization.
619     // Having Lexical run everything in controlled mode would fix the issue without additional code
620     // but this would kill the massive performance win from the most common typing event.
621     // Alternatively, when this happens we can prematurely update our EditorState based on the DOM
622     // content, a job that would usually be the input event's responsibility.
623     if (unprocessedBeforeInputData !== null) {
624       $updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData);
625     }
626
627     if (
628       (!selection.dirty || unprocessedBeforeInputData !== null) &&
629       selection.isCollapsed() &&
630       !$isRootNode(selection.anchor.getNode()) &&
631       targetRange !== null
632     ) {
633       selection.applyDOMRange(targetRange);
634     }
635
636     unprocessedBeforeInputData = null;
637
638     const anchor = selection.anchor;
639     const focus = selection.focus;
640     const anchorNode = anchor.getNode();
641     const focusNode = focus.getNode();
642
643     if (inputType === 'insertText' || inputType === 'insertTranspose') {
644       if (data === '\n') {
645         event.preventDefault();
646         dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
647       } else if (data === DOUBLE_LINE_BREAK) {
648         event.preventDefault();
649         dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
650       } else if (data == null && event.dataTransfer) {
651         // Gets around a Safari text replacement bug.
652         const text = event.dataTransfer.getData('text/plain');
653         event.preventDefault();
654         selection.insertRawText(text);
655       } else if (
656         data != null &&
657         $shouldPreventDefaultAndInsertText(
658           selection,
659           targetRange,
660           data,
661           event.timeStamp,
662           true,
663         )
664       ) {
665         event.preventDefault();
666         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
667       } else {
668         unprocessedBeforeInputData = data;
669       }
670       lastBeforeInputInsertTextTimeStamp = event.timeStamp;
671       return;
672     }
673
674     // Prevent the browser from carrying out
675     // the input event, so we can control the
676     // output.
677     event.preventDefault();
678
679     switch (inputType) {
680       case 'insertFromYank':
681       case 'insertFromDrop':
682       case 'insertReplacementText': {
683         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
684         break;
685       }
686
687       case 'insertFromComposition': {
688         // This is the end of composition
689         $setCompositionKey(null);
690         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
691         break;
692       }
693
694       case 'insertLineBreak': {
695         // Used for Android
696         $setCompositionKey(null);
697         dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
698         break;
699       }
700
701       case 'insertParagraph': {
702         // Used for Android
703         $setCompositionKey(null);
704
705         // Safari does not provide the type "insertLineBreak".
706         // So instead, we need to infer it from the keyboard event.
707         // We do not apply this logic to iOS to allow newline auto-capitalization
708         // work without creating linebreaks when pressing Enter
709         if (isInsertLineBreak && !IS_IOS) {
710           isInsertLineBreak = false;
711           dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
712         } else {
713           dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
714         }
715
716         break;
717       }
718
719       case 'insertFromPaste':
720       case 'insertFromPasteAsQuotation': {
721         dispatchCommand(editor, PASTE_COMMAND, event);
722         break;
723       }
724
725       case 'deleteByComposition': {
726         if ($canRemoveText(anchorNode, focusNode)) {
727           dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
728         }
729
730         break;
731       }
732
733       case 'deleteByDrag':
734       case 'deleteByCut': {
735         dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
736         break;
737       }
738
739       case 'deleteContent': {
740         dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
741         break;
742       }
743
744       case 'deleteWordBackward': {
745         dispatchCommand(editor, DELETE_WORD_COMMAND, true);
746         break;
747       }
748
749       case 'deleteWordForward': {
750         dispatchCommand(editor, DELETE_WORD_COMMAND, false);
751         break;
752       }
753
754       case 'deleteHardLineBackward':
755       case 'deleteSoftLineBackward': {
756         dispatchCommand(editor, DELETE_LINE_COMMAND, true);
757         break;
758       }
759
760       case 'deleteContentForward':
761       case 'deleteHardLineForward':
762       case 'deleteSoftLineForward': {
763         dispatchCommand(editor, DELETE_LINE_COMMAND, false);
764         break;
765       }
766
767       case 'formatStrikeThrough': {
768         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough');
769         break;
770       }
771
772       case 'formatBold': {
773         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
774         break;
775       }
776
777       case 'formatItalic': {
778         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
779         break;
780       }
781
782       case 'formatUnderline': {
783         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
784         break;
785       }
786
787       case 'historyUndo': {
788         dispatchCommand(editor, UNDO_COMMAND, undefined);
789         break;
790       }
791
792       case 'historyRedo': {
793         dispatchCommand(editor, REDO_COMMAND, undefined);
794         break;
795       }
796
797       default:
798       // NO-OP
799     }
800   });
801 }
802
803 function onInput(event: InputEvent, editor: LexicalEditor): void {
804   // We don't want the onInput to bubble, in the case of nested editors.
805   event.stopPropagation();
806   updateEditor(editor, () => {
807     const selection = $getSelection();
808     const data = event.data;
809     const targetRange = getTargetRange(event);
810
811     if (
812       data != null &&
813       $isRangeSelection(selection) &&
814       $shouldPreventDefaultAndInsertText(
815         selection,
816         targetRange,
817         data,
818         event.timeStamp,
819         false,
820       )
821     ) {
822       // Given we're over-riding the default behavior, we will need
823       // to ensure to disable composition before dispatching the
824       // insertText command for when changing the sequence for FF.
825       if (isFirefoxEndingComposition) {
826         $onCompositionEndImpl(editor, data);
827         isFirefoxEndingComposition = false;
828       }
829       const anchor = selection.anchor;
830       const anchorNode = anchor.getNode();
831       const domSelection = getDOMSelection(editor._window);
832       if (domSelection === null) {
833         return;
834       }
835       const isBackward = selection.isBackward();
836       const startOffset = isBackward
837         ? selection.anchor.offset
838         : selection.focus.offset;
839       const endOffset = isBackward
840         ? selection.focus.offset
841         : selection.anchor.offset;
842       // If the content is the same as inserted, then don't dispatch an insertion.
843       // Given onInput doesn't take the current selection (it uses the previous)
844       // we can compare that against what the DOM currently says.
845       if (
846         !CAN_USE_BEFORE_INPUT ||
847         selection.isCollapsed() ||
848         !$isTextNode(anchorNode) ||
849         domSelection.anchorNode === null ||
850         anchorNode.getTextContent().slice(0, startOffset) +
851           data +
852           anchorNode.getTextContent().slice(startOffset + endOffset) !==
853           getAnchorTextFromDOM(domSelection.anchorNode)
854       ) {
855         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
856       }
857
858       const textLength = data.length;
859
860       // Another hack for FF, as it's possible that the IME is still
861       // open, even though compositionend has already fired (sigh).
862       if (
863         IS_FIREFOX &&
864         textLength > 1 &&
865         event.inputType === 'insertCompositionText' &&
866         !editor.isComposing()
867       ) {
868         selection.anchor.offset -= textLength;
869       }
870
871       // This ensures consistency on Android.
872       if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) {
873         lastKeyDownTimeStamp = 0;
874         $setCompositionKey(null);
875       }
876     } else {
877       const characterData = data !== null ? data : undefined;
878       $updateSelectedTextFromDOM(false, editor, characterData);
879
880       // onInput always fires after onCompositionEnd for FF.
881       if (isFirefoxEndingComposition) {
882         $onCompositionEndImpl(editor, data || undefined);
883         isFirefoxEndingComposition = false;
884       }
885     }
886
887     // Also flush any other mutations that might have occurred
888     // since the change.
889     $flushMutations();
890   });
891   unprocessedBeforeInputData = null;
892 }
893
894 function onCompositionStart(
895   event: CompositionEvent,
896   editor: LexicalEditor,
897 ): void {
898   updateEditor(editor, () => {
899     const selection = $getSelection();
900
901     if ($isRangeSelection(selection) && !editor.isComposing()) {
902       const anchor = selection.anchor;
903       const node = selection.anchor.getNode();
904       $setCompositionKey(anchor.key);
905
906       if (
907         // If it has been 30ms since the last keydown, then we should
908         // apply the empty space heuristic. We can't do this for Safari,
909         // as the keydown fires after composition start.
910         event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
911         // FF has issues around composing multibyte characters, so we also
912         // need to invoke the empty space heuristic below.
913         anchor.type === 'element' ||
914         !selection.isCollapsed() ||
915         node.getFormat() !== selection.format ||
916         ($isTextNode(node) && node.getStyle() !== selection.style)
917       ) {
918         // We insert a zero width character, ready for the composition
919         // to get inserted into the new node we create. If
920         // we don't do this, Safari will fail on us because
921         // there is no text node matching the selection.
922         dispatchCommand(
923           editor,
924           CONTROLLED_TEXT_INSERTION_COMMAND,
925           COMPOSITION_START_CHAR,
926         );
927       }
928     }
929   });
930 }
931
932 function $onCompositionEndImpl(editor: LexicalEditor, data?: string): void {
933   const compositionKey = editor._compositionKey;
934   $setCompositionKey(null);
935
936   // Handle termination of composition.
937   if (compositionKey !== null && data != null) {
938     // Composition can sometimes move to an adjacent DOM node when backspacing.
939     // So check for the empty case.
940     if (data === '') {
941       const node = $getNodeByKey(compositionKey);
942       const textNode = getDOMTextNode(editor.getElementByKey(compositionKey));
943
944       if (
945         textNode !== null &&
946         textNode.nodeValue !== null &&
947         $isTextNode(node)
948       ) {
949         $updateTextNodeFromDOMContent(
950           node,
951           textNode.nodeValue,
952           null,
953           null,
954           true,
955         );
956       }
957
958       return;
959     }
960
961     // Composition can sometimes be that of a new line. In which case, we need to
962     // handle that accordingly.
963     if (data[data.length - 1] === '\n') {
964       const selection = $getSelection();
965
966       if ($isRangeSelection(selection)) {
967         // If the last character is a line break, we also need to insert
968         // a line break.
969         const focus = selection.focus;
970         selection.anchor.set(focus.key, focus.offset, focus.type);
971         dispatchCommand(editor, KEY_ENTER_COMMAND, null);
972         return;
973       }
974     }
975   }
976
977   $updateSelectedTextFromDOM(true, editor, data);
978 }
979
980 function onCompositionEnd(
981   event: CompositionEvent,
982   editor: LexicalEditor,
983 ): void {
984   // Firefox fires onCompositionEnd before onInput, but Chrome/Webkit,
985   // fire onInput before onCompositionEnd. To ensure the sequence works
986   // like Chrome/Webkit we use the isFirefoxEndingComposition flag to
987   // defer handling of onCompositionEnd in Firefox till we have processed
988   // the logic in onInput.
989   if (IS_FIREFOX) {
990     isFirefoxEndingComposition = true;
991   } else {
992     updateEditor(editor, () => {
993       $onCompositionEndImpl(editor, event.data);
994     });
995   }
996 }
997
998 function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
999   lastKeyDownTimeStamp = event.timeStamp;
1000   lastKeyCode = event.key;
1001   if (editor.isComposing()) {
1002     return;
1003   }
1004
1005   const {key, shiftKey, ctrlKey, metaKey, altKey} = event;
1006
1007   if (dispatchCommand(editor, KEY_DOWN_COMMAND, event)) {
1008     return;
1009   }
1010
1011   if (key == null) {
1012     return;
1013   }
1014
1015   if (isMoveForward(key, ctrlKey, altKey, metaKey)) {
1016     dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event);
1017   } else if (isMoveToEnd(key, ctrlKey, shiftKey, altKey, metaKey)) {
1018     dispatchCommand(editor, MOVE_TO_END, event);
1019   } else if (isMoveBackward(key, ctrlKey, altKey, metaKey)) {
1020     dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event);
1021   } else if (isMoveToStart(key, ctrlKey, shiftKey, altKey, metaKey)) {
1022     dispatchCommand(editor, MOVE_TO_START, event);
1023   } else if (isMoveUp(key, ctrlKey, metaKey)) {
1024     dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event);
1025   } else if (isMoveDown(key, ctrlKey, metaKey)) {
1026     dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event);
1027   } else if (isLineBreak(key, shiftKey)) {
1028     isInsertLineBreak = true;
1029     dispatchCommand(editor, KEY_ENTER_COMMAND, event);
1030   } else if (isSpace(key)) {
1031     dispatchCommand(editor, KEY_SPACE_COMMAND, event);
1032   } else if (isOpenLineBreak(key, ctrlKey)) {
1033     event.preventDefault();
1034     isInsertLineBreak = true;
1035     dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
1036   } else if (isParagraph(key, shiftKey)) {
1037     isInsertLineBreak = false;
1038     dispatchCommand(editor, KEY_ENTER_COMMAND, event);
1039   } else if (isDeleteBackward(key, altKey, metaKey, ctrlKey)) {
1040     if (isBackspace(key)) {
1041       dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event);
1042     } else {
1043       event.preventDefault();
1044       dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
1045     }
1046   } else if (isEscape(key)) {
1047     dispatchCommand(editor, KEY_ESCAPE_COMMAND, event);
1048   } else if (isDeleteForward(key, ctrlKey, shiftKey, altKey, metaKey)) {
1049     if (isDelete(key)) {
1050       dispatchCommand(editor, KEY_DELETE_COMMAND, event);
1051     } else {
1052       event.preventDefault();
1053       dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
1054     }
1055   } else if (isDeleteWordBackward(key, altKey, ctrlKey)) {
1056     event.preventDefault();
1057     dispatchCommand(editor, DELETE_WORD_COMMAND, true);
1058   } else if (isDeleteWordForward(key, altKey, ctrlKey)) {
1059     event.preventDefault();
1060     dispatchCommand(editor, DELETE_WORD_COMMAND, false);
1061   } else if (isDeleteLineBackward(key, metaKey)) {
1062     event.preventDefault();
1063     dispatchCommand(editor, DELETE_LINE_COMMAND, true);
1064   } else if (isDeleteLineForward(key, metaKey)) {
1065     event.preventDefault();
1066     dispatchCommand(editor, DELETE_LINE_COMMAND, false);
1067   } else if (isBold(key, altKey, metaKey, ctrlKey)) {
1068     event.preventDefault();
1069     dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
1070   } else if (isUnderline(key, altKey, metaKey, ctrlKey)) {
1071     event.preventDefault();
1072     dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
1073   } else if (isItalic(key, altKey, metaKey, ctrlKey)) {
1074     event.preventDefault();
1075     dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
1076   } else if (isTab(key, altKey, ctrlKey, metaKey)) {
1077     dispatchCommand(editor, KEY_TAB_COMMAND, event);
1078   } else if (isUndo(key, shiftKey, metaKey, ctrlKey)) {
1079     event.preventDefault();
1080     dispatchCommand(editor, UNDO_COMMAND, undefined);
1081   } else if (isRedo(key, shiftKey, metaKey, ctrlKey)) {
1082     event.preventDefault();
1083     dispatchCommand(editor, REDO_COMMAND, undefined);
1084   } else {
1085     const prevSelection = editor._editorState._selection;
1086     if ($isNodeSelection(prevSelection)) {
1087       if (isCopy(key, shiftKey, metaKey, ctrlKey)) {
1088         event.preventDefault();
1089         dispatchCommand(editor, COPY_COMMAND, event);
1090       } else if (isCut(key, shiftKey, metaKey, ctrlKey)) {
1091         event.preventDefault();
1092         dispatchCommand(editor, CUT_COMMAND, event);
1093       } else if (isSelectAll(key, metaKey, ctrlKey)) {
1094         event.preventDefault();
1095         dispatchCommand(editor, SELECT_ALL_COMMAND, event);
1096       }
1097       // FF does it well (no need to override behavior)
1098     } else if (!IS_FIREFOX && isSelectAll(key, metaKey, ctrlKey)) {
1099       event.preventDefault();
1100       dispatchCommand(editor, SELECT_ALL_COMMAND, event);
1101     }
1102   }
1103
1104   if (isModifier(ctrlKey, shiftKey, altKey, metaKey)) {
1105     dispatchCommand(editor, KEY_MODIFIER_COMMAND, event);
1106   }
1107 }
1108
1109 function getRootElementRemoveHandles(
1110   rootElement: HTMLElement,
1111 ): RootElementRemoveHandles {
1112   // @ts-expect-error: internal field
1113   let eventHandles = rootElement.__lexicalEventHandles;
1114
1115   if (eventHandles === undefined) {
1116     eventHandles = [];
1117     // @ts-expect-error: internal field
1118     rootElement.__lexicalEventHandles = eventHandles;
1119   }
1120
1121   return eventHandles;
1122 }
1123
1124 // Mapping root editors to their active nested editors, contains nested editors
1125 // mapping only, so if root editor is selected map will have no reference to free up memory
1126 const activeNestedEditorsMap: Map<string, LexicalEditor> = new Map();
1127
1128 function onDocumentSelectionChange(event: Event): void {
1129   const target = event.target as null | Element | Document;
1130   const targetWindow =
1131     target == null
1132       ? null
1133       : target.nodeType === 9
1134       ? (target as Document).defaultView
1135       : (target as Element).ownerDocument.defaultView;
1136   const domSelection = getDOMSelection(targetWindow);
1137   if (domSelection === null) {
1138     return;
1139   }
1140   const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode);
1141   if (nextActiveEditor === null) {
1142     return;
1143   }
1144
1145   if (isSelectionChangeFromMouseDown) {
1146     isSelectionChangeFromMouseDown = false;
1147     updateEditor(nextActiveEditor, () => {
1148       const lastSelection = $getPreviousSelection();
1149       const domAnchorNode = domSelection.anchorNode;
1150       if (domAnchorNode === null) {
1151         return;
1152       }
1153       const nodeType = domAnchorNode.nodeType;
1154       // If the user is attempting to click selection back onto text, then
1155       // we should attempt create a range selection.
1156       // When we click on an empty paragraph node or the end of a paragraph that ends
1157       // with an image/poll, the nodeType will be ELEMENT_NODE
1158       if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) {
1159         return;
1160       }
1161       const newSelection = $internalCreateRangeSelection(
1162         lastSelection,
1163         domSelection,
1164         nextActiveEditor,
1165         event,
1166       );
1167       $setSelection(newSelection);
1168     });
1169   }
1170
1171   // When editor receives selection change event, we're checking if
1172   // it has any sibling editors (within same parent editor) that were active
1173   // before, and trigger selection change on it to nullify selection.
1174   const editors = getEditorsToPropagate(nextActiveEditor);
1175   const rootEditor = editors[editors.length - 1];
1176   const rootEditorKey = rootEditor._key;
1177   const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey);
1178   const prevActiveEditor = activeNestedEditor || rootEditor;
1179
1180   if (prevActiveEditor !== nextActiveEditor) {
1181     onSelectionChange(domSelection, prevActiveEditor, false);
1182   }
1183
1184   onSelectionChange(domSelection, nextActiveEditor, true);
1185
1186   // If newly selected editor is nested, then add it to the map, clean map otherwise
1187   if (nextActiveEditor !== rootEditor) {
1188     activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor);
1189   } else if (activeNestedEditor) {
1190     activeNestedEditorsMap.delete(rootEditorKey);
1191   }
1192 }
1193
1194 function stopLexicalPropagation(event: Event): void {
1195   // We attach a special property to ensure the same event doesn't re-fire
1196   // for parent editors.
1197   // @ts-ignore
1198   event._lexicalHandled = true;
1199 }
1200
1201 function hasStoppedLexicalPropagation(event: Event): boolean {
1202   // @ts-ignore
1203   const stopped = event._lexicalHandled === true;
1204   return stopped;
1205 }
1206
1207 export type EventHandler = (event: Event, editor: LexicalEditor) => void;
1208
1209 export function addRootElementEvents(
1210   rootElement: HTMLElement,
1211   editor: LexicalEditor,
1212 ): void {
1213   // We only want to have a single global selectionchange event handler, shared
1214   // between all editor instances.
1215   const doc = rootElement.ownerDocument;
1216   const documentRootElementsCount = rootElementsRegistered.get(doc);
1217   if (
1218     documentRootElementsCount === undefined ||
1219     documentRootElementsCount < 1
1220   ) {
1221     doc.addEventListener('selectionchange', onDocumentSelectionChange);
1222   }
1223   rootElementsRegistered.set(doc, (documentRootElementsCount || 0) + 1);
1224
1225   // @ts-expect-error: internal field
1226   rootElement.__lexicalEditor = editor;
1227   const removeHandles = getRootElementRemoveHandles(rootElement);
1228
1229   for (let i = 0; i < rootElementEvents.length; i++) {
1230     const [eventName, onEvent] = rootElementEvents[i];
1231     const eventHandler =
1232       typeof onEvent === 'function'
1233         ? (event: Event) => {
1234             if (hasStoppedLexicalPropagation(event)) {
1235               return;
1236             }
1237             stopLexicalPropagation(event);
1238             if (editor.isEditable() || eventName === 'click') {
1239               onEvent(event, editor);
1240             }
1241           }
1242         : (event: Event) => {
1243             if (hasStoppedLexicalPropagation(event)) {
1244               return;
1245             }
1246             stopLexicalPropagation(event);
1247             const isEditable = editor.isEditable();
1248             switch (eventName) {
1249               case 'cut':
1250                 return (
1251                   isEditable &&
1252                   dispatchCommand(editor, CUT_COMMAND, event as ClipboardEvent)
1253                 );
1254
1255               case 'copy':
1256                 return dispatchCommand(
1257                   editor,
1258                   COPY_COMMAND,
1259                   event as ClipboardEvent,
1260                 );
1261
1262               case 'paste':
1263                 return (
1264                   isEditable &&
1265                   dispatchCommand(
1266                     editor,
1267                     PASTE_COMMAND,
1268                     event as ClipboardEvent,
1269                   )
1270                 );
1271
1272               case 'dragstart':
1273                 return (
1274                   isEditable &&
1275                   dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent)
1276                 );
1277
1278               case 'dragover':
1279                 return (
1280                   isEditable &&
1281                   dispatchCommand(editor, DRAGOVER_COMMAND, event as DragEvent)
1282                 );
1283
1284               case 'dragend':
1285                 return (
1286                   isEditable &&
1287                   dispatchCommand(editor, DRAGEND_COMMAND, event as DragEvent)
1288                 );
1289
1290               case 'focus':
1291                 return (
1292                   isEditable &&
1293                   dispatchCommand(editor, FOCUS_COMMAND, event as FocusEvent)
1294                 );
1295
1296               case 'blur': {
1297                 return (
1298                   isEditable &&
1299                   dispatchCommand(editor, BLUR_COMMAND, event as FocusEvent)
1300                 );
1301               }
1302
1303               case 'drop':
1304                 return (
1305                   isEditable &&
1306                   dispatchCommand(editor, DROP_COMMAND, event as DragEvent)
1307                 );
1308             }
1309           };
1310     rootElement.addEventListener(eventName, eventHandler);
1311     removeHandles.push(() => {
1312       rootElement.removeEventListener(eventName, eventHandler);
1313     });
1314   }
1315 }
1316
1317 export function removeRootElementEvents(rootElement: HTMLElement): void {
1318   const doc = rootElement.ownerDocument;
1319   const documentRootElementsCount = rootElementsRegistered.get(doc);
1320   invariant(
1321     documentRootElementsCount !== undefined,
1322     'Root element not registered',
1323   );
1324
1325   // We only want to have a single global selectionchange event handler, shared
1326   // between all editor instances.
1327   const newCount = documentRootElementsCount - 1;
1328   invariant(newCount >= 0, 'Root element count less than 0');
1329   rootElementsRegistered.set(doc, newCount);
1330   if (newCount === 0) {
1331     doc.removeEventListener('selectionchange', onDocumentSelectionChange);
1332   }
1333
1334   const editor = getEditorPropertyFromDOMNode(rootElement);
1335
1336   if (isLexicalEditor(editor)) {
1337     cleanActiveNestedEditorsMap(editor);
1338     // @ts-expect-error: internal field
1339     rootElement.__lexicalEditor = null;
1340   } else if (editor) {
1341     invariant(
1342       false,
1343       'Attempted to remove event handlers from a node that does not belong to this build of Lexical',
1344     );
1345   }
1346
1347   const removeHandles = getRootElementRemoveHandles(rootElement);
1348
1349   for (let i = 0; i < removeHandles.length; i++) {
1350     removeHandles[i]();
1351   }
1352
1353   // @ts-expect-error: internal field
1354   rootElement.__lexicalEventHandles = [];
1355 }
1356
1357 function cleanActiveNestedEditorsMap(editor: LexicalEditor) {
1358   if (editor._parentEditor !== null) {
1359     // For nested editor cleanup map if this editor was marked as active
1360     const editors = getEditorsToPropagate(editor);
1361     const rootEditor = editors[editors.length - 1];
1362     const rootEditorKey = rootEditor._key;
1363
1364     if (activeNestedEditorsMap.get(rootEditorKey) === editor) {
1365       activeNestedEditorsMap.delete(rootEditorKey);
1366     }
1367   } else {
1368     // For top-level editors cleanup map
1369     activeNestedEditorsMap.delete(editor._key);
1370   }
1371 }
1372
1373 export function markSelectionChangeFromDOMUpdate(): void {
1374   isSelectionChangeFromDOMUpdate = true;
1375 }
1376
1377 export function markCollapsedSelectionFormat(
1378   format: number,
1379   style: string,
1380   offset: number,
1381   key: NodeKey,
1382   timeStamp: number,
1383 ): void {
1384   collapsedSelectionFormat = [format, style, offset, key, timeStamp];
1385 }