implemented markdown extended
This commit is contained in:
parent
23cad42bc1
commit
ba985b1552
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
class ParsedownTableExtension extends Parsedown
|
||||
class ParsedownTableExtension extends ParsedownCheckbox
|
||||
{
|
||||
protected function blockTable($Line, array $Block = null)
|
||||
{
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
"require": {
|
||||
"bcosca/fatfree-core": "^3.9",
|
||||
"erusev/parsedown": "^1.7",
|
||||
"ezyang/htmlpurifier": "^4.18"
|
||||
"ezyang/htmlpurifier": "^4.18",
|
||||
"erusev/parsedown-extra": "^0.8.1",
|
||||
"singular-it/parsedown-checkbox": "^0.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
332
public/js/tp_md_editor.js
Normal file
332
public/js/tp_md_editor.js
Normal file
@ -0,0 +1,332 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -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);
|
||||
|
||||
52
public/test.md.php
Normal file
52
public/test.md.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
?>
|
||||
|
||||
<head>
|
||||
<script src="js/tp_md_editor.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
tp_md_editor.init({
|
||||
groups: [
|
||||
['h1', 'h2', 'h3'],
|
||||
['bold', 'italic', 'quote'],
|
||||
['link', 'code'],
|
||||
['bullet', 'number', 'task'],
|
||||
['hr', 'table']
|
||||
]
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<!-- font awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
|
||||
integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<style>
|
||||
html { font-family: sans-serif; }
|
||||
tp-md-editor { width: 50%; display: block; padding: .2em; }
|
||||
tp-md-toolbar, tp-md-toolbar-group { display: flex; gap: .5em; }
|
||||
tp-md-toolbar-group:not(:last-child) { border-right:1px solid #ccc; padding-right: .5em; }
|
||||
tp-md-toolbar-button { width: 32px; height: 32px; align-content:center;}
|
||||
tp-md-toolbar-button { display:inline-block; text-align:center; border: none; cursor: pointer; }
|
||||
tp-md-toolbar-button:hover { color:royalblue; }
|
||||
tp-md-editor textarea { border: 1px solid #ccc; padding: 1em; margin-top: 1em; width: 100%; }
|
||||
tp-md-editor textarea:focus-visible { outline: 0; }
|
||||
|
||||
.fa-stack-100 { width: 2em; line-height: 2em; }
|
||||
.fa-stack-offset { right: 10%; position: absolute; bottom: 15%; font-size: .5em; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>MD Testing</h1>
|
||||
<tp-md-editor name="description">
|
||||
1. MD CONTENT
|
||||
2. list item two
|
||||
|
||||
and something else
|
||||
|
||||
- and then
|
||||
- and then
|
||||
- and then
|
||||
</tp-md-editor>
|
||||
</body>
|
||||
Loading…
x
Reference in New Issue
Block a user