]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/rich-text/index.ts
bc5c3f1d2073e316108014a9c4c6c6d969b07325
[bookstack] / resources / js / wysiwyg / lexical / rich-text / index.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 {
10   CommandPayloadType,
11   DOMConversionMap,
12   DOMConversionOutput,
13   DOMExportOutput,
14   EditorConfig,
15   ElementFormatType,
16   LexicalCommand,
17   LexicalEditor,
18   LexicalNode,
19   NodeKey,
20   ParagraphNode,
21   PasteCommandType,
22   RangeSelection,
23   SerializedElementNode,
24   Spread,
25   TextFormatType,
26 } from 'lexical';
27
28 import {
29   $insertDataTransferForRichText,
30   copyToClipboard,
31 } from '@lexical/clipboard';
32 import {
33   $moveCharacter,
34   $shouldOverrideDefaultCharacterSelection,
35 } from '@lexical/selection';
36 import {
37   $findMatchingParent,
38   $getNearestBlockElementAncestorOrThrow,
39   addClassNamesToElement,
40   isHTMLElement,
41   mergeRegister,
42   objectKlassEquals,
43 } from '@lexical/utils';
44 import {
45   $applyNodeReplacement,
46   $createParagraphNode,
47   $createRangeSelection,
48   $createTabNode,
49   $getAdjacentNode,
50   $getNearestNodeFromDOMNode,
51   $getRoot,
52   $getSelection,
53   $insertNodes,
54   $isDecoratorNode,
55   $isElementNode,
56   $isNodeSelection,
57   $isRangeSelection,
58   $isRootNode,
59   $isTextNode,
60   $normalizeSelection__EXPERIMENTAL,
61   $selectAll,
62   $setSelection,
63   CLICK_COMMAND,
64   COMMAND_PRIORITY_EDITOR,
65   CONTROLLED_TEXT_INSERTION_COMMAND,
66   COPY_COMMAND,
67   createCommand,
68   CUT_COMMAND,
69   DELETE_CHARACTER_COMMAND,
70   DELETE_LINE_COMMAND,
71   DELETE_WORD_COMMAND,
72   DRAGOVER_COMMAND,
73   DRAGSTART_COMMAND,
74   DROP_COMMAND,
75   ElementNode,
76   FORMAT_ELEMENT_COMMAND,
77   FORMAT_TEXT_COMMAND,
78   INDENT_CONTENT_COMMAND,
79   INSERT_LINE_BREAK_COMMAND,
80   INSERT_PARAGRAPH_COMMAND,
81   INSERT_TAB_COMMAND,
82   isSelectionCapturedInDecoratorInput,
83   KEY_ARROW_DOWN_COMMAND,
84   KEY_ARROW_LEFT_COMMAND,
85   KEY_ARROW_RIGHT_COMMAND,
86   KEY_ARROW_UP_COMMAND,
87   KEY_BACKSPACE_COMMAND,
88   KEY_DELETE_COMMAND,
89   KEY_ENTER_COMMAND,
90   KEY_ESCAPE_COMMAND,
91   OUTDENT_CONTENT_COMMAND,
92   PASTE_COMMAND,
93   REMOVE_TEXT_COMMAND,
94   SELECT_ALL_COMMAND,
95 } from 'lexical';
96 import caretFromPoint from 'lexical/shared/caretFromPoint';
97 import {
98   CAN_USE_BEFORE_INPUT,
99   IS_APPLE_WEBKIT,
100   IS_IOS,
101   IS_SAFARI,
102 } from 'lexical/shared/environment';
103
104 export type SerializedHeadingNode = Spread<
105   {
106     tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
107   },
108   SerializedElementNode
109 >;
110
111 export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
112   'DRAG_DROP_PASTE_FILE',
113 );
114
115 export type SerializedQuoteNode = SerializedElementNode;
116
117 /** @noInheritDoc */
118 export class QuoteNode extends ElementNode {
119   static getType(): string {
120     return 'quote';
121   }
122
123   static clone(node: QuoteNode): QuoteNode {
124     return new QuoteNode(node.__key);
125   }
126
127   constructor(key?: NodeKey) {
128     super(key);
129   }
130
131   // View
132
133   createDOM(config: EditorConfig): HTMLElement {
134     const element = document.createElement('blockquote');
135     addClassNamesToElement(element, config.theme.quote);
136     return element;
137   }
138   updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean {
139     return false;
140   }
141
142   static importDOM(): DOMConversionMap | null {
143     return {
144       blockquote: (node: Node) => ({
145         conversion: $convertBlockquoteElement,
146         priority: 0,
147       }),
148     };
149   }
150
151   exportDOM(editor: LexicalEditor): DOMExportOutput {
152     const {element} = super.exportDOM(editor);
153
154     if (element && isHTMLElement(element)) {
155       if (this.isEmpty()) {
156         element.append(document.createElement('br'));
157       }
158     }
159
160     return {
161       element,
162     };
163   }
164
165   static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
166     const node = $createQuoteNode();
167     return node;
168   }
169
170   exportJSON(): SerializedElementNode {
171     return {
172       ...super.exportJSON(),
173       type: 'quote',
174     };
175   }
176
177   // Mutation
178
179   insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode {
180     const newBlock = $createParagraphNode();
181     const direction = this.getDirection();
182     newBlock.setDirection(direction);
183     this.insertAfter(newBlock, restoreSelection);
184     return newBlock;
185   }
186
187   collapseAtStart(): true {
188     const paragraph = $createParagraphNode();
189     const children = this.getChildren();
190     children.forEach((child) => paragraph.append(child));
191     this.replace(paragraph);
192     return true;
193   }
194
195   canMergeWhenEmpty(): true {
196     return true;
197   }
198 }
199
200 export function $createQuoteNode(): QuoteNode {
201   return $applyNodeReplacement(new QuoteNode());
202 }
203
204 export function $isQuoteNode(
205   node: LexicalNode | null | undefined,
206 ): node is QuoteNode {
207   return node instanceof QuoteNode;
208 }
209
210 export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
211
212 /** @noInheritDoc */
213 export class HeadingNode extends ElementNode {
214   /** @internal */
215   __tag: HeadingTagType;
216
217   static getType(): string {
218     return 'heading';
219   }
220
221   static clone(node: HeadingNode): HeadingNode {
222     return new HeadingNode(node.__tag, node.__key);
223   }
224
225   constructor(tag: HeadingTagType, key?: NodeKey) {
226     super(key);
227     this.__tag = tag;
228   }
229
230   getTag(): HeadingTagType {
231     return this.__tag;
232   }
233
234   // View
235
236   createDOM(config: EditorConfig): HTMLElement {
237     const tag = this.__tag;
238     const element = document.createElement(tag);
239     const theme = config.theme;
240     const classNames = theme.heading;
241     if (classNames !== undefined) {
242       const className = classNames[tag];
243       addClassNamesToElement(element, className);
244     }
245     return element;
246   }
247
248   updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
249     return false;
250   }
251
252   static importDOM(): DOMConversionMap | null {
253     return {
254       h1: (node: Node) => ({
255         conversion: $convertHeadingElement,
256         priority: 0,
257       }),
258       h2: (node: Node) => ({
259         conversion: $convertHeadingElement,
260         priority: 0,
261       }),
262       h3: (node: Node) => ({
263         conversion: $convertHeadingElement,
264         priority: 0,
265       }),
266       h4: (node: Node) => ({
267         conversion: $convertHeadingElement,
268         priority: 0,
269       }),
270       h5: (node: Node) => ({
271         conversion: $convertHeadingElement,
272         priority: 0,
273       }),
274       h6: (node: Node) => ({
275         conversion: $convertHeadingElement,
276         priority: 0,
277       }),
278       p: (node: Node) => {
279         // domNode is a <p> since we matched it by nodeName
280         const paragraph = node as HTMLParagraphElement;
281         const firstChild = paragraph.firstChild;
282         if (firstChild !== null && isGoogleDocsTitle(firstChild)) {
283           return {
284             conversion: () => ({node: null}),
285             priority: 3,
286           };
287         }
288         return null;
289       },
290       span: (node: Node) => {
291         if (isGoogleDocsTitle(node)) {
292           return {
293             conversion: (domNode: Node) => {
294               return {
295                 node: $createHeadingNode('h1'),
296               };
297             },
298             priority: 3,
299           };
300         }
301         return null;
302       },
303     };
304   }
305
306   exportDOM(editor: LexicalEditor): DOMExportOutput {
307     const {element} = super.exportDOM(editor);
308
309     if (element && isHTMLElement(element)) {
310       if (this.isEmpty()) {
311         element.append(document.createElement('br'));
312       }
313     }
314
315     return {
316       element,
317     };
318   }
319
320   static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
321     return $createHeadingNode(serializedNode.tag);
322   }
323
324   exportJSON(): SerializedHeadingNode {
325     return {
326       ...super.exportJSON(),
327       tag: this.getTag(),
328       type: 'heading',
329       version: 1,
330     };
331   }
332
333   // Mutation
334   insertNewAfter(
335     selection?: RangeSelection,
336     restoreSelection = true,
337   ): ParagraphNode | HeadingNode {
338     const anchorOffet = selection ? selection.anchor.offset : 0;
339     const lastDesc = this.getLastDescendant();
340     const isAtEnd =
341       !lastDesc ||
342       (selection &&
343         selection.anchor.key === lastDesc.getKey() &&
344         anchorOffet === lastDesc.getTextContentSize());
345     const newElement =
346       isAtEnd || !selection
347         ? $createParagraphNode()
348         : $createHeadingNode(this.getTag());
349     const direction = this.getDirection();
350     newElement.setDirection(direction);
351     this.insertAfter(newElement, restoreSelection);
352     if (anchorOffet === 0 && !this.isEmpty() && selection) {
353       const paragraph = $createParagraphNode();
354       paragraph.select();
355       this.replace(paragraph, true);
356     }
357     return newElement;
358   }
359
360   collapseAtStart(): true {
361     const newElement = !this.isEmpty()
362       ? $createHeadingNode(this.getTag())
363       : $createParagraphNode();
364     const children = this.getChildren();
365     children.forEach((child) => newElement.append(child));
366     this.replace(newElement);
367     return true;
368   }
369
370   extractWithChild(): boolean {
371     return true;
372   }
373 }
374
375 function isGoogleDocsTitle(domNode: Node): boolean {
376   if (domNode.nodeName.toLowerCase() === 'span') {
377     return (domNode as HTMLSpanElement).style.fontSize === '26pt';
378   }
379   return false;
380 }
381
382 function $convertHeadingElement(element: HTMLElement): DOMConversionOutput {
383   const nodeName = element.nodeName.toLowerCase();
384   let node = null;
385   if (
386     nodeName === 'h1' ||
387     nodeName === 'h2' ||
388     nodeName === 'h3' ||
389     nodeName === 'h4' ||
390     nodeName === 'h5' ||
391     nodeName === 'h6'
392   ) {
393     node = $createHeadingNode(nodeName);
394   }
395   return {node};
396 }
397
398 function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput {
399   const node = $createQuoteNode();
400   return {node};
401 }
402
403 export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode {
404   return $applyNodeReplacement(new HeadingNode(headingTag));
405 }
406
407 export function $isHeadingNode(
408   node: LexicalNode | null | undefined,
409 ): node is HeadingNode {
410   return node instanceof HeadingNode;
411 }
412
413 function onPasteForRichText(
414   event: CommandPayloadType<typeof PASTE_COMMAND>,
415   editor: LexicalEditor,
416 ): void {
417   event.preventDefault();
418   editor.update(
419     () => {
420       const selection = $getSelection();
421       const clipboardData =
422         objectKlassEquals(event, InputEvent) ||
423         objectKlassEquals(event, KeyboardEvent)
424           ? null
425           : (event as ClipboardEvent).clipboardData;
426       if (clipboardData != null && selection !== null) {
427         $insertDataTransferForRichText(clipboardData, selection, editor);
428       }
429     },
430     {
431       tag: 'paste',
432     },
433   );
434 }
435
436 async function onCutForRichText(
437   event: CommandPayloadType<typeof CUT_COMMAND>,
438   editor: LexicalEditor,
439 ): Promise<void> {
440   await copyToClipboard(
441     editor,
442     objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
443   );
444   editor.update(() => {
445     const selection = $getSelection();
446     if ($isRangeSelection(selection)) {
447       selection.removeText();
448     } else if ($isNodeSelection(selection)) {
449       selection.getNodes().forEach((node) => node.remove());
450     }
451   });
452 }
453
454 // Clipboard may contain files that we aren't allowed to read. While the event is arguably useless,
455 // in certain occasions, we want to know whether it was a file transfer, as opposed to text. We
456 // control this with the first boolean flag.
457 export function eventFiles(
458   event: DragEvent | PasteCommandType,
459 ): [boolean, Array<File>, boolean] {
460   let dataTransfer: null | DataTransfer = null;
461   if (objectKlassEquals(event, DragEvent)) {
462     dataTransfer = (event as DragEvent).dataTransfer;
463   } else if (objectKlassEquals(event, ClipboardEvent)) {
464     dataTransfer = (event as ClipboardEvent).clipboardData;
465   }
466
467   if (dataTransfer === null) {
468     return [false, [], false];
469   }
470
471   const types = dataTransfer.types;
472   const hasFiles = types.includes('Files');
473   const hasContent =
474     types.includes('text/html') || types.includes('text/plain');
475   return [hasFiles, Array.from(dataTransfer.files), hasContent];
476 }
477
478 function $handleIndentAndOutdent(
479   indentOrOutdent: (block: ElementNode) => void,
480 ): boolean {
481   const selection = $getSelection();
482   if (!$isRangeSelection(selection)) {
483     return false;
484   }
485   const alreadyHandled = new Set();
486   const nodes = selection.getNodes();
487   for (let i = 0; i < nodes.length; i++) {
488     const node = nodes[i];
489     const key = node.getKey();
490     if (alreadyHandled.has(key)) {
491       continue;
492     }
493     const parentBlock = $findMatchingParent(
494       node,
495       (parentNode): parentNode is ElementNode =>
496         $isElementNode(parentNode) && !parentNode.isInline(),
497     );
498     if (parentBlock === null) {
499       continue;
500     }
501     const parentKey = parentBlock.getKey();
502     if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
503       alreadyHandled.add(parentKey);
504       indentOrOutdent(parentBlock);
505     }
506   }
507   return alreadyHandled.size > 0;
508 }
509
510 function $isTargetWithinDecorator(target: HTMLElement): boolean {
511   const node = $getNearestNodeFromDOMNode(target);
512   return $isDecoratorNode(node);
513 }
514
515 function $isSelectionAtEndOfRoot(selection: RangeSelection) {
516   const focus = selection.focus;
517   return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
518 }
519
520 export function registerRichText(editor: LexicalEditor): () => void {
521   const removeListener = mergeRegister(
522     editor.registerCommand(
523       CLICK_COMMAND,
524       (payload) => {
525         const selection = $getSelection();
526         if ($isNodeSelection(selection)) {
527           selection.clear();
528           return true;
529         }
530         return false;
531       },
532       0,
533     ),
534     editor.registerCommand<boolean>(
535       DELETE_CHARACTER_COMMAND,
536       (isBackward) => {
537         const selection = $getSelection();
538         if (!$isRangeSelection(selection)) {
539           return false;
540         }
541         selection.deleteCharacter(isBackward);
542         return true;
543       },
544       COMMAND_PRIORITY_EDITOR,
545     ),
546     editor.registerCommand<boolean>(
547       DELETE_WORD_COMMAND,
548       (isBackward) => {
549         const selection = $getSelection();
550         if (!$isRangeSelection(selection)) {
551           return false;
552         }
553         selection.deleteWord(isBackward);
554         return true;
555       },
556       COMMAND_PRIORITY_EDITOR,
557     ),
558     editor.registerCommand<boolean>(
559       DELETE_LINE_COMMAND,
560       (isBackward) => {
561         const selection = $getSelection();
562         if (!$isRangeSelection(selection)) {
563           return false;
564         }
565         selection.deleteLine(isBackward);
566         return true;
567       },
568       COMMAND_PRIORITY_EDITOR,
569     ),
570     editor.registerCommand(
571       CONTROLLED_TEXT_INSERTION_COMMAND,
572       (eventOrText) => {
573         const selection = $getSelection();
574
575         if (typeof eventOrText === 'string') {
576           if (selection !== null) {
577             selection.insertText(eventOrText);
578           }
579         } else {
580           if (selection === null) {
581             return false;
582           }
583
584           const dataTransfer = eventOrText.dataTransfer;
585           if (dataTransfer != null) {
586             $insertDataTransferForRichText(dataTransfer, selection, editor);
587           } else if ($isRangeSelection(selection)) {
588             const data = eventOrText.data;
589             if (data) {
590               selection.insertText(data);
591             }
592             return true;
593           }
594         }
595         return true;
596       },
597       COMMAND_PRIORITY_EDITOR,
598     ),
599     editor.registerCommand(
600       REMOVE_TEXT_COMMAND,
601       () => {
602         const selection = $getSelection();
603         if (!$isRangeSelection(selection)) {
604           return false;
605         }
606         selection.removeText();
607         return true;
608       },
609       COMMAND_PRIORITY_EDITOR,
610     ),
611     editor.registerCommand<TextFormatType>(
612       FORMAT_TEXT_COMMAND,
613       (format) => {
614         const selection = $getSelection();
615         if (!$isRangeSelection(selection)) {
616           return false;
617         }
618         selection.formatText(format);
619         return true;
620       },
621       COMMAND_PRIORITY_EDITOR,
622     ),
623     editor.registerCommand<ElementFormatType>(
624       FORMAT_ELEMENT_COMMAND,
625       (format) => {
626         const selection = $getSelection();
627         if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
628           return false;
629         }
630         const nodes = selection.getNodes();
631         for (const node of nodes) {
632           const element = $findMatchingParent(
633             node,
634             (parentNode): parentNode is ElementNode =>
635               $isElementNode(parentNode) && !parentNode.isInline(),
636           );
637         }
638         return true;
639       },
640       COMMAND_PRIORITY_EDITOR,
641     ),
642     editor.registerCommand<boolean>(
643       INSERT_LINE_BREAK_COMMAND,
644       (selectStart) => {
645         const selection = $getSelection();
646         if (!$isRangeSelection(selection)) {
647           return false;
648         }
649         selection.insertLineBreak(selectStart);
650         return true;
651       },
652       COMMAND_PRIORITY_EDITOR,
653     ),
654     editor.registerCommand(
655       INSERT_PARAGRAPH_COMMAND,
656       () => {
657         const selection = $getSelection();
658         if (!$isRangeSelection(selection)) {
659           return false;
660         }
661         selection.insertParagraph();
662         return true;
663       },
664       COMMAND_PRIORITY_EDITOR,
665     ),
666     editor.registerCommand(
667       INSERT_TAB_COMMAND,
668       () => {
669         $insertNodes([$createTabNode()]);
670         return true;
671       },
672       COMMAND_PRIORITY_EDITOR,
673     ),
674     editor.registerCommand<KeyboardEvent>(
675       KEY_ARROW_UP_COMMAND,
676       (event) => {
677         const selection = $getSelection();
678         if (
679           $isNodeSelection(selection) &&
680           !$isTargetWithinDecorator(event.target as HTMLElement)
681         ) {
682           // If selection is on a node, let's try and move selection
683           // back to being a range selection.
684           const nodes = selection.getNodes();
685           if (nodes.length > 0) {
686             nodes[0].selectPrevious();
687             return true;
688           }
689         } else if ($isRangeSelection(selection)) {
690           const possibleNode = $getAdjacentNode(selection.focus, true);
691           if (
692             !event.shiftKey &&
693             $isDecoratorNode(possibleNode) &&
694             !possibleNode.isIsolated() &&
695             !possibleNode.isInline()
696           ) {
697             possibleNode.selectPrevious();
698             event.preventDefault();
699             return true;
700           }
701         }
702         return false;
703       },
704       COMMAND_PRIORITY_EDITOR,
705     ),
706     editor.registerCommand<KeyboardEvent>(
707       KEY_ARROW_DOWN_COMMAND,
708       (event) => {
709         const selection = $getSelection();
710         if ($isNodeSelection(selection)) {
711           // If selection is on a node, let's try and move selection
712           // back to being a range selection.
713           const nodes = selection.getNodes();
714           if (nodes.length > 0) {
715             nodes[0].selectNext(0, 0);
716             return true;
717           }
718         } else if ($isRangeSelection(selection)) {
719           if ($isSelectionAtEndOfRoot(selection)) {
720             event.preventDefault();
721             return true;
722           }
723           const possibleNode = $getAdjacentNode(selection.focus, false);
724           if (
725             !event.shiftKey &&
726             $isDecoratorNode(possibleNode) &&
727             !possibleNode.isIsolated() &&
728             !possibleNode.isInline()
729           ) {
730             possibleNode.selectNext();
731             event.preventDefault();
732             return true;
733           }
734         }
735         return false;
736       },
737       COMMAND_PRIORITY_EDITOR,
738     ),
739     editor.registerCommand<KeyboardEvent>(
740       KEY_ARROW_LEFT_COMMAND,
741       (event) => {
742         const selection = $getSelection();
743         if ($isNodeSelection(selection)) {
744           // If selection is on a node, let's try and move selection
745           // back to being a range selection.
746           const nodes = selection.getNodes();
747           if (nodes.length > 0) {
748             event.preventDefault();
749             nodes[0].selectPrevious();
750             return true;
751           }
752         }
753         if (!$isRangeSelection(selection)) {
754           return false;
755         }
756         if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
757           const isHoldingShift = event.shiftKey;
758           event.preventDefault();
759           $moveCharacter(selection, isHoldingShift, true);
760           return true;
761         }
762         return false;
763       },
764       COMMAND_PRIORITY_EDITOR,
765     ),
766     editor.registerCommand<KeyboardEvent>(
767       KEY_ARROW_RIGHT_COMMAND,
768       (event) => {
769         const selection = $getSelection();
770         if (
771           $isNodeSelection(selection) &&
772           !$isTargetWithinDecorator(event.target as HTMLElement)
773         ) {
774           // If selection is on a node, let's try and move selection
775           // back to being a range selection.
776           const nodes = selection.getNodes();
777           if (nodes.length > 0) {
778             event.preventDefault();
779             nodes[0].selectNext(0, 0);
780             return true;
781           }
782         }
783         if (!$isRangeSelection(selection)) {
784           return false;
785         }
786         const isHoldingShift = event.shiftKey;
787         if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
788           event.preventDefault();
789           $moveCharacter(selection, isHoldingShift, false);
790           return true;
791         }
792         return false;
793       },
794       COMMAND_PRIORITY_EDITOR,
795     ),
796     editor.registerCommand<KeyboardEvent>(
797       KEY_BACKSPACE_COMMAND,
798       (event) => {
799         if ($isTargetWithinDecorator(event.target as HTMLElement)) {
800           return false;
801         }
802         const selection = $getSelection();
803         if (!$isRangeSelection(selection)) {
804           return false;
805         }
806         event.preventDefault();
807
808         return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
809       },
810       COMMAND_PRIORITY_EDITOR,
811     ),
812     editor.registerCommand<KeyboardEvent>(
813       KEY_DELETE_COMMAND,
814       (event) => {
815         if ($isTargetWithinDecorator(event.target as HTMLElement)) {
816           return false;
817         }
818         const selection = $getSelection();
819         if (!$isRangeSelection(selection)) {
820           return false;
821         }
822         event.preventDefault();
823         return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
824       },
825       COMMAND_PRIORITY_EDITOR,
826     ),
827     editor.registerCommand<KeyboardEvent | null>(
828       KEY_ENTER_COMMAND,
829       (event) => {
830         const selection = $getSelection();
831         if (!$isRangeSelection(selection)) {
832           return false;
833         }
834         if (event !== null) {
835           // If we have beforeinput, then we can avoid blocking
836           // the default behavior. This ensures that the iOS can
837           // intercept that we're actually inserting a paragraph,
838           // and autocomplete, autocapitalize etc work as intended.
839           // This can also cause a strange performance issue in
840           // Safari, where there is a noticeable pause due to
841           // preventing the key down of enter.
842           if (
843             (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
844             CAN_USE_BEFORE_INPUT
845           ) {
846             return false;
847           }
848           event.preventDefault();
849           if (event.shiftKey) {
850             return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
851           }
852         }
853         return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
854       },
855       COMMAND_PRIORITY_EDITOR,
856     ),
857     editor.registerCommand(
858       KEY_ESCAPE_COMMAND,
859       () => {
860         const selection = $getSelection();
861         if (!$isRangeSelection(selection)) {
862           return false;
863         }
864         editor.blur();
865         return true;
866       },
867       COMMAND_PRIORITY_EDITOR,
868     ),
869     editor.registerCommand<DragEvent>(
870       DROP_COMMAND,
871       (event) => {
872         const [, files] = eventFiles(event);
873         if (files.length > 0) {
874           const x = event.clientX;
875           const y = event.clientY;
876           const eventRange = caretFromPoint(x, y);
877           if (eventRange !== null) {
878             const {offset: domOffset, node: domNode} = eventRange;
879             const node = $getNearestNodeFromDOMNode(domNode);
880             if (node !== null) {
881               const selection = $createRangeSelection();
882               if ($isTextNode(node)) {
883                 selection.anchor.set(node.getKey(), domOffset, 'text');
884                 selection.focus.set(node.getKey(), domOffset, 'text');
885               } else {
886                 const parentKey = node.getParentOrThrow().getKey();
887                 const offset = node.getIndexWithinParent() + 1;
888                 selection.anchor.set(parentKey, offset, 'element');
889                 selection.focus.set(parentKey, offset, 'element');
890               }
891               const normalizedSelection =
892                 $normalizeSelection__EXPERIMENTAL(selection);
893               $setSelection(normalizedSelection);
894             }
895             editor.dispatchCommand(DRAG_DROP_PASTE, files);
896           }
897           event.preventDefault();
898           return true;
899         }
900
901         const selection = $getSelection();
902         if ($isRangeSelection(selection)) {
903           return true;
904         }
905
906         return false;
907       },
908       COMMAND_PRIORITY_EDITOR,
909     ),
910     editor.registerCommand<DragEvent>(
911       DRAGSTART_COMMAND,
912       (event) => {
913         const [isFileTransfer] = eventFiles(event);
914         const selection = $getSelection();
915         if (isFileTransfer && !$isRangeSelection(selection)) {
916           return false;
917         }
918         return true;
919       },
920       COMMAND_PRIORITY_EDITOR,
921     ),
922     editor.registerCommand<DragEvent>(
923       DRAGOVER_COMMAND,
924       (event) => {
925         const [isFileTransfer] = eventFiles(event);
926         const selection = $getSelection();
927         if (isFileTransfer && !$isRangeSelection(selection)) {
928           return false;
929         }
930         const x = event.clientX;
931         const y = event.clientY;
932         const eventRange = caretFromPoint(x, y);
933         if (eventRange !== null) {
934           const node = $getNearestNodeFromDOMNode(eventRange.node);
935           if ($isDecoratorNode(node)) {
936             // Show browser caret as the user is dragging the media across the screen. Won't work
937             // for DecoratorNode nor it's relevant.
938             event.preventDefault();
939           }
940         }
941         return true;
942       },
943       COMMAND_PRIORITY_EDITOR,
944     ),
945     editor.registerCommand(
946       SELECT_ALL_COMMAND,
947       () => {
948         $selectAll();
949
950         return true;
951       },
952       COMMAND_PRIORITY_EDITOR,
953     ),
954     editor.registerCommand(
955       COPY_COMMAND,
956       (event) => {
957         copyToClipboard(
958           editor,
959           objectKlassEquals(event, ClipboardEvent)
960             ? (event as ClipboardEvent)
961             : null,
962         );
963         return true;
964       },
965       COMMAND_PRIORITY_EDITOR,
966     ),
967     editor.registerCommand(
968       CUT_COMMAND,
969       (event) => {
970         onCutForRichText(event, editor);
971         return true;
972       },
973       COMMAND_PRIORITY_EDITOR,
974     ),
975     editor.registerCommand(
976       PASTE_COMMAND,
977       (event) => {
978         const [, files, hasTextContent] = eventFiles(event);
979         if (files.length > 0 && !hasTextContent) {
980           editor.dispatchCommand(DRAG_DROP_PASTE, files);
981           return true;
982         }
983
984         // if inputs then paste within the input ignore creating a new node on paste event
985         if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
986           return false;
987         }
988
989         const selection = $getSelection();
990         if (selection !== null) {
991           onPasteForRichText(event, editor);
992           return true;
993         }
994
995         return false;
996       },
997       COMMAND_PRIORITY_EDITOR,
998     ),
999   );
1000   return removeListener;
1001 }