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