1/* DokuWiki MoaiEditor Mirror.js file 2 Version : 0.5a (May 6, 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 = '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.onInput(); 243 } 244 // ──────────────────────────────────── 245 onTextareaInput() { 246 this.onInput(); 247 } 248 // ──────────────────────────────────── 249 onTextareaKeydown() { 250 this.onInput(); 251 } 252 // ──────────────────────────────────── 253 onInput() { 254 if (this.enabled) {; 255 this.watcher.onInput(); 256 } 257 } 258 // ──────────────────────────────────── 259 onTextareaResize() { 260 if (this.enabled) 261 this.onTextareaStyleChange(); 262 } 263 // ┌───────────────────────────────────┐ 264 // │ Private │ 265 // └───────────────────────────────────┘ 266 267 onTextareaStyleChange() { 268 this.copyStyle(this.textarea, this.content); 269 270 // Recalc scroll points 271 moaiEditor.matches.recalcScrollPoints(); 272 273 // Fix textarea scroll 274 //this.textarea.style.scrollBehavior = 'auto'; 275 this.textarea.scrollTop = this.mirror.scrollTop; 276 //this.textarea.style.scrollBehavior = 'smooth'; 277 278 } 279 // ──────────────────────────────────── 280 onTextChanged(change) { 281 282 // Update the mirror lines of text (remove and add) 283 this.updateMirrorLines(change); 284 285 // Update the matches and scroll positions 286 moaiEditor.matches.onTextChanged(change); 287 288 // Keep track of changed text sections (for partial preview) 289 moaiEditor.dirty.onTextChanged(change); 290 } 291 // ──────────────────────────────────── 292 updateMirrorLines(change) { 293 this.start = Date.now(); 294 295 // Handle de case where just one line is being edited (to make the syntax of that line persistent) 296 if (change.num.remove == 1 && change.num.insert == 1 && change.shift == 0) { 297 let i = change.num.keepfirst; 298 var text = this.watcher.lines[i]; 299 var line = this.content.childNodes[i]; 300 line.text = text; 301 line.highlight.match = null; 302 this.highlight(line); 303 return; 304 } 305 // Determine where the removals and insertions start ('null' means at the end) 306 var mirrornode = null; 307 if (this.content.childElementCount > change.num.keepfirst) 308 mirrornode = this.content.childNodes[change.num.keepfirst]; 309 310 // Remove lines 311 var remove = change.num.remove; 312 while (remove > 0) { 313 var next = mirrornode.nextSibling; 314 this.content.removeChild (mirrornode); 315 mirrornode = next; 316 remove -= 1; 317 } 318 // Add lines (without syntax highlight yet) 319 for (let j=0; j<change.num.insert; j++) { 320 let i = j+change.num.keepfirst; 321 var text = this.watcher.lines[i]; 322 var line = this.getNewLine(text); 323 this.content.insertBefore (line, mirrornode); // If 'mirrornode' is null the insertion will happen at the end 324 } 325 // Check if the number of lines in the mirror is correct 326 const numlines_text = this.watcher.lines.length; 327 const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length; 328 if (numlines_text != numlines_mirror) { 329 const error = "moaiEditor.mirror.updateMirrorLines :: line-counts don't match ("+numlines_text+" vs "+numlines_mirror+")"; 330 if (moaiEditor.strict) 331 throw new Error(error); 332 else 333 console.warn(error); 334 // ▆▆▆▆▆▆ 335 // ▆▆▆▆▆▆ TODO: Recreate all lines if the error happens 336 // ▆▆▆▆▆▆ 337 } 338 // Measure time elapsed 339 this.measureFrameTime(" MIRROR UPDATE"); 340 } 341 // ──────────────────────────────────── 342 highlight(line) { 343 344 // Make sure the line is not empty (to be rendered by the browser) 345 if (line.text == '') 346 line.text = ' '; 347 348 // Highlight syntax 349 this.syntax.highlight(line); 350 351 // Highlight match 352 if (this.settings.show.matches) { 353 let match = line.highlight.match; 354 let overlay = line.querySelector('.moaied-highlight-match'); 355 if (match === null) 356 overlay.classList.remove("on"); 357 else { 358 overlay.classList.add("on"); 359 overlay.classList.add(match); 360 } 361 } 362 } 363 // ──────────────────────────────────── 364 getNewLine(text) { 365 var line = this.line.cloneNode(true); 366 line.text = text; 367 line.highlight = {match:null, syntax:null, children:null}; 368 this.highlight(line); 369 return line; 370 } 371 // ──────────────────────────────────── 372 copyStyle(source, destination) { 373 374 // Define presets 375 const properties = [ 376 377 // Needed for the container 378 'display', 379 'position', 380 'top', 381 'left', 382 'border', 383 'margin', 384 'padding', 385 //'overflowY', 386 //'overflowX', 387 'boxSizing', 388 389 // Needed for the lines 390 'boxSizing', 391 'fontFamily', 392 'fontSize', 393 'fontWeight', 394 'letterSpacing', 395 'lineHeight', 396 'textDecoration', 397 'textIndent', 398 'textTransform', 399 'textWrap', 400 'whiteSpace', 401 'whiteSpaceCollapse', 402 'wordSpacing', 403 'wordWrap', 404 ]; 405 // Copy styles 406 const sourceStyles = window.getComputedStyle(source); 407 for (let property of properties) { 408 //if (destination.style[property] != sourceStyles[property]) 409 // console.warn(" "+property+": "+JSON.stringify(sourceStyles[property])); 410 destination.style[property] = sourceStyles[property]; 411 //console.warn(" "+property+": "+JSON.stringify(sourceStyles[property])); 412 } 413 } 414 // ──────────────────────────────────── 415 debug_show_counts() { 416 const numlines_text = this.watcher.lines.length; 417 const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length; 418 } 419 // ──────────────────────────────────── 420 measureFrameTime (text) { 421 requestAnimationFrame(() => { 422 const elapsed = Date.now()-this.start; 423 }); 424 } 425}; // End Class 426 427// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 428// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 429 430/* This class implements the 'scroll.max', 'scroll.top' and 'scroll.smooth' 431 getters and setters required for every editor. 432*/ 433MoaiEditor.MirrorScroll = class { 434 435 constructor (outer) { 436 this.outer = outer; 437 this.smoothvalue = false; 438 } 439 // ──────────────────────────────────── 440 get max() { 441 // Return the maximum scroll possible 442 return moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); 443 } 444 get height() { 445 // Return the height of the scrollable area 446 return this.outer.mirror.scrollHeight; 447 } 448 // ──────────────────────────────────── 449 get top() { 450 /* Obscure bug: 451 - Happens in Firefox 142 when the textarea's text color is set to transparent and scroll-behavior is set to 'smooth'. 452 - Given these conditions, when scrollTop is set to a value it will be not applied at all, until after scoll-behavior 453 is set to 'auto' again. 454 - This bug is only observed in Firefox but not in Chrome, Brave, Vivaldi, Opera, where setting scrollTop works 455 as expected, whether the textarea color is transparent or not. 456 - It seems Firefox is "optimizing out" a scroll when the text color is transparent, even if other things are still 457 still visible, for example: selected text, caret (cursor), spellchecking red underlines. 458 - This bug makes the MoaiEditor.ScrollTo class fail. 459 - If we disable the textarea smooth scroll the red spellchecker underlines of misspeled words will jump instantly 460 while the mirror will smooth smoothly which destroys the illusion the mirror is trying to achieve. 461 - Possible fixes: 462 a) Not relying on the smooth scrolling of the browser at all. But the idea is to rely as much as possible 463 on browser smooth scroll as it is heavily optimized and possibly multithreaded as opposed to manual 464 scroll which will run in the main thread. 465 b) Just disable the textarea spellcheck (but some user could miss it) - element.spellcheck = false; 466 --> This works in practice but seeing the red underlines appear and disspaear is a bit distracting. 467 c) Let the browser scroll the mirror but sync the textarea manually (frame by frame). 468 --> This actually does not work in practice as the synchronization is too choppy and looks terrible. 469 c) Try adding a tiny bit of opacity to the texarea text so Firefox is forced to treat it as a 470 visible element and does not "optimize out" the scrolling. 471 --> This works well. 472 */ 473 return this.outer.textarea.scrollTop; 474 //return this.outer.mirror.scrollTop; 475 } 476 set top(value) { 477 this.outer.mirror.scrollTop = value; 478 this.outer.textarea.scrollTop = value; 479 } 480 // ──────────────────────────────────── 481 get smooth() { 482 return this.smoothvalue; 483 } 484 set smooth(boolean) { 485 var value = 'auto'; 486 if (boolean) 487 value = 'smooth'; 488 this.smoothvalue = boolean; 489 this.outer.mirror.style.scrollBehavior = value; 490 this.outer.textarea.style.scrollBehavior = value; // This was commented before to avoid Firefox issues 491 } 492 // ──────────────────────────────────── 493 debugLine() { 494 // Exit if debugline is not enabled 495 const line = document.querySelector("#moai__debug div:nth-child(2)"); 496 if (!line) return; 497 // Debug line 498 const scroll = Math.floor(this.outer.textarea.scrollTop); 499 const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); 500 const text = "scroll:"+scroll+" maxScroll:"+maxScroll; 501 line.textContent = text; 502 } 503}; // End Class 504 505 506