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 ────────────────────────────────────────────────── */
.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;
display: block;
overflow: hidden;
}
.md-fm__textarea {
width: 100%;
/* Recognised-keys caption under the header (tooltip carries the full list). */
.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%;
box-sizing: border-box;
margin: 0;
padding: 0.4rem 0.6rem;
border: 0;
background: transparent;
color: var(--text);
}
.md-fm__editor .CodeMirror {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.8rem;
line-height: 1.45;
resize: none;
outline: none;
white-space: pre;
overflow: auto;
tab-size: 2;
background: transparent;
color: var(--text);
}
.md-fm__textarea::placeholder {
color: var(--text-muted);
font-style: italic;
}
.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;
.md-fm__editor .CodeMirror-gutters {
background: var(--bg-secondary);
border-right: 1px solid var(--border);
}
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced

View file

@ -83,16 +83,22 @@
var fmPlaceholder = null;
var fmPlaceholderPromise = null;
// applyFrontMatterPlaceholder sets the textarea placeholder to the server's
// recognised-field hint, in server mode only. Async + best-effort: a failed
// fetch leaves the pane blank (no placeholder), never an error.
function applyFrontMatterPlaceholder(textarea) {
// applyFrontMatterHint populates a greyed caption (+ tooltip) with the
// server's recognised front-matter fields, in server mode only. Async +
// best-effort: a failed fetch leaves the caption hidden, never an error.
// (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;
if (!st || st.source !== 'server') return;
if (fmPlaceholder !== null) {
textarea.placeholder = fmPlaceholder;
return;
function paint() {
if (!el.isConnected) return; // user switched files before resolve
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) {
fmPlaceholderPromise = fetch('/.api/frontmatter', {
headers: { 'Accept': 'application/json' },
@ -101,11 +107,7 @@
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
.catch(function () { fmPlaceholder = ''; });
}
fmPlaceholderPromise.then(function () {
// 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;
});
fmPlaceholderPromise.then(paint);
}
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
@ -427,22 +429,19 @@
fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body';
var fmTextarea = document.createElement('textarea');
fmTextarea.className = 'md-fm__textarea';
fmTextarea.spellcheck = false;
fmTextarea.autocapitalize = 'off';
fmTextarea.autocomplete = 'off';
// Placeholder: in server mode, hint the recognised front-matter keys
// (doctype, numbering, …) as greyed text so authors can discover them.
// It's placeholder-only — inserts nothing, vanishes on the first
// keystroke — so arbitrary keys stay free and a file with no front
// matter still renders as a genuinely empty pane. The text is fetched
// from the server (/.api/frontmatter), the single source of truth, so
// it never drifts from what the converter honours. file:// mode shows
// no placeholder (conversion is server-only).
fmTextarea.placeholder = '';
applyFrontMatterPlaceholder(fmTextarea);
fmBody.appendChild(fmTextarea);
// CodeMirror YAML editor host — mounted with the front-matter value
// once it's computed (sync-on-open) below. Same editor family as the
// .zddc previewer: syntax highlighting, line numbers, lint gutter.
var fmEditorHost = document.createElement('div');
fmEditorHost.className = 'md-fm__editor';
fmBody.appendChild(fmEditorHost);
// Recognised-keys hint (server mode): a greyed caption under the header
// whose tooltip carries the full "key: # hint" template from
// /.api/frontmatter. Replaces the old textarea placeholder.
var fmHint = document.createElement('div');
fmHint.className = 'md-fm__hint';
fmHint.style.display = 'none';
applyFrontMatterHint(fmHint);
// Rename cue: shown when the author edits an identity field
// (tracking_number / revision / status / title) away from the
// 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;'
+ 'display:none;';
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmHint);
fmSection.appendChild(fmWarn);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
@ -615,9 +615,32 @@
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 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 —
// arrow-key nav can continue through markdown files without
// diverting into the editor. The user clicks into the editor
@ -666,7 +689,7 @@
node: node,
hash: initialHash,
tocEl: tocBody,
fmEl: fmTextarea,
fmEl: fmCM,
ac: ac,
// Server version token captured at load — sent as If-Match on
// save and refreshed from each successful PUT's response ETag.
@ -678,7 +701,7 @@
if (!writableMode) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true;
// fmCM was created with readOnly:!writableMode — nothing more here.
}
renderToc(tocBody, bodyText, editor);
@ -788,7 +811,7 @@
var onChange = debounce(async function () {
if (currentInstance !== instance) return;
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
renderToc(tocBody, body, editor);
@ -826,7 +849,7 @@
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
var fid = filenameIdentity(node.name);
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 = [];
IDENTITY_FIELDS.forEach(function (f) {
if (!(f.fm in data)) return;
@ -866,13 +889,13 @@
async function revertIdentityEdits() {
var fid = filenameIdentity(node.name);
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) {
if (Object.prototype.hasOwnProperty.call(fid, k)) data[k] = fid[k];
}
fmTextarea.value = stringifyFrontMatter(data);
fmCM.setValue(stringifyFrontMatter(data));
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
renderIdentityCue();
@ -931,18 +954,18 @@
var onFmChange = debounce(async function () {
if (currentInstance !== instance) return;
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
renderIdentityCue();
}, 250);
fmTextarea.addEventListener('input', onFmChange);
fmCM.on('change', onFmChange);
renderIdentityCue(); // initial state on load (clean after sync-on-open)
// 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
// change is otherwise silent (the values just match the filename now).
if (writableMode && fmTextarea.value !== onDiskFM) {
if (writableMode && fmCM.getValue() !== onDiskFM) {
markDirty(true);
statusEl.textContent = 'Front matter synced to filename — review and save';
}
@ -1009,7 +1032,7 @@
async function save() {
if (currentInstance !== instance) return;
if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
try {
statusEl.textContent = 'Saving…';
var res = await saveContent(node, content, {