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