]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/LexicalEvents.ts
Lexical: Merged custom paragraph node, removed old format/indent refs
[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.style = lastNode.getTextStyle();
359             } else {
360               selection.format = 0;
361             }
362           }
363         }
364       } else {
365         const anchorKey = anchor.key;
366         const focus = selection.focus;
367         const focusKey = focus.key;
368         const nodes = selection.getNodes();
369         const nodesLength = nodes.length;
370         const isBackward = selection.isBackward();
371         const startOffset = isBackward ? focusOffset : anchorOffset;
372         const endOffset = isBackward ? anchorOffset : focusOffset;
373         const startKey = isBackward ? focusKey : anchorKey;
374         const endKey = isBackward ? anchorKey : focusKey;
375         let combinedFormat = IS_ALL_FORMATTING;
376         let hasTextNodes = false;
377         for (let i = 0; i < nodesLength; i++) {
378           const node = nodes[i];
379           const textContentSize = node.getTextContentSize();
380           if (
381             $isTextNode(node) &&
382             textContentSize !== 0 &&
383             // Exclude empty text nodes at boundaries resulting from user's selection
384             !(
385               (i === 0 &&
386                 node.__key === startKey &&
387                 startOffset === textContentSize) ||
388               (i === nodesLength - 1 &&
389                 node.__key === endKey &&
390                 endOffset === 0)
391             )
392           ) {
393             // TODO: what about style?
394             hasTextNodes = true;
395             combinedFormat &= node.getFormat();
396             if (combinedFormat === 0) {
397               break;
398             }
399           }
400         }
401
402         selection.format = hasTextNodes ? combinedFormat : 0;
403       }
404     }
405
406     dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined);
407   });
408 }
409
410 // This is a work-around is mainly Chrome specific bug where if you select
411 // the contents of an empty block, you cannot easily unselect anything.
412 // This results in a tiny selection box that looks buggy/broken. This can
413 // also help other browsers when selection might "appear" lost, when it
414 // really isn't.
415 function onClick(event: PointerEvent, editor: LexicalEditor): void {
416   updateEditor(editor, () => {
417     const selection = $getSelection();
418     const domSelection = getDOMSelection(editor._window);
419     const lastSelection = $getPreviousSelection();
420
421     if (domSelection) {
422       if ($isRangeSelection(selection)) {
423         const anchor = selection.anchor;
424         const anchorNode = anchor.getNode();
425
426         if (
427           anchor.type === 'element' &&
428           anchor.offset === 0 &&
429           selection.isCollapsed() &&
430           !$isRootNode(anchorNode) &&
431           $getRoot().getChildrenSize() === 1 &&
432           anchorNode.getTopLevelElementOrThrow().isEmpty() &&
433           lastSelection !== null &&
434           selection.is(lastSelection)
435         ) {
436           domSelection.removeAllRanges();
437           selection.dirty = true;
438         } else if (event.detail === 3 && !selection.isCollapsed()) {
439           // Tripple click causing selection to overflow into the nearest element. In that
440           // case visually it looks like a single element content is selected, focus node
441           // is actually at the beginning of the next element (if present) and any manipulations
442           // with selection (formatting) are affecting second element as well
443           const focus = selection.focus;
444           const focusNode = focus.getNode();
445           if (anchorNode !== focusNode) {
446             if ($isElementNode(anchorNode)) {
447               anchorNode.select(0);
448             } else {
449               anchorNode.getParentOrThrow().select(0);
450             }
451           }
452         }
453       } else if (event.pointerType === 'touch') {
454         // This is used to update the selection on touch devices when the user clicks on text after a
455         // node selection. See isSelectionChangeFromMouseDown for the inverse
456         const domAnchorNode = domSelection.anchorNode;
457         if (domAnchorNode !== null) {
458           const nodeType = domAnchorNode.nodeType;
459           // If the user is attempting to click selection back onto text, then
460           // we should attempt create a range selection.
461           // When we click on an empty paragraph node or the end of a paragraph that ends
462           // with an image/poll, the nodeType will be ELEMENT_NODE
463           if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) {
464             const newSelection = $internalCreateRangeSelection(
465               lastSelection,
466               domSelection,
467               editor,
468               event,
469             );
470             $setSelection(newSelection);
471           }
472         }
473       }
474     }
475
476     dispatchCommand(editor, CLICK_COMMAND, event);
477   });
478 }
479
480 function onPointerDown(event: PointerEvent, editor: LexicalEditor) {
481   // TODO implement text drag & drop
482   const target = event.target;
483   const pointerType = event.pointerType;
484   if (target instanceof Node && pointerType !== 'touch') {
485     updateEditor(editor, () => {
486       // Drag & drop should not recompute selection until mouse up; otherwise the initially
487       // selected content is lost.
488       if (!$isSelectionCapturedInDecorator(target)) {
489         isSelectionChangeFromMouseDown = true;
490       }
491     });
492   }
493 }
494
495 function getTargetRange(event: InputEvent): null | StaticRange {
496   if (!event.getTargetRanges) {
497     return null;
498   }
499   const targetRanges = event.getTargetRanges();
500   if (targetRanges.length === 0) {
501     return null;
502   }
503   return targetRanges[0];
504 }
505
506 function $canRemoveText(
507   anchorNode: TextNode | ElementNode,
508   focusNode: TextNode | ElementNode,
509 ): boolean {
510   return (
511     anchorNode !== focusNode ||
512     $isElementNode(anchorNode) ||
513     $isElementNode(focusNode) ||
514     !anchorNode.isToken() ||
515     !focusNode.isToken()
516   );
517 }
518
519 function isPossiblyAndroidKeyPress(timeStamp: number): boolean {
520   return (
521     lastKeyCode === 'MediaLast' &&
522     timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY
523   );
524 }
525
526 function onBeforeInput(event: InputEvent, editor: LexicalEditor): void {
527   const inputType = event.inputType;
528   const targetRange = getTargetRange(event);
529
530   // We let the browser do its own thing for composition.
531   if (
532     inputType === 'deleteCompositionText' ||
533     // If we're pasting in FF, we shouldn't get this event
534     // as the `paste` event should have triggered, unless the
535     // user has dom.event.clipboardevents.enabled disabled in
536     // about:config. In that case, we need to process the
537     // pasted content in the DOM mutation phase.
538     (IS_FIREFOX && isFirefoxClipboardEvents(editor))
539   ) {
540     return;
541   } else if (inputType === 'insertCompositionText') {
542     return;
543   }
544
545   updateEditor(editor, () => {
546     const selection = $getSelection();
547
548     if (inputType === 'deleteContentBackward') {
549       if (selection === null) {
550         // Use previous selection
551         const prevSelection = $getPreviousSelection();
552
553         if (!$isRangeSelection(prevSelection)) {
554           return;
555         }
556
557         $setSelection(prevSelection.clone());
558       }
559
560       if ($isRangeSelection(selection)) {
561         const isSelectionAnchorSameAsFocus =
562           selection.anchor.key === selection.focus.key;
563
564         if (
565           isPossiblyAndroidKeyPress(event.timeStamp) &&
566           editor.isComposing() &&
567           isSelectionAnchorSameAsFocus
568         ) {
569           $setCompositionKey(null);
570           lastKeyDownTimeStamp = 0;
571           // Fixes an Android bug where selection flickers when backspacing
572           setTimeout(() => {
573             updateEditor(editor, () => {
574               $setCompositionKey(null);
575             });
576           }, ANDROID_COMPOSITION_LATENCY);
577           if ($isRangeSelection(selection)) {
578             const anchorNode = selection.anchor.getNode();
579             anchorNode.markDirty();
580             invariant(
581               $isTextNode(anchorNode),
582               'Anchor node must be a TextNode',
583             );
584             selection.style = anchorNode.getStyle();
585           }
586         } else {
587           $setCompositionKey(null);
588           event.preventDefault();
589           // Chromium Android at the moment seems to ignore the preventDefault
590           // on 'deleteContentBackward' and still deletes the content. Which leads
591           // to multiple deletions. So we let the browser handle the deletion in this case.
592           const selectedNodeText = selection.anchor.getNode().getTextContent();
593           const hasSelectedAllTextInNode =
594             selection.anchor.offset === 0 &&
595             selection.focus.offset === selectedNodeText.length;
596           const shouldLetBrowserHandleDelete =
597             IS_ANDROID_CHROME &&
598             isSelectionAnchorSameAsFocus &&
599             !hasSelectedAllTextInNode;
600           if (!shouldLetBrowserHandleDelete) {
601             dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
602           }
603         }
604         return;
605       }
606     }
607
608     if (!$isRangeSelection(selection)) {
609       return;
610     }
611
612     const data = event.data;
613
614     // This represents the case when two beforeinput events are triggered at the same time (without a
615     // full event loop ending at input). This happens with MacOS with the default keyboard settings,
616     // a combination of autocorrection + autocapitalization.
617     // Having Lexical run everything in controlled mode would fix the issue without additional code
618     // but this would kill the massive performance win from the most common typing event.
619     // Alternatively, when this happens we can prematurely update our EditorState based on the DOM
620     // content, a job that would usually be the input event's responsibility.
621     if (unprocessedBeforeInputData !== null) {
622       $updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData);
623     }
624
625     if (
626       (!selection.dirty || unprocessedBeforeInputData !== null) &&
627       selection.isCollapsed() &&
628       !$isRootNode(selection.anchor.getNode()) &&
629       targetRange !== null
630     ) {
631       selection.applyDOMRange(targetRange);
632     }
633
634     unprocessedBeforeInputData = null;
635
636     const anchor = selection.anchor;
637     const focus = selection.focus;
638     const anchorNode = anchor.getNode();
639     const focusNode = focus.getNode();
640
641     if (inputType === 'insertText' || inputType === 'insertTranspose') {
642       if (data === '\n') {
643         event.preventDefault();
644         dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
645       } else if (data === DOUBLE_LINE_BREAK) {
646         event.preventDefault();
647         dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
648       } else if (data == null && event.dataTransfer) {
649         // Gets around a Safari text replacement bug.
650         const text = event.dataTransfer.getData('text/plain');
651         event.preventDefault();
652         selection.insertRawText(text);
653       } else if (
654         data != null &&
655         $shouldPreventDefaultAndInsertText(
656           selection,
657           targetRange,
658           data,
659           event.timeStamp,
660           true,
661         )
662       ) {
663         event.preventDefault();
664         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
665       } else {
666         unprocessedBeforeInputData = data;
667       }
668       lastBeforeInputInsertTextTimeStamp = event.timeStamp;
669       return;
670     }
671
672     // Prevent the browser from carrying out
673     // the input event, so we can control the
674     // output.
675     event.preventDefault();
676
677     switch (inputType) {
678       case 'insertFromYank':
679       case 'insertFromDrop':
680       case 'insertReplacementText': {
681         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
682         break;
683       }
684
685       case 'insertFromComposition': {
686         // This is the end of composition
687         $setCompositionKey(null);
688         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
689         break;
690       }
691
692       case 'insertLineBreak': {
693         // Used for Android
694         $setCompositionKey(null);
695         dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
696         break;
697       }
698
699       case 'insertParagraph': {
700         // Used for Android
701         $setCompositionKey(null);
702
703         // Safari does not provide the type "insertLineBreak".
704         // So instead, we need to infer it from the keyboard event.
705         // We do not apply this logic to iOS to allow newline auto-capitalization
706         // work without creating linebreaks when pressing Enter
707         if (isInsertLineBreak && !IS_IOS) {
708           isInsertLineBreak = false;
709           dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
710         } else {
711           dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
712         }
713
714         break;
715       }
716
717       case 'insertFromPaste':
718       case 'insertFromPasteAsQuotation': {
719         dispatchCommand(editor, PASTE_COMMAND, event);
720         break;
721       }
722
723       case 'deleteByComposition': {
724         if ($canRemoveText(anchorNode, focusNode)) {
725           dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
726         }
727
728         break;
729       }
730
731       case 'deleteByDrag':
732       case 'deleteByCut': {
733         dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
734         break;
735       }
736
737       case 'deleteContent': {
738         dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
739         break;
740       }
741
742       case 'deleteWordBackward': {
743         dispatchCommand(editor, DELETE_WORD_COMMAND, true);
744         break;
745       }
746
747       case 'deleteWordForward': {
748         dispatchCommand(editor, DELETE_WORD_COMMAND, false);
749         break;
750       }
751
752       case 'deleteHardLineBackward':
753       case 'deleteSoftLineBackward': {
754         dispatchCommand(editor, DELETE_LINE_COMMAND, true);
755         break;
756       }
757
758       case 'deleteContentForward':
759       case 'deleteHardLineForward':
760       case 'deleteSoftLineForward': {
761         dispatchCommand(editor, DELETE_LINE_COMMAND, false);
762         break;
763       }
764
765       case 'formatStrikeThrough': {
766         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough');
767         break;
768       }
769
770       case 'formatBold': {
771         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
772         break;
773       }
774
775       case 'formatItalic': {
776         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
777         break;
778       }
779
780       case 'formatUnderline': {
781         dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
782         break;
783       }
784
785       case 'historyUndo': {
786         dispatchCommand(editor, UNDO_COMMAND, undefined);
787         break;
788       }
789
790       case 'historyRedo': {
791         dispatchCommand(editor, REDO_COMMAND, undefined);
792         break;
793       }
794
795       default:
796       // NO-OP
797     }
798   });
799 }
800
801 function onInput(event: InputEvent, editor: LexicalEditor): void {
802   // We don't want the onInput to bubble, in the case of nested editors.
803   event.stopPropagation();
804   updateEditor(editor, () => {
805     const selection = $getSelection();
806     const data = event.data;
807     const targetRange = getTargetRange(event);
808
809     if (
810       data != null &&
811       $isRangeSelection(selection) &&
812       $shouldPreventDefaultAndInsertText(
813         selection,
814         targetRange,
815         data,
816         event.timeStamp,
817         false,
818       )
819     ) {
820       // Given we're over-riding the default behavior, we will need
821       // to ensure to disable composition before dispatching the
822       // insertText command for when changing the sequence for FF.
823       if (isFirefoxEndingComposition) {
824         $onCompositionEndImpl(editor, data);
825         isFirefoxEndingComposition = false;
826       }
827       const anchor = selection.anchor;
828       const anchorNode = anchor.getNode();
829       const domSelection = getDOMSelection(editor._window);
830       if (domSelection === null) {
831         return;
832       }
833       const isBackward = selection.isBackward();
834       const startOffset = isBackward
835         ? selection.anchor.offset
836         : selection.focus.offset;
837       const endOffset = isBackward
838         ? selection.focus.offset
839         : selection.anchor.offset;
840       // If the content is the same as inserted, then don't dispatch an insertion.
841       // Given onInput doesn't take the current selection (it uses the previous)
842       // we can compare that against what the DOM currently says.
843       if (
844         !CAN_USE_BEFORE_INPUT ||
845         selection.isCollapsed() ||
846         !$isTextNode(anchorNode) ||
847         domSelection.anchorNode === null ||
848         anchorNode.getTextContent().slice(0, startOffset) +
849           data +
850           anchorNode.getTextContent().slice(startOffset + endOffset) !==
851           getAnchorTextFromDOM(domSelection.anchorNode)
852       ) {
853         dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
854       }
855
856       const textLength = data.length;
857
858       // Another hack for FF, as it's possible that the IME is still
859       // open, even though compositionend has already fired (sigh).
860       if (
861         IS_FIREFOX &&
862         textLength > 1 &&
863         event.inputType === 'insertCompositionText' &&
864         !editor.isComposing()
865       ) {
866         selection.anchor.offset -= textLength;
867       }
868
869       // This ensures consistency on Android.
870       if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) {
871         lastKeyDownTimeStamp = 0;
872         $setCompositionKey(null);
873       }
874     } else {
875       const characterData = data !== null ? data : undefined;
876       $updateSelectedTextFromDOM(false, editor, characterData);
877
878       // onInput always fires after onCompositionEnd for FF.
879       if (isFirefoxEndingComposition) {
880         $onCompositionEndImpl(editor, data || undefined);
881         isFirefoxEndingComposition = false;
882       }
883     }
884
885     // Also flush any other mutations that might have occurred
886     // since the change.
887     $flushMutations();
888   });
889   unprocessedBeforeInputData = null;
890 }
891
892 function onCompositionStart(
893   event: CompositionEvent,
894   editor: LexicalEditor,
895 ): void {
896   updateEditor(editor, () => {
897     const selection = $getSelection();
898
899     if ($isRangeSelection(selection) && !editor.isComposing()) {
900       const anchor = selection.anchor;
901       const node = selection.anchor.getNode();
902       $setCompositionKey(anchor.key);
903
904       if (
905         // If it has been 30ms since the last keydown, then we should
906         // apply the empty space heuristic. We can't do this for Safari,
907         // as the keydown fires after composition start.
908         event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
909         // FF has issues around composing multibyte characters, so we also
910         // need to invoke the empty space heuristic below.
911         anchor.type === 'element' ||
912         !selection.isCollapsed() ||
913         ($isTextNode(node) && node.getStyle() !== selection.style)
914       ) {
915         // We insert a zero width character, ready for the composition
916         // to get inserted into the new node we create. If
917         // we don't do this, Safari will fail on us because
918         // there is no text node matching the selection.
919         dispatchCommand(
920           editor,
921           CONTROLLED_TEXT_INSERTION_COMMAND,
922           COMPOSITION_START_CHAR,
923         );
924       }
925     }
926   });
927 }
928
929 function $onCompositionEndImpl(editor: LexicalEditor, data?: string): void {
930   const compositionKey = editor._compositionKey;
931   $setCompositionKey(null);
932
933   // Handle termination of composition.
934   if (compositionKey !== null && data != null) {
935     // Composition can sometimes move to an adjacent DOM node when backspacing.
936     // So check for the empty case.
937     if (data === '') {
938       const node = $getNodeByKey(compositionKey);
939       const textNode = getDOMTextNode(editor.getElementByKey(compositionKey));
940
941       if (
942         textNode !== null &&
943         textNode.nodeValue !== null &&
944         $isTextNode(node)
945       ) {
946         $updateTextNodeFromDOMContent(
947           node,
948           textNode.nodeValue,
949           null,
950           null,
951           true,
952         );
953       }
954
955       return;
956     }
957
958     // Composition can sometimes be that of a new line. In which case, we need to
959     // handle that accordingly.
960     if (data[data.length - 1] === '\n') {
961       const selection = $getSelection();
962
963       if ($isRangeSelection(selection)) {
964         // If the last character is a line break, we also need to insert
965         // a line break.
966         const focus = selection.focus;
967         selection.anchor.set(focus.key, focus.offset, focus.type);
968         dispatchCommand(editor, KEY_ENTER_COMMAND, null);
969         return;
970       }
971     }
972   }
973
974   $updateSelectedTextFromDOM(true, editor, data);
975 }
976
977 function onCompositionEnd(
978   event: CompositionEvent,
979   editor: LexicalEditor,
980 ): void {
981   // Firefox fires onCompositionEnd before onInput, but Chrome/Webkit,
982   // fire onInput before onCompositionEnd. To ensure the sequence works
983   // like Chrome/Webkit we use the isFirefoxEndingComposition flag to
984   // defer handling of onCompositionEnd in Firefox till we have processed
985   // the logic in onInput.
986   if (IS_FIREFOX) {
987     isFirefoxEndingComposition = true;
988   } else {
989     updateEditor(editor, () => {
990       $onCompositionEndImpl(editor, event.data);
991     });
992   }
993 }
994
995 function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
996   lastKeyDownTimeStamp = event.timeStamp;
997   lastKeyCode = event.key;
998   if (editor.isComposing()) {
999     return;
1000   }
1001
1002   const {key, shiftKey, ctrlKey, metaKey, altKey} = event;
1003
1004   if (dispatchCommand(editor, KEY_DOWN_COMMAND, event)) {
1005     return;
1006   }
1007
1008   if (key == null) {
1009     return;
1010   }
1011
1012   if (isMoveForward(key, ctrlKey, altKey, metaKey)) {
1013     dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event);
1014   } else if (isMoveToEnd(key, ctrlKey, shiftKey, altKey, metaKey)) {
1015     dispatchCommand(editor, MOVE_TO_END, event);
1016   } else if (isMoveBackward(key, ctrlKey, altKey, metaKey)) {
1017     dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event);
1018   } else if (isMoveToStart(key, ctrlKey, shiftKey, altKey, metaKey)) {
1019     dispatchCommand(editor, MOVE_TO_START, event);
1020   } else if (isMoveUp(key, ctrlKey, metaKey)) {
1021     dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event);
1022   } else if (isMoveDown(key, ctrlKey, metaKey)) {
1023     dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event);
1024   } else if (isLineBreak(key, shiftKey)) {
1025     isInsertLineBreak = true;
1026     dispatchCommand(editor, KEY_ENTER_COMMAND, event);
1027   } else if (isSpace(key)) {
1028     dispatchCommand(editor, KEY_SPACE_COMMAND, event);
1029   } else if (isOpenLineBreak(key, ctrlKey)) {
1030     event.preventDefault();
1031     isInsertLineBreak = true;
1032     dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
1033   } else if (isParagraph(key, shiftKey)) {
1034     isInsertLineBreak = false;
1035     dispatchCommand(editor, KEY_ENTER_COMMAND, event);
1036   } else if (isDeleteBackward(key, altKey, metaKey, ctrlKey)) {
1037     if (isBackspace(key)) {
1038       dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event);
1039     } else {
1040       event.preventDefault();
1041       dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
1042     }
1043   } else if (isEscape(key)) {
1044     dispatchCommand(editor, KEY_ESCAPE_COMMAND, event);
1045   } else if (isDeleteForward(key, ctrlKey, shiftKey, altKey, metaKey)) {
1046     if (isDelete(key)) {
1047       dispatchCommand(editor, KEY_DELETE_COMMAND, event);
1048     } else {
1049       event.preventDefault();
1050       dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
1051     }
1052   } else if (isDeleteWordBackward(key, altKey, ctrlKey)) {
1053     event.preventDefault();
1054     dispatchCommand(editor, DELETE_WORD_COMMAND, true);
1055   } else if (isDeleteWordForward(key, altKey, ctrlKey)) {
1056     event.preventDefault();
1057     dispatchCommand(editor, DELETE_WORD_COMMAND, false);
1058   } else if (isDeleteLineBackward(key, metaKey)) {
1059     event.preventDefault();
1060     dispatchCommand(editor, DELETE_LINE_COMMAND, true);
1061   } else if (isDeleteLineForward(key, metaKey)) {
1062     event.preventDefault();
1063     dispatchCommand(editor, DELETE_LINE_COMMAND, false);
1064   } else if (isBold(key, altKey, metaKey, ctrlKey)) {
1065     event.preventDefault();
1066     dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
1067   } else if (isUnderline(key, altKey, metaKey, ctrlKey)) {
1068     event.preventDefault();
1069     dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
1070   } else if (isItalic(key, altKey, metaKey, ctrlKey)) {
1071     event.preventDefault();
1072     dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
1073   } else if (isTab(key, altKey, ctrlKey, metaKey)) {
1074     dispatchCommand(editor, KEY_TAB_COMMAND, event);
1075   } else if (isUndo(key, shiftKey, metaKey, ctrlKey)) {
1076     event.preventDefault();
1077     dispatchCommand(editor, UNDO_COMMAND, undefined);
1078   } else if (isRedo(key, shiftKey, metaKey, ctrlKey)) {
1079     event.preventDefault();
1080     dispatchCommand(editor, REDO_COMMAND, undefined);
1081   } else {
1082     const prevSelection = editor._editorState._selection;
1083     if ($isNodeSelection(prevSelection)) {
1084       if (isCopy(key, shiftKey, metaKey, ctrlKey)) {
1085         event.preventDefault();
1086         dispatchCommand(editor, COPY_COMMAND, event);
1087       } else if (isCut(key, shiftKey, metaKey, ctrlKey)) {
1088         event.preventDefault();
1089         dispatchCommand(editor, CUT_COMMAND, event);
1090       } else if (isSelectAll(key, metaKey, ctrlKey)) {
1091         event.preventDefault();
1092         dispatchCommand(editor, SELECT_ALL_COMMAND, event);
1093       }
1094       // FF does it well (no need to override behavior)
1095     } else if (!IS_FIREFOX && isSelectAll(key, metaKey, ctrlKey)) {
1096       event.preventDefault();
1097       dispatchCommand(editor, SELECT_ALL_COMMAND, event);
1098     }
1099   }
1100
1101   if (isModifier(ctrlKey, shiftKey, altKey, metaKey)) {
1102     dispatchCommand(editor, KEY_MODIFIER_COMMAND, event);
1103   }
1104 }
1105
1106 function getRootElementRemoveHandles(
1107   rootElement: HTMLElement,
1108 ): RootElementRemoveHandles {
1109   // @ts-expect-error: internal field
1110   let eventHandles = rootElement.__lexicalEventHandles;
1111
1112   if (eventHandles === undefined) {
1113     eventHandles = [];
1114     // @ts-expect-error: internal field
1115     rootElement.__lexicalEventHandles = eventHandles;
1116   }
1117
1118   return eventHandles;
1119 }
1120
1121 // Mapping root editors to their active nested editors, contains nested editors
1122 // mapping only, so if root editor is selected map will have no reference to free up memory
1123 const activeNestedEditorsMap: Map<string, LexicalEditor> = new Map();
1124
1125 function onDocumentSelectionChange(event: Event): void {
1126   const target = event.target as null | Element | Document;
1127   const targetWindow =
1128     target == null
1129       ? null
1130       : target.nodeType === 9
1131       ? (target as Document).defaultView
1132       : (target as Element).ownerDocument.defaultView;
1133   const domSelection = getDOMSelection(targetWindow);
1134   if (domSelection === null) {
1135     return;
1136   }
1137   const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode);
1138   if (nextActiveEditor === null) {
1139     return;
1140   }
1141
1142   if (isSelectionChangeFromMouseDown) {
1143     isSelectionChangeFromMouseDown = false;
1144     updateEditor(nextActiveEditor, () => {
1145       const lastSelection = $getPreviousSelection();
1146       const domAnchorNode = domSelection.anchorNode;
1147       if (domAnchorNode === null) {
1148         return;
1149       }
1150       const nodeType = domAnchorNode.nodeType;
1151       // If the user is attempting to click selection back onto text, then
1152       // we should attempt create a range selection.
1153       // When we click on an empty paragraph node or the end of a paragraph that ends
1154       // with an image/poll, the nodeType will be ELEMENT_NODE
1155       if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) {
1156         return;
1157       }
1158       const newSelection = $internalCreateRangeSelection(
1159         lastSelection,
1160         domSelection,
1161         nextActiveEditor,
1162         event,
1163       );
1164       $setSelection(newSelection);
1165     });
1166   }
1167
1168   // When editor receives selection change event, we're checking if
1169   // it has any sibling editors (within same parent editor) that were active
1170   // before, and trigger selection change on it to nullify selection.
1171   const editors = getEditorsToPropagate(nextActiveEditor);
1172   const rootEditor = editors[editors.length - 1];
1173   const rootEditorKey = rootEditor._key;
1174   const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey);
1175   const prevActiveEditor = activeNestedEditor || rootEditor;
1176
1177   if (prevActiveEditor !== nextActiveEditor) {
1178     onSelectionChange(domSelection, prevActiveEditor, false);
1179   }
1180
1181   onSelectionChange(domSelection, nextActiveEditor, true);
1182
1183   // If newly selected editor is nested, then add it to the map, clean map otherwise
1184   if (nextActiveEditor !== rootEditor) {
1185     activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor);
1186   } else if (activeNestedEditor) {
1187     activeNestedEditorsMap.delete(rootEditorKey);
1188   }
1189 }
1190
1191 function stopLexicalPropagation(event: Event): void {
1192   // We attach a special property to ensure the same event doesn't re-fire
1193   // for parent editors.
1194   // @ts-ignore
1195   event._lexicalHandled = true;
1196 }
1197
1198 function hasStoppedLexicalPropagation(event: Event): boolean {
1199   // @ts-ignore
1200   const stopped = event._lexicalHandled === true;
1201   return stopped;
1202 }
1203
1204 export type EventHandler = (event: Event, editor: LexicalEditor) => void;
1205
1206 export function addRootElementEvents(
1207   rootElement: HTMLElement,
1208   editor: LexicalEditor,
1209 ): void {
1210   // We only want to have a single global selectionchange event handler, shared
1211   // between all editor instances.
1212   const doc = rootElement.ownerDocument;
1213   const documentRootElementsCount = rootElementsRegistered.get(doc);
1214   if (
1215     documentRootElementsCount === undefined ||
1216     documentRootElementsCount < 1
1217   ) {
1218     doc.addEventListener('selectionchange', onDocumentSelectionChange);
1219   }
1220   rootElementsRegistered.set(doc, (documentRootElementsCount || 0) + 1);
1221
1222   // @ts-expect-error: internal field
1223   rootElement.__lexicalEditor = editor;
1224   const removeHandles = getRootElementRemoveHandles(rootElement);
1225
1226   for (let i = 0; i < rootElementEvents.length; i++) {
1227     const [eventName, onEvent] = rootElementEvents[i];
1228     const eventHandler =
1229       typeof onEvent === 'function'
1230         ? (event: Event) => {
1231             if (hasStoppedLexicalPropagation(event)) {
1232               return;
1233             }
1234             stopLexicalPropagation(event);
1235             if (editor.isEditable() || eventName === 'click') {
1236               onEvent(event, editor);
1237             }
1238           }
1239         : (event: Event) => {
1240             if (hasStoppedLexicalPropagation(event)) {
1241               return;
1242             }
1243             stopLexicalPropagation(event);
1244             const isEditable = editor.isEditable();
1245             switch (eventName) {
1246               case 'cut':
1247                 return (
1248                   isEditable &&
1249                   dispatchCommand(editor, CUT_COMMAND, event as ClipboardEvent)
1250                 );
1251
1252               case 'copy':
1253                 return dispatchCommand(
1254                   editor,
1255                   COPY_COMMAND,
1256                   event as ClipboardEvent,
1257                 );
1258
1259               case 'paste':
1260                 return (
1261                   isEditable &&
1262                   dispatchCommand(
1263                     editor,
1264                     PASTE_COMMAND,
1265                     event as ClipboardEvent,
1266                   )
1267                 );
1268
1269               case 'dragstart':
1270                 return (
1271                   isEditable &&
1272                   dispatchCommand(editor, DRAGSTART_COMMAND, event as DragEvent)
1273                 );
1274
1275               case 'dragover':
1276                 return (
1277                   isEditable &&
1278                   dispatchCommand(editor, DRAGOVER_COMMAND, event as DragEvent)
1279                 );
1280
1281               case 'dragend':
1282                 return (
1283                   isEditable &&
1284                   dispatchCommand(editor, DRAGEND_COMMAND, event as DragEvent)
1285                 );
1286
1287               case 'focus':
1288                 return (
1289                   isEditable &&
1290                   dispatchCommand(editor, FOCUS_COMMAND, event as FocusEvent)
1291                 );
1292
1293               case 'blur': {
1294                 return (
1295                   isEditable &&
1296                   dispatchCommand(editor, BLUR_COMMAND, event as FocusEvent)
1297                 );
1298               }
1299
1300               case 'drop':
1301                 return (
1302                   isEditable &&
1303                   dispatchCommand(editor, DROP_COMMAND, event as DragEvent)
1304                 );
1305             }
1306           };
1307     rootElement.addEventListener(eventName, eventHandler);
1308     removeHandles.push(() => {
1309       rootElement.removeEventListener(eventName, eventHandler);
1310     });
1311   }
1312 }
1313
1314 export function removeRootElementEvents(rootElement: HTMLElement): void {
1315   const doc = rootElement.ownerDocument;
1316   const documentRootElementsCount = rootElementsRegistered.get(doc);
1317   invariant(
1318     documentRootElementsCount !== undefined,
1319     'Root element not registered',
1320   );
1321
1322   // We only want to have a single global selectionchange event handler, shared
1323   // between all editor instances.
1324   const newCount = documentRootElementsCount - 1;
1325   invariant(newCount >= 0, 'Root element count less than 0');
1326   rootElementsRegistered.set(doc, newCount);
1327   if (newCount === 0) {
1328     doc.removeEventListener('selectionchange', onDocumentSelectionChange);
1329   }
1330
1331   const editor = getEditorPropertyFromDOMNode(rootElement);
1332
1333   if (isLexicalEditor(editor)) {
1334     cleanActiveNestedEditorsMap(editor);
1335     // @ts-expect-error: internal field
1336     rootElement.__lexicalEditor = null;
1337   } else if (editor) {
1338     invariant(
1339       false,
1340       'Attempted to remove event handlers from a node that does not belong to this build of Lexical',
1341     );
1342   }
1343
1344   const removeHandles = getRootElementRemoveHandles(rootElement);
1345
1346   for (let i = 0; i < removeHandles.length; i++) {
1347     removeHandles[i]();
1348   }
1349
1350   // @ts-expect-error: internal field
1351   rootElement.__lexicalEventHandles = [];
1352 }
1353
1354 function cleanActiveNestedEditorsMap(editor: LexicalEditor) {
1355   if (editor._parentEditor !== null) {
1356     // For nested editor cleanup map if this editor was marked as active
1357     const editors = getEditorsToPropagate(editor);
1358     const rootEditor = editors[editors.length - 1];
1359     const rootEditorKey = rootEditor._key;
1360
1361     if (activeNestedEditorsMap.get(rootEditorKey) === editor) {
1362       activeNestedEditorsMap.delete(rootEditorKey);
1363     }
1364   } else {
1365     // For top-level editors cleanup map
1366     activeNestedEditorsMap.delete(editor._key);
1367   }
1368 }
1369
1370 export function markSelectionChangeFromDOMUpdate(): void {
1371   isSelectionChangeFromDOMUpdate = true;
1372 }
1373
1374 export function markCollapsedSelectionFormat(
1375   format: number,
1376   style: string,
1377   offset: number,
1378   key: NodeKey,
1379   timeStamp: number,
1380 ): void {
1381   collapsedSelectionFormat = [format, style, offset, key, timeStamp];
1382 }