fix: the reference pin lives in the media tile menu only; the shortcuts search keeps its text after an edit; shortcuts sort alphabetically, case ignored

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-13 05:31:54 +03:00
co-authored by Claude Sonnet 5.5
parent 03df8882f6
commit d80b0aebef
7 changed files with 48 additions and 38 deletions
+3 -1
View File
@@ -183,10 +183,11 @@ define("textarea", "md", ({ value = "", rows = 4, autoGrow = false, fold = false
*/
const FILTER_ICON_TONES = new Set(["accent", "danger", "warn", "good", "neutral"]);
define("filterList", "md", ({ items = [], value, onChange, placeholder = "Search", empty = "Nothing matches" } = {}) => {
define("filterList", "md", ({ items = [], value, onChange, placeholder = "Search", empty = "Nothing matches", query = "" } = {}) => {
let selected = value;
const search = el("input", { cls: ["cx-input", "cx-input-sm", "cx-search", "cx-focusable"],
attrs: { type: "search", placeholder } });
search.value = query; // a list rebuilt after an edit starts from what was typed
const list = el("div", { cls: "cx-filter-list", attrs: { role: "listbox" } });
function draw() {
@@ -239,6 +240,7 @@ define("filterList", "md", ({ items = [], value, onChange, placeholder = "Search
return {
node,
get value() { return selected; },
get query() { return search.value; },
setValue(v) { selected = v; draw(); },
destroy: () => node.remove(),
};
+7 -4
View File
@@ -1,11 +1,12 @@
// Shortcuts: the library of trigger -> replacement phrases, expanded into a prompt when it is generated.
import { composer as c } from "../../composer/composer.js";
import { alpha } from "../../shell/alpha.js";
const lines = (text) => String(text || "").split("\n").map((l) => l.trim()).filter(Boolean);
export function shortcuts(app, own) {
const page = c.region.stack({ gap: "sm" });
let alive = true;
let alive = true, box = null;
own(() => { alive = false; });
const tell = (text) => c.toast.warn({ text });
@@ -61,12 +62,14 @@ export function shortcuts(app, own) {
function draw(lib) {
if (!alive) return;
const items = (lib.shortcuts || []).map((s) => ({ id: s.name, label: `${s.enabled ? "" : "⏸ "}${s.name}`, hint: s.triggers.join(", "), keywords: `${s.category} ${s.sub_category} ${s.replacements.join(" ")}`, group: s.category || "" }));
items.sort((x, y) => x.group.localeCompare(y.group) || x.label.localeCompare(y.label));
const items = [...(lib.shortcuts || [])].sort((x, y) => alpha(x.category || "", y.category || "") || alpha(x.name, y.name)) // by name: the pause mark in a label must not decide the order
.map((s) => ({ id: s.name, label: `${s.enabled ? "" : "⏸ "}${s.name}`, hint: s.triggers.join(", "), keywords: `${s.category} ${s.sub_category} ${s.replacements.join(" ")}`, group: s.category || "" }));
const query = box ? box.query : ""; // what was typed in the search box survives a save, a delete, an import
box = c.filterList.md({ items, query, placeholder: "Search shortcuts", empty: "No shortcuts yet. Add one with + New.", onChange: (id) => edit((lib.shortcuts || []).find((s) => s.name === id)) });
const more = c.button.sm({ label: "⋯", tone: "ghost", title: "Export, import, categories, delete all", onClick: () => tools(more.node, lib) });
page.set([
c.toolbar.default({ items: [c.label.section({ text: "Shortcuts" })], trailing: [c.button.sm({ label: "+ New", tone: "primary", onClick: () => edit(null) }), more] }),
c.filterList.md({ items, placeholder: "Search shortcuts", empty: "No shortcuts yet. Add one with + New.", onChange: (id) => edit((lib.shortcuts || []).find((s) => s.name === id)) }),
box,
c.hint.default({ text: "Type a trigger in any prompt and it is replaced when you generate." }),
]);
}
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let shortcuts;
test.before(async () => { setupDom(); shortcuts = (await import("./shortcuts.js")).shortcuts; });
test.after(() => teardownDom());
const one = (name, extra = {}) => ({ name, triggers: [name.toLowerCase()], replacements: ["x"], enabled: true, category: "", sub_category: "", ...extra });
const wait = () => new Promise((r) => setTimeout(r, 10));
const mount = async (api) => { const page = shortcuts({ api }, () => {}); document.body.append(page.node); await wait(); return page; };
test("shortcuts are in real alphabetical order: case ignored, and a paused one is placed by its name, not by its pause mark", async () => {
const lib = { shortcuts: [one("banana"), one("Apple"), one("cherry", { enabled: false }), one("apple2"), one("Zed")] };
const page = await mount({ shortcuts: async () => lib });
assert.deepEqual([...page.node.querySelectorAll(".cx-filter-label")].map((n) => n.textContent), ["Apple", "apple2", "banana", "⏸ cherry", "Zed"]);
page.node.remove();
});
test("what was typed in the search box is still there after a shortcut is saved", async () => {
const lib = { shortcuts: [one("banana"), one("Apple"), one("bandana")] };
const page = await mount({ shortcuts: async () => lib, saveShortcut: async () => lib });
const search = () => page.node.querySelector(".cx-search");
search().value = "ban";
search().dispatchEvent(new window.Event("input", { bubbles: true }));
assert.deepEqual([...page.node.querySelectorAll(".cx-filter-label")].map((n) => n.textContent), ["banana", "bandana"]);
page.node.querySelector(".cx-filter-row").click(); // opens the editor
[...document.querySelectorAll("button")].find((b) => b.textContent === "Save").click();
await wait();
assert.equal(search().value, "ban", "the search box was rebuilt but kept its text");
assert.equal(page.node.querySelectorAll(".cx-filter-label").length, 2, "and the list is still filtered");
page.node.remove();
});
+2 -1
View File
@@ -1,5 +1,6 @@
// What sits around the Story box: saved templates (scenes + variables), the $variables list, and the shortcut picker.
import { composer as c } from "../../composer/composer.js";
import { alpha } from "../../shell/alpha.js";
import { applyStory, storyRoots } from "./story.js";
const clone = (x) => JSON.parse(JSON.stringify(x));
@@ -88,7 +89,7 @@ export async function pickShortcut(app) {
if (!all.length) { c.toast.warn({ text: "No shortcuts yet. Add some in the Shortcuts tab." }); return null; }
return new Promise((resolve) => {
const items = all.map((s) => ({ id: s.name, label: s.name, hint: (s.replacements || [])[0] || "", keywords: `${s.triggers.join(" ")} ${s.category} ${s.sub_category}`, group: [s.category, s.sub_category].filter(Boolean).join(" › ") }))
.sort((x, y) => x.group.localeCompare(y.group) || x.label.localeCompare(y.label));
.sort((x, y) => alpha(x.group, y.group) || alpha(x.label, y.label));
let chosen = null;
const win = c.modal.generic({ title: "Add shortcut", size: "md", onClose: () => resolve(chosen),
body: c.filterList.md({ items, placeholder: "Search shortcuts", empty: "Nothing matches.", onChange: (id) => { chosen = all.find((s) => s.name === id).triggers[0]; win.close("done"); } }) });
-19
View File
@@ -46,22 +46,3 @@ test("typing in the Prompt box, then pressing a button in the panel, does not re
assert.ok(button.isConnected, "the pressed button is still there for its mouse-up");
host.remove();
});
test("each reference has a 'Pin reference to slot RN' row; ticking one pins it at its number, and the rows follow", () => {
let fire;
const doc = { id: "P", frame_rate: 25, num_frames_per_scene: 49, fixed_references: {}, scene_renders: {}, scenes: [{ id: "a", text: "t", references: ["m1", "m2"] }] };
const p = { project: doc, get scenes() { return doc.scenes; }, get selected() { return doc.scenes[0]; }, selectedId: "a",
setText: () => {}, setScene: () => {}, edit: (fn) => fn(doc) };
const host = document.createElement("div");
document.body.append(host);
hub.setup({ host, app: { project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
const rows = () => [...host.querySelectorAll("label")].filter((l) => l.textContent.includes("Pin reference to slot"));
const box = (i) => rows()[i].previousElementSibling; // the checkbox is the label's sibling
const names = () => rows().map((l) => l.textContent.match(/Pin reference to slot R\d+/)[0]);
assert.deepEqual(names(), ["Pin reference to slot R1", "Pin reference to slot R2"]);
box(1).click();
assert.deepEqual(doc.fixed_references, { m2: 2 }, "pinned at the number it had");
fire("change");
assert.deepEqual(names(), ["Pin reference to slot R2", "Pin reference to slot R1"], "the pinned one keeps R2, the other takes R1");
assert.ok(box(0).checked && !box(1).checked);
});
+1 -13
View File
@@ -3,7 +3,6 @@ import { composer as c } from "../../composer/composer.js";
import { namesOf, pick } from "./mediapick.js";
import { effFrames, effFps, genUnitId, isSubclip, seconds, snapFrames } from "../../shell/scenes.js";
import { gridOf } from "../../shell/frame_grid.js";
import { referenceNumbers, sceneReferences } from "../../shell/reference_wiring.js";
import { restoreToPlan } from "../../shell/edits.js";
const SOURCES = [{ value: "image", label: "Image · i2v anchor" }, { value: "carry", label: "From generated frame" }, { value: "video", label: "Video clip" }];
@@ -20,17 +19,6 @@ const mediaField = (app, p, sc, { label, hint, key, multiple, kinds, empty }) =>
};
/** One "Pin reference to the slot" per reference: a pinned one keeps its R-number when an earlier reference is switched off. */
/** One "Pin reference to slot RN" per reference the scene sends. Ticking it pins the reference at the number it has now, for every scene, until it is unticked. */
const pinRows = (sc, app) => {
const pr = app.project.project, fixed = (pr && pr.fixed_references) || {};
const plan = sceneReferences(sc, fixed);
if (!plan.ids.length) return [];
const numbers = referenceNumbers(plan.ids, plan.pins);
return [card(...plan.ids.map((id, i) => c.checkboxRow.default({ label: `Pin reference to slot R${numbers[i]}`,
hint: namesOf([id], app.api, () => app.say("bin"))[0] || id, checked: id in fixed,
onChange: (on) => app.project.edit((x) => { const next = { ...(x.fixed_references || {}) }; if (on) next[id] = numbers[i]; else delete next[id]; x.fixed_references = next; return true; }) })))];
};
/** A number-or-mode pair: "Project default / Timeline trim / Custom", and the number only when Custom. */
const lengthField = (p, sc, label, modeKey, valueKey, shown, grid) => {
const mode = sc[modeKey] || "project";
@@ -75,7 +63,7 @@ export function sceneRows(p, app) {
c.label.section({ text: "Reference media (v5 pipeline)" }),
card(mediaField(app, p, sc, { label: "Resolution source", hint: "Sets this scene's aspect ratio for generation — the project's own Width/Height set the actual resolution; this image's pixels are not used.",
key: "source_image", multiple: false, kinds: ["image"], empty: "— choose resolution source —" }),
mediaField(app, p, sc, { label: "References", key: "references", multiple: true, kinds: ["image", "video", "audio"], empty: "+ Add reference" })), ...pinRows(sc, app),
mediaField(app, p, sc, { label: "References", key: "references", multiple: true, kinds: ["image", "video", "audio"], empty: "+ Add reference" })),
c.label.section({ text: "Length" }),
card(c.field.row({ fields: [
lengthField(p, sc, "Frames", "frames_mode", "frames", effFrames(sc, open), gridOf(app.pipeline && app.pipeline.slots())),
+2
View File
@@ -0,0 +1,2 @@
// Alphabetical, ignoring case and accents; two names that differ only in case fall back to the exact text so the order never shuffles.
export const alpha = (a, b) => String(a).localeCompare(String(b), undefined, { sensitivity: "base" }) || String(a).localeCompare(String(b));