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