xref: /plugin/diagrams/script/DiagramsEditor.js (revision ca5b88414e2f89994852ebc3bdabb047e4e3158f)
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     * Create the editor iframe and attach the message listener
164     */
165    #createEditor() {
166        this.#diagramsEditor = document.createElement('iframe');
167        this.#diagramsEditor.id = 'plugin__diagrams-editor';
168        this.#diagramsEditor.src = JSINFO['plugins']['diagrams']['service_url'];
169        document.body.appendChild(this.#diagramsEditor);
170        window.addEventListener('message', this.#handleMessage.bind(this));
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.removeEventListener('message', this.#handleMessage.bind(this));
181    }
182
183    /**
184     * Handle messages from diagramming service
185     *
186     * @param {Event} event
187     */
188    async #handleMessage(event) {
189        const msg = JSON.parse(event.data);
190
191        switch (msg.event) {
192            case 'init':
193                // load the SVG data into the editor
194                this.#diagramsEditor.contentWindow.postMessage(JSON.stringify({action: 'load', xml: this.#svg}), '*');
195                break;
196            case 'save':
197                // Save triggers an export to SVG action
198                this.#diagramsEditor.contentWindow.postMessage(
199                    JSON.stringify({
200                        action: 'export',
201                        format: 'xmlsvg',
202                        spin: LANG.plugins.diagrams.saving
203                    }),
204                    '*'
205                );
206                break;
207            case 'export':
208                if (msg.format !== 'svg') {
209                    alert(LANG.plugins.diagrams.errorUnsupportedFormat);
210                    return;
211                }
212                const ok = await this.#saveCallback(
213                    // msg.data contains the SVG as a Base64 encoded data URI
214                    decodeURIComponent(atob(
215                        msg.data.split(',')[1])
216                        .split('')
217                        .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
218                        .join('')
219                    )
220                );
221                if (ok) {
222                    this.#removeEditor();
223                    if (this.#postSaveCallback !== null) {
224                        this.#postSaveCallback();
225                    }
226                } else {
227                    alert(LANG.plugins.diagrams.errorSaving);
228                }
229                break;
230            case 'exit':
231                this.#removeEditor();
232                break;
233        }
234    }
235
236    /**
237     * Get the URL to upload a media file
238     * @param {string} mediaid
239     * @returns {string}
240     */
241    #mediaUploadUrl(mediaid) {
242        // split mediaid into namespace and id
243        let id = mediaid;
244        let ns = '';
245        const idParts = id.split(':');
246        if (idParts.length > 1) {
247            id = idParts.pop(idParts);
248            ns = idParts.join(':');
249        }
250
251        return DOKU_BASE +
252            'lib/exe/ajax.php?call=mediaupload&ow=true&ns=' +
253            encodeURIComponent(ns) +
254            '&qqfile=' +
255            encodeURIComponent(id) +
256            '&sectok=' +
257            encodeURIComponent(JSINFO['sectok']);
258    }
259}
260