/* DokuWiki MoaiEditor Dirty.js file Version : 0.5a (May 6, 2026) Author : MoaiTools License : GPL 3 (http://www.gnu.org/licenses/gpl.html) */ /* Dirty Class ‾‾‾‾‾‾‾‾‾‾‾ Helps implement partial previews, which means rendering only the parts of the page that changed in order to improve the live-preview performance on large pages. Needs to keep track of the sections of the page that changed since the last preview was done. We call the areas that need to be re-rendered "dirty". As we don't update anything smaller than a section, and to keep things simple, our "dirty" area consists of all sections between the upmost and bottommost lines of text that changed since the last preview update. For debugging and for development purposes, it can: 1. Display a red vertical stripe on top of the text to show the dirty area. 2. Colorize the new rendered areas in the preview. If you want to see graphically how this class works, set the following flags in the constructor: this.settings = {highlight:{dirty:true, preview:true}}; */ MoaiEditor.Dirty = class { constructor() { // Variables this.settings = {highlight:{dirty:false, preview:false}}; this.request_id = 0; this.state = {}; // Top and bottom header matches this.state.current = {changed:false, bottom:null, top:null}; // Current state this.state.future = null; // This state will become the current if the preview request is successful 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) this.lastElementId = 0; // To create unique ids for every dom element created on preview updates this.firstPreview = true; } // ──────────────────────────────────── onRequest () { // Return if no changes have been made if (!this.state.current.changed && !this.firstPreview) return null; // Create a post-request-success state this.state.future = {changed:false, bottom:null, top:null}; // Increment the id for this request this.request_id += 1; const padded_id = this.request_id.toString().padEnd(5," "); // If full preview var text; if (this.firstPreview || moaiEditor.buttons.partialpreview.mode == 'off') { // Update the whole preview when the response is received this.state.request = { top:null, bottom:null, full:true}; // Send the whole text for preview text = moaiEditor.editor.current.text; } // If partial preview else { // Freeze the state at request time this.state.request = this.getRequestState(); // Gather the changed text text = this.getText(); } // Pack and return const md5 = MD5.generate(text); const payload = padded_id + md5 + text; return payload; } // ──────────────────────────────────── getRequestState() { // Upper boundary matches (ordered from closest to farthest to the boundary match) var top = this.state.current.top; var headerMatches = this.getTopLevelHeaderMatches(); var upperMatches = this.getPreviousHeaderMatches (top, headerMatches); // Lower boundary matches (ordered from closest to farthest to the boundary match) headerMatches.reverse(); var bottom = this.state.current.bottom; var lowerMatches = this.getPreviousHeaderMatches (bottom, headerMatches); // Pack and return const state = { top:top, bottom:bottom, upper:upperMatches, lower:lowerMatches, full:false }; return state; } getPreviousHeaderMatches (match, matches) { if (match === null) return []; var previous = []; for (let m of matches) { previous.push(m); if (match.handle === m.handle) break; } previous.reverse(); return previous; } // ──────────────────────────────────── getText () { // Handles const lines = moaiEditor.editor.current.watcher.lines; // Get top and bottom boundaries const top = this.state.request.top; const bottom = this.state.request.bottom; // Top line (include header to trigger php to render the section wrapper div) var startline = 0; if (top !== null) startline = top.endline; // Bottom line (no need to include the header) var endline = lines.length-1; if (bottom !== null) endline = bottom.startline-1; // Return string const slice = lines.slice(startline, endline+1); return slice.join('\n'); } // ──────────────────────────────────── onResponse (padded_id, html) { const start = Date.now(); // Return error if the request id does not match const id = parseInt(padded_id); // parseInt will return an integer or NaN if (id !== this.request_id) { return false; } // Reset the current state only if the text did not change during the request if (!this.state.future.changed) this.state.current = {changed:false, bottom:null, top:null}; // Disable autoscroll and store current scroll position moaiEditor.scroll.sync.disabled = true; const preview = document.querySelector("#moaied__preview_content"); const scrollBehavior = preview.style.scrollBehavior; preview.style.scrollBehavior = 'auto'; const scroll = preview.scrollTop; // Highlight dirty area (for debug) this.showDirtyArea(); // Update the DOM var top = this.state.request.top; if (top !== null) top = top.handle; var bottom = this.state.request.bottom; if (bottom !== null) bottom = bottom.handle; this.updateDOM(top, bottom, html, this.state.request.full); // Not first preview anymore this.firstPreview = false; // Find top and bottom boundary header matches (that still exist after the request was initiated) var headerMatches = this.getTopLevelHeaderMatches(); top = this.getExistingBoundaryMatch (headerMatches, this.state.request.upper); bottom = this.getExistingBoundaryMatch (headerMatches, this.state.request.lower); // Refresh matches, syntax highlighting, scrollpoints, table of contents, clickable headers moaiEditor.matches.update(top, bottom); // Update table of contents moaiEditor.toc.update(); const elapsed = Date.now() - start; // Re-enable the Preview button this.enableDisablePreviewButton(); // Restore scroll position and enable autoscroll (if it was enabled before) preview.scrollTop = scroll; moaiEditor.scroll.sync.disabled = false; preview.style.scrollBehavior = scrollBehavior; // Return success return true; } // ──────────────────────────────────── getExistingBoundaryMatch(headerMatches, boundaryMatches) { if (boundaryMatches === undefined || boundaryMatches === null) return null; for (let boundaryMatch of boundaryMatches) for (let headerMatch of headerMatches) if (headerMatch.handle == boundaryMatch.handle) return boundaryMatch; return null; } // ──────────────────────────────────── createElementIds() { for (let element of document.querySelectorAll("#moaied__preview_content *")) this.assignElementId(element); } // ──────────────────────────────────── assignElementId (element) { this.lastElementId += 1; element.id = 'moaied__'+this.lastElementId; } // ──────────────────────────────────── updateDOM (top, bottom, html, full) { // Parse the HTML and generate the elements var div = document.createElement('div'); div.innerHTML = html; // Delete the first header if it exists (was only used to trigger the generation of the section wrapper div) if (top !== null && ['H1','H2','H3','H4','H5'].includes(div.children[0].tagName)) div.children[0].remove(); // ▆▆▆▆▆▆▆ Remove the footnotes if it was a partial preview. // ▆▆▆▆▆▆▆ TODO: Create a footnotes class to handle footers correctly with partial previews (it can be done easily) if (!full) for (let element of div.querySelectorAll(".footnotes")) element.remove(); // Create a unique id for each new element to avoid duplicates for (let element of div.querySelectorAll("*")) this.assignElementId(element); // Avoid lazy image loading in the preview because it can mess with the the scroll for (let image of div.querySelectorAll("img")) image.loading = 'eager'; // Random color highlight (for debug) const color = this.getRandomColor(); if (this.settings.highlight.preview && !this.firstPreview) for (let element of div.querySelectorAll("*")) element.style.background = color; // Reverse the order before insertion var elements = []; for (let element of div.children) elements.push(element); elements.reverse(); // Remove old elements const container = document.getElementById('moaied__preview_content'); var element = parent.firstChild; if (top === null) element = container.firstChild; else element = top.nextSibling; if (element !== null) while (element !== undefined && element !== null && element !== bottom) { const next = element.nextElementSibling; element.remove(); element = next; } // Add new elements for (let element of elements) if (top === null) container.insertBefore (element, container.firstChild); // Insert a child element at the beginning else container.insertBefore (element, top.nextElementSibling); // Insert a child element after a particular node } // ──────────────────────────────────── getRandomColor () { var r,g,b; while (true) { r = 200 + Math.floor(Math.random() * 56); g = 200 + Math.floor(Math.random() * 56); b = 200 + Math.floor(Math.random() * 56); const d = Math.abs(r-g) + Math.abs(g-b) + Math.abs(b-r); if (d > 20) break; } const color = "rgb("+r+" "+g+" "+b+")"; return color; } // ──────────────────────────────────── onTextChanged (change) { // Update the changed states this.updateState(this.state.current, change); this.updateState(this.state.future, change); // Highlight the changed area this.showDirtyArea(); // Enable the Preview button this.enableDisablePreviewButton(); // Reset the live preview timer moaiEditor.ajax.timer.reset(); } // ──────────────────────────────────── enableDisablePreviewButton () { const button = document.getElementById("moaied__btn_preview"); if (this.state.current.changed) button.disabled = false; else button.disabled = true; } // ──────────────────────────────────── getTopLevelHeaderMatches () { var matches = []; for (let match of moaiEditor.matches.matches) { if (match.syntax != 'header') continue; if (match.handle?.parentNode?.id != 'moaied__preview_content') continue; matches.push(match); } return matches; } // ──────────────────────────────────── updateState (state, change) { // Return if the state is null if (state === null) return; // Keep track of the changed area (for partial preview) // Use a margin of one line arround the changed text because some tags depend on the previous or next lines var top = null; var bottom = null; for (let match of this.getTopLevelHeaderMatches()) { // Set top and bottom if needed if (match.endline < change.num.keepfirst-1) if (top === null || match.endline > top.endline) top = match; if (match.startline > change.index.keeplast) if (bottom === null || match.startline < bottom.startline) bottom = match; } if (state.changed === false) { state.top = top; state.bottom = bottom; state.changed = true; } else { if (state.top !== null) if (top === null || top.endline < state.top.endline) state.top = top; if (state.bottom !== null) if (bottom === null || bottom.endline > state.bottom.endline) state.bottom = bottom; } } // ──────────────────────────────────── showDirtyArea () { // Handles const editor = moaiEditor.editor.current; const overlay = editor.dirty; const state = this.state.current; // Exit if not enabled or the dirty area is empty if (!this.settings.highlight.dirty || state.changed === false) { overlay.style.display = 'none'; return; } // Highlight the changed area var top = 0; var bottom = editor.scroll.height; const topline = state.top?.startline; if (topline !== undefined) top = editor.getLineRect(topline).bottom; const bottomline = state.bottom?.endline; if (bottomline !== undefined) bottom = editor.getLineRect(bottomline).top; overlay.style.top = top+'px'; overlay.style.height = (bottom-top)+'px' ; overlay.style.display = 'block'; } }; // End Class