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 278 onTextChanged(change) { 279 280 // Update the mirror lines of text (remove and add) 281 this.updateMirrorLines(change); 282 283 // Update the matches and scroll positions 284 moaiEditor.matches.onTextChanged(change); 285 286 // Keep track of changed text sections (for partial preview) 287 moaiEditor.dirty.onTextChanged(change); 288 } 289 290 updateMirrorLines(change) { 291 this.start = Date.now(); 292 293 // Handle de case where just one line is being edited (to make the syntax of that line persistent) 294 if (change.num.remove == 1 && change.num.insert == 1 && change.shift == 0) { 295 let i = change.num.keepfirst; 296 var text = this.watcher.lines[i]; 297 var line = this.content.childNodes[i]; 298 line.text = text; 299 line.highlight.match = null; 300 this.highlight(line); 301 return; 302 } 303 // Determine where the removals and insertions start ('null' means at the end) 304 var mirrornode = null; 305 if (this.content.childElementCount > change.num.keepfirst) 306 mirrornode = this.content.childNodes[change.num.keepfirst]; 307 308 // Remove lines 309 var remove = change.num.remove; 310 while (remove > 0) { 311 var next = mirrornode.nextSibling; 312 this.content.removeChild (mirrornode); 313 mirrornode = next; 314 remove -= 1; 315 } 316 // Add lines (without syntax highlight yet) 317 for (let j=0; j<change.num.insert; j++) { 318 let i = j+change.num.keepfirst; 319 var text = this.watcher.lines[i]; 320 var line = this.getNewLine(text); 321 this.content.insertBefore (line, mirrornode); // If 'mirrornode' is null the insertion will happen at the end 322 } 323 // Check if the number of lines in the mirror is correct 324 const numlines_text = this.watcher.lines.length; 325 const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length; 326 if (numlines_text != numlines_mirror) { 327 const error = "moaiEditor.mirror.updateMirrorLines :: line-counts don't match ("+numlines_text+" vs "+numlines_mirror+")"; 328 if (moaiEditor.strict) 329 throw new Error(error); 330 else 331 console.warn(error); 332 // ▆▆▆▆▆▆ 333 // ▆▆▆▆▆▆ TODO: Recreate all lines if the error happens 334 // ▆▆▆▆▆▆ 335 } 336 // Measure time elapsed 337 this.measureFrameTime(" MIRROR UPDATE"); 338 } 339 340 highlight(line) { 341 342 // Make sure the line is not empty (to be rendered by the browser) 343 if (line.text == '') 344 line.text = ' '; 345 346 // Highlight syntax 347 this.syntax.highlight(line); 348 349 // Highlight match 350 if (this.settings.show.matches) { 351 let match = line.highlight.match; 352 let overlay = line.querySelector('.moaied-highlight-match'); 353 if (match === null) 354 overlay.classList.remove("on"); 355 else { 356 overlay.classList.add("on"); 357 overlay.classList.add(match); 358 } 359 } 360 } 361 362 getNewLine(text) { 363 var line = this.line.cloneNode(true); 364 line.text = text; 365 line.highlight = {match:null, syntax:null, children:null}; 366 this.highlight(line); 367 return line; 368 } 369 370 copyStyle(source, destination) { 371 372 // Define presets 373 const properties = [ 374 375 // Needed for the container 376 'display', 377 'position', 378 'top', 379 'left', 380 'border', 381 'margin', 382 'padding', 383 //'overflowY', 384 //'overflowX', 385 'boxSizing', 386 387 // Needed for the lines 388 'boxSizing', 389 'fontFamily', 390 'fontSize', 391 'fontWeight', 392 'letterSpacing', 393 'lineHeight', 394 'textDecoration', 395 'textIndent', 396 'textTransform', 397 'textWrap', 398 'whiteSpace', 399 'whiteSpaceCollapse', 400 'wordSpacing', 401 'wordWrap', 402 ]; 403 // Copy styles 404 const sourceStyles = window.getComputedStyle(source); 405 for (let property of properties) { 406 //if (destination.style[property] != sourceStyles[property]) 407 // console.warn(" "+property+": "+JSON.stringify(sourceStyles[property])); 408 destination.style[property] = sourceStyles[property]; 409 //console.warn(" "+property+": "+JSON.stringify(sourceStyles[property])); 410 } 411 } 412 413 debug_show_counts() { 414 const numlines_text = this.watcher.lines.length; 415 const numlines_mirror = this.content.querySelectorAll('.moaied-mirror-line').length; 416 } 417 418 measureFrameTime (text) { 419 requestAnimationFrame(() => { 420 const elapsed = Date.now()-this.start; 421 }); 422 } 423}; // End Class 424 425// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 426// ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆ 427 428/* This class implements the 'scroll.max', 'scroll.top' and 'scroll.smooth' 429 getters and setters required for every editor. 430*/ 431MoaiEditor.MirrorScroll = class { 432 433 constructor (outer) { 434 this.outer = outer; 435 this.smoothvalue = false; 436 } 437 438 get max() { 439 // Return the maximum scroll possible 440 return moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); 441 } 442 get height() { 443 // Return the height of the scrollable area 444 return this.outer.mirror.scrollHeight; 445 } 446 447 get top() { 448 return this.outer.textarea.scrollTop; 449 //return this.outer.mirror.scrollTop; 450 } 451 set top(value) { 452 this.outer.mirror.scrollTop = value; 453 this.outer.textarea.scrollTop = value; 454 } 455 456 get smooth() { 457 return this.smoothvalue; 458 } 459 set smooth(boolean) { 460 var value = 'auto'; 461 if (boolean) 462 value = 'smooth'; 463 this.smoothvalue = boolean; 464 this.outer.mirror.style.scrollBehavior = value; 465 this.outer.textarea.style.scrollBehavior = value; // This was commented before to avoid Firefox issues 466 } 467 468 debugLine() { 469 // Exit if debugline is not enabled 470 const line = document.querySelector("#moai__debug div:nth-child(2)"); 471 if (!line) return; 472 // Debug line 473 const scroll = Math.floor(this.outer.textarea.scrollTop); 474 const maxScroll = moaiEditor.scroll.tools.getMaxScrollY(this.outer.textarea); 475 const text = "scroll:"+scroll+" maxScroll:"+maxScroll; 476 line.textContent = text; 477 } 478}; // End Class 479 480 481