@nativedesktop/panes 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ applyTileDrop,
4
+ deserializeTiles,
5
+ emptyTiles,
6
+ findTile,
7
+ moveTile,
8
+ parseTileDrag,
9
+ placeTile,
10
+ raiseTile,
11
+ removeTile,
12
+ resizeTile,
13
+ seedTiles,
14
+ serializeTiles,
15
+ tileCellAt,
16
+ tileDragPayload,
17
+ tileRows,
18
+ updateTile,
19
+ } from "./tiles.ts";
20
+ import type { TileModel } from "./tiles.ts";
21
+
22
+ const isString = (d: unknown): d is string => typeof d === "string";
23
+
24
+ /** Two 2x2 tiles side by side on a 4-column grid, plus one below them. */
25
+ function base(): TileModel<string> {
26
+ return seedTiles(
27
+ [
28
+ { x: 0, y: 0, w: 2, h: 2, data: "a" },
29
+ { x: 2, y: 0, w: 2, h: 2, data: "b" },
30
+ { x: 0, y: 2, w: 4, h: 1, data: "c" },
31
+ ],
32
+ 4,
33
+ );
34
+ }
35
+
36
+ function boxes(m: TileModel<string>): string[] {
37
+ return m.tiles.map((t) => `${t.id}@${t.x},${t.y} ${t.w}x${t.h}`);
38
+ }
39
+
40
+ describe("placeTile", () => {
41
+ test("mints sequential ids and keeps what fits where it was asked for", () => {
42
+ expect(boxes(base())).toEqual(["1@0,0 2x2", "2@2,0 2x2", "3@0,2 4x1"]);
43
+ expect(base().nextId).toBe(4);
44
+ });
45
+
46
+ test("the new tile keeps its cells and pushes the occupant down", () => {
47
+ const m = placeTile(base(), { x: 0, y: 0, w: 4, h: 1, data: "d" });
48
+ expect(boxes(m)).toEqual(["1@0,1 2x2", "2@2,1 2x2", "3@0,3 4x1", "4@0,0 4x1"]);
49
+ });
50
+
51
+ test("cells are clamped to the grid and to whole rows and columns", () => {
52
+ const m = placeTile(emptyTiles<string>(4), { x: 9, y: -3, w: 99, h: 0.4, data: "a" });
53
+ expect(boxes(m)).toEqual(["1@0,0 4x1"]);
54
+ expect(boxes(placeTile(emptyTiles<string>(4), { x: 3, y: 0, w: 2, h: 1, data: "a" }))).toEqual(["1@2,0 2x1"]);
55
+ expect(boxes(placeTile(emptyTiles<string>(4), { x: NaN, y: NaN, w: NaN, h: NaN, data: "a" }))).toEqual([
56
+ "1@0,0 1x1",
57
+ ]);
58
+ });
59
+
60
+ test("an explicit id advances nextId past it; a duplicate id is a no-op", () => {
61
+ const m = placeTile(emptyTiles<string>(4), { id: "7", x: 0, y: 0, w: 1, h: 1, data: "a" });
62
+ expect(m.nextId).toBe(8);
63
+ expect(placeTile(m, { id: "7", x: 2, y: 0, w: 1, h: 1, data: "b" })).toBe(m);
64
+ });
65
+ });
66
+
67
+ describe("moveTile", () => {
68
+ test("pushes the tiles it lands on down, cascading", () => {
69
+ // "1" onto "2"'s cells: 2 goes below it, and c below that.
70
+ const m = moveTile(base(), "1", 2, 0);
71
+ expect(boxes(m)).toEqual(["1@2,0 2x2", "2@2,2 2x2", "3@0,4 4x1"]);
72
+ });
73
+
74
+ test("tiles that did not move keep their object identity", () => {
75
+ const m = base();
76
+ const moved = moveTile(m, "3", 0, 3);
77
+ expect(moved.tiles[0]).toBe(m.tiles[0]);
78
+ expect(moved.tiles[1]).toBe(m.tiles[1]);
79
+ expect(moved.tiles[2]).not.toBe(m.tiles[2]);
80
+ });
81
+
82
+ test("no upward compaction: a hole stays a hole", () => {
83
+ const m = removeTile(base(), "1");
84
+ expect(boxes(m)).toEqual(["2@2,0 2x2", "3@0,2 4x1"]);
85
+ expect(boxes(moveTile(m, "3", 0, 6))).toEqual(["2@2,0 2x2", "3@0,6 4x1"]);
86
+ });
87
+
88
+ test("same cells, unknown id and an out-of-range move that clamps back are same-reference", () => {
89
+ const m = base();
90
+ expect(moveTile(m, "1", 0, 0)).toBe(m);
91
+ expect(moveTile(m, "1", -5, -5)).toBe(m);
92
+ expect(moveTile(m, "zzz", 1, 1)).toBe(m);
93
+ });
94
+ });
95
+
96
+ describe("resizeTile", () => {
97
+ test("growing over a neighbour pushes it down", () => {
98
+ const m = resizeTile(base(), "1", 4, 2);
99
+ expect(boxes(m)).toEqual(["1@0,0 4x2", "2@2,2 2x2", "3@0,4 4x1"]);
100
+ });
101
+
102
+ test("a width past the grid edge pulls the tile back inside", () => {
103
+ expect(boxes(resizeTile(base(), "2", 4, 2))).toEqual(["1@0,2 2x2", "2@0,0 4x2", "3@0,4 4x1"]);
104
+ });
105
+
106
+ test("same size and unknown id are same-reference", () => {
107
+ const m = base();
108
+ expect(resizeTile(m, "1", 2, 2)).toBe(m);
109
+ expect(resizeTile(m, "1", 0.2, -4)).not.toBe(m); // clamps to 1x1, which is a change
110
+ expect(resizeTile(m, "zzz", 1, 1)).toBe(m);
111
+ });
112
+ });
113
+
114
+ describe("raiseTile", () => {
115
+ test("moves the tile last in layout order without moving any cells", () => {
116
+ const m = raiseTile(base(), "1");
117
+ expect(m.tiles.map((t) => t.id)).toEqual(["2", "3", "1"]);
118
+ expect(boxes(m).sort()).toEqual(boxes(base()).sort());
119
+ });
120
+
121
+ test("already last, or unknown, is same-reference", () => {
122
+ const m = base();
123
+ expect(raiseTile(m, "3")).toBe(m);
124
+ expect(raiseTile(m, "zzz")).toBe(m);
125
+ });
126
+ });
127
+
128
+ describe("removeTile / updateTile", () => {
129
+ test("removeTile drops the tile; unknown id is same-reference", () => {
130
+ const m = base();
131
+ expect(removeTile(m, "2").tiles.map((t) => t.id)).toEqual(["1", "3"]);
132
+ expect(removeTile(m, "zzz")).toBe(m);
133
+ });
134
+
135
+ test("updateTile replaces data; identical data is same-reference", () => {
136
+ const m = base();
137
+ const updated = updateTile(m, "1", (d) => `${d}!`);
138
+ expect(findTile(updated, "1")!.data).toBe("a!");
139
+ expect(updateTile(updated, "1", (d) => d)).toBe(updated);
140
+ expect(updateTile(updated, "zzz", (d) => d)).toBe(updated);
141
+ });
142
+ });
143
+
144
+ describe("serializeTiles / deserializeTiles", () => {
145
+ test("round-trips a layout through JSON", () => {
146
+ const m = raiseTile(moveTile(base(), "1", 2, 0), "3");
147
+ const back = deserializeTiles<string>(JSON.parse(JSON.stringify(serializeTiles(m))), 4, isString);
148
+ expect(back).toEqual(m);
149
+ });
150
+
151
+ test("serializeTiles is a deep copy, not a view of the live model", () => {
152
+ const m = base();
153
+ const snapshot = serializeTiles(m);
154
+ expect(boxes(moveTile(m, "1", 2, 0))[0]).toBe("1@2,0 2x2");
155
+ expect(findTile(snapshot, "1")).toEqual({ id: "1", x: 0, y: 0, w: 2, h: 2, data: "a" });
156
+ });
157
+
158
+ test("malformed input falls back to the empty layout on the given columns", () => {
159
+ const empty = emptyTiles<string>(6);
160
+ expect(deserializeTiles<string>(null, 6, isString)).toEqual(empty);
161
+ expect(deserializeTiles<string>({ tiles: "nope" }, 6, isString)).toEqual(empty);
162
+ expect(deserializeTiles<string>({ tiles: [{ x: 0, y: 0, w: 1, h: 1, data: "a" }] }, 6, isString)).toEqual(empty);
163
+ expect(deserializeTiles<string>({ tiles: [{ id: "1", x: "0", y: 0, w: 1, h: 1, data: "a" }] }, 6, isString)).toEqual(
164
+ empty,
165
+ );
166
+ expect(deserializeTiles<string>({ tiles: [{ id: "1", x: 0, y: 0, w: 1, h: 1, data: 42 }] }, 6, isString)).toEqual(
167
+ empty,
168
+ );
169
+ });
170
+
171
+ test("a duplicate id rejects the layout", () => {
172
+ const raw = {
173
+ columns: 4,
174
+ tiles: [
175
+ { id: "1", x: 0, y: 0, w: 1, h: 1, data: "a" },
176
+ { id: "1", x: 1, y: 0, w: 1, h: 1, data: "b" },
177
+ ],
178
+ nextId: 2,
179
+ };
180
+ expect(deserializeTiles<string>(raw, 4, isString)).toEqual(emptyTiles<string>(4));
181
+ });
182
+
183
+ test("a hand-edited overlapping layout is pushed apart on load, and nextId lands past every id", () => {
184
+ const raw = {
185
+ columns: 4,
186
+ tiles: [
187
+ { id: "1", x: 0, y: 0, w: 2, h: 2, data: "a" },
188
+ { id: "9", x: 0, y: 0, w: 2, h: 2, data: "b" },
189
+ ],
190
+ nextId: 1,
191
+ };
192
+ const m = deserializeTiles<string>(raw, 4, isString);
193
+ expect(boxes(m)).toEqual(["1@0,0 2x2", "9@0,2 2x2"]);
194
+ expect(m.nextId).toBe(10);
195
+ });
196
+ });
197
+
198
+ describe("tile drag payloads", () => {
199
+ test("round-trip, and anything foreign is not a tile drag", () => {
200
+ expect(parseTileDrag(tileDragPayload("3"))).toBe("3");
201
+ for (const foreign of ["", "3", "nd-tiles:tile:", "nd-dock:tab:3"]) {
202
+ expect(parseTileDrag(foreign)).toBeUndefined();
203
+ }
204
+ });
205
+ });
206
+
207
+ describe("tileCellAt", () => {
208
+ // base() occupies 4 columns and 3 rows, so a 400x300 grid is 100px cells.
209
+ const size = { width: 400, height: 300 };
210
+
211
+ test("a point maps to the cell it lands in", () => {
212
+ const m = base();
213
+ expect(tileRows(m)).toBe(3);
214
+ expect(tileCellAt(m, size, 0, 0)).toEqual({ column: 0, row: 0 });
215
+ expect(tileCellAt(m, size, 250, 150)).toEqual({ column: 2, row: 1 });
216
+ expect(tileCellAt(m, size, 399, 299)).toEqual({ column: 3, row: 2 });
217
+ });
218
+
219
+ test("cell boundaries belong to the cell they open", () => {
220
+ const m = base();
221
+ expect(tileCellAt(m, size, 99, 99)).toEqual({ column: 0, row: 0 });
222
+ expect(tileCellAt(m, size, 100, 100)).toEqual({ column: 1, row: 1 });
223
+ });
224
+
225
+ test("a point outside the grid clamps to its edge column, and a degenerate size is cell 0", () => {
226
+ const m = base();
227
+ expect(tileCellAt(m, size, -20, -20)).toEqual({ column: 0, row: 0 });
228
+ expect(tileCellAt(m, size, 900, 150)).toEqual({ column: 3, row: 1 });
229
+ expect(tileCellAt(m, { width: 0, height: 0 }, 40, 40)).toEqual({ column: 0, row: 0 });
230
+ expect(tileCellAt(m, { width: Number.NaN, height: 300 }, 40, 40)).toEqual({ column: 0, row: 0 });
231
+ });
232
+ });
233
+
234
+ describe("applyTileDrop", () => {
235
+ const size = { width: 400, height: 300 };
236
+
237
+ test("the cell under the pointer becomes the tile's top-left", () => {
238
+ // Tile 3 (row 2, full width) dropped on column 2 of row 0: it takes those
239
+ // cells and pushes tiles 1 and 2 down past it.
240
+ const m = applyTileDrop(base(), tileDragPayload("3"), size, 250, 40);
241
+ expect(boxes(m)).toEqual(["1@0,1 2x2", "2@2,1 2x2", "3@0,0 4x1"]);
242
+ });
243
+
244
+ test("a tile dropped inside a neighbour lands on that neighbour's origin cell", () => {
245
+ const m = applyTileDrop(base(), tileDragPayload("1"), size, 250, 40);
246
+ expect(findTile(m, "1")).toMatchObject({ x: 2, y: 0 });
247
+ });
248
+
249
+ test("a tile dropped back on the cell it already occupies is the same model", () => {
250
+ const m = base();
251
+ // Anywhere inside tile 2's own 2x2 block resolves to a cell it already
252
+ // covers, and clamping lands it back on its own origin.
253
+ expect(applyTileDrop(m, tileDragPayload("2"), size, 250, 40)).toBe(m);
254
+ expect(applyTileDrop(m, tileDragPayload("2"), size, 210, 10)).toBe(m);
255
+ });
256
+
257
+ test("a foreign drag and an unknown tile are ignored", () => {
258
+ const m = base();
259
+ expect(applyTileDrop(m, "https://example.com", size, 250, 40)).toBe(m);
260
+ expect(applyTileDrop(m, tileDragPayload("gone"), size, 250, 40)).toBe(m);
261
+ });
262
+ });
package/src/tiles.ts ADDED
@@ -0,0 +1,285 @@
1
+ // Tiles model: freeform panels on a column grid, rendered through the
2
+ // existing <grid> widget (x/y/w/h map straight onto the gridColumn/gridRow/
3
+ // gridColumnSpan/gridRowSpan attached props). No schema change, no ABI change.
4
+ //
5
+ // Collision handling is the standard grid-layout one: the tile you just
6
+ // touched keeps the cells it asked for and every tile it overlaps is pushed
7
+ // down until it clears, cascading. There is no upward compaction, so a tile
8
+ // left at row 8 stays at row 8 and the layout is exactly what the caller
9
+ // placed.
10
+ //
11
+ // Same invariant as model.ts: an op that changes nothing returns the SAME
12
+ // reference, and every tile the op did not move keeps its object identity so
13
+ // a re-render diffs down to the tiles that actually moved.
14
+
15
+ export interface Tile<T> {
16
+ id: string;
17
+ /** Column of the tile's leading edge, 0-based. */
18
+ x: number;
19
+ /** Row of the tile's top edge, 0-based. */
20
+ y: number;
21
+ /** Width in columns, at least 1. */
22
+ w: number;
23
+ /** Height in rows, at least 1. */
24
+ h: number;
25
+ data: T;
26
+ }
27
+
28
+ export interface TileModel<T> {
29
+ /** Column count the model clamps against. The native grid still sizes its
30
+ * columns from the children, so this bounds placement, not pixels. */
31
+ columns: number;
32
+ /** Layout order, which is also paint order: later tiles draw over earlier
33
+ * ones where the platform lets cells overlap. `raiseTile` moves one last. */
34
+ tiles: Tile<T>[];
35
+ nextId: number;
36
+ }
37
+
38
+ export interface TilePlacement<T> {
39
+ id?: string;
40
+ x: number;
41
+ y: number;
42
+ w: number;
43
+ h: number;
44
+ data: T;
45
+ }
46
+
47
+ /** Grid geometry is integer cells, and a non-finite span would panic a
48
+ * Debug/ReleaseSafe host on the attached-prop write, so this is a safety
49
+ * boundary like clampPaneRatio: everything rounds to an integer, spans floor
50
+ * at 1, and a tile is kept inside the column count. */
51
+ function clampColumns(columns: number): number {
52
+ if (!Number.isFinite(columns)) return 1;
53
+ return Math.max(1, Math.trunc(columns));
54
+ }
55
+
56
+ function cell(value: number, fallback: number): number {
57
+ return Number.isFinite(value) ? Math.round(value) : fallback;
58
+ }
59
+
60
+ function clampTile<T>(tile: Tile<T>, columns: number): Tile<T> {
61
+ const w = Math.min(columns, Math.max(1, cell(tile.w, 1)));
62
+ const h = Math.max(1, cell(tile.h, 1));
63
+ const x = Math.min(columns - w, Math.max(0, cell(tile.x, 0)));
64
+ const y = Math.max(0, cell(tile.y, 0));
65
+ if (x === tile.x && y === tile.y && w === tile.w && h === tile.h) return tile;
66
+ return { ...tile, x, y, w, h };
67
+ }
68
+
69
+ function overlaps<T>(a: Tile<T>, b: Tile<T>): boolean {
70
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
71
+ }
72
+
73
+ /** Pushes every tile that collides with an already-placed one down past it.
74
+ * `anchorId` is placed first, so the tile the caller just moved or resized is
75
+ * the one that wins its cells. Terminates because each push strictly
76
+ * increases a tile's y and heights are at least 1. */
77
+ function resolveOverlaps<T>(tiles: readonly Tile<T>[], anchorId: string): Tile<T>[] {
78
+ const order = tiles.map((tile, index) => ({ tile, index }));
79
+ order.sort((a, b) => a.tile.y - b.tile.y || a.tile.x - b.tile.x || a.index - b.index);
80
+ const anchor = order.findIndex((entry) => entry.tile.id === anchorId);
81
+ if (anchor > 0) order.unshift(order.splice(anchor, 1)[0]!);
82
+
83
+ const placed: Tile<T>[] = [];
84
+ const out = tiles.slice();
85
+ for (const entry of order) {
86
+ let current = entry.tile;
87
+ for (;;) {
88
+ const hit = placed.find((p) => overlaps(p, current));
89
+ if (!hit) break;
90
+ current = { ...current, y: hit.y + hit.h };
91
+ }
92
+ placed.push(current);
93
+ out[entry.index] = current;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /** The model, or the one it was given when resolution moved nothing. */
99
+ function commit<T>(m: TileModel<T>, tiles: Tile<T>[], nextId = m.nextId): TileModel<T> {
100
+ const changed = tiles.length !== m.tiles.length || tiles.some((tile, i) => tile !== m.tiles[i]);
101
+ if (!changed && nextId === m.nextId) return m;
102
+ return { columns: m.columns, tiles, nextId };
103
+ }
104
+
105
+ function numericId(id: string): number {
106
+ const match = /^(\d+)$/.exec(id);
107
+ return match ? Number(match[1]) : 0;
108
+ }
109
+
110
+ export function emptyTiles<T>(columns: number): TileModel<T> {
111
+ return { columns: clampColumns(columns), tiles: [], nextId: 1 };
112
+ }
113
+
114
+ /** Places `items` in order, each one dropped at its requested cell and pushed
115
+ * clear of what is already down. Ids default to "1".."n". */
116
+ export function seedTiles<T>(items: readonly TilePlacement<T>[], columns: number): TileModel<T> {
117
+ let model = emptyTiles<T>(columns);
118
+ for (const item of items) model = placeTile(model, item);
119
+ return model;
120
+ }
121
+
122
+ export function findTile<T>(m: TileModel<T>, id: string): Tile<T> | undefined {
123
+ return m.tiles.find((tile) => tile.id === id);
124
+ }
125
+
126
+ /** Adds a tile at the cells it asks for, pushing whatever was there down. An
127
+ * id already in the model is rejected (same reference), the way addTab
128
+ * rejects a duplicate tab id. */
129
+ export function placeTile<T>(m: TileModel<T>, placement: TilePlacement<T>): TileModel<T> {
130
+ const id = placement.id ?? String(m.nextId);
131
+ if (findTile(m, id)) return m;
132
+ const tile = clampTile({ id, x: placement.x, y: placement.y, w: placement.w, h: placement.h, data: placement.data }, m.columns);
133
+ const tiles = resolveOverlaps([...m.tiles, tile], id);
134
+ return commit(m, tiles, Math.max(m.nextId + (placement.id === undefined ? 1 : 0), numericId(id) + 1));
135
+ }
136
+
137
+ export function removeTile<T>(m: TileModel<T>, id: string): TileModel<T> {
138
+ if (!findTile(m, id)) return m;
139
+ return commit(m, m.tiles.filter((tile) => tile.id !== id));
140
+ }
141
+
142
+ export function moveTile<T>(m: TileModel<T>, id: string, x: number, y: number): TileModel<T> {
143
+ const tile = findTile(m, id);
144
+ if (!tile) return m;
145
+ const moved = clampTile({ ...tile, x, y }, m.columns);
146
+ if (moved.x === tile.x && moved.y === tile.y) return m;
147
+ return commit(m, resolveOverlaps(m.tiles.map((t) => (t.id === id ? moved : t)), id));
148
+ }
149
+
150
+ export function resizeTile<T>(m: TileModel<T>, id: string, w: number, h: number): TileModel<T> {
151
+ const tile = findTile(m, id);
152
+ if (!tile) return m;
153
+ const sized = clampTile({ ...tile, w, h }, m.columns);
154
+ if (sized.w === tile.w && sized.h === tile.h && sized.x === tile.x) return m;
155
+ return commit(m, resolveOverlaps(m.tiles.map((t) => (t.id === id ? sized : t)), id));
156
+ }
157
+
158
+ /** Moves a tile last in layout order, which is the top of the paint order. */
159
+ export function raiseTile<T>(m: TileModel<T>, id: string): TileModel<T> {
160
+ const index = m.tiles.findIndex((tile) => tile.id === id);
161
+ if (index < 0 || index === m.tiles.length - 1) return m;
162
+ const tiles = m.tiles.slice();
163
+ tiles.push(tiles.splice(index, 1)[0]!);
164
+ return { columns: m.columns, tiles, nextId: m.nextId };
165
+ }
166
+
167
+ export function updateTile<T>(m: TileModel<T>, id: string, fn: (data: T) => T): TileModel<T> {
168
+ const tile = findTile(m, id);
169
+ if (!tile) return m;
170
+ const data = fn(tile.data);
171
+ if (Object.is(data, tile.data)) return m;
172
+ return commit(m, m.tiles.map((t) => (t.id === id ? { ...t, data } : t)));
173
+ }
174
+
175
+ // ============================================================================
176
+ // Drag and drop
177
+ // ============================================================================
178
+
179
+ /** Pixel extent of the grid the tiles are laid out in. A drop reports a point
180
+ * in the grid's own coordinate space and the native grid never reports its
181
+ * geometry, so this is what turns that point back into a cell. Feed it from
182
+ * whatever sizes the grid. Without it a drop is ignored. */
183
+ export interface TileSize {
184
+ width: number;
185
+ height: number;
186
+ }
187
+
188
+ export interface TileCell {
189
+ column: number;
190
+ row: number;
191
+ }
192
+
193
+ const DRAG_PREFIX = "nd-tiles:tile:";
194
+
195
+ /** The `dragPayload` a tile carries, namespaced for the same reason
196
+ * dockDragPayload is: text dragged in from another application arrives on the
197
+ * same drop handler and must not be read as a tile id. */
198
+ export function tileDragPayload(id: string): string {
199
+ return `${DRAG_PREFIX}${id}`;
200
+ }
201
+
202
+ export function parseTileDrag(payload: string): string | undefined {
203
+ if (!payload.startsWith(DRAG_PREFIX)) return undefined;
204
+ const id = payload.slice(DRAG_PREFIX.length);
205
+ return id.length > 0 ? id : undefined;
206
+ }
207
+
208
+ /** Row count the layout currently occupies, at least 1. The native grid sizes
209
+ * its rows from their children, so this is what a uniform-cell mapping has to
210
+ * divide by. */
211
+ export function tileRows<T>(m: TileModel<T>): number {
212
+ let rows = 1;
213
+ for (const tile of m.tiles) rows = Math.max(rows, tile.y + tile.h);
214
+ return rows;
215
+ }
216
+
217
+ /** Cell under a point in the grid's coordinate space. Cells are taken as
218
+ * uniform, which is what the grid draws when every tile expands; a layout of
219
+ * mixed intrinsic heights drifts from this by whatever the row heights
220
+ * differ. Points outside the grid clamp to its edge cells. */
221
+ export function tileCellAt<T>(m: TileModel<T>, size: TileSize, x: number, y: number): TileCell {
222
+ const columns = Math.max(1, m.columns);
223
+ const rows = tileRows(m);
224
+ const width = Number.isFinite(size.width) ? size.width : 0;
225
+ const height = Number.isFinite(size.height) ? size.height : 0;
226
+ const px = Number.isFinite(x) ? x : 0;
227
+ const py = Number.isFinite(y) ? y : 0;
228
+ const column = width > 0 ? Math.floor((px / width) * columns) : 0;
229
+ const row = height > 0 ? Math.floor((py / height) * rows) : 0;
230
+ return {
231
+ column: Math.min(columns - 1, Math.max(0, column)),
232
+ row: Math.max(0, row),
233
+ };
234
+ }
235
+
236
+ /** Turns one drop into one model op: the cell under the pointer becomes the
237
+ * dragged tile's top-left, and `moveTile` pushes whatever it lands on clear.
238
+ * A tile dropped back on the cell it already occupies returns the SAME
239
+ * reference, as does a payload from anything but a tile. */
240
+ export function applyTileDrop<T>(m: TileModel<T>, payload: string, size: TileSize, x: number, y: number): TileModel<T> {
241
+ const id = parseTileDrag(payload);
242
+ if (id === undefined || !findTile(m, id)) return m;
243
+ const cell = tileCellAt(m, size, x, y);
244
+ return moveTile(m, id, cell.column, cell.row);
245
+ }
246
+
247
+ /** JSON-safe deep copy, same contract as serializeDock. */
248
+ export function serializeTiles<T>(m: TileModel<T>): TileModel<T> {
249
+ return { columns: m.columns, tiles: m.tiles.map((tile) => ({ ...tile })), nextId: m.nextId };
250
+ }
251
+
252
+ /** Rebuilds a tile layout from persisted unknown data. Anything malformed (a
253
+ * missing id, a non-numeric cell, a duplicate id, data failing `isData`)
254
+ * rejects the whole layout back to emptyTiles(columns); `columns` is also the
255
+ * fallback when the persisted column count is unusable. Loaded tiles are
256
+ * clamped and pushed clear of each other, so a hand-edited file cannot land
257
+ * an overlapping layout. */
258
+ export function deserializeTiles<T>(raw: unknown, columns: number, isData?: (d: unknown) => d is T): TileModel<T> {
259
+ const fallback = emptyTiles<T>(columns);
260
+ if (typeof raw !== "object" || raw === null) return fallback;
261
+ const candidate = raw as { columns?: unknown; tiles?: unknown; nextId?: unknown };
262
+ if (!Array.isArray(candidate.tiles)) return fallback;
263
+ const width = typeof candidate.columns === "number" ? clampColumns(candidate.columns) : fallback.columns;
264
+ const seen = new Set<string>();
265
+ const tiles: Tile<T>[] = [];
266
+ let maxNumericId = 0;
267
+ for (const entry of candidate.tiles) {
268
+ if (typeof entry !== "object" || entry === null) return fallback;
269
+ const t = entry as { id?: unknown; x?: unknown; y?: unknown; w?: unknown; h?: unknown; data?: unknown };
270
+ if (typeof t.id !== "string" || t.id.length === 0 || seen.has(t.id)) return fallback;
271
+ if (typeof t.x !== "number" || typeof t.y !== "number" || typeof t.w !== "number" || typeof t.h !== "number") {
272
+ return fallback;
273
+ }
274
+ if (isData && !isData(t.data)) return fallback;
275
+ seen.add(t.id);
276
+ maxNumericId = Math.max(maxNumericId, numericId(t.id));
277
+ tiles.push(clampTile({ id: t.id, x: t.x, y: t.y, w: t.w, h: t.h, data: t.data as T }, width));
278
+ }
279
+ const rawNextId = typeof candidate.nextId === "number" && Number.isInteger(candidate.nextId) ? candidate.nextId : 1;
280
+ return {
281
+ columns: width,
282
+ tiles: tiles.length > 0 ? resolveOverlaps(tiles, tiles[0]!.id) : tiles,
283
+ nextId: Math.max(rawNextId, maxNumericId + 1, 1),
284
+ };
285
+ }