1/* DokuWiki MoaiEditor Dirty.js file 2 Author : MoaiTools <info@moaitools.org> 3 License : GPL 3 (http://www.gnu.org/licenses/gpl.html) */ 4 5/* Dirty Class 6 ‾‾‾‾‾‾‾‾‾‾‾ 7 Helps implement partial previews, which means rendering only the parts of the page that changed 8 in order to improve the live-preview performance on large pages. 9 10 Needs to keep track of the sections of the page that changed since the last preview was done. 11 We call the areas that need to be re-rendered "dirty". As we don't update anything smaller 12 than a section, and to keep things simple, our "dirty" area consists of all sections between 13 the upmost and bottommost lines of text that changed since the last preview update. 14 15 For debugging and for development purposes, it can: 16 1. Display a red vertical stripe on top of the text to show the dirty area. 17 2. Colorize the new rendered areas in the preview. 18 19 If you want to see graphically how this class works, set the following flags in the constructor: 20 this.settings = {highlight:{dirty:true, preview:true}}; 21*/ 22MoaiEditor.Dirty = class { 23 24 constructor() { 25 26 // Variables 27 this.settings = {highlight:{dirty:false, preview:false}}; 28 this.request_id = 0; 29 this.state = {}; // Top and bottom header matches 30 this.state.current = {changed:false, bottom:null, top:null}; // Current state 31 this.state.future = null; // This state will become the current if the preview request is successful 32 this.state.request = {top:null, bottom:null, upper:[], lower:[], full:false}; // Frozen state at request time (with list of upper and bottom header boundary matches sorted from closer to farthest) 33 this.lastElementId = 0; // To create unique ids for every dom element created on preview updates 34 this.firstPreview = true; 35 } 36 37 onRequest () { 38 39 // Return if no changes have been made 40 if (!this.state.current.changed && !this.firstPreview) 41 return null; 42 43 // Create a post-request-success state 44 this.state.future = {changed:false, bottom:null, top:null}; 45 46 // Increment the id for this request 47 this.request_id += 1; 48 const padded_id = this.request_id.toString().padEnd(5," "); 49 50 // If full preview 51 var text; 52 if (this.firstPreview || moaiEditor.buttons.partialpreview.mode == 'off') { 53 // Update the whole preview when the response is received 54 this.state.request = { top:null, bottom:null, full:true}; 55 // Send the whole text for preview 56 text = moaiEditor.editor.current.text; 57 } 58 // If partial preview 59 else { 60 // Freeze the state at request time 61 this.state.request = this.getRequestState(); 62 // Gather the changed text 63 text = this.getText(); 64 } 65 // Pack and return 66 const md5 = MD5.generate(text); 67 const payload = padded_id + md5 + text; 68 return payload; 69 } 70 71 getRequestState() { 72 73 // Upper boundary matches (ordered from closest to farthest to the boundary match) 74 var top = this.state.current.top; 75 var headerMatches = this.getTopLevelHeaderMatches(); 76 var upperMatches = this.getPreviousHeaderMatches (top, headerMatches); 77 78 // Lower boundary matches (ordered from closest to farthest to the boundary match) 79 headerMatches.reverse(); 80 var bottom = this.state.current.bottom; 81 var lowerMatches = this.getPreviousHeaderMatches (bottom, headerMatches); 82 83 // Pack and return 84 const state = { top:top, bottom:bottom, upper:upperMatches, lower:lowerMatches, full:false }; 85 return state; 86 } 87 getPreviousHeaderMatches (match, matches) { 88 if (match === null) 89 return []; 90 var previous = []; 91 for (let m of matches) { 92 previous.push(m); 93 if (match.handle === m.handle) 94 break; 95 } 96 previous.reverse(); 97 return previous; 98 } 99 100 getText () { 101 // Handles 102 const lines = moaiEditor.editor.current.watcher.lines; 103 104 // Get top and bottom boundaries 105 const top = this.state.request.top; 106 const bottom = this.state.request.bottom; 107 108 // Top line (include header to trigger php to render the section wrapper div) 109 var startline = 0; 110 if (top !== null) 111 startline = top.endline; 112 113 // Bottom line (no need to include the header) 114 var endline = lines.length-1; 115 if (bottom !== null) 116 endline = bottom.startline-1; 117 118 // Return string 119 const slice = lines.slice(startline, endline+1); 120 return slice.join('\n'); 121 } 122 123 onResponse (padded_id, html) { 124 125 const start = Date.now(); 126 127 // Return error if the request id does not match 128 const id = parseInt(padded_id); // parseInt will return an integer or NaN 129 if (id !== this.request_id) { 130 return false; 131 } 132 // Reset the current state only if the text did not change during the request 133 if (!this.state.future.changed) 134 this.state.current = {changed:false, bottom:null, top:null}; 135 136 // Disable autoscroll and store current scroll position 137 moaiEditor.scroll.sync.disabled = true; 138 const preview = document.querySelector("#moaied__preview_content"); 139 const scrollBehavior = preview.style.scrollBehavior; 140 preview.style.scrollBehavior = 'auto'; 141 const scroll = preview.scrollTop; 142 143 // Highlight dirty area (for debug) 144 this.showDirtyArea(); 145 146 // Update the DOM 147 var top = this.state.request.top; 148 if (top !== null) 149 top = top.handle; 150 var bottom = this.state.request.bottom; 151 if (bottom !== null) 152 bottom = bottom.handle; 153 this.updateDOM(top, bottom, html, this.state.request.full); 154 155 // Not first preview anymore 156 this.firstPreview = false; 157 158 // Find top and bottom boundary header matches (that still exist after the request was initiated) 159 var headerMatches = this.getTopLevelHeaderMatches(); 160 top = this.getExistingBoundaryMatch (headerMatches, this.state.request.upper); 161 bottom = this.getExistingBoundaryMatch (headerMatches, this.state.request.lower); 162 163 // Refresh matches, syntax highlighting, scrollpoints, table of contents, clickable headers 164 moaiEditor.matches.update(top, bottom); 165 166 // Update table of contents 167 moaiEditor.toc.update(); 168 169 const elapsed = Date.now() - start; 170 171 // Re-enable the Preview button 172 this.enableDisablePreviewButton(); 173 174 // Restore scroll position and enable autoscroll (if it was enabled before) 175 preview.scrollTop = scroll; 176 moaiEditor.scroll.sync.disabled = false; 177 preview.style.scrollBehavior = scrollBehavior; 178 179 // Return success 180 return true; 181 } 182 183 getExistingBoundaryMatch(headerMatches, boundaryMatches) { 184 185 if (boundaryMatches === undefined || boundaryMatches === null) 186 return null; 187 for (let boundaryMatch of boundaryMatches) 188 for (let headerMatch of headerMatches) 189 if (headerMatch.handle == boundaryMatch.handle) 190 return boundaryMatch; 191 return null; 192 } 193 194 createElementIds() { 195 for (let element of document.querySelectorAll("#moaied__preview_content *")) 196 this.assignElementId(element); 197 } 198 199 assignElementId (element) { 200 this.lastElementId += 1; 201 element.id = 'moaied__'+this.lastElementId; 202 } 203 204 updateDOM (top, bottom, html, full) { 205 206 207 // Parse the HTML and generate the elements 208 var div = document.createElement('div'); 209 div.innerHTML = html; 210 211 // Delete the first header if it exists (was only used to trigger the generation of the section wrapper div) 212 if (top !== null && ['H1','H2','H3','H4','H5'].includes(div.children[0].tagName)) 213 div.children[0].remove(); 214 215 // ▆▆▆▆▆▆▆ Remove the footnotes if it was a partial preview. 216 // ▆▆▆▆▆▆▆ TODO: Create a footnotes class to handle footers correctly with partial previews (it can be done easily) 217 if (!full) 218 for (let element of div.querySelectorAll(".footnotes")) 219 element.remove(); 220 221 // Create a unique id for each new element to avoid duplicates 222 for (let element of div.querySelectorAll("*")) 223 this.assignElementId(element); 224 225 // Avoid lazy image loading in the preview because it can mess with the the scroll 226 for (let image of div.querySelectorAll("img")) 227 image.loading = 'eager'; 228 229 // Random color highlight (for debug) 230 const color = this.getRandomColor(); 231 if (this.settings.highlight.preview && !this.firstPreview) 232 for (let element of div.querySelectorAll("*")) 233 element.style.background = color; 234 235 // Reverse the order before insertion 236 var elements = []; 237 for (let element of div.children) 238 elements.push(element); 239 elements.reverse(); 240 241 // Remove old elements 242 const container = document.getElementById('moaied__preview_content'); 243 var element = parent.firstChild; 244 if (top === null) 245 element = container.firstChild; 246 else 247 element = top.nextSibling; 248 if (element !== null) 249 while (element !== undefined && element !== null && element !== bottom) { 250 const next = element.nextElementSibling; 251 element.remove(); 252 element = next; 253 } 254 // Add new elements 255 for (let element of elements) 256 if (top === null) 257 container.insertBefore (element, container.firstChild); // Insert a child element at the beginning 258 else 259 container.insertBefore (element, top.nextElementSibling); // Insert a child element after a particular node 260 } 261 262 getRandomColor () { 263 264 var r,g,b; 265 while (true) { 266 r = 200 + Math.floor(Math.random() * 56); 267 g = 200 + Math.floor(Math.random() * 56); 268 b = 200 + Math.floor(Math.random() * 56); 269 const d = Math.abs(r-g) + Math.abs(g-b) + Math.abs(b-r); 270 if (d > 20) 271 break; 272 } 273 const color = "rgb("+r+" "+g+" "+b+")"; 274 return color; 275 } 276 277 onTextChanged (change) { 278 279 // Update the changed states 280 this.updateState(this.state.current, change); 281 this.updateState(this.state.future, change); 282 283 // Highlight the changed area 284 this.showDirtyArea(); 285 286 // Enable the Preview button 287 this.enableDisablePreviewButton(); 288 289 // Reset the live preview timer 290 moaiEditor.ajax.timer.reset(); 291 } 292 293 enableDisablePreviewButton () { 294 const button = document.getElementById("moaied__btn_preview"); 295 if (this.state.current.changed) 296 button.disabled = false; 297 else 298 button.disabled = true; 299 } 300 301 getTopLevelHeaderMatches () { 302 var matches = []; 303 for (let match of moaiEditor.matches.matches) { 304 if (match.syntax != 'header') 305 continue; 306 if (match.handle?.parentNode?.id != 'moaied__preview_content') 307 continue; 308 matches.push(match); 309 } 310 return matches; 311 } 312 313 updateState (state, change) { 314 315 // Return if the state is null 316 if (state === null) 317 return; 318 319 // Keep track of the changed area (for partial preview) 320 // Use a margin of one line arround the changed text because some tags depend on the previous or next lines 321 var top = null; 322 var bottom = null; 323 for (let match of this.getTopLevelHeaderMatches()) { 324 // Set top and bottom if needed 325 if (match.endline < change.num.keepfirst-1) 326 if (top === null || match.endline > top.endline) 327 top = match; 328 if (match.startline > change.index.keeplast) 329 if (bottom === null || match.startline < bottom.startline) 330 bottom = match; 331 } 332 if (state.changed === false) { 333 state.top = top; 334 state.bottom = bottom; 335 state.changed = true; 336 } 337 else { 338 if (state.top !== null) 339 if (top === null || top.endline < state.top.endline) 340 state.top = top; 341 if (state.bottom !== null) 342 if (bottom === null || bottom.endline > state.bottom.endline) 343 state.bottom = bottom; 344 } 345 } 346 347 showDirtyArea () { 348 // Handles 349 const editor = moaiEditor.editor.current; 350 const overlay = editor.dirty; 351 const state = this.state.current; 352 353 // Exit if not enabled or the dirty area is empty 354 if (!this.settings.highlight.dirty || state.changed === false) { 355 overlay.style.display = 'none'; 356 return; 357 } 358 // Highlight the changed area 359 var top = 0; 360 var bottom = editor.scroll.height; 361 const topline = state.top?.startline; 362 if (topline !== undefined) 363 top = editor.getLineRect(topline).bottom; 364 const bottomline = state.bottom?.endline; 365 if (bottomline !== undefined) 366 bottom = editor.getLineRect(bottomline).top; 367 overlay.style.top = top+'px'; 368 overlay.style.height = (bottom-top)+'px' ; 369 overlay.style.display = 'block'; 370 } 371}; // End Class 372 373 374 375 376