diff --git a/browse/build.sh b/browse/build.sh index 2a99797..9262b36 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -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" \ diff --git a/browse/css/tree.css b/browse/css/tree.css index a181753..4767ddf 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -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. */ diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index c4f135e..e03b423 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -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(); }); diff --git a/shared/vendor/codemirror-show-hint.min.css b/shared/vendor/codemirror-show-hint.min.css new file mode 100644 index 0000000..b5e651c --- /dev/null +++ b/shared/vendor/codemirror-show-hint.min.css @@ -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} \ No newline at end of file diff --git a/shared/vendor/codemirror-show-hint.min.js b/shared/vendor/codemirror-show-hint.min.js new file mode 100644 index 0000000..9a9cc99 --- /dev/null +++ b/shared/vendor/codemirror-show-hint.min.js @@ -0,0 +1 @@ +!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(T){"use strict";var F="CodeMirror-hint-active";function n(t,e){var i;this.cm=t,this.options=e,this.widget=null,this.debounce=0,this.tick=0,this.startPos=this.cm.getCursor("start"),this.startLen=this.cm.getLine(this.startPos.line).length-this.cm.getSelection().length,this.options.updateOnCursorActivity&&t.on("cursorActivity",(i=this).activityFunc=function(){i.cursorActivity()})}T.showHint=function(t,e,i){if(!e)return t.showHint(i);i&&i.async&&(e.async=!0);var n={hint:e};if(i)for(var o in i)n[o]=i[o];return t.showHint(n)},T.defineExtension("showHint",function(t){t=function(t,e,i){var n=t.options.hintOptions,o={};for(s in c)o[s]=c[s];if(n)for(var s in n)void 0!==n[s]&&(o[s]=n[s]);if(i)for(var s in i)void 0!==i[s]&&(o[s]=i[s]);o.hint.resolve&&(o.hint=o.hint.resolve(t,e));return o}(this,this.getCursor("start"),t);var e=this.listSelections();if(!(1l.clientHeight+1;setTimeout(function(){f=s.getScrollInfo()});0b&&(l.style.width=b-5+"px",k-=H.right-H.left-b),l.style.left=(m=Math.max(p.left-k-y,0))+"px"),e)for(var x=l.firstChild;x;x=x.nextSibling)x.style.paddingRight=s.display.nativeBarWidth+"px";s.addKeyMap(this.keyMap=function(t,n){var o={Up:function(){n.moveFocus(-1)},Down:function(){n.moveFocus(1)},PageUp:function(){n.moveFocus(1-n.menuSize(),!0)},PageDown:function(){n.moveFocus(n.menuSize()-1,!0)},Home:function(){n.setFocus(0)},End:function(){n.setFocus(n.length-1)},Enter:n.pick,Tab:n.pick,Esc:n.close},e=(/Mac/.test(navigator.platform)&&(o["Ctrl-P"]=function(){n.moveFocus(-1)},o["Ctrl-N"]=function(){n.moveFocus(1)}),t.options.customKeys),s=e?{}:o;function i(t,e){var i="string"!=typeof e?function(t){return e(t,n)}:o.hasOwnProperty(e)?o[e]:e;s[t]=i}if(e)for(var c in e)e.hasOwnProperty(c)&&i(c,e[c]);var r=t.options.extraKeys;if(r)for(var c in r)r.hasOwnProperty(c)&&i(c,r[c]);return s}(o,{moveFocus:function(t,e){i.changeActive(i.selectedHint+t,e)},setFocus:function(t){i.changeActive(t)},menuSize:function(){return i.screenAmount()},length:n.length,close:function(){o.close()},pick:function(){i.pick()},data:t})),o.options.closeOnUnfocus&&(s.on("blur",this.onBlur=function(){C=setTimeout(function(){o.close()},100)}),s.on("focus",this.onFocus=function(){clearTimeout(C)})),s.on("scroll",this.onScroll=function(){var t=s.getScrollInfo(),e=s.getWrapperElement().getBoundingClientRect(),i=(f=f||s.getScrollInfo(),g+f.top-t.top),n=i-(r.pageYOffset||(c.documentElement||c.body).scrollTop);if(v||(n+=l.offsetHeight),n<=e.top||n>=e.bottom)return o.close();l.style.top=i+"px",l.style.left=m+f.left-t.left+"px"}),T.on(l,"dblclick",function(t){t=O(l,t.target||t.srcElement);t&&null!=t.hintId&&(i.changeActive(t.hintId),i.pick())}),T.on(l,"click",function(t){t=O(l,t.target||t.srcElement);t&&null!=t.hintId&&(i.changeActive(t.hintId),o.options.completeOnSingleClick&&i.pick())}),T.on(l,"mousedown",function(){setTimeout(function(){s.focus()},20)});var S=this.getSelectedHintRange();return 0===S.from&&0===S.to||this.scrollToActive(),T.signal(t,"select",n[this.selectedHint],l.childNodes[this.selectedHint]),!0}function r(t,e,i,n){t.async?t(e,n,i):(t=t(e,i))&&t.then?t.then(n):n(t)}n.prototype={close:function(){this.active()&&(this.cm.state.completionActive=null,this.tick=null,this.options.updateOnCursorActivity&&this.cm.off("cursorActivity",this.activityFunc),this.widget&&this.data&&T.signal(this.data,"close"),this.widget&&this.widget.close(),T.signal(this.cm,"endCompletion",this.cm))},active:function(){return this.cm.state.completionActive==this},pick:function(t,e){var i=t.list[e],n=this;this.cm.operation(function(){i.hint?i.hint(n.cm,t,i):n.cm.replaceRange(M(i),i.from||t.from,i.to||t.to,"complete"),T.signal(t,"pick",i),n.cm.scrollIntoView()}),this.options.closeOnPick&&this.close()},cursorActivity:function(){this.debounce&&(s(this.debounce),this.debounce=0);var t,e=this.startPos,i=(this.data&&(e=this.data.from),this.cm.getCursor()),n=this.cm.getLine(i.line);i.line!=this.startPos.line||n.length-i.ch!=this.startLen-this.startPos.ch||i.ch=this.data.list.length?t=e?this.data.list.length-1:0:t<0&&(t=e?0:this.data.list.length-1),this.selectedHint!=t&&((e=this.hints.childNodes[this.selectedHint])&&(e.className=e.className.replace(" "+F,""),e.removeAttribute("aria-selected")),(e=this.hints.childNodes[this.selectedHint=t]).className+=" "+F,e.setAttribute("aria-selected","true"),this.completion.cm.getInputField().setAttribute("aria-activedescendant",e.id),this.scrollToActive(),T.signal(this.data,"select",this.data.list[this.selectedHint],e))},scrollToActive:function(){var t=this.getSelectedHintRange(),e=this.hints.childNodes[t.from],t=this.hints.childNodes[t.to],i=this.hints.firstChild;e.offsetTopthis.hints.scrollTop+this.hints.clientHeight&&(this.hints.scrollTop=t.offsetTop+t.offsetHeight-this.hints.clientHeight+i.offsetTop)},screenAmount:function(){return Math.floor(this.hints.clientHeight/this.hints.firstChild.offsetHeight)||1},getSelectedHintRange:function(){var t=this.completion.options.scrollMargin||0;return{from:Math.max(0,this.selectedHint-t),to:Math.min(this.data.list.length-1,this.selectedHint+t)}}},T.registerHelper("hint","auto",{resolve:function(t,e){var i,c=t.getHelpers(e,"hint");return c.length?((e=function(t,n,o){var s=function(t,e){if(!t.somethingSelected())return e;for(var i=[],n=0;n,]/,closeOnPick:!0,closeOnUnfocus:!0,updateOnCursorActivity:!0,completeOnSingleClick:!0,container:null,customKeys:null,extraKeys:null,paddingForScrollbar:!0,moveOnOverlap:!0};T.defineOption("hintOptions",null)}); \ No newline at end of file diff --git a/zddc/internal/convert/convert.go b/zddc/internal/convert/convert.go index 5363686..3aada75 100644 --- a/zddc/internal/convert/convert.go +++ b/zddc/internal/convert/convert.go @@ -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}, } }