]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/rich-text/index.ts
Searching: Added test for guillemets
[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   ElementFormatType,
12   LexicalCommand,
13   LexicalEditor,
14   PasteCommandType,
15   RangeSelection,
16   TextFormatType,
17 } from 'lexical';
18 import {
19   $createRangeSelection,
20   $createTabNode,
21   $getAdjacentNode,
22   $getNearestNodeFromDOMNode,
23   $getRoot,
24   $getSelection,
25   $insertNodes,
26   $isDecoratorNode,
27   $isElementNode,
28   $isNodeSelection,
29   $isRangeSelection,
30   $isTextNode,
31   $normalizeSelection__EXPERIMENTAL,
32   $selectAll,
33   $setSelection,
34   CLICK_COMMAND,
35   COMMAND_PRIORITY_EDITOR,
36   CONTROLLED_TEXT_INSERTION_COMMAND,
37   COPY_COMMAND,
38   createCommand,
39   CUT_COMMAND,
40   DELETE_CHARACTER_COMMAND,
41   DELETE_LINE_COMMAND,
42   DELETE_WORD_COMMAND,
43   DRAGOVER_COMMAND,
44   DRAGSTART_COMMAND,
45   DROP_COMMAND,
46   ElementNode,
47   FORMAT_ELEMENT_COMMAND,
48   FORMAT_TEXT_COMMAND,
49   INSERT_LINE_BREAK_COMMAND,
50   INSERT_PARAGRAPH_COMMAND,
51   INSERT_TAB_COMMAND,
52   isSelectionCapturedInDecoratorInput,
53   KEY_ARROW_DOWN_COMMAND,
54   KEY_ARROW_LEFT_COMMAND,
55   KEY_ARROW_RIGHT_COMMAND,
56   KEY_ARROW_UP_COMMAND,
57   KEY_BACKSPACE_COMMAND,
58   KEY_DELETE_COMMAND,
59   KEY_ENTER_COMMAND,
60   KEY_ESCAPE_COMMAND,
61   PASTE_COMMAND,
62   REMOVE_TEXT_COMMAND,
63   SELECT_ALL_COMMAND,
64 } from 'lexical';
65
66 import {$insertDataTransferForRichText, copyToClipboard,} from '@lexical/clipboard';
67 import {$moveCharacter, $shouldOverrideDefaultCharacterSelection,} from '@lexical/selection';
68 import {$findMatchingParent, mergeRegister, objectKlassEquals,} from '@lexical/utils';
69 import caretFromPoint from 'lexical/shared/caretFromPoint';
70 import {CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI,} from 'lexical/shared/environment';
71
72 export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
73   'DRAG_DROP_PASTE_FILE',
74 );
75
76
77
78 function onPasteForRichText(
79   event: CommandPayloadType<typeof PASTE_COMMAND>,
80   editor: LexicalEditor,
81 ): void {
82   event.preventDefault();
83   editor.update(
84     () => {
85       const selection = $getSelection();
86       const clipboardData =
87         objectKlassEquals(event, InputEvent) ||
88         objectKlassEquals(event, KeyboardEvent)
89           ? null
90           : (event as ClipboardEvent).clipboardData;
91       if (clipboardData != null && selection !== null) {
92         $insertDataTransferForRichText(clipboardData, selection, editor);
93       }
94     },
95     {
96       tag: 'paste',
97     },
98   );
99 }
100
101 async function onCutForRichText(
102   event: CommandPayloadType<typeof CUT_COMMAND>,
103   editor: LexicalEditor,
104 ): Promise<void> {
105   await copyToClipboard(
106     editor,
107     objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
108   );
109   editor.update(() => {
110     const selection = $getSelection();
111     if ($isRangeSelection(selection)) {
112       selection.removeText();
113     } else if ($isNodeSelection(selection)) {
114       selection.getNodes().forEach((node) => node.remove());
115     }
116   });
117 }
118
119 // Clipboard may contain files that we aren't allowed to read. While the event is arguably useless,
120 // in certain occasions, we want to know whether it was a file transfer, as opposed to text. We
121 // control this with the first boolean flag.
122 export function eventFiles(
123   event: DragEvent | PasteCommandType,
124 ): [boolean, Array<File>, boolean] {
125   let dataTransfer: null | DataTransfer = null;
126   if (objectKlassEquals(event, DragEvent)) {
127     dataTransfer = (event as DragEvent).dataTransfer;
128   } else if (objectKlassEquals(event, ClipboardEvent)) {
129     dataTransfer = (event as ClipboardEvent).clipboardData;
130   }
131
132   if (dataTransfer === null) {
133     return [false, [], false];
134   }
135
136   const types = dataTransfer.types;
137   const hasFiles = types.includes('Files');
138   const hasContent =
139     types.includes('text/html') || types.includes('text/plain');
140   return [hasFiles, Array.from(dataTransfer.files), hasContent];
141 }
142
143 function $handleIndentAndOutdent(
144   indentOrOutdent: (block: ElementNode) => void,
145 ): boolean {
146   const selection = $getSelection();
147   if (!$isRangeSelection(selection)) {
148     return false;
149   }
150   const alreadyHandled = new Set();
151   const nodes = selection.getNodes();
152   for (let i = 0; i < nodes.length; i++) {
153     const node = nodes[i];
154     const key = node.getKey();
155     if (alreadyHandled.has(key)) {
156       continue;
157     }
158     const parentBlock = $findMatchingParent(
159       node,
160       (parentNode): parentNode is ElementNode =>
161         $isElementNode(parentNode) && !parentNode.isInline(),
162     );
163     if (parentBlock === null) {
164       continue;
165     }
166     const parentKey = parentBlock.getKey();
167     if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
168       alreadyHandled.add(parentKey);
169       indentOrOutdent(parentBlock);
170     }
171   }
172   return alreadyHandled.size > 0;
173 }
174
175 function $isTargetWithinDecorator(target: HTMLElement): boolean {
176   const node = $getNearestNodeFromDOMNode(target);
177   return $isDecoratorNode(node);
178 }
179
180 function $isSelectionAtEndOfRoot(selection: RangeSelection) {
181   const focus = selection.focus;
182   return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
183 }
184
185 export function registerRichText(editor: LexicalEditor): () => void {
186   const removeListener = mergeRegister(
187     editor.registerCommand(
188       CLICK_COMMAND,
189       (payload) => {
190         const selection = $getSelection();
191         if ($isNodeSelection(selection)) {
192           selection.clear();
193           return true;
194         }
195         return false;
196       },
197       0,
198     ),
199     editor.registerCommand<boolean>(
200       DELETE_CHARACTER_COMMAND,
201       (isBackward) => {
202         const selection = $getSelection();
203         if (!$isRangeSelection(selection)) {
204           return false;
205         }
206         selection.deleteCharacter(isBackward);
207         return true;
208       },
209       COMMAND_PRIORITY_EDITOR,
210     ),
211     editor.registerCommand<boolean>(
212       DELETE_WORD_COMMAND,
213       (isBackward) => {
214         const selection = $getSelection();
215         if (!$isRangeSelection(selection)) {
216           return false;
217         }
218         selection.deleteWord(isBackward);
219         return true;
220       },
221       COMMAND_PRIORITY_EDITOR,
222     ),
223     editor.registerCommand<boolean>(
224       DELETE_LINE_COMMAND,
225       (isBackward) => {
226         const selection = $getSelection();
227         if (!$isRangeSelection(selection)) {
228           return false;
229         }
230         selection.deleteLine(isBackward);
231         return true;
232       },
233       COMMAND_PRIORITY_EDITOR,
234     ),
235     editor.registerCommand(
236       CONTROLLED_TEXT_INSERTION_COMMAND,
237       (eventOrText) => {
238         const selection = $getSelection();
239
240         if (typeof eventOrText === 'string') {
241           if (selection !== null) {
242             selection.insertText(eventOrText);
243           }
244         } else {
245           if (selection === null) {
246             return false;
247           }
248
249           const dataTransfer = eventOrText.dataTransfer;
250           if (dataTransfer != null) {
251             $insertDataTransferForRichText(dataTransfer, selection, editor);
252           } else if ($isRangeSelection(selection)) {
253             const data = eventOrText.data;
254             if (data) {
255               selection.insertText(data);
256             }
257             return true;
258           }
259         }
260         return true;
261       },
262       COMMAND_PRIORITY_EDITOR,
263     ),
264     editor.registerCommand(
265       REMOVE_TEXT_COMMAND,
266       () => {
267         const selection = $getSelection();
268         if (!$isRangeSelection(selection)) {
269           return false;
270         }
271         selection.removeText();
272         return true;
273       },
274       COMMAND_PRIORITY_EDITOR,
275     ),
276     editor.registerCommand<TextFormatType>(
277       FORMAT_TEXT_COMMAND,
278       (format) => {
279         const selection = $getSelection();
280         if (!$isRangeSelection(selection)) {
281           return false;
282         }
283         selection.formatText(format);
284         return true;
285       },
286       COMMAND_PRIORITY_EDITOR,
287     ),
288     editor.registerCommand<ElementFormatType>(
289       FORMAT_ELEMENT_COMMAND,
290       (format) => {
291         const selection = $getSelection();
292         if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
293           return false;
294         }
295         const nodes = selection.getNodes();
296         for (const node of nodes) {
297           const element = $findMatchingParent(
298             node,
299             (parentNode): parentNode is ElementNode =>
300               $isElementNode(parentNode) && !parentNode.isInline(),
301           );
302         }
303         return true;
304       },
305       COMMAND_PRIORITY_EDITOR,
306     ),
307     editor.registerCommand<boolean>(
308       INSERT_LINE_BREAK_COMMAND,
309       (selectStart) => {
310         const selection = $getSelection();
311         if (!$isRangeSelection(selection)) {
312           return false;
313         }
314         selection.insertLineBreak(selectStart);
315         return true;
316       },
317       COMMAND_PRIORITY_EDITOR,
318     ),
319     editor.registerCommand(
320       INSERT_PARAGRAPH_COMMAND,
321       () => {
322         const selection = $getSelection();
323         if (!$isRangeSelection(selection)) {
324           return false;
325         }
326         selection.insertParagraph();
327         return true;
328       },
329       COMMAND_PRIORITY_EDITOR,
330     ),
331     editor.registerCommand(
332       INSERT_TAB_COMMAND,
333       () => {
334         $insertNodes([$createTabNode()]);
335         return true;
336       },
337       COMMAND_PRIORITY_EDITOR,
338     ),
339     editor.registerCommand<KeyboardEvent>(
340       KEY_ARROW_UP_COMMAND,
341       (event) => {
342         const selection = $getSelection();
343         if (
344           $isNodeSelection(selection) &&
345           !$isTargetWithinDecorator(event.target as HTMLElement)
346         ) {
347           // If selection is on a node, let's try and move selection
348           // back to being a range selection.
349           const nodes = selection.getNodes();
350           if (nodes.length > 0) {
351             nodes[0].selectPrevious();
352             return true;
353           }
354         } else if ($isRangeSelection(selection)) {
355           const possibleNode = $getAdjacentNode(selection.focus, true);
356           if (
357             !event.shiftKey &&
358             $isDecoratorNode(possibleNode) &&
359             !possibleNode.isIsolated() &&
360             !possibleNode.isInline()
361           ) {
362             possibleNode.selectPrevious();
363             event.preventDefault();
364             return true;
365           }
366         }
367         return false;
368       },
369       COMMAND_PRIORITY_EDITOR,
370     ),
371     editor.registerCommand<KeyboardEvent>(
372       KEY_ARROW_DOWN_COMMAND,
373       (event) => {
374         const selection = $getSelection();
375         if ($isNodeSelection(selection)) {
376           // If selection is on a node, let's try and move selection
377           // back to being a range selection.
378           const nodes = selection.getNodes();
379           if (nodes.length > 0) {
380             nodes[0].selectNext(0, 0);
381             return true;
382           }
383         } else if ($isRangeSelection(selection)) {
384           if ($isSelectionAtEndOfRoot(selection)) {
385             event.preventDefault();
386             return true;
387           }
388           const possibleNode = $getAdjacentNode(selection.focus, false);
389           if (
390             !event.shiftKey &&
391             $isDecoratorNode(possibleNode) &&
392             !possibleNode.isIsolated() &&
393             !possibleNode.isInline()
394           ) {
395             possibleNode.selectNext();
396             event.preventDefault();
397             return true;
398           }
399         }
400         return false;
401       },
402       COMMAND_PRIORITY_EDITOR,
403     ),
404     editor.registerCommand<KeyboardEvent>(
405       KEY_ARROW_LEFT_COMMAND,
406       (event) => {
407         const selection = $getSelection();
408         if ($isNodeSelection(selection)) {
409           // If selection is on a node, let's try and move selection
410           // back to being a range selection.
411           const nodes = selection.getNodes();
412           if (nodes.length > 0) {
413             event.preventDefault();
414             nodes[0].selectPrevious();
415             return true;
416           }
417         }
418         if (!$isRangeSelection(selection)) {
419           return false;
420         }
421         if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
422           const isHoldingShift = event.shiftKey;
423           event.preventDefault();
424           $moveCharacter(selection, isHoldingShift, true);
425           return true;
426         }
427         return false;
428       },
429       COMMAND_PRIORITY_EDITOR,
430     ),
431     editor.registerCommand<KeyboardEvent>(
432       KEY_ARROW_RIGHT_COMMAND,
433       (event) => {
434         const selection = $getSelection();
435         if (
436           $isNodeSelection(selection) &&
437           !$isTargetWithinDecorator(event.target as HTMLElement)
438         ) {
439           // If selection is on a node, let's try and move selection
440           // back to being a range selection.
441           const nodes = selection.getNodes();
442           if (nodes.length > 0) {
443             event.preventDefault();
444             nodes[0].selectNext(0, 0);
445             return true;
446           }
447         }
448         if (!$isRangeSelection(selection)) {
449           return false;
450         }
451         const isHoldingShift = event.shiftKey;
452         if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
453           event.preventDefault();
454           $moveCharacter(selection, isHoldingShift, false);
455           return true;
456         }
457         return false;
458       },
459       COMMAND_PRIORITY_EDITOR,
460     ),
461     editor.registerCommand<KeyboardEvent>(
462       KEY_BACKSPACE_COMMAND,
463       (event) => {
464         if ($isTargetWithinDecorator(event.target as HTMLElement)) {
465           return false;
466         }
467         const selection = $getSelection();
468         if (!$isRangeSelection(selection)) {
469           return false;
470         }
471         event.preventDefault();
472
473         return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
474       },
475       COMMAND_PRIORITY_EDITOR,
476     ),
477     editor.registerCommand<KeyboardEvent>(
478       KEY_DELETE_COMMAND,
479       (event) => {
480         if ($isTargetWithinDecorator(event.target as HTMLElement)) {
481           return false;
482         }
483         const selection = $getSelection();
484         if (!$isRangeSelection(selection)) {
485           return false;
486         }
487         event.preventDefault();
488         return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
489       },
490       COMMAND_PRIORITY_EDITOR,
491     ),
492     editor.registerCommand<KeyboardEvent | null>(
493       KEY_ENTER_COMMAND,
494       (event) => {
495         const selection = $getSelection();
496         if (!$isRangeSelection(selection)) {
497           return false;
498         }
499         if (event !== null) {
500           // If we have beforeinput, then we can avoid blocking
501           // the default behavior. This ensures that the iOS can
502           // intercept that we're actually inserting a paragraph,
503           // and autocomplete, autocapitalize etc work as intended.
504           // This can also cause a strange performance issue in
505           // Safari, where there is a noticeable pause due to
506           // preventing the key down of enter.
507           if (
508             (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
509             CAN_USE_BEFORE_INPUT
510           ) {
511             return false;
512           }
513           event.preventDefault();
514           if (event.shiftKey) {
515             return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
516           }
517         }
518         return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
519       },
520       COMMAND_PRIORITY_EDITOR,
521     ),
522     editor.registerCommand(
523       KEY_ESCAPE_COMMAND,
524       () => {
525         const selection = $getSelection();
526         if (!$isRangeSelection(selection)) {
527           return false;
528         }
529         editor.blur();
530         return true;
531       },
532       COMMAND_PRIORITY_EDITOR,
533     ),
534     editor.registerCommand<DragEvent>(
535       DROP_COMMAND,
536       (event) => {
537         const [, files] = eventFiles(event);
538         if (files.length > 0) {
539           const x = event.clientX;
540           const y = event.clientY;
541           const eventRange = caretFromPoint(x, y);
542           if (eventRange !== null) {
543             const {offset: domOffset, node: domNode} = eventRange;
544             const node = $getNearestNodeFromDOMNode(domNode);
545             if (node !== null) {
546               const selection = $createRangeSelection();
547               if ($isTextNode(node)) {
548                 selection.anchor.set(node.getKey(), domOffset, 'text');
549                 selection.focus.set(node.getKey(), domOffset, 'text');
550               } else {
551                 const parentKey = node.getParentOrThrow().getKey();
552                 const offset = node.getIndexWithinParent() + 1;
553                 selection.anchor.set(parentKey, offset, 'element');
554                 selection.focus.set(parentKey, offset, 'element');
555               }
556               const normalizedSelection =
557                 $normalizeSelection__EXPERIMENTAL(selection);
558               $setSelection(normalizedSelection);
559             }
560             editor.dispatchCommand(DRAG_DROP_PASTE, files);
561           }
562           event.preventDefault();
563           return true;
564         }
565
566         const selection = $getSelection();
567         if ($isRangeSelection(selection)) {
568           return true;
569         }
570
571         return false;
572       },
573       COMMAND_PRIORITY_EDITOR,
574     ),
575     editor.registerCommand<DragEvent>(
576       DRAGSTART_COMMAND,
577       (event) => {
578         const [isFileTransfer] = eventFiles(event);
579         const selection = $getSelection();
580         if (isFileTransfer && !$isRangeSelection(selection)) {
581           return false;
582         }
583         return true;
584       },
585       COMMAND_PRIORITY_EDITOR,
586     ),
587     editor.registerCommand<DragEvent>(
588       DRAGOVER_COMMAND,
589       (event) => {
590         const [isFileTransfer] = eventFiles(event);
591         const selection = $getSelection();
592         if (isFileTransfer && !$isRangeSelection(selection)) {
593           return false;
594         }
595         const x = event.clientX;
596         const y = event.clientY;
597         const eventRange = caretFromPoint(x, y);
598         if (eventRange !== null) {
599           const node = $getNearestNodeFromDOMNode(eventRange.node);
600           if ($isDecoratorNode(node)) {
601             // Show browser caret as the user is dragging the media across the screen. Won't work
602             // for DecoratorNode nor it's relevant.
603             event.preventDefault();
604           }
605         }
606         return true;
607       },
608       COMMAND_PRIORITY_EDITOR,
609     ),
610     editor.registerCommand(
611       SELECT_ALL_COMMAND,
612       () => {
613         $selectAll();
614
615         return true;
616       },
617       COMMAND_PRIORITY_EDITOR,
618     ),
619     editor.registerCommand(
620       COPY_COMMAND,
621       (event) => {
622         copyToClipboard(
623           editor,
624           objectKlassEquals(event, ClipboardEvent)
625             ? (event as ClipboardEvent)
626             : null,
627         );
628         return true;
629       },
630       COMMAND_PRIORITY_EDITOR,
631     ),
632     editor.registerCommand(
633       CUT_COMMAND,
634       (event) => {
635         onCutForRichText(event, editor);
636         return true;
637       },
638       COMMAND_PRIORITY_EDITOR,
639     ),
640     editor.registerCommand(
641       PASTE_COMMAND,
642       (event) => {
643         const [, files, hasTextContent] = eventFiles(event);
644         if (files.length > 0 && !hasTextContent) {
645           editor.dispatchCommand(DRAG_DROP_PASTE, files);
646           return true;
647         }
648
649         // if inputs then paste within the input ignore creating a new node on paste event
650         if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
651           return false;
652         }
653
654         const selection = $getSelection();
655         if (selection !== null) {
656           onPasteForRichText(event, editor);
657           return true;
658         }
659
660         return false;
661       },
662       COMMAND_PRIORITY_EDITOR,
663     ),
664   );
665   return removeListener;
666 }