87 lines
5.6 KiB
JavaScript
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); };
|
|
},
|
|
};
|