@griddle/core 0.1.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.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/compaction.d.ts +3 -0
- package/dist/compaction.d.ts.map +1 -0
- package/dist/compaction.js +85 -0
- package/dist/compaction.js.map +1 -0
- package/dist/drag.d.ts +52 -0
- package/dist/drag.d.ts.map +1 -0
- package/dist/drag.js +120 -0
- package/dist/drag.js.map +1 -0
- package/dist/events.d.ts +9 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +22 -0
- package/dist/events.js.map +1 -0
- package/dist/geometry.d.ts +33 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +164 -0
- package/dist/geometry.js.map +1 -0
- package/dist/grid.d.ts +100 -0
- package/dist/grid.d.ts.map +1 -0
- package/dist/grid.js +539 -0
- package/dist/grid.js.map +1 -0
- package/dist/group-drag.d.ts +41 -0
- package/dist/group-drag.d.ts.map +1 -0
- package/dist/group-drag.js +97 -0
- package/dist/group-drag.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/loop.d.ts +99 -0
- package/dist/loop.d.ts.map +1 -0
- package/dist/loop.js +188 -0
- package/dist/loop.js.map +1 -0
- package/dist/movement.d.ts +17 -0
- package/dist/movement.d.ts.map +1 -0
- package/dist/movement.js +333 -0
- package/dist/movement.js.map +1 -0
- package/dist/packing.d.ts +16 -0
- package/dist/packing.d.ts.map +1 -0
- package/dist/packing.js +159 -0
- package/dist/packing.js.map +1 -0
- package/dist/pan.d.ts +58 -0
- package/dist/pan.d.ts.map +1 -0
- package/dist/pan.js +174 -0
- package/dist/pan.js.map +1 -0
- package/dist/positioning.d.ts +117 -0
- package/dist/positioning.d.ts.map +1 -0
- package/dist/positioning.js +251 -0
- package/dist/positioning.js.map +1 -0
- package/dist/repack.d.ts +4 -0
- package/dist/repack.d.ts.map +1 -0
- package/dist/repack.js +179 -0
- package/dist/repack.js.map +1 -0
- package/dist/types.d.ts +236 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/virtualize.d.ts +27 -0
- package/dist/virtualize.d.ts.map +1 -0
- package/dist/virtualize.js +53 -0
- package/dist/virtualize.js.map +1 -0
- package/package.json +55 -0
- package/src/compaction.ts +80 -0
- package/src/drag.ts +146 -0
- package/src/events.ts +25 -0
- package/src/geometry.ts +199 -0
- package/src/grid.ts +578 -0
- package/src/group-drag.ts +120 -0
- package/src/index.ts +78 -0
- package/src/loop.ts +246 -0
- package/src/movement.ts +363 -0
- package/src/packing.ts +169 -0
- package/src/pan.ts +217 -0
- package/src/positioning.ts +292 -0
- package/src/repack.ts +212 -0
- package/src/types.ts +262 -0
- package/src/virtualize.ts +77 -0
package/src/grid.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// Grid: the central state holder. Holds config + tiles and exposes a mutation API.
|
|
2
|
+
// All geometry/movement/compaction logic lives in sibling modules; Grid just orchestrates.
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
CellPos,
|
|
6
|
+
CellRect,
|
|
7
|
+
Footprint,
|
|
8
|
+
GridChangeEvent,
|
|
9
|
+
GridConfig,
|
|
10
|
+
GridSnapshot,
|
|
11
|
+
Tile,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
import { Emitter } from './events.js';
|
|
14
|
+
import {
|
|
15
|
+
directionStep,
|
|
16
|
+
priorityDirections,
|
|
17
|
+
rectsOverlap,
|
|
18
|
+
tileRect,
|
|
19
|
+
} from './geometry.js';
|
|
20
|
+
import { moveTile as moveEngine } from './movement.js';
|
|
21
|
+
import { compact as compactEngine } from './compaction.js';
|
|
22
|
+
import { isInFlow } from './positioning.js';
|
|
23
|
+
import { assertLoopable, loopEnabled } from './loop.js';
|
|
24
|
+
import { computePack } from './packing.js';
|
|
25
|
+
|
|
26
|
+
function defaultConfig(c: GridConfig): GridConfig {
|
|
27
|
+
return {
|
|
28
|
+
infiniteX: c.infiniteX ?? c.cols === Infinity,
|
|
29
|
+
infiniteY: c.infiniteY ?? c.rows === Infinity,
|
|
30
|
+
gap: c.gap ?? 0,
|
|
31
|
+
gravity: c.gravity ?? 'none',
|
|
32
|
+
resizeHandles: c.resizeHandles ?? ['se'],
|
|
33
|
+
snapDuringDrag: c.snapDuringDrag ?? true,
|
|
34
|
+
maxRepackHops: c.maxRepackHops ?? 64,
|
|
35
|
+
...c,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findNearestFreeCell(
|
|
40
|
+
grid: Grid,
|
|
41
|
+
victim: Tile,
|
|
42
|
+
avoid: CellRect,
|
|
43
|
+
selfId: string,
|
|
44
|
+
): CellPos | null {
|
|
45
|
+
const visited = new Set<string>();
|
|
46
|
+
const queue: CellPos[] = [{ col: victim.col, row: victim.row }];
|
|
47
|
+
let iters = 0;
|
|
48
|
+
while (queue.length > 0 && iters++ < 512) {
|
|
49
|
+
const p = queue.shift()!;
|
|
50
|
+
const key = `${p.col},${p.row}`;
|
|
51
|
+
if (visited.has(key)) continue;
|
|
52
|
+
visited.add(key);
|
|
53
|
+
const rect: CellRect = { col: p.col, row: p.row, w: victim.w, h: victim.h };
|
|
54
|
+
if (grid.rectInBounds(rect)) {
|
|
55
|
+
const hits = grid.tilesIn(rect, new Set([victim.id, selfId]));
|
|
56
|
+
if (hits.length === 0 && !rectsOverlap(rect, avoid)) {
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
queue.push({ col: p.col + 1, row: p.row });
|
|
61
|
+
queue.push({ col: p.col - 1, row: p.row });
|
|
62
|
+
queue.push({ col: p.col, row: p.row + 1 });
|
|
63
|
+
queue.push({ col: p.col, row: p.row - 1 });
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class Grid {
|
|
69
|
+
config: GridConfig;
|
|
70
|
+
private tilesById = new Map<string, Tile>();
|
|
71
|
+
readonly changes = new Emitter<GridChangeEvent>();
|
|
72
|
+
|
|
73
|
+
constructor(config: GridConfig, initialTiles: Tile[] = []) {
|
|
74
|
+
this.config = defaultConfig(config);
|
|
75
|
+
assertLoopable(this.config);
|
|
76
|
+
for (const t of initialTiles) this.tilesById.set(t.id, { ...t });
|
|
77
|
+
// Note: no auto-pack here even when loop starts enabled — packing fires
|
|
78
|
+
// only on the loop off->on toggle (see updateConfig). Constructing or
|
|
79
|
+
// loading a snapshot respects the stored layout as-is.
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- config -------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
updateConfig(patch: Partial<GridConfig>): void {
|
|
85
|
+
const wasLooping = loopEnabled(this.config);
|
|
86
|
+
const next = defaultConfig({ ...this.config, ...patch });
|
|
87
|
+
assertLoopable(next);
|
|
88
|
+
this.config = next;
|
|
89
|
+
this.changes.emit({ type: 'config', tileIds: [] });
|
|
90
|
+
// Loop repeats the content's bounding box; holes inside it would repeat
|
|
91
|
+
// too, so entering loop mode compacts the layout into a dense block.
|
|
92
|
+
if (!wasLooping && loopEnabled(next)) this.pack();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- tile queries -------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
get tiles(): Tile[] {
|
|
98
|
+
return Array.from(this.tilesById.values());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getTile(id: string): Tile | undefined {
|
|
102
|
+
return this.tilesById.get(id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* All tiles whose grid rect overlaps `rect`. Out-of-flow tiles
|
|
107
|
+
* (`position: 'absolute' | 'fixed'`) are skipped — they don't participate
|
|
108
|
+
* in collision/displacement queries even if their stored col/row happens
|
|
109
|
+
* to overlap.
|
|
110
|
+
*/
|
|
111
|
+
tilesIn(rect: CellRect, exclude: ReadonlySet<string> = new Set()): Tile[] {
|
|
112
|
+
const out: Tile[] = [];
|
|
113
|
+
for (const t of this.tilesById.values()) {
|
|
114
|
+
if (exclude.has(t.id)) continue;
|
|
115
|
+
if (!isInFlow(t)) continue;
|
|
116
|
+
if (rectsOverlap(rect, tileRect(t))) out.push(t);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
rectInBounds(rect: CellRect): boolean {
|
|
122
|
+
const { cols, rows, infiniteX, infiniteY } = this.config;
|
|
123
|
+
if (!infiniteX) {
|
|
124
|
+
if (rect.col < 0 || rect.col + rect.w > cols) return false;
|
|
125
|
+
} else {
|
|
126
|
+
if (rect.col < 0) return false;
|
|
127
|
+
}
|
|
128
|
+
if (!infiniteY) {
|
|
129
|
+
if (rect.row < 0 || rect.row + rect.h > rows) return false;
|
|
130
|
+
} else {
|
|
131
|
+
if (rect.row < 0) return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- mutations ---------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
addTile(tile: Tile): void {
|
|
139
|
+
if (this.tilesById.has(tile.id)) {
|
|
140
|
+
throw new Error(`Griddle: duplicate tile id "${tile.id}"`);
|
|
141
|
+
}
|
|
142
|
+
this.tilesById.set(tile.id, { ...tile });
|
|
143
|
+
this.changes.emit({ type: 'add', tileIds: [tile.id] });
|
|
144
|
+
if (this.config.gravity && this.config.gravity !== 'none') {
|
|
145
|
+
this.compactAll();
|
|
146
|
+
}
|
|
147
|
+
this._structuralRepack();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Add a tile, displacing any overlapping tiles using the same push logic as
|
|
152
|
+
* `resizeTile`. Returns false (and leaves the grid unchanged) if the rect is
|
|
153
|
+
* out of bounds or any victim cannot be placed.
|
|
154
|
+
*/
|
|
155
|
+
addTileWithDisplacement(tile: Tile): boolean {
|
|
156
|
+
if (this.tilesById.has(tile.id)) {
|
|
157
|
+
throw new Error(`Griddle: duplicate tile id "${tile.id}"`);
|
|
158
|
+
}
|
|
159
|
+
const newRect: CellRect = { col: tile.col, row: tile.row, w: tile.w, h: tile.h };
|
|
160
|
+
if (!this.rectInBounds(newRect)) return false;
|
|
161
|
+
|
|
162
|
+
const snapshot = this.snapshotTiles();
|
|
163
|
+
this.tilesById.set(tile.id, { ...tile });
|
|
164
|
+
|
|
165
|
+
const overlapping = this.tilesIn(newRect, new Set([tile.id]));
|
|
166
|
+
for (const victim of overlapping) {
|
|
167
|
+
const pDirs = priorityDirections(newRect, tileRect(victim));
|
|
168
|
+
const steps = pDirs.map(directionStep);
|
|
169
|
+
let placed = false;
|
|
170
|
+
|
|
171
|
+
for (const step of steps) {
|
|
172
|
+
if (step.dx === 0 && step.dy === 0) continue;
|
|
173
|
+
let cursor: CellPos = { col: victim.col, row: victim.row };
|
|
174
|
+
for (let k = 0; k < 128 && !placed; k++) {
|
|
175
|
+
cursor = { col: cursor.col + step.dx, row: cursor.row + step.dy };
|
|
176
|
+
const rect: CellRect = { col: cursor.col, row: cursor.row, w: victim.w, h: victim.h };
|
|
177
|
+
if (!this.rectInBounds(rect)) break;
|
|
178
|
+
const blocked = this.tilesIn(rect, new Set([victim.id, tile.id]));
|
|
179
|
+
if (blocked.length === 0 && !rectsOverlap(rect, newRect)) {
|
|
180
|
+
this._setTilePos(victim.id, cursor);
|
|
181
|
+
placed = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (placed) break;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!placed) {
|
|
188
|
+
const fallback = findNearestFreeCell(this, victim, newRect, tile.id);
|
|
189
|
+
if (fallback) {
|
|
190
|
+
this._setTilePos(victim.id, fallback);
|
|
191
|
+
placed = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!placed) {
|
|
196
|
+
this.restoreTiles(snapshot);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.changes.emit({ type: 'add', tileIds: [tile.id] });
|
|
202
|
+
if (this.config.gravity && this.config.gravity !== 'none') this.compactAll();
|
|
203
|
+
this._structuralRepack();
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
removeTile(id: string): void {
|
|
208
|
+
if (!this.tilesById.has(id)) return;
|
|
209
|
+
this.tilesById.delete(id);
|
|
210
|
+
this.changes.emit({ type: 'remove', tileIds: [id] });
|
|
211
|
+
if (this.config.gravity && this.config.gravity !== 'none') {
|
|
212
|
+
this.compactAll();
|
|
213
|
+
}
|
|
214
|
+
this._structuralRepack();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Re-pack after a structural change (resize/add/remove) while looping,
|
|
219
|
+
* when opted in via `loop.repack: 'structural'`. Plain moves never repack.
|
|
220
|
+
*/
|
|
221
|
+
private _structuralRepack(): void {
|
|
222
|
+
if (!loopEnabled(this.config)) return;
|
|
223
|
+
if ((this.config.loop?.repack ?? 'toggle') !== 'structural') return;
|
|
224
|
+
this.pack();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_setTilePos(id: string, pos: CellPos): void {
|
|
228
|
+
const t = this.tilesById.get(id);
|
|
229
|
+
if (!t) return;
|
|
230
|
+
t.col = pos.col;
|
|
231
|
+
t.row = pos.row;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
_setTileRect(id: string, rect: CellRect): void {
|
|
235
|
+
const t = this.tilesById.get(id);
|
|
236
|
+
if (!t) return;
|
|
237
|
+
t.col = rect.col;
|
|
238
|
+
t.row = rect.row;
|
|
239
|
+
t.w = rect.w;
|
|
240
|
+
t.h = rect.h;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Move an in-flow tile to `target`, running the rules engine (displacement,
|
|
245
|
+
* cascade push, BFS repack). For out-of-flow tiles (`absolute`/`fixed`) this
|
|
246
|
+
* is a no-op — use `setTilePinned` instead.
|
|
247
|
+
*/
|
|
248
|
+
moveTile(id: string, target: CellPos): boolean {
|
|
249
|
+
const tile = this.tilesById.get(id);
|
|
250
|
+
if (!tile) return false;
|
|
251
|
+
if (!isInFlow(tile)) return false;
|
|
252
|
+
if (tile.col === target.col && tile.row === target.row) return true;
|
|
253
|
+
const ok = moveEngine(this, id, target);
|
|
254
|
+
if (ok) {
|
|
255
|
+
this.changes.emit({ type: 'move', tileIds: this.tiles.map((t) => t.id) });
|
|
256
|
+
if (this.config.gravity && this.config.gravity !== 'none') {
|
|
257
|
+
this.compactAll();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return ok;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Move a group of in-flow tiles by a uniform cell delta, preserving their
|
|
265
|
+
* relative positions. Non-group tiles that overlap after the shift are
|
|
266
|
+
* displaced using the single-tile rules engine. Returns true if the entire
|
|
267
|
+
* group landed successfully; on failure, the grid is left unchanged.
|
|
268
|
+
*/
|
|
269
|
+
moveGroup(ids: string[], delta: { dcol: number; drow: number }): boolean {
|
|
270
|
+
if (ids.length === 0) return true;
|
|
271
|
+
if (delta.dcol === 0 && delta.drow === 0) return true;
|
|
272
|
+
|
|
273
|
+
const groupSet = new Set(ids);
|
|
274
|
+
const groupTiles: Tile[] = [];
|
|
275
|
+
for (const id of ids) {
|
|
276
|
+
const tile = this.tilesById.get(id);
|
|
277
|
+
if (!tile || !isInFlow(tile)) return false;
|
|
278
|
+
groupTiles.push(tile);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Compute target positions and validate bounds.
|
|
282
|
+
const targets = new Map<string, CellPos>();
|
|
283
|
+
for (const tile of groupTiles) {
|
|
284
|
+
const target: CellPos = {
|
|
285
|
+
col: tile.col + delta.dcol,
|
|
286
|
+
row: tile.row + delta.drow,
|
|
287
|
+
};
|
|
288
|
+
const rect: CellRect = { col: target.col, row: target.row, w: tile.w, h: tile.h };
|
|
289
|
+
if (!this.rectInBounds(rect)) return false;
|
|
290
|
+
targets.set(tile.id, target);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const snapshot = this.snapshotTiles();
|
|
294
|
+
|
|
295
|
+
// Place all group tiles at their new positions.
|
|
296
|
+
for (const [id, pos] of targets) {
|
|
297
|
+
this._setTilePos(id, pos);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Collect non-group tiles that now overlap with any group member.
|
|
301
|
+
const victims = new Set<string>();
|
|
302
|
+
for (const tile of groupTiles) {
|
|
303
|
+
const target = targets.get(tile.id)!;
|
|
304
|
+
const targetRect: CellRect = { col: target.col, row: target.row, w: tile.w, h: tile.h };
|
|
305
|
+
for (const hit of this.tilesIn(targetRect, groupSet)) {
|
|
306
|
+
victims.add(hit.id);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Try to displace each victim using the single-tile movement engine
|
|
311
|
+
// against the combined group bounding box as origin.
|
|
312
|
+
const groupBounds = this._groupBounds(groupTiles, targets);
|
|
313
|
+
for (const victimId of victims) {
|
|
314
|
+
const victim = this.tilesById.get(victimId);
|
|
315
|
+
if (!victim) continue;
|
|
316
|
+
const victimTarget = findNearestFreeCell(this, victim, groupBounds, victimId);
|
|
317
|
+
if (!victimTarget) {
|
|
318
|
+
this.restoreTiles(snapshot);
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
this._setTilePos(victimId, victimTarget);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Final validation: ensure no group tile overlaps another non-group tile.
|
|
325
|
+
for (const tile of groupTiles) {
|
|
326
|
+
const pos = targets.get(tile.id)!;
|
|
327
|
+
const rect: CellRect = { col: pos.col, row: pos.row, w: tile.w, h: tile.h };
|
|
328
|
+
const hits = this.tilesIn(rect, groupSet);
|
|
329
|
+
if (hits.length > 0) {
|
|
330
|
+
this.restoreTiles(snapshot);
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
this.changes.emit({ type: 'move', tileIds: this.tiles.map((t) => t.id) });
|
|
336
|
+
if (this.config.gravity && this.config.gravity !== 'none') {
|
|
337
|
+
this.compactAll();
|
|
338
|
+
}
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Bounding rect of a group of tiles at their target positions. */
|
|
343
|
+
private _groupBounds(tiles: Tile[], targets: Map<string, CellPos>): CellRect {
|
|
344
|
+
let minCol = Infinity, minRow = Infinity, maxCol = -Infinity, maxRow = -Infinity;
|
|
345
|
+
for (const tile of tiles) {
|
|
346
|
+
const pos = targets.get(tile.id) ?? { col: tile.col, row: tile.row };
|
|
347
|
+
minCol = Math.min(minCol, pos.col);
|
|
348
|
+
minRow = Math.min(minRow, pos.row);
|
|
349
|
+
maxCol = Math.max(maxCol, pos.col + tile.w);
|
|
350
|
+
maxRow = Math.max(maxRow, pos.row + tile.h);
|
|
351
|
+
}
|
|
352
|
+
return { col: minCol, row: minRow, w: maxCol - minCol, h: maxRow - minRow };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Update `tile.pinned` for an `absolute` or `fixed` tile. Returns false if
|
|
357
|
+
* the tile is missing or is in flow (use `moveTile` for those). Does NOT
|
|
358
|
+
* trigger the rules engine — pinned coords are independent of grid layout.
|
|
359
|
+
*/
|
|
360
|
+
setTilePinned(id: string, pinned: { x: number; y: number }): boolean {
|
|
361
|
+
const tile = this.tilesById.get(id);
|
|
362
|
+
if (!tile) return false;
|
|
363
|
+
if (isInFlow(tile)) return false;
|
|
364
|
+
tile.pinned = { x: pinned.x, y: pinned.y };
|
|
365
|
+
this.changes.emit({ type: 'move', tileIds: [id] });
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Change a tile's CSS-like positioning mode. When transitioning into
|
|
371
|
+
* `absolute`/`fixed` you can pass a `pinned` start position; switching back
|
|
372
|
+
* to `static`/`relative`/`sticky` keeps the tile's existing col/row so it
|
|
373
|
+
* snaps back into the grid where it logically belongs.
|
|
374
|
+
*/
|
|
375
|
+
setTilePosition(
|
|
376
|
+
id: string,
|
|
377
|
+
position: NonNullable<Tile['position']>,
|
|
378
|
+
opts: {
|
|
379
|
+
pinned?: { x: number; y: number };
|
|
380
|
+
offset?: { x: number; y: number };
|
|
381
|
+
sticky?: NonNullable<Tile['sticky']>;
|
|
382
|
+
} = {},
|
|
383
|
+
): boolean {
|
|
384
|
+
const tile = this.tilesById.get(id);
|
|
385
|
+
if (!tile) return false;
|
|
386
|
+
tile.position = position;
|
|
387
|
+
if (opts.pinned !== undefined) tile.pinned = { ...opts.pinned };
|
|
388
|
+
if (opts.offset !== undefined) tile.offset = { ...opts.offset };
|
|
389
|
+
if (opts.sticky !== undefined) tile.sticky = { ...opts.sticky };
|
|
390
|
+
this.changes.emit({ type: 'move', tileIds: [id] });
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
resizeTile(id: string, size: Footprint): boolean {
|
|
395
|
+
const tile = this.tilesById.get(id);
|
|
396
|
+
if (!tile) return false;
|
|
397
|
+
const clamped: Footprint = {
|
|
398
|
+
w: Math.max(tile.minW ?? 1, Math.min(tile.maxW ?? 1_000_000, Math.floor(size.w))),
|
|
399
|
+
h: Math.max(tile.minH ?? 1, Math.min(tile.maxH ?? 1_000_000, Math.floor(size.h))),
|
|
400
|
+
};
|
|
401
|
+
if (clamped.w === tile.w && clamped.h === tile.h) return true;
|
|
402
|
+
const newRect: CellRect = {
|
|
403
|
+
col: tile.col,
|
|
404
|
+
row: tile.row,
|
|
405
|
+
w: clamped.w,
|
|
406
|
+
h: clamped.h,
|
|
407
|
+
};
|
|
408
|
+
if (!this.rectInBounds(newRect)) return false;
|
|
409
|
+
|
|
410
|
+
const snapshot = this.snapshotTiles();
|
|
411
|
+
|
|
412
|
+
tile.w = clamped.w;
|
|
413
|
+
tile.h = clamped.h;
|
|
414
|
+
|
|
415
|
+
const overlapping = this.tilesIn(newRect, new Set([id]));
|
|
416
|
+
for (const victim of overlapping) {
|
|
417
|
+
const priorityDirs = priorityDirections(newRect, tileRect(victim));
|
|
418
|
+
const steps = priorityDirs.map(directionStep);
|
|
419
|
+
let placed = false;
|
|
420
|
+
|
|
421
|
+
// Walk each priority direction outward; the first legal placement wins.
|
|
422
|
+
for (const step of steps) {
|
|
423
|
+
if (step.dx === 0 && step.dy === 0) continue;
|
|
424
|
+
let cursor: CellPos = { col: victim.col, row: victim.row };
|
|
425
|
+
for (let k = 0; k < 128 && !placed; k++) {
|
|
426
|
+
cursor = { col: cursor.col + step.dx, row: cursor.row + step.dy };
|
|
427
|
+
const rect: CellRect = {
|
|
428
|
+
col: cursor.col,
|
|
429
|
+
row: cursor.row,
|
|
430
|
+
w: victim.w,
|
|
431
|
+
h: victim.h,
|
|
432
|
+
};
|
|
433
|
+
if (!this.rectInBounds(rect)) break;
|
|
434
|
+
const blocked = this.tilesIn(rect, new Set([victim.id, id]));
|
|
435
|
+
if (blocked.length === 0 && !rectsOverlap(rect, newRect)) {
|
|
436
|
+
this._setTilePos(victim.id, cursor);
|
|
437
|
+
placed = true;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (placed) break;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!placed) {
|
|
444
|
+
const fallback = findNearestFreeCell(this, victim, newRect, id);
|
|
445
|
+
if (fallback) {
|
|
446
|
+
this._setTilePos(victim.id, fallback);
|
|
447
|
+
placed = true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!placed) {
|
|
452
|
+
this.restoreTiles(snapshot);
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
this.changes.emit({ type: 'resize', tileIds: [id] });
|
|
458
|
+
if (this.config.gravity && this.config.gravity !== 'none') this.compactAll();
|
|
459
|
+
this._structuralRepack();
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---- snapshots ---------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
snapshotTiles(): Map<string, Tile> {
|
|
466
|
+
const m = new Map<string, Tile>();
|
|
467
|
+
for (const [id, t] of this.tilesById) m.set(id, { ...t });
|
|
468
|
+
return m;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
restoreTiles(snap: Map<string, Tile>): void {
|
|
472
|
+
this.tilesById.clear();
|
|
473
|
+
for (const [id, t] of snap) this.tilesById.set(id, { ...t });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---- compaction --------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
compactAll(): void {
|
|
479
|
+
const moved = compactEngine(this);
|
|
480
|
+
if (moved.length > 0) {
|
|
481
|
+
this.changes.emit({ type: 'compact', tileIds: moved });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Repack all in-flow tiles into a dense block (no holes when a hole-free
|
|
487
|
+
* tiling exists — exact bounded search, then a gap-minimizing greedy
|
|
488
|
+
* fallback; see packing.ts). A layout that is already perfectly dense is
|
|
489
|
+
* left exactly as the user arranged it (translated to the origin if
|
|
490
|
+
* offset). Out-of-flow tiles are untouched. Returns true if any tile moved.
|
|
491
|
+
*/
|
|
492
|
+
pack(): boolean {
|
|
493
|
+
const flow = this.tiles.filter(isInFlow);
|
|
494
|
+
if (flow.length === 0) return false;
|
|
495
|
+
|
|
496
|
+
const cols = Number.isFinite(this.config.cols) ? this.config.cols : 0;
|
|
497
|
+
if (cols <= 0) return false;
|
|
498
|
+
|
|
499
|
+
// Already dense: keep the user's arrangement, just anchor it at (0,0).
|
|
500
|
+
let minCol = Infinity, minRow = Infinity, maxCol = -Infinity, maxRow = -Infinity;
|
|
501
|
+
let area = 0;
|
|
502
|
+
for (const t of flow) {
|
|
503
|
+
minCol = Math.min(minCol, t.col);
|
|
504
|
+
minRow = Math.min(minRow, t.row);
|
|
505
|
+
maxCol = Math.max(maxCol, t.col + t.w);
|
|
506
|
+
maxRow = Math.max(maxRow, t.row + t.h);
|
|
507
|
+
area += t.w * t.h;
|
|
508
|
+
}
|
|
509
|
+
if ((maxCol - minCol) * (maxRow - minRow) === area) {
|
|
510
|
+
if (minCol === 0 && minRow === 0) return false;
|
|
511
|
+
const moved: string[] = [];
|
|
512
|
+
for (const t of flow) {
|
|
513
|
+
this._setTilePos(t.id, { col: t.col - minCol, row: t.row - minRow });
|
|
514
|
+
moved.push(t.id);
|
|
515
|
+
}
|
|
516
|
+
this.changes.emit({ type: 'compact', tileIds: moved });
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const result = computePack(
|
|
521
|
+
flow.map((t) => ({ id: t.id, w: Math.min(t.w, cols), h: t.h })),
|
|
522
|
+
cols,
|
|
523
|
+
);
|
|
524
|
+
if (!result) return false;
|
|
525
|
+
|
|
526
|
+
const moved: string[] = [];
|
|
527
|
+
for (const t of flow) {
|
|
528
|
+
const p = result.placements.get(t.id);
|
|
529
|
+
if (!p) continue;
|
|
530
|
+
const w = Math.min(t.w, cols);
|
|
531
|
+
if (p.col !== t.col || p.row !== t.row || w !== t.w) {
|
|
532
|
+
this._setTileRect(t.id, { col: p.col, row: p.row, w, h: t.h });
|
|
533
|
+
moved.push(t.id);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (moved.length > 0) {
|
|
537
|
+
this.changes.emit({ type: 'compact', tileIds: moved });
|
|
538
|
+
}
|
|
539
|
+
return moved.length > 0;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ---- serialization ----------------------------------------------------
|
|
543
|
+
|
|
544
|
+
toJSON(): GridSnapshot {
|
|
545
|
+
return {
|
|
546
|
+
version: 1,
|
|
547
|
+
config: { ...this.config },
|
|
548
|
+
tiles: this.tiles.map((t) => ({ ...t })),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Replace this grid's config and tiles with a snapshot, in bulk. Unlike
|
|
554
|
+
* add/remove/resize this has no side effects (no gravity compaction, no
|
|
555
|
+
* structural repack, no pack-on-toggle) — the stored layout is restored
|
|
556
|
+
* verbatim. Emits a single 'load' event.
|
|
557
|
+
*/
|
|
558
|
+
loadJSON(snap: GridSnapshot): void {
|
|
559
|
+
if (snap.version !== 1) {
|
|
560
|
+
throw new Error(`Griddle: unsupported snapshot version ${snap.version}`);
|
|
561
|
+
}
|
|
562
|
+
const next = defaultConfig(snap.config);
|
|
563
|
+
assertLoopable(next);
|
|
564
|
+
this.config = next;
|
|
565
|
+
this.tilesById.clear();
|
|
566
|
+
for (const t of snap.tiles) this.tilesById.set(t.id, { ...t });
|
|
567
|
+
this.changes.emit({ type: 'load', tileIds: this.tiles.map((t) => t.id) });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
static fromJSON(snap: GridSnapshot): Grid {
|
|
571
|
+
if (snap.version !== 1) {
|
|
572
|
+
throw new Error(`Griddle: unsupported snapshot version ${snap.version}`);
|
|
573
|
+
}
|
|
574
|
+
const g = new Grid(snap.config, snap.tiles);
|
|
575
|
+
g.changes.emit({ type: 'load', tileIds: g.tiles.map((t) => t.id) });
|
|
576
|
+
return g;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// GroupDragController — live-preview state machine for dragging multiple
|
|
2
|
+
// selected tiles as a unit. Works analogously to DragController but applies
|
|
3
|
+
// a uniform cell delta to the entire group and uses Grid.moveGroup for
|
|
4
|
+
// displacement logic.
|
|
5
|
+
|
|
6
|
+
import type { CellPos, Tile } from './types.js';
|
|
7
|
+
import type { Grid } from './grid.js';
|
|
8
|
+
|
|
9
|
+
type Snapshot = Map<string, Tile>;
|
|
10
|
+
|
|
11
|
+
export interface GroupDragUpdateResult {
|
|
12
|
+
committed: boolean;
|
|
13
|
+
indicatorDelta: { dcol: number; drow: number } | null;
|
|
14
|
+
changed: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class GroupDragController {
|
|
18
|
+
private grid: Grid;
|
|
19
|
+
private tileIds: string[] = [];
|
|
20
|
+
private snapshot: Snapshot | null = null;
|
|
21
|
+
private pickupCells = new Map<string, CellPos>();
|
|
22
|
+
private lastDelta: { dcol: number; drow: number } | null = null;
|
|
23
|
+
private lastCommittedDelta: { dcol: number; drow: number } | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(grid: Grid) {
|
|
26
|
+
this.grid = grid;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
isActive(): boolean {
|
|
30
|
+
return this.tileIds.length > 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
draggedIds(): string[] {
|
|
34
|
+
return this.tileIds;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pickupCell(id: string): CellPos | undefined {
|
|
38
|
+
return this.pickupCells.get(id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Begin a group drag. Snapshots the grid so previews can be rewound.
|
|
43
|
+
* Returns false if any tile doesn't exist.
|
|
44
|
+
*/
|
|
45
|
+
start(ids: string[]): boolean {
|
|
46
|
+
if (ids.length === 0) return false;
|
|
47
|
+
this.pickupCells.clear();
|
|
48
|
+
for (const id of ids) {
|
|
49
|
+
const tile = this.grid.getTile(id);
|
|
50
|
+
if (!tile) return false;
|
|
51
|
+
this.pickupCells.set(id, { col: tile.col, row: tile.row });
|
|
52
|
+
}
|
|
53
|
+
this.tileIds = [...ids];
|
|
54
|
+
this.snapshot = this.grid.snapshotTiles();
|
|
55
|
+
this.lastDelta = { dcol: 0, drow: 0 };
|
|
56
|
+
this.lastCommittedDelta = { dcol: 0, drow: 0 };
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Update the candidate delta (col/row offset from pickup). If the delta
|
|
62
|
+
* hasn't changed since last call, this is a no-op.
|
|
63
|
+
*/
|
|
64
|
+
update(delta: { dcol: number; drow: number }): GroupDragUpdateResult {
|
|
65
|
+
if (!this.snapshot || this.tileIds.length === 0) {
|
|
66
|
+
return { committed: false, indicatorDelta: null, changed: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const same =
|
|
70
|
+
this.lastDelta !== null &&
|
|
71
|
+
this.lastDelta.dcol === delta.dcol &&
|
|
72
|
+
this.lastDelta.drow === delta.drow;
|
|
73
|
+
if (same) {
|
|
74
|
+
return {
|
|
75
|
+
committed: this.lastCommittedDelta !== null,
|
|
76
|
+
indicatorDelta: this.lastCommittedDelta,
|
|
77
|
+
changed: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
this.lastDelta = { dcol: delta.dcol, drow: delta.drow };
|
|
81
|
+
|
|
82
|
+
// Rewind to pickup snapshot before attempting the new delta.
|
|
83
|
+
this.grid.restoreTiles(this.snapshot);
|
|
84
|
+
|
|
85
|
+
if (delta.dcol === 0 && delta.drow === 0) {
|
|
86
|
+
this.lastCommittedDelta = { dcol: 0, drow: 0 };
|
|
87
|
+
return { committed: true, indicatorDelta: this.lastCommittedDelta, changed: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ok = this.grid.moveGroup(this.tileIds, delta);
|
|
91
|
+
this.lastCommittedDelta = ok ? { dcol: delta.dcol, drow: delta.drow } : null;
|
|
92
|
+
return {
|
|
93
|
+
committed: ok,
|
|
94
|
+
indicatorDelta: this.lastCommittedDelta,
|
|
95
|
+
changed: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
end(): { committed: boolean } {
|
|
100
|
+
const result = { committed: this.lastCommittedDelta !== null };
|
|
101
|
+
if (!this.lastCommittedDelta && this.snapshot) {
|
|
102
|
+
this.grid.restoreTiles(this.snapshot);
|
|
103
|
+
}
|
|
104
|
+
this.reset();
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
cancel(): void {
|
|
109
|
+
if (this.snapshot) this.grid.restoreTiles(this.snapshot);
|
|
110
|
+
this.reset();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private reset(): void {
|
|
114
|
+
this.tileIds = [];
|
|
115
|
+
this.snapshot = null;
|
|
116
|
+
this.pickupCells.clear();
|
|
117
|
+
this.lastDelta = null;
|
|
118
|
+
this.lastCommittedDelta = null;
|
|
119
|
+
}
|
|
120
|
+
}
|