/** * tp_md_editor.js * Self-contained Markdown Editor with Toolbar Buttons * Usage: tp_md_editor.init(config) */ class TPMarkdownEditor { static init(config = {}) { document.querySelectorAll('tp-md-editor').forEach(editor => { new TPMarkdownEditor(editor, config); }); } constructor(wrapper, config) { this.wrapper = wrapper; this.name = wrapper.getAttribute('name'); this.config = config; this.textarea = document.createElement('textarea'); this.textarea.name = this.name; this.textarea.rows = 25; this.toolbar = document.createElement('tp-md-toolbar'); this.undoStack = [] this.redoStack = [] this.localStorageKey = this.getStorageKey(); this.unsaved = false; this.autosaveInterval = null; // this.loadInitialContent(); this.captureInitialContent(); this.createUnsavedBanner(); this.loadFromLocalStorage(); this.wrapper.appendChild(this.unsavedBanner); this.wrapper.appendChild(this.toolbar); this.wrapper.appendChild(this.textarea); this.buttonClasses = TPMarkdownEditor.defaultButtons(); this.buildToolbar(); this.setupAutoList(); this.setupUndoRedo(); this.setupPersistence(); this.setupAutoSave(); } getStorageKey(){ const path = window.location.pathname; return `tp_md_editor:${path}:${this.name}`; } createUnsavedBanner(){ const container = document.createElement('div'); container.style.cssText = 'background: #fff3cd; color: #856404; padding: 5px 10px; font-size: 0.9em; display: flex; justify-content: space-between; align-items: center; display: none;'; const text = document.createElement('span'); text.textContent = 'You have unsaved changes.'; const discardBtn = document.createElement('button'); discardBtn.textContent = 'Discard'; discardBtn.style.cssText = 'margin-left: auto; background: none; border: none; color: #856404; text-decoration: underline; cursor: pointer;'; discardBtn.addEventListener('click', () => { localStorage.removeItem(this.localStorageKey); const hidden = this.wrapper.querySelector('.tp-md-initial'); if(hidden){ this.textarea.value = hidden.textContent; } this.clearUnsaved(); }); container.appendChild(text); container.appendChild(discardBtn); this.unsavedBanner = container } markUnsaved(){ this.unsaved = true; this.unsavedBanner.style.display = 'block'; } clearUnsaved(){ this.unsaved = false; this.unsavedBanner.style.display = 'none'; } setupPersistence(){ window.addEventListener('beforeunload', (e) => { if(this.unsaved){ localStorage.setItem(this.localStorageKey, this.textarea.value); } }); } setupAutoSave(){ this.autosaveInterval = setInterval(() => { if(this.unsaved){ localStorage.setItem(this.localStorageKey, this.textarea.value); } }, 5000); // save every 5 sec } loadFromLocalStorage(){ const saved = localStorage.getItem(this.localStorageKey); if(saved){ this.textarea.value = saved; this.markUnsaved(); } } buildToolbar() { const groups = this.config.groups || [Object.keys(this.buttonClasses)]; groups.forEach(group => { const groupEl = document.createElement('tp-md-toolbar-group'); group.forEach(btnType => { const BtnClass = this.buttonClasses[btnType]; if (BtnClass) { const btn = new BtnClass(this.textarea); groupEl.appendChild(btn.element); } }); this.toolbar.appendChild(groupEl); }); } setupUndoRedo(){ this.textarea.addEventListener('input', ()=>{ this.undoStack.push(this.textarea.value); if(this.undoStack > 100) this.undoStack.shift(); this.redoStack = []; this.markUnsaved(); }); this.textarea.addEventListener('keydown', (e) => { if(e.ctrlKey && e.key === 'z'){ e.preventDefault(); if(this.undoStack.length > 0 ){ this.redoStack.push(this.textarea.value); this.textarea.value = this.undoStack.pop(); this.markUnsaved(); } } else if (e.ctrlKey && (e.key === 'y')) { // || e.shiftkey && e.key === 'z' e.preventDefault(); if(this.redoStack.length > 0){ this.undoStack.push(this.textarea.value); this.textarea.value = this.redoStack.pop(); this.markUnsaved(); } } }); } setupAutoList() { this.textarea.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const pos = this.textarea.selectionStart; const before = this.textarea.value.slice(0, pos); const after = this.textarea.value.slice(pos); const lines = before.split('\n'); const lastLine = lines[lines.length - 1]; // need order of task > ul let match; if ((match = lastLine.match(/^(\s*)- \[( |x)\] /))) { // task e.preventDefault(); if (lastLine.trim() === '- [ ]' || lastLine.trim() === '- [x]') { this.removeLastLine(pos, lastLine.length); } else { const insert = '\n' + match[1] + '- [ ] '; this.insertAtCursor(insert); } } else if ((match = lastLine.match(/^(\s*)([-*+] )/))) { // ul e.preventDefault(); if (lastLine.trim() === match[2].trim()) { this.removeLastLine(pos, lastLine.length); } else { const insert = '\n' + match[1] + match[2]; this.insertAtCursor(insert); } } else if ((match = lastLine.match(/^(\s*)(\d+)\. /))) { // ol e.preventDefault(); if (lastLine.trim() === `${match[2]}.`) { this.removeLastLine(pos, lastLine.length); } else { const nextNum = parseInt(match[2]) + 1; const insert = `\n${match[1]}${nextNum}. `; this.insertAtCursor(insert); } } } }); } removeLastLine(cursorPos, lengthToRemove) { const start = cursorPos - lengthToRemove; this.textarea.setRangeText('', start, cursorPos, 'start'); this.textarea.setSelectionRange(start, start); } insertAtCursor(text) { const start = this.textarea.selectionStart; const end = this.textarea.selectionEnd; this.textarea.setRangeText(text, start, end, 'end'); const newPos = start + text.length; this.textarea.setSelectionRange(newPos, newPos) this.markUnsaved(); } captureInitialContent() { const hidden = document.createElement('script'); hidden.type = 'text/plain'; // hidden.style.display = 'none'; hidden.classList.add('tp-md-initial'); hidden.textContent = this.wrapper.textContent.trim(); // clear inner content so it's not visible twice this.wrapper.textContent = ''; this.wrapper.appendChild(hidden); this.textarea.value = hidden.textContent; } static defaultButtons() { return { h1: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '# ', 'H1', 'fas fa-heading', '', 'fas fa-1'); } }, h2: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '## ', 'H2', 'fas fa-heading', '', 'fas fa-2'); } }, h3: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '### ', 'H3', 'fas fa-heading', '', 'fas fa-3'); } }, bold: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '**', 'Bold', 'fas fa-bold', '**'); } }, italic: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '_', 'Italic', 'fas fa-italic', '_'); } }, quote: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '> ', 'Quote', 'fas fa-quote-right'); } }, code: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '`', 'Code', 'fas fa-code', '`'); } formatSelection(sel) { if (!sel || !sel.includes('\n')) { return '`' + (sel || 'code') + '`'; } return '```\n' + sel + '\n```'; } }, link: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '[', 'Link', 'fas fa-link', '](url)'); } formatSelection(sel) { return `[${sel || 'text'}](url)`; } }, bullet: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '- ', 'Bullet', 'fas fa-list-ul'); } formatSelection(sel){ return sel.split('\n').map(line => '- ' + line).join('\n'); } }, number: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '1. ', 'Numbered', 'fas fa-list-ol'); } formatSelection(sel){ return sel.split('\n').map((line, i) => `${i+1}. ${line}`).join('\n'); } }, task: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '- [ ] ', 'Task', 'fas fa-tasks'); } formatSelection(sel){ return sel.split('\n').map(line => ' - [ ]' + line).join('\n') } }, hr: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '---\n', 'HR', 'fas fa-minus'); } }, table: class extends TPMarkdownButton { constructor(textarea) { super(textarea, '', 'Table', 'fas fa-table'); } formatSelection(_) { return '| Col1 | Col2 |\n|------|------|\n| Val1 | Val2 |'; } }, }; } } class TPMarkdownButton { constructor(textarea, prefix = '', title = '', icon = '', suffix = '', icon_offset = '') { this.textarea = textarea; this.prefix = prefix; this.suffix = suffix; this.element = document.createElement('tp-md-toolbar-button'); this.element.title = title; if (icon_offset == '') { this.element.innerHTML = ``; } else { this.element.innerHTML = ``; } this.element.addEventListener('click', () => this.apply()); } formatSelection(sel) { return this.prefix + (sel || 'text') + this.suffix; } apply() { const textarea = this.textarea; const start = textarea.selectionStart; const end = textarea.selectionEnd; const text = textarea.value; this.previousValue = textarea.value; const selected = text.substring(start, end); const formatted = this.formatSelection(selected); textarea.setRangeText(formatted, start, end, 'end'); if(this.previousValue !== textarea.value){ if(!textarea.undoStack) textarea.undoStack = []; textarea.undoStack.push(this.previousValue); } textarea.focus(); } } // Export as global window.tp_md_editor = TPMarkdownEditor;