1/*  DokuWiki MoaiEditor Mirror.js file
2    Version : 0.5 (May 5, 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 = 'transparent';
100
101        // Create flash box
102        this.flashbox = moaiEditor.createHTML('<div id="moaied__mirror_flashbox" class="moaied-flashbox"></div>');
103    }
104    // ────────────────────────────────────
105    disable () {
106        // Hide
107        this.mirror.classList.add('moaied-display-none');
108        // Set flag
109        this.enabled = false;
110    }
111    // ────────────────────────────────────
112    enable () {
113        // Show
114        this.mirror.classList.remove('moaied-display-none');
115        // Copy textarea lines to the watcher
116        this.watcher.lines = moaiEditor.layout.textarea.value.split("\n");
117        // Clear mirror lines
118        this.content.textContent = '';
119        // Render mirror lines (without syntax highlight yet)
120        for (let textline of this.watcher.lines)
121            this.content.appendChild (this.getNewLine(textline));
122        // Add the matches to mirror lines (and highlight syntax)
123        this.addMatches(moaiEditor.matches.matches);
124        // Recalc scroll points
125        moaiEditor.matches.recalcScrollPoints();
126        // Set flag
127        this.enabled = true;
128    }
129    // ────────────────────────────────────
130    onAjax (newMatches) {
131        // Add newly found syntax definitions to lines
132        this.addMatches(newMatches);
133    }
134    // ────────────────────────────────────
135    getLineRect(linenum, mode='local') {
136        this.debug_show_counts();
137
138        // Preparations
139        const element = this.content.childNodes[linenum];
140        const rect = element.getBoundingClientRect();
141        rect.height = rect.bottom - rect.top + 1;
142        rect.width = rect.right - rect.left;
143
144        // Viewport mode (coordinates relative to the visible area of the page)
145        if (mode == 'viewport')
146            return rect;
147
148        // Local mode (coordinates relative to the parent)
149        const parent = element.parentElement;
150        const parentRect = parent.getBoundingClientRect();
151        return {
152            top    : rect.top    - parentRect.top + parent.scrollTop,
153            bottom : rect.bottom - parentRect.top + parent.scrollTop,
154            left   : rect.left   - parentRect.left,
155            right  : rect.right  - parentRect.left,
156            height : rect.height,
157            width  : rect.width
158        };
159    }
160    // ────────────────────────────────────
161    addMatches(newMatches) {
162        // Add syntax to lines (this function is called when a preview is updated and new matches are found)
163        for (let match of newMatches)
164            for (let i=match.startline; i<=match.endline; i++) {
165                const line = this.content.childNodes[i];
166                line.highlight.match = match.syntax;
167                line.highlight.syntax = match.syntax;
168                this.highlight(line);
169            }
170    }
171    // ────────────────────────────────────
172    removeMatches(startline, endline) {
173        // Remove matches and syntax from mirror lines.
174        // This function is called when a preview is updated, and before the new matches are calculated.
175        for (let i=startline; i<=endline; i++) {
176            const line = this.content.childNodes[i];
177            line.highlight = {match:null, syntax:null, children:null};
178            this.highlight(line);
179        }
180    }
181    // ────────────────────────────────────
182    setWrap(value) {
183        moaiEditor.layout.elements.textarea.wrap = value;
184        dw_editor.setWrap (moaiEditor.layout.elements.textarea, value);
185        this.onTextareaStyleChange();
186    }
187    // ────────────────────────────────────
188    set pointerEvents(boolean) {
189        if (boolean)
190            this.textarea.style.pointerEvents = 'auto';
191        else
192            this.textarea.style.pointerEvents = 'none';
193    }
194    // ────────────────────────────────────
195    flash(flash, data=null) {
196        if (flash == 'remove') {
197            this.flashbox.remove();
198            return;
199        }
200        if (flash == 'remove')
201            this.flashbox.remove();
202        if (flash === null)
203            return;
204        this.flashbox = moaiEditor.createHTML('<div id="moaied__mirror_flashbox" class="moaied-flashbox"></div>');
205        if (flash === false)
206            this.flashbox.classList.add('red');
207        const start = this.getLineRect(data.startline);
208        const end = this.getLineRect(data.endline);
209        const height = end.bottom - start.top;
210        const width = start.width;
211        this.flashbox.style.top = start.top + 'px';
212        this.flashbox.style.left = '0px';
213        this.flashbox.style.width = width + 'px';
214        this.flashbox.style.height = height + 'px';
215        this.mirror.appendChild(this.flashbox);
216    }
217    // ────────────────────────────────────
218    get text() {
219        return this.textarea.value;
220    }
221    // ┌───────────────────────────────────┐
222    // │ Input events                      │
223    // └───────────────────────────────────┘
224
225    onTextareaScroll(event) {
226
227        if (!this.enabled)
228            return;
229        // Synchronize mirror scroll
230        if (!moaiEditor.scroll.sync.disabled) {
231            this.mirror.scrollTop = this.textarea.scrollTop;
232            this.content.scrollLeft = this.textarea.scrollLeft;
233        }
234        // Synchronize preview scroll
235        moaiEditor.scroll.sync.onScroll();
236        // Debugline
237        //this.scroll.debugLine();
238    }
239    // ────────────────────────────────────
240    onToolbarButtonInput() {
241        this.onInput();
242    }
243    // ────────────────────────────────────
244    onTextareaInput() {
245        this.onInput();
246    }
247    // ────────────────────────────────────
248    onTextareaKeydown() {
249        this.onInput();
250    }
251    // ────────────────────────────────────
252    onInput() {
253        if (this.enabled) {;
254            this.watcher.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           - In Firefox 142 when the textarea text color is set to transparent and scrollBehavior is set to 'smooth' and
451             scrollTop is set to a value, it will not take effect until scollBehavior is set to 'auto' again.
452           - This bug is only observed in Firefox but not in Chrome, Brave, Vivaldi, Opera, where setting scrollTop works
453             as expected, whether the textarea color is transparent or not.
454           - This bug makes the MoaiEditor.ScrollTo class fail.
455           - We are not seting the textarea scrollBehavior to 'smooth' anymore to prevent this issue in Firefox.
456        */
457        //return this.outer.textarea.scrollTop;
458        return this.outer.mirror.scrollTop;
459    }
460    set top(value) {
461        this.outer.mirror.scrollTop = value;
462        this.outer.textarea.scrollTop = value;
463    }
464    // ────────────────────────────────────
465    get smooth() {
466        return this.smoothvalue;
467    }
468    set smooth(boolean) {
469        var value = 'auto';
470        if (boolean)
471            value = 'smooth';
472        this.smoothvalue = boolean;
473        this.outer.mirror.style.scrollBehavior = value;
474        //this.outer.textarea.style.scrollBehavior = value;     // Don't set the textarea scrollBeahavior to smooth to avoid Firefox issues
475    }
476    // ────────────────────────────────────
477    debugLine() {
478        // Exit if debugline is not enabled
479        const line = document.querySelector("#moai__debug div:nth-child(2)");
480        if (!line) return;
481        // Debug line
482        const scroll = Math.floor(this.outer.textarea.scrollTop);
483        const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea);
484        const text = "scroll:"+scroll+"  maxScroll:"+maxScroll;
485        line.textContent = text;
486    }
487}; // End Class
488
489
490