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:
co-authored by
Claude Fable 5
parent
0c2685ce6f
commit
5c48fed1ab
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user