1/*  DokuWiki MoaiEditor Cm_main.js file
2    Author  : MoaiTools <info@moaitools.org>
3    License : GPL 3 (http://www.gnu.org/licenses/gpl.html) */
4
5/*  CodeMirror main class
6    ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
7    Handles the CodeMirror plugin integration into MoaiEditor.
8
9
10    Codemirror DOM structure
11    ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
12    .CodeMirror                                               -- Main container
13      .CodeMirror-scroll                                      -- Scrolling element
14        .CodeMirror-sizer                                     -- Full height element (seems to hold the height of the full document even if few lines are rendered)
15          <div style="position:relative; top:233px;">         -- (↕)  Being moved up and down with respect to the parent (has some padding)
16            .CodeMirror-lines                                 -- (=h) Changes height constantly depending on the number of rendered lines (position:static)
17              <div style="position:relative; outline:none;">  -- (=h) (position:relative)
18                .CodeMirror-code                              -- (=h) (position:static)
19                  lines
20                                                                 (=h): Same height as parent (margin 0, padding 0)
21*/
22MoaiEditor.Codemirror = class {
23
24    constructor() {
25
26        // Constants
27        this.name = 'CodeMirror';   // Label to display to the user
28        this.isEditor = true;       // Identify editor plugins
29
30        // Variables
31        this.enabled = false;       // Editor enabled flag (
32        this.toggler = null;        // Button that starts/stops CodeMirror
33        this.editor = null;         // Editor object (or null if CM is disabled)
34        this.settings = null;       // Button which displays/hides the CodeMirror settings menu (gear icon)
35        this.numHints = 0;          // Show animated hints only a limited number of times
36        this.container = null;      // Codemirror container element (.Codemirror)
37
38        // Query selectors
39        this.selectors = {
40            'textarea'     : "#wiki__text",
41            'container'    : ".CodeMirror",                                 // Codemirror DOM container
42            'toggler'      : "ul.cm-settings-menu>li:last-child a",         // Button that starts/stops CodeMirror
43            'native'       : "ul.cm-settings-menu>li:last-child a span.ui-icon-check",  // Checkmark if CodeMirror is disabled
44            'divider'      : "ul.cm-settings-menu li.ui-menu-divider",      // Menu divider before the toggler button (will be hidden)
45            'settingsBtn'  : "#size__ctl img.cm-settings-button",           // Button which displays/hides the CodeMirror settings menu (gear icon)
46        };
47        // Objects
48        this.watcher = new MoaiEditor.CodemirrorWatcher(this);
49        this.scroll = new MoaiEditor.CodemirrorScroll(this);
50    }
51    // ┌───────────────────────────────────┐
52    // │ Public                            │
53    // └───────────────────────────────────┘
54
55    exists () {
56        this.toggler = this.element('toggler');
57        if (this.toggler === null)
58            return false;
59        return true;
60    }
61
62    initBeforeLayout () {
63
64        if (!this.exists())
65            return;
66        // Put the settings img/button inside a container (because <img> tags cannot have children) and add an animated hint
67        this.settings = this.element('settingsBtn');
68        this.settingsWrapper = moaiEditor.createHTML('<div id="moaied__cm_settings_wrapper"></div>');
69        this.settingsWrapper.appendChild(this.settings);
70        this.settingsHint = new MoaiEditor.Hint('arrow-right', 'Settings', this.settingsWrapper, 30, -3);
71
72        // Style the settings img/button and wrapper
73        this.settings.style.margin = '0';
74        this.settings.style.marginBottom = '15px';
75        this.settingsWrapper.style.display = 'none';
76        this.settingsWrapper.style.position = 'relative';
77
78        // Disable CodeMirror (if it was enabled before starting MoaiEditor)
79        if (this.state == 'enabled')
80            this.disable();
81
82        // The user should enable/disable CodeMirror only through MoaiEditor now
83        this.element('divider').style.display = 'none';     // Hide divider
84        this.toggler.parentNode.style.display = 'none';     // Hide toggler button
85    }
86
87    initAfterLayout () {
88
89        if (!this.exists())
90            return;
91        // Move the settings menu to the new layout
92        moaiEditor.layout.bottomRight.appendChild(this.settingsWrapper);
93        // Create flash box
94        this.flashbox = moaiEditor.createHTML('<div id="moaied__codemirror_flashbox" class="moaied-flashbox"></div>');
95    }
96
97    disable () {
98        if (this.state == 'disabled')
99            return;
100        // Stop CodeMirror
101        this.stop();
102        // Hide the settings
103        this.settingsWrapper.style.display = 'none';
104        // Hide the hint
105        this.settingsHint.disable();
106        // Set flag
107        this.enabled = false;
108    }
109
110    enable (clicked=false) {
111        if (this.state == 'enabled')
112            return;
113        /* Disable plugin»codemirror»autoheight=1, which sets viewportMargin=Infinty.
114         * This option makes sense in the vanilla editor to avoid nested scrolling, but it is not really
115         * needed in MoaiEditor where there is no nested scrolling anyways. It will slow down or freeze
116         * the browser on big documents, and therefore it is recommended against in the manual:
117         * https://codemirror.net/5/doc/manual.html#option_viewportMargin
118         * Note: Trying to achieve the same result by doing this: this.editor.setOption('viewportMargin', 20)
119         *       inmediately after starting CodeMirror will sometimes trigger the following error:
120         *       "can't access property 'setOption', a is null"   ← 'a' being a minified name.
121         */
122        JSINFO.plugin_codemirror.autoheight = 0;
123        // Start CodeMirror
124        this.start();
125        // Get DOM elements (.Codemirror)
126        this.container = this.element('container');
127        this.scroller = this.container.querySelector(".CodeMirror-scroll");
128        // Create overlay to display dirty area (for debug)
129        this.dirty = moaiEditor.createHTML('<div class="moaied-show-dirty-area"></div>');
130        this.container.querySelector(".CodeMirror-sizer").appendChild(this.dirty);
131        // Get the editor object
132        this.editor = this.container.CodeMirror;
133        // Style the container
134        this.container.style.position = 'absolute';
135        this.container.style.top = '0';
136        this.container.style.left = '0';
137        this.container.style.width = '100%';
138        this.container.style.height = '100%';
139        this.container.style.border = 'none';
140        this.container.style.margin = '0';
141        // Display the settings
142        this.settingsWrapper.style.display = 'block';
143        // Show the hint when the user activates Codemirror (maximum 2 times)
144        if (clicked  &&  this.numHints < 2) {
145            this.settingsHint.start();
146            this.numHints += 1;
147        }
148        // Add event listeners
149        this.editor.on('scroll', this.onScroll.bind(this));
150        this.editor.on('refresh', this.onRefresh.bind(this));           // Fires on font size changes
151        this.editor.on('change', this.onDocumentChange.bind(this));
152        new ResizeObserver(this.onResize.bind(this)).observe(this.container);
153        // Copy textarea lines to the watcher
154        this.watcher.lines = this.editor.getValue().split("\n");
155        // Recalc scroll points
156        moaiEditor.matches.recalcScrollPoints();
157        // Set flag
158        this.enabled = true;
159    }
160
161    onAjax () {
162        // This method is required but we don't use it
163    }
164
165    getLineRect(linenum, mode='local') {
166
167        // Preparations
168        if (mode == 'viewport')
169            mode = 'window';
170        const top = this.editor.heightAtLine(linenum, mode);
171        const bottom = this.editor.heightAtLine(linenum+1, mode);
172        const height = bottom-top;
173        return {
174            top: top,
175            bottom: bottom,
176            height: height
177        };
178    }
179
180    addMatches(newMatches) {
181        // This method is required but we don't use it
182    }
183    removeMatches(startline, endline) {
184        // This method is required but we don't use it
185    }
186
187    setWrap(value) {
188        // CodeMirror has a hook on 'dw_editor.setWrap()'
189        dw_editor.setWrap (moaiEditor.layout.elements.textarea, value);
190        moaiEditor.matches.recalcScrollPoints();
191    }
192
193    set pointerEvents(boolean) {
194        if (boolean)
195            this.container.style.pointerEvents = 'auto';
196        else
197            this.container.style.pointerEvents = 'none';
198    }
199
200    flash(flash, data=null) {
201        if (flash == 'remove') {
202            this.flashbox.remove();
203            return;
204        }
205        if (flash === null)
206            return;
207        this.flashbox.remove();
208        this.flashbox = moaiEditor.createHTML('<div id="moaied__codemirror_flashbox" class="moaied-flashbox"></div>');
209        if (flash === false)
210            this.flashbox.classList.add('red');
211        const start = this.getLineRect(data.startline);
212        const end = this.getLineRect(data.endline);
213        const height = end.bottom - start.top;
214        const width = this.editor.getScrollInfo().width;
215        this.flashbox.style.top = start.top + 'px';
216        this.flashbox.style.left = '0px';
217        this.flashbox.style.width = width + 'px';
218        this.flashbox.style.height = height + 'px';
219        this.scroller.appendChild(this.flashbox);
220    }
221
222    get text() {
223        return this.editor.getValue();
224    }
225    // ┌───────────────────────────────────┐
226    // │ Input events                      │
227    // └───────────────────────────────────┘
228
229    onScroll() {
230        // Make sure we update the scroll position of newly rendered lines to avoid CodeMirror approximation errors
231        this.scroll.onScroll();
232        // Synchronize preview scroll
233        moaiEditor.scroll.sync.onScroll();
234    }
235
236
237    onToolbarButtonInput() {
238        this.onInput();
239    }
240
241    onDocumentChange() {
242        this.onInput();
243    }
244
245    onInput() {
246        this.watcher.onInput();
247    }
248
249    onRefresh() {
250        moaiEditor.matches.recalcScrollPoints();
251    }
252
253    onResize() {
254        moaiEditor.matches.recalcScrollPoints();
255    }
256    // ┌───────────────────────────────────┐
257    // │ Private                           │
258    // └───────────────────────────────────┘
259
260    element(key) {
261        return document.querySelector(this.selectors[key]);
262    }
263
264    onTextChanged(change) {
265
266        // Update the matches and scroll positions
267        moaiEditor.matches.onTextChanged(change);
268
269        // Keep track of changed text sections (for partial preview)
270        moaiEditor.dirty.onTextChanged(change);
271    }
272
273    start () {
274        if (this.state !== 'disabled')
275            return;
276        this.toggler.click();
277        if (this.state !== 'enabled')
278            this.crash();
279    }
280    stop () {
281        if (this.state !== 'enabled')
282            return;
283        this.toggler.click();
284        if (this.state !== 'disabled')
285            this.crash();
286    }
287
288    crash () {
289        // Try to destroy the editor gracefully
290        try {
291            // If the button shows CM is enabled, click it
292            if (!this.element('native'))
293                this.toggler.click();
294            // If the editor still exists
295            const container = this.element('container');
296            const editor = container.CodeMirror;
297            editor.toTextArea();
298            // Hide the settings and the hint
299            this.settingsWrapper.style.display = 'none';
300            this.settingsHint.disable();
301        } catch ({name, message}) {
302            console.warn("moaiEditor.codemirror: "+name+": "+message);
303        }
304        // Error messages
305        console.warn(`MoaiEditor Error :: Something unexpected happened with CodeMirror. Please copy or screenshot the previous error and send it to the MoaiEditor developer.`);
306        moaiEditor.dokuMessage(`Something unexpected happened with CodeMirror. It is not recommended to edit a document when this error is present. You could try disabling CodeMirror before starting MoaiEditor. See the browser's console (F12) for more information.`, -1);
307        // Inform the editor main class
308        moaiEditor.editor.crash('codemirror');
309    }
310
311    // Return the state of CodeMirror ('enabled', 'disabled', 'error')
312    get state () {
313
314        const tx_display   = window.getComputedStyle(this.element('textarea')).display;     // CodeMirror will add an inline 'display:none' declaration
315        const cm_instances = document.querySelectorAll(".CodeMirror").length;
316        const cm_toggler   = !this.element('native');
317
318        var state = 'error';
319        if      ( tx_display !== 'none'  &&  cm_instances === 0  &&  !cm_toggler )      // Disabled
320            state = 'disabled';
321        else if ( tx_display === 'none'  &&  cm_instances === 1  &&  cm_toggler )       // Enabled
322            state = 'enabled';
323
324        if (state == 'error') {
325            console.warn(`The state of CodeMirror is not what MoaiEditor was expecting:`);
326            console.warn(`    tx_display = ${JSON.stringify(tx_display)}`);
327            console.warn(`    cm_instances = ${JSON.stringify(cm_instances)}`);
328            console.warn(`    cm_toggler = ${JSON.stringify(cm_toggler)}`);
329            this.crash();
330        }
331        return state;
332    }
333
334}; // End Class
335
336
337
338