/* DokuWiki MoaiEditor Mirror.js file Version : 0.5 (May 5, 2026) Author : MoaiTools License : GPL 3 (http://www.gnu.org/licenses/gpl.html) */ /* Textarea mirror class ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ This is the native editor of MoaiEditor. The editing is done on the textarea itself, but we add an overlay element which maintains a separate div for each line of text, mirroring the textarea. This mirror overlay is used for syntax highlighting and scroll synchronization. This class also detects textarea content changes with the help of the watcher class. DOM structure ‾‾‾‾‾‾‾‾‾‾‾‾‾ #moaied__mirror -- Main container .moaied-show-dirty-area -- Debug overlay (shows dirty area) #moaied__scrollpoints_overlay -- Debug overlay (shows scroll points) #moaied__mirror_content -- Container for lines .moaied-mirror-line -- Line container .moaied-highlight-match -- Debug overlay (show matches) .moaied-mirror-line-content -- Actual content (highlighted) Data structures ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ Each mirror line DOM element carries some Javascript metadata information: element.text: the plain text of that line element.highlight: { match : null, -- E.g. 'paragraph','header'... Used to display matches (debug option) syntax : null, -- E.g. 'paragraph','header'... Used to highlight the block level syntax of the line children : null Used to highlight the inline level syntax of children } */ MoaiEditor.TextAreaMirror = class { constructor() { // Settings this.settings = {show:{matches:false, textarea:false}}; // Constants this.name = 'Native editor'; // Label to display to the user //this.name = 'MoaiEditor'; // Label to display to the user this.mirror = null; // Container element this.content = null; // Container element for mirrored lines this.textarea = null; // The textarea element this.line = null; // Template for new mirror line elements // Variables this.enabled = false; // Flag to indicate if this editor is enabled right now // Objects this.syntax = new MoaiEditor.Highlight(this); this.scroll = new MoaiEditor.MirrorScroll(this); this.watcher = new MoaiEditor.WatchTextChanges(this); } // ┌───────────────────────────────────┐ // │ Public │ // └───────────────────────────────────┘ init() { // Get node handles this.textarea = moaiEditor.layout.textarea; // Create mirror line template var line = moaiEditor.createHTML('
'); var content = moaiEditor.createHTML(''); var match = moaiEditor.createHTML('
'); line.appendChild(content); if (this.settings.show.matches) line.appendChild(match); this.line = line; // Create main mirror container (it scrolls) this.mirror = moaiEditor.createHTML('
'); moaiEditor.layout.editpane.appendChild(this.mirror); // Create container for the content this.content = moaiEditor.createHTML('
'); this.copyStyle(this.textarea, this.content); this.mirror.appendChild(this.content); // Create overlay to display dirty area (for debug) this.dirty = moaiEditor.createHTML('
'); this.mirror.appendChild(this.dirty); // Create overlay to display scrollpoints (for debug) const scrollpoints = moaiEditor.createHTML('
'); this.mirror.appendChild(scrollpoints); // Make texarea text invisible (but keep cursor visible) if (!this.settings.show.textarea) this.textarea.style.color = 'transparent'; // Create flash box this.flashbox = moaiEditor.createHTML('
'); } // ──────────────────────────────────── disable () { // Hide this.mirror.classList.add('moaied-display-none'); // Set flag this.enabled = false; } // ──────────────────────────────────── enable () { // Show this.mirror.classList.remove('moaied-display-none'); // Copy textarea lines to the watcher this.watcher.lines = moaiEditor.layout.textarea.value.split("\n"); // Clear mirror lines this.content.textContent = ''; // Render mirror lines (without syntax highlight yet) for (let textline of this.watcher.lines) this.content.appendChild (this.getNewLine(textline)); // Add the matches to mirror lines (and highlight syntax) this.addMatches(moaiEditor.matches.matches); // Recalc scroll points moaiEditor.matches.recalcScrollPoints(); // Set flag this.enabled = true; } // ──────────────────────────────────── onAjax (newMatches) { // Add newly found syntax definitions to lines this.addMatches(newMatches); } // ──────────────────────────────────── getLineRect(linenum, mode='local') { this.debug_show_counts(); // Preparations const element = this.content.childNodes[linenum]; const rect = element.getBoundingClientRect(); rect.height = rect.bottom - rect.top + 1; rect.width = rect.right - rect.left; // Viewport mode (coordinates relative to the visible area of the page) if (mode == 'viewport') return rect; // Local mode (coordinates relative to the parent) const parent = element.parentElement; const parentRect = parent.getBoundingClientRect(); return { top : rect.top - parentRect.top + parent.scrollTop, bottom : rect.bottom - parentRect.top + parent.scrollTop, left : rect.left - parentRect.left, right : rect.right - parentRect.left, height : rect.height, width : rect.width }; } // ──────────────────────────────────── addMatches(newMatches) { // Add syntax to lines (this function is called when a preview is updated and new matches are found) for (let match of newMatches) for (let i=match.startline; i<=match.endline; i++) { const line = this.content.childNodes[i]; line.highlight.match = match.syntax; line.highlight.syntax = match.syntax; this.highlight(line); } } // ──────────────────────────────────── removeMatches(startline, endline) { // Remove matches and syntax from mirror lines. // This function is called when a preview is updated, and before the new matches are calculated. for (let i=startline; i<=endline; i++) { const line = this.content.childNodes[i]; line.highlight = {match:null, syntax:null, children:null}; this.highlight(line); } } // ──────────────────────────────────── setWrap(value) { moaiEditor.layout.elements.textarea.wrap = value; dw_editor.setWrap (moaiEditor.layout.elements.textarea, value); this.onTextareaStyleChange(); } // ──────────────────────────────────── set pointerEvents(boolean) { if (boolean) this.textarea.style.pointerEvents = 'auto'; else this.textarea.style.pointerEvents = 'none'; } // ──────────────────────────────────── flash(flash, data=null) { if (flash == 'remove') { this.flashbox.remove(); return; } if (flash == 'remove') this.flashbox.remove(); if (flash === null) return; this.flashbox = moaiEditor.createHTML('
'); if (flash === false) this.flashbox.classList.add('red'); const start = this.getLineRect(data.startline); const end = this.getLineRect(data.endline); const height = end.bottom - start.top; const width = start.width; this.flashbox.style.top = start.top + 'px'; this.flashbox.style.left = '0px'; this.flashbox.style.width = width + 'px'; this.flashbox.style.height = height + 'px'; this.mirror.appendChild(this.flashbox); } // ──────────────────────────────────── get text() { return this.textarea.value; } // ┌───────────────────────────────────┐ // │ Input events │ // └───────────────────────────────────┘ onTextareaScroll(event) { if (!this.enabled) return; // Synchronize mirror scroll if (!moaiEditor.scroll.sync.disabled) { this.mirror.scrollTop = this.textarea.scrollTop; this.content.scrollLeft = this.textarea.scrollLeft; } // Synchronize preview scroll moaiEditor.scroll.sync.onScroll(); // Debugline //this.scroll.debugLine(); } // ──────────────────────────────────── onToolbarButtonInput() { this.onInput(); } // ──────────────────────────────────── onTextareaInput() { this.onInput(); } // ──────────────────────────────────── onTextareaKeydown() { this.onInput(); } // ──────────────────────────────────── onInput() { if (this.enabled) {; this.watcher.onInput(); } } // ──────────────────────────────────── onTextareaResize() { if (this.enabled) this.onTextareaStyleChange(); } // ┌───────────────────────────────────┐ // │ Private │ // └───────────────────────────────────┘ onTextareaStyleChange() { this.copyStyle(this.textarea, this.content); // Recalc scroll points moaiEditor.matches.recalcScrollPoints(); // Fix textarea scroll //this.textarea.style.scrollBehavior = 'auto'; this.textarea.scrollTop = this.mirror.scrollTop; //this.textarea.style.scrollBehavior = 'smooth'; } // ──────────────────────────────────── onTextChanged(change) { // Update the mirror lines of text (remove and add) this.updateMirrorLines(change); // Update the matches and scroll positions moaiEditor.matches.onTextChanged(change); // Keep track of changed text sections (for partial preview) moaiEditor.dirty.onTextChanged(change); } // ──────────────────────────────────── updateMirrorLines(change) { this.start = Date.now(); // Handle de case where just one line is being edited (to make the syntax of that line persistent) if (change.num.remove == 1 && change.num.insert == 1 && change.shift == 0) { let i = change.num.keepfirst; var text = this.watcher.lines[i]; var line = this.content.childNodes[i]; line.text = text; line.highlight.match = null; this.highlight(line); return; } // Determine where the removals and insertions start ('null' means at the end) var mirrornode = null; if (this.content.childElementCount > change.num.keepfirst) mirrornode = this.content.childNodes[change.num.keepfirst]; // Remove lines var remove = change.num.remove; while (remove > 0) { var next = mirrornode.nextSibling; this.content.removeChild (mirrornode); mirrornode = next; remove -= 1; } // Add lines (without syntax highlight yet) for (let j=0; j { const elapsed = Date.now()-this.start; }); } }; // End Class // ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ // ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ /* This class implements the 'scroll.max', 'scroll.top' and 'scroll.smooth' getters and setters required for every editor. */ MoaiEditor.MirrorScroll = class { constructor (outer) { this.outer = outer; this.smoothvalue = false; } // ──────────────────────────────────── get max() { // Return the maximum scroll possible return moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); } get height() { // Return the height of the scrollable area return this.outer.mirror.scrollHeight; } // ──────────────────────────────────── get top() { /* Obscure bug: - In Firefox 142 when the textarea text color is set to transparent and scrollBehavior is set to 'smooth' and scrollTop is set to a value, it will not take effect until scollBehavior is set to 'auto' again. - This bug is only observed in Firefox but not in Chrome, Brave, Vivaldi, Opera, where setting scrollTop works as expected, whether the textarea color is transparent or not. - This bug makes the MoaiEditor.ScrollTo class fail. - We are not seting the textarea scrollBehavior to 'smooth' anymore to prevent this issue in Firefox. */ //return this.outer.textarea.scrollTop; return this.outer.mirror.scrollTop; } set top(value) { this.outer.mirror.scrollTop = value; this.outer.textarea.scrollTop = value; } // ──────────────────────────────────── get smooth() { return this.smoothvalue; } set smooth(boolean) { var value = 'auto'; if (boolean) value = 'smooth'; this.smoothvalue = boolean; this.outer.mirror.style.scrollBehavior = value; //this.outer.textarea.style.scrollBehavior = value; // Don't set the textarea scrollBeahavior to smooth to avoid Firefox issues } // ──────────────────────────────────── debugLine() { // Exit if debugline is not enabled const line = document.querySelector("#moai__debug div:nth-child(2)"); if (!line) return; // Debug line const scroll = Math.floor(this.outer.textarea.scrollTop); const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); const text = "scroll:"+scroll+" maxScroll:"+maxScroll; line.textContent = text; } }; // End Class