fix(ui): keys (Backspace, shifted +, dialog/repeat/Simple guards, +/- zoom), dock in Simple and preview-only hide, validated saved layout, honest Composer Story

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 22:05:11 +03:00
co-authored by Claude Sonnet 5.5
parent 5c5948dd6a
commit f8b0a4b89d
8 changed files with 49 additions and 18 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ const later = (what) => c.emptyState.default({ icon: "◌", title: "Not built ye
const story = (p) => c.region.stack({ gap: "sm", children: [
c.toolbar.default({ items: [c.select.sm({ label: "Templates", disabled: true, options: [{ value: "", label: "Templates…" }], value: "" })], trailing: [inert("Save")] }),
c.toolbar.default({ items: [c.label.section({ text: "Story" })], trailing: [inert("+ Add shortcut"), inert("💡")] }),
c.textarea.md({ label: "Story", rows: 14, value: p.scenes.map((s) => s.text || "").join("\n\n") }),
c.hint.default({ text: "Edits apply to the scenes as you type, and scene edits show here. The anchor is its own field. Shortcuts expand at generation time." }),
c.textarea.md({ label: "Story", rows: 14, disabled: true, value: p.scenes.map((s) => s.text || "").join("\n\n") }),
c.hint.default({ text: "Coming soon: edit all scenes as one story here." }),
c.collapsible.default({ label: "+ Variables", body: c.hint.default({ text: "$name → text, filled in at generation." }) }),
] });
+7 -4
View File
@@ -1,18 +1,21 @@
// v4's editing keys: undo/redo, new project, add and delete a scene.
// v4's editing keys: undo/redo, delete the selected scene, +/- zoom the timeline. Scene keys do nothing in Simple mode, where no timeline shows.
import * as edits from "../../shell/edits.js";
export default {
id: "keys",
mount: "menubar.menus",
needs: ["project", "selection", "keys"],
needs: ["project", "selection", "keys", "mode"],
setup({ app }) {
const p = app.project, sel = app.selection;
const scene = (fn) => () => (app.mode.now === "simple" ? false : fn());
const remove = scene(() => sel.ids.length ? sel.ids.forEach((id) => p.edit((pr) => edits.removeScene(pr, id))) : false);
const off = [
app.keys.bind("mod+z", () => p.undo()),
app.keys.bind("shift+mod+z", () => p.redo()),
app.keys.bind("mod+y", () => p.redo()),
app.keys.bind("+", () => { if (!p.project) return false; const sc = p.edit((pr) => edits.addScene(pr)); if (sc) p.select(sc.id); }),
app.keys.bind("delete", () => sel.ids.length ? sel.ids.forEach((id) => p.edit((pr) => edits.removeScene(pr, id))) : false),
app.keys.bind("delete", remove), app.keys.bind("backspace", remove),
...["+", "="].map((k) => app.keys.bind(k, scene(() => app.say("zoom.in")))),
app.keys.bind("-", scene(() => app.say("zoom.out"))),
];
return () => off.forEach((f) => f());
},
+3 -3
View File
@@ -37,17 +37,17 @@ export default {
const menus = {
File: async () => {
const recent = await list().catch(() => []);
return [{ id: "new", label: "New Project", hint: "⌘N" }, soon("Project Setup Wizard…", "theme · model · tour"), { separator: true },
return [{ id: "new", label: "New Project" }, soon("Project Setup Wizard…", "theme · model · tour"), { separator: true },
{ heading: "Open recent" }, ...(recent.length ? recent.slice(0, 8).map((r) => ({ id: `open:${r.id}`, label: r.name })) : [{ id: "-", label: "No projects", disabled: true }]),
{ separator: true }, soon("Save Project File…", "⬇"), soon("Load Project File…"), { separator: true }, soon("Import Media…"),
{ id: "delete", label: "Delete Current Project", danger: true, disabled: !p.project }];
},
Edit: () => [{ id: "undo", label: "Undo", hint: "⌘Z", disabled: !p.canUndo }, { id: "redo", label: "Redo", hint: "⇧⌘Z", disabled: !p.canRedo }, { separator: true },
{ id: "add", label: "Add Scene", hint: "+", disabled: !p.project }, { id: "del", label: "Delete Scene", disabled: !sel.ids.length }, { separator: true },
{ id: "add", label: "Add Scene", disabled: !p.project }, { id: "del", label: "Delete Scene", disabled: !sel.ids.length }, { separator: true },
{ id: "left", label: "Move Clip Left", hint: "timeline cut", disabled: !sel.focus }, { id: "right", label: "Move Clip Right", hint: "timeline cut", disabled: !sel.focus }, { separator: true },
{ id: "exclude", label: "Toggle Exclude", disabled: !sel.focus }],
View: () => [soon("Refresh Preview", ""), { id: "reset", label: "Reset Layout" }],
Help: () => [soon("Restart tour", ""), soon("Skip to FAQ", ""), soon("Exit tour", ""), { separator: true }, { id: "welcome", label: "Welcome tour…", hint: "?" }],
Help: () => [soon("Restart tour", ""), soon("Skip to FAQ", ""), soon("Exit tour", ""), { separator: true }, { id: "welcome", label: "Welcome tour…" }],
};
const pick = (id) => (id.startsWith("open:") ? p.open(id.slice(5)) : actions[id] && actions[id]());
for (const [label, items] of Object.entries(menus)) {
+3 -2
View File
@@ -29,7 +29,8 @@ export default {
onClose: () => { markSeen(); win = null; },
});
};
if (!seen()) setTimeout(open, 600); // after the first paint, so the screen it sits over is already there
return app.on((what) => { if (what === "welcome.open") open(); });
const timer = seen() ? 0 : setTimeout(open, 600); // after the first paint, so the screen it sits over is already there
const off = app.on((what) => { if (what === "welcome.open") open(); });
return () => { clearTimeout(timer); off(); };
},
};
+8 -4
View File
@@ -43,7 +43,11 @@
.fp-split-bottom { left: 0; right: 0; block-size: 7px; bottom: calc(var(--timeline-h) - 3px); cursor: row-resize; }
:root[data-ui-mode="simple"] .fp-split { display: none; }
/* Dock: a hidden zone leaves the row (and the splitter beside it). */
.fp-hide-assets .fp-row > :first-child, .fp-hide-preview .fp-row > :nth-child(2), .fp-hide-properties .fp-row > :nth-child(3) { display: none; }
.fp-hide-assets .fp-split-left, .fp-hide-properties .fp-split-right { display: none; }
.fp-hide-assets.fp-hide-preview .fp-row > :nth-child(3), .fp-hide-preview.fp-hide-properties .fp-row > :first-child { flex: 1 1 auto; }
/* Dock: a hidden zone leaves the row (and the splitter beside it); Simple mode ignores the dock. */
:root:not([data-ui-mode="simple"]).fp-hide-assets .fp-row > :nth-child(1),
:root:not([data-ui-mode="simple"]).fp-hide-preview .fp-row > :nth-child(2),
:root:not([data-ui-mode="simple"]).fp-hide-properties .fp-row > :nth-child(3) { display: none; }
.fp-hide-assets .fp-split-left, .fp-hide-properties .fp-split-right, .fp-hide-preview .fp-split-right { display: none; }
:root:not([data-ui-mode="simple"]).fp-hide-preview .fp-row > :nth-child(3), :root:not([data-ui-mode="simple"]).fp-hide-preview.fp-hide-properties .fp-row > :nth-child(1) { flex: 1 1 auto; }
:root:not([data-ui-mode="simple"]).fp-hide-assets.fp-hide-preview .fp-row > :nth-child(3) { flex: 1 1 auto; }
.fp-split { border: 0 !important; }
+7 -1
View File
@@ -65,7 +65,13 @@ export function build(root) {
// v4's draggable splitters: three 7px bars that set the zone sizes; sizes are remembered.
const KEY = "funpack_layout", css = document.documentElement.style;
const size = { "--media-w": [160, 600], "--properties-w": [200, 700], "--timeline-h": [200, 700] };
const saved = (() => { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch { return {}; } })();
const saved = (() => {
try {
const raw = JSON.parse(localStorage.getItem(KEY)), out = {};
for (const k in size) if (Number.isFinite(raw[k])) out[k] = Math.min(size[k][1], Math.max(size[k][0], raw[k]));
return out;
} catch { return {}; } // nothing, or nothing usable, remembered
})();
const apply = () => { for (const k in size) saved[k] ? css.setProperty(k, saved[k] + "px") : css.removeProperty(k); };
const keep = () => { try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch { /* not remembered */ } };
const splitter = (host, cls, prop, sign, axis) => {
+3 -1
View File
@@ -2,9 +2,11 @@
// Typing in a field is never intercepted. A binding that returns false lets the key through.
export function createKeys(doc = document) {
const table = new Map();
const combo = (e) => [e.shiftKey && "shift", (e.metaKey || e.ctrlKey) && "mod", e.key.toLowerCase()].filter(Boolean).join("+");
// shift names a combo only for letters and named keys: "+" is Shift+= on a US keyboard.
const combo = (e) => [e.shiftKey && (e.key.length > 1 || /[a-z]/i.test(e.key)) && "shift", (e.metaKey || e.ctrlKey) && "mod", e.key.toLowerCase()].filter(Boolean).join("+");
doc.addEventListener("keydown", (e) => {
const t = e.target;
if (e.repeat || e.altKey || doc.querySelector('[role="dialog"]')) return; // no held-key floods, nothing behind a dialog or the Composer
if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;
const fn = table.get(combo(e));
if (fn && fn(e) !== false) e.preventDefault();
+16 -1
View File
@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { createKeys } from "../keys.js";
const fakeDoc = () => { const d = { on: null, addEventListener(_, fn) { d.on = fn; } }; return d; };
const fakeDoc = (dialog = false) => { const d = { on: null, addEventListener(_, fn) { d.on = fn; }, querySelector: () => dialog }; return d; };
const press = (d, key, extra = {}, target = {}) => { let stopped = false; d.on({ key, target, preventDefault: () => { stopped = true; }, ...extra }); return stopped; };
test("a bound combo runs and is swallowed; false lets the key through", () => {
@@ -27,3 +27,18 @@ test("shift is part of the combo, typing in a field is never intercepted, unbind
press(d, "Z", { metaKey: true, shiftKey: true });
assert.equal(n, 1);
});
test("+ works with Shift held, and nothing fires on repeat, with Alt, or behind a dialog", () => {
const d = fakeDoc(), k = createKeys(d);
let n = 0;
k.bind("+", () => { n++; });
press(d, "+", { shiftKey: true });
assert.equal(n, 1);
press(d, "+", { repeat: true });
press(d, "+", { altKey: true });
assert.equal(n, 1);
const behind = fakeDoc(true), k2 = createKeys(behind);
k2.bind("+", () => { n++; });
press(behind, "+");
assert.equal(n, 1);
});