Steps, Sampler and Scheduler are on the main window -- the three v4 buried several clicks deep in Engine Settings. No new UI: the sampler slot declares roles the way the prompt and the size already do. What made that nearly free is that the controls now come from the NODE's own description, through the settingFor/rendererFor pair the pipeline window has always used. So a combo is a dropdown of the real choices and a number obeys the real bounds, and prompt.js lost both of its hand-rolled branches: it no longer decides what a control looks like, it asks. File: New project, and every project listed with the open one ticked. The store has been able to do this since the day it arrived and there was no way in -- 20 lines of menu. Three faults found on the way, all in the shared input helper: * ENTER COULD NOT BE TYPED IN A TEXTAREA. `wire()` bound Enter to commit for every text control, textareas included, so a multi-line value was impossible -- the prompt above all, which is the longest multi-line thing in the app. It has been that way in the pipeline window since it was written; nobody had typed a second line into one. * A control committed on blur alone, so autofill, a password manager and any script setting a value went unheard. It listens to `change` as well now, and commit() only acts on a real difference so the pair costs nothing. * A control commits on blur, which means text still being typed is not in the project yet -- and a tab closing is exactly when that is lost. boot blurs before it saves.
209 lines
7.3 KiB
JavaScript
209 lines
7.3 KiB
JavaScript
// The project: an ordered list of scenes, and the only thing in the app that
|
|
// has to survive a reload.
|
|
//
|
|
// Held here rather than in the timeline that draws it, for the reason the
|
|
// pipeline is held in boot: one store of live values. A timeline that owned the
|
|
// project would make "what is on screen" and "what a run uses" two questions
|
|
// with two answers.
|
|
//
|
|
// Saving is DEBOUNCED and last-write-wins per project. Typing a prompt is one
|
|
// edit per keystroke and each is a PUT of the whole project; without this the
|
|
// server answers them out of order and an older body lands last.
|
|
|
|
const BASE = "/funpack/api/projects";
|
|
const SAVE_AFTER = 600;
|
|
|
|
/**
|
|
* A scene id the server will keep.
|
|
*
|
|
* Twelve hex characters is what core/projects.py generates and the only shape it
|
|
* accepts back; anything else it replaces with one of its own. Minting a valid
|
|
* one here means a new scene is addressable the moment it appears, instead of
|
|
* being unselectable until a save round trip has answered.
|
|
*/
|
|
function newId() {
|
|
const bytes = new Uint8Array(6);
|
|
(globalThis.crypto || {}).getRandomValues?.(bytes);
|
|
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
async function json(method, path, body) {
|
|
const res = await fetch(path, {
|
|
method,
|
|
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
let detail = "";
|
|
try { detail = ((await res.json()).problems || [])[0] || ""; } catch { /* not JSON */ }
|
|
throw new Error(detail || `HTTP ${res.status}`);
|
|
}
|
|
return res.status === 204 ? null : res.json();
|
|
}
|
|
|
|
export const list = () => json("GET", BASE).then((d) => d.projects || []);
|
|
export const create = (name) => json("POST", BASE, { name });
|
|
export const read = (id) => json("GET", `${BASE}/${encodeURIComponent(id)}`);
|
|
export const remove = (id) => json("DELETE", `${BASE}/${encodeURIComponent(id)}`);
|
|
const put = (project) => json("PUT", `${BASE}/${encodeURIComponent(project.id)}`, project);
|
|
|
|
/**
|
|
* createProject({ onChange, onError }) -> the live project.
|
|
*
|
|
* `onChange` fires for anything that alters what is on screen, including the
|
|
* selection. Nothing here draws; the timeline subscribes.
|
|
*/
|
|
export function createProject({ onChange, onError } = {}) {
|
|
let project = null;
|
|
let selected = null;
|
|
// Every project this browser knows about, for the File menu. Read once at
|
|
// startup and kept in step here rather than re-fetched each time the menu
|
|
// opens: a listing is not a thing that changes behind the user's back.
|
|
let recent = [];
|
|
let timer = null;
|
|
let saving = null; // the PUT in flight, so a queued one waits for it
|
|
let dirty = false;
|
|
|
|
const changed = () => { if (onChange) onChange(); };
|
|
|
|
function scheduleSave() {
|
|
dirty = true;
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(flush, SAVE_AFTER);
|
|
}
|
|
|
|
async function flush() {
|
|
if (timer) { clearTimeout(timer); timer = null; }
|
|
if (!project || !dirty) return;
|
|
// One PUT at a time. Two overlapping writes of a whole project can land in
|
|
// either order, and the loser is a version of the project the user has
|
|
// already moved past.
|
|
if (saving) { await saving.catch(() => {}); if (!dirty) return; }
|
|
dirty = false;
|
|
const body = { ...project };
|
|
saving = put(body).then((saved) => {
|
|
// Only the fields the server owns are taken back: replacing the whole
|
|
// project would overwrite whatever was typed while the PUT was in flight.
|
|
if (project && saved && project.id === saved.id) project.updated_at = saved.updated_at;
|
|
}).catch((err) => {
|
|
dirty = true; // it is still unsaved, so say so
|
|
if (onError) onError(err);
|
|
}).finally(() => { saving = null; });
|
|
return saving;
|
|
}
|
|
|
|
const sceneAt = (id) => (project ? (project.scenes || []).find((s) => s.id === id) : null);
|
|
|
|
return {
|
|
get project() { return project; },
|
|
get scenes() { return project ? project.scenes || [] : []; },
|
|
get selectedId() { return selected; },
|
|
get selected() { return sceneAt(selected); },
|
|
|
|
/** The most recent project, or a new one when there are none. */
|
|
get recent() { return recent; },
|
|
|
|
async start() {
|
|
const found = await list();
|
|
recent = found;
|
|
project = found.length ? await read(found[0].id) : await create("Untitled");
|
|
if (!found.length) recent = [{ id: project.id, name: project.name }];
|
|
selected = (project.scenes || [])[0]?.id ?? null;
|
|
changed();
|
|
return project;
|
|
},
|
|
|
|
async open(id) {
|
|
await flush();
|
|
project = await read(id);
|
|
selected = (project.scenes || [])[0]?.id ?? null;
|
|
changed();
|
|
return project;
|
|
},
|
|
|
|
async newProject(name) {
|
|
await flush();
|
|
project = await create(name);
|
|
recent = [{ id: project.id, name: project.name }, ...recent];
|
|
selected = (project.scenes || [])[0]?.id ?? null;
|
|
changed();
|
|
return project;
|
|
},
|
|
|
|
select(id) {
|
|
if (!sceneAt(id) || id === selected) return;
|
|
selected = id;
|
|
changed(); // selection is not saved: it is a view
|
|
},
|
|
|
|
/** What the whole project generates at, whichever scene is current. */
|
|
get video() { return (project && project.video) || {}; },
|
|
|
|
setVideo(key, value) {
|
|
if (!project) return;
|
|
project.video = project.video || {};
|
|
if (project.video[key] === value) return;
|
|
project.video[key] = value;
|
|
scheduleSave(); // nothing on screen draws from this
|
|
},
|
|
|
|
/** The text of one scene. The prompt box on the main window edits this. */
|
|
setText(id, text) {
|
|
const scene = sceneAt(id);
|
|
if (!scene || scene.text === text) return;
|
|
scene.text = text;
|
|
scheduleSave();
|
|
changed();
|
|
},
|
|
|
|
addScene() {
|
|
if (!project) return null;
|
|
// A new scene lands AFTER the selected one, not at the end: adding partway
|
|
// through a timeline is how a scene gets inserted, and appending silently
|
|
// would put it somewhere the user was not looking.
|
|
const at = project.scenes.findIndex((s) => s.id === selected);
|
|
const scene = { id: newId(), text: "", result: null };
|
|
project.scenes.splice(at < 0 ? project.scenes.length : at + 1, 0, scene);
|
|
selected = scene.id; // you add a scene in order to fill it
|
|
scheduleSave();
|
|
changed();
|
|
return scene;
|
|
},
|
|
|
|
removeScene(id) {
|
|
if (!project || !sceneAt(id)) return;
|
|
const at = project.scenes.findIndex((s) => s.id === id);
|
|
project.scenes.splice(at, 1);
|
|
if (selected === id) {
|
|
const next = project.scenes[at] || project.scenes[at - 1];
|
|
selected = next ? next.id : null;
|
|
}
|
|
scheduleSave();
|
|
changed();
|
|
},
|
|
|
|
move(id, by) {
|
|
if (!project) return;
|
|
const at = project.scenes.findIndex((s) => s.id === id);
|
|
const to = at + by;
|
|
if (at < 0 || to < 0 || to >= project.scenes.length) return;
|
|
const [scene] = project.scenes.splice(at, 1);
|
|
project.scenes.splice(to, 0, scene);
|
|
scheduleSave();
|
|
changed();
|
|
},
|
|
|
|
/** Attach what a run produced to the scene it was started from. */
|
|
setResult(id, result) {
|
|
const scene = sceneAt(id);
|
|
if (!scene) return;
|
|
scene.result = result;
|
|
scheduleSave();
|
|
changed();
|
|
},
|
|
|
|
flush,
|
|
get unsaved() { return dirty; },
|
|
};
|
|
}
|