1/** 2 * Rich Text Editor Component 3 * Discord-style markdown editor with live syntax highlighting 4 * Shows markdown markers but applies styling to content 5 */ 6class RichTextEditor { 7 constructor(options = {}) { 8 this.textareaId = options.textareaId; 9 this.previewId = options.previewId; 10 this.onUpdate = options.onUpdate || (() => {}); 11 this.maxLength = options.maxLength || null; 12 this.excludeFormats = options.excludeFormats || []; // Formats to exclude from tooltip 13 14 // Unique instance ID for scoping 15 this.instanceId = 'rte-' + Math.random().toString(36).substring(2, 11); 16 17 this.textarea = null; // Hidden textarea for form submission 18 this.editor = null; // Contenteditable div 19 this.preview = null; // Optional preview pane 20 this.selectionTooltip = null; 21 this.linkPopup = null; // Link edit popup (for existing links) 22 this.linkModal = null; // Custom link modal (replaces JS prompt) 23 this.isComposing = false; // For IME input 24 this.renderTimeout = null; 25 this.skipNextRender = false;
26 27 // Undo/Redo history 28 this.history = []; 29 this.historyIndex = -1; 30 this.maxHistorySize = 100; 31 this.isUndoRedo = false; 32 this.lastSavedContent = ''; 33 34 // Saved selection for external toolbar buttons (as character offsets to survive DOM changes) 35 this.savedSelection = null; 36 37 // Tooltip format buttons (filtered by excludeFormats) 38 const allFormats = ['bold', 'italic', 'strike', 'underline', 'spoiler', 'link', 'code']; 39 this.tooltipFormats = allFormats.filter(f => !this.excludeFormats.includes(f)); 40 41 // Format definitions 42 this.formats = { 43 bold: { prefix: '**', suffix: '**', placeholder: 'bold text' }, 44 italic: { prefix: '*', suffix: '*', placeholder: 'italic text' }, 45 strike: { prefix: '~~', suffix: '~~', placeholder: 'strikethrough' }, 46 underline: { prefix: '__(', suffix: ')__', placeholder: 'underlined text' }, 47 spoiler: { prefix: '~!', suffix: '!~', placeholder: 'spoiler text' }, 48 code: { prefix: '`', suffix: '`', placeholder: 'code' }, 49 center: { prefix: '~~~', suffix: '~~~', placeholder: 'centered text' }, 50 link: { custom: true }, 51 image: { prefix: 'img(', suffix: ')', placeholder: 'image url' }, 52 heading: { prefix: '# ', suffix: '', placeholder: 'Heading' }, 53 quote: { prefix: '> ', suffix: '', placeholder: 'quote', multiline: true }, 54 ulist: { prefix: '- ', suffix: '', placeholder: 'item', multiline: true }, 55 olist: { custom: true, multiline: true } 56 }; 57 58 this.init(); 59 } 60 61 init() { 62 this.textarea = document.getElementById(this.textareaId); 63 if (!this.textarea) { 64 console.error(`RichTextEditor: element #${this.textareaId} not found`); 65 return; 66 } 67 68 if (this.previewId) { 69 this.preview = document.getElementById(this.previewId); 70 } 71 72 // Create contenteditable editor 73 this.createEditor(); 74 75 // Create selection tooltip 76 this.createSelectionTooltip(); 77 78 // Create link popup (for existing links) 79 this.createLinkPopup(); 80 81 // Create custom link modal (replaces JS prompt) 82 this.createLinkModal(); 83 84 // Bind events 85 this.bindEvents(); 86 87 // Initial render and history 88 this.renderHighlighting(); 89 this.updatePreview(); 90 this.saveToHistory(); 91 } 92 93 createEditor() { 94 this.editor = document.createElement('div'); 95 this.editor.className = 'rte-editor'; 96 this.editor.contentEditable = 'true'; 97 this.editor.spellcheck = true; 98 this.editor.setAttribute('role', 'textbox'); 99 this.editor.setAttribute('aria-multiline', 'true'); 100 this.editor.dataset.placeholder = this.textarea.placeholder || 'Start typing...'; 101 this.editor.dataset.instanceId = this.instanceId; 102 103 const computedStyle = window.getComputedStyle(this.textarea); 104 this.editor.style.minHeight = computedStyle.minHeight || '200px'; 105 106 this.textarea.style.display = 'none'; 107 this.textarea.parentNode.insertBefore(this.editor, this.textarea.nextSibling); 108 109 if (this.textarea.value) { 110 this.editor.textContent = this.textarea.value; 111 } 112 } 113 114 createSelectionTooltip() { 115 this.selectionTooltip = document.createElement('div'); 116 // Add instance ID to scope this tooltip to this editor 117 this.selectionTooltip.className = `rte-selection-tooltip rte-tooltip-${this.instanceId}`; 118 this.selectionTooltip.dataset.instanceId = this.instanceId; 119 this.selectionTooltip.innerHTML = this.tooltipFormats.map(format => { 120 return `<button type="button" class="rte-tooltip-btn" data-format="${format}" title="${this.getFormatTitle(format)}">${this.getFormatIcon(format)}</button>`; 121 }).join('') + `<button type="button" class="rte-tooltip-btn rte-tooltip-btn-clear" data-format="clear" title="Clear Formatting"> 122 <svg viewBox="0 0 24 24" fill="currentColor"><path d="M3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21 18 19.73 3.55 5.27 3.27 5zM6 5v.18L8.82 8h2.4l-.72 1.68 2.1 2.1L14.21 8H20V5H6z"/></svg> 123 </button>`; 124 125 document.body.appendChild(this.selectionTooltip); 126 127 // Use querySelector on THIS specific tooltip instance 128 this.selectionTooltip.querySelectorAll('.rte-tooltip-btn').forEach(btn => { 129 btn.addEventListener('mousedown', (e) => { 130 e.preventDefault(); 131 this.insertFormat(btn.dataset.format); 132 }); 133 }); 134 } 135 136 createLinkPopup() { 137 this.linkPopup = document.createElement('div'); 138 // Add instance ID to scope this popup to this editor 139 this.linkPopup.className = `rte-link-popup rte-popup-${this.instanceId}`; 140 this.linkPopup.dataset.instanceId = this.instanceId; 141 this.linkPopup.innerHTML = ` 142 <div class="rte-link-popup-content"> 143 <div class="rte-link-popup-url"></div> 144 <div class="rte-link-popup-actions"> 145 <button type="button" class="rte-link-edit-btn" title="Edit link"> 146 <svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg> 147 </button> 148 <button type="button" class="rte-link-remove-btn" title="Remove link"> 149 <svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg> 150 </button> 151 </div> 152 </div> 153 `; 154 document.body.appendChild(this.linkPopup); 155 156 this.linkPopup.querySelector('.rte-link-edit-btn').addEventListener('click', () => { 157 this.editCurrentLink(); 158 }); 159 160 this.linkPopup.querySelector('.rte-link-remove-btn').addEventListener('click', () => { 161 this.removeCurrentLink(); 162 }); 163 } 164 165 // Custom HTML Link Modal - replaces JS prompt() to prevent focus loss 166 createLinkModal() { 167 this.linkModal = document.createElement('div'); 168 // Add instance ID to scope this modal to this editor 169 this.linkModal.className = `rte-custom-modal rte-modal-${this.instanceId}`; 170 this.linkModal.dataset.instanceId = this.instanceId; 171 this.linkModal.innerHTML = ` 172 <div class="rte-modal-overlay"></div> 173 <div class="rte-modal-content"> 174 <div class="rte-modal-header">Insert Link</div> 175 <div class="rte-modal-body"> 176 <div class="rte-modal-field"> 177 <label>URL</label> 178 <input type="text" class="rte-link-url" placeholder="https://"> 179 </div> 180 <div class="rte-modal-field"> 181 <label>Link Text</label> 182 <input type="text" class="rte-link-text" placeholder="Display text"> 183 </div> 184 </div> 185 <div class="rte-modal-footer"> 186 <button type="button" class="rte-modal-cancel">Cancel</button> 187 <button type="button" class="rte-modal-save">Insert</button> 188 </div> 189 </div> 190 `; 191 document.body.appendChild(this.linkModal); 192 this.linkModal.style.display = 'none'; 193 194 // Event listeners for modal buttons 195 const urlInput = this.linkModal.querySelector('.rte-link-url'); 196 const textInput = this.linkModal.querySelector('.rte-link-text'); 197 const saveBtn = this.linkModal.querySelector('.rte-modal-save'); 198 const cancelBtn = this.linkModal.querySelector('.rte-modal-cancel');
199 const overlay = this.linkModal.querySelector('.rte-modal-overlay'); 200 201 saveBtn.addEventListener('click', () => { 202 this.handleLinkModalSave(); 203 }); 204 205 cancelBtn.addEventListener('click', () => { 206 this.hideLinkModal(); 207 }); 208 209 overlay.addEventListener('click', () => { 210 this.hideLinkModal(); 211 }); 212 213 // Enter key to save 214 urlInput.addEventListener('keydown', (e) => { 215 if (e.key === 'Enter') { 216 e.preventDefault(); 217 textInput.focus(); 218 } else if (e.key === 'Escape') { 219 this.hideLinkModal(); 220 } 221 }); 222 223 textInput.addEventListener('keydown', (e) => { 224 if (e.key === 'Enter') { 225 e.preventDefault(); 226 this.handleLinkModalSave(); 227 } else if (e.key === 'Escape') { 228 this.hideLinkModal(); 229 } 230 }); 231 } 232 233 getFormatIcon(format) { 234 const icons = { 235 bold: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>', 236 italic: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>', 237 strike: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/></svg>', 238 underline: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"/></svg>', 239 spoiler: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>', 240 link: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>', 241 code: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>', 242 clear: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21 18 19.73 3.55 5.27 3.27 5zM6 5v.18L8.82 8h2.4l-.72 1.68 2.1 2.1L14.21 8H20V5H6z"/></svg>' 243 }; 244 return icons[format] || ''; 245 } 246 247 getFormatTitle(format) { 248 const titles = { 249 bold: 'Bold (Ctrl+B)', 250 italic: 'Italic (Ctrl+I)', 251 strike: 'Strikethrough', 252 underline: 'Underline (Ctrl+U)', 253 spoiler: 'Spoiler', 254 link: 'Link (Ctrl+K)', 255 code: 'Code', 256 clear: 'Clear Formatting' 257 }; 258 return titles[format] || format; 259 } 260 261 bindEvents() { 262 // Input handling 263 this.editor.addEventListener('input', (e) => { 264 if (this.isComposing || this.isUndoRedo) return; 265 this.handleInput(); 266 }); 267 268 // IME composition 269 this.editor.addEventListener('compositionstart', () => { this.isComposing = true; }); 270 this.editor.addEventListener('compositionend', () => { 271 this.isComposing = false; 272 this.handleInput(); 273 }); 274 275 // INSTANCE-SPECIFIC selection tracking (prevents crosstalk between editors) 276 this.editor.addEventListener('mouseup', () => this.updateLocalSelection()); 277 this.editor.addEventListener('keyup', () => this.updateLocalSelection()); 278 279 // Hide tooltips on blur (but not if focus went to link modal) 280 this.editor.addEventListener('blur', () => { 281 setTimeout(() => { 282 if (!this.selectionTooltip.contains(document.activeElement) && 283 !this.linkPopup.contains(document.activeElement) && 284 !this.linkModal.contains(document.activeElement)) { 285 this.hideSelectionTooltip(); 286 this.hideLinkPopup(); 287 } 288 }, 150); 289 }); 290 291 // Keyboard shortcuts 292 this.editor.addEventListener('keydown', (e) => { 293 if (e.ctrlKey || e.metaKey) { 294 if (e.key === 'z' && !e.shiftKey) { 295 e.preventDefault(); 296 this.undo(); 297 return; 298 } else if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { 299 e.preventDefault(); 300 this.redo(); 301 return; 302 } else if (e.key === 'b') { 303 e.preventDefault(); 304 this.insertFormat('bold'); 305 } else if (e.key === 'i') { 306 e.preventDefault(); 307 this.insertFormat('italic'); 308 } else if (e.key === 'u') { 309 e.preventDefault(); 310 this.insertFormat('underline'); 311 } else if (e.key === 'k') { 312 e.preventDefault(); 313 this.insertFormat('link'); 314 } 315 } 316 317 if (e.key === 'Enter' && !e.shiftKey) { 318 setTimeout(() => this.handleInput(), 0); 319 } 320 }); 321 322 // Paste handling 323 this.editor.addEventListener('paste', (e) => { 324 e.preventDefault(); 325 const text = e.clipboardData.getData('text/plain'); 326 document.execCommand('insertText', false, text); 327 }); 328 329 // Click on links 330 this.editor.addEventListener('click', (e) => { 331 const linkEl = e.target.closest('.rte-link'); 332 if (linkEl) { 333 e.preventDefault(); 334 this.showLinkPopup(linkEl); 335 } else { 336 this.hideLinkPopup(); 337 } 338 }); 339 340 // Click outside 341 document.addEventListener('mousedown', (e) => { 342 if (!this.selectionTooltip.contains(e.target) && e.target !== this.editor && !this.editor.contains(e.target)) { 343 this.hideSelectionTooltip(); 344 } 345 if (!this.linkPopup.contains(e.target) && !e.target.closest('.rte-link')) { 346 this.hideLinkPopup(); 347 } 348 }); 349 350 // Scroll/resize 351 window.addEventListener('scroll', () => { 352 this.repositionTooltip(); 353 this.hideLinkPopup(); 354 }, true); 355 window.addEventListener('resize', () => { 356 this.repositionTooltip(); 357 this.hideLinkPopup(); 358 }); 359 } 360 361 // Instance-specific selection tracking 362 updateLocalSelection() { 363 const sel = window.getSelection(); 364 if (sel.rangeCount > 0 && this.editor.contains(sel.anchorNode)) { 365 const newSelection = this.saveSelection(); 366 // Only update savedSelection if we got a valid selection (don't overwrite with null) 367 if (newSelection) { 368 this.savedSelection = newSelection; 369 } 370 if (!sel.isCollapsed) { 371 this.checkSelection(); 372 } else { 373 this.hideSelectionTooltip(); 374 } 375 } 376 } 377 378 // History management 379 saveToHistory() { 380 const content = this.getPlainText(); 381 382 // Don't save if same as current state 383 if (this.history.length > 0 && this.history[this.historyIndex] === content) { 384 return; 385 } 386 387 // Remove future states if we're not at the end 388 if (this.historyIndex < this.history.length - 1) { 389 this.history = this.history.slice(0, this.historyIndex + 1); 390 } 391 392 this.history.push(content); 393 394 if (this.history.length > this.maxHistorySize) { 395 this.history.shift(); 396 } 397 398 this.historyIndex = this.history.length - 1; 399 this.lastSavedContent = content; 400 } 401 402 undo() { 403 if (this.historyIndex > 0) { 404 // Save current state if changed 405 const current = this.getPlainText(); 406 if (current !== this.history[this.historyIndex]) { 407 this.saveToHistory(); 408 this.historyIndex--; // Go back one more since we just added 409 } 410 411 this.historyIndex--; 412 this.restoreFromHistory(); 413 } 414 } 415 416 redo() { 417 if (this.historyIndex < this.history.length - 1) { 418 this.historyIndex++; 419 this.restoreFromHistory(); 420 } 421 } 422 423 restoreFromHistory() { 424 const content = this.history[this.historyIndex]; 425 if (content === undefined) return; 426 427 this.isUndoRedo = true; 428 429 // Restore content 430 this.editor.textContent = content; 431 this.textarea.value = content; 432 433 // Re-render highlighting
434 this.renderHighlighting(); 435 this.updatePreview(); 436 this.onUpdate(content); 437 438 // Place cursor at end 439 const range = document.createRange(); 440 range.selectNodeContents(this.editor); 441 range.collapse(false); 442 const sel = window.getSelection(); 443 sel.removeAllRanges(); 444 sel.addRange(range); 445 446 this.isUndoRedo = false; 447 } 448 449 handleInput() { 450 clearTimeout(this.renderTimeout); 451 this.renderTimeout = setTimeout(() => { 452 this.syncToTextarea(); 453 this.renderHighlighting(); 454 this.updatePreview(); 455 this.onUpdate(this.getValue()); 456 457 // Save to history after debounce 458 const current = this.getPlainText(); 459 if (current !== this.lastSavedContent) { 460 this.saveToHistory(); 461 } 462 }, 100); 463 } 464 465 syncToTextarea() { 466 this.textarea.value = this.getPlainText(); 467 } 468 469 getPlainText() { 470 // Clone and process to get plain text 471 const clone = this.editor.cloneNode(true); 472 473 // Restore link markdown from data attributes 474 clone.querySelectorAll('.rte-link').forEach(link => { 475 const text = link.dataset.text || link.textContent; 476 const url = link.dataset.url || ''; 477 link.replaceWith(`[${text}](${url})`); 478 }); 479 480 // Replace <br> with newlines 481 clone.querySelectorAll('br').forEach(br => br.replaceWith('\n')); 482 483 // Handle divs (each div is a new line) 484 clone.querySelectorAll('div').forEach(el => { 485 if (el.previousSibling && el.previousSibling.nodeName !== 'BR') { 486 el.insertAdjacentText('beforebegin', '\n'); 487 } 488 }); 489 490 return clone.textContent || ''; 491 } 492 493 renderHighlighting() { 494 const savedSelection = this.saveSelection(); 495 const text = this.getPlainText(); 496 497 const html = this.highlightSyntax(text); 498 this.editor.innerHTML = html || '<br>'; 499 500 if (savedSelection !== null && !this.isUndoRedo) { 501 this.restoreSelection(savedSelection); 502 } 503 } 504 505 highlightSyntax(text) { 506 if (!text) return ''; 507 508 // Clean any stray placeholder tokens from previous renders (make brackets optional to catch partial tokens) 509 let html = text 510 .replace(/[\u2997\u2998]?RTEPH\d+[\u2997\u2998]?/g, '') 511 .replace(/___RTEPH___\d*___RTEPH___/g, ''); 512 513 html = html 514 .replace(/&/g, '&') 515 .replace(/</g, '<') 516 .replace(/>/g, '>'); 517 518 const placeholders = []; 519 const addPlaceholder = (content) => { 520 const index = placeholders.length; 521 placeholders.push(content); 522 return `\u2997RTEPH${index}\u2998`; 523 }; 524 525 // Code blocks (must be first) 526 html = html.replace(/```([\s\S]*?)```/g, (match, code) => { 527 return addPlaceholder( 528 `<span class="rte-marker">\`\`\`</span><span class="rte-code-block">${code}</span><span class="rte-marker">\`\`\`</span>` 529 ); 530 }); 531 532 // Inline code 533 html = html.replace(/`([^`\n]+)`/g, (match, code) => { 534 return addPlaceholder( 535 `<span class="rte-marker">\`</span><span class="rte-code">${code}</span><span class="rte-marker">\`</span>` 536 ); 537 }); 538 539 // Links [text](url) - show only text, store markdown in data attributes 540 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { 541 return addPlaceholder( 542 `<span class="rte-link" data-text="${text.replace(/"/g, '"')}" data-url="${url.replace(/"/g, '"')}" contenteditable="false">${text}</span>` 543 ); 544 }); 545 546 // Center (before strikethrough) 547 html = html.replace(/~~~([^~]+)~~~/g, (match, content) => { 548 return addPlaceholder( 549 `<span class="rte-marker">~~~</span><span class="rte-center">${content}</span><span class="rte-marker">~~~</span>` 550 ); 551 }); 552 553 // Strikethrough 554 html = html.replace(/~~([^~]+)~~/g, (match, content) => { 555 return addPlaceholder( 556 `<span class="rte-marker">~~</span><span class="rte-strike">${content}</span><span class="rte-marker">~~</span>` 557 ); 558 }); 559 560 // Spoiler 561 html = html.replace(/~!([^!]+)!~/g, (match, content) => { 562 return addPlaceholder( 563 `<span class="rte-marker">~!</span><span class="rte-spoiler-text">${content}</span><span class="rte-marker">!~</span>` 564 ); 565 }); 566 567 // Underline __(text)__ 568 html = html.replace(/__\(([^)]+)\)__/g, (match, content) => { 569 return addPlaceholder( 570 `<span class="rte-marker">__(</span><span class="rte-underline">${content}</span><span class="rte-marker">)__</span>` 571 ); 572 }); 573 574 // Bold **text** 575 html = html.replace(/\*\*([^*]+)\*\*/g, (match, content) => { 576 return addPlaceholder( 577 `<span class="rte-marker">**</span><span class="rte-bold">${content}</span><span class="rte-marker">**</span>` 578 ); 579 }); 580 581 // Italic *text* 582 html = html.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, (match, content) => { 583 return addPlaceholder( 584 `<span class="rte-marker">*</span><span class="rte-italic">${content}</span><span class="rte-marker">*</span>` 585 ); 586 }); 587 588 // Images img(url) or img220(url) 589 html = html.replace(/img(\d*)\(([^)]+)\)/g, (match, size, url) => { 590 return addPlaceholder( 591 `<span class="rte-marker">img${size}(</span><span class="rte-image-url">${url}</span><span class="rte-marker">)</span>` 592 ); 593 }); 594 595 // Headings # ## ### 596 html = html.replace(/^(#{1,3}) (.+)$/gm, (match, hashes, content) => { 597 const level = hashes.length; 598 return addPlaceholder( 599 `<span class="rte-marker">${hashes} </span><span class="rte-heading rte-h${level}">${content}</span>` 600 ); 601 }); 602 603 // Blockquotes > text 604 html = html.replace(/^(>) (.+)$/gm, (match, marker, content) => { 605 return addPlaceholder( 606 `<span class="rte-marker">> </span><span class="rte-quote">${content}</span>` 607 ); 608 }); 609 610 // Unordered list 611 html = html.replace(/^- (.+)$/gm, (match, content) => { 612 return addPlaceholder( 613 `<span class="rte-marker">- </span><span class="rte-list-item">${content}</span>` 614 ); 615 }); 616 617 // Ordered list 618 html = html.replace(/^(\d+)\. (.+)$/gm, (match, num, content) => { 619 return addPlaceholder( 620 `<span class="rte-marker">${num}. </span><span class="rte-list-item">${content}</span>` 621 ); 622 }); 623 624 // Restore placeholders
625 placeholders.forEach((content, i) => { 626 html = html.replace(`\u2997RTEPH${i}\u2998`, content); 627 }); 628 629 // Final safety cleanup - remove any placeholder tokens that weren't replaced 630 html = html.replace(/[\u2997\u2998]?RTEPH\d+[\u2997\u2998]?/g, ''); 631 632 // Convert newlines to <br> 633 html = html.replace(/\n/g, '<br>'); 634 635 return html; 636 } 637 638 saveSelection() { 639 const sel = window.getSelection(); 640 if (!sel.rangeCount || !this.editor.contains(sel.anchorNode)) return null; 641 642 const range = sel.getRangeAt(0); 643 const preRange = document.createRange(); 644 preRange.selectNodeContents(this.editor); 645 preRange.setEnd(range.startContainer, range.startOffset); 646 647 // Count characters including BR elements (which become \n in getPlainText) 648 let start = 0; 649 const walker = document.createTreeWalker( 650 this.editor, 651 NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, 652 { 653 acceptNode: (node) => { 654 if (node.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT; 655 if (node.nodeName === 'BR') return NodeFilter.FILTER_ACCEPT; 656 return NodeFilter.FILTER_SKIP; 657 } 658 }, 659 false 660 ); 661 let foundStart = false;
662 663 while (walker.nextNode()) { 664 const node = walker.currentNode; 665 if (node === range.startContainer) { 666 start += range.startOffset; 667 foundStart = true; 668 break; 669 } 670 if (node.nodeName === 'BR') { 671 start += 1; // BR = \n = 1 character 672 } else { 673 start += node.textContent.length; 674 } 675 } 676 677 const length = range.toString().length; 678 return foundStart ? { start, length } : null; 679 } 680 681 restoreSelection(saved) { 682 if (!saved) return; 683 684 const sel = window.getSelection(); 685 const range = document.createRange(); 686 687 let charCount = 0; 688 let startNode = null, startOffset = 0; 689 let endNode = null, endOffset = 0; 690 const targetStart = saved.start; 691 const targetEnd = saved.start + saved.length; 692 693 // Count characters including BR elements (which are \n in plain text) 694 const walker = document.createTreeWalker( 695 this.editor, 696 NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, 697 { 698 acceptNode: (node) => { 699 if (node.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT; 700 if (node.nodeName === 'BR') return NodeFilter.FILTER_ACCEPT; 701 return NodeFilter.FILTER_SKIP; 702 } 703 }, 704 false 705 ); 706 707 while (walker.nextNode()) { 708 const node = walker.currentNode; 709 710 if (node.nodeName === 'BR') { 711 // BR counts as 1 character but we can't select inside it 712 charCount += 1; 713 continue; 714 } 715 716 const nodeLength = node.textContent.length; 717 718 if (!startNode && charCount + nodeLength >= targetStart) { 719 startNode = node; 720 startOffset = Math.min(targetStart - charCount, nodeLength); 721 } 722 723 if (!endNode && charCount + nodeLength >= targetEnd) { 724 endNode = node; 725 endOffset = Math.min(targetEnd - charCount, nodeLength); 726 break; 727 } 728 729 charCount += nodeLength; 730 } 731 732 if (!startNode) { 733 range.selectNodeContents(this.editor); 734 range.collapse(false); 735 } else if (!endNode) { 736 endNode = startNode; 737 endOffset = startOffset; 738 try { 739 range.setStart(startNode, startOffset); 740 range.setEnd(endNode, endOffset); 741 } catch (e) { 742 range.selectNodeContents(this.editor); 743 range.collapse(false); 744 } 745 } else { 746 try { 747 range.setStart(startNode, startOffset); 748 range.setEnd(endNode, endOffset); 749 } catch (e) { 750 range.selectNodeContents(this.editor); 751 range.collapse(false); 752 } 753 } 754 755 sel.removeAllRanges(); 756 sel.addRange(range); 757 } 758 759 checkSelection() { 760 const sel = window.getSelection(); 761 if (!sel.rangeCount) return; 762 763 const range = sel.getRangeAt(0); 764 if (!range.collapsed && this.editor.contains(range.commonAncestorContainer)) { 765 this.showSelectionTooltip(); 766 } else { 767 this.hideSelectionTooltip(); 768 } 769 } 770 771 showSelectionTooltip() { 772 const sel = window.getSelection(); 773 if (!sel.rangeCount) return; 774 775 const range = sel.getRangeAt(0); 776 const rect = range.getBoundingClientRect(); 777 778 this.selectionTooltip.style.display = 'flex'; 779 this.selectionTooltip.style.left = `${rect.left + rect.width / 2}px`; 780 this.selectionTooltip.style.top = `${rect.top - 45}px`; 781 782 requestAnimationFrame(() => { 783 const tooltipRect = this.selectionTooltip.getBoundingClientRect(); 784 if (tooltipRect.right > window.innerWidth) { 785 this.selectionTooltip.style.left = `${window.innerWidth - tooltipRect.width - 10}px`; 786 } 787 if (tooltipRect.left < 0) { 788 this.selectionTooltip.style.left = '10px'; 789 } 790 if (tooltipRect.top < 0) { 791 this.selectionTooltip.style.top = `${rect.bottom + 10}px`; 792 } 793 }); 794 } 795 796 hideSelectionTooltip() { 797 this.selectionTooltip.style.display = 'none'; 798 } 799 800 repositionTooltip() { 801 if (this.selectionTooltip.style.display !== 'none') { 802 this.showSelectionTooltip(); 803 } 804 } 805 806 // Link popup methods (for existing links) 807 showLinkPopup(linkEl) { 808 this.currentLinkElement = linkEl; 809 const url = linkEl.dataset.url || ''; 810 const rect = linkEl.getBoundingClientRect(); 811 812 this.linkPopup.querySelector('.rte-link-popup-url').textContent = url; 813 this.linkPopup.style.display = 'block'; 814 this.linkPopup.style.left = `${rect.left}px`; 815 this.linkPopup.style.top = `${rect.bottom + 5}px`; 816 817 // Adjust if off screen 818 requestAnimationFrame(() => { 819 const popupRect = this.linkPopup.getBoundingClientRect(); 820 if (popupRect.right > window.innerWidth) { 821 this.linkPopup.style.left = `${window.innerWidth - popupRect.width - 10}px`; 822 } 823 }); 824 } 825 826 hideLinkPopup() { 827 this.linkPopup.style.display = 'none'; 828 this.currentLinkElement = null; 829 } 830 831 // Show custom link modal (non-blocking, replaces prompt) 832 showLinkModal(prefillText = '', prefillUrl = 'https://', isEdit = false) { 833 const urlInput = this.linkModal.querySelector('.rte-link-url'); 834 const textInput = this.linkModal.querySelector('.rte-link-text'); 835 const header = this.linkModal.querySelector('.rte-modal-header'); 836 const saveBtn = this.linkModal.querySelector('.rte-modal-save'); 837 838 urlInput.value = prefillUrl; 839 textInput.value = prefillText; 840 header.textContent = isEdit ? 'Edit Link' : 'Insert Link'; 841 saveBtn.textContent = isEdit ? 'Save' : 'Insert'; 842 843 this.linkModal.dataset.mode = isEdit ? 'edit' : 'insert'; 844 this.linkModal.style.display = 'flex'; 845 846 // Focus URL input 847 setTimeout(() => urlInput.focus(), 50); 848 } 849 850 hideLinkModal() { 851 this.linkModal.style.display = 'none'; 852 // Restore focus to editor 853 this.editor.focus(); 854 } 855 856 handleLinkModalSave() { 857 const urlInput = this.linkModal.querySelector('.rte-link-url'); 858 const textInput = this.linkModal.querySelector('.rte-link-text'); 859 const url = urlInput.value.trim(); 860 const text = textInput.value.trim() || 'link'; 861 const isEdit = this.linkModal.dataset.mode === 'edit'; 862 863 if (!url || url === 'https://') { 864 this.hideLinkModal(); 865 return; 866 } 867 868 if (isEdit && this.currentLinkElement) { 869 // Editing existing link 870 this.currentLinkElement.dataset.text = text; 871 this.currentLinkElement.dataset.url = url; 872 this.currentLinkElement.textContent = text; 873 874 this.hideLinkPopup(); 875 this.hideLinkModal(); 876 this.syncToTextarea(); 877 this.updatePreview(); 878 this.saveToHistory(); 879 } else { 880 // Inserting new link 881 const insertion = `[${text}](${url})`; 882 this.applyManualSplicing(insertion); 883 this.hideLinkModal(); 884 } 885 } 886 887 // Manual string splicing - prevents deletion bug by rebuilding the string 888 applyManualSplicing(insertion) { 889 const originalText = this.getPlainText(); 890 const saved = this.savedSelection || { start: originalText.length, length: 0 }; 891 892 const before = originalText.substring(0, saved.start); 893 const after = originalText.substring(saved.start + saved.length); 894 const newContent = before + insertion + after; 895 896 this.textarea.value = newContent; 897 this.editor.textContent = newContent; 898
899 this.renderHighlighting(); 900 this.restoreSelection({ 901 start: saved.start + insertion.length, 902 length: 0 903 }); 904 905 this.saveToHistory(); 906 this.updatePreview(); 907 this.onUpdate(newContent); 908 this.editor.focus(); 909 } 910 911 editCurrentLink() { 912 if (!this.currentLinkElement) return; 913 914 const currentText = this.currentLinkElement.dataset.text || this.currentLinkElement.textContent; 915 const currentUrl = this.currentLinkElement.dataset.url || ''; 916 917 this.showLinkModal(currentText, currentUrl, true); 918 } 919 920 removeCurrentLink() { 921 if (!this.currentLinkElement) return; 922 923 const text = this.currentLinkElement.dataset.text || this.currentLinkElement.textContent; 924 this.currentLinkElement.replaceWith(text); 925 926 this.hideLinkPopup(); 927 this.syncToTextarea(); 928 this.renderHighlighting(); 929 this.updatePreview(); 930 this.saveToHistory(); 931 } 932 933 insertFormat(type) { 934 clearTimeout(this.renderTimeout); 935 936 // 1. LOCK THE CONTEXT: Capture this specific editor instance 937 const activeEditor = this.editor; 938 const originalText = this.getPlainText(); 939 940 // 2. Try to get current selection, fall back to saved selection 941 const currentSel = this.saveSelection(); 942 const saved = currentSel || this.savedSelection; 943 944 // Only update savedSelection if we have a valid selection (don't overwrite with null) 945 if (saved) { 946 this.savedSelection = saved; 947 } 948 949 // 3. Get selected text 950 let selectedText = ''; 951 const sel = window.getSelection(); 952 if (sel.rangeCount > 0 && activeEditor.contains(sel.getRangeAt(0).commonAncestorContainer)) { 953 selectedText = sel.toString(); 954 } else if (saved && saved.length > 0) { 955 selectedText = originalText.substring(saved.start, saved.start + saved.length); 956 } 957 958 // Link uses custom modal instead of JS prompt 959 if (type === 'link') { 960 this.hideSelectionTooltip(); 961 this.showLinkModal(selectedText, 'https://', false); 962 return; 963 } 964 965 // Default formatting logic 966 activeEditor.focus(); 967 let finalInsertion = ''; 968 969 if (type === 'code') { 970 finalInsertion = selectedText.includes('\n') ? `\`\`\`\n${selectedText}\n\`\`\`` : `\`${selectedText || 'code'}\``; 971 } else if (type === 'olist') { 972 finalInsertion = selectedText ? selectedText.split('\n').map((l, i) => `${i + 1}. ${l}`).join('\n') : '1. item'; 973 } else if (type === 'clear') { 974 finalInsertion = this.clearFormatting(selectedText); 975 } else { 976 const format = this.formats[type]; 977 if (!format) return; 978 const txt = selectedText.length > 0 ? selectedText : format.placeholder; 979 finalInsertion = `${format.prefix}${txt}${format.suffix}`; 980 } 981 982 // Use manual splicing for reliability 983 if (saved) { 984 const before = originalText.substring(0, saved.start); 985 const after = originalText.substring(saved.start + saved.length); 986 const newContent = before + finalInsertion + after; 987 988 this.textarea.value = newContent; 989 activeEditor.textContent = newContent; 990 991 this.renderHighlighting(); 992 this.restoreSelection({ 993 start: saved.start + finalInsertion.length, 994 length: 0 995 }); 996 997 this.saveToHistory(); 998 this.updatePreview(); 999 this.onUpdate(newContent); 1000 } else { 1001 document.execCommand('insertText', false, finalInsertion); 1002 this.handleInput(); 1003 } 1004 } 1005 1006 clearFormatting(text) { 1007 return text 1008 .replace(/\*\*([^*]+)\*\*/g, '$1') 1009 .replace(/__\(([^)]+)\)__/g, '$1') 1010 .replace(/__([^_]+)__/g, '$1') 1011 .replace(/\*([^*]+)\*/g, '$1') 1012 .replace(/(?<!\w)_([^_]+)_(?!\w)/g, '$1') 1013 .replace(/~~~([^~]+)~~~/g, '$1') 1014 .replace(/~~([^~]+)~~/g, '$1') 1015 .replace(/~!([^!]+)!~/g, '$1') 1016 .replace(/`([^`]+)`/g, '$1') 1017 .replace(/```[\s\S]*?```/g, (m) => m.slice(3, -3).trim()) 1018 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') 1019 .replace(/img\d*\([^)]+\)/g, '') 1020 .replace(/^#+\s*/gm, '') 1021 .replace(/^>\s*/gm, '') 1022 .replace(/^-\s+/gm, '') 1023 .replace(/^\d+\.\s+/gm, ''); 1024 } 1025 1026 updatePreview() { 1027 if (this.preview) { 1028 const html = RichTextEditor.renderMarkdown(this.getValue()); 1029 this.preview.innerHTML = html || '<span style="color: var(--text-muted)">Preview will appear here...</span>'; 1030 } 1031 } 1032 1033 // Static markdown renderer 1034 static renderMarkdown(text) { 1035 if (!text) return ''; 1036 1037 // Clean any stray placeholder tokens (make brackets optional to catch partial tokens) 1038 let html = text 1039 .replace(/[\u2997\u2998]?RTE[A-Z]*\d+[\u2997\u2998]?/g, '') 1040 .replace(/___RTE[A-Z]*___\d*___RTE[A-Z]*___/g, ''); 1041 1042 html = html 1043 .replace(/&/g, '&') 1044 .replace(/</g, '<') 1045 .replace(/>/g, '>'); 1046 1047 const codeBlocks = []; 1048 html = html.replace(/```([\s\S]*?)```/g, (_, code) => { 1049 codeBlocks.push(`<pre><code>${code}</code></pre>`); 1050 return `\u2997RTECB${codeBlocks.length - 1}\u2998`; 1051 }); 1052 1053 const inlineCodes = []; 1054 html = html.replace(/`([^`]+)`/g, (_, code) => { 1055 inlineCodes.push(`<code>${code}</code>`); 1056 return `\u2997RTEIC${inlineCodes.length - 1}\u2998`; 1057 }); 1058 1059 html = html.replace(/~~~([\s\S]*?)~~~/g, '<div class="center">$1</div>'); 1060 html = html.replace(/~~([\s\S]*?)~~/g, '<s>$1</s>'); 1061 html = html.replace(/~!([\s\S]*?)!~/g, '<span class="spoiler" onclick="this.classList.toggle(\'revealed\')">$1</span>'); 1062 html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>'); 1063 html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>'); 1064 html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>'); 1065 html = html.replace(/img(\d*)\(([^)]+)\)/g, (_, size, url) => { 1066 const style = size ? ` style="max-width: ${size}px"` : ''; 1067 return `<img src="${url}"${style} alt="image" loading="lazy">`; 1068 }); 1069 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'); 1070 html = html.replace(/__\(([\s\S]*?)\)__/g, '<u>$1</u>'); 1071 html = html.replace(/\*\*([\s\S]*?)\*\*/g, '<strong>$1</strong>'); 1072 html = html.replace(/__([\s\S]*?)__/g, '<strong>$1</strong>'); 1073 html = html.replace(/\*([\s\S]*?)\*/g, '<em>$1</em>'); 1074 html = html.replace(/(?<!\w)_([\s\S]*?)_(?!\w)/g, '<em>$1</em>'); 1075 html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>'); 1076 html = html.replace(/<\/blockquote>\n<blockquote>/g, '<br>'); 1077 1078 const lines = html.split('\n'); 1079 let inUl = false, inOl = false;
1080 const processed = []; 1081 for (const line of lines) { 1082 const ulM = line.match(/^- (.+)$/); 1083 const olM = line.match(/^\d+\. (.+)$/); 1084 if (ulM) { 1085 if (!inUl) { if (inOl) { processed.push('</ol>'); inOl = false; } processed.push('<ul>'); inUl = true; } 1086 processed.push(`<li>${ulM[1]}</li>`); 1087 } else if (olM) { 1088 if (!inOl) { if (inUl) { processed.push('</ul>'); inUl = false; } processed.push('<ol>'); inOl = true; } 1089 processed.push(`<li>${olM[1]}</li>`); 1090 } else { 1091 if (inUl) { processed.push('</ul>'); inUl = false; } 1092 if (inOl) { processed.push('</ol>'); inOl = false; } 1093 processed.push(line); 1094 } 1095 } 1096 if (inUl) processed.push('</ul>'); 1097 if (inOl) processed.push('</ol>'); 1098 html = processed.join('\n'); 1099 1100 html = html.replace(/\n/g, '<br>'); 1101 html = html.replace(/<br>(<\/?(h[1-3]|ul|ol|li|blockquote|pre|div))/g, '$1'); 1102 html = html.replace(/(<\/?(h[1-3]|ul|ol|li|blockquote|pre|div)>)<br>/g, '$1'); 1103 1104 codeBlocks.forEach((b, i) => { html = html.replace(`\u2997RTECB${i}\u2998`, b); }); 1105 inlineCodes.forEach((c, i) => { html = html.replace(`\u2997RTEIC${i}\u2998`, c); }); 1106 1107 // Final safety cleanup - remove any placeholder tokens that weren't replaced 1108 html = html.replace(/[\u2997\u2998]?RTE[A-Z]*\d+[\u2997\u2998]?/g, ''); 1109 1110 return html; 1111 } 1112 1113 getValue() { 1114 return this.textarea.value; 1115 } 1116 1117 setValue(value) { 1118 this.textarea.value = value; 1119 this.editor.textContent = value; 1120 this.renderHighlighting(); 1121 this.updatePreview(); 1122 this.saveToHistory(); 1123 } 1124 1125 focus() { 1126 this.editor.focus(); 1127 } 1128 1129 destroy() { 1130 if (this.selectionTooltip?.parentNode) { 1131 this.selectionTooltip.parentNode.removeChild(this.selectionTooltip); 1132 } 1133 if (this.linkPopup?.parentNode) { 1134 this.linkPopup.parentNode.removeChild(this.linkPopup); 1135 } 1136 if (this.linkModal?.parentNode) { 1137 this.linkModal.parentNode.removeChild(this.linkModal); 1138 } 1139 if (this.editor?.parentNode) { 1140 this.editor.parentNode.removeChild(this.editor); 1141 } 1142 this.textarea.style.display = ''; 1143 } 1144} 1145 1146// Store for editor instances 1147window.rteEditors = window.rteEditors || {}; 1148 1149// Export 1150if (typeof module !== 'undefined' && module.exports) { 1151 module.exports = RichTextEditor; 1152}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.