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 <noreply@anthropic.com>
This commit is contained in:
@@ -22,5 +22,9 @@ def handle_list(base, pool_id):
|
|||||||
return pool.read_manifest(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):
|
def handle_set_mask(base, pool_id, index, mask_png_bytes):
|
||||||
return pool.set_mask(base, pool_id, index, mask_png_bytes) # Task 12
|
return pool.set_mask(base, pool_id, index, mask_png_bytes) # Task 12
|
||||||
|
|||||||
@@ -112,6 +112,20 @@ def set_label(base_dir, pool_id, index, label):
|
|||||||
return m
|
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):
|
def set_mask(base_dir, pool_id, index, mask_bytes):
|
||||||
m = read_manifest(base_dir, pool_id)
|
m = read_manifest(base_dir, pool_id)
|
||||||
if not (0 <= index < len(m["slots"])):
|
if not (0 <= index < len(m["slots"])):
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ async def _set_mask(request):
|
|||||||
return web.json_response(m)
|
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")
|
@routes.get("/grid_pool/list")
|
||||||
async def _list(request):
|
async def _list(request):
|
||||||
pool_id = request.query.get("pool_id", "default")
|
pool_id = request.query.get("pool_id", "default")
|
||||||
|
|||||||
@@ -131,3 +131,23 @@ def test_set_mask_writes_sidecar(tmp_path):
|
|||||||
def test_set_mask_out_of_range_noop(tmp_path):
|
def test_set_mask_out_of_range_noop(tmp_path):
|
||||||
m = pool.set_mask(str(tmp_path), "p1", 0, b"x")
|
m = pool.set_mask(str(tmp_path), "p1", 0, b"x")
|
||||||
assert m["slots"] == []
|
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"]
|
||||||
|
|||||||
@@ -29,3 +29,11 @@ def test_handle_set_mask(tmp_path):
|
|||||||
m = handlers.handle_set_mask(base, "p1", 0, b"MASKBYTES")
|
m = handlers.handle_set_mask(base, "p1", 0, b"MASKBYTES")
|
||||||
assert m["slots"][0]["mask"] == "img_0001.mask.png"
|
assert m["slots"][0]["mask"] == "img_0001.mask.png"
|
||||||
assert (tmp_path / "p1" / "img_0001.mask.png").read_bytes() == b"MASKBYTES"
|
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"]
|
||||||
|
|||||||
@@ -90,6 +90,17 @@ async function removeSlot(node, index) {
|
|||||||
node.setDirtyCanvas(true, true);
|
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 --------------------------------------------------------------
|
// ---- rendering --------------------------------------------------------------
|
||||||
|
|
||||||
function viewUrl(poolId, name, bust) {
|
function viewUrl(poolId, name, bust) {
|
||||||
@@ -167,11 +178,37 @@ async function refresh(node) {
|
|||||||
const cell = document.createElement("div");
|
const cell = document.createElement("div");
|
||||||
cell.className = "gip-cell" + (i === active ? " gip-active" : "");
|
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");
|
const thumb = document.createElement("img");
|
||||||
thumb.className = "gip-thumb";
|
thumb.className = "gip-thumb";
|
||||||
thumb.src = viewUrl(poolId, slot.image, bust);
|
thumb.src = viewUrl(poolId, slot.image, bust);
|
||||||
thumb.title = `#${i}` + (slot.label ? ` — ${slot.label}` : "");
|
thumb.title = `#${i}` + (slot.label ? ` — ${slot.label}` : "");
|
||||||
thumb.onclick = () => setActive(node, i);
|
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);
|
cell.appendChild(thumb);
|
||||||
|
|
||||||
// index badge
|
// index badge
|
||||||
@@ -436,6 +473,7 @@ function injectStyles() {
|
|||||||
.gip-cell { position:relative; width:96px; height:96px; border:2px solid transparent;
|
.gip-cell { position:relative; width:96px; height:96px; border:2px solid transparent;
|
||||||
border-radius:4px; overflow:hidden; background:#222; }
|
border-radius:4px; overflow:hidden; background:#222; }
|
||||||
.gip-cell.gip-active { border-color:#6cf; }
|
.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-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);
|
.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; }
|
color:#fff; padding:0 4px; border-radius:3px; pointer-events:none; }
|
||||||
|
|||||||
Reference in New Issue
Block a user