1/*  DokuWiki MoaiEditor Dirty.js file
2    Version : 0.5a (May 6, 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