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:
parent
f06ab5542d
commit
cfe379d4f9
2 changed files with 85 additions and 64 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Reference in a new issue