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