1/* DokuWiki MoaiEditor Scroll_to.js file 2 Version : 0.5b (2026-05-08) 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.WatchText(this); 61 this.selection = new MoaiEditor.WatchSelection(this); 62 } 63 // ┌───────────────────────────────────┐ 64 // │ Public │ 65 // └───────────────────────────────────┘ 66 67 init() { 68 69 // Get node handles 70 this.textarea = moaiEditor.layout.textarea; 71 72 // Create mirror line template 73 var line = moaiEditor.createHTML('<div class="moaied-mirror-line"></div>'); 74 var content = moaiEditor.createHTML('<span class="moaied-mirror-line-content"></span>'); 75 var match = moaiEditor.createHTML('<div class="moaied-highlight-match"></div>'); 76 line.appendChild(content); 77 if (this.settings.show.matches) 78 line.appendChild(match); 79 this.line = line; 80 81 // Create main mirror container (it scrolls) 82 this.mirror = moaiEditor.createHTML('<div id="moaied__mirror" class="moaied-display-none"></div>'); 83 moaiEditor.layout.editpane.appendChild(this.mirror); 84 85 // Create container for the content 86 this.content = moaiEditor.createHTML('<div id="moaied__mirror_content"></div>'); 87 this.copyStyle(this.textarea, this.content); 88 this.mirror.appendChild(this.content); 89 90 // Create overlay to display dirty area (for debug) 91 this.dirty = moaiEditor.createHTML('<div class="moaied-show-dirty-area"></div>'); 92 this.mirror.appendChild(this.dirty); 93 94 // Create overlay to display scrollpoints (for debug) 95 const scrollpoints = moaiEditor.createHTML('<div id="moaied__scrollpoints_overlay" class="moaied-highlight-scrollpoints"></div>'); 96 this.mirror.appendChild(scrollpoints); 97 98 // Make texarea text invisible (but keep cursor visible) 99 if (!this.settings.show.textarea) 100 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) 101 //this.textarea.style.color = 'transparent'; 102 103 // Create flash box 104 this.flashbox = moaiEditor.createHTML('<div id="moaied__mirror_flashbox" class="moaied-flashbox"></div>'); 105 } 106 // ──────────────────────────────────── 107 disable () { 108 // Hide 109 this.mirror.classList.add('moaied-display-none'); 110 // Set flag 111 this.enabled = false; 112 } 113 // ──────────────────────────────────── 114 enable () { 115 // Show 116 this.mirror.classList.remove('moaied-display-none'); 117 // Copy textarea lines to the watcher 118 this.watcher.lines = moaiEditor.layout.textarea.value.split("\n"); 119 // Clear mirror lines 120 this.content.textContent = ''; 121 // Render mirror lines (without syntax highlight yet) 122 for (let textline of this.watcher.lines) 123 this.content.appendChild (this.getNewLine(textline)); 124 // Add the matches to mirror lines (and highlight syntax) 125 this.addMatches(moaiEditor.matches.matches); 126 // Recalc scroll points 127 moaiEditor.matches.recalcScrollPoints(); 128 // Set flag 129 this.enabled = true; 130 } 131 // ──────────────────────────────────── 132 onAjax (newMatches) { 133 // Add newly found syntax definitions to lines 134 this.addMatches(newMatches); 135 } 136 // ──────────────────────────────────── 137 getLineRect(linenum, mode='local') { 138 this.debug_show_counts(); 139 140 // Preparations 141 const element = this.content.childNodes[linenum]; 142 const rect = element.getBoundingClientRect(); 143 rect.height = rect.bottom - rect.top + 1; 144 rect.width = rect.right - rect.left; 145 146 // Viewport mode (coordinates relative to the visible area of the page) 147 if (mode == 'viewport') 148 return rect; 149 150 // Local mode (coordinates relative to the parent) 151 const parent = element.parentElement; 152 const parentRect = parent.getBoundingClientRect(); 153 return { 154 top : rect.top - parentRect.top + parent.scrollTop, 155 bottom : rect.bottom - parentRect.top + parent.scrollTop, 156 left : rect.left - parentRect.left, 157 right : rect.right - parentRect.left, 158 height : rect.height, 159 width : rect.width 160 }; 161 } 162 // ──────────────────────────────────── 163 addMatches(newMatches) { 164 // Add syntax to lines (this function is called when a preview is updated and new matches are found) 165 for (let match of newMatches) 166 for (let i=match.startline; i<=match.endline; i++) { 167 const line = this.content.childNodes[i]; 168 line.highlight.match = match.syntax; 169 line.highlight.syntax = match.syntax; 170 this.highlight(line); 171 } 172 } 173 // ──────────────────────────────────── 174 removeMatches(startline, endline) { 175 // Remove matches and syntax from mirror lines. 176 // This function is called when a preview is updated, and before the new matches are calculated. 177 for (let i=startline; i<=endline; i++) { 178 const line = this.content.childNodes[i]; 179 line.highlight = {match:null, syntax:null, children:null}; 180 this.highlight(line); 181 } 182 } 183 // ──────────────────────────────────── 184 setWrap(value) { 185 moaiEditor.layout.elements.textarea.wrap = value; 186 dw_editor.setWrap (moaiEditor.layout.elements.textarea, value); 187 this.onTextareaStyleChange(); 188 } 189 // ──────────────────────────────────── 190 set pointerEvents(boolean) { 191 if (boolean) 192 this.textarea.style.pointerEvents = 'auto'; 193 else 194 this.textarea.style.pointerEvents = 'none'; 195 } 196 // ──────────────────────────────────── 197 flash(flash, data=null) { 198 if (flash == 'remove') { 199 this.flashbox.remove(); 200 return; 201 } 202 if (flash == 'remove') 203 this.flashbox.remove(); 204 if (flash === null) 205 return; 206 this.flashbox = moaiEditor.createHTML('<div id="moaied__mirror_flashbox" class="moaied-flashbox"></div>'); 207 if (flash === false) 208 this.flashbox.classList.add('red'); 209 const start = this.getLineRect(data.startline); 210 const end = this.getLineRect(data.endline); 211 const height = end.bottom - start.top; 212 const width = start.width; 213 this.flashbox.style.top = start.top + 'px'; 214 this.flashbox.style.left = '0px'; 215 this.flashbox.style.width = width + 'px'; 216 this.flashbox.style.height = height + 'px'; 217 this.mirror.appendChild(this.flashbox); 218 } 219 // ──────────────────────────────────── 220 get text() { 221 return this.textarea.value; 222 } 223 // ┌───────────────────────────────────┐ 224 // │ Input events │ 225 // └───────────────────────────────────┘ 226 227 onTextareaScroll(event) { 228 229 if (!this.enabled) 230 return; 231 // Synchronize mirror scroll 232 if (!moaiEditor.scroll.sync.disabled) { 233 this.mirror.scrollTop = this.textarea.scrollTop; 234 this.content.scrollLeft = this.textarea.scrollLeft; 235 } 236 // Synchronize preview scroll 237 moaiEditor.scroll.sync.onScroll(); 238 // Debugline 239 //this.scroll.debugLine(); 240 } 241 // ──────────────────────────────────── 242 onToolbarButtonInput() { 243 this.onTextChange(); 244 this.onSelectionChange(); 245 } 246 onTextChange() { 247 if (this.enabled) {; 248 this.watcher.onInput(); 249 this.onSelectionChange(); 250 } 251 } 252 onSelectionChange() { 253 if (this.enabled) {; 254 this.selection.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 - Happens in Firefox 142 when the textarea's text color is set to transparent and scroll-behavior is set to 'smooth'. 451 - Given these conditions, when scrollTop is set to a value it will be not applied at all, until after scoll-behavior 452 is set to 'auto' again. 453 - This bug is only observed in Firefox but not in Chrome, Brave, Vivaldi, Opera, where setting scrollTop works 454 as expected, whether the textarea color is transparent or not. 455 - It seems Firefox is "optimizing out" a scroll when the text color is transparent, even if other things are still 456 still visible, for example: selected text, caret (cursor), spellchecking red underlines. 457 - This bug makes the MoaiEditor.ScrollTo class fail. 458 - If we disable the textarea smooth scroll the red spellchecker underlines of misspeled words will jump instantly 459 while the mirror will smooth smoothly which destroys the illusion the mirror is trying to achieve. 460 - Possible fixes: 461 a) Not relying on the smooth scrolling of the browser at all. But the idea is to rely as much as possible 462 on browser smooth scroll as it is heavily optimized and possibly multithreaded as opposed to manual 463 scroll which will run in the main thread. 464 b) Just disable the textarea spellcheck (but some user could miss it) - element.spellcheck = false; 465 --> This works in practice but seeing the red underlines appear and disspaear is a bit distracting. 466 c) Let the browser scroll the mirror but sync the textarea manually (frame by frame). 467 --> This actually does not work in practice as the synchronization is too choppy and looks terrible. 468 c) Try adding a tiny bit of opacity to the texarea text so Firefox is forced to treat it as a 469 visible element and does not "optimize out" the scrolling. 470 --> This works well. 471 */ 472 return this.outer.textarea.scrollTop; 473 //return this.outer.mirror.scrollTop; 474 } 475 set top(value) { 476 this.outer.mirror.scrollTop = value; 477 this.outer.textarea.scrollTop = value; 478 } 479 // ──────────────────────────────────── 480 get smooth() { 481 return this.smoothvalue; 482 } 483 set smooth(boolean) { 484 var value = 'auto'; 485 if (boolean) 486 value = 'smooth'; 487 this.smoothvalue = boolean; 488 this.outer.mirror.style.scrollBehavior = value; 489 this.outer.textarea.style.scrollBehavior = value; // This was commented before to avoid Firefox issues 490 } 491 // ──────────────────────────────────── 492 debugLine() { 493 // Exit if debugline is not enabled 494 const line = document.querySelector("#moai__debug div:nth-child(2)"); 495 if (!line) return; 496 // Debug line 497 const scroll = Math.floor(this.outer.textarea.scrollTop); 498 const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); 499 const text = "scroll:"+scroll+" maxScroll:"+maxScroll; 500 line.textContent = text; 501 } 502}; // End Class 503 504 505