Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/state/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ID>, index: number): void {
applyLayer(ids, () => index);
Expand Down
30 changes: 29 additions & 1 deletion src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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[draggable="true"] {
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;
Expand Down
107 changes: 106 additions & 1 deletion src/ui/layersPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ 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;
}

/**
* 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
Expand All @@ -71,6 +93,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,
Expand Down Expand Up @@ -104,7 +130,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
Expand Down Expand Up @@ -266,6 +295,66 @@ 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 the insertion row even when the pointer is in the gap between rows. */
private resolveDropTarget(event: DragEvent): DropTarget | null {
const items = [...this.list.querySelectorAll<HTMLElement>(".layers-panel__item")];
if (!items.length) return null;

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.resolveDropTarget(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) return;
const to = dropTargetFloorIndex(
total,
from,
target.index,
target.placement,
);
if (to !== from) actions.reorderLayer(from, to);
}

private row(
row: FloorRow,
active: number,
Expand Down Expand Up @@ -398,7 +487,10 @@ export class LayersPanel {
class: row.hidden ? "layers-panel__item is-hidden" : "layers-panel__item",
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);
Expand All @@ -410,6 +502,19 @@ export class LayersPanel {
this.activate(i, row.hidden);
}
},
ondragstart: (e: DragEvent) => {
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;
(e.currentTarget as HTMLElement).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(
Expand Down
86 changes: 86 additions & 0 deletions test/reorderLayer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});