Files
olivv-cs-ComfyUI-FunPack/app/shell/autocomplete.js
T
digital-garbage 2e384c48b2 core, app: shortcuts, autocomplete, and prompt-craft expansion
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.
2026-09-08 11:49:49 +03:00

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));
}