PageSourceSearch

https://otakiroku.com/shared/rich-text-editor.js

js otakiroku.com collected 2026-10-02 01:38:54 UTC 44,776 bytes, 1,152 lines download raw bytes

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, '&amp;')
515            .replace(/</g, '&lt;')
516            .replace(/>/g, '&gt;');
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, '&quot;')}" data-url="${url.replace(/"/g, '&quot;')}" 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(/^(&gt;) (.+)$/gm, (match, marker, content) => {
605            return addPlaceholder(
606                `<span class="rte-marker">&gt; </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, '&amp;')
1044            .replace(/</g, '&lt;')
1045            .replace(/>/g, '&gt;');
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(/^&gt; (.+)$/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.