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