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 ────────────────────────────────────────────────── */
|
/* ── 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
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue