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