implemented markdown extended
This commit is contained in:
parent
23cad42bc1
commit
ba985b1552
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
class ParsedownTableExtension extends Parsedown
|
class ParsedownTableExtension extends ParsedownCheckbox
|
||||||
{
|
{
|
||||||
protected function blockTable($Line, array $Block = null)
|
protected function blockTable($Line, array $Block = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"bcosca/fatfree-core": "^3.9",
|
"bcosca/fatfree-core": "^3.9",
|
||||||
"erusev/parsedown": "^1.7",
|
"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); }
|
#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 Component */
|
||||||
.list{
|
.list{
|
||||||
--be-list-color:var(--bulma-text);
|
--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