From d3c2e0689e8da10dda0724d0392e5df8d92044ad Mon Sep 17 00:00:00 2001 From: Patolord Date: Tue, 28 Jul 2026 21:19:44 -0300 Subject: [PATCH 1/2] Add drag-and-drop layer reordering Keep floor contents and rendering synchronized when users rearrange the layer stack. Co-authored-by: Cursor --- src/state/actions.ts | 53 +++++++++++++++ src/styles.css | 30 ++++++++- src/ui/layersPanel.ts | 134 +++++++++++++++++++++++++++++++++++++- test/reorderLayer.test.ts | 86 ++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 test/reorderLayer.test.ts diff --git a/src/state/actions.ts b/src/state/actions.ts index 6afc1ae..595bd8f 100644 --- a/src/state/actions.ts +++ b/src/state/actions.ts @@ -493,6 +493,59 @@ export function deleteLayer(index: number, mode: "drop" | "purge" = "drop"): voi bumpRevision(); } +/** Remap a floor index after moving the floor at `from` to `to`. */ +function remapLayerIndex(old: number, from: number, to: number): number { + if (old === from) return to; + if (from < to) { + if (old > from && old <= to) return old - 1; + } else if (from > to) { + if (old >= to && old < from) return old + 1; + } + return old; +} + +/** + * Move floor `fromIndex` to `toIndex` in the named-floor stack. Shapes and + * free-floating edges on every remapped floor follow; the active floor highlight + * tracks the moved entry. Paint/elevation update immediately. + */ +export function reorderLayer(fromIndex: number, toIndex: number): void { + const count = floorCount(doc.board); + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= count || + toIndex >= count + ) { + return; + } + + const layers = materializeLayers(); + const [moved] = layers.splice(fromIndex, 1); + layers.splice(toIndex, 0, moved); + + for (const s of Object.values(doc.board.shapes)) { + const cur = s.layer ?? 0; + const next = remapLayerIndex(cur, fromIndex, toIndex); + if (next === cur) continue; + s.layer = next; + scene.updateNode(s.id); + } + for (const e of Object.values(doc.board.edges)) { + if (e.from !== undefined || e.to !== undefined) continue; + const cur = e.layer ?? 0; + const next = remapLayerIndex(cur, fromIndex, toIndex); + if (next === cur) continue; + e.layer = next; + scene.updateEdge(e.id); + } + + $activeLayer.set(remapLayerIndex($activeLayer.get(), fromIndex, toIndex)); + scene.redrawBoard(); + bumpRevision(); +} + /** Move the given shapes onto floor `index`. */ export function assignSelectionToLayer(ids: Iterable, index: number): void { applyLayer(ids, () => index); diff --git a/src/styles.css b/src/styles.css index a9211d5..ee9d1ab 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1245,7 +1245,35 @@ button { color: var(--text); text-align: left; cursor: pointer; - transition: border-color 0.12s, background 0.12s, box-shadow 0.12s; + position: relative; + transition: border-color 0.12s, background 0.12s, box-shadow 0.12s, opacity 0.12s; +} +.layers-panel__item.is-draggable { + cursor: grab; +} +.layers-panel__item.is-dragging { + opacity: 0.45; + cursor: grabbing; +} +/* Drop line between floors while reordering (issue #21). */ +.layers-panel__item.is-drop-before::before, +.layers-panel__item.is-drop-after::after { + content: ""; + position: absolute; + left: 6px; + right: 6px; + height: 2px; + border-radius: 1px; + background: var(--accent); + box-shadow: 0 0 10px rgba(56, 189, 248, 0.7); + pointer-events: none; + z-index: 1; +} +.layers-panel__item.is-drop-before::before { + top: -5px; +} +.layers-panel__item.is-drop-after::after { + bottom: -5px; } .layers-panel__top { display: flex; diff --git a/src/ui/layersPanel.ts b/src/ui/layersPanel.ts index 0280ec4..47594ee 100644 --- a/src/ui/layersPanel.ts +++ b/src/ui/layersPanel.ts @@ -53,6 +53,33 @@ interface FloorRow { color?: string; } +type DropPlacement = "before" | "after"; + +interface DropTarget { + item: HTMLElement; + index: number; + placement: DropPlacement; +} + +/** Convert a high→low panel drop position into a bottom→top floor index. */ +function dropTargetFloorIndex( + floorCount: number, + fromIndex: number, + overIndex: number, + placement: DropPlacement, +): number { + const fromPosition = floorCount - 1 - fromIndex; + const overPosition = floorCount - 1 - overIndex; + let insertPosition = placement === "before" ? overPosition : overPosition + 1; + if (fromPosition < insertPosition) insertPosition--; + return floorCount - 1 - insertPosition; +} + +function dropPlacement(item: HTMLElement, event: DragEvent): DropPlacement { + const bounds = item.getBoundingClientRect(); + return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after"; +} + /** * Always-visible, right-docked panel listing every floor of the board (top→bottom). * Each row carries a show/hide eye toggle; clicking the body highlights that floor @@ -71,6 +98,10 @@ export class LayersPanel { /** a refresh requested while collapsed is deferred until the panel is shown again */ private dirty = false; private unsubs: Array<() => void> = []; + /** floor index being dragged, or null when idle */ + private dragFrom: number | null = null; + /** last valid insertion point; used when the browser ends a drag without firing drop */ + private dragTarget: DropTarget | null = null; constructor( editor: HTMLElement, @@ -104,7 +135,10 @@ export class LayersPanel { }, ); - this.list = h("div", { class: "layers-panel__list" }); + this.list = h("div", { + class: "layers-panel__list", + ondragover: (e: DragEvent) => this.dragOver(e), + }); // Floor-spread dial: scales the world-up gap between stacked floors so a // layered board is easier to read. Mirrors (and is mirrored by) the @@ -266,6 +300,77 @@ export class LayersPanel { $activeLayer.set(i); } + private clearDropIndicators(): void { + for (const el of this.list.querySelectorAll( + ".layers-panel__item.is-drop-before, .layers-panel__item.is-drop-after", + )) { + el.classList.remove("is-drop-before", "is-drop-after"); + } + } + + /** Resolve row and insertion side even when the pointer is in the gap between rows. */ + private dropTarget(event: DragEvent): DropTarget | null { + const items = [...this.list.querySelectorAll(".layers-panel__item")]; + if (!items.length) return null; + + const eventTarget = event.target instanceof Element + ? event.target.closest(".layers-panel__item") + : null; + let item = eventTarget && this.list.contains(eventTarget) ? eventTarget : null; + let placement: DropPlacement; + + if (item) { + placement = dropPlacement(item, event); + } else { + item = items.find((candidate) => event.clientY < candidate.getBoundingClientRect().top) + ?? items[items.length - 1]; + placement = item === items[items.length - 1] && event.clientY >= item.getBoundingClientRect().top + ? "after" + : "before"; + } + + const index = Number(item.dataset.layerIndex); + return Number.isInteger(index) ? { item, index, placement } : null; + } + + private dragOver(event: DragEvent): void { + const target = this.dropTarget(event); + if (this.dragFrom == null || !target || this.dragFrom === target.index) { + this.dragTarget = null; + this.clearDropIndicators(); + return; + } + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + this.dragTarget = target; + this.clearDropIndicators(); + target.item.classList.add( + target.placement === "before" ? "is-drop-before" : "is-drop-after", + ); + } + + private finishDrag(event: DragEvent): void { + const from = this.dragFrom; + let target = this.dragTarget; + const total = this.list.children.length; + if (event.clientX || event.clientY) { + const endTarget = document.elementFromPoint(event.clientX, event.clientY); + if (!endTarget || !this.list.contains(endTarget)) target = null; + } + this.dragFrom = null; + this.dragTarget = null; + this.list.querySelector(".layers-panel__item.is-dragging")?.classList.remove("is-dragging"); + this.clearDropIndicators(); + if (from == null || !target || from === target.index) return; + const to = dropTargetFloorIndex( + total, + from, + target.index, + target.placement, + ); + if (to !== from) actions.reorderLayer(from, to); + } + private row( row: FloorRow, active: number, @@ -392,13 +497,18 @@ export class LayersPanel { "🗑", ); - return h( + const item = h( "div", { - class: row.hidden ? "layers-panel__item is-hidden" : "layers-panel__item", + class: + (row.hidden ? "layers-panel__item is-hidden" : "layers-panel__item") + + (total > 1 ? " is-draggable" : ""), role: "button", tabindex: "0", + draggable: total > 1, + "data-layer-index": String(i), "aria-current": i === active ? "true" : undefined, + title: total > 1 ? "Drag to reorder floors" : undefined, onclick: () => this.activate(i, row.hidden), ondblclick: () => { const name = prompt("Rename layer", row.name); @@ -410,6 +520,23 @@ export class LayersPanel { this.activate(i, row.hidden); } }, + ondragstart: (e: DragEvent) => { + if (total < 2) { + e.preventDefault(); + return; + } + const target = e.target as HTMLElement | null; + if (target?.closest("button, input, .layers-panel__color, .swatch")) { + e.preventDefault(); + return; + } + this.dragFrom = i; + this.dragTarget = null; + item.classList.add("is-dragging"); + e.dataTransfer?.setData("text/plain", String(i)); + if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; + }, + ondragend: (e: DragEvent) => this.finishDrag(e), }, // Top line: visibility controls + the (now full-width) floor name. h( @@ -432,5 +559,6 @@ export class LayersPanel { h("div", { class: "layers-panel__actions" }, assignBtn, renameBtn, delBtn), ), ); + return item; } } diff --git a/test/reorderLayer.test.ts b/test/reorderLayer.test.ts new file mode 100644 index 0000000..726c252 --- /dev/null +++ b/test/reorderLayer.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/render/scene", () => ({ + scene: { + addNode: vi.fn(), + addEdge: vi.fn(), + updateNode: vi.fn(), + updateEdge: vi.fn(), + removeEdge: vi.fn(), + rebuild: vi.fn(), + redrawBoard: vi.fn(), + requestRender: vi.fn(), + }, +})); + +import { + addLayer, + createFreeEdge, + createShape, + loadBoard, + reorderLayer, +} from "../src/state/actions"; +import { $activeLayer, doc } from "../src/state/store"; +import type { Board } from "../src/state/types"; + +function emptyBoard(): Board { + return { name: "t", shapes: {}, edges: {}, order: [] }; +} + +beforeEach(() => { + loadBoard(emptyBoard()); + $activeLayer.set(0); +}); + +describe("reorderLayer", () => { + it("reorders named floors and remaps shapes + free edges", () => { + addLayer(); // 1 + addLayer(); // 2 + doc.board.layers![0].name = "Ground"; + doc.board.layers![1].name = "Mid"; + doc.board.layers![2].name = "Top"; + + const ground = createShape("rect", 0, 0, 40, 40, { layer: 0 }); + const mid = createShape("rect", 0, 0, 40, 40, { layer: 1 }); + const top = createShape("rect", 0, 0, 40, 40, { layer: 2 }); + const midLine = createFreeEdge({ x1: 0, y1: 0, x2: 40, y2: 0, layer: 1 }); + const anchored = createFreeEdge({ from: top.id, to: mid.id, layer: 9 }); + + $activeLayer.set(2); + reorderLayer(2, 0); // Top → bottom of stack + + expect(doc.board.layers!.map((l) => l.name)).toEqual(["Top", "Ground", "Mid"]); + expect(doc.board.shapes[top.id].layer).toBe(0); + expect(doc.board.shapes[ground.id].layer).toBe(1); + expect(doc.board.shapes[mid.id].layer).toBe(2); + expect(doc.board.edges[midLine.id].layer).toBe(2); + expect(doc.board.edges[anchored.id].layer).toBe(9); // anchored edges keep their own field + expect($activeLayer.get()).toBe(0); // active followed the moved floor + }); + + it("moves a lower floor upward and shifts intervening floors down", () => { + addLayer(); + addLayer(); + const ground = createShape("rect", 0, 0, 40, 40, { layer: 0 }); + const mid = createShape("rect", 0, 0, 40, 40, { layer: 1 }); + const top = createShape("rect", 0, 0, 40, 40, { layer: 2 }); + + reorderLayer(0, 2); + + expect(doc.board.layers!.map((layer) => layer.name)).toEqual(["Layer 1", "Layer 2", "Ground"]); + expect(doc.board.shapes[ground.id].layer).toBe(2); + expect(doc.board.shapes[mid.id].layer).toBe(0); + expect(doc.board.shapes[top.id].layer).toBe(1); + }); + + it("does not materialize or reorder floors for invalid moves", () => { + reorderLayer(0, 0); + expect(doc.board.layers).toEqual([]); + + addLayer(); + const before = doc.board.layers!.map((l) => l.id); + reorderLayer(-1, 0); + reorderLayer(0, 99); + expect(doc.board.layers!.map((l) => l.id)).toEqual(before); + }); +}); From ef86abaa234229c5ff37a58e97d898df77fee693 Mon Sep 17 00:00:00 2001 From: Patolord Date: Tue, 28 Jul 2026 21:26:18 -0300 Subject: [PATCH 2/2] Simplify layer drag reorder handling Trim redundant drag target resolution and rely on the proven dragover-to-dragend commit path. Co-authored-by: Cursor --- src/styles.css | 2 +- src/ui/layersPanel.ts | 47 +++++++++++-------------------------------- 2 files changed, 13 insertions(+), 36 deletions(-) diff --git a/src/styles.css b/src/styles.css index ee9d1ab..e6a89bd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1248,7 +1248,7 @@ button { position: relative; transition: border-color 0.12s, background 0.12s, box-shadow 0.12s, opacity 0.12s; } -.layers-panel__item.is-draggable { +.layers-panel__item[draggable="true"] { cursor: grab; } .layers-panel__item.is-dragging { diff --git a/src/ui/layersPanel.ts b/src/ui/layersPanel.ts index 47594ee..ba3962f 100644 --- a/src/ui/layersPanel.ts +++ b/src/ui/layersPanel.ts @@ -75,11 +75,6 @@ function dropTargetFloorIndex( return floorCount - 1 - insertPosition; } -function dropPlacement(item: HTMLElement, event: DragEvent): DropPlacement { - const bounds = item.getBoundingClientRect(); - return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after"; -} - /** * Always-visible, right-docked panel listing every floor of the board (top→bottom). * Each row carries a show/hide eye toggle; clicking the body highlights that floor @@ -308,33 +303,22 @@ export class LayersPanel { } } - /** Resolve row and insertion side even when the pointer is in the gap between rows. */ - private dropTarget(event: DragEvent): DropTarget | null { + /** Resolve the insertion row even when the pointer is in the gap between rows. */ + private resolveDropTarget(event: DragEvent): DropTarget | null { const items = [...this.list.querySelectorAll(".layers-panel__item")]; if (!items.length) return null; - const eventTarget = event.target instanceof Element - ? event.target.closest(".layers-panel__item") - : null; - let item = eventTarget && this.list.contains(eventTarget) ? eventTarget : null; - let placement: DropPlacement; - - if (item) { - placement = dropPlacement(item, event); - } else { - item = items.find((candidate) => event.clientY < candidate.getBoundingClientRect().top) - ?? items[items.length - 1]; - placement = item === items[items.length - 1] && event.clientY >= item.getBoundingClientRect().top - ? "after" - : "before"; - } - + const item = items.find((candidate) => event.clientY <= candidate.getBoundingClientRect().bottom) + ?? items[items.length - 1]; + const bounds = item.getBoundingClientRect(); + const placement: DropPlacement = + event.clientY < bounds.top + bounds.height / 2 ? "before" : "after"; const index = Number(item.dataset.layerIndex); return Number.isInteger(index) ? { item, index, placement } : null; } private dragOver(event: DragEvent): void { - const target = this.dropTarget(event); + const target = this.resolveDropTarget(event); if (this.dragFrom == null || !target || this.dragFrom === target.index) { this.dragTarget = null; this.clearDropIndicators(); @@ -361,7 +345,7 @@ export class LayersPanel { this.dragTarget = null; this.list.querySelector(".layers-panel__item.is-dragging")?.classList.remove("is-dragging"); this.clearDropIndicators(); - if (from == null || !target || from === target.index) return; + if (from == null || !target) return; const to = dropTargetFloorIndex( total, from, @@ -497,12 +481,10 @@ export class LayersPanel { "🗑", ); - const item = h( + return h( "div", { - class: - (row.hidden ? "layers-panel__item is-hidden" : "layers-panel__item") + - (total > 1 ? " is-draggable" : ""), + class: row.hidden ? "layers-panel__item is-hidden" : "layers-panel__item", role: "button", tabindex: "0", draggable: total > 1, @@ -521,10 +503,6 @@ export class LayersPanel { } }, ondragstart: (e: DragEvent) => { - if (total < 2) { - e.preventDefault(); - return; - } const target = e.target as HTMLElement | null; if (target?.closest("button, input, .layers-panel__color, .swatch")) { e.preventDefault(); @@ -532,7 +510,7 @@ export class LayersPanel { } this.dragFrom = i; this.dragTarget = null; - item.classList.add("is-dragging"); + (e.currentTarget as HTMLElement).classList.add("is-dragging"); e.dataTransfer?.setData("text/plain", String(i)); if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; }, @@ -559,6 +537,5 @@ export class LayersPanel { h("div", { class: "layers-panel__actions" }, assignBtn, renameBtn, delBtn), ), ); - return item; } }