xref: /plugin/diagrams/script/DiagramsEditor.js (revision a3e56072734f003a5afe3d3ec768f979fed517b1)
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} index The index of the diagram on the page (0-based)
73     */
74    async editEmbed(pageid, index) {
75        this.#saveCallback = (svg) => this.#saveEmbed(pageid, index, svg);
76
77        const url = 'FIXME'; // FIXME we need a renderer endpoint that parses SVG out of a page
78
79        const response = await fetch(url, {
80            method: 'GET',
81            cache: 'no-cache',
82        });
83
84        if (response.ok) {
85            // if not 404, load the SVG data
86            this.#svg = await response.text();
87        } else {
88            // a 404 for an embedded diagram should not happen
89            alert(LANG.plugins.diagrams.errorLoading);
90            return;
91        }
92
93        this.#createEditor();
94    }
95
96    /**
97     * Initialize the editor for editing a diagram in memory
98     *
99     * @param {string} svg The SVG raw data to edit, empty for new file
100     * @param {saveCallback} callback The callback to call when the editor is closed
101     */
102    editMemory(svg, callback) {
103        this.#svg = svg;
104        this.#saveCallback = callback.bind(this);
105        this.#createEditor();
106    }
107
108    /**
109     * Saves a diagram as a media file
110     *
111     * @param {string} mediaid The media ID to save
112     * @param {string} svg The SVG raw data to save
113     * @returns {Promise<boolean>}
114     */
115    async #saveMediaFile(mediaid, svg) {
116        const uploadUrl = this.#mediaUploadUrl(mediaid);
117
118        const response = await fetch(uploadUrl, {
119            method: 'POST',
120            cache: 'no-cache',
121        });
122
123        if (!response.ok) {
124            alert(LANG.plugins.diagrams.errorSaving);
125        }
126        return response.ok;
127    }
128
129    /**
130     *
131     * @param {string} pageid The page ID on which the diagram is embedded
132     * @param {string} index The index of the diagram on the page (0-based)
133     * @param {string} svg The SVG raw data to save
134     * @returns {Promise<boolean>}
135     */
136    async #saveEmbed(pageid, index, svg) {
137        const uploadUrl = 'FIXME'; // FIXME we need an endpoint that saves an embedded diagram
138
139        const response = await fetch(uploadUrl, {
140            method: 'POST',
141            cache: 'no-cache',
142        });
143
144        if (!response.ok) {
145            alert(LANG.plugins.diagrams.errorSaving);
146        }
147
148        return response.ok;
149    }
150
151    /**
152     * Create the editor iframe and attach the message listener
153     */
154    #createEditor() {
155        this.#diagramsEditor = document.createElement('iframe');
156        this.#diagramsEditor.id = 'diagrams-frame';
157        this.#diagramsEditor.style = {
158            border: 0,
159            position: 'fixed',
160            top: 0,
161            left: 0,
162            right: 0,
163            bottom: 0,
164            width: '100%',
165            height: '100%',
166            zIndex: 9999,
167        }; // FIXME assign these via CSS
168        this.#diagramsEditor.src = JSINFO['plugins']['diagrams']['service_url'];
169        document.body.appendChild(this.#diagramsEditor);
170        window.addEventListener('message', this.#handleMessage.bind(this)); // FIXME might need to be public
171    }
172
173    /**
174     * Remove the editor iframe and detach the message listener
175     */
176    #removeEditor() {
177        if (this.#diagramsEditor === null) return;
178        this.#diagramsEditor.remove();
179        this.#diagramsEditor = null;
180        window.removeDiagramsEditor(this.#handleMessage.bind(this));
181    }
182
183    /**
184     * Handle messages from diagramming service
185     *
186     * @param {Event} event
187     */
188    async #handleMessage(event) {
189        // FIXME do we need this originalEvent here? or is that jQuery specific?
190        const msg = JSON.parse(event.originalEvent.data);
191
192        switch (msg.event) {
193            case 'init':
194                // load the SVG data into the editor
195                this.#diagramsEditor.postMessage(JSON.stringify({action: 'load', xml: this.#svg}), '*');
196                break;
197            case 'save':
198                // Save triggers an export to SVG action
199                this.#diagramsEditor.postMessage(
200                    JSON.stringify({
201                        action: 'export',
202                        format: 'xmlsvg',
203                        spin: LANG.plugins.diagrams.saving
204                    }),
205                    '*'
206                );
207                break;
208            case 'export':
209                if (msg.format !== 'svg') {
210                    alert(LANG.plugins.diagrams.errorUnsupportedFormat);
211                    return;
212                }
213                const ok = await this.#saveCallback(
214                    // FIXME we used to prepend a doctype, but doctypes are no longer recommended for SVG
215                    // FIXME we may need to add a XML header though?
216                    decodeURIComponent(atob(
217                        msg.data.split(',')[1])
218                        .split('')
219                        .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
220                        .join('')
221                    )
222                );
223                this.#removeEditor(); // FIXME do we need this or wouldn't we get an exit message?
224                if (this.#postSaveCallback !== null) {
225                    this.#postSaveCallback(ok);
226                }
227                break;
228            case 'exit':
229                this.#removeEditor();
230                break;
231        }
232    }
233
234    /**
235     * Get the URL to upload a media file
236     * @param {string} mediaid
237     * @returns {string}
238     */
239    #mediaUploadUrl(mediaid) {
240        // split mediaid into namespace and id
241        let id = mediaid;
242        let ns = '';
243        const idParts = id.split(':');
244        if (idParts.length > 1) {
245            id = idParts.pop(idParts);
246            ns = idParts.join(':');
247        }
248
249        return DOKU_BASE +
250            'lib/exe/ajax.php?call=mediaupload&ow=true&ns=' +
251            encodeURIComponent(ns) +
252            '&qqfile=' +
253            encodeURIComponent(id) +
254            '&sectok=' +
255            encodeURIComponent(JSINFO['sectok']);
256    }
257}
258