A trigger->replacement library (core/shortcuts.py, a close port of v4's apply_prompt_shortcuts/resolve_variables), a Settings section to manage it, autocomplete on the real prompt textarea (composer.autocomplete.default, so it outranks the Constructor modal it lives inside), and a stateless /api/prompt/expand that combines anchor + shortcuts + $variables + postfix at the moment a run is queued -- scene.text itself stays exactly what was typed. Project gained anchor/postfix/variables editing UI on its own tab. Along the way: a real layout bug in the Properties column (every composer stack can flex-shrink below its own content, and once the Project tab grew past a couple of rows the column started squeezing its own children's content into each other and, at the outer level, into the sibling Generation section below) -- fixed with flex-shrink:0 at each level that needs it, caught by a Playwright regression test.
143 lines
5.5 KiB
JavaScript
143 lines
5.5 KiB
JavaScript
// Shortcut autocomplete: as the user types in a prompt textarea, suggest
|
|
// matching shortcut triggers from the library (shortcuts.js).
|
|
//
|
|
// Positioning/dismiss/z-index is composer's own `composer.autocomplete.default`
|
|
// (composer/elements/popover.js) -- it is the ONE overlay in this kit built to
|
|
// outrank a modal (the prompt field lives inside the Constructor window), a
|
|
// z-layer rung `popover.anchored` does not claim. What stays custom here is
|
|
// the part that primitive does not offer: its `source(fieldValue)` contract
|
|
// hands a caller the WHOLE field text, and this needs to match a TRAILING
|
|
// WORD-RUN under the caret instead -- so `source` ignores the value it is
|
|
// given and reads the caret position off `ta` itself, and `onPick` replaces
|
|
// only that span rather than trusting the primitive to write the field.
|
|
//
|
|
// attach(textarea) -- safe to call once per field; a second call is a no-op.
|
|
|
|
import { composer } from "../composer/composer.js";
|
|
import { fetchAll } from "./shortcuts.js";
|
|
|
|
const DELIMS = /[,\n;]/;
|
|
const MIN_QUERY = 2;
|
|
|
|
/** Every (trigger, shortcut) pair -- one shortcut with several triggers is
|
|
* searchable by each of them. */
|
|
function triggerIndex(items) {
|
|
const out = [];
|
|
for (const s of items) {
|
|
if (s.enabled === false) continue;
|
|
for (const t of s.triggers || []) {
|
|
const trig = String(t || "").trim();
|
|
if (trig) out.push({ trigger: trig, sc: s });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Triggers containing `query` (substring match), prefix matches ranked
|
|
* first, identical triggers de-duped. */
|
|
function matchTriggers(items, query) {
|
|
const q = query.toLowerCase();
|
|
const scored = [];
|
|
for (const e of triggerIndex(items)) {
|
|
const tl = e.trigger.toLowerCase();
|
|
const at = tl.indexOf(q);
|
|
if (at < 0) continue;
|
|
scored.push({ ...e, rank: at === 0 ? 0 : 1, pos: at });
|
|
}
|
|
scored.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.trigger.length - b.trigger.length);
|
|
const seen = new Set();
|
|
const uniq = [];
|
|
for (const e of scored) {
|
|
const k = e.trigger.toLowerCase();
|
|
if (seen.has(k)) continue;
|
|
seen.add(k);
|
|
uniq.push(e);
|
|
}
|
|
return uniq;
|
|
}
|
|
|
|
/** What to suggest under the caret: the longest trailing word-run of the
|
|
* current token that matches any trigger, so a suggestion surfaces mid-prose
|
|
* without needing to retype from the last comma. */
|
|
function suggestionsFor(ta, items) {
|
|
const caret = ta.selectionStart;
|
|
if (caret == null || caret !== ta.selectionEnd) return null;
|
|
const text = ta.value.slice(0, caret);
|
|
let dStart = caret;
|
|
while (dStart > 0 && !DELIMS.test(text[dStart - 1])) dStart--;
|
|
const starts = [];
|
|
let prevWs = true;
|
|
for (let i = dStart; i < caret; i++) {
|
|
const ws = /\s/.test(text[i]);
|
|
if (!ws && prevWs) starts.push(i);
|
|
prevWs = ws;
|
|
}
|
|
for (const start of starts) {
|
|
const run = text.slice(start, caret);
|
|
if (run.trim().length < MIN_QUERY) continue;
|
|
const found = matchTriggers(items, run);
|
|
if (found.length) return { span: { start, end: caret }, items: found };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function attach(ta) {
|
|
if (!ta || ta._acAttached) return;
|
|
ta._acAttached = true;
|
|
|
|
// The span the CURRENT suggestion list matches, set by source() and read
|
|
// by onPick() -- source's own return value carries the item labels, but
|
|
// composer's autocomplete primitive has no notion of "which slice of the
|
|
// field this item replaces", so that travels beside it instead.
|
|
let span = null;
|
|
|
|
// composer's autocomplete calls `source(fieldValue)` and uses its return
|
|
// value directly (not awaited), so it has to be synchronous -- the library
|
|
// itself, fetched over HTTP, cannot be. Kept warm here instead: fetched
|
|
// once up front and refetched (fire-and-forget) on every focus, so typing
|
|
// itself never waits on a network round-trip.
|
|
let library = [];
|
|
const warm = () => { fetchAll().then((l) => { library = l; }); };
|
|
warm();
|
|
ta.addEventListener("focus", warm);
|
|
|
|
const overlay = composer.autocomplete.default({
|
|
input: ta,
|
|
// minChars gates on the WHOLE field's length in the primitive's own
|
|
// contract; that is the wrong length to gate on (a long prompt with a
|
|
// two-letter trailing word must still suggest), so it is left at 0 and
|
|
// source() below does its own gating on the trailing word instead.
|
|
minChars: 0,
|
|
source: () => {
|
|
if (!library.length) { span = null; return []; }
|
|
const res = suggestionsFor(ta, library);
|
|
if (!res) { span = null; return []; }
|
|
span = res.span;
|
|
return res.items.map((e) => ({
|
|
label: e.trigger,
|
|
hint: [(e.sc.replacements || [])[0], [e.sc.category, e.sc.sub_category].filter(Boolean).join(" · ")]
|
|
.filter(Boolean).join(" · "),
|
|
trigger: e.trigger,
|
|
}));
|
|
},
|
|
onPick: (item) => {
|
|
if (!span) return;
|
|
const v = ta.value;
|
|
const after = v.slice(span.end);
|
|
// Finish with a trailing space so the caret is ready for the next
|
|
// trigger and the menu does not re-open on the just-completed one --
|
|
// except when a space/delimiter already follows, end-of-text included.
|
|
const sep = /^(\s|[,;])/.test(after) ? "" : " ";
|
|
ta.value = v.slice(0, span.start) + item.trigger + sep + after;
|
|
const caret = span.start + item.trigger.length + sep.length;
|
|
ta.setSelectionRange(caret, caret);
|
|
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
ta.focus();
|
|
},
|
|
});
|
|
|
|
// composer's own popover z-layer already handles outside-click and Escape
|
|
// dismissal; nothing here needs to duplicate that.
|
|
ta.addEventListener("blur", () => setTimeout(() => overlay.close(), 120));
|
|
}
|