HackmanCMS/web/assets/js/milkdown-mount.js
2026-05-03 20:56:15 +03:00

362 lines
16 KiB
JavaScript

/**
* milkdown-mount.js — auto-mount a Milkdown WYSIWYG editor on any
* <textarea class="mk-mount"> and keep it synced with the underlying
* textarea. Adapted from Agenda (/opt/agenda/web/lib/milkdown-mount.js)
* with English UI and HackmanCMS's dark theme.
*
* Relies on window.MilkdownKit being populated by the loader in view.php.
*
* data- attributes on the textarea:
* data-mk-min-height="20rem" override the editor's min-height
* data-mk-required empty content blocks form submit
*/
(function () {
'use strict';
// ── Link dialog ─────────────────────────────────────────────────────
var _linkModalEl = null;
function ensureLinkModal() {
if (_linkModalEl) return _linkModalEl;
var el = document.createElement('div');
el.className = 'modal fade';
el.tabIndex = -1;
el.setAttribute('aria-hidden', 'true');
el.innerHTML =
'<div class="modal-dialog modal-dialog-centered modal-sm">' +
'<div class="modal-content">' +
'<form class="ld-form">' +
'<div class="modal-header py-2">' +
'<h5 class="modal-title h6 mb-0"><i class="bi bi-link-45deg"></i> Link</h5>' +
'<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="mb-2">' +
'<label class="form-label small mb-1">URL</label>' +
'<input type="text" class="form-control form-control-sm ld-url" required ' +
'placeholder="https://… / /path / mailto:…">' +
'<div class="invalid-feedback small ld-url-warning"></div>' +
'</div>' +
'<div class="mb-1 ld-text-wrap">' +
'<label class="form-label small mb-1">Text <span class="text-muted">(optional)</span></label>' +
'<input type="text" class="form-control form-control-sm ld-text">' +
'</div>' +
'</div>' +
'<div class="modal-footer justify-content-between py-2">' +
'<button type="button" class="btn btn-sm btn-outline-danger ld-remove d-none">' +
'<i class="bi bi-trash"></i> Remove' +
'</button>' +
'<div class="ms-auto d-flex gap-2">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>' +
'<button type="submit" class="btn btn-sm btn-primary ld-confirm">Insert</button>' +
'</div>' +
'</div>' +
'</form>' +
'</div>' +
'</div>';
document.body.appendChild(el);
_linkModalEl = el;
return el;
}
function isPlausibleUrl(url) {
if (!url) return false;
return /^(https?:|mailto:|tel:|ftp:|#|\/|[\w.-]+\.[a-z]{2,})/i.test(url.trim());
}
window.LinkDialog = {
open: function (opts) {
opts = opts || {};
var el = ensureLinkModal();
var inst = bootstrap.Modal.getOrCreateInstance(el);
var form = el.querySelector('.ld-form');
var urlEl = el.querySelector('.ld-url');
var textEl = el.querySelector('.ld-text');
var textWrap = el.querySelector('.ld-text-wrap');
var removeBtn = el.querySelector('.ld-remove');
var warnEl = el.querySelector('.ld-url-warning');
var confirmBtn = el.querySelector('.ld-confirm');
urlEl.value = opts.url || '';
textEl.value = opts.text || '';
urlEl.classList.remove('is-invalid');
warnEl.textContent = '';
textWrap.classList.toggle('d-none', !!opts.urlOnly);
removeBtn.classList.toggle('d-none', !opts.canRemove);
confirmBtn.textContent = opts.canRemove ? 'Save' : 'Insert';
return new Promise(function (resolve) {
var done = false;
function cleanup(result) {
if (done) return; done = true;
form.removeEventListener('submit', onSubmit);
removeBtn.removeEventListener('click', onRemove);
el.removeEventListener('hidden.bs.modal', onHidden);
el.removeEventListener('shown.bs.modal', onShown);
inst.hide();
resolve(result);
}
function onSubmit(e) {
e.preventDefault();
var url = urlEl.value.trim();
if (!url) { urlEl.classList.add('is-invalid'); warnEl.textContent = 'URL required.'; return; }
if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('/') && !url.startsWith('#')) {
if (/^[\w.-]+\.[a-z]{2,}/i.test(url)) url = 'https://' + url;
}
if (!isPlausibleUrl(url)) {
urlEl.classList.add('is-invalid'); warnEl.textContent = "Doesn't look like a valid URL.";
urlEl.addEventListener('input', function once() {
urlEl.classList.remove('is-invalid'); warnEl.textContent = '';
urlEl.removeEventListener('input', once);
});
return;
}
cleanup({ action: 'confirm', url: url, text: textEl.value.trim() });
}
function onRemove() { cleanup({ action: 'remove' }); }
function onHidden() { cleanup(null); }
function onShown() { urlEl.focus(); urlEl.select(); }
form.addEventListener('submit', onSubmit);
removeBtn.addEventListener('click', onRemove);
el.addEventListener('hidden.bs.modal', onHidden);
el.addEventListener('shown.bs.modal', onShown);
inst.show();
});
}
};
function mountAll() {
var kit = window.MilkdownKit;
if (!kit) return;
document.querySelectorAll('textarea.mk-mount:not([data-mk-mounted])').forEach(function (ta) {
ta.dataset.mkMounted = '1';
mountOne(ta, kit);
});
}
function mountOne(ta, kit) {
var form = ta.closest('form');
var wrap = document.createElement('div');
wrap.className = 'mk-mount-wrap';
var toolbar = document.createElement('div');
toolbar.className = 'ie-mk-toolbar';
toolbar.innerHTML =
'<div class="ie-mk-tools">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="bold" title="Bold"><i class="bi bi-type-bold"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="italic" title="Italic"><i class="bi bi-type-italic"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="strikethrough" title="Strikethrough"><i class="bi bi-type-strikethrough"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="inlineCode" title="Inline code"><i class="bi bi-code"></i></button>' +
'<span class="ie-mk-sep"></span>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="bullet" title="Bullet list"><i class="bi bi-list-ul"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="ordered" title="Ordered list"><i class="bi bi-list-ol"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="blockquote" title="Quote"><i class="bi bi-blockquote-left"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="codeBlock" title="Code block"><i class="bi bi-code-square"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="link" title="Link"><i class="bi bi-link-45deg"></i></button>' +
'</div>' +
'<button type="button" class="btn btn-sm btn-outline-secondary mk-mode" title="Switch to Markdown source">' +
'<i class="bi bi-markdown"></i> MD' +
'</button>';
var body = document.createElement('div');
body.className = 'ie-mk-body';
// Only honour an explicit data-mk-min-height; otherwise let CSS size the
// body via the surrounding flex layout (so it actually fills the tab pane
// and lets the inner ProseMirror trigger overflow scroll).
var minH = ta.dataset.mkMinHeight;
if (minH) body.style.minHeight = minH;
var editorEl = document.createElement('div');
body.appendChild(editorEl);
var mdTa = document.createElement('textarea');
mdTa.className = 'form-control ie-mk-ta';
mdTa.rows = 4;
mdTa.style.display = 'none';
if (minH) mdTa.style.minHeight = minH;
body.appendChild(mdTa);
wrap.appendChild(toolbar);
wrap.appendChild(body);
var initialMd = ta.value;
ta.style.display = 'none';
var wasRequired = ta.hasAttribute('required');
if (wasRequired) { ta.removeAttribute('required'); ta.dataset.mkRequired = '1'; }
ta.parentNode.insertBefore(wrap, ta);
var mkEditor = null;
var mode = 'wysiwyg';
kit.Editor.make()
.config(function (ctx) {
ctx.set(kit.rootCtx, editorEl);
ctx.set(kit.defaultValueCtx, initialMd);
})
.use(kit.commonmark).use(kit.gfm).use(kit.history)
.create()
.then(function (ed) {
mkEditor = ed;
ta.dispatchEvent(new CustomEvent('mk-ready'));
})
.catch(function (err) { console.error('[mk-mount] init failed:', err); });
function getContent() {
if (mode === 'markdown') return mdTa.value;
if (mkEditor) return mkEditor.action(kit.getMarkdown());
return initialMd;
}
function setContent(md) {
if (mode === 'markdown') { mdTa.value = md; return; }
if (mkEditor) mkEditor.action(kit.replaceAll(md));
}
function insertMd(before, after) {
var s = mdTa.selectionStart, e = mdTa.selectionEnd;
var sel = mdTa.value.substring(s, e);
mdTa.value = mdTa.value.substring(0, s) + before + sel + after + mdTa.value.substring(e);
mdTa.selectionStart = s + before.length;
mdTa.selectionEnd = s + before.length + sel.length;
mdTa.focus();
}
toolbar.querySelector('.ie-mk-tools').addEventListener('click', function (e) {
var btn = e.target.closest('[data-cmd]'); if (!btn) return;
e.preventDefault();
var cmd = btn.dataset.cmd;
if (mode === 'markdown') {
var md = {
bold: function () { insertMd('**', '**'); },
italic: function () { insertMd('*', '*'); },
strikethrough: function () { insertMd('~~', '~~'); },
inlineCode: function () { insertMd('`', '`'); },
bullet: function () { insertMd('- ', ''); },
ordered: function () { insertMd('1. ', ''); },
blockquote: function () { insertMd('> ', ''); },
codeBlock: function () { insertMd('```\n', '\n```'); },
link: function () {
var s = mdTa.selectionStart, e = mdTa.selectionEnd;
var sel = mdTa.value.substring(s, e);
window.LinkDialog.open({ text: sel }).then(function (r) {
if (!r || r.action !== 'confirm') { mdTa.focus(); return; }
var txt = r.text || r.url;
var out = '[' + txt + '](' + r.url + ')';
mdTa.value = mdTa.value.substring(0, s) + out + mdTa.value.substring(e);
mdTa.selectionStart = mdTa.selectionEnd = s + out.length;
mdTa.focus();
});
},
};
if (md[cmd]) md[cmd](); return;
}
if (!mkEditor) return;
var c = kit.commands;
function exec(key, payload) { mkEditor.action(function (ctx) { ctx.get(kit.commandsCtx).call(key, payload); }); }
var wy = {
bold: function () { exec(c.bold.key); },
italic: function () { exec(c.italic.key); },
strikethrough: function () { exec(c.strikethrough.key); },
inlineCode: function () { exec(c.inlineCode.key); },
bullet: function () { exec(c.bulletList.key); },
ordered: function () { exec(c.orderedList.key); },
blockquote: function () { exec(c.blockquote.key); },
codeBlock: function () { exec(c.codeBlock.key); },
link: function () {
window.LinkDialog.open({ urlOnly: true }).then(function (r) {
if (!r || r.action !== 'confirm') return;
exec(c.link.key, { href: r.url });
var pm = editorEl.querySelector('.ProseMirror'); if (pm) pm.focus();
});
},
};
if (wy[cmd]) wy[cmd]();
var pm = editorEl.querySelector('.ProseMirror'); if (pm) pm.focus();
});
editorEl.addEventListener('click', function (evt) {
if (mode !== 'wysiwyg') return;
var a = evt.target.closest('a'); if (!a || !editorEl.contains(a)) return;
evt.preventDefault(); evt.stopPropagation();
var pm = editorEl.querySelector('.ProseMirror');
var href = a.getAttribute('href') || '', text = a.textContent || '';
function selectAnchor() {
if (!pm || !document.body.contains(a)) return false;
pm.focus();
var range = document.createRange(); range.selectNodeContents(a);
var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
return true;
}
selectAnchor();
window.LinkDialog.open({ url: href, text: text, canRemove: true, urlOnly: true }).then(function (r) {
if (!r) return;
if (!selectAnchor() || !mkEditor) return;
var c = kit.commands;
function exec(key, payload) { mkEditor.action(function (ctx) { ctx.get(kit.commandsCtx).call(key, payload); }); }
if (r.action === 'remove') { exec(c.link.key); }
else if (r.action === 'confirm') { exec(c.link.key); exec(c.link.key, { href: r.url }); }
if (pm) pm.focus();
});
});
function switchMkMode() {
if (mode === 'wysiwyg') {
mdTa.value = getContent();
editorEl.style.display = 'none';
mdTa.style.display = '';
mdTa.focus();
mode = 'markdown';
} else {
var md = mdTa.value;
mdTa.style.display = 'none';
editorEl.style.display = '';
if (mkEditor) mkEditor.action(kit.replaceAll(md));
mode = 'wysiwyg';
}
var modeBtn = toolbar.querySelector('.mk-mode');
if (modeBtn) modeBtn.innerHTML = mode === 'wysiwyg'
? '<i class="bi bi-markdown"></i> MD'
: '<i class="bi bi-eye"></i> WYSIWYG';
}
toolbar.querySelector('.mk-mode').addEventListener('click', switchMkMode);
// Track dirty changes
editorEl.addEventListener('input', function () {
if (typeof ta.onMkInput === 'function') ta.onMkInput();
}, true);
mdTa.addEventListener('input', function () {
if (typeof ta.onMkInput === 'function') ta.onMkInput();
});
if (form) {
form.addEventListener('submit', function (e) {
var md = getContent().trim();
if (ta.dataset.mkRequired && md === '') {
e.preventDefault();
var pm = editorEl.querySelector('.ProseMirror');
if (pm) pm.focus(); else mdTa.focus();
return;
}
ta.value = md;
});
}
ta.mkMount = {
getContent: getContent,
setContent: setContent,
getMode: function () { return mode; },
setMode: function (target) {
// target: 'wysiwyg' | 'markdown'
if (target !== 'wysiwyg' && target !== 'markdown') return;
if (mode !== target) switchMkMode();
},
};
ta.dispatchEvent(new CustomEvent('mk-mounted'));
}
if (window.MilkdownKit) mountAll();
else window.addEventListener('milkdown-ready', mountAll);
// Re-scan whenever new mk-mount textareas get inserted (e.g. opening a new tab)
var moObserver = new MutationObserver(mountAll);
moObserver.observe(document.body, { childList: true, subtree: true });
})();