feat(ui): prompt autocomplete for shortcut triggers (+ Editor toggle)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
2c3c6ed89a
commit
6511e69d7d
@@ -0,0 +1,49 @@
|
||||
// Prompt autocomplete: while typing in a prompt box, suggest the shortcut triggers that fit the word under the caret.
|
||||
// Attaches to the prompt boxes by what they are called, when they are first focused; no other feature knows about it.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { accept, suggestionsAt } from "./match.js";
|
||||
|
||||
const BOXES = new Set(["Story", "Prompt"]); // aria-labels of the prompt fields
|
||||
|
||||
export default {
|
||||
id: "autocomplete",
|
||||
mount: "menubar.menus",
|
||||
needs: ["project", "api"],
|
||||
setup({ app }) {
|
||||
const p = app.project;
|
||||
let library = [], fetched = 0, span = null;
|
||||
const refresh = () => { if (Date.now() - fetched > 10000) { fetched = Date.now(); app.api.shortcuts().then((r) => { library = r.shortcuts || []; }).catch(() => {}); } };
|
||||
const live = new WeakMap();
|
||||
|
||||
function attach(box) {
|
||||
if (live.has(box)) return;
|
||||
const source = () => {
|
||||
span = null;
|
||||
if (!p.pref("autocomplete", true) || box.selectionStart !== box.selectionEnd) return [];
|
||||
const found = suggestionsAt(library, box.value, box.selectionStart);
|
||||
if (!found) return [];
|
||||
span = found.span;
|
||||
return found.items.map((e) => ({ label: e.trigger, hint: (e.sc.replacements || [])[0] || [e.sc.category, e.sc.sub_category].filter(Boolean).join(" · "), trigger: e.trigger }));
|
||||
};
|
||||
const onPick = (item) => {
|
||||
if (!span) return;
|
||||
const done = accept(box.value, span, item.trigger);
|
||||
box.value = done.text; box.setSelectionRange(done.caret, done.caret);
|
||||
box.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
box.focus();
|
||||
};
|
||||
live.set(box, c.autocomplete.default({ input: box, source, onPick }));
|
||||
}
|
||||
|
||||
const onFocus = (e) => {
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { refresh(); attach(t); }
|
||||
};
|
||||
document.addEventListener("focusin", onFocus);
|
||||
|
||||
app.editorSettings.push(() => c.region.stack({ gap: "sm", children: [
|
||||
c.toggle.default({ label: "Prompt autocomplete", hint: "Suggest matching shortcuts while you type in the Story and scene prompts. Shows the trigger and its prompt.",
|
||||
checked: p.pref("autocomplete", true), onChange: (v) => p.setPref("autocomplete", v) })] }));
|
||||
return () => document.removeEventListener("focusin", onFocus);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
// Which shortcut triggers fit the word under the caret. Pure.
|
||||
const DELIMS = /[,\n;()[\]]/; // a trigger lives between delimiters; brackets wrap one: (trigger:1.5), [trigger@1-3]
|
||||
const MIN_QUERY = 2;
|
||||
|
||||
/** Triggers containing `query` (prefix matches first), one row per distinct trigger. `library`: the shortcuts. */
|
||||
export function matchTriggers(library, query) {
|
||||
const q = query.toLowerCase(), seen = new Set(), scored = [];
|
||||
for (const sc of library) {
|
||||
if (sc.enabled === false) continue;
|
||||
for (const raw of sc.triggers || []) {
|
||||
const trigger = String(raw || "").trim(), at = trigger.toLowerCase().indexOf(q);
|
||||
if (trigger && at >= 0) scored.push({ trigger, sc, rank: at === 0 ? 0 : 1, at });
|
||||
}
|
||||
}
|
||||
scored.sort((a, b) => a.rank - b.rank || a.at - b.at || a.trigger.length - b.trigger.length);
|
||||
return scored.filter((e) => !seen.has(e.trigger.toLowerCase()) && seen.add(e.trigger.toLowerCase()));
|
||||
}
|
||||
|
||||
/** What to offer under the caret: the longest trailing run of words in the current token that matches any trigger.
|
||||
* -> { span: {start, end}, items } or null. */
|
||||
export function suggestionsAt(library, text, caret) {
|
||||
const head = text.slice(0, caret);
|
||||
let from = caret;
|
||||
while (from > 0 && !DELIMS.test(head[from - 1])) from -= 1;
|
||||
const starts = [];
|
||||
for (let i = from, space = true; i < caret; i += 1) { const ws = /\s/.test(head[i]); if (!ws && space) starts.push(i); space = ws; }
|
||||
for (const start of starts) {
|
||||
const run = head.slice(start, caret);
|
||||
if (run.trim().length < MIN_QUERY) continue;
|
||||
const items = matchTriggers(library, run);
|
||||
if (items.length) return { span: { start, end: caret }, items };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `text` with the span replaced by the trigger and a space after it (none when a separator already follows). -> { text, caret } */
|
||||
export function accept(text, span, trigger) {
|
||||
const after = text.slice(span.end), sep = /^(\s|[,;)\]:@])/.test(after) ? "" : " ";
|
||||
return { text: text.slice(0, span.start) + trigger + sep + after, caret: span.start + trigger.length + sep.length };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { accept, matchTriggers, suggestionsAt } from "./match.js";
|
||||
|
||||
const lib = [{ name: "a", triggers: ["golden hour", "goldfish"], replacements: ["warm light"] }, { name: "b", triggers: ["rain"], enabled: false }, { name: "c", triggers: ["old gold"] }];
|
||||
|
||||
test("prefix matches come first, a disabled shortcut never shows", () => {
|
||||
assert.deepEqual(matchTriggers(lib, "gold").map((e) => e.trigger), ["goldfish", "golden hour", "old gold"]);
|
||||
assert.deepEqual(matchTriggers(lib, "rain"), []);
|
||||
});
|
||||
|
||||
test("the longest trailing run of words that matches wins, within the current token", () => {
|
||||
const text = "a man, walking in golden ho";
|
||||
const r = suggestionsAt(lib, text, text.length);
|
||||
assert.equal(text.slice(r.span.start, r.span.end), "golden ho");
|
||||
assert.equal(r.items[0].trigger, "golden hour");
|
||||
assert.equal(suggestionsAt(lib, "x", 1), null); // under two letters: nothing
|
||||
assert.equal(suggestionsAt(lib, "(zz", 3), null);
|
||||
});
|
||||
|
||||
test("accepting replaces only the span and leaves the caret ready for the next word", () => {
|
||||
const out = accept("a golden ho", { start: 2, end: 11 }, "golden hour");
|
||||
assert.deepEqual(out, { text: "a golden hour ", caret: 14 });
|
||||
assert.equal(accept("a gold, b", { start: 2, end: 6 }, "goldfish").text, "a goldfish, b"); // a separator already follows
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export default [
|
||||
"./features/rating/index.js",
|
||||
"./features/upscale/index.js",
|
||||
"./features/revolver/index.js",
|
||||
"./features/autocomplete/index.js",
|
||||
"./features/status/index.js",
|
||||
"./features/audio/index.js",
|
||||
"./features/montage/index.js",
|
||||
|
||||
Reference in New Issue
Block a user