xref: /plugin/diagrams/script/DiagramsEditor.js (revision 8c8c70074e1156a58dbfd89fe9e41e3dd346fb54)
1/**
2 * Callback for saving a diagram
3 * @callback saveCallback
4 * @param {string} svg The SVG data to save
5 */
6
7/**
8 * Callback for when saving has finished
9 * @callback postSaveCallback
10 * @param {bool} ok True if saving was successful
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 * FIXME should messages be sent to diagramsEditor.contentWindow instead of diagramsEditor?
21 * FIXME we're not catching any fetch exceptions currently. Should we?
22 * FIXME should we somehow ensure that there is only ever one instance of this class?
23 * @class
24 */
25class DiagramsEditor {
26    /** @type {HTMLIFrameElement} the editor iframe */
27    #diagramsEditor = null;
28
29    /** @type {saveCallback} the method to call for saving the diagram */
30    #saveCallback = null;
31
32    /** @type {postSaveCallback} called when saving has finished*/
33    #postSaveCallback = null;
34
35    /** @type {string} the initial save data to load, set by one of the edit* methods */
36    #svg = '';
37
38    /**
39     * Create a new diagrams editor
40     *
41     * @param {postSaveCallback} postSaveCallback Called when saving has finished
42     */
43    constructor(postSaveCallback = null) {
44        this.#postSaveCallback = postSaveCallback;
45    }
46
47    /**
48     * Initialize the editor for editing a media file
49     *
50     * @param {string} mediaid The media ID to edit, if 404 a new file will be created
51     */
52    async editMediaFile(mediaid) {
53        this.#saveCallback = (svg) => this.#saveMediaFile(mediaid, svg);
54
55        const response = await fetch(DOKU_BASE + 'lib/exe/fetch.php?media=' + mediaid, {
56            method: 'GET',
57            cache: 'no-cache',
58        });
59
60        if (response.ok) {
61            // if not 404, load the SVG data
62            this.#svg = await response.text();
63        }
64
65        this.#createEditor();
66    }
67
68    /**
69     * Initialize the editor for editing an embedded diagram
70     *
71     * @param {string} pageid The page ID to on which the diagram is embedded
72     * @param {int} position The position of the diagram in the page
73     * @param {int} length The length of the diagram in the page
74     */
75    async editEmbed(pageid, position, length) {
76        this.#saveCallback = (svg) => this.#saveEmbed(pageid, position, length, svg);
77
78        const url = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_diagrams_embed_load' +
79            '&id' + encodeURIComponent(pageid) +
80            '&pos=' + encodeURIComponent(position) +
81            '&len=' + encodeURIComponent(length);
82
83        const response = await fetch(url, {
84            method: 'GET',
85            cache: 'no-cache',
86        });
87
88        if (response.ok) {
89            // if not 404, load the SVG data
90            this.#svg = await response.text();
91        } else {
92            // a 404 for an embedded diagram should not happen
93            alert(LANG.plugins.diagrams.errorLoading);
94            return;
95        }
96
97        this.#createEditor();
98    }
99
100    /**
101     * Initialize the editor for editing a diagram in memory
102     *
103     * @param {string} svg The SVG raw data to edit, empty for new file
104     * @param {saveCallback} callback The callback to call when the editor is closed
105     */
106    editMemory(svg, callback) {
107        this.#svg = svg;
108        this.#saveCallback = callback.bind(this);
109        this.#createEditor();
110    }
111
112    /**
113     * Saves a diagram as a media file
114     *
115     * @param {string} mediaid The media ID to save
116     * @param {string} svg The SVG raw data to save
117     * @returns {Promise<boolean>}
118     */
119    async #saveMediaFile(mediaid, svg) {
120        const uploadUrl = this.#mediaUploadUrl(mediaid);
121
122        const response = await fetch(uploadUrl, {
123            method: 'POST',
124            cache: 'no-cache',
125        });
126
127        if (!response.ok) {
128            alert(LANG.plugins.diagrams.errorSaving);
129        }
130        return response.ok;
131    }
132
133    /**
134     * Saves a diagram as an embedded diagram
135     *
136     * This replaces the previous diagram at the given postion
137     *
138     * @param {string} pageid The page ID on which the diagram is embedded
139     * @param {int} position The position of the diagram in the page
140     * @param {int} length The length of the diagram as it was before
141     * @param {string} svg The SVG raw data to save
142     * @returns {Promise<boolean>}
143     */
144    async #saveEmbed(pageid, position, length, svg) {
145        const uploadUrl = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_diagrams_embed_save' +
146            '&id=' + encodeURIComponent(pageid) +
147            '&pos=' + encodeURIComponent(position) +
148            '&len=' + encodeURIComponent(length) +
149            '&svg=' + encodeURIComponent(svg) +
150            '&sectok=' + JSINFO['sectok'];
151
152        const response = await fetch(uploadUrl, {
153            method: 'POST',
154            cache: 'no-cache',
155        });
156
157        if (!response.ok) {
158            alert(LANG.plugins.diagrams.errorSaving);
159        }
160
161        return response.ok;
162    }
163
164    /**
165     * Create the editor iframe and attach the message listener
166     */
167    #createEditor() {
168        this.#diagramsEditor = document.createElement('iframe');
169        this.#diagramsEditor.id = 'diagrams-frame';
170        this.#diagramsEditor.style = {
171            border: 0,
172            position: 'fixed',
173            top: 0,
174            left: 0,
175            right: 0,
176            bottom: 0,
177            width: '100%',
178            height: '100%',
179            zIndex: 9999,
180        }; // FIXME assign these via CSS
181        this.#diagramsEditor.src = JSINFO['plugins']['diagrams']['service_url'];
182        document.body.appendChild(this.#diagramsEditor);
183        window.addEventListener('message', this.#handleMessage.bind(this)); // FIXME might need to be public
184    }
185
186    /**
187     * Remove the editor iframe and detach the message listener
188     */
189    #removeEditor() {
190        if (this.#diagramsEditor === null) return;
191        this.#diagramsEditor.remove();
192        this.#diagramsEditor = null;
193        window.removeDiagramsEditor(this.#handleMessage.bind(this));
194    }
195
196    /**
197     * Handle messages from diagramming service
198     *
199     * @param {Event} event
200     */
201    async #handleMessage(event) {
202        // FIXME do we need this originalEvent here? or is that jQuery specific?
203        const msg = JSON.parse(event.originalEvent.data);
204
205        switch (msg.event) {
206            case 'init':
207                // load the SVG data into the editor
208                this.#diagramsEditor.postMessage(JSON.stringify({action: 'load', xml: this.#svg}), '*');
209                break;
210            case 'save':
211                // Save triggers an export to SVG action
212                this.#diagramsEditor.postMessage(
213                    JSON.stringify({
214                        action: 'export',
215                        format: 'xmlsvg',
216                        spin: LANG.plugins.diagrams.saving
217                    }),
218                    '*'
219                );
220                break;
221            case 'export':
222                if (msg.format !== 'svg') {
223                    alert(LANG.plugins.diagrams.errorUnsupportedFormat);
224                    return;
225                }
226                const ok = await this.#saveCallback(
227                    // FIXME we used to prepend a doctype, but doctypes are no longer recommended for SVG
228                    // FIXME we may need to add a XML header though?
229                    decodeURIComponent(atob(
230                        msg.data.split(',')[1])
231                        .split('')
232                        .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
233                        .join('')
234                    )
235                );
236                this.#removeEditor(); // FIXME do we need this or wouldn't we get an exit message?
237                if (this.#postSaveCallback !== null) {
238                    this.#postSaveCallback(ok);
239                }
240                break;
241            case 'exit':
242                this.#removeEditor();
243                break;
244        }
245    }
246
247    /**
248     * Get the URL to upload a media file
249     * @param {string} mediaid
250     * @returns {string}
251     */
252    #mediaUploadUrl(mediaid) {
253        // split mediaid into namespace and id
254        let id = mediaid;
255        let ns = '';
256        const idParts = id.split(':');
257        if (idParts.length > 1) {
258            id = idParts.pop(idParts);
259            ns = idParts.join(':');
260        }
261
262        return DOKU_BASE +
263            'lib/exe/ajax.php?call=mediaupload&ow=true&ns=' +
264            encodeURIComponent(ns) +
265            '&qqfile=' +
266            encodeURIComponent(id) +
267            '&sectok=' +
268            encodeURIComponent(JSINFO['sectok']);
269    }
270}
271