Editor: shortcut Ideas bulb — quiet nudge for unused shortcut categories

A 💡 next to the scene prompt and Composer global prompt lights up when whole
shortcut categories (camera moves, sub-actions, …) exist in the library but are
absent from the prompt. Click-only (never auto-opens): a category-grouped
popover lists every missing category with count, random insertable samples,
+N-more browse per category, and a shuffle. Chips insert at the caret through
the field's normal input path. Toggleable via Settings > Editor > Shortcut ideas.

Guards: no popover off a hidden/detached bulb (stranded top-left window),
dismiss listeners detach on every close path, popover closes when a re-render
removes the bulb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-07-06 03:45:48 +03:00
co-authored by Claude Fable 5
parent 0c2685ce6f
commit 5c48fed1ab
7 changed files with 256 additions and 3 deletions
+1
View File
@@ -213,6 +213,7 @@
"Edits apply automatically and stay in sync with the timeline's per-scene prompts. Shortcuts expand at generation time."));
composeTextarea = ta;
if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta);
if (st.project && window.ShortcutSuggest) titleRow.append(window.ShortcutSuggest.bulb(ta));
// Variables ($name) — collapsible, same toggle pattern as Settings ▸ Built-in pipeline.
wrap.append(composeVariables());
+5 -1
View File
@@ -158,6 +158,10 @@
"Prompt autocomplete",
"Suggest matching shortcuts while you type in the global prompt and scene prompts. Shows the trigger, its prompt, and category.",
"autocomplete"));
content.append(toggleRow(
"Shortcut ideas",
"A 💡 next to prompt fields lights up when shortcut categories in your library (camera moves, sub-actions…) aren't used in the prompt. Click it for insertable ideas — it never pops up on its own.",
"suggestions"));
content.append(toggleRow(
"Use anchor",
"Text before the first split trigger is a shared anchor prepended to every scene. Turn off to make that leading text Scene 1 instead.",
@@ -169,7 +173,7 @@
window.SettingsWindow.register({
id: "editor", group: "", title: "Editor",
subtitle: "Per-browser preferences — they apply to this browser, not the project.",
keywords: "autocomplete anchor prompt shortcuts i2v bypass guide preferences",
keywords: "autocomplete anchor prompt shortcuts ideas suggestions i2v bypass guide preferences",
iconBg: "linear-gradient(180deg,#6aa9ff,#3b6fd9)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round"><path d="M2 4.5h12M2 8h12M2 11.5h12"/><circle cx="6" cy="4.5" r="1.7" fill="#fff" stroke="none"/><circle cx="11" cy="8" r="1.7" fill="#fff" stroke="none"/><circle cx="5" cy="11.5" r="1.7" fill="#fff" stroke="none"/></svg>',
mount,
+1
View File
@@ -130,6 +130,7 @@
<script src="editor_settings.js"></script>
<script src="temp_browser.js"></script>
<script src="autocomplete.js"></script>
<script src="suggestions.js"></script>
<script src="slot_picker.js"></script>
<script src="welcome.js"></script>
<script src="pipeline_setup.js"></script>
+6 -1
View File
@@ -288,7 +288,12 @@
const ta = el("textarea"); ta.rows = 4; ta.value = root.text || ""; ta.placeholder = "Describe this scene…"; ta.dataset.k = "sc-text";
ta.oninput = () => S.patchSceneQuiet(scene.id, { text: ta.value });
body.append(field("Prompt", ta));
// Ideas bulb floats OUTSIDE the label — a button inside a <label> becomes its
// implicit control (double-fired clicks, stolen focus target).
const promptWrap = el("div", "sugg-fieldwrap");
promptWrap.append(field("Prompt", ta));
if (window.ShortcutSuggest) promptWrap.append(window.ShortcutSuggest.bulb(ta));
body.append(promptWrap);
if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta);
renderSourceField(st, root, scene);
if (PC()?.usesChainSampler(st)) {
+1 -1
View File
@@ -39,7 +39,7 @@
// prompt is parsed/edited, not what is generated from already-distributed scenes.
const EDITOR_SETTINGS_KEY = "funpack_editor_settings";
const EDITOR_SETTINGS_DEFAULTS = {
autocomplete: true, anchorEnabled: true,
autocomplete: true, suggestions: true, anchorEnabled: true,
// "Anchor as guide" i2v bypass: declare a custom i2v node so it can be forced
// to a state (value-only) on anchor_guide runs. anchorGuideBypass = {node, input, value}.
anchorGuideHasI2v: false, anchorGuideBypass: null,
+19
View File
@@ -1561,6 +1561,25 @@ body.tour-mode { overflow: hidden; }
.ac-prompt { color: var(--muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.ac-cat { color: var(--teal); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .5px; text-transform: uppercase; flex-shrink: 0; }
/* Shortcut ideas bulb + popover (quiet nudge — lights up when categories go unused) */
.sugg-bulb { padding: 0 4px; font-size: 12px; line-height: 1; opacity: .35; filter: grayscale(1); transition: opacity .3s, filter .3s; }
.sugg-bulb.on { opacity: 1; filter: none; text-shadow: 0 0 7px rgba(255, 196, 87, .55); }
.sugg-fieldwrap { position: relative; }
.sugg-fieldwrap .sugg-bulb { position: absolute; top: -3px; right: 0; }
.sugg-pop { position: fixed; z-index: 99999; width: 330px; max-height: 280px; overflow-y: auto;
background: #16140f; border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); padding: 6px; }
.sugg-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 2px 4px 6px; }
.sugg-title { color: var(--muted); font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; }
.sugg-list { display: flex; flex-direction: column; }
.sugg-item { display: flex; align-items: baseline; gap: 8px; padding: 6px 9px; border-radius: 5px; cursor: pointer; }
.sugg-item:hover { background: var(--amber-soft); }
.sugg-empty { color: var(--muted); font-size: 11.5px; padding: 6px 9px; }
.sugg-cat-head { display: flex; align-items: center; gap: 7px; padding: 7px 4px 2px; }
.sugg-cat-head:first-child { padding-top: 2px; }
.sugg-cat-name { color: var(--teal); font-family: var(--font-mono); font-size: 10px; letter-spacing: .6px; text-transform: uppercase; }
.sugg-cat-count { color: var(--muted); font-size: 9.5px; font-family: var(--font-mono); background: var(--ink-3); border-radius: 8px; padding: 0 6px; }
.sugg-more { margin-left: auto; font-size: 10px; padding: 0 4px; color: var(--muted); }
/* Editor settings modal */
.es-content { display: flex; flex-direction: column; gap: 18px; }
.es-row { display: flex; flex-direction: column; gap: 5px; }
+223
View File
@@ -0,0 +1,223 @@
// Shortcut suggestions ("Ideas" bulb): a quiet nudge next to prompt fields that lights
// up when whole shortcut categories (camera moves, sub-actions, …) exist in the library
// but are absent from the current prompt — easy to forget, and the video comes out plain.
// Never opens on its own: click the bulb for a handful of insertable ideas, ⟳ rerolls.
// Toggled by the "Shortcut ideas" editor setting. Reusable: ShortcutSuggest.bulb(textarea).
(function () {
const { el } = window.dom;
const S = window.Store;
const MAX_CHIPS = 3; // random samples shown per category (before "+N more")
function enabled() { return !!S.getEditorSetting("suggestions"); }
function libraryShortcuts() {
return (S.get().shortcuts || []).filter((s) => s.enabled !== false && (s.triggers || []).length);
}
// Does `text` contain `trigger` as a whole token (word boundaries on both sides)?
// Triggers may contain spaces, so this is a boundary-checked substring scan.
function containsTrigger(textLc, trigger) {
const t = trigger.toLowerCase();
if (!t) return false;
let at = textLc.indexOf(t);
const isWord = (ch) => /[a-z0-9_]/.test(ch || "");
while (at >= 0) {
const before = textLc[at - 1];
const after = textLc[at + t.length];
if (!isWord(before) && !isWord(after)) return true;
at = textLc.indexOf(t, at + 1);
}
return false;
}
// Group the library by category and split into covered (some trigger appears in the
// prompt) vs missing. The missing groups are the "you forgot about these" pool.
function analyze(text) {
const textLc = String(text || "").toLowerCase();
const groups = new Map(); // category -> { used: bool, items: [shortcut] }
for (const sc of libraryShortcuts()) {
const cat = sc.category || "other";
if (!groups.has(cat)) groups.set(cat, { used: false, items: [] });
const g = groups.get(cat);
g.items.push(sc);
if (!g.used && (sc.triggers || []).some((t) => containsTrigger(textLc, String(t || "").trim()))) {
g.used = true;
}
}
const missing = [...groups.entries()].filter(([, g]) => !g.used)
.map(([cat, g]) => ({ cat, items: g.items }));
return { missing, total: groups.size };
}
function shuffled(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// Insert at the caret (replacing any selection), padded with spaces against
// neighboring words, and fire `input` so the field's normal handlers persist it.
function insertIntoTextarea(ta, text) {
const v = ta.value;
let s = ta.selectionStart, e = ta.selectionEnd;
if (s == null) { s = e = v.length; }
const before = v.slice(0, s), after = v.slice(e);
const lead = before && !/\s$/.test(before) ? " " : "";
const trail = !/^(\s|[,;])/.test(after) ? " " : "";
const ins = lead + text + trail;
ta.value = before + ins + after;
const caret = s + ins.length;
ta.setSelectionRange(caret, caret);
ta.dispatchEvent(new Event("input", { bubbles: true }));
ta.focus();
}
function bulb(ta) {
const btn = el("button", "btn ghost tiny sugg-bulb", "💡");
btn.type = "button";
if (!enabled()) { btn.style.display = "none"; return btn; }
let pop = null;
let unlisten = null; // detaches the document-level dismiss listeners
function closePop() {
if (pop) { pop.remove(); pop = null; }
if (unlisten) { unlisten(); unlisten = null; }
}
function refresh() {
const { missing } = analyze(ta.value);
const n = missing.length;
btn.classList.toggle("on", n > 0 && !!ta.value.trim());
btn.title = n > 0
? `Ideas — ${n} shortcut ${n === 1 ? "category" : "categories"} this prompt isn't using`
: "Ideas — this prompt already touches every shortcut category";
}
function position() {
if (!pop) return;
const r = btn.getBoundingClientRect();
pop.style.top = (r.bottom + 4) + "px";
pop.style.left = Math.max(8, Math.min(r.left, window.innerWidth - 340)) + "px";
}
function chipRow(sc) {
const trig = String((sc.triggers || [])[0] || "").trim();
if (!trig) return null;
const row = el("div", "sugg-item");
row.append(el("span", "ac-trigger", trig));
const rep = (sc.replacements || [])[0];
if (rep) row.append(el("span", "ac-prompt", rep));
if (sc.sub_category) row.append(el("span", "ac-cat", sc.sub_category));
row.title = rep || trig;
// mousedown (not click) so the textarea keeps focus context, same as autocomplete.
row.addEventListener("mousedown", (ev) => {
ev.preventDefault();
insertIntoTextarea(ta, trig);
closePop();
refresh();
});
return row;
}
function openPop() {
closePop();
document.querySelectorAll(".sugg-pop").forEach((p) => p.remove());
// Never open off an invisible/detached bulb — the popover would strand at (0,0).
const br = btn.getBoundingClientRect();
if (!btn.isConnected || (!br.width && !br.height)) return;
const { missing } = analyze(ta.value);
pop = el("div", "sugg-pop");
const head = el("div", "sugg-head");
head.append(el("span", "sugg-title", missing.length
? "Categories not in this prompt yet"
: "All categories covered ✓"));
const reroll = el("button", "btn ghost tiny", "⟳");
reroll.title = "Shuffle the samples";
reroll.onclick = (ev) => { ev.stopPropagation(); fill(); };
head.append(reroll);
pop.append(head);
const list = el("div", "sugg-list");
pop.append(list);
// Category-grouped browser: every missing category is listed (so you SEE what
// whole families you're skipping), with a few random samples under each and a
// "+N more" toggle to browse that category in full.
const expanded = new Set();
function fill() {
list.replaceChildren();
if (!missing.length) {
list.append(el("div", "sugg-empty",
"Nothing left to suggest — every shortcut category already appears here."));
return;
}
const cats = missing.slice().sort((a, b) => a.cat.localeCompare(b.cat));
for (const m of cats) {
const headRow = el("div", "sugg-cat-head");
headRow.append(el("span", "sugg-cat-name", m.cat));
headRow.append(el("span", "sugg-cat-count", String(m.items.length)));
const showAll = expanded.has(m.cat);
if (m.items.length > MAX_CHIPS) {
const more = el("button", "btn ghost tiny sugg-more",
showAll ? "less" : `+${m.items.length - MAX_CHIPS} more`);
more.onclick = (ev) => {
ev.stopPropagation();
showAll ? expanded.delete(m.cat) : expanded.add(m.cat);
fill();
};
headRow.append(more);
}
list.append(headRow);
const items = showAll ? m.items : shuffled(m.items).slice(0, MAX_CHIPS);
for (const sc of items) {
const row = chipRow(sc);
if (row) list.append(row);
}
}
}
fill();
document.body.append(pop);
position();
// Dismiss on any outside press or Escape; reposition while scrolling.
// Every close path funnels through closePop, which also detaches these.
const onDown = (ev) => { if (pop && !pop.contains(ev.target) && ev.target !== btn) closePop(); };
const onKey = (ev) => { if (ev.key === "Escape") closePop(); };
const onScroll = () => position();
document.addEventListener("mousedown", onDown, true);
document.addEventListener("keydown", onKey, true);
window.addEventListener("scroll", onScroll, true);
unlisten = () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("keydown", onKey, true);
window.removeEventListener("scroll", onScroll, true);
};
}
btn.onclick = (ev) => { ev.preventDefault(); if (pop) closePop(); else openPop(); };
// Keep the glow honest while typing — cheap scan, debounced.
let timer = null;
ta.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(refresh, 400);
});
// Callers create the bulb before appending the field to the DOM — defer the first scan.
setTimeout(refresh, 0);
// A panel re-render can remove the bulb while its popover is open; close it (and
// stop watching) when the bulb detaches, same pattern as the autocomplete menu.
const detachObs = new MutationObserver(() => {
if (!btn.isConnected) { closePop(); detachObs.disconnect(); }
});
setTimeout(() => { if (btn.isConnected) detachObs.observe(document.body, { childList: true, subtree: true }); }, 0);
return btn;
}
window.ShortcutSuggest = { bulb };
})();