]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/nodes/diagram.ts
1aff06400374ec45375d9da55371a8bc1cdc9194
[bookstack] / resources / js / wysiwyg / nodes / diagram.ts
1 import {
2     DecoratorNode,
3     DOMConversion,
4     DOMConversionMap,
5     DOMConversionOutput,
6     LexicalEditor, LexicalNode,
7     SerializedLexicalNode,
8     Spread
9 } from "lexical";
10 import type {EditorConfig} from "lexical/LexicalEditor";
11 import {el} from "../helpers";
12 import {EditorDecoratorAdapter} from "../ui/framework/decorator";
13 import * as DrawIO from '../../services/drawio';
14 import {EditorUiContext} from "../ui/framework/core";
15 import {HttpError} from "../../services/http";
16
17 export type SerializedDiagramNode = Spread<{
18     id: string;
19     drawingId: string;
20     drawingUrl: string;
21 }, SerializedLexicalNode>
22
23 export class DiagramNode extends DecoratorNode<EditorDecoratorAdapter> {
24     __id: string = '';
25     __drawingId: string = '';
26     __drawingUrl: string = '';
27
28     static getType(): string {
29         return 'diagram';
30     }
31
32     static clone(node: DiagramNode): DiagramNode {
33         return new DiagramNode(node.__drawingId, node.__drawingUrl);
34     }
35
36     constructor(drawingId: string, drawingUrl: string, key?: string) {
37         super(key);
38         this.__drawingId = drawingId;
39         this.__drawingUrl = drawingUrl;
40     }
41
42     setDrawingIdAndUrl(drawingId: string, drawingUrl: string): void {
43         const self = this.getWritable();
44         self.__drawingUrl = drawingUrl;
45         self.__drawingId = drawingId;
46     }
47
48     getDrawingIdAndUrl(): { id: string, url: string } {
49         const self = this.getLatest();
50         return {
51             id: self.__drawingId,
52             url: self.__drawingUrl,
53         };
54     }
55
56     setId(id: string) {
57         const self = this.getWritable();
58         self.__id = id;
59     }
60
61     getId(): string {
62         const self = this.getLatest();
63         return self.__id;
64     }
65
66     decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
67         return {
68             type: 'diagram',
69             getNode: () => this,
70         };
71     }
72
73     isInline(): boolean {
74         return false;
75     }
76
77     isIsolated() {
78         return true;
79     }
80
81     createDOM(_config: EditorConfig, _editor: LexicalEditor) {
82         return el('div', {
83             id: this.__id || null,
84             'drawio-diagram': this.__drawingId,
85         }, [
86             el('img', {src: this.__drawingUrl}),
87         ]);
88     }
89
90     updateDOM(prevNode: DiagramNode, dom: HTMLElement) {
91         const img = dom.querySelector('img');
92         if (!img) return false;
93
94         if (prevNode.__id !== this.__id) {
95             dom.setAttribute('id', this.__id);
96         }
97
98         if (prevNode.__drawingUrl !== this.__drawingUrl) {
99             img.setAttribute('src', this.__drawingUrl);
100         }
101
102         if (prevNode.__drawingId !== this.__drawingId) {
103             dom.setAttribute('drawio-diagram', this.__drawingId);
104         }
105
106         return false;
107     }
108
109     static importDOM(): DOMConversionMap | null {
110         return {
111             div(node: HTMLElement): DOMConversion | null {
112
113                 if (!node.hasAttribute('drawio-diagram')) {
114                     return null;
115                 }
116
117                 return {
118                     conversion: (element: HTMLElement): DOMConversionOutput | null => {
119
120                         const img = element.querySelector('img');
121                         const drawingUrl = img?.getAttribute('src') || '';
122                         const drawingId = element.getAttribute('drawio-diagram') || '';
123
124                         return {
125                             node: $createDiagramNode(drawingId, drawingUrl),
126                         };
127                     },
128                     priority: 3,
129                 };
130             },
131         };
132     }
133
134     exportJSON(): SerializedDiagramNode {
135         return {
136             type: 'diagram',
137             version: 1,
138             id: this.__id,
139             drawingId: this.__drawingId,
140             drawingUrl: this.__drawingUrl,
141         };
142     }
143
144     static importJSON(serializedNode: SerializedDiagramNode): DiagramNode {
145         const node = $createDiagramNode(serializedNode.drawingId, serializedNode.drawingUrl);
146         node.setId(serializedNode.id || '');
147         return node;
148     }
149 }
150
151 export function $createDiagramNode(drawingId: string = '', drawingUrl: string = ''): DiagramNode {
152     return new DiagramNode(drawingId, drawingUrl);
153 }
154
155 export function $isDiagramNode(node: LexicalNode | null | undefined) {
156     return node instanceof DiagramNode;
157 }
158
159
160 function handleUploadError(error: HttpError, context: EditorUiContext): void {
161     if (error.status === 413) {
162         window.$events.emit('error', context.options.translations.serverUploadLimitText || '');
163     } else {
164         window.$events.emit('error', context.options.translations.imageUploadErrorText || '');
165     }
166     console.error(error);
167 }
168
169 async function loadDiagramIdFromNode(editor: LexicalEditor, node: DiagramNode): Promise<string> {
170     const drawingId = await new Promise<string>((res, rej) => {
171         editor.getEditorState().read(() => {
172             const {id: drawingId} = node.getDrawingIdAndUrl();
173             res(drawingId);
174         });
175     });
176
177     return drawingId || '';
178 }
179
180 async function updateDrawingNodeFromData(context: EditorUiContext, node: DiagramNode, pngData: string, isNew: boolean): Promise<void> {
181     DrawIO.close();
182
183     if (isNew) {
184         const loadingImage: string = window.baseUrl('/loading.gif');
185         context.editor.update(() => {
186             node.setDrawingIdAndUrl('', loadingImage);
187         });
188     }
189
190     try {
191         const img = await DrawIO.upload(pngData, context.options.pageId);
192         context.editor.update(() => {
193             node.setDrawingIdAndUrl(String(img.id), img.url);
194         });
195     } catch (err) {
196         if (err instanceof HttpError) {
197             handleUploadError(err, context);
198         }
199
200         if (isNew) {
201             context.editor.update(() => {
202                 node.remove();
203             });
204         }
205
206         throw new Error(`Failed to save image with error: ${err}`);
207     }
208 }
209
210 export function $openDrawingEditorForNode(context: EditorUiContext, node: DiagramNode): void {
211     let isNew = false;
212     DrawIO.show(context.options.drawioUrl, async () => {
213         const drawingId = await loadDiagramIdFromNode(context.editor, node);
214         isNew = !drawingId;
215         return isNew ? '' : DrawIO.load(drawingId);
216     }, async (pngData: string) => {
217         return updateDrawingNodeFromData(context, node, pngData, isNew);
218     });
219 }