feat(browse): CodeMirror YAML editor for the markdown front-matter pane

Replace the raw <textarea> front-matter editor with a CodeMirror 5 instance —
the same editor family the .zddc previewer already uses (and already bundled in
browse, so no added weight). Gains: YAML syntax highlighting, line numbers, and
the shared js-yaml lint gutter; one consistent editing feel across the two YAML
surfaces.

- Mount fmCM (mode:yaml, lineNumbers, lint gutter, readOnly when not writable)
  into a host div; refresh on the next frame so it measures correctly.
- Route every front-matter read/write through fmCM.getValue()/setValue() and
  the change event (sync-on-open, dirty tracking, the rename cue, Cancel, save).
- The old textarea placeholder (recognised front-matter keys) becomes a greyed
  caption under the header whose tooltip carries the full key list — CodeMirror
  5 has no built-in placeholder. Arbitrary keys remain free either way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-08 08:43:54 -05:00
parent f06ab5542d
commit cfe379d4f9
2 changed files with 85 additions and 64 deletions

View file

@ -896,39 +896,37 @@ body {
/* ── Front matter editor ────────────────────────────────────────────────── */ /* ── Front matter editor ────────────────────────────────────────────────── */
.md-fm__body { .md-fm__body {
/* Body cell owns the textarea; sized by the sidebar's grid row. */ /* Body cell owns the CodeMirror editor; sized by the sidebar's grid row. */
padding: 0; padding: 0;
display: block; display: block;
overflow: hidden; overflow: hidden;
} }
.md-fm__textarea { /* Recognised-keys caption under the header (tooltip carries the full list). */
width: 100%; .md-fm__hint {
padding: 2px 0.6rem 4px;
font-size: 0.72rem;
color: var(--text-muted);
cursor: help;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* CodeMirror YAML front-matter editor fills the body cell + scrolls
internally, matching the .zddc previewer's editor styling. */
.md-fm__editor,
.md-fm__editor .CodeMirror {
height: 100%; height: 100%;
box-sizing: border-box; }
margin: 0; .md-fm__editor .CodeMirror {
padding: 0.4rem 0.6rem;
border: 0;
background: transparent;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace); font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.45; line-height: 1.45;
resize: none; background: transparent;
outline: none; color: var(--text);
white-space: pre;
overflow: auto;
tab-size: 2;
} }
.md-fm__textarea::placeholder { .md-fm__editor .CodeMirror-gutters {
color: var(--text-muted); background: var(--bg-secondary);
font-style: italic; border-right: 1px solid var(--border);
}
.md-fm__textarea:focus {
background: var(--surface-2, rgba(0, 0, 0, 0.025));
}
.md-fm__textarea[readonly] {
color: var(--text-muted);
cursor: not-allowed;
} }
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced /* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced

View file

@ -83,16 +83,22 @@
var fmPlaceholder = null; var fmPlaceholder = null;
var fmPlaceholderPromise = null; var fmPlaceholderPromise = null;
// applyFrontMatterPlaceholder sets the textarea placeholder to the server's // applyFrontMatterHint populates a greyed caption (+ tooltip) with the
// recognised-field hint, in server mode only. Async + best-effort: a failed // server's recognised front-matter fields, in server mode only. Async +
// fetch leaves the pane blank (no placeholder), never an error. // best-effort: a failed fetch leaves the caption hidden, never an error.
function applyFrontMatterPlaceholder(textarea) { // (Replaces the old textarea placeholder — CodeMirror 5 has no built-in
// placeholder without an unvendored add-on. Arbitrary keys stay free.)
function applyFrontMatterHint(el) {
var st = window.app && window.app.state; var st = window.app && window.app.state;
if (!st || st.source !== 'server') return; if (!st || st.source !== 'server') return;
if (fmPlaceholder !== null) { function paint() {
textarea.placeholder = fmPlaceholder; if (!el.isConnected) return; // user switched files before resolve
return; if (!fmPlaceholder) { el.style.display = 'none'; return; }
el.textContent = 'ⓘ Recognised front-matter keys (hover) — any other key is allowed';
el.title = fmPlaceholder;
el.style.display = '';
} }
if (fmPlaceholder !== null) { paint(); return; }
if (!fmPlaceholderPromise) { if (!fmPlaceholderPromise) {
fmPlaceholderPromise = fetch('/.api/frontmatter', { fmPlaceholderPromise = fetch('/.api/frontmatter', {
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
@ -101,11 +107,7 @@
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; }) .then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
.catch(function () { fmPlaceholder = ''; }); .catch(function () { fmPlaceholder = ''; });
} }
fmPlaceholderPromise.then(function () { fmPlaceholderPromise.then(paint);
// Only apply if this textarea is still in the DOM (user may have
// switched files before the fetch resolved).
if (textarea.isConnected) textarea.placeholder = fmPlaceholder;
});
} }
// Lightweight YAML front-matter parser. Same envelope as mdedit's: // Lightweight YAML front-matter parser. Same envelope as mdedit's:
@ -427,22 +429,19 @@
fmHeader.textContent = 'YAML front matter'; fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div'); var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body'; fmBody.className = 'md-side__body md-fm__body';
var fmTextarea = document.createElement('textarea'); // CodeMirror YAML editor host — mounted with the front-matter value
fmTextarea.className = 'md-fm__textarea'; // once it's computed (sync-on-open) below. Same editor family as the
fmTextarea.spellcheck = false; // .zddc previewer: syntax highlighting, line numbers, lint gutter.
fmTextarea.autocapitalize = 'off'; var fmEditorHost = document.createElement('div');
fmTextarea.autocomplete = 'off'; fmEditorHost.className = 'md-fm__editor';
// Placeholder: in server mode, hint the recognised front-matter keys fmBody.appendChild(fmEditorHost);
// (doctype, numbering, …) as greyed text so authors can discover them. // Recognised-keys hint (server mode): a greyed caption under the header
// It's placeholder-only — inserts nothing, vanishes on the first // whose tooltip carries the full "key: # hint" template from
// keystroke — so arbitrary keys stay free and a file with no front // /.api/frontmatter. Replaces the old textarea placeholder.
// matter still renders as a genuinely empty pane. The text is fetched var fmHint = document.createElement('div');
// from the server (/.api/frontmatter), the single source of truth, so fmHint.className = 'md-fm__hint';
// it never drifts from what the converter honours. file:// mode shows fmHint.style.display = 'none';
// no placeholder (conversion is server-only). applyFrontMatterHint(fmHint);
fmTextarea.placeholder = '';
applyFrontMatterPlaceholder(fmTextarea);
fmBody.appendChild(fmTextarea);
// Rename cue: shown when the author edits an identity field // Rename cue: shown when the author edits an identity field
// (tracking_number / revision / status / title) away from the // (tracking_number / revision / status / title) away from the
// filename. The filename owns identity, so the cue offers an explicit // filename. The filename owns identity, so the cue offers an explicit
@ -459,6 +458,7 @@
+ '0.78rem;line-height:1.5;flex-wrap:wrap;align-items:center;gap:6px;' + '0.78rem;line-height:1.5;flex-wrap:wrap;align-items:center;gap:6px;'
+ 'display:none;'; + 'display:none;';
fmSection.appendChild(fmHeader); fmSection.appendChild(fmHeader);
fmSection.appendChild(fmHint);
fmSection.appendChild(fmWarn); fmSection.appendChild(fmWarn);
fmSection.appendChild(fmBody); fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection); sidebar.appendChild(fmSection);
@ -615,9 +615,32 @@
if (Object.prototype.hasOwnProperty.call(fid, ik)) initialParsed.data[ik] = fid[ik]; if (Object.prototype.hasOwnProperty.call(fid, ik)) initialParsed.data[ik] = fid[ik];
} }
} }
fmTextarea.value = stringifyFrontMatter(initialParsed.data); var syncedFM = stringifyFrontMatter(initialParsed.data);
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText)); var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
var writableMode = canSave(node); var writableMode = canSave(node);
// Front-matter YAML editor — CodeMirror, the same editor family as the
// .zddc previewer (syntax highlighting, line numbers, shared js-yaml
// lint gutter). Replaces the old <textarea>.
var fmCM = window.CodeMirror(fmEditorHost, {
value: syncedFM,
mode: 'yaml',
lineNumbers: true,
tabSize: 2,
indentUnit: 2,
indentWithTabs: false,
lineWrapping: true,
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
lint: { hasGutters: true },
autofocus: false,
readOnly: !writableMode
});
// The yaml lint helper (registered by the .zddc previewer) checks this
// to decide the schema layer; a .md node → plain js-yaml parse lint.
fmCM._zddcNode = node;
// CodeMirror mis-measures when mounted before its pane is laid out;
// refresh on the next frame so the gutters + scroll size correctly.
requestAnimationFrame(function () { fmCM.refresh(); });
// autofocus:false keeps the keyboard caret in the tree pane — // autofocus:false keeps the keyboard caret in the tree pane —
// arrow-key nav can continue through markdown files without // arrow-key nav can continue through markdown files without
// diverting into the editor. The user clicks into the editor // diverting into the editor. The user clicks into the editor
@ -666,7 +689,7 @@
node: node, node: node,
hash: initialHash, hash: initialHash,
tocEl: tocBody, tocEl: tocBody,
fmEl: fmTextarea, fmEl: fmCM,
ac: ac, ac: ac,
// Server version token captured at load — sent as If-Match on // Server version token captured at load — sent as If-Match on
// save and refreshed from each successful PUT's response ETag. // save and refreshed from each successful PUT's response ETag.
@ -678,7 +701,7 @@
if (!writableMode) { if (!writableMode) {
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.'; saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true; // fmCM was created with readOnly:!writableMode — nothing more here.
} }
renderToc(tocBody, bodyText, editor); renderToc(tocBody, bodyText, editor);
@ -788,7 +811,7 @@
var onChange = debounce(async function () { var onChange = debounce(async function () {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
var body = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body)); var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
markDirty(h !== instance.hash); markDirty(h !== instance.hash);
renderToc(tocBody, body, editor); renderToc(tocBody, body, editor);
@ -826,7 +849,7 @@
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild); while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
var fid = filenameIdentity(node.name); var fid = filenameIdentity(node.name);
if (!fid || !canSave(node)) { fmWarn.style.display = 'none'; return; } if (!fid || !canSave(node)) { fmWarn.style.display = 'none'; return; }
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {}; var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
var edits = []; var edits = [];
IDENTITY_FIELDS.forEach(function (f) { IDENTITY_FIELDS.forEach(function (f) {
if (!(f.fm in data)) return; if (!(f.fm in data)) return;
@ -866,13 +889,13 @@
async function revertIdentityEdits() { async function revertIdentityEdits() {
var fid = filenameIdentity(node.name); var fid = filenameIdentity(node.name);
if (!fid) return; if (!fid) return;
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {}; var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
for (var k in fid) { for (var k in fid) {
if (Object.prototype.hasOwnProperty.call(fid, k)) data[k] = fid[k]; if (Object.prototype.hasOwnProperty.call(fid, k)) data[k] = fid[k];
} }
fmTextarea.value = stringifyFrontMatter(data); fmCM.setValue(stringifyFrontMatter(data));
var body = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body)); var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
markDirty(h !== instance.hash); markDirty(h !== instance.hash);
renderIdentityCue(); renderIdentityCue();
@ -931,18 +954,18 @@
var onFmChange = debounce(async function () { var onFmChange = debounce(async function () {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
var body = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body)); var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
markDirty(h !== instance.hash); markDirty(h !== instance.hash);
renderIdentityCue(); renderIdentityCue();
}, 250); }, 250);
fmTextarea.addEventListener('input', onFmChange); fmCM.on('change', onFmChange);
renderIdentityCue(); // initial state on load (clean after sync-on-open) renderIdentityCue(); // initial state on load (clean after sync-on-open)
// If sync-on-open corrected the front matter, open the buffer dirty so // If sync-on-open corrected the front matter, open the buffer dirty so
// a save bakes the filename-derived identity in — and say so, since the // a save bakes the filename-derived identity in — and say so, since the
// change is otherwise silent (the values just match the filename now). // change is otherwise silent (the values just match the filename now).
if (writableMode && fmTextarea.value !== onDiskFM) { if (writableMode && fmCM.getValue() !== onDiskFM) {
markDirty(true); markDirty(true);
statusEl.textContent = 'Front matter synced to filename — review and save'; statusEl.textContent = 'Front matter synced to filename — review and save';
} }
@ -1009,7 +1032,7 @@
async function save() { async function save() {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
if (!instance.dirty || !canSave(node)) return; if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmTextarea.value, editor.getMarkdown()); var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
try { try {
statusEl.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
var res = await saveContent(node, content, { var res = await saveContent(node, content, {