/**
* 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;