1/* DokuWiki MoaiEditor Mirror.js file 2 Version : 0.5 (May 5, 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 = 'transparent'; 100 101 // Create flash box 102 this.flashbox = moaiEditor.createHTML('<div id="moaied__mirror_flashbox" class="moaied-flashbox"></div>'); 103 } 104 // ──────────────────────────────────── 105 disable () { 106 // Hide 107 this.mirror.classList.add('moaied-display-none'); 108 // Set flag 109 this.enabled = false; 110 } 111 // ──────────────────────────────────── 112 enable () { 113 // Show 114 this.mirror.classList.remove('moaied-display-none'); 115 // Copy textarea lines to the watcher 116 this.watcher.lines = moaiEditor.layout.textarea.value.split("\n"); 117 // Clear mirror lines 118 this.content.textContent = ''; 119 // Render mirror lines (without syntax highlight yet) 120 for (let textline of this.watcher.lines) 121 this.content.appendChild (this.getNewLine(textline)); 122 // Add the matches to mirror lines (and highlight syntax) 123 this.addMatches(moaiEditor.matches.matches); 124 // Recalc scroll points 125 moaiEditor.matches.recalcScrollPoints(); 126 // Set flag 127 this.enabled = true; 128 } 129 // ──────────────────────────────────── 130 onAjax (newMatches) { 131 // Add newly found syntax definitions to lines 132 this.addMatches(newMatches); 133 } 134 // ──────────────────────────────────── 135 getLineRect(linenum, mode='local') { 136 this.debug_show_counts(); 137 138 // Preparations 139 const element = this.content.childNodes[linenum]; 140 const rect = element.getBoundingClientRect(); 141 rect.height = rect.bottom - rect.top + 1; 142 rect.width = rect.right - rect.left; 143 144 // Viewport mode (coordinates relative to the visible area of the page) 145 if (mode == 'viewport') 146 return rect; 147 148 // Local mode (coordinates relative to the parent) 149 const parent = element.parentElement; 150 const parentRect = parent.getBoundingClientRect(); 151 return { 152 top : rect.top - parentRect.top + parent.scrollTop, 153 bottom : rect.bottom - parentRect.top + parent.scrollTop, 154 left : rect.left - parentRect.left, 155 right : rect.right - parentRect.left, 156 height : rect.height, 157 width : rect.width 158 }; 159 } 160 // ──────────────────────────────────── 161 addMatches(newMatches) { 162 // Add syntax to lines (this function is called when a preview is updated and new matches are found) 163 for (let match of newMatches) 164 for (let i=match.startline; i<=match.endline; i++) { 165 const line = this.content.childNodes[i]; 166 line.highlight.match = match.syntax; 167 line.highlight.syntax = match.syntax; 168 this.highlight(line); 169 } 170 } 171 // ──────────────────────────────────── 172 removeMatches(startline, endline) { 173 // Remove matches and syntax from mirror lines. 174 // This function is called when a preview is updated, and before the new matches are calculated. 175 for (let i=startline; i<=endline; i++) { 176 const line = this.content.childNodes[i]; 177 line.highlight = {match:null, syntax:null, children:null}; 178 this.highlight(line); 179 } 180 } 181 // ──────────────────────────────────── 182 setWrap(value) { 183 moaiEditor.layout.elements.textarea.wrap = value; 184 dw_editor.setWrap (moaiEditor.layout.elements.textarea, value); 185 this.onTextareaStyleChange(); 186 } 187 // ──────────────────────────────────── 188 set pointerEvents(boolean) { 189 if (boolean) 190 this.textarea.style.pointerEvents = 'auto'; 191 else 192 this.textarea.style.pointerEvents = 'none'; 193 } 194 // ──────────────────────────────────── 195 flash(flash, data=null) { 196 if (flash == 'remove') { 197 this.flashbox.remove(); 198 return; 199 } 200 if (flash == 'remove') 201 this.flashbox.remove(); 202 if (flash === null) 203 return; 204 this.flashbox = moaiEditor.createHTML('<div id="moaied__mirror_flashbox" class="moaied-flashbox"></div>'); 205 if (flash === false) 206 this.flashbox.classList.add('red'); 207 const start = this.getLineRect(data.startline); 208 const end = this.getLineRect(data.endline); 209 const height = end.bottom - start.top; 210 const width = start.width; 211 this.flashbox.style.top = start.top + 'px'; 212 this.flashbox.style.left = '0px'; 213 this.flashbox.style.width = width + 'px'; 214 this.flashbox.style.height = height + 'px'; 215 this.mirror.appendChild(this.flashbox); 216 } 217 // ──────────────────────────────────── 218 get text() { 219 return this.textarea.value; 220 } 221 // ┌───────────────────────────────────┐ 222 // │ Input events │ 223 // └───────────────────────────────────┘ 224 225 onTextareaScroll(event) { 226 227 if (!this.enabled) 228 return; 229 // Synchronize mirror scroll 230 if (!moaiEditor.scroll.sync.disabled) { 231 this.mirror.scrollTop = this.textarea.scrollTop; 232 this.content.scrollLeft = this.textarea.scrollLeft; 233 } 234 // Synchronize preview scroll 235 moaiEditor.scroll.sync.onScroll(); 236 // Debugline 237 //this.scroll.debugLine(); 238 } 239 // ──────────────────────────────────── 240 onToolbarButtonInput() { 241 this.onInput(); 242 } 243 // ──────────────────────────────────── 244 onTextareaInput() { 245 this.onInput(); 246 } 247 // ──────────────────────────────────── 248 onTextareaKeydown() { 249 this.onInput(); 250 } 251 // ──────────────────────────────────── 252 onInput() { 253 if (this.enabled) {; 254 this.watcher.onInput(); 255 } 256 } 257 // ──────────────────────────────────── 258 onTextareaResize() { 259 if (this.enabled) 260 this.onTextareaStyleChange(); 261 } 262 // ┌───────────────────────────────────┐ 263 // │ Private │ 264 // └───────────────────────────────────┘ 265 266 onTextareaStyleChange() { 267 this.copyStyle(this.textarea, this.content); 268 269 // Recalc scroll points 270 moaiEditor.matches.recalcScrollPoints(); 271 272 // Fix textarea scroll 273 //this.textarea.style.scrollBehavior = 'auto'; 274 this.textarea.scrollTop = this.mirror.scrollTop; 275 //this.textarea.style.scrollBehavior = 'smooth'; 276 277 } 278 // ──────────────────────────────────── 279 onTextChanged(change) { 280 281 // Update the mirror lines of text (remove and add) 282 this.updateMirrorLines(change); 283 284 // Update the matches and scroll positions 285 moaiEditor.matches.onTextChanged(change); 286 287 // Keep track of changed text sections (for partial preview) 288 moaiEditor.dirty.onTextChanged(change); 289 } 290 // ──────────────────────────────────── 291 updateMirrorLines(change) { 292 this.start = Date.now(); 293 294 // Handle de case where just one line is being edited (to make the syntax of that line persistent) 295 if (change.num.remove == 1 && change.num.insert == 1 && change.shift == 0) { 296 let i = change.num.keepfirst; 297 var text = this.watcher.lines[i]; 298 var line = this.content.childNodes[i]; 299 line.text = text; 300 line.highlight.match = null; 301 this.highlight(line); 302 return; 303 } 304 // Determine where the removals and insertions start ('null' means at the end) 305 var mirrornode = null; 306 if (this.content.childElementCount > change.num.keepfirst) 307 mirrornode = this.content.childNodes[change.num.keepfirst]; 308 309 // Remove lines 310 var remove = change.num.remove; 311 while (remove > 0) { 312 var next = mirrornode.nextSibling; 313 this.content.removeChild (mirrornode); 314 mirrornode = next; 315 remove -= 1; 316 } 317 // Add lines (without syntax highlight yet) 318 for (let j=0; j<change.num.insert; j++) { 319 let i = j+change.num.keepfirst; 320 var text = this.watcher.lines[i]; 321 var line = this.getNewLine(text); 322 this.content.insertBefore (line, mirrornode); // If 'mirrornode' is null the insertion will happen at the end 323 } 324 // Check if the number of lines in the mirror is correct 325 const numlines_text = this.watcher.lines.length; 326 const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length; 327 if (numlines_text != numlines_mirror) { 328 const error = "moaiEditor.mirror.updateMirrorLines :: line-counts don't match ("+numlines_text+" vs "+numlines_mirror+")"; 329 if (moaiEditor.strict) 330 throw new Error(error); 331 else 332 console.warn(error); 333 // ▆▆▆▆▆▆ 334 // ▆▆▆▆▆▆ TODO: Recreate all lines if the error happens 335 // ▆▆▆▆▆▆ 336 } 337 // Measure time elapsed 338 this.measureFrameTime(" MIRROR UPDATE"); 339 } 340 // ──────────────────────────────────── 341 highlight(line) { 342 343 // Make sure the line is not empty (to be rendered by the browser) 344 if (line.text == '') 345 line.text = ' '; 346 347 // Highlight syntax 348 this.syntax.highlight(line); 349 350 // Highlight match 351 if (this.settings.show.matches) { 352 let match = line.highlight.match; 353 let overlay = line.querySelector('.moaied-highlight-match'); 354 if (match === null) 355 overlay.classList.remove("on"); 356 else { 357 overlay.classList.add("on"); 358 overlay.classList.add(match); 359 } 360 } 361 } 362 // ──────────────────────────────────── 363 getNewLine(text) { 364 var line = this.line.cloneNode(true); 365 line.text = text; 366 line.highlight = {match:null, syntax:null, children:null}; 367 this.highlight(line); 368 return line; 369 } 370 // ──────────────────────────────────── 371 copyStyle(source, destination) { 372 373 // Define presets 374 const properties = [ 375 376 // Needed for the container 377 'display', 378 'position', 379 'top', 380 'left', 381 'border', 382 'margin', 383 'padding', 384 //'overflowY', 385 //'overflowX', 386 'boxSizing', 387 388 // Needed for the lines 389 'boxSizing', 390 'fontFamily', 391 'fontSize', 392 'fontWeight', 393 'letterSpacing', 394 'lineHeight', 395 'textDecoration', 396 'textIndent', 397 'textTransform', 398 'textWrap', 399 'whiteSpace', 400 'whiteSpaceCollapse', 401 'wordSpacing', 402 'wordWrap', 403 ]; 404 // Copy styles 405 const sourceStyles = window.getComputedStyle(source); 406 for (let property of properties) { 407 //if (destination.style[property] != sourceStyles[property]) 408 // console.warn(" "+property+": "+JSON.stringify(sourceStyles[property])); 409 destination.style[property] = sourceStyles[property]; 410 //console.warn(" "+property+": "+JSON.stringify(sourceStyles[property])); 411 } 412 } 413 // ──────────────────────────────────── 414 debug_show_counts() { 415 const numlines_text = this.watcher.lines.length; 416 const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length; 417 } 418 // ──────────────────────────────────── 419 measureFrameTime (text) { 420 requestAnimationFrame(() => { 421 const elapsed = Date.now()-this.start; 422 }); 423 } 424}; // End Class 425 426// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 427// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 428 429/* This class implements the 'scroll.max', 'scroll.top' and 'scroll.smooth' 430 getters and setters required for every editor. 431*/ 432MoaiEditor.MirrorScroll = class { 433 434 constructor (outer) { 435 this.outer = outer; 436 this.smoothvalue = false; 437 } 438 // ──────────────────────────────────── 439 get max() { 440 // Return the maximum scroll possible 441 return moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); 442 } 443 get height() { 444 // Return the height of the scrollable area 445 return this.outer.mirror.scrollHeight; 446 } 447 // ──────────────────────────────────── 448 get top() { 449 /* Obscure bug: 450 - In Firefox 142 when the textarea text color is set to transparent and scrollBehavior is set to 'smooth' and 451 scrollTop is set to a value, it will not take effect until scollBehavior is set to 'auto' again. 452 - This bug is only observed in Firefox but not in Chrome, Brave, Vivaldi, Opera, where setting scrollTop works 453 as expected, whether the textarea color is transparent or not. 454 - This bug makes the MoaiEditor.ScrollTo class fail. 455 - We are not seting the textarea scrollBehavior to 'smooth' anymore to prevent this issue in Firefox. 456 */ 457 //return this.outer.textarea.scrollTop; 458 return this.outer.mirror.scrollTop; 459 } 460 set top(value) { 461 this.outer.mirror.scrollTop = value; 462 this.outer.textarea.scrollTop = value; 463 } 464 // ──────────────────────────────────── 465 get smooth() { 466 return this.smoothvalue; 467 } 468 set smooth(boolean) { 469 var value = 'auto'; 470 if (boolean) 471 value = 'smooth'; 472 this.smoothvalue = boolean; 473 this.outer.mirror.style.scrollBehavior = value; 474 //this.outer.textarea.style.scrollBehavior = value; // Don't set the textarea scrollBeahavior to smooth to avoid Firefox issues 475 } 476 // ──────────────────────────────────── 477 debugLine() { 478 // Exit if debugline is not enabled 479 const line = document.querySelector("#moai__debug div:nth-child(2)"); 480 if (!line) return; 481 // Debug line 482 const scroll = Math.floor(this.outer.textarea.scrollTop); 483 const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); 484 const text = "scroll:"+scroll+" maxScroll:"+maxScroll; 485 line.textContent = text; 486 } 487}; // End Class 488 489 490