From 4cf2f58bf68a81d9adca30e5939fc892e1942ab6 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:05:03 +0200 Subject: [PATCH] Media bin: controls pinned at the top, Delete ends Select mode Co-Authored-By: Claude Sonnet 5.5 --- app/ui/composer/elements/layout.css | 3 +++ app/ui/composer/elements/layout.js | 4 ++-- app/ui/features/media/hub.test.js | 22 ++++++++++++++++++++++ app/ui/features/media/index.js | 5 +++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/ui/composer/elements/layout.css b/app/ui/composer/elements/layout.css index e6d89bbd..97a1de14 100644 --- a/app/ui/composer/elements/layout.css +++ b/app/ui/composer/elements/layout.css @@ -408,6 +408,9 @@ .cx-stack { display: flex; flex-direction: column; min-inline-size: 0; } .cx-stack > * { min-inline-size: 0; } .cx-stack-none { gap: 0; } +/* Pinned to the top of the scrolling panel body: the controls stay in reach while the list under them scrolls. + The body pads its top, so the stack reaches back over that padding and gives it back inside. */ +.cx-stack-sticky { position: sticky; inset-block-start: calc(-1 * var(--sp-7)); z-index: 1; margin-block-start: calc(-1 * var(--sp-7)); padding-block: var(--sp-7) var(--sp-4); background: var(--elev-1); } .cx-stack-sm { gap: var(--sp-5); --cx-gap: var(--sp-5); } .cx-stack-md { gap: var(--sp-8); --cx-gap: var(--sp-8); } .cx-stack-lg { gap: var(--sp-10); } diff --git a/app/ui/composer/elements/layout.js b/app/ui/composer/elements/layout.js index 1012ea75..ac1faaee 100644 --- a/app/ui/composer/elements/layout.js +++ b/app/ui/composer/elements/layout.js @@ -126,8 +126,8 @@ define("panel", "zone", panel("zone")); // Without it the shell reaches for the document to make a div of its own, and a // div the shell made is a div the shell has to lay out -- which is the one thing // that is supposed to live only here. -define("region", "stack", ({ children = [], gap = "md", label, fill = false } = {}) => { - const node = el("div", { cls: ["cx-stack", `cx-stack-${gap}`, fill ? "cx-stack-fill" : null], +define("region", "stack", ({ children = [], gap = "md", label, fill = false, sticky = false } = {}) => { + const node = el("div", { cls: ["cx-stack", `cx-stack-${gap}`, fill ? "cx-stack-fill" : null, sticky ? "cx-stack-sticky" : null], attrs: { "aria-label": label } }); const set = (next = []) => { // A redraw replaces the field under the caret (an arrow press commits, and the panel redraws): the caret moves to diff --git a/app/ui/features/media/hub.test.js b/app/ui/features/media/hub.test.js index 38a1653e..40342c03 100644 --- a/app/ui/features/media/hub.test.js +++ b/app/ui/features/media/hub.test.js @@ -218,3 +218,25 @@ menuTest("an already pinned picture is unpinned, scenes keep it, new scenes no l menuTest("reference for all scenes skips scenes that have it or are full, pinned counts as having it", cat, { fixed_references: { z: 1 }, scenes: [{ id: "s1" }, { id: "s2", references: ["m1"] }, { id: "s3", references: Array.from({ length: 15 }, (_, i) => `r${i}`) }, { id: "s4", references: ["a"] }] }, "reference for all scenes", (d) => assert.deepEqual(d.scenes.map((s) => s.references), [["m1"], ["m1"], Array.from({ length: 15 }, (_, i) => `r${i}`), ["a", "m1"]])); + +test("Delete after picking files ends Select mode, and the bin's controls are pinned at the top", async () => { + const project = { selected: null, project: { fixed_references: {} }, setScene: () => {}, edit: () => {} }; + let list = [{ id: "m1", name: "cat.png", kind: "image" }, { id: "m2", name: "dog.png", kind: "image" }]; + const api = { media: async () => ({ media: list }), deleteMedia: async (id) => { list = list.filter((m) => m.id !== id); } }; + const host = document.createElement("div"); + hub.setup({ host, app: { project, api, mediaPeek: {}, say: () => {}, on: () => () => {} } }); + await new Promise((r) => setTimeout(r, 10)); + const btn = (t) => [...document.querySelectorAll("button")].find((b) => b.textContent.trim().startsWith(t)); + assert.ok(host.querySelector(".cx-stack-sticky button"), "the controls sit in the pinned stack"); + document.body.append(host); + btn("Select").click(); + host.querySelector(".cx-cell").click(); + btn("Delete (1)").click(); + await new Promise((r) => setTimeout(r, 10)); + [...document.querySelectorAll("button")].filter((b) => b.textContent.trim() === "Delete").pop().click(); + await new Promise((r) => setTimeout(r, 30)); + assert.equal(host.querySelectorAll(".cx-cell").length, 1); + assert.ok(btn("Select"), "back to Select, not Done"); + assert.equal(btn("Delete (0)").hidden, true); + host.remove(); +}); diff --git a/app/ui/features/media/index.js b/app/ui/features/media/index.js index c3d9dd17..9219a819 100644 --- a/app/ui/features/media/index.js +++ b/app/ui/features/media/index.js @@ -146,12 +146,13 @@ export default { const ids = [...chosen]; if (!ids.length || !(await c.modal.dialogue({ title: "Delete media", message: `Delete ${ids.length} file${ids.length > 1 ? "s" : ""} from the bin for good? Scenes in other projects that use them will lose them too.`, tone: "danger", confirmLabel: "Delete" }).result)) return; await gone(ids); + selecting = false; chosen.clear(); draw(true); // Delete ends Select mode } }); const controls = { sort, view: view, cols: tileSize }; bulk.node.hidden = true; const clearRefs = c.button.sm({ label: "Clear references", tone: "ghost", title: "Stop using every reference picture in every scene (pinned ones stay)", onClick: () => p.edit(clearReferences) }); - host.append(c.toolbar.default({ items: [c.text.sm({ text: "Media" })], trailing: [clearRefs, selectBtn, bulk] }).node, drop.node, note.node, seg("Show", FILTERS, "filter").node, sort.node, - view.node, tileSize.node, shelf.node); + const bar = c.region.stack({ gap: "sm", sticky: true, children: [c.toolbar.default({ items: [c.text.sm({ text: "Media" })], trailing: [clearRefs, selectBtn, bulk] }), drop, note, seg("Show", FILTERS, "filter"), sort, view, tileSize] }); + host.append(bar.node, shelf.node); let drawn = ""; function adopt() {