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/logo.css" \
|
||||||
"../shared/vendor/toastui-editor.min.css" \
|
"../shared/vendor/toastui-editor.min.css" \
|
||||||
"../shared/vendor/codemirror-yaml.min.css" \
|
"../shared/vendor/codemirror-yaml.min.css" \
|
||||||
|
"../shared/vendor/codemirror-show-hint.min.css" \
|
||||||
"../shared/context-menu.css" \
|
"../shared/context-menu.css" \
|
||||||
"../shared/elevation.css" \
|
"../shared/elevation.css" \
|
||||||
"../shared/profile-menu.css" \
|
"../shared/profile-menu.css" \
|
||||||
|
|
@ -48,6 +49,7 @@ concat_files \
|
||||||
"../shared/vendor/utif.min.js" \
|
"../shared/vendor/utif.min.js" \
|
||||||
"../shared/vendor/js-yaml.min.js" \
|
"../shared/vendor/js-yaml.min.js" \
|
||||||
"../shared/vendor/codemirror-yaml.min.js" \
|
"../shared/vendor/codemirror-yaml.min.js" \
|
||||||
|
"../shared/vendor/codemirror-show-hint.min.js" \
|
||||||
"../shared/vendor/toastui-editor-all.min.js" \
|
"../shared/vendor/toastui-editor-all.min.js" \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/zddc-filter.js" \
|
"../shared/zddc-filter.js" \
|
||||||
|
|
|
||||||
|
|
@ -928,6 +928,24 @@ body {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-right: 1px solid var(--border);
|
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
|
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
|
||||||
by the .md-shell BEM block above. */
|
by the .md-shell BEM block above. */
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@
|
||||||
// empty / unavailable. The promise dedupes concurrent fetches.
|
// empty / unavailable. The promise dedupes concurrent fetches.
|
||||||
var fmPlaceholder = null;
|
var fmPlaceholder = null;
|
||||||
var fmPlaceholderPromise = 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
|
// applyFrontMatterHint populates a greyed caption (+ tooltip) with the
|
||||||
// server's recognised front-matter fields, in server mode only. Async +
|
// server's recognised front-matter fields, in server mode only. Async +
|
||||||
|
|
@ -104,8 +107,11 @@
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
credentials: 'same-origin'
|
credentials: 'same-origin'
|
||||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||||
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
|
.then(function (j) {
|
||||||
.catch(function () { fmPlaceholder = ''; });
|
fmPlaceholder = (j && j.placeholder) || '';
|
||||||
|
fmFields = (j && j.fields) || [];
|
||||||
|
})
|
||||||
|
.catch(function () { fmPlaceholder = ''; fmFields = []; });
|
||||||
}
|
}
|
||||||
fmPlaceholderPromise.then(paint);
|
fmPlaceholderPromise.then(paint);
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +209,73 @@
|
||||||
return out;
|
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) ────────────────────────────────────────────
|
// ── TOC (table of contents) ────────────────────────────────────────────
|
||||||
// ATX headings only; the body markdown drives the outline. Clicking
|
// ATX headings only; the body markdown drives the outline. Clicking
|
||||||
// a heading routes to whichever Toast UI pane is currently active
|
// a heading routes to whichever Toast UI pane is currently active
|
||||||
|
|
@ -639,11 +712,27 @@
|
||||||
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
||||||
lint: { hasGutters: true },
|
lint: { hasGutters: true },
|
||||||
autofocus: false,
|
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
|
// The yaml lint helper (registered by the .zddc previewer) checks this
|
||||||
// to decide the schema layer; a .md node → plain js-yaml parse lint.
|
// to decide the schema layer; a .md node → plain js-yaml parse lint.
|
||||||
fmCM._zddcNode = node;
|
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;
|
// CodeMirror mis-measures when mounted before its pane is laid out;
|
||||||
// refresh on the next frame so the gutters + scroll size correctly.
|
// refresh on the next frame so the gutters + scroll size correctly.
|
||||||
requestAnimationFrame(function () { fmCM.refresh(); });
|
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 {
|
type FrontMatterField struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Hint string `json:"hint"`
|
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
|
// RecognizedFrontMatter is the single source of truth for the front-matter keys
|
||||||
|
|
@ -76,18 +79,20 @@ type FrontMatterField struct {
|
||||||
// user most needs told about.
|
// user most needs told about.
|
||||||
func RecognizedFrontMatter() []FrontMatterField {
|
func RecognizedFrontMatter() []FrontMatterField {
|
||||||
return []FrontMatterField{
|
return []FrontMatterField{
|
||||||
{"doctype", "report | letter | specification"},
|
// doctype enum tracks the template set (internal/convert/templates/
|
||||||
{"numbering", "true to number headings (default false)"},
|
// *.html, sans the _-prefixed partials).
|
||||||
{"title", "mirrors the filename — rename the file to change it"},
|
{"doctype", "report | letter | specification", []string{"report", "letter", "specification"}},
|
||||||
{"tracking_number", "mirrors the filename — rename the file to change it"},
|
{"numbering", "true to number headings (default false)", []string{"true", "false"}},
|
||||||
{"revision", "mirrors the filename — rename the file to change it"},
|
{"title", "mirrors the filename — rename the file to change it", nil},
|
||||||
{"status", "mirrors the filename — rename the file to change it"},
|
{"tracking_number", "mirrors the filename — rename the file to change it", nil},
|
||||||
{"date", "document date (free text)"},
|
{"revision", "mirrors the filename — rename the file to change it", nil},
|
||||||
{"custom_header", "extra line shown in the document header"},
|
{"status", "mirrors the filename — rename the file to change it", nil},
|
||||||
{"client", "overrides the .zddc convert: cascade"},
|
{"date", "document date (free text)", nil},
|
||||||
{"project", "overrides the .zddc convert: cascade"},
|
{"custom_header", "extra line shown in the document header", nil},
|
||||||
{"project_number", "overrides the .zddc convert: cascade"},
|
{"client", "overrides the .zddc convert: cascade", nil},
|
||||||
{"contractor", "overrides the .zddc convert: cascade"},
|
{"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