feat(ui): page editor — single rendered pane with Edit/Preview toggle

Replaces the always-on split Edit|Preview (page shown twice) with a rendered
preview by default + an Edit toggle that swaps the pane to a textarea in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 22:53:56 +10:00
parent 2ec6dd336d
commit f5c7b24d81

View File

@@ -1,8 +1,8 @@
// Split-pane Markdown editor. // Single-pane Markdown view with an Edit/Preview toggle.
// - Left: textarea (raw markdown) // - Default: rendered HTML (marked → DOMPurify).
// - Right: rendered HTML preview via marked → DOMPurify // - Click "Edit" to replace the pane in place with a raw-markdown textarea + Save.
// Save button calls a caller-supplied save(value) that returns a promise // - Click "Preview" to re-render and go back to read mode.
// resolving to the updated row. We surface the last edited_at timestamp. // save(value) is caller-supplied and returns a promise resolving to the updated row.
import { marked } from '../vendor/marked.esm.js'; import { marked } from '../vendor/marked.esm.js';
import DOMPurify from '../vendor/purify.es.mjs'; import DOMPurify from '../vendor/purify.es.mjs';
@@ -11,6 +11,8 @@ import { el } from '../dom.js';
marked.setOptions({ gfm: true, breaks: true }); marked.setOptions({ gfm: true, breaks: true });
export function markdownEditor({ initial = '', save }) { export function markdownEditor({ initial = '', save }) {
let editing = false;
const ta = el('textarea', { const ta = el('textarea', {
style: { style: {
width: '100%', minHeight: '420px', resize: 'vertical', width: '100%', minHeight: '420px', resize: 'vertical',
@@ -21,36 +23,54 @@ export function markdownEditor({ initial = '', save }) {
const preview = el('div', { class: 'md-preview' }); const preview = el('div', { class: 'md-preview' });
function rerender() { function rerender() {
const html = DOMPurify.sanitize(marked.parse(ta.value)); preview.innerHTML = DOMPurify.sanitize(marked.parse(ta.value)); // sanitized
preview.innerHTML = html; // sanitized — see dom.js note about html: opt-in
} }
ta.addEventListener('input', rerender);
rerender(); rerender();
// The pane shows EITHER the preview (read) or the textarea (edit).
const pane = el('div', {}, preview);
const stamp = el('span', { class: 'muted', style: { fontSize: '11px' } }, ''); const stamp = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
const btn = el('button', {
const saveBtn = el('button', {
class: 'primary', class: 'primary',
style: { display: 'none' },
onclick: async () => { onclick: async () => {
btn.disabled = true; saveBtn.disabled = true;
try { try {
const updated = await save(ta.value); const updated = await save(ta.value);
if (updated?.updated_at) stamp.textContent = 'Saved ' + new Date(updated.updated_at).toLocaleString(); stamp.textContent = updated?.updated_at
else stamp.textContent = 'Saved'; ? 'Saved ' + new Date(updated.updated_at).toLocaleString()
: 'Saved';
} catch (e) { } catch (e) {
stamp.textContent = 'Save failed: ' + e.message; stamp.textContent = 'Save failed: ' + e.message;
} finally { } finally {
btn.disabled = false; saveBtn.disabled = false;
} }
} }
}, 'Save'); }, 'Save');
const toggle = el('button', {
onclick: () => {
editing = !editing;
if (editing) {
pane.replaceChild(ta, preview);
ta.focus();
toggle.textContent = 'Preview';
saveBtn.style.display = '';
} else {
rerender();
pane.replaceChild(preview, ta);
toggle.textContent = 'Edit';
saveBtn.style.display = 'none';
}
}
}, 'Edit');
return el('div', {}, return el('div', {},
el('div', { class: 'row' }, el('div', { style: { display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '8px' } },
el('div', { class: 'card' }, el('h3', {}, 'Edit'), ta), toggle, saveBtn, stamp
el('div', { class: 'card' }, el('h3', {}, 'Preview'), preview)
), ),
el('div', { style: { display: 'flex', gap: '12px', alignItems: 'center', marginTop: '8px' } }, el('div', { class: 'card' }, pane)
btn, stamp
)
); );
} }