Files

87 lines
5.6 KiB
JavaScript

// ◆ Composer: the floating "prompt craft" window. Opens from the timeline header; its tabs are the ones v4 had.
import { composer as c } from "../../composer/composer.js";
import { applyStory, clash, joinStory, shownStory } from "./story.js";
import { shortcuts } from "./shortcuts.js";
import { cuts } from "./cuts.js";
import { enhance } from "./enhance.js";
import { build } from "./build.js";
import { chat, chatHook } from "./chat.js";
import { files } from "./files.js";
import { pickShortcut, templatesBar, variablesPanel } from "./storytools.js";
const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: "Shortcuts" }, { value: "build", label: "Build" }, { value: "cuts", label: "Cuts" }, { value: "enhance", label: "Enhance" }, { value: "chat", label: "Chat" }, { value: "files", label: "Files" }];
// The box follows the scenes; typing in it rewrites them (after a short pause, or when focus leaves).
export const story = (app, own) => {
const p = app.project;
let shown = null, marker = "qcut", words = ["qcut"], timer = 0, typedFor = null, asked = 0, inFlight = Promise.resolve();
const box = c.textarea.md({ label: "Story", rows: 14, value: p.project ? joinStory(p.project, marker) : "", onInput: (v) => { clearTimeout(timer); typedFor = p.project && p.project.id; timer = setTimeout(() => { timer = 0; apply(v); }, 700); }, onCommit: (v) => { clearTimeout(timer); timer = 0; apply(v); } });
const area = box.node, warn = c.hint.default({ text: "" });
// Text typed in one project is never split into another one that was opened (by keyboard, with no blur) before the pause ran out.
function apply(text) { if (typedFor && p.project && p.project.id !== typedFor) { typedFor = null; sync(); return inFlight; } typedFor = null; return (inFlight = split(text)); }
async function split(text) {
if (!p.project) return;
const mine = ++asked, pid = p.project.id;
try {
const { scenes } = await app.api.storySplit(text);
if (mine !== asked || !p.project || p.project.id !== pid) return; // typed more meanwhile, or another project opened: not for this one
p.edit((pr) => applyStory(pr, scenes, shown));
if (p.project) shown = shownStory(p.project);
} catch (err) { c.toast.warn({ text: `Could not split the story: ${err.message}` }); }
}
app.api.storyMarkers().then((r) => { words = r.markers || words; marker = words[0]; sync(); }).catch(() => {});
function sync() {
warn.setText(p.project && clash(p.project, words) ? `A scene's own text contains a cut word (${words.join(", ")}), so the story shows it as two scenes. Remove the word from that scene.` : "");
if (!p.project || app.dom.focusOn(area) || timer) return; // never rewrite the box while it is being typed in
const next = joinStory(p.project, marker);
if (area.value !== next) box.setValue(next);
shown = shownStory(p.project);
}
own(app.on(sync));
// Generate runs what the box shows: text still waiting for its pause, or still being split, lands first.
const flush = async () => { if (timer) { clearTimeout(timer); timer = 0; apply(area.value); } await inFlight; };
app.beforeRun.push(flush);
own(() => { flush().finally(() => app.beforeRun.splice(app.beforeRun.indexOf(flush) >>> 0, 1)); }); // closed mid-edit: still waited for
sync();
return c.region.stack({ gap: "sm", children: [
templatesBar(app, own),
c.toolbar.default({ items: [c.label.section({ text: "Story" })], trailing: [c.button.sm({ label: "+ Add shortcut", tone: "ghost", title: "Insert a shortcut trigger: browse the library", onClick: async () => {
const trigger = await pickShortcut(app);
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 }));
} })] }),
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, own) }),
] });
};
// Tabs whose backend module may be missing.
const TAB_MODULE = { enhance: "conditioning_prompt_enhancer", chat: "conditioning_prompt_enhancer" };
const sheets = { story, shortcuts, build, cuts,
enhance, chat, files };
export default {
id: "composer",
mount: "timeline.status",
needs: ["project", "api", "pipeline", "dom"],
setup({ host, app }) {
let win = null, owned = [];
const cleanup = () => { owned.forEach((f) => f()); owned = []; };
const open = () => {
if (win) return;
const body = c.region.stack({ gap: "sm", fill: true });
const show = (tab) => { cleanup(); body.set([c.tabs.underline({ label: "Composer", tabs: TABS.filter((t) => !TAB_MODULE[t.value] || !app.hasModule || app.hasModule(TAB_MODULE[t.value])), value: tab, onChange: show }), sheets[tab](app, (off) => owned.push(off))]); };
show("story");
win = c.floating.window({ id: "composer", title: "Composer", subtitle: "prompt craft", body, width: 420, height: 520, x: 320, y: 90, origin: () => button.node,
onClose: () => { cleanup(); win = null; } });
};
const button = c.button.sm({ label: "◆ Composer", tone: "neutral", onClick: () => (win ? win.close() : open()) });
host.append(button.node); // the button toggles it, as v4
app.inputHooks.push(chatHook); // last, so a setup that failed above leaves nothing behind
return () => { cleanup(); app.inputHooks.splice(app.inputHooks.indexOf(chatHook) >>> 0, 1); };
},
};