95 lines
7.0 KiB
JavaScript
95 lines
7.0 KiB
JavaScript
// 💡 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 "../../shell/habits.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;
|
||
let frame = 0;
|
||
const closeBulb = () => { cancelAnimationFrame(frame); 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; settle(); } });
|
||
}
|
||
|
||
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);
|
||
// Followed every frame while shown: the box moves with its window (dragged, resized, scrolled inside),
|
||
// which no page-level event reports.
|
||
const follow = () => { place(); if (node) frame = requestAnimationFrame(follow); };
|
||
follow();
|
||
}
|
||
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
|
||
// Inside the box's own bottom-right corner (left of the resize grip): on the box it belongs to, never over a neighbour's controls.
|
||
const b = node.getBoundingClientRect();
|
||
Object.assign(node.style, { left: `${Math.max(r.left + 4, r.right - b.width - 16)}px`, top: `${Math.max(r.top + 2, r.bottom - b.height - 4)}px` });
|
||
}
|
||
const onFocus = (e) => { const t = e.target; if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { clearTimeout(hold); show(t); } };
|
||
const settle = () => { clearTimeout(hold); hold = setTimeout(() => { if (node && box !== document.activeElement && !node.contains(document.activeElement) && !pop) closeBulb(); }, 400); };
|
||
const onBlur = (e) => { if (e.target === box || (node && node.contains(e.target))) settle(); };
|
||
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: "Shows a 💡 beside the Story and Prompt boxes while you are in them. Click it for shortcuts to insert: pairings you use, what usually follows the previous shot, and categories this prompt does not use yet.",
|
||
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); };
|
||
},
|
||
};
|