feat(browse): schema completion in the front-matter editor (keys + enum values)
Vendor CodeMirror's show-hint add-on and wire deterministic, schema-driven
completion into the markdown front-matter pane — NO heuristics, no AI; every
candidate comes from the converter's own field list.
- Vendor codemirror-show-hint.min.{js,css} (CM 5.65.x add-on); concat in
browse/build.sh after the core CM bundle so it extends window.CodeMirror.
- Server: add a structured `values` enum to convert.FrontMatterField (doctype →
report/letter/specification, numbering → true/false), exposed via the existing
/.api/frontmatter JSON. Tracks the template set; keeps the server as the
single source of truth instead of parsing the hint prose.
- Client: frontMatterHints() completes recognised KEYS at line start (excluding
the filename-driven identity keys and keys already present) and enum VALUES
after `key:`. Picking an enum key auto-opens its value list. Triggered on
Ctrl-Space and automatically as you type (completeSingle:false — always a
menu, never an auto-guess). Themed dropdown for dark mode.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
84f93ba56d
commit
2c877bd5b7
6 changed files with 131 additions and 15 deletions
|
|
@ -27,6 +27,7 @@ concat_files \
|
|||
"../shared/logo.css" \
|
||||
"../shared/vendor/toastui-editor.min.css" \
|
||||
"../shared/vendor/codemirror-yaml.min.css" \
|
||||
"../shared/vendor/codemirror-show-hint.min.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
|
|
@ -48,6 +49,7 @@ concat_files \
|
|||
"../shared/vendor/utif.min.js" \
|
||||
"../shared/vendor/js-yaml.min.js" \
|
||||
"../shared/vendor/codemirror-yaml.min.js" \
|
||||
"../shared/vendor/codemirror-show-hint.min.js" \
|
||||
"../shared/vendor/toastui-editor-all.min.js" \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
|
|
|
|||
|
|
@ -928,6 +928,24 @@ body {
|
|||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
/* Schema-completion dropdown (show-hint add-on) — theme it to the app
|
||||
palette so it reads in dark mode; show-hint.css ships light-only. */
|
||||
.CodeMirror-hints {
|
||||
z-index: 9600;
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||
font-size: 0.78rem;
|
||||
background: var(--bg-elevated, var(--bg, #fff));
|
||||
border: 1px solid var(--border, #ccc);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.CodeMirror-hint {
|
||||
color: var(--text, #222);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
li.CodeMirror-hint-active {
|
||||
background: var(--primary, #2868c8);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
|
||||
by the .md-shell BEM block above. */
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@
|
|||
// empty / unavailable. The promise dedupes concurrent fetches.
|
||||
var fmPlaceholder = null;
|
||||
var fmPlaceholderPromise = null;
|
||||
// Recognised fields ([{name, hint, values}]) from the same /.api/frontmatter
|
||||
// fetch — drives schema completion (keys + enum values). null = not loaded.
|
||||
var fmFields = null;
|
||||
|
||||
// applyFrontMatterHint populates a greyed caption (+ tooltip) with the
|
||||
// server's recognised front-matter fields, in server mode only. Async +
|
||||
|
|
@ -104,8 +107,11 @@
|
|||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
|
||||
.catch(function () { fmPlaceholder = ''; });
|
||||
.then(function (j) {
|
||||
fmPlaceholder = (j && j.placeholder) || '';
|
||||
fmFields = (j && j.fields) || [];
|
||||
})
|
||||
.catch(function () { fmPlaceholder = ''; fmFields = []; });
|
||||
}
|
||||
fmPlaceholderPromise.then(paint);
|
||||
}
|
||||
|
|
@ -203,6 +209,73 @@
|
|||
return out;
|
||||
}
|
||||
|
||||
// ── Schema completion for the front-matter editor ──────────────────────
|
||||
// Deterministic, schema-driven — NO heuristics, no AI. Keys + enum values
|
||||
// come straight from /.api/frontmatter (fmFields), the converter's own
|
||||
// field list. Returns a CodeMirror show-hint result ({list, from, to}) or
|
||||
// null. The front matter is flat (top-level keys), so no nesting to walk.
|
||||
function frontMatterHints(cm) {
|
||||
if (!fmFields || !fmFields.length || !window.CodeMirror) return null;
|
||||
var Pos = window.CodeMirror.Pos;
|
||||
var cur = cm.getCursor();
|
||||
var before = cm.getLine(cur.line).slice(0, cur.ch);
|
||||
var colon = before.indexOf(':');
|
||||
|
||||
if (colon === -1) {
|
||||
// KEY context — complete recognised keys, minus the filename-driven
|
||||
// identity keys (we don't want those typed into front matter) and
|
||||
// keys already present in the document.
|
||||
var m = before.match(/^(\s*)([\w-]*)$/);
|
||||
if (!m) return null;
|
||||
var indent = m[1], typed = m[2];
|
||||
var identity = {};
|
||||
IDENTITY_FIELDS.forEach(function (f) { identity[f.fm] = true; });
|
||||
var present = {};
|
||||
for (var ln = 0; ln < cm.lineCount(); ln++) {
|
||||
var km = cm.getLine(ln).match(/^\s*([\w-]+)\s*:/);
|
||||
if (km) present[km[1]] = true;
|
||||
}
|
||||
var list = [];
|
||||
fmFields.forEach(function (f) {
|
||||
if (identity[f.name] || present[f.name]) return;
|
||||
if (typed && f.name.indexOf(typed) !== 0) return;
|
||||
var item = {
|
||||
text: f.name + ': ',
|
||||
displayText: f.name + (f.hint ? ' — ' + f.hint : '')
|
||||
};
|
||||
// If the key is an enum, insert "key: " then immediately offer
|
||||
// its values — one fluid keystroke path for doctype/numbering.
|
||||
if (f.values && f.values.length) {
|
||||
item.hint = function (cmi, data, comp) {
|
||||
cmi.replaceRange(comp.text, data.from, data.to);
|
||||
setTimeout(function () {
|
||||
cmi.showHint({ hint: frontMatterHints, completeSingle: false });
|
||||
}, 0);
|
||||
};
|
||||
}
|
||||
list.push(item);
|
||||
});
|
||||
if (!list.length) return null;
|
||||
return { list: list, from: Pos(cur.line, indent.length), to: cur };
|
||||
}
|
||||
|
||||
// VALUE context — complete the enum values for this line's key.
|
||||
var key = before.slice(0, colon).trim();
|
||||
var field = null;
|
||||
for (var i = 0; i < fmFields.length; i++) {
|
||||
if (fmFields[i].name === key) { field = fmFields[i]; break; }
|
||||
}
|
||||
if (!field || !field.values || !field.values.length) return null;
|
||||
var rest = before.slice(colon + 1);
|
||||
var valTyped = rest.replace(/^\s*/, '');
|
||||
var valStart = colon + 1 + (rest.length - valTyped.length);
|
||||
var vlist = field.values.filter(function (v) {
|
||||
return v.indexOf(valTyped) === 0;
|
||||
}).map(function (v) { return { text: v, displayText: v }; });
|
||||
if (!vlist.length) return null;
|
||||
return { list: vlist, from: Pos(cur.line, valStart), to: cur };
|
||||
}
|
||||
|
||||
// ── TOC (table of contents) ────────────────────────────────────────────
|
||||
// ATX headings only; the body markdown drives the outline. Clicking
|
||||
// a heading routes to whichever Toast UI pane is currently active
|
||||
|
|
@ -639,11 +712,27 @@
|
|||
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
||||
lint: { hasGutters: true },
|
||||
autofocus: false,
|
||||
readOnly: !writableMode
|
||||
readOnly: !writableMode,
|
||||
// Ctrl-Space (and Tab as a convenience) opens schema completion.
|
||||
extraKeys: {
|
||||
'Ctrl-Space': function (cm) {
|
||||
cm.showHint({ hint: frontMatterHints, completeSingle: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
// 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;
|
||||
// Auto-open completion as the author types a key/value character (never
|
||||
// auto-inserts — completeSingle:false — so it's a menu, not a guess).
|
||||
// No-op when frontMatterHints finds nothing to offer.
|
||||
if (writableMode) {
|
||||
fmCM.on('inputRead', function (cm, change) {
|
||||
if (!change.text || change.text.length !== 1) return; // skip paste/delete
|
||||
if (!/[\w-]/.test(change.text[0])) return;
|
||||
cm.showHint({ hint: frontMatterHints, completeSingle: false });
|
||||
});
|
||||
}
|
||||
// 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(); });
|
||||
|
|
|
|||
1
shared/vendor/codemirror-show-hint.min.css
vendored
Normal file
1
shared/vendor/codemirror-show-hint.min.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff}
|
||||
1
shared/vendor/codemirror-show-hint.min.js
vendored
Normal file
1
shared/vendor/codemirror-show-hint.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -65,6 +65,9 @@ type Metadata struct {
|
|||
type FrontMatterField struct {
|
||||
Name string `json:"name"`
|
||||
Hint string `json:"hint"`
|
||||
// Values is the closed set of valid values for this key (an enum), used
|
||||
// by the editor for value completion. Empty/nil = free-text.
|
||||
Values []string `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// RecognizedFrontMatter is the single source of truth for the front-matter keys
|
||||
|
|
@ -76,18 +79,20 @@ type FrontMatterField struct {
|
|||
// user most needs told about.
|
||||
func RecognizedFrontMatter() []FrontMatterField {
|
||||
return []FrontMatterField{
|
||||
{"doctype", "report | letter | specification"},
|
||||
{"numbering", "true to number headings (default false)"},
|
||||
{"title", "mirrors the filename — rename the file to change it"},
|
||||
{"tracking_number", "mirrors the filename — rename the file to change it"},
|
||||
{"revision", "mirrors the filename — rename the file to change it"},
|
||||
{"status", "mirrors the filename — rename the file to change it"},
|
||||
{"date", "document date (free text)"},
|
||||
{"custom_header", "extra line shown in the document header"},
|
||||
{"client", "overrides the .zddc convert: cascade"},
|
||||
{"project", "overrides the .zddc convert: cascade"},
|
||||
{"project_number", "overrides the .zddc convert: cascade"},
|
||||
{"contractor", "overrides the .zddc convert: cascade"},
|
||||
// doctype enum tracks the template set (internal/convert/templates/
|
||||
// *.html, sans the _-prefixed partials).
|
||||
{"doctype", "report | letter | specification", []string{"report", "letter", "specification"}},
|
||||
{"numbering", "true to number headings (default false)", []string{"true", "false"}},
|
||||
{"title", "mirrors the filename — rename the file to change it", nil},
|
||||
{"tracking_number", "mirrors the filename — rename the file to change it", nil},
|
||||
{"revision", "mirrors the filename — rename the file to change it", nil},
|
||||
{"status", "mirrors the filename — rename the file to change it", nil},
|
||||
{"date", "document date (free text)", nil},
|
||||
{"custom_header", "extra line shown in the document header", nil},
|
||||
{"client", "overrides the .zddc convert: cascade", nil},
|
||||
{"project", "overrides the .zddc convert: cascade", nil},
|
||||
{"project_number", "overrides the .zddc convert: cascade", nil},
|
||||
{"contractor", "overrides the .zddc convert: cascade", nil},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue