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