1/* DokuWiki MoaiEditor Mirror.js file 2 Author : MoaiTools <info@moaitools.org> 3 License : GPL 3 (http://www.gnu.org/licenses/gpl.html) */ 4 5/* Textarea mirror class 6 ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ 7 This is the native editor of MoaiEditor. The editing is done on the 8 textarea itself, but we add an overlay element which maintains a 9 separate div for each line of text, mirroring the textarea. 10 11 This mirror overlay is used for syntax highlighting and scroll 12 synchronization. 13 14 This class also detects textarea content changes with the help of 15 the watcher class. 16 17 DOM structure 18 ‾‾‾‾‾‾‾‾‾‾‾‾‾ 19 #moaied__mirror -- Main container 20 .moaied-show-dirty-area -- Debug overlay (shows dirty area) 21 #moaied__scrollpoints_overlay -- Debug overlay (shows scroll points) 22 #moaied__mirror_content -- Container for lines 23 .moaied-mirror-line -- Line container 24 .moaied-highlight-match -- Debug overlay (show matches) 25 .moaied-mirror-line-content -- Actual content (highlighted) 26 27 Data structures 28 ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ 29 Each mirror line DOM element carries some Javascript metadata information: 30 31 element.text: the plain text of that line 32 element.highlight: { 33 match : null, -- E.g. 'paragraph','header'... Used to display matches (debug option) 34 syntax : null, -- E.g. 'paragraph','header'... Used to highlight the block level syntax of the line 35 children : null Used to highlight the inline level syntax of children 36 } 37*/ 38MoaiEditor.TextAreaMirror = class { 39 40 constructor() { 41 42 // Settings 43 this.settings = {show:{matches:false, textarea:false}}; 44 45 // Constants 46 this.name = 'Native editor'; // Label to display to the user 47 //this.name = 'MoaiEditor'; // Label to display to the user 48 this.mirror = null; // Container element 49 this.content = null; // Container element for mirrored lines 50 this.textarea = null; // The textarea element 51 this.line = null; // Template for new mirror line elements 52 53 // Variables 54 this.enabled = false; // Flag to indicate if this editor is enabled right now 55 56 // Objects 57 this.syntax = new MoaiEditor.Highlight(this); 58 this.scroll = new MoaiEditor.MirrorScroll(this); 59 this.watcher = new MoaiEditor.WatchText(this); 60 this.selection = new MoaiEditor.WatchSelection(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.onTextChange(); 243 this.onSelectionChange(); 244 } 245 onTextChange() { 246 if (this.enabled) {; 247 this.watcher.onInput(); 248 this.onSelectionChange(); 249 } 250 } 251 onSelectionChange() { 252 if (this.enabled) {; 253 this.selection.onInput(); 254 } 255 } 256 257 onTextareaResize() { 258 if (this.enabled) 259 this.onTextareaStyleChange(); 260 } 261 // ┌───────────────────────────────────┐ 262 // │ Private │ 263 // └───────────────────────────────────┘ 264 265 onTextareaStyleChange() { 266 this.copyStyle(this.textarea, this.content); 267 268 // Recalc scroll points 269 moaiEditor.matches.recalcScrollPoints(); 270 271 // Fix textarea scroll 272 //this.textarea.style.scrollBehavior = 'auto'; 273 this.textarea.scrollTop = this.mirror.scrollTop; 274 //this.textarea.style.scrollBehavior = 'smooth'; 275 } 276 277 onTextChanged(change) { 278 279 // Update the mirror lines of text (remove and add) 280 this.updateMirrorLines(change); 281 282 // Update the matches and scroll positions 283 moaiEditor.matches.onTextChanged(change); 284 285 // Keep track of changed text sections (for partial preview) 286 moaiEditor.dirty.onTextChanged(change); 287 } 288 289 updateMirrorLines(change) { 290 this.start = Date.now(); 291 292 // Handle de case where just one line is being edited (to make the syntax of that line persistent) 293 if (change.num.remove == 1 && change.num.insert == 1 && change.shift == 0) { 294 let i = change.num.keepfirst; 295 var text = this.watcher.lines[i]; 296 var line = this.content.childNodes[i]; 297 line.text = text; 298 line.highlight.match = null; 299 this.highlight(line); 300 return; 301 } 302 // Determine where the removals and insertions start ('null' means at the end) 303 var mirrornode = null; 304 if (this.content.childElementCount > change.num.keepfirst) 305 mirrornode = this.content.childNodes[change.num.keepfirst]; 306 307 // Remove lines 308 var remove = change.num.remove; 309 while (remove > 0) { 310 var next = mirrornode.nextSibling; 311 this.content.removeChild (mirrornode); 312 mirrornode = next; 313 remove -= 1; 314 } 315 // Add lines (without syntax highlight yet) 316 for (let j=0; j<change.num.insert; j++) { 317 let i = j+change.num.keepfirst; 318 var text = this.watcher.lines[i]; 319 var line = this.getNewLine(text); 320 this.content.insertBefore (line, mirrornode); // If 'mirrornode' is null the insertion will happen at the end 321 } 322 // Check if the number of lines in the mirror is correct 323 const numlines_text = this.watcher.lines.length; 324 const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length; 325 if (numlines_text != numlines_mirror) { 326 const error = "moaiEditor.mirror.updateMirrorLines :: line-counts don't match ("+numlines_text+" vs "+numlines_mirror+")"; 327 if (moaiEditor.strict) 328 throw new Error(error); 329 else 330 console.warn(error); 331 // ▆▆▆▆▆▆ 332 // ▆▆▆▆▆▆ TODO: Recreate all lines if the error happens 333 // ▆▆▆▆▆▆ 334 } 335 // Measure time elapsed 336 this.measureFrameTime(" MIRROR UPDATE"); 337 } 338 339 highlight(line) { 340 341 // Make sure the line is not empty (to be rendered by the browser) 342 if (line.text == '') 343 line.text = ' '; 344 345 // Highlight syntax 346 this.syntax.highlight(line); 347 348 // Highlight match 349 if (this.settings.show.matches) { 350 let match = line.highlight.match; 351 let overlay = line.querySelector('.moaied-highlight-match'); 352 if (match === null) 353 overlay.classList.remove("on"); 354 else { 355 overlay.classList.add("on"); 356 overlay.classList.add(match); 357 } 358 } 359 } 360 361 getNewLine(text) { 362 var line = this.line.cloneNode(true); 363 line.text = text; 364 line.highlight = {match:null, syntax:null, children:null}; 365 this.highlight(line); 366 return line; 367 } 368 369 copyStyle(source, destination) { 370 371 // Define presets 372 const properties = [ 373 374 // Needed for the container 375 'display', 376 'position', 377 'top', 378 'left', 379 'border', 380 'margin', 381 'padding', 382 //'overflowY', 383 //'overflowX', 384 'boxSizing', 385 386 // Needed for the lines 387 'boxSizing', 388 'fontFamily', 389 'fontSize', 390 'fontWeight', 391 'letterSpacing', 392 'lineHeight', 393 'textDecoration', 394 'textIndent', 395 'textTransform', 396 'textWrap', 397 'whiteSpace', 398 'whiteSpaceCollapse', 399 'wordSpacing', 400 'wordWrap', 401 ]; 402 // Copy styles 403 const sourceStyles = window.getComputedStyle(source); 404 for (let property of properties) { 405 //if (destination.style[property] != sourceStyles[property]) 406 // console.warn(" "+property+": "+JSON.stringify(sourceStyles[property])); 407 destination.style[property] = sourceStyles[property]; 408 //console.warn(" "+property+": "+JSON.stringify(sourceStyles[property])); 409 } 410 } 411 412 debug_show_counts() { 413 const numlines_text = this.watcher.lines.length; 414 const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length; 415 } 416 417 measureFrameTime (text) { 418 requestAnimationFrame(() => { 419 const elapsed = Date.now()-this.start; 420 }); 421 } 422}; // End Class 423 424// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 425// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 426 427/* This class implements the 'scroll.max', 'scroll.top' and 'scroll.smooth' 428 getters and setters required for every editor. 429*/ 430MoaiEditor.MirrorScroll = class { 431 432 constructor (outer) { 433 this.outer = outer; 434 this.smoothvalue = false; 435 } 436 437 get max() { 438 // Return the maximum scroll possible 439 return moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); 440 } 441 get height() { 442 // Return the height of the scrollable area 443 return this.outer.mirror.scrollHeight; 444 } 445 446 get top() { 447 return this.outer.textarea.scrollTop; 448 //return this.outer.mirror.scrollTop; 449 } 450 set top(value) { 451 this.outer.mirror.scrollTop = value; 452 this.outer.textarea.scrollTop = value; 453 } 454 455 get smooth() { 456 return this.smoothvalue; 457 } 458 set smooth(boolean) { 459 var value = 'auto'; 460 if (boolean) 461 value = 'smooth'; 462 this.smoothvalue = boolean; 463 this.outer.mirror.style.scrollBehavior = value; 464 this.outer.textarea.style.scrollBehavior = value; // This was commented before to avoid Firefox issues 465 } 466 467 debugLine() { 468 // Exit if debugline is not enabled 469 const line = document.querySelector("#moai__debug div:nth-child(2)"); 470 if (!line) return; 471 // Debug line 472 const scroll = Math.floor(this.outer.textarea.scrollTop); 473 const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); 474 const text = "scroll:"+scroll+" maxScroll:"+maxScroll; 475 line.textContent = text; 476 } 477}; // End Class 478 479 480