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/*  Textarea mirror class
7    ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
8    This is the native editor of MoaiEditor. The editing is done on the
9    textarea itself, but we add an overlay element which maintains a
10    separate div for each line of text, mirroring the textarea.
11
12    This mirror overlay is used for syntax highlighting and scroll
13    synchronization.
14
15    This class also detects textarea content changes with the help of
16    the watcher class.
17
18    DOM structure
19    ‾‾‾‾‾‾‾‾‾‾‾‾‾
20    #moaied__mirror                     -- Main container
21      .moaied-show-dirty-area           -- Debug overlay (shows dirty area)
22      #moaied__scrollpoints_overlay     -- Debug overlay (shows scroll points)
23      #moaied__mirror_content           -- Container for lines
24        .moaied-mirror-line             -- Line container
25          .moaied-highlight-match       -- Debug overlay (show matches)
26          .moaied-mirror-line-content   -- Actual content (highlighted)
27
28    Data structures
29    ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
30    Each mirror line DOM element carries some Javascript metadata information:
31
32        element.text:  the plain text of that line
33        element.highlight: {
34            match    : null,    -- E.g. 'paragraph','header'...   Used to display matches (debug option)
35            syntax   : null,    -- E.g. 'paragraph','header'...   Used to highlight the block level syntax of the line
36            children : null                                       Used to highlight the inline level syntax of children
37        }
38*/
39MoaiEditor.TextAreaMirror = class {
40
41    constructor() {
42
43        // Settings
44        this.settings = {show:{matches:false, textarea:false}};
45
46        // Constants
47        this.name = 'Native editor';   // Label to display to the user
48        //this.name = 'MoaiEditor';   // Label to display to the user
49        this.mirror = null;         // Container element
50        this.content = null;        // Container element for mirrored lines
51        this.textarea = null;       // The textarea element
52        this.line = null;           // Template for new mirror line elements
53
54        // Variables
55        this.enabled = false;       // Flag to indicate if this editor is enabled right now
56
57        // Objects
58        this.syntax = new MoaiEditor.Highlight(this);
59        this.scroll = new MoaiEditor.MirrorScroll(this);
60        this.watcher = new MoaiEditor.WatchText(this);
61        this.selection = new MoaiEditor.WatchSelection(this);
62    }
63    // ┌───────────────────────────────────┐
64    // │ Public                            │
65    // └───────────────────────────────────┘
66
67    init() {
68
69        // Get node handles
70        this.textarea = moaiEditor.layout.textarea;
71
72        // Create mirror line template
73        var line = moaiEditor.createHTML('<div class="moaied-mirror-line"></div>');
74        var content = moaiEditor.createHTML('<span class="moaied-mirror-line-content"></span>');
75        var match = moaiEditor.createHTML('<div class="moaied-highlight-match"></div>');
76        line.appendChild(content);
77        if (this.settings.show.matches)
78            line.appendChild(match);
79        this.line = line;
80
81        // Create main mirror container (it scrolls)
82        this.mirror = moaiEditor.createHTML('<div id="moaied__mirror" class="moaied-display-none"></div>');
83        moaiEditor.layout.editpane.appendChild(this.mirror);
84
85        // Create container for the content
86        this.content = moaiEditor.createHTML('<div id="moaied__mirror_content"></div>');
87        this.copyStyle(this.textarea, this.content);
88        this.mirror.appendChild(this.content);
89
90        // Create overlay to display dirty area (for debug)
91        this.dirty = moaiEditor.createHTML('<div class="moaied-show-dirty-area"></div>');
92        this.mirror.appendChild(this.dirty);
93
94        // Create overlay to display scrollpoints (for debug)
95        const scrollpoints = moaiEditor.createHTML('<div id="moaied__scrollpoints_overlay" class="moaied-highlight-scrollpoints"></div>');
96        this.mirror.appendChild(scrollpoints);
97
98        // Make texarea text invisible (but keep cursor visible)
99        if (!this.settings.show.textarea)
100            this.textarea.style.color = 'rgba(0,0,0,0.01)';     // Firefox will "optimize out" a smooth scroll if the text is transparent (even if other things are not)
101            //this.textarea.style.color = 'transparent';
102
103        // Create flash box
104        this.flashbox = moaiEditor.createHTML('<div id="moaied__mirror_flashbox" class="moaied-flashbox"></div>');
105    }
106    // ────────────────────────────────────
107    disable () {
108        // Hide
109        this.mirror.classList.add('moaied-display-none');
110        // Set flag
111        this.enabled = false;
112    }
113    // ────────────────────────────────────
114    enable () {
115        // Show
116        this.mirror.classList.remove('moaied-display-none');
117        // Copy textarea lines to the watcher
118        this.watcher.lines = moaiEditor.layout.textarea.value.split("\n");
119        // Clear mirror lines
120        this.content.textContent = '';
121        // Render mirror lines (without syntax highlight yet)
122        for (let textline of this.watcher.lines)
123            this.content.appendChild (this.getNewLine(textline));
124        // Add the matches to mirror lines (and highlight syntax)
125        this.addMatches(moaiEditor.matches.matches);
126        // Recalc scroll points
127        moaiEditor.matches.recalcScrollPoints();
128        // Set flag
129        this.enabled = true;
130    }
131    // ────────────────────────────────────
132    onAjax (newMatches) {
133        // Add newly found syntax definitions to lines
134        this.addMatches(newMatches);
135    }
136    // ────────────────────────────────────
137    getLineRect(linenum, mode='local') {
138        this.debug_show_counts();
139
140        // Preparations
141        const element = this.content.childNodes[linenum];
142        const rect = element.getBoundingClientRect();
143        rect.height = rect.bottom - rect.top + 1;
144        rect.width = rect.right - rect.left;
145
146        // Viewport mode (coordinates relative to the visible area of the page)
147        if (mode == 'viewport')
148            return rect;
149
150        // Local mode (coordinates relative to the parent)
151        const parent = element.parentElement;
152        const parentRect = parent.getBoundingClientRect();
153        return {
154            top    : rect.top    - parentRect.top + parent.scrollTop,
155            bottom : rect.bottom - parentRect.top + parent.scrollTop,
156            left   : rect.left   - parentRect.left,
157            right  : rect.right  - parentRect.left,
158            height : rect.height,
159            width  : rect.width
160        };
161    }
162    // ────────────────────────────────────
163    addMatches(newMatches) {
164        // Add syntax to lines (this function is called when a preview is updated and new matches are found)
165        for (let match of newMatches)
166            for (let i=match.startline; i<=match.endline; i++) {
167                const line = this.content.childNodes[i];
168                line.highlight.match = match.syntax;
169                line.highlight.syntax = match.syntax;
170                this.highlight(line);
171            }
172    }
173    // ────────────────────────────────────
174    removeMatches(startline, endline) {
175        // Remove matches and syntax from mirror lines.
176        // This function is called when a preview is updated, and before the new matches are calculated.
177        for (let i=startline; i<=endline; i++) {
178            const line = this.content.childNodes[i];
179            line.highlight = {match:null, syntax:null, children:null};
180            this.highlight(line);
181        }
182    }
183    // ────────────────────────────────────
184    setWrap(value) {
185        moaiEditor.layout.elements.textarea.wrap = value;
186        dw_editor.setWrap (moaiEditor.layout.elements.textarea, value);
187        this.onTextareaStyleChange();
188    }
189    // ────────────────────────────────────
190    set pointerEvents(boolean) {
191        if (boolean)
192            this.textarea.style.pointerEvents = 'auto';
193        else
194            this.textarea.style.pointerEvents = 'none';
195    }
196    // ────────────────────────────────────
197    flash(flash, data=null) {
198        if (flash == 'remove') {
199            this.flashbox.remove();
200            return;
201        }
202        if (flash == 'remove')
203            this.flashbox.remove();
204        if (flash === null)
205            return;
206        this.flashbox = moaiEditor.createHTML('<div id="moaied__mirror_flashbox" class="moaied-flashbox"></div>');
207        if (flash === false)
208            this.flashbox.classList.add('red');
209        const start = this.getLineRect(data.startline);
210        const end = this.getLineRect(data.endline);
211        const height = end.bottom - start.top;
212        const width = start.width;
213        this.flashbox.style.top = start.top + 'px';
214        this.flashbox.style.left = '0px';
215        this.flashbox.style.width = width + 'px';
216        this.flashbox.style.height = height + 'px';
217        this.mirror.appendChild(this.flashbox);
218    }
219    // ────────────────────────────────────
220    get text() {
221        return this.textarea.value;
222    }
223    // ┌───────────────────────────────────┐
224    // │ Input events                      │
225    // └───────────────────────────────────┘
226
227    onTextareaScroll(event) {
228
229        if (!this.enabled)
230            return;
231        // Synchronize mirror scroll
232        if (!moaiEditor.scroll.sync.disabled) {
233            this.mirror.scrollTop = this.textarea.scrollTop;
234            this.content.scrollLeft = this.textarea.scrollLeft;
235        }
236        // Synchronize preview scroll
237        moaiEditor.scroll.sync.onScroll();
238        // Debugline
239        //this.scroll.debugLine();
240    }
241    // ────────────────────────────────────
242    onToolbarButtonInput() {
243        this.onTextChange();
244        this.onSelectionChange();
245    }
246    onTextChange() {
247        if (this.enabled) {;
248            this.watcher.onInput();
249            this.onSelectionChange();
250        }
251    }
252    onSelectionChange() {
253        if (this.enabled) {;
254            this.selection.onInput();
255        }
256    }
257    // ────────────────────────────────────
258    onTextareaResize() {
259        if (this.enabled)
260            this.onTextareaStyleChange();
261    }
262    // ┌───────────────────────────────────┐
263    // │ Private                           │
264    // └───────────────────────────────────┘
265
266    onTextareaStyleChange() {
267        this.copyStyle(this.textarea, this.content);
268
269            // Recalc scroll points
270            moaiEditor.matches.recalcScrollPoints();
271
272            // Fix textarea scroll
273            //this.textarea.style.scrollBehavior = 'auto';
274            this.textarea.scrollTop = this.mirror.scrollTop;
275            //this.textarea.style.scrollBehavior = 'smooth';
276
277    }
278    // ────────────────────────────────────
279    onTextChanged(change) {
280
281        // Update the mirror lines of text (remove and add)
282        this.updateMirrorLines(change);
283
284        // Update the matches and scroll positions
285        moaiEditor.matches.onTextChanged(change);
286
287        // Keep track of changed text sections (for partial preview)
288        moaiEditor.dirty.onTextChanged(change);
289    }
290    // ────────────────────────────────────
291    updateMirrorLines(change) {
292        this.start = Date.now();
293
294        // Handle de case where just one line is being edited (to make the syntax of that line persistent)
295        if (change.num.remove == 1  &&  change.num.insert == 1  &&  change.shift == 0) {
296            let i = change.num.keepfirst;
297            var text = this.watcher.lines[i];
298            var line = this.content.childNodes[i];
299            line.text = text;
300            line.highlight.match = null;
301            this.highlight(line);
302            return;
303        }
304        // Determine where the removals and insertions start ('null' means at the end)
305        var mirrornode = null;
306        if (this.content.childElementCount > change.num.keepfirst)
307            mirrornode = this.content.childNodes[change.num.keepfirst];
308
309        // Remove lines
310        var remove = change.num.remove;
311        while (remove > 0) {
312            var next = mirrornode.nextSibling;
313            this.content.removeChild (mirrornode);
314            mirrornode = next;
315            remove -= 1;
316        }
317        // Add lines (without syntax highlight yet)
318        for (let j=0; j<change.num.insert; j++) {
319            let i = j+change.num.keepfirst;
320            var text = this.watcher.lines[i];
321            var line = this.getNewLine(text);
322            this.content.insertBefore (line, mirrornode);   // If 'mirrornode' is null the insertion will happen at the end
323        }
324        // Check if the number of lines in the mirror is correct
325        const numlines_text = this.watcher.lines.length;
326        const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length;
327        if (numlines_text != numlines_mirror) {
328            const error = "moaiEditor.mirror.updateMirrorLines :: line-counts don't match ("+numlines_text+" vs "+numlines_mirror+")";
329            if (moaiEditor.strict)
330                throw new Error(error);
331            else
332                console.warn(error);
333            // ▆▆▆▆▆▆
334            // ▆▆▆▆▆▆ TODO: Recreate all lines if the error happens
335            // ▆▆▆▆▆▆
336        }
337        // Measure time elapsed
338        this.measureFrameTime("�� MIRROR UPDATE");
339    }
340    // ────────────────────────────────────
341    highlight(line) {
342
343        // Make sure the line is not empty (to be rendered by the browser)
344        if (line.text == '')
345            line.text = ' ';
346
347        // Highlight syntax
348        this.syntax.highlight(line);
349
350        // Highlight match
351        if (this.settings.show.matches) {
352            let match = line.highlight.match;
353            let overlay = line.querySelector('.moaied-highlight-match');
354            if (match === null)
355                overlay.classList.remove("on");
356            else {
357                overlay.classList.add("on");
358                overlay.classList.add(match);
359            }
360        }
361    }
362    // ────────────────────────────────────
363    getNewLine(text) {
364        var line = this.line.cloneNode(true);
365        line.text = text;
366        line.highlight = {match:null, syntax:null, children:null};
367        this.highlight(line);
368        return line;
369    }
370    // ────────────────────────────────────
371    copyStyle(source, destination) {
372
373        // Define presets
374        const properties = [
375
376            // Needed for the container
377            'display',
378            'position',
379            'top',
380            'left',
381            'border',
382            'margin',
383            'padding',
384            //'overflowY',
385            //'overflowX',
386            'boxSizing',
387
388            // Needed for the lines
389            'boxSizing',
390            'fontFamily',
391            'fontSize',
392            'fontWeight',
393            'letterSpacing',
394            'lineHeight',
395            'textDecoration',
396            'textIndent',
397            'textTransform',
398            'textWrap',
399            'whiteSpace',
400            'whiteSpaceCollapse',
401            'wordSpacing',
402            'wordWrap',
403        ];
404        // Copy styles
405        const sourceStyles = window.getComputedStyle(source);
406        for (let property of properties) {
407            //if (destination.style[property] != sourceStyles[property])
408            //    console.warn("  "+property+": "+JSON.stringify(sourceStyles[property]));
409            destination.style[property] = sourceStyles[property];
410            //console.warn("  "+property+": "+JSON.stringify(sourceStyles[property]));
411        }
412    }
413    // ────────────────────────────────────
414    debug_show_counts() {
415        const numlines_text = this.watcher.lines.length;
416        const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length;
417    }
418    // ────────────────────────────────────
419    measureFrameTime (text) {
420        requestAnimationFrame(() => {
421            const elapsed = Date.now()-this.start;
422        });
423    }
424}; // End Class
425
426// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆
427// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆
428
429/*  This class implements the 'scroll.max', 'scroll.top' and 'scroll.smooth'
430    getters and setters required for every editor.
431*/
432MoaiEditor.MirrorScroll = class {
433
434    constructor (outer) {
435        this.outer = outer;
436        this.smoothvalue = false;
437    }
438    // ────────────────────────────────────
439    get max() {
440        // Return the maximum scroll possible
441        return moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea);
442    }
443    get height() {
444        // Return the height of the scrollable area
445        return this.outer.mirror.scrollHeight;
446    }
447    // ────────────────────────────────────
448    get top() {
449        /* Obscure bug:
450           - Happens in Firefox 142 when the textarea's text color is set to transparent and scroll-behavior is set to 'smooth'.
451           - Given these conditions, when scrollTop is set to a value it will be not applied at all, until after scoll-behavior
452             is set to 'auto' again.
453           - This bug is only observed in Firefox but not in Chrome, Brave, Vivaldi, Opera, where setting scrollTop works
454             as expected, whether the textarea color is transparent or not.
455           - It seems Firefox is "optimizing out" a scroll when the text color is transparent, even if other things are still
456             still visible, for example: selected text, caret (cursor), spellchecking red underlines.
457           - This bug makes the MoaiEditor.ScrollTo class fail.
458           - If we disable the textarea smooth scroll the red spellchecker underlines of misspeled words will jump instantly
459             while the mirror will smooth smoothly which destroys the illusion the mirror is trying to achieve.
460           - Possible fixes:
461                a) Not relying on the smooth scrolling of the browser at all. But the idea is to rely as much as possible
462                   on browser smooth scroll as it is heavily optimized and possibly multithreaded as opposed to manual
463                   scroll which will run in the main thread.
464                b) Just disable the textarea spellcheck (but some user could miss it) - element.spellcheck = false;
465                   --> This works in practice but seeing the red underlines appear and disspaear is a bit distracting.
466                c) Let the browser scroll the mirror but sync the textarea manually (frame by frame).
467                   --> This actually does not work in practice as the synchronization is too choppy and looks terrible.
468                c) Try adding a tiny bit of opacity to the texarea text so Firefox is forced to treat it as a
469                   visible element and does not "optimize out" the scrolling.
470                   --> This works well.
471        */
472        return this.outer.textarea.scrollTop;
473        //return this.outer.mirror.scrollTop;
474    }
475    set top(value) {
476        this.outer.mirror.scrollTop = value;
477        this.outer.textarea.scrollTop = value;
478    }
479    // ────────────────────────────────────
480    get smooth() {
481        return this.smoothvalue;
482    }
483    set smooth(boolean) {
484        var value = 'auto';
485        if (boolean)
486            value = 'smooth';
487        this.smoothvalue = boolean;
488        this.outer.mirror.style.scrollBehavior = value;
489        this.outer.textarea.style.scrollBehavior = value;     // This was commented before to avoid Firefox issues
490    }
491    // ────────────────────────────────────
492    debugLine() {
493        // Exit if debugline is not enabled
494        const line = document.querySelector("#moai__debug div:nth-child(2)");
495        if (!line) return;
496        // Debug line
497        const scroll = Math.floor(this.outer.textarea.scrollTop);
498        const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea);
499        const text = "scroll:"+scroll+"  maxScroll:"+maxScroll;
500        line.textContent = text;
501    }
502}; // End Class
503
504
505