From 25e89ada2b6ea771166e8f7ed384e5ac92c13c6b Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 15:12:53 +0200 Subject: [PATCH] feat: drag-reorder slots pool.reorder() permutes slots (validated permutation) and keeps the active selection on its slot; exposed via /grid_pool/reorder. The grid thumbnails are drag handles; dropping on another cell reorders. Co-Authored-By: Claude Opus 4.8 --- gates/handlers.py | 4 ++++ gates/pool.py | 14 ++++++++++++++ gates/routes.py | 7 +++++++ tests/test_pool.py | 20 ++++++++++++++++++++ tests/test_routes_logic.py | 8 ++++++++ web/grid_image_pool.js | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+) diff --git a/gates/handlers.py b/gates/handlers.py index 9e9e1b7..3b98a87 100644 --- a/gates/handlers.py +++ b/gates/handlers.py @@ -22,5 +22,9 @@ def handle_list(base, pool_id): return pool.read_manifest(base, pool_id) +def handle_reorder(base, pool_id, order): + return pool.reorder(base, pool_id, order) + + def handle_set_mask(base, pool_id, index, mask_png_bytes): return pool.set_mask(base, pool_id, index, mask_png_bytes) # Task 12 diff --git a/gates/pool.py b/gates/pool.py index bb42257..e946492 100644 --- a/gates/pool.py +++ b/gates/pool.py @@ -112,6 +112,20 @@ def set_label(base_dir, pool_id, index, label): return m +def reorder(base_dir, pool_id, order): + m = read_manifest(base_dir, pool_id) + n = len(m["slots"]) + # order must be a permutation of range(n) — otherwise leave untouched + if sorted(order) != list(range(n)): + return m + old_active = m.get("active", 0) + m["slots"] = [m["slots"][i] for i in order] + m["active"] = order.index(old_active) if old_active in order else 0 + m["active"] = _clamp_active(m) + write_manifest(base_dir, pool_id, m) + return m + + def set_mask(base_dir, pool_id, index, mask_bytes): m = read_manifest(base_dir, pool_id) if not (0 <= index < len(m["slots"])): diff --git a/gates/routes.py b/gates/routes.py index 4b530e3..a4293b9 100644 --- a/gates/routes.py +++ b/gates/routes.py @@ -60,6 +60,13 @@ async def _set_mask(request): return web.json_response(m) +@routes.post("/grid_pool/reorder") +async def _reorder(request): + body = await request.json() + order = [int(i) for i in body["order"]] + return web.json_response(handlers.handle_reorder(_base(), body["pool_id"], order)) + + @routes.get("/grid_pool/list") async def _list(request): pool_id = request.query.get("pool_id", "default") diff --git a/tests/test_pool.py b/tests/test_pool.py index 682f9b1..4012796 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -131,3 +131,23 @@ def test_set_mask_writes_sidecar(tmp_path): def test_set_mask_out_of_range_noop(tmp_path): m = pool.set_mask(str(tmp_path), "p1", 0, b"x") assert m["slots"] == [] + + +def test_reorder_slots_and_fixes_active(tmp_path): + pool.add_image(str(tmp_path), "p1", b"a", ts=1) + pool.add_image(str(tmp_path), "p1", b"b", ts=2) + pool.add_image(str(tmp_path), "p1", b"c", ts=3) + pool.set_active(str(tmp_path), "p1", 2) # active = img_0003 + m = pool.reorder(str(tmp_path), "p1", [2, 0, 1]) # new order: c, a, b + assert [s["image"] for s in m["slots"]] == ["img_0003.png", "img_0001.png", "img_0002.png"] + assert m["active"] == 0 # active followed its slot + + +def test_reorder_invalid_is_noop(tmp_path): + pool.add_image(str(tmp_path), "p1", b"a", ts=1) + pool.add_image(str(tmp_path), "p1", b"b", ts=2) + # not a permutation of range(2) -> unchanged + assert [s["image"] for s in pool.reorder(str(tmp_path), "p1", [0])["slots"]] == \ + ["img_0001.png", "img_0002.png"] + assert [s["image"] for s in pool.reorder(str(tmp_path), "p1", [0, 5])["slots"]] == \ + ["img_0001.png", "img_0002.png"] diff --git a/tests/test_routes_logic.py b/tests/test_routes_logic.py index ea64c48..57f3d38 100644 --- a/tests/test_routes_logic.py +++ b/tests/test_routes_logic.py @@ -29,3 +29,11 @@ def test_handle_set_mask(tmp_path): m = handlers.handle_set_mask(base, "p1", 0, b"MASKBYTES") assert m["slots"][0]["mask"] == "img_0001.mask.png" assert (tmp_path / "p1" / "img_0001.mask.png").read_bytes() == b"MASKBYTES" + + +def test_handle_reorder(tmp_path): + base = str(tmp_path) + handlers.handle_add(base, "p1", _png_bytes(), "png", ts=1) + handlers.handle_add(base, "p1", _png_bytes(), "png", ts=2) + m = handlers.handle_reorder(base, "p1", [1, 0]) + assert [s["image"] for s in m["slots"]] == ["img_0002.png", "img_0001.png"] diff --git a/web/grid_image_pool.js b/web/grid_image_pool.js index c690734..75eefa9 100644 --- a/web/grid_image_pool.js +++ b/web/grid_image_pool.js @@ -90,6 +90,17 @@ async function removeSlot(node, index) { node.setDirtyCanvas(true, true); } +async function reorderSlots(node, from, to) { + const n = (node._slots || []).length; + if (from < 0 || from >= n || to < 0 || to >= n || from === to) return; + const order = Array.from({ length: n }, (_, k) => k); + const [moved] = order.splice(from, 1); + order.splice(to, 0, moved); + await postJson("reorder", { pool_id: getPoolId(node), order }); + await refresh(node); + node.setDirtyCanvas(true, true); +} + // ---- rendering -------------------------------------------------------------- function viewUrl(poolId, name, bust) { @@ -167,11 +178,37 @@ async function refresh(node) { const cell = document.createElement("div"); cell.className = "gip-cell" + (i === active ? " gip-active" : ""); + // drag-to-reorder: the thumbnail is the drag handle, the cell is the target + cell.ondragover = (e) => { + if (node._dragFrom == null) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + cell.classList.add("gip-drop"); + }; + cell.ondragleave = () => cell.classList.remove("gip-drop"); + cell.ondrop = (e) => { + if (node._dragFrom == null) return; + e.preventDefault(); + e.stopPropagation(); + cell.classList.remove("gip-drop"); + const from = node._dragFrom; + node._dragFrom = null; + reorderSlots(node, from, i); + }; + const thumb = document.createElement("img"); thumb.className = "gip-thumb"; thumb.src = viewUrl(poolId, slot.image, bust); thumb.title = `#${i}` + (slot.label ? ` — ${slot.label}` : ""); thumb.onclick = () => setActive(node, i); + thumb.draggable = true; + thumb.ondragstart = (e) => { + node._dragFrom = i; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(i)); + }; + thumb.ondragend = () => { node._dragFrom = null; }; cell.appendChild(thumb); // index badge @@ -436,6 +473,7 @@ function injectStyles() { .gip-cell { position:relative; width:96px; height:96px; border:2px solid transparent; border-radius:4px; overflow:hidden; background:#222; } .gip-cell.gip-active { border-color:#6cf; } + .gip-cell.gip-drop { border-color:#fc6; border-style:dashed; } .gip-thumb { width:100%; height:76px; object-fit:cover; display:block; cursor:pointer; } .gip-badge { position:absolute; top:2px; left:2px; font-size:10px; background:rgba(0,0,0,0.6); color:#fff; padding:0 4px; border-radius:3px; pointer-events:none; }