feat(ui): Shortcut ideas bulb beside the focused prompt box (habits from saved projects + library browse, 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
6511e69d7d
commit
5102dc0035
@@ -8,7 +8,6 @@ import { chat } from "./chat.js";
|
||||
import { pickShortcut, templatesBar, variablesPanel } from "./storytools.js";
|
||||
|
||||
const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: "Shortcuts" }, { value: "cuts", label: "Cuts" }, { value: "enhance", label: "Enhance" }, { value: "chat", label: "Chat" }];
|
||||
const inert = (label, tone = "ghost") => c.button.sm({ label, tone, disabled: true });
|
||||
|
||||
// The box follows the scenes; typing in it rewrites them (after a short pause, or when focus leaves).
|
||||
const story = (app, own) => {
|
||||
@@ -41,7 +40,7 @@ const story = (app, own) => {
|
||||
if (!trigger) return;
|
||||
area.focus(); area.setRangeText(`${area.selectionStart && !/\s$/.test(area.value.slice(0, area.selectionStart)) ? " " : ""}${trigger} `, area.selectionStart, area.selectionEnd, "end");
|
||||
area.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
} }), inert("💡")] }),
|
||||
} })] }),
|
||||
box, warn,
|
||||
c.hint.default({ text: `Scenes are cut at the word “${marker}”. Edits apply to the scenes as you type; the anchor is its own field.` }),
|
||||
c.collapsible.default({ label: "+ Variables", body: variablesPanel(app) }),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// What a prompt could still use, from the shortcut library and the person's own habits. Pure. A shortcut is keyed by its name.
|
||||
const usable = (library) => library.filter((s) => s.enabled !== false && (s.triggers || []).length);
|
||||
const isWord = (ch) => /[a-z0-9_]/.test(ch || "");
|
||||
|
||||
function hasTrigger(lc, trigger) { // as a whole token: word boundaries on both sides, spaces allowed inside
|
||||
const t = String(trigger || "").trim().toLowerCase();
|
||||
for (let at = t ? lc.indexOf(t) : -1; at >= 0; at = lc.indexOf(t, at + 1)) if (!isWord(lc[at - 1]) && !isWord(lc[at + t.length])) return true;
|
||||
return false;
|
||||
}
|
||||
const uses = (lc, sc) => sc.triggers.some((t) => hasTrigger(lc, t));
|
||||
|
||||
/** The shortcuts whose trigger is in `text`. */
|
||||
export const presentIn = (library, text) => { const lc = String(text || "").toLowerCase(); return lc.trim() ? usable(library).filter((s) => uses(lc, s)) : []; };
|
||||
|
||||
/** Library grouped by category: `used` have a trigger in the text, `missing` have none. */
|
||||
export function analyze(library, text) {
|
||||
const lc = String(text || "").toLowerCase(), groups = new Map();
|
||||
for (const sc of usable(library)) {
|
||||
const cat = sc.category || "other";
|
||||
if (!groups.has(cat)) groups.set(cat, { cat, used: false, items: [] });
|
||||
const g = groups.get(cat);
|
||||
g.items.push(sc);
|
||||
g.used = g.used || uses(lc, sc);
|
||||
}
|
||||
const all = [...groups.values()];
|
||||
return { used: all.filter((g) => g.used), missing: all.filter((g) => !g.used) };
|
||||
}
|
||||
|
||||
/** Habit partners of the context shortcuts: `edges` is [[a, b, n]] (stats.pairs is symmetric once read both ways).
|
||||
* -> [{ sc, n, with }] strongest first; one-offs (n < 2) are not a habit yet. */
|
||||
export function byHabit(library, edges, context, exclude, { both = false, limit = 4 } = {}) {
|
||||
const byName = new Map(usable(library).map((s) => [s.name, s])), score = new Map();
|
||||
for (const [a, b, n] of edges) {
|
||||
for (const [from, to] of both ? [[a, b], [b, a]] : [[a, b]]) {
|
||||
if (!context.has(from) || exclude.has(to) || !byName.has(to)) continue;
|
||||
const cur = score.get(to) || { n: 0, with: from, best: 0 };
|
||||
cur.n += n;
|
||||
if (n > cur.best) { cur.best = n; cur.with = from; }
|
||||
score.set(to, cur);
|
||||
}
|
||||
}
|
||||
return [...score].filter(([, v]) => v.n >= 2).sort((x, y) => y[1].n - x[1].n).slice(0, limit).map(([name, v]) => ({ sc: byName.get(name), n: v.n, with: byName.get(v.with) }));
|
||||
}
|
||||
|
||||
export const shuffled = (list, random = Math.random) => {
|
||||
const a = list.slice();
|
||||
for (let i = a.length - 1; i > 0; i -= 1) { const j = Math.floor(random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
|
||||
return a;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { analyze, byHabit, presentIn } from "./ideas.js";
|
||||
|
||||
const lib = [
|
||||
{ name: "rain", triggers: ["rain"], category: "weather" }, { name: "fog", triggers: ["fog"], category: "weather" },
|
||||
{ name: "dolly", triggers: ["dolly in"], category: "camera" }, { name: "off", triggers: ["zoom"], category: "camera", enabled: false }, { name: "x", triggers: [], category: "none" },
|
||||
];
|
||||
|
||||
test("a trigger counts only as a whole token", () => {
|
||||
assert.deepEqual(presentIn(lib, "a train in the rain").map((s) => s.name), ["rain"]);
|
||||
assert.deepEqual(presentIn(lib, "constraint").map((s) => s.name), []);
|
||||
assert.deepEqual(presentIn(lib, "a DOLLY IN shot").map((s) => s.name), ["dolly"]);
|
||||
});
|
||||
|
||||
test("a category is used when any of its triggers is in the text; disabled and trigger-less shortcuts are ignored", () => {
|
||||
const { used, missing } = analyze(lib, "rain");
|
||||
assert.deepEqual(used.map((g) => g.cat), ["weather"]);
|
||||
assert.deepEqual(missing.map((g) => g.cat), ["camera"]);
|
||||
});
|
||||
|
||||
test("habits: partners of what is here, strongest first, one-offs ignored, nothing already used", () => {
|
||||
const edges = [["rain", "dolly", 3], ["rain", "fog", 1], ["fog", "dolly", 2]];
|
||||
const got = byHabit(lib, edges, new Set(["rain"]), new Set(["rain"]), { both: true });
|
||||
assert.deepEqual(got.map((g) => [g.sc.name, g.n, g.with.name]), [["dolly", 3, "rain"]]);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// 💡 Shortcut ideas: a bulb beside the prompt box you are in. It never opens by itself; it glows while shortcut categories go unused,
|
||||
// and its list mixes your own habits (what you paired before, what usually follows the previous shot) with browsing the library.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { claimFor } from "../../composer/internals/zlayer.js";
|
||||
import { genUnitId, isGenerative, unitRoot } from "../../shell/scenes.js";
|
||||
import { analyze, byHabit, presentIn, shuffled } from "./ideas.js";
|
||||
|
||||
const BOXES = new Set(["Story", "Prompt"]);
|
||||
const first = (sc) => String((sc.triggers || [])[0] || "").trim();
|
||||
|
||||
export default {
|
||||
id: "ideas",
|
||||
mount: "menubar.menus",
|
||||
needs: ["project", "api", "selection"],
|
||||
setup({ app }) {
|
||||
const p = app.project, on = () => p.pref("suggestions", true);
|
||||
let box = null, node = null, layer = null, pop = null, hold = 0;
|
||||
const closeBulb = () => { if (pop) pop.close(); if (node) { node.remove(); layer.release(); node = layer = box = null; } };
|
||||
const prevText = () => { // the previous shot's prompt, for "your usual next shot" (scene prompt only)
|
||||
const open = p.project; if (!open || box.getAttribute("aria-label") !== "Prompt") return "";
|
||||
const roots = open.scenes.filter((s) => isGenerative(s) && unitRoot(open, genUnitId(s)) === s), at = roots.findIndex((s) => s.id === app.selection.focus);
|
||||
return at > 0 ? roots[at - 1].text || "" : "";
|
||||
};
|
||||
const insert = (text) => {
|
||||
const v = box.value, s = box.selectionStart ?? v.length, e = box.selectionEnd ?? s, before = v.slice(0, s), after = v.slice(e);
|
||||
const ins = `${before && !/\s$/.test(before) ? " " : ""}${text}${/^(\s|[,;])/.test(after) ? "" : " "}`;
|
||||
box.value = before + ins + after; box.setSelectionRange(s + ins.length, s + ins.length);
|
||||
box.dispatchEvent(new Event("input", { bubbles: true })); box.focus();
|
||||
};
|
||||
|
||||
async function openList() {
|
||||
if (pop) return pop.close();
|
||||
let library = [], stats = null;
|
||||
try { library = (await app.api.shortcuts()).shortcuts || []; } catch (err) { return c.toast.warn({ text: err.message }); }
|
||||
try { stats = await app.api.suggestionStats(); } catch { /* habits are optional */ }
|
||||
const text = box.value, { used, missing } = analyze(library, text), here = presentIn(library, text), hereNames = new Set(here.map((s) => s.name));
|
||||
const body = c.region.stack({ gap: "xs" });
|
||||
const row = (sc, why) => c.button.sm({ label: `${first(sc)}${(sc.replacements || [])[0] ? ` — ${sc.replacements[0].slice(0, 40)}` : ""}${why ? ` (${why})` : ""}`, tone: "ghost", onClick: () => { insert(first(sc)); pop && pop.close(); } });
|
||||
const draw = () => {
|
||||
const parts = [];
|
||||
if (stats && hereNames.size) {
|
||||
const pairs = byHabit(library, stats.pairs || [], hereNames, hereNames, { both: true });
|
||||
if (pairs.length) parts.push(c.label.section({ text: "You've paired these before" }), ...pairs.map((i) => row(i.sc, `with ${first(i.with)} ×${i.n}`)));
|
||||
}
|
||||
const prev = new Set(presentIn(library, prevText()).map((s) => s.name));
|
||||
if (stats && prev.size) {
|
||||
const next = byHabit(library, stats.follows || [], prev, hereNames);
|
||||
if (next.length) parts.push(c.label.section({ text: "Your usual next shot" }), ...next.map((i) => row(i.sc, `after ${first(i.with)} ×${i.n}`)));
|
||||
}
|
||||
const more = used.map((g) => ({ cat: g.cat, items: g.items.filter((s) => !hereNames.has(s.name)) })).filter((g) => g.items.length);
|
||||
if (more.length) parts.push(c.label.section({ text: "Continue the thread" }), ...shuffled(more).slice(0, 3).flatMap((g) => [c.hint.default({ text: `more ${g.cat}` }), ...shuffled(g.items).slice(0, 2).map((s) => row(s))]));
|
||||
if (missing.length) parts.push(c.label.section({ text: "New directions" }), ...missing.sort((a, b) => a.cat.localeCompare(b.cat)).flatMap((g) => [c.hint.default({ text: `${g.cat} (${g.items.length})` }), ...shuffled(g.items).slice(0, 3).map((s) => row(s))]));
|
||||
if (!parts.length) parts.push(c.hint.default({ text: "Every shortcut category already appears here — nothing left to browse." }));
|
||||
body.set([c.toolbar.default({ items: [c.label.section({ text: "Ideas" })], trailing: [c.button.sm({ label: "⟳", tone: "ghost", title: "Shuffle the samples", onClick: draw })] }), ...parts]);
|
||||
};
|
||||
draw();
|
||||
pop = c.popover.anchored({ anchor: node, body, side: "bottom", align: "end", onClose: () => { pop = null; } });
|
||||
}
|
||||
|
||||
function show(target) {
|
||||
if (box === target || !on()) return;
|
||||
closeBulb();
|
||||
box = target;
|
||||
const button = c.button.sm({ label: "💡", tone: "ghost", title: "Shortcut ideas", onClick: openList });
|
||||
node = button.node;
|
||||
Object.assign(node.style, { position: "fixed" });
|
||||
document.body.append(node); layer = claimFor("popover", node);
|
||||
place();
|
||||
}
|
||||
function place() {
|
||||
if (!box || !node) return;
|
||||
const r = box.isConnected ? box.getBoundingClientRect() : null;
|
||||
if (!r || !r.width) return closeBulb(); // gone, or hidden with its panel
|
||||
Object.assign(node.style, { left: `${Math.max(4, r.right - 40)}px`, top: `${Math.max(4, r.top - 30)}px` });
|
||||
}
|
||||
const onFocus = (e) => { const t = e.target; if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { clearTimeout(hold); show(t); } };
|
||||
const onBlur = (e) => { if (e.target === box) hold = setTimeout(() => { if (node && !node.contains(document.activeElement) && !pop) closeBulb(); }, 400); };
|
||||
const onMove = () => place();
|
||||
document.addEventListener("focusin", onFocus); document.addEventListener("focusout", onBlur);
|
||||
window.addEventListener("scroll", onMove, true); window.addEventListener("resize", onMove);
|
||||
|
||||
app.editorSettings.push(() => c.region.stack({ gap: "sm", children: [
|
||||
c.toggle.default({ label: "Shortcut ideas", hint: "A 💡 beside prompt boxes lights up when shortcut categories in your library aren't used in the prompt. Click it for insertable ideas; it never pops up on its own.",
|
||||
checked: on(), onChange: (v) => { p.setPref("suggestions", v); if (!v) closeBulb(); } })] }));
|
||||
return () => { closeBulb(); clearTimeout(hold); document.removeEventListener("focusin", onFocus); document.removeEventListener("focusout", onBlur); window.removeEventListener("scroll", onMove, true); window.removeEventListener("resize", onMove); };
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,7 @@ export default [
|
||||
"./features/upscale/index.js",
|
||||
"./features/revolver/index.js",
|
||||
"./features/autocomplete/index.js",
|
||||
"./features/ideas/index.js",
|
||||
"./features/status/index.js",
|
||||
"./features/audio/index.js",
|
||||
"./features/montage/index.js",
|
||||
|
||||
@@ -76,6 +76,7 @@ export const api = {
|
||||
const videos = Object.values(entry.outputs || {}).flatMap((o) => o.videos || []);
|
||||
return videos.length ? { videos } : { error: "no video came out, check the ComfyUI terminal" };
|
||||
},
|
||||
suggestionStats: () => call("GET", "/api/shortcuts/suggestion_stats"),
|
||||
expandPrompt: (body) => call("POST", "/api/prompt/expand", { ...body, seed: 1 }),
|
||||
packs: () => call("GET", "/api/packs"),
|
||||
pack: (action, body) => call("POST", `/api/packs/${action}`, body || {}),
|
||||
|
||||
Reference in New Issue
Block a user