diff --git a/app/ui/composer/elements/input.js b/app/ui/composer/elements/input.js index 38d6d5a0..96dd2254 100644 --- a/app/ui/composer/elements/input.js +++ b/app/ui/composer/elements/input.js @@ -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(), }; diff --git a/app/ui/features/composer/shortcuts.js b/app/ui/features/composer/shortcuts.js index 7b8037d0..c2e03192 100644 --- a/app/ui/features/composer/shortcuts.js +++ b/app/ui/features/composer/shortcuts.js @@ -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." }), ]); } diff --git a/app/ui/features/composer/shortcuts.test.js b/app/ui/features/composer/shortcuts.test.js new file mode 100644 index 00000000..b84253dd --- /dev/null +++ b/app/ui/features/composer/shortcuts.test.js @@ -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(); +}); diff --git a/app/ui/features/composer/storytools.js b/app/ui/features/composer/storytools.js index 92a59993..6b1c3452 100644 --- a/app/ui/features/composer/storytools.js +++ b/app/ui/features/composer/storytools.js @@ -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"); } }) }); diff --git a/app/ui/features/inspector/hub.test.js b/app/ui/features/inspector/hub.test.js index 9498092d..49cbead5 100644 --- a/app/ui/features/inspector/hub.test.js +++ b/app/ui/features/inspector/hub.test.js @@ -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); -}); diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index ce523982..cc4ce33c 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -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())), diff --git a/app/ui/shell/alpha.js b/app/ui/shell/alpha.js new file mode 100644 index 00000000..aa09b14e --- /dev/null +++ b/app/ui/shell/alpha.js @@ -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));