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:
@@ -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
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user