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 '§ok=' + 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 '§ok=' + 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 '§ok=' + 312 encodeURIComponent(JSINFO['sectok']); 313 } 314} 315