fix(ui): prefs survive undo, Chat/Variables follow the open project, generate hooks replace a named enhancer, undo no longer re-upscales, bulb settles, media delete asks and names stay current

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 02:05:39 +03:00
co-authored by Claude Sonnet 5.5
parent d3bf2896b3
commit 74cbd3c3bb
17 changed files with 115 additions and 56 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "o
const frame = build(document.getElementById("app"));
const selection = createSelection({ project });
selection.on(() => say("select"));
const app = { editorSettings: [], project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
const app = { editorSettings: [], inputHooks: [], project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
const settings = createSettings({ menubar: hostFor("menubar.menus") });
const result = await loadFeatures(paths, { app });
settings.addButton();
+26 -15
View File
@@ -1,11 +1,21 @@
// Chat: comment on the enhancer's last rewrite. The next Generate rewrites again with every comment still applying; Reset starts over.
// The comments are the project's own (editor_settings.enhance_chat) and reach the enhancer node with the run.
// The comments are the project's own (editor_settings.enhance_chat); a generate hook hands them to the enhancer node for the prompt they were about.
import { composer as c } from "../../composer/composer.js";
import { genUnitId, isGenerative, unitRoot } from "../../shell/scenes.js";
const NODE = "FunPackEnhancePrompt";
/** Generate hook: the comments about THIS typed prompt go to the enhancer slot; the others are said to be left out. */
export function chatHook({ project, scene, slots }) {
const chat = (project.editor_settings || {}).enhance_chat, slot = (slots || []).find((s) => s.node === NODE);
if (!slot || !Array.isArray(chat)) return null;
const typed = (scene.text || "").trim(), mine = chat.filter((r) => r && (r.original === undefined || r.original === typed)), left = chat.length - mine.length;
return { inputs: mine.length ? { [slot.id]: { chat: JSON.stringify(mine) } } : {}, notes: left ? [`${left} Chat comment(s) are about a different prompt and were left out of this run.`] : [] };
}
export function chat(app, own) {
const p = app.project, page = c.region.stack({ gap: "sm" });
let last = null, alive = true;
let last = null, alive = true, key = "", draft = "";
own(() => { alive = false; });
const rounds = () => { const r = p.pref("enhance_chat", []); return Array.isArray(r) ? r : []; };
const typed = () => { // the prompt a comment is about: the picked scene's own text, as typed
@@ -14,31 +24,32 @@ export function chat(app, own) {
return root ? (root.text || "").trim() : undefined;
};
const first = (r) => Object.values(r.rewrites || {})[0];
let draft = "";
function draw() {
function draw(force) {
if (!alive) return;
const list = rounds(), ok = last && last.ok, next = JSON.stringify([p.project && p.project.id, list, last && last.after]);
if (!force && next === key) return;
key = next;
if (!p.project) return page.set([c.hint.default({ text: "Open a project first." })]);
const list = rounds(), ok = last && last.ok;
const bubbles = [...list.flatMap((r) => [first(r) ? c.text.sm({ text: `Model: ${first(r)}` }) : null, c.text.sm({ text: `You: ${r.comment}` })]),
...(ok ? [c.hint.default({ text: `Last rewrite of: ${String(last.before || "").slice(0, 80)}` }), c.text.sm({ text: `Model: ${last.after}` })] : []),
...(!list.length && !ok ? [c.hint.default({ text: "Generate once with the enhancer on, then comment here." })] : [])].filter(Boolean);
const box = c.textarea.md({ label: "Comment", rows: 2, value: draft, placeholder: "e.g. warmer light, no rain", onInput: (v) => { draft = v; } });
const send = () => {
const comment = draft.trim();
const comment = draft.trim(), now = rounds();
if (!comment) return;
const prev = list.length ? first(list[list.length - 1]) : null;
const fresh = ok && last.after !== prev; // a rewrite nobody has commented on yet
const original = typed();
const round = { rewrites: fresh ? { whole: last.after } : {}, comment, ...(original === undefined ? {} : { original }) };
const prev = now.length ? first(now[now.length - 1]) : null, here = typed();
const fresh = ok && last.after !== prev && app.enhanceTyped !== undefined && app.enhanceTyped === here; // a rewrite of THIS prompt that nobody has commented on yet
draft = "";
p.setPref("enhance_chat", [...list, round]);
draw();
p.setPref("enhance_chat", [...now, { rewrites: fresh ? { whole: last.after } : {}, comment, ...(here === undefined ? {} : { original: here }) }]);
draw(true);
};
page.set([c.label.section({ text: "Chat" }), c.hint.default({ text: "Comment on the last rewrite. The next Generate rewrites again with every comment still applying. Reset starts over." }),
c.region.stack({ gap: "xs", children: bubbles }), box,
c.toolbar.default({ items: [c.button.sm({ label: "Send", tone: "primary", onClick: send })], trailing: [c.button.sm({ label: "Reset", tone: "ghost", disabled: !list.length, onClick: () => { p.setPref("enhance_chat", []); draw(); } })] })]);
c.toolbar.default({ items: [c.button.sm({ label: "Send", tone: "primary", onClick: send })], trailing: [c.button.sm({ label: "Reset", tone: "ghost", disabled: !list.length, onClick: () => { p.setPref("enhance_chat", []); draw(true); } })] })]);
}
draw();
app.api.enhancerRuns().then((r) => { last = (r.runs || []).slice(-1)[0] || null; draw(); }).catch(() => {});
const look = () => app.api.enhancerRuns().then((r) => { last = (r.runs || []).slice(-1)[0] || null; draw(); }).catch(() => {});
own(app.on((what) => { if (what === "open") look(); else draw(); }));
draw(true);
look();
return page;
}
+17
View File
@@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { chatHook } from "./chat.js";
const slots = [{ id: "e", node: "FunPackEnhancePrompt" }, { id: "x", node: "Other" }];
const project = { editor_settings: { enhance_chat: [{ comment: "warmer", original: "a cat" }, { comment: "old", original: "a dog" }, { comment: "any" }] } };
test("comments reach the enhancer only when they are about the prompt being run, and the rest are said", () => {
const r = chatHook({ project, scene: { text: " a cat " }, slots });
assert.deepEqual(JSON.parse(r.inputs.e.chat).map((x) => x.comment), ["warmer", "any"]);
assert.equal(r.notes.length, 1);
});
test("no enhancer in the pipeline, or no comments: nothing is sent", () => {
assert.equal(chatHook({ project, scene: { text: "a cat" }, slots: [slots[1]] }), null);
assert.equal(chatHook({ project: {}, scene: { text: "a cat" }, slots }), null);
});
+4 -3
View File
@@ -4,7 +4,7 @@ import { applyStory, clash, joinStory } from "./story.js";
import { shortcuts } from "./shortcuts.js";
import { cuts } from "./cuts.js";
import { enhance } from "./enhance.js";
import { chat } from "./chat.js";
import { chat, chatHook } from "./chat.js";
import { pickShortcut, templatesBar, variablesPanel } from "./storytools.js";
const TABS = [{ value: "story", label: "Story" }, { value: "shortcuts", label: "Shortcuts" }, { value: "cuts", label: "Cuts" }, { value: "enhance", label: "Enhance" }, { value: "chat", label: "Chat" }];
@@ -43,7 +43,7 @@ const story = (app, own) => {
} })] }),
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) }),
c.collapsible.default({ label: "+ Variables", body: variablesPanel(app, own) }),
] });
};
@@ -56,6 +56,7 @@ export default {
needs: ["project", "api", "pipeline"],
setup({ host, app }) {
let win = null, owned = [];
app.inputHooks.push(chatHook);
const cleanup = () => { owned.forEach((f) => f()); owned = []; };
const open = () => {
if (win) return;
@@ -66,6 +67,6 @@ export default {
onClose: () => { cleanup(); win = null; } });
};
host.append(c.button.sm({ label: "◆ Composer", tone: "neutral", onClick: open }).node);
return cleanup;
return () => { cleanup(); app.inputHooks.splice(app.inputHooks.indexOf(chatHook) >>> 0, 1); };
},
};
+6 -4
View File
@@ -53,7 +53,7 @@ export function templatesBar(app, own) {
bar.set([c.toolbar.default({
items: [c.select.sm({ label: "Templates", disabled: !open, value: known ? active : "",
options: [{ value: "", label: "Templates…" }, { value: NONE, label: "— None (clear all prompts) —" }, ...tpls().map((t) => ({ value: t.name, label: t.name }))],
onChange: (v) => { if (v === NONE) clear(); else if (v) apply(v); } })],
onChange: async (v) => { if (v === NONE) await clear(); else if (v) apply(v); key = ""; draw(); } })],
trailing: [c.button.sm({ label: known ? "Update…" : "Save", tone: "ghost", disabled: !open, title: known ? `Overwrite “${active}” or save under a new name` : "Save the Story and variables as a template", onClick: save }),
...(known ? [c.button.sm({ label: "✎", tone: "ghost", title: `Rename “${active}”`, onClick: () => rename(active) }), c.button.sm({ label: "✕", tone: "ghost", title: `Delete “${active}”`, onClick: () => remove(active) })] : [])] })]);
};
@@ -63,19 +63,21 @@ export function templatesBar(app, own) {
}
/** `$name` → text, filled in at generation. Rows commit on leaving the field; empty names are dropped on save. */
export function variablesPanel(app) {
export function variablesPanel(app, own) {
const p = app.project, page = c.region.stack({ gap: "xs" });
let shown = "";
const rows = () => (p.project && p.project.variables) || [];
const write = (list) => p.setField("variables", list.map((v) => ({ name: String(v.name || "").replace(/^\$+/, "").trim(), value: String(v.value || "") })).filter((v) => v.name || v.value));
const draw = () => page.set([
const draw = () => { shown = JSON.stringify([p.project && p.project.id, rows()]); page.set([
...rows().map((v, i) => c.toolbar.default({
items: [c.input.sm({ label: "Name", value: v.name, placeholder: "name", onCommit: (x) => { const l = clone(rows()); l[i].name = x; write(l); } }),
c.input.sm({ label: "Value", value: v.value, placeholder: "text", onCommit: (x) => { const l = clone(rows()); l[i].value = x; write(l); } })],
trailing: [c.button.sm({ label: "✕", tone: "ghost", title: "Remove", onClick: () => { write(rows().filter((_, k) => k !== i)); draw(); } })] })),
c.button.sm({ label: "+ Add variable", tone: "ghost", disabled: !p.project, onClick: () => { write([...rows(), { name: "name", value: "" }]); draw(); } }),
c.hint.default({ text: "Write $name in a prompt; it becomes the variable's text when you generate." }),
]);
]); };
draw();
own(app.on(() => { const typing = page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName); if (!typing && shown !== JSON.stringify([p.project && p.project.id, rows()])) draw(); }));
return page;
}
+3 -2
View File
@@ -52,10 +52,11 @@ export default {
if (!root || group.every((s) => s.excluded) || !isGenerative(root)) continue; // removed, all left out, or not made by the model
// The unit is made once at the length of its clips together (each cut adds one shared frame).
const frames = group.reduce((t, s) => t + effFrames(s, p.project), 0) - (group.length - 1);
const { inputs, unwired, noPrompt, chatLeftOut } = await buildInputs({ project: p.project, scene: root, slots: app.pipeline.slots(), expand, frames });
const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: p.project, scene: root, slots: app.pipeline.slots(), expand, frames, hooks: app.inputHooks });
app.enhanceTyped = (root.text || "").trim(); // what a Chat comment made now would be about
if (stopped) break;
if (noPrompt && !made) tell("This pipeline has no prompt input, so the scene text is not sent.");
if (chatLeftOut) tell(`${chatLeftOut} Chat comment(s) are about a different prompt and were left out of this run.`);
notes.forEach(tell);
if (unwired) tell(`${unwired} reference(s) did not fit this pipeline and are not used.`);
const done = g.waitForTerminal(); // listening before the run starts, so a fast one is not missed
said = false;
+8 -9
View File
@@ -6,7 +6,7 @@ import { DRIVEN } from "../../shell/scenes.js";
const rolesAt = (slots, at) => (slots || []).flatMap((s) => (s.roles || []).filter((r) => r.at === at).map((role) => ({ slot: s, role })));
/** `frames`: how long this unit is (its clips together); a fresh `seed` per call so a re-roll is a different result. */
export async function buildInputs({ project, scene, slots, expand, frames, seed = () => Math.floor(Math.random() * 2 ** 31) }) {
export async function buildInputs({ project, scene, slots, expand, frames, hooks = [], seed = () => Math.floor(Math.random() * 2 ** 31) }) {
const raw = {};
const put = (slot, name, value) => { raw[slot.id] = { ...raw[slot.id], [name]: value }; };
@@ -27,13 +27,12 @@ export async function buildInputs({ project, scene, slots, expand, frames, seed
.catch(() => null); // a failing prompt-craft feature must not block the run: send what was typed
put(prompt.slot, prompt.role.input, body && typeof body.text === "string" ? body.text : text);
}
// Chat comments on the enhancer's last rewrite ride with the project; only those about THIS typed prompt reach the run.
const enhancer = (slots || []).find((s) => s.node === "FunPackEnhancePrompt"), chat = (project.editor_settings || {}).enhance_chat;
let chatLeftOut = 0;
if (enhancer && Array.isArray(chat)) {
const typed = (scene.text || "").trim(), mine = chat.filter((r) => r && (r.original === undefined || r.original === typed));
chatLeftOut = chat.length - mine.length;
if (mine.length) put(enhancer, "chat", JSON.stringify(mine));
// Features that add inputs of their own (a Chat comment for the enhancer, say) say so through a hook: { inputs: {slot: {name: value}}, notes: [text] }.
const notes = [];
for (const hook of hooks) {
const out = hook({ project, scene, slots }) || {};
for (const [id, fields] of Object.entries(out.inputs || {})) raw[id] = { ...raw[id], ...fields };
notes.push(...(out.notes || []));
}
return { inputs: raw, unwired, noPrompt: !prompt, chatLeftOut };
return { inputs: raw, unwired, noPrompt: !prompt, notes };
}
+5 -8
View File
@@ -42,12 +42,9 @@ test("starting shots from a prompt sends no picture even when the scene has one"
assert.equal(inputs.s.media_id, "");
});
test("Chat comments reach the enhancer only when they are about the prompt being run", async () => {
const s = [...slots, { id: "e", node: "FunPackEnhancePrompt", roles: [] }];
const chat = [{ comment: "warmer", original: "a cat" }, { comment: "old", original: "a dog" }, { comment: "any" }];
const project = { editor_settings: { enhance_chat: chat } };
const r = await buildInputs({ project, scene: { text: " a cat " }, slots: s, expand: async () => null });
assert.deepEqual(JSON.parse(r.inputs.e.chat).map((x) => x.comment), ["warmer", "any"]);
assert.equal(r.chatLeftOut, 1);
assert.equal((await buildInputs({ project, scene: { text: "a cat" }, slots, expand: async () => null })).inputs.e, undefined); // no enhancer in the pipeline: nothing to send
test("a hook's inputs are merged and its notes come back", async () => {
const hook = ({ scene }) => ({ inputs: { k: { extra: scene.text } }, notes: ["said"] });
const r = await buildInputs({ project: {}, scene: { text: "x" }, slots, hooks: [hook, () => null], expand: async () => null });
assert.equal(r.inputs.k.extra, "x");
assert.deepEqual(r.notes, ["said"]);
});
+3 -2
View File
@@ -54,7 +54,7 @@ export default {
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; } });
pop = c.popover.anchored({ anchor: node, body, side: "bottom", align: "end", onClose: () => { pop = null; settle(); } });
}
function show(target) {
@@ -74,7 +74,8 @@ export default {
Object.assign(node.style, { left: `${Math.max(4, r.right - 40)}px`, top: `${Math.max(4, r.top - 30)}px` });
}
const onFocus = (e) => { const t = e.target; if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { clearTimeout(hold); show(t); } };
const onBlur = (e) => { if (e.target === box) hold = setTimeout(() => { if (node && !node.contains(document.activeElement) && !pop) closeBulb(); }, 400); };
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);
+2 -2
View File
@@ -2,7 +2,7 @@
import { composer as c } from "../../composer/composer.js";
import { effFrames, effFps, genUnitId } from "../../shell/scenes.js";
import { sceneRows } from "./scene.js";
import { names } from "./mediapick.js";
import { bin } from "../../shell/bin.js";
import { projectRows } from "./project.js";
export default {
@@ -25,7 +25,7 @@ export default {
};
function draw(force) { // not on every keystroke: that would rebuild the box being typed in
const sc = p.selected, open = p.project;
const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.postfix_enabled !== false, open.generation_mode] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), names.size] : ""}`;
const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.postfix_enabled !== false, open.generation_mode] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), bin.version] : ""}`;
titled();
if (!force && next === key) return;
key = next;
+7 -6
View File
@@ -1,18 +1,19 @@
// The media bin as a choice, for fields that need one picture or several: a window listing what is in it.
import { composer as c } from "../../composer/composer.js";
export const names = new Map(); // id -> name, remembered from the last look at the bin
import { learn, names } from "../../shell/bin.js";
let asked = false;
export async function look(api) {
const bin = (await api.media()).media || [];
bin.forEach((m) => names.set(m.id, m.name));
return bin;
const list = (await api.media()).media || [];
learn(list);
return list;
}
/** The names behind some ids; looks the bin up once if any are not known yet, then says so with `again`. */
export function namesOf(ids, api, again) {
if (!asked && ids.some((id) => !names.has(id))) { asked = true; look(api).then(again, () => {}); }
if (!asked && ids.some((id) => !names.has(id))) { asked = true; look(api).then(again, () => { asked = false; }); }
return ids.map((id) => names.get(id) || "…");
}
@@ -28,7 +29,7 @@ export async function pick(api, { title, kinds, multiple, current = [] }) {
: c.checklist.default({ label: title, items, values: [], onChange: (v) => { chosen = v.filter((id) => !chosen.includes(id)).slice(-1); win.close("done"); } });
let done = false;
const finish = (value) => { if (!done) { done = true; resolve(value); } };
const win = c.modal.generic({ title, size: "sm", body, onClose: (why) => finish(why === "done" ? chosen : null) });
const win = c.modal.generic({ title, size: "sm", body, closeOnOutside: !multiple, onClose: (why) => finish(why === "done" ? chosen : null) });
if (multiple) win.setFooter({ actions: [c.button.sm({ label: "Done", tone: "primary", onClick: () => win.close("done") })] });
});
}
+4 -1
View File
@@ -1,5 +1,6 @@
// The media bin: files brought in (reference images, clips), kept apart from what runs produce.
import { composer as c } from "../../composer/composer.js";
import { learn } from "../../shell/bin.js";
const base = (id) => `/funpack/api/media/${encodeURIComponent(id)}`;
@@ -37,6 +38,8 @@ export default {
},
export: (it) => { const m = items.find((x) => x.id === it.id); Object.assign(document.createElement("a"), { href: `${base(it.id)}/file`, download: m ? m.name : it.id }).click(); },
delete: async (it) => {
const name = (items.find((m) => m.id === it.id) || {}).name || "this file";
if (!(await c.modal.dialogue({ title: "Delete media", message: `Delete “${name}” from the bin for good? Scenes in other projects that use it will lose it too.`, tone: "danger", confirmLabel: "Delete" }).result)) return;
try {
await api.deleteMedia(it.id);
p.edit((pr) => pr.scenes.reduce((hit, s) => {
@@ -77,7 +80,7 @@ export default {
g.setValue(sc && sc.source_image ? [sc.source_image] : []);
}
async function refresh() {
try { items = (await api.media()).media || []; } catch (err) { tell(err.message); }
try { items = (await api.media()).media || []; learn(items); app.say("bin"); } catch (err) { tell(err.message); }
draw(true);
}
refresh();
+1
View File
@@ -3,5 +3,6 @@ import { composer as c } from "../../composer/composer.js";
export const editor = (app) => function mount() {
const parts = app.editorSettings.map((section) => section());
if (parts.length && !app.project.project) parts.unshift(c.banner.info({ text: "Open a project first: these preferences are kept with the project, so nothing here can be changed without one." }));
return parts.length ? c.region.stack({ gap: "lg", children: parts }) : c.emptyState.default({ icon: "☰", title: "Nothing to set", hint: "No editor feature has a preference to show." });
};
+3 -3
View File
@@ -44,12 +44,12 @@ export default {
}
// "Always": a render that appears after the project was opened is upscaled. What was there on open is left alone.
let seen = null, openId = null;
let seen = null, obj = null;
const watch = () => {
const open = p.project;
if (!open) { seen = null; openId = null; return; }
if (!open) { seen = null; obj = null; return; }
const now = new Map(Object.entries(open.scene_renders || {}).filter(([, r]) => r && r.media).map(([id, r]) => [id, keyOf(r.media)]));
if (open.id !== openId || !seen) { openId = open.id; seen = now; return; }
if (open !== obj || !seen) { obj = open; seen = now; return; } // another project, or undo/redo put an older copy back: not a new render
if (p.pref("upscaleMode", "never") === "always") {
const fresh = new Map([...now].filter(([id, k]) => seen.get(id) !== k));
for (const media of new Map([...fresh.keys()].map((id) => [fresh.get(id), open.scene_renders[id].media])).values()) run(media);
+10
View File
@@ -0,0 +1,10 @@
// What the media bin's files are called, as last seen by whoever looked: one shared memory so a rename shows everywhere.
export const names = new Map();
export const bin = { version: 0 };
/** Remember a listing (replacing what was known). */
export function learn(list) {
names.clear();
list.forEach((m) => names.set(m.id, m.name));
bin.version += 1;
}
+2
View File
@@ -107,7 +107,9 @@ export function createProject({ onChange, onError, onOpen, keepConsistent } = {}
// before this must not still be allowed to land on top of it once its
// own fetch catches up.
generation += 1;
const prefs = project.editor_settings; // preferences are not edits: stepping back never takes them with it
project = was.project;
if (prefs !== undefined) project.editor_settings = prefs;
selected = was.selected;
// The scene that was selected may be gone -- stepping FORWARD into a state
// where it had been removed. Fall back rather than leaving a selection that
+13
View File
@@ -638,3 +638,16 @@ test("an edit for a project that is not open is read, changed and saved there",
assert.equal(done, true);
assert.equal(sent.at(-1).name, "other");
});
test("an undo takes edits back but never an editor preference", async () => {
server();
const p = createProject({});
await p.start();
p.setVideo("width", 832);
p.setPref("upscaleMode", "always");
p.undo();
assert.equal(p.video.width, undefined, "the edit was not undone");
assert.equal(p.pref("upscaleMode", "never"), "always");
p.redo();
assert.equal(p.pref("upscaleMode", "never"), "always");
});