xref: /plugin/diagrams/script/DiagramsEditor.js (revision 2bec1a225a06bb4c356005e6a5e76e5d4d346b20)
1/**
2 * Callback for saving a diagram
3 * @callback saveCallback
4 * @param {string} svg The SVG data to save
5 * @returns {Promise<boolean>|boolean} true if saving was successful, false otherwise
6 */
7
8/**
9 * Callback for when saving has finished suscessfully
10 * @callback postSaveCallback
11 */
12
13
14/**
15 * This class encapsulates all interaction with the diagrams editor
16 *
17 * It manages displaying and communicating with the editor, most importantly in manages loading
18 * and saving diagrams.
19 *
20 * Note: devs should take care to ensure that only ever one instance of this class is active at a time
21 * in the same window.
22 *
23 * FIXME we're not catching any fetch exceptions currently. Should we?
24 * @class
25 */
26class DiagramsEditor {
27    /** @type {HTMLIFrameElement} the editor iframe */
28    #diagramsEditor = null;
29
30    /** @type {saveCallback} the method to call for saving the diagram */
31    #saveCallback = null;
32
33    /** @type {postSaveCallback} called when saving has finished*/
34    #postSaveCallback = null;
35
36    /** @type {string} the initial save data to load, set by one of the edit* methods */
37    #svg = '';
38
39    /**
40     * Create a new diagrams editor
41     *
42     * @param {postSaveCallback} postSaveCallback Called when saving has finished
43     */
44    constructor(postSaveCallback = null) {
45        this.#postSaveCallback = postSaveCallback;
46    }
47
48    /**
49     * Initialize the editor for editing a media file
50     *
51     * @param {string} mediaid The media ID to edit, if 404 a new file will be created
52     */
53    async editMediaFile(mediaid) {
54        this.#saveCallback = (svg) => this.#saveMediaFile(mediaid, svg);
55
56        const response = await fetch(DOKU_BASE + 'lib/exe/fetch.php?media=' + mediaid, {
57            method: 'GET',
58            cache: 'no-cache',
59        });
60
61        if (response.ok) {
62            // if not 404, load the SVG data
63            this.#svg = await response.text();
64        }
65
66        this.#createEditor();
67    }
68
69    /**
70     * Initialize the editor for editing an embedded diagram
71     *
72     * @param {string} pageid The page ID to on which the diagram is embedded
73     * @param {int} position The position of the diagram in the page
74     * @param {int} length The length of the diagram in the page
75     */
76    async editEmbed(pageid, position, length) {
77        this.#saveCallback = (svg) => this.#saveEmbed(pageid, position, length, svg);
78
79        const url = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_diagrams_embed_load' +
80            '&id=' + encodeURIComponent(pageid) +
81            '&pos=' + encodeURIComponent(position) +
82            '&len=' + encodeURIComponent(length);
83
84        const response = await fetch(url, {
85            method: 'GET',
86            cache: 'no-cache',
87        });
88
89        if (response.ok) {
90            // if not 404, load the SVG data
91            this.#svg = await response.text();
92        } else {
93            // a 404 for an embedded diagram should not happen
94            alert(LANG.plugins.diagrams.errorLoading);
95            return;
96        }
97
98        this.#createEditor();
99    }
100
101    /**
102     * Initialize the editor for editing a diagram in memory
103     *
104     * @param {string} svg The SVG raw data to edit, empty for new file
105     * @param {saveCallback} callback The callback to call when the editor is closed
106     */
107    editMemory(svg, callback) {
108        this.#svg = svg;
109        this.#saveCallback = callback.bind(this);
110        this.#createEditor();
111    }
112
113    /**
114     * Saves a diagram as a media file
115     *
116     * @param {string} mediaid The media ID to save
117     * @param {string} svg The SVG raw data to save
118     * @returns {Promise<boolean>}
119     */
120    async #saveMediaFile(mediaid, svg) {
121        const uploadUrl = this.#mediaUploadUrl(mediaid);
122
123        const response = await fetch(uploadUrl, {
124            method: 'POST',
125            cache: 'no-cache',
126            body: svg,
127        });
128
129        return response.ok;
130    }
131
132    /**
133     * Saves a diagram as an embedded diagram
134     *
135     * This replaces the previous diagram at the given postion
136     *
137     * @param {string} pageid The page ID on which the diagram is embedded
138     * @param {int} position The position of the diagram in the page
139     * @param {int} length The length of the diagram as it was before
140     * @param {string} svg The SVG raw data to save
141     * @returns {Promise<boolean>}
142     */
143    async #saveEmbed(pageid, position, length, svg) {
144        const uploadUrl = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_diagrams_embed_save' +
145            '&id=' + encodeURIComponent(pageid) +
146            '&pos=' + encodeURIComponent(position) +
147            '&len=' + encodeURIComponent(length) +
148            '&sectok=' + JSINFO['sectok'];
149
150        const body = new FormData();
151        body.set('svg', svg);
152
153        const response = await fetch(uploadUrl, {
154            method: 'POST',
155            cache: 'no-cache',
156            body: body,
157        });
158
159        return response.ok;
160    }
161
162    /**
163     * Save the PNG cache for a diagram
164     *
165     * @param {string} svg
166     * @param {string} png
167     * @returns {Promise<boolean>}
168     */
169    async #savePngCache(svg, png) {
170        const uploadUrl = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_diagrams_savecache' +
171            '&sectok=' + JSINFO['sectok'];
172
173        const body = new FormData();
174        body.set('svg', svg);
175        body.set('png', png);
176
177        const response = await fetch(uploadUrl, {
178            method: 'POST',
179            cache: 'no-cache',
180            body: body,
181        });
182
183        return response.ok;
184    }
185
186    /**
187     * Create the editor iframe and attach the message listener
188     */
189    #createEditor() {
190        this.#diagramsEditor = document.createElement('iframe');
191        this.#diagramsEditor.id = 'plugin__diagrams-editor';
192        this.#diagramsEditor.src = JSINFO['plugins']['diagrams']['service_url'];
193        document.body.appendChild(this.#diagramsEditor);
194        window.addEventListener('message', this.#handleMessage.bind(this));
195    }
196
197    /**
198     * Remove the editor iframe and detach the message listener
199     */
200    #removeEditor() {
201        if (this.#diagramsEditor === null) return;
202        this.#diagramsEditor.remove();
203        this.#diagramsEditor = null;
204        window.removeEventListener('message', this.#handleMessage.bind(this));
205    }
206
207    /**
208     * Get the raw data from a data URI
209     *
210     * @param {string} dataUri
211     * @returns {string|null}
212     */
213    #decodeDataUri(dataUri) {
214        const matches = dataUri.match(/^data:(.*);base64,(.*)$/);
215        if (matches === null) return null;
216
217        return decodeURIComponent(
218            atob(matches[2])
219                .split('')
220                .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
221                .join('')
222        );
223    }
224
225    /**
226     * Handle messages from diagramming service
227     *
228     * @param {Event} event
229     */
230    async #handleMessage(event) {
231        const msg = JSON.parse(event.data);
232
233        switch (msg.event) {
234            case 'init':
235                // load the SVG data into the editor
236                this.#diagramsEditor.contentWindow.postMessage(JSON.stringify({action: 'load', xml: this.#svg}), '*');
237                break;
238            case 'save':
239                this.#svg = '';
240
241                // Save triggers an export to SVG action
242                this.#diagramsEditor.contentWindow.postMessage(
243                    JSON.stringify({
244                        action: 'export',
245                        format: 'xmlsvg',
246                        spin: LANG.plugins.diagrams.saving
247                    }),
248                    '*'
249                );
250                break;
251            case 'export':
252                if (msg.format === 'svg') {
253                    this.#svg = this.#decodeDataUri(msg.data);
254
255                    // export again as PNG
256                    this.#diagramsEditor.contentWindow.postMessage(
257                        JSON.stringify({
258                            action: 'export',
259                            format: 'png',
260                            spin: LANG.plugins.diagrams.saving
261                        }),
262                        '*'
263                    );
264                } else if (msg.format === 'png') {
265                    const png = msg.data; // keep as data uri, for binary safety
266                    let ok = await this.#savePngCache(this.#svg, png);
267                    if (!ok) {
268                        alert(LANG.plugins.diagrams.errorSaving);
269                        return;
270                    }
271                    ok = await this.#saveCallback(this.#svg);
272                    if (ok) {
273                        this.#removeEditor();
274                        if (this.#postSaveCallback !== null) {
275                            this.#postSaveCallback();
276                        }
277                    } else {
278                        alert(LANG.plugins.diagrams.errorSaving);
279                    }
280                } else {
281                    alert(LANG.plugins.diagrams.errorUnsupportedFormat);
282                    return;
283                }
284                break;
285            case 'exit':
286                this.#removeEditor();
287                break;
288        }
289    }
290
291    /**
292     * Get the URL to upload a media file
293     * @param {string} mediaid
294     * @returns {string}
295     */
296    #mediaUploadUrl(mediaid) {
297        // split mediaid into namespace and id
298        let id = mediaid;
299        let ns = '';
300        const idParts = id.split(':');
301        if (idParts.length > 1) {
302            id = idParts.pop(idParts);
303            ns = idParts.join(':');
304        }
305
306        return DOKU_BASE +
307            'lib/exe/ajax.php?call=mediaupload&ow=true&ns=' +
308            encodeURIComponent(ns) +
309            '&qqfile=' +
310            encodeURIComponent(id) +
311            '&sectok=' +
312            encodeURIComponent(JSINFO['sectok']);
313    }
314}
315