]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/services/drop-paste-handling.ts
Lexical: Added drop/paste image handling
[bookstack] / resources / js / wysiwyg / services / drop-paste-handling.ts
1 import {
2     $insertNodes,
3     $isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND,
4     LexicalEditor,
5     LexicalNode, PASTE_COMMAND
6 } from "lexical";
7 import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection";
8 import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes";
9 import {Clipboard} from "../../services/clipboard";
10 import {$createImageNode} from "../nodes/image";
11 import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
12 import {$createLinkNode} from "@lexical/link";
13 import {EditorImageData, uploadImageFile} from "../utils/images";
14 import {EditorUiContext} from "../ui/framework/core";
15
16 function $getNodeFromMouseEvent(event: MouseEvent, editor: LexicalEditor): LexicalNode|null {
17     const x = event.clientX;
18     const y = event.clientY;
19     const dom = document.elementFromPoint(x, y);
20     if (!dom) {
21         return null;
22     }
23
24     return $getNearestBlockNodeForCoords(editor, event.clientX, event.clientY);
25 }
26
27 function $insertNodesAtEvent(nodes: LexicalNode[], event: DragEvent, editor: LexicalEditor) {
28     const positionNode = $getNodeFromMouseEvent(event, editor);
29
30     if (positionNode) {
31         $selectSingleNode(positionNode);
32     }
33
34     $insertNewBlockNodesAtSelection(nodes, true);
35
36     if (!$isDecoratorNode(positionNode) || !positionNode?.getTextContent()) {
37         positionNode?.remove();
38     }
39 }
40
41 async function insertTemplateToEditor(editor: LexicalEditor, templateId: string, event: DragEvent) {
42     const resp = await window.$http.get(`/templates/${templateId}`);
43     const data = (resp.data || {html: ''}) as {html: string}
44     const html: string = data.html || '';
45
46     editor.update(() => {
47         const newNodes = $htmlToBlockNodes(editor, html);
48         $insertNodesAtEvent(newNodes, event, editor);
49     });
50 }
51
52 function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolean {
53     const clipboard = new Clipboard(data);
54     let handled = false;
55
56     // Don't handle the event ourselves if no items exist of contains table-looking data
57     if (!clipboard.hasItems() || clipboard.containsTabularData()) {
58         return handled;
59     }
60
61     const images = clipboard.getImages();
62     if (images.length > 0) {
63         handled = true;
64     }
65
66     context.editor.update(async () => {
67         for (const imageFile of images) {
68             const loadingImage = window.baseUrl('/loading.gif');
69             const loadingNode = $createImageNode(loadingImage);
70             const imageWrap = $createCustomParagraphNode();
71             imageWrap.append(loadingNode);
72             $insertNodes([imageWrap]);
73
74             try {
75                 const respData: EditorImageData = await uploadImageFile(imageFile, context.options.pageId);
76                 const safeName = respData.name.replace(/"/g, '');
77                 context.editor.update(() => {
78                     const finalImage = $createImageNode(respData.thumbs?.display || '', {
79                         alt: safeName,
80                     });
81                     const imageLink = $createLinkNode(respData.url, {target: '_blank'});
82                     imageLink.append(finalImage);
83                     loadingNode.replace(imageLink);
84                 });
85             } catch (err: any) {
86                 context.editor.update(() => {
87                     loadingNode.remove(false);
88                 });
89                 window.$events.error(err?.data?.message || context.options.translations.imageUploadErrorText);
90                 console.error(err);
91             }
92         }
93     });
94
95     return handled;
96 }
97
98 function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean {
99     const editor = context.editor;
100     return (event: DragEvent): boolean => {
101         // Template handling
102         const templateId = event.dataTransfer?.getData('bookstack/template') || '';
103         if (templateId) {
104             insertTemplateToEditor(editor, templateId, event);
105             event.preventDefault();
106             return true;
107         }
108
109         // HTML contents drop
110         const html = event.dataTransfer?.getData('text/html') || '';
111         if (html) {
112             editor.update(() => {
113                 const newNodes = $htmlToBlockNodes(editor, html);
114                 $insertNodesAtEvent(newNodes, event, editor);
115             });
116             event.preventDefault();
117             return true;
118         }
119
120         if (event.dataTransfer) {
121             const handled = handleMediaInsert(event.dataTransfer, context);
122             if (handled) {
123                 event.preventDefault();
124                 return true;
125             }
126         }
127
128         return false;
129     };
130 }
131
132 function createPasteListener(context: EditorUiContext): (event: ClipboardEvent) => boolean {
133     return (event: ClipboardEvent) => {
134         if (!event.clipboardData) {
135             return false;
136         }
137
138         const handled = handleMediaInsert(event.clipboardData, context);
139         if (handled) {
140             event.preventDefault();
141         }
142
143         return handled;
144     };
145 }
146
147 export function registerDropPasteHandling(context: EditorUiContext): () => void {
148     const dropListener = createDropListener(context);
149     const pasteListener = createPasteListener(context);
150
151     const unregisterDrop = context.editor.registerCommand(DROP_COMMAND, dropListener, COMMAND_PRIORITY_HIGH);
152     const unregisterPaste = context.editor.registerCommand(PASTE_COMMAND, pasteListener, COMMAND_PRIORITY_HIGH);
153
154     return () => {
155         unregisterDrop();
156         unregisterPaste();
157     };
158 }