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