]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/services/drop-paste-handling.ts
Opensearch: Fixed XML declaration when php short tags enabled
[bookstack] / resources / js / wysiwyg / services / drop-paste-handling.ts
1 import {
2     $createParagraphNode,
3     $insertNodes,
4     $isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND,
5     LexicalEditor,
6     LexicalNode, PASTE_COMMAND
7 } from "lexical";
8 import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection";
9 import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes";
10 import {Clipboard} from "../../services/clipboard";
11 import {$createImageNode} from "@lexical/rich-text/LexicalImageNode";
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 = $createParagraphNode();
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 handleImageLinkInsert(data: DataTransfer, context: EditorUiContext): boolean {
99     const regex = /https?:\/\/([^?#]*?)\.(png|jpeg|jpg|gif|webp|bmp|avif)/i
100     const text = data.getData('text/plain');
101     if (text && regex.test(text)) {
102         context.editor.update(() => {
103             const image = $createImageNode(text);
104             $insertNodes([image]);
105             image.select();
106         });
107         return true;
108     }
109
110     return false;
111 }
112
113 function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean {
114     const editor = context.editor;
115     return (event: DragEvent): boolean => {
116         // Template handling
117         const templateId = event.dataTransfer?.getData('bookstack/template') || '';
118         if (templateId) {
119             insertTemplateToEditor(editor, templateId, event);
120             event.preventDefault();
121             event.stopPropagation();
122             return true;
123         }
124
125         // HTML contents drop
126         const html = event.dataTransfer?.getData('text/html') || '';
127         if (html) {
128             editor.update(() => {
129                 const newNodes = $htmlToBlockNodes(editor, html);
130                 $insertNodesAtEvent(newNodes, event, editor);
131             });
132             event.preventDefault();
133             event.stopPropagation();
134             return true;
135         }
136
137         if (event.dataTransfer) {
138             const handled = handleMediaInsert(event.dataTransfer, context);
139             if (handled) {
140                 event.preventDefault();
141                 event.stopPropagation();
142                 return true;
143             }
144         }
145
146         return false;
147     };
148 }
149
150 function createPasteListener(context: EditorUiContext): (event: ClipboardEvent) => boolean {
151     return (event: ClipboardEvent) => {
152         if (!event.clipboardData) {
153             return false;
154         }
155
156         const handled =
157             handleImageLinkInsert(event.clipboardData, context) ||
158             handleMediaInsert(event.clipboardData, context);
159
160         if (handled) {
161             event.preventDefault();
162         }
163
164         return handled;
165     };
166 }
167
168 export function registerDropPasteHandling(context: EditorUiContext): () => void {
169     const dropListener = createDropListener(context);
170     const pasteListener = createPasteListener(context);
171
172     const unregisterDrop = context.editor.registerCommand(DROP_COMMAND, dropListener, COMMAND_PRIORITY_HIGH);
173     const unregisterPaste = context.editor.registerCommand(PASTE_COMMAND, pasteListener, COMMAND_PRIORITY_HIGH);
174     context.scrollDOM.addEventListener('drop', dropListener);
175
176     return () => {
177         unregisterDrop();
178         unregisterPaste();
179         context.scrollDOM.removeEventListener('drop', dropListener);
180     };
181 }