From 4bb2ac56cc2e87f74fcc5ae7ad3c9d283669927b Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:03:04 +0300 Subject: [PATCH] feat: pinned references show a pin on their tile; bin redraws keep keyboard focus; drop the no-op alpha sort helper Co-Authored-By: Claude Sonnet 5.5 --- app/ui/composer/elements/gallery.js | 4 +++- app/ui/composer/elements/layout.js | 2 +- app/ui/features/composer/shortcuts.js | 3 +-- app/ui/features/composer/storytools.js | 3 +-- app/ui/features/media/hub.test.js | 18 +++++++++++++++++- app/ui/features/media/index.js | 2 +- app/ui/shell/alpha.js | 2 -- 7 files changed, 24 insertions(+), 10 deletions(-) delete mode 100644 app/ui/shell/alpha.js diff --git a/app/ui/composer/elements/gallery.js b/app/ui/composer/elements/gallery.js index c96b1c03..70beda60 100644 --- a/app/ui/composer/elements/gallery.js +++ b/app/ui/composer/elements/gallery.js @@ -80,7 +80,9 @@ function collection({ items = [], empty = EMPTY, selection = [], onActivate, onC }); kept = next; const now = [...host.children]; - if (now.length !== cells.length || cells.some((cell, i) => cell !== now[i])) host.replaceChildren(...cells); + // a rebuilt tile swaps in place when every other tile stays where it is, so a tile with the keyboard focus keeps it + if (now.length === cells.length && cells.every((cell, i) => cell === now[i] || !cell.parentNode)) cells.forEach((cell, i) => { if (cell !== now[i]) now[i].replaceWith(cell); }); + else if (now.length !== cells.length || cells.some((cell, i) => cell !== now[i])) host.replaceChildren(...cells); } draw(); diff --git a/app/ui/composer/elements/layout.js b/app/ui/composer/elements/layout.js index d2b3dbe6..ccf66575 100644 --- a/app/ui/composer/elements/layout.js +++ b/app/ui/composer/elements/layout.js @@ -132,7 +132,7 @@ define("region", "stack", ({ children = [], gap = "md", label, fill = false } = // document node in through the one element that takes children late. const kids = next.filter(Boolean).map((c) => nodeOf(c, "region.stack")); swapping.depth += 1; - try { node.replaceChildren(...kids); } finally { swapping.depth -= 1; } + try { if (kids.length !== node.childNodes.length || kids.some((k, i) => k !== node.childNodes[i])) node.replaceChildren(...kids); } finally { swapping.depth -= 1; } const after = at >= 0 ? twins() : [], twin = after.length === before.length && after[at]; // a field appearing or going: no guessing which if (!twin || twin === was) return; if ("committed" in was.dataset && was.value !== was.dataset.committed && twin.value === was.dataset.committed) twin.value = was.value; diff --git a/app/ui/features/composer/shortcuts.js b/app/ui/features/composer/shortcuts.js index c2e03192..3730b7a6 100644 --- a/app/ui/features/composer/shortcuts.js +++ b/app/ui/features/composer/shortcuts.js @@ -1,6 +1,5 @@ // 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); @@ -62,7 +61,7 @@ export function shortcuts(app, own) { function draw(lib) { if (!alive) return; - 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 + const items = [...(lib.shortcuts || [])].sort((x, y) => (x.category || "").localeCompare(y.category || "") || x.name.localeCompare(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)) }); diff --git a/app/ui/features/composer/storytools.js b/app/ui/features/composer/storytools.js index 6b1c3452..5d2a193f 100644 --- a/app/ui/features/composer/storytools.js +++ b/app/ui/features/composer/storytools.js @@ -1,6 +1,5 @@ // 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)); @@ -89,7 +88,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) => alpha(x.group, y.group) || alpha(x.label, y.label)); + .sort((x, y) => String(x.group).localeCompare(String(y.group)) || String(x.label).localeCompare(String(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/media/hub.test.js b/app/ui/features/media/hub.test.js index ae8cbc72..9aea1ad2 100644 --- a/app/ui/features/media/hub.test.js +++ b/app/ui/features/media/hub.test.js @@ -58,7 +58,7 @@ test("a changed pin repaints the slot numbers on the tiles even when the list of hub.setup({ host, app: { project, api, mediaPeek: {}, say: () => {}, on: (cb) => { fire = cb; return () => {}; } } }); await new Promise((r) => setTimeout(r, 10)); const flag = (n) => [...[...host.querySelectorAll(".cx-cell")].find((x) => x.textContent.includes(n)).querySelectorAll(".cx-cell-flag")].map((x) => x.textContent).join(); - assert.deepEqual([flag("cat.png"), flag("dog.png")], ["R2", "R1"], "pinned at 2, the other takes the free number"); + assert.deepEqual([flag("cat.png"), flag("dog.png")], ["📌R2", "R1"], "pinned at 2 (marked with a pin), the other takes the free number"); delete fixed.m1; fire("project"); assert.deepEqual([flag("cat.png"), flag("dog.png")], ["R1", "R2"], "unpinned: back to list order"); @@ -106,3 +106,19 @@ test("toggling a reference leaves the other tiles untouched, so the bin does not assert.notEqual(after[1], before[1], "dog.png is the one that changed: it gained its R1 mark"); assert.equal(after[1].querySelector(".cx-cell-flag").textContent, "R1"); }); + +test("redrawing the bin keeps keyboard focus on the tile that has it", async () => { + const project = { selected: { id: "s1", references: [] }, project: {}, setScene: () => {} }; + const api = { media: async () => ({ media: [{ id: "m1", name: "cat.png", kind: "image" }, { id: "m2", name: "dog.png", kind: "image" }] }) }; + const host = document.createElement("div"); + document.body.append(host); + let fire; + hub.setup({ host, app: { project, api, mediaPeek: {}, say: () => {}, on: (cb) => { fire = cb; return () => {}; } } }); + await new Promise((r) => setTimeout(r, 10)); + const tile = [...host.querySelectorAll(".cx-cell")].find((x) => x.textContent.includes("dog.png")); + tile.focus(); const was = document.activeElement === tile; + project.selected.references = ["m1"]; + fire("project"); + const now = [...host.querySelectorAll(".cx-cell")].find((x) => x.textContent.includes("dog.png")); assert.ok(was, "focusable"); assert.equal(now, tile, "same tile node"); assert.equal(document.activeElement, tile, "the focused tile survives the redraw"); + host.remove(); +}); diff --git a/app/ui/features/media/index.js b/app/ui/features/media/index.js index 00bc75ca..0f4a57e7 100644 --- a/app/ui/features/media/index.js +++ b/app/ui/features/media/index.js @@ -127,7 +127,7 @@ export default { if (g.setCols) g.setCols(Number(show.cols)); // what the selected scene uses is marked on its tile, so a reference can be told from the rest const flags = (m) => [sc && sc.source_image === m.id ? { text: "◐", title: "The selected scene's resolution source" } : null, - refs.includes(m.id) ? { text: `R${nums[refs.indexOf(m.id)]}`, title: `Reference slot ${nums[refs.indexOf(m.id)]} for the selected scene` } : null].filter(Boolean); + refs.includes(m.id) ? { text: `${plan.pins[m.id] ? "📌" : ""}R${nums[refs.indexOf(m.id)]}`, title: `Reference slot ${nums[refs.indexOf(m.id)]} for the selected scene${plan.pins[m.id] ? ", pinned to every scene" : ""}` } : null].filter(Boolean); g.setItems(shown.map((m) => ({ id: m.id, label: m.name, thumb: m.kind === "audio" ? undefined : `${base(m.id)}/thumb`, badge: m.kind, flags: flags(m) }))); g.setValue(selecting ? [...chosen] : sc ? [sc.source_image, ...refs].filter(Boolean) : []); selectBtn.setLabel(selecting ? "Done" : "Select"); diff --git a/app/ui/shell/alpha.js b/app/ui/shell/alpha.js deleted file mode 100644 index aa09b14e..00000000 --- a/app/ui/shell/alpha.js +++ /dev/null @@ -1,2 +0,0 @@ -// 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));