Files

76 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 💡 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 { 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", "dom"],
setup({ app }) {
const p = app.project, on = () => p.pref("suggestions", true);
let box = null, bulb = null, pop = null, hold = 0;
const closeBulb = () => { if (pop) pop.close(); if (bulb) { bulb.destroy(); bulb = 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: bulb, body, side: "bottom", align: "end", onClose: () => { pop = null; settle(); } });
}
function show(target) {
if (box === target || !on()) return;
closeBulb();
box = target;
bulb = c.floating.corner({ anchor: target, label: "💡", title: "Shortcut ideas", onClick: openList, onGone: () => { bulb = box = null; if (pop) pop.close(); } });
}
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 (bulb && !app.dom.focusOn(box) && !app.dom.holdsFocus(bulb.node) && !pop) closeBulb(); }, 400); };
const onBlur = (e) => { if (e.target === box || (bulb && bulb.node.contains(e.target))) settle(); };
const offFocus = [app.dom.onPage("focusin", onFocus), app.dom.onPage("focusout", onBlur)];
app.editorSettings.push(() => c.region.stack({ gap: "sm", children: [
c.toggle.default({ label: "Shortcut ideas", hint: "Shows a 💡 beside the Story and Prompt boxes with shortcut suggestions to insert, based on what you use.",
checked: on(), onChange: (v) => { p.setPref("suggestions", v); if (!v) closeBulb(); } })] }));
return () => { closeBulb(); clearTimeout(hold); offFocus.forEach((f) => f()); };
},
};