diff --git a/app/extensions/ParsedownTableExtension.php b/app/extensions/ParsedownTableExtension.php index 16e4899..3bbce93 100644 --- a/app/extensions/ParsedownTableExtension.php +++ b/app/extensions/ParsedownTableExtension.php @@ -1,6 +1,6 @@ { + 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; \ No newline at end of file diff --git a/public/style.css b/public/style.css index 3362e33..1b569e5 100644 --- a/public/style.css +++ b/public/style.css @@ -10,6 +10,11 @@ i.fa { font-weight: 100 !important ; } #ticket_list .g-flex-item { border-bottom: 1px solid var(--bulma-text-soft); } +/* parsedown check-checkbox */ +li.parsedown-task-list { + list-style: none; +} + /* List Component */ .list{ --be-list-color:var(--bulma-text); diff --git a/public/test.md.php b/public/test.md.php new file mode 100644 index 0000000..641fe26 --- /dev/null +++ b/public/test.md.php @@ -0,0 +1,52 @@ + + + + + + + + + + + + +

MD Testing

+ +1. MD CONTENT +2. list item two + +and something else + +- and then +- and then +- and then + + \ No newline at end of file