332 lines
12 KiB
JavaScript
332 lines
12 KiB
JavaScript
|
|
/**
|
||
|
|
* 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 = `<i class="${icon}"></i>`;
|
||
|
|
} else {
|
||
|
|
this.element.innerHTML = `<span class="fa-stack fa-stack-100"><i class="fa-stack-1x ${icon}"></i><i class="fa-stack-offset ${icon_offset}"></i></span>`;
|
||
|
|
}
|
||
|
|
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;
|