1/*  DokuWiki MoaiEditor Mirror.js file
2    Author  : MoaiTools <info@moaitools.org>
3    License : GPL 3 (http://www.gnu.org/licenses/gpl.html) */
4
5/*  Textarea mirror class
6    ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
7    This is the native editor of MoaiEditor. The editing is done on the
8    textarea itself, but we add an overlay element which maintains a
9    separate div for each line of text, mirroring the textarea.
10
11    This mirror overlay is used for syntax highlighting and scroll
12    synchronization.
13
14    This class also detects textarea content changes with the help of
15    the watcher class.
16
17    DOM structure
18    ‾‾‾‾‾‾‾‾‾‾‾‾‾
19    #moaied__mirror                     -- Main container
20      .moaied-show-dirty-area           -- Debug overlay (shows dirty area)
21      #moaied__scrollpoints_overlay     -- Debug overlay (shows scroll points)
22      #moaied__mirror_content           -- Container for lines
23        .moaied-mirror-line             -- Line container
24          .moaied-highlight-match       -- Debug overlay (show matches)
25          .moaied-mirror-line-content   -- Actual content (highlighted)
26
27    Data structures
28    ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
29    Each mirror line DOM element carries some Javascript metadata information:
30
31        element.text:  the plain text of that line
32        element.highlight: {
33            match    : null,    -- E.g. 'paragraph','header'...   Used to display matches (debug option)
34            syntax   : null,    -- E.g. 'paragraph','header'...   Used to highlight the block level syntax of the line
35            children : null                                       Used to highlight the inline level syntax of children
36        }
37*/
38MoaiEditor.TextAreaMirror = class {
39
40    constructor() {
41
42        // Settings
43        this.settings = {show:{matches:false, textarea:false}};
44
45        // Constants
46        this.name = 'Native editor';   // Label to display to the user
47        //this.name = 'MoaiEditor';   // Label to display to the user
48        this.mirror = null;         // Container element
49        this.content = null;        // Container element for mirrored lines
50        this.textarea = null;       // The textarea element
51        this.line = null;           // Template for new mirror line elements
52
53        // Variables
54        this.enabled = false;       // Flag to indicate if this editor is enabled right now
55
56        // Objects
57        this.syntax = new MoaiEditor.Highlight(this);
58        this.scroll = new MoaiEditor.MirrorScroll(this);
59        this.watcher = new MoaiEditor.WatchText(this);
60        this.selection = new MoaiEditor.WatchSelection(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.onTextChange();
243        this.onSelectionChange();
244    }
245    onTextChange() {
246        if (this.enabled) {;
247            this.watcher.onInput();
248            this.onSelectionChange();
249        }
250    }
251    onSelectionChange() {
252        if (this.enabled) {;
253            this.selection.onInput();
254        }
255    }
256
257    onTextareaResize() {
258        if (this.enabled)
259            this.onTextareaStyleChange();
260    }
261    // ┌───────────────────────────────────┐
262    // │ Private                           │
263    // └───────────────────────────────────┘
264
265    onTextareaStyleChange() {
266        this.copyStyle(this.textarea, this.content);
267
268            // Recalc scroll points
269            moaiEditor.matches.recalcScrollPoints();
270
271            // Fix textarea scroll
272            //this.textarea.style.scrollBehavior = 'auto';
273            this.textarea.scrollTop = this.mirror.scrollTop;
274            //this.textarea.style.scrollBehavior = 'smooth';
275
276    }
277
278    onTextChanged(change) {
279
280        // Update the mirror lines of text (remove and add)
281        this.updateMirrorLines(change);
282
283        // Update the matches and scroll positions
284        moaiEditor.matches.onTextChanged(change);
285
286        // Keep track of changed text sections (for partial preview)
287        moaiEditor.dirty.onTextChanged(change);
288    }
289
290    updateMirrorLines(change) {
291        this.start = Date.now();
292
293        // Handle de case where just one line is being edited (to make the syntax of that line persistent)
294        if (change.num.remove == 1  &&  change.num.insert == 1  &&  change.shift == 0) {
295            let i = change.num.keepfirst;
296            var text = this.watcher.lines[i];
297            var line = this.content.childNodes[i];
298            line.text = text;
299            line.highlight.match = null;
300            this.highlight(line);
301            return;
302        }
303        // Determine where the removals and insertions start ('null' means at the end)
304        var mirrornode = null;
305        if (this.content.childElementCount > change.num.keepfirst)
306            mirrornode = this.content.childNodes[change.num.keepfirst];
307
308        // Remove lines
309        var remove = change.num.remove;
310        while (remove > 0) {
311            var next = mirrornode.nextSibling;
312            this.content.removeChild (mirrornode);
313            mirrornode = next;
314            remove -= 1;
315        }
316        // Add lines (without syntax highlight yet)
317        for (let j=0; j<change.num.insert; j++) {
318            let i = j+change.num.keepfirst;
319            var text = this.watcher.lines[i];
320            var line = this.getNewLine(text);
321            this.content.insertBefore (line, mirrornode);   // If 'mirrornode' is null the insertion will happen at the end
322        }
323        // Check if the number of lines in the mirror is correct
324        const numlines_text = this.watcher.lines.length;
325        const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length;
326        if (numlines_text != numlines_mirror) {
327            const error = "moaiEditor.mirror.updateMirrorLines :: line-counts don't match ("+numlines_text+" vs "+numlines_mirror+")";
328            if (moaiEditor.strict)
329                throw new Error(error);
330            else
331                console.warn(error);
332            // ▆▆▆▆▆▆
333            // ▆▆▆▆▆▆ TODO: Recreate all lines if the error happens
334            // ▆▆▆▆▆▆
335        }
336        // Measure time elapsed
337        this.measureFrameTime("�� MIRROR UPDATE");
338    }
339
340    highlight(line) {
341
342        // Make sure the line is not empty (to be rendered by the browser)
343        if (line.text == '')
344            line.text = ' ';
345
346        // Highlight syntax
347        this.syntax.highlight(line);
348
349        // Highlight match
350        if (this.settings.show.matches) {
351            let match = line.highlight.match;
352            let overlay = line.querySelector('.moaied-highlight-match');
353            if (match === null)
354                overlay.classList.remove("on");
355            else {
356                overlay.classList.add("on");
357                overlay.classList.add(match);
358            }
359        }
360    }
361
362    getNewLine(text) {
363        var line = this.line.cloneNode(true);
364        line.text = text;
365        line.highlight = {match:null, syntax:null, children:null};
366        this.highlight(line);
367        return line;
368    }
369
370    copyStyle(source, destination) {
371
372        // Define presets
373        const properties = [
374
375            // Needed for the container
376            'display',
377            'position',
378            'top',
379            'left',
380            'border',
381            'margin',
382            'padding',
383            //'overflowY',
384            //'overflowX',
385            'boxSizing',
386
387            // Needed for the lines
388            'boxSizing',
389            'fontFamily',
390            'fontSize',
391            'fontWeight',
392            'letterSpacing',
393            'lineHeight',
394            'textDecoration',
395            'textIndent',
396            'textTransform',
397            'textWrap',
398            'whiteSpace',
399            'whiteSpaceCollapse',
400            'wordSpacing',
401            'wordWrap',
402        ];
403        // Copy styles
404        const sourceStyles = window.getComputedStyle(source);
405        for (let property of properties) {
406            //if (destination.style[property] != sourceStyles[property])
407            //    console.warn("  "+property+": "+JSON.stringify(sourceStyles[property]));
408            destination.style[property] = sourceStyles[property];
409            //console.warn("  "+property+": "+JSON.stringify(sourceStyles[property]));
410        }
411    }
412
413    debug_show_counts() {
414        const numlines_text = this.watcher.lines.length;
415        const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length;
416    }
417
418    measureFrameTime (text) {
419        requestAnimationFrame(() => {
420            const elapsed = Date.now()-this.start;
421        });
422    }
423}; // End Class
424
425// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆
426// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆
427
428/*  This class implements the 'scroll.max', 'scroll.top' and 'scroll.smooth'
429    getters and setters required for every editor.
430*/
431MoaiEditor.MirrorScroll = class {
432
433    constructor (outer) {
434        this.outer = outer;
435        this.smoothvalue = false;
436    }
437
438    get max() {
439        // Return the maximum scroll possible
440        return moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea);
441    }
442    get height() {
443        // Return the height of the scrollable area
444        return this.outer.mirror.scrollHeight;
445    }
446
447    get top() {
448        return this.outer.textarea.scrollTop;
449        //return this.outer.mirror.scrollTop;
450    }
451    set top(value) {
452        this.outer.mirror.scrollTop = value;
453        this.outer.textarea.scrollTop = value;
454    }
455
456    get smooth() {
457        return this.smoothvalue;
458    }
459    set smooth(boolean) {
460        var value = 'auto';
461        if (boolean)
462            value = 'smooth';
463        this.smoothvalue = boolean;
464        this.outer.mirror.style.scrollBehavior = value;
465        this.outer.textarea.style.scrollBehavior = value;     // This was commented before to avoid Firefox issues
466    }
467
468    debugLine() {
469        // Exit if debugline is not enabled
470        const line = document.querySelector("#moai__debug div:nth-child(2)");
471        if (!line) return;
472        // Debug line
473        const scroll = Math.floor(this.outer.textarea.scrollTop);
474        const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea);
475        const text = "scroll:"+scroll+"  maxScroll:"+maxScroll;
476        line.textContent = text;
477    }
478}; // End Class
479
480
481