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    onTextChanged(change) {
278
279        // Update the mirror lines of text (remove and add)
280        this.updateMirrorLines(change);
281
282        // Update the matches and scroll positions
283        moaiEditor.matches.onTextChanged(change);
284
285        // Keep track of changed text sections (for partial preview)
286        moaiEditor.dirty.onTextChanged(change);
287    }
288
289    updateMirrorLines(change) {
290        this.start = Date.now();
291
292        // Handle de case where just one line is being edited (to make the syntax of that line persistent)
293        if (change.num.remove == 1  &&  change.num.insert == 1  &&  change.shift == 0) {
294            let i = change.num.keepfirst;
295            var text = this.watcher.lines[i];
296            var line = this.content.childNodes[i];
297            line.text = text;
298            line.highlight.match = null;
299            this.highlight(line);
300            return;
301        }
302        // Determine where the removals and insertions start ('null' means at the end)
303        var mirrornode = null;
304        if (this.content.childElementCount > change.num.keepfirst)
305            mirrornode = this.content.childNodes[change.num.keepfirst];
306
307        // Remove lines
308        var remove = change.num.remove;
309        while (remove > 0) {
310            var next = mirrornode.nextSibling;
311            this.content.removeChild (mirrornode);
312            mirrornode = next;
313            remove -= 1;
314        }
315        // Add lines (without syntax highlight yet)
316        for (let j=0; j<change.num.insert; j++) {
317            let i = j+change.num.keepfirst;
318            var text = this.watcher.lines[i];
319            var line = this.getNewLine(text);
320            this.content.insertBefore (line, mirrornode);   // If 'mirrornode' is null the insertion will happen at the end
321        }
322        // Check if the number of lines in the mirror is correct
323        const numlines_text = this.watcher.lines.length;
324        const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length;
325        if (numlines_text != numlines_mirror) {
326            const error = "moaiEditor.mirror.updateMirrorLines :: line-counts don't match ("+numlines_text+" vs "+numlines_mirror+")";
327            if (moaiEditor.strict)
328                throw new Error(error);
329            else
330                console.warn(error);
331            // ▆▆▆▆▆▆
332            // ▆▆▆▆▆▆ TODO: Recreate all lines if the error happens
333            // ▆▆▆▆▆▆
334        }
335        // Measure time elapsed
336        this.measureFrameTime("�� MIRROR UPDATE");
337    }
338
339    highlight(line) {
340
341        // Make sure the line is not empty (to be rendered by the browser)
342        if (line.text == '')
343            line.text = ' ';
344
345        // Highlight syntax
346        this.syntax.highlight(line);
347
348        // Highlight match
349        if (this.settings.show.matches) {
350            let match = line.highlight.match;
351            let overlay = line.querySelector('.moaied-highlight-match');
352            if (match === null)
353                overlay.classList.remove("on");
354            else {
355                overlay.classList.add("on");
356                overlay.classList.add(match);
357            }
358        }
359    }
360
361    getNewLine(text) {
362        var line = this.line.cloneNode(true);
363        line.text = text;
364        line.highlight = {match:null, syntax:null, children:null};
365        this.highlight(line);
366        return line;
367    }
368
369    copyStyle(source, destination) {
370
371        // Define presets
372        const properties = [
373
374            // Needed for the container
375            'display',
376            'position',
377            'top',
378            'left',
379            'border',
380            'margin',
381            'padding',
382            //'overflowY',
383            //'overflowX',
384            'boxSizing',
385
386            // Needed for the lines
387            'boxSizing',
388            'fontFamily',
389            'fontSize',
390            'fontWeight',
391            'letterSpacing',
392            'lineHeight',
393            'textDecoration',
394            'textIndent',
395            'textTransform',
396            'textWrap',
397            'whiteSpace',
398            'whiteSpaceCollapse',
399            'wordSpacing',
400            'wordWrap',
401        ];
402        // Copy styles
403        const sourceStyles = window.getComputedStyle(source);
404        for (let property of properties) {
405            //if (destination.style[property] != sourceStyles[property])
406            //    console.warn("  "+property+": "+JSON.stringify(sourceStyles[property]));
407            destination.style[property] = sourceStyles[property];
408            //console.warn("  "+property+": "+JSON.stringify(sourceStyles[property]));
409        }
410    }
411
412    debug_show_counts() {
413        const numlines_text = this.watcher.lines.length;
414        const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length;
415    }
416
417    measureFrameTime (text) {
418        requestAnimationFrame(() => {
419            const elapsed = Date.now()-this.start;
420        });
421    }
422}; // End Class
423
424// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆
425// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆
426
427/*  This class implements the 'scroll.max', 'scroll.top' and 'scroll.smooth'
428    getters and setters required for every editor.
429*/
430MoaiEditor.MirrorScroll = class {
431
432    constructor (outer) {
433        this.outer = outer;
434        this.smoothvalue = false;
435    }
436
437    get max() {
438        // Return the maximum scroll possible
439        return moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea);
440    }
441    get height() {
442        // Return the height of the scrollable area
443        return this.outer.mirror.scrollHeight;
444    }
445
446    get top() {
447        return this.outer.textarea.scrollTop;
448        //return this.outer.mirror.scrollTop;
449    }
450    set top(value) {
451        this.outer.mirror.scrollTop = value;
452        this.outer.textarea.scrollTop = value;
453    }
454
455    get smooth() {
456        return this.smoothvalue;
457    }
458    set smooth(boolean) {
459        var value = 'auto';
460        if (boolean)
461            value = 'smooth';
462        this.smoothvalue = boolean;
463        this.outer.mirror.style.scrollBehavior = value;
464        this.outer.textarea.style.scrollBehavior = value;     // This was commented before to avoid Firefox issues
465    }
466
467    debugLine() {
468        // Exit if debugline is not enabled
469        const line = document.querySelector("#moai__debug div:nth-child(2)");
470        if (!line) return;
471        // Debug line
472        const scroll = Math.floor(this.outer.textarea.scrollTop);
473        const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea);
474        const text = "scroll:"+scroll+"  maxScroll:"+maxScroll;
475        line.textContent = text;
476    }
477}; // End Class
478
479
480