Files
olivv-cs-ComfyUI-FunPack/app/legacy/autocomplete.js
T
digital-garbageandClaude Sonnet 5 29ac44ffcd feat: v4 UI port Phase A start -- module categories, legacy frontend served live
Two pieces of the "port v4's exact UI onto v5's backend" plan:

- core/contract.py: new optional ModuleSpec.category field (closed set:
  continuity, guidance, conditioning, sampling, post, system), validated in
  core/schema.py, threaded through core/registry.py's FIELDS and
  to_manifest(). Presentation grouping, independent of STAGE (execution
  order) -- a module can be stage="sampling" and category="guidance" at
  once. Only the 4 existing modules that actually render a settings panel
  (alg, sharpen, appearance, full_control) got a category assigned; the
  other 12 have none to categorize (no SETTINGS block) and stay
  uncategorised, which is valid.

- v4's frontend (movie_editor/frontend/*, ~29k lines, 61 files) copied
  verbatim into app/legacy/. core/routes.py's "/" route now serves
  legacy/index.html instead of the composer entry point; a <base> tag in
  that file resolves its own relative script/link tags against
  /funpack/app/legacy/ regardless of what URL it's served at, so every
  file moved as a unit with zero per-file path edits. Verified live: all
  61 assets load 200 OK, the real v4 onboarding screen renders pixel-for-
  pixel (it's the actual DOM/CSS, not a reimplementation), and several v4
  API calls (/api/media, /api/projects, /api/git/status) already succeed
  against v5's real backend by coincidence of shared route naming -- the
  rest 404/405 as expected, since api.js's ~90 functions haven't been
  rewired to v5's actual routes yet (next piece of this plan).

The old composer entry point (app/index.html, app/composer/**) stays in
the tree, unreferenced, until a later cleanup pass once nothing imports it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 23:58:51 +03:00

195 lines
8.0 KiB
JavaScript

// Shortcut autocomplete: as the user types in a prompt field, suggest matching
// shortcut triggers. Toggled by the "Prompt autocomplete" editor setting.
// Reusable: ShortcutAutocomplete.attach(textarea) — safe to call once per field.
(function () {
const { el } = window.dom;
const S = window.Store;
const DELIMS = /[,\n;]/; // token boundaries — a trigger lives between delimiters
const MIN_QUERY = 2;
function enabled() { return !!S.getEditorSetting("autocomplete"); }
// Every (trigger, shortcut) pair — a shortcut with multiple triggers is searchable by each.
function triggerIndex() {
const out = [];
(S.get().shortcuts || []).forEach((s) => {
if (s.enabled === false) return;
(s.triggers || []).forEach((t) => {
const trig = String(t || "").trim();
if (trig) out.push({ trigger: trig, sc: s });
});
});
return out;
}
// Triggers whose text contains `query` (substring, so "@golden" matches "golden"),
// prefix matches ranked first, identical triggers de-duped. Unbounded — the menu itself
// scrolls (max-height in CSS), so a prefix shared by many shortcuts (e.g. 15 "lazy_*"
// triggers) shows every one of them instead of silently hiding the tail.
function matchTriggers(query) {
const q = query.toLowerCase();
const scored = [];
for (const e of triggerIndex()) {
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 query is a TRAILING WORD-RUN of the current
// token (text since the last delimiter): we try the longest run first ("golden ho"),
// then shorter tails ("ho"), and use the longest run that matches any trigger. This
// means suggestions surface mid-prose (you don't have to retype from a comma), while
// the replaced span stays a clean unit so accepting can't duplicate words.
function suggestionsFor(ta) {
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--;
// Word-run start offsets within the token, in order (longest run → shortest).
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(run);
if (found.length) return { span: { start, end: caret }, items: found };
}
return null;
}
function attach(ta) {
if (!ta || ta._acAttached) return;
ta._acAttached = true;
let menu = null;
let items = [];
let active = -1;
let span = null;
function close() {
if (menu) { menu.remove(); menu = null; }
items = []; active = -1; span = null;
}
function accept(i) {
if (!span || !items[i]) return;
const trig = items[i].trigger;
const v = ta.value;
const after = v.slice(span.end);
// Finish the shortcut with a trailing space so the caret is ready for the next one
// AND the menu doesn't re-open on the just-completed trigger (it would match itself).
// This includes end-of-text — skip the space ONLY when a space/delimiter already
// follows. (An earlier `$` here skipped the space at end-of-text, the common case,
// which made the menu stick on the accepted trigger.)
const sep = /^(\s|[,;])/.test(after) ? "" : " ";
ta.value = v.slice(0, span.start) + trig + sep + after;
const caret = span.start + trig.length + sep.length;
ta.setSelectionRange(caret, caret);
close();
ta.dispatchEvent(new Event("input", { bubbles: true }));
ta.focus();
}
function paintActive() {
if (!menu) return;
[...menu.children].forEach((c, i) => c.classList.toggle("active", i === active));
const cur = menu.children[active];
if (cur) cur.scrollIntoView({ block: "nearest" });
}
function open(found, sp) {
close();
// One menu at a time across the whole app — sweep away any orphan left behind by a
// re-rendered/removed textarea (otherwise it strands in the corner with no dismiss).
document.querySelectorAll(".ac-menu").forEach((m) => m.remove());
if (!ta.isConnected) return;
span = sp; items = found; active = 0;
menu = el("div", "ac-menu");
found.forEach((e, i) => {
const row = el("div", "ac-item" + (i === 0 ? " active" : ""));
row.append(el("span", "ac-trigger", e.trigger));
const rep = (e.sc.replacements || [])[0];
if (rep) row.append(el("span", "ac-prompt", rep));
const tag = [e.sc.category, e.sc.sub_category].filter(Boolean).join(" · ");
if (tag) row.append(el("span", "ac-cat", tag));
row.addEventListener("mousedown", (ev) => { ev.preventDefault(); accept(i); });
row.addEventListener("mouseenter", () => { active = i; paintActive(); });
menu.append(row);
});
document.body.append(menu);
position();
}
function position() {
if (!menu) return;
if (!ta.isConnected) { close(); return; }
const r = ta.getBoundingClientRect();
// Flip above the field when there isn't room below (e.g. Easy Gen's prompt box
// sits in a footer bar at the bottom of the viewport) — otherwise the menu opens
// off-screen under the field with no way to see or click any suggestion.
const GAP = 2;
const menuH = menu.offsetHeight || 240;
const spaceBelow = window.innerHeight - r.bottom;
const spaceAbove = r.top;
const openAbove = spaceBelow < menuH + GAP && spaceAbove > spaceBelow;
menu.style.left = r.left + "px";
if (openAbove) {
menu.style.top = "";
menu.style.bottom = (window.innerHeight - r.top + GAP) + "px";
menu.style.maxHeight = Math.max(120, Math.min(240, spaceAbove - GAP * 2)) + "px";
} else {
menu.style.bottom = "";
menu.style.top = (r.bottom + GAP) + "px";
menu.style.maxHeight = Math.max(120, Math.min(240, spaceBelow - GAP * 2)) + "px";
}
menu.style.minWidth = Math.min(r.width, 420) + "px";
menu.style.maxWidth = Math.max(r.width, 320) + "px";
}
function refresh() {
if (!enabled() || !ta.isConnected) { close(); return; }
const res = suggestionsFor(ta);
if (!res) { close(); return; }
open(res.items, res.span);
}
ta.addEventListener("input", refresh);
ta.addEventListener("click", refresh);
ta.addEventListener("keydown", (e) => {
if (!menu) return;
if (e.key === "ArrowDown") { e.preventDefault(); active = (active + 1) % items.length; paintActive(); }
else if (e.key === "ArrowUp") { e.preventDefault(); active = (active - 1 + items.length) % items.length; paintActive(); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); accept(active); }
else if (e.key === "Escape") { e.preventDefault(); close(); }
});
ta.addEventListener("blur", () => setTimeout(close, 120));
window.addEventListener("scroll", () => { if (menu) position(); }, true);
// A panel re-render can remove this textarea while a menu is open; removal doesn't
// fire blur reliably, so watch the DOM and close (+ stop watching) when it detaches.
const detachObs = new MutationObserver(() => {
if (!ta.isConnected) { close(); detachObs.disconnect(); }
});
detachObs.observe(document.body, { childList: true, subtree: true });
}
window.ShortcutAutocomplete = { attach };
})();