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