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:
co-authored by
Claude Sonnet 5.5
parent
5c5948dd6a
commit
f8b0a4b89d
@@ -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." }) }),
|
||||
] });
|
||||
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user