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