@edv4h/usketch-plugin-dashboard 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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/config-ops.d.ts +48 -0
  4. package/dist/config-ops.d.ts.map +1 -0
  5. package/dist/config-ops.js +101 -0
  6. package/dist/config-ops.js.map +1 -0
  7. package/dist/dashboard-config-shape-def.d.ts +4 -0
  8. package/dist/dashboard-config-shape-def.d.ts.map +1 -0
  9. package/dist/dashboard-config-shape-def.js +33 -0
  10. package/dist/dashboard-config-shape-def.js.map +1 -0
  11. package/dist/dashboard-config-shape.d.ts +71 -0
  12. package/dist/dashboard-config-shape.d.ts.map +1 -0
  13. package/dist/dashboard-config-shape.js +51 -0
  14. package/dist/dashboard-config-shape.js.map +1 -0
  15. package/dist/dashboard-runtime.d.ts +23 -0
  16. package/dist/dashboard-runtime.d.ts.map +1 -0
  17. package/dist/dashboard-runtime.js +726 -0
  18. package/dist/dashboard-runtime.js.map +1 -0
  19. package/dist/dashboard-service.d.ts +76 -0
  20. package/dist/dashboard-service.d.ts.map +1 -0
  21. package/dist/dashboard-service.js +287 -0
  22. package/dist/dashboard-service.js.map +1 -0
  23. package/dist/drag-target-store.d.ts +11 -0
  24. package/dist/drag-target-store.d.ts.map +1 -0
  25. package/dist/drag-target-store.js +33 -0
  26. package/dist/drag-target-store.js.map +1 -0
  27. package/dist/grid-overlay.d.ts +16 -0
  28. package/dist/grid-overlay.d.ts.map +1 -0
  29. package/dist/grid-overlay.js +111 -0
  30. package/dist/grid-overlay.js.map +1 -0
  31. package/dist/grid.d.ts +110 -0
  32. package/dist/grid.d.ts.map +1 -0
  33. package/dist/grid.js +214 -0
  34. package/dist/grid.js.map +1 -0
  35. package/dist/index.d.ts +13 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +13 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/items.d.ts +25 -0
  40. package/dist/items.d.ts.map +1 -0
  41. package/dist/items.js +59 -0
  42. package/dist/items.js.map +1 -0
  43. package/dist/order.d.ts +10 -0
  44. package/dist/order.d.ts.map +1 -0
  45. package/dist/order.js +15 -0
  46. package/dist/order.js.map +1 -0
  47. package/dist/plugin.d.ts +12 -0
  48. package/dist/plugin.d.ts.map +1 -0
  49. package/dist/plugin.js +59 -0
  50. package/dist/plugin.js.map +1 -0
  51. package/dist/register-dashboard-hud.d.ts +5 -0
  52. package/dist/register-dashboard-hud.d.ts.map +1 -0
  53. package/dist/register-dashboard-hud.js +173 -0
  54. package/dist/register-dashboard-hud.js.map +1 -0
  55. package/dist/slide-animation.d.ts +10 -0
  56. package/dist/slide-animation.d.ts.map +1 -0
  57. package/dist/slide-animation.js +67 -0
  58. package/dist/slide-animation.js.map +1 -0
  59. package/dist/viewport-lock.d.ts +4 -0
  60. package/dist/viewport-lock.d.ts.map +1 -0
  61. package/dist/viewport-lock.js +172 -0
  62. package/dist/viewport-lock.js.map +1 -0
  63. package/package.json +49 -0
@@ -0,0 +1,726 @@
1
+ import { fitToGridOf, freeOutOfRangeOf, getDashboardConfig, gridSpecFromConfig, modeOf, swapDelayOf, swapOf, swapThresholdOf, } from "./config-ops.js";
2
+ import { setDragTarget } from "./drag-target-store.js";
3
+ import { cellOfPoint, cellXY, fitSize, packAbsolute, packSpans, spanOf, targetIndexFromPoint, } from "./grid.js";
4
+ import { allDashboardItems, dashboardItems, isGridItem, isWithinGrid } from "./items.js";
5
+ import { readingOrder } from "./order.js";
6
+ import { clearSlideExclude, setSlideActive, setSlideExclude, teardownSlide, } from "./slide-animation.js";
7
+ // ── Self-write guard (module-scoped; one app instance per JS runtime) ──
8
+ let dashboardWrites = 0;
9
+ /** Run `fn` with the dashboard-write guard raised so the reflow runtime ignores
10
+ * the store mutations it produces. Exported so the service can guard its own
11
+ * config command's execute/undo the same way. */
12
+ export function runGuarded(fn) {
13
+ dashboardWrites++;
14
+ try {
15
+ fn();
16
+ }
17
+ finally {
18
+ dashboardWrites--;
19
+ }
20
+ }
21
+ /** A batch move command whose execute AND undo are guarded — so neither applying
22
+ * nor undoing it is mistaken for a user drag. */
23
+ function guardedBatchCommand(store, moves) {
24
+ return {
25
+ execute() {
26
+ runGuarded(() => {
27
+ for (const m of moves)
28
+ store.updateShape(m.id, m.to);
29
+ });
30
+ },
31
+ undo() {
32
+ runGuarded(() => {
33
+ for (const m of moves)
34
+ store.updateShape(m.id, m.from);
35
+ });
36
+ },
37
+ };
38
+ }
39
+ /** rAF with a timeout fallback for SSR/tests/sandbox (no rAF available). */
40
+ const scheduleFrame = typeof globalThis.requestAnimationFrame === "function"
41
+ ? (cb) => globalThis.requestAnimationFrame(cb)
42
+ : (cb) => globalThis.setTimeout(cb, 16);
43
+ const cancelFrame = typeof globalThis.cancelAnimationFrame === "function"
44
+ ? (handle) => globalThis.cancelAnimationFrame(handle)
45
+ : (handle) => globalThis.clearTimeout(handle);
46
+ /** Monotonic-ish current time in ms (for the avoid dwell). */
47
+ const nowMs = () => typeof performance !== "undefined" && typeof performance.now === "function"
48
+ ? performance.now()
49
+ : Date.now();
50
+ /** Centre of a shape's box (rotation is about the centre, so it's rotation-invariant). */
51
+ function centerOf(shape) {
52
+ return { x: shape.x + shape.width / 2, y: shape.y + shape.height / 2 };
53
+ }
54
+ /** Overlap ratio of two boxes: intersection area / the smaller box's area (0–1). */
55
+ function overlapRatio(a, b) {
56
+ const ix = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
57
+ const iy = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
58
+ const inter = ix * iy;
59
+ const minArea = Math.max(1, Math.min(a.width * a.height, b.width * b.height));
60
+ return inter / minArea;
61
+ }
62
+ // Cell offsets ordered by distance then a right→down→left→up preference — used to
63
+ // find the nearest empty cell an "avoided" item can step into.
64
+ const AVOID_OFFSETS = (() => {
65
+ const N = 40;
66
+ const out = [];
67
+ for (let dr = -N; dr <= N; dr++) {
68
+ for (let dc = -N; dc <= N; dc++) {
69
+ if (dc !== 0 || dr !== 0)
70
+ out.push({ dc, dr });
71
+ }
72
+ }
73
+ const rank = (o) => {
74
+ const man = Math.abs(o.dc) + Math.abs(o.dr);
75
+ const diag = o.dc !== 0 && o.dr !== 0 ? 1 : 0;
76
+ let dir = 0; // right, down, left, up (orthogonal only)
77
+ if (!diag)
78
+ dir = o.dc > 0 ? 0 : o.dr > 0 ? 1 : o.dc < 0 ? 2 : 3;
79
+ return man * 1000 + diag * 100 + dir * 10;
80
+ };
81
+ return out.sort((a, b) => rank(a) - rank(b));
82
+ })();
83
+ /** The grid cell whose REGION contains world point `(px,py)` (gap counts with the
84
+ * preceding cell), clamped to the columns and to row ≥ 0. Unlike `cellOfPoint`
85
+ * (which rounds to the nearest cell origin), this is a containment test — so an
86
+ * item's CENTRE maps to the cell it visually sits over, regardless of snapping. */
87
+ function cellContaining(px, py, spec, cols) {
88
+ const stepX = spec.cellW + spec.gap;
89
+ const stepY = spec.cellH + spec.gap;
90
+ const col = Math.min(Math.max(0, Math.floor((px - spec.originX - spec.padding) / stepX)), cols - 1);
91
+ const row = Math.max(0, Math.floor((py - spec.originY - spec.padding) / stepY));
92
+ return { col, row };
93
+ }
94
+ /** Nearest empty cell to `(col,row)` not in `occupied` and within the grid (col in
95
+ * `[0,cols)`, row ≥ 0), searching right→down→left→up outward. Null if none nearby. */
96
+ function nearestFreeCell(col, row, occupied, cols) {
97
+ for (const o of AVOID_OFFSETS) {
98
+ const c = col + o.dc;
99
+ const r = row + o.dr;
100
+ if (c < 0 || c >= cols || r < 0)
101
+ continue;
102
+ if (!occupied.has(`${c},${r}`))
103
+ return { col: c, row: r };
104
+ }
105
+ return null;
106
+ }
107
+ /** Resolve the board's grid spec, or null when this board isn't a dashboard. */
108
+ function specOf(store) {
109
+ const config = getDashboardConfig(store);
110
+ return config ? gridSpecFromConfig(config) : null;
111
+ }
112
+ /** (id → width/height) for a list of ids in order, skipping any gone. */
113
+ function sizedOrder(store, ids) {
114
+ const out = [];
115
+ for (const id of ids) {
116
+ const s = store.getShape(id);
117
+ if (s)
118
+ out.push({ id, width: s.width, height: s.height });
119
+ }
120
+ return out;
121
+ }
122
+ /** A single shape as a placement box. */
123
+ function boxOfShape(s) {
124
+ return { id: s.id, x: s.x, y: s.y, width: s.width, height: s.height };
125
+ }
126
+ /** (id → position + size) boxes for a list of ids in order (for absolute packing). */
127
+ function boxesOf(store, ids) {
128
+ const out = [];
129
+ for (const id of ids) {
130
+ const s = store.getShape(id);
131
+ if (s)
132
+ out.push({ id, x: s.x, y: s.y, width: s.width, height: s.height });
133
+ }
134
+ return out;
135
+ }
136
+ /** Compact placed footprints for `ids` (span-packed) — a stable reference for the
137
+ * drop-index (independent of any live gap already opened). */
138
+ function placedBoxes(store, ids, spec) {
139
+ const sizes = sizedOrder(store, ids);
140
+ const compact = packSpans(sizes, spec);
141
+ return compact.map((p, i) => ({
142
+ id: p.id,
143
+ x: p.x,
144
+ y: p.y,
145
+ width: sizes[i].width,
146
+ height: sizes[i].height,
147
+ }));
148
+ }
149
+ /**
150
+ * Snap every item to its reading-order cell, span-aware, in ONE guarded+undoable
151
+ * command. Used for non-drag changes (add/remove) and the "整列" action / service
152
+ * `repack()`. No-op writes are dropped, so it's safe to call speculatively.
153
+ */
154
+ export function repackBoard(ctx, includeAll = false) {
155
+ const spec = specOf(ctx.store);
156
+ if (!spec)
157
+ return;
158
+ // `includeAll` (enable / 整列) gathers every shape into the grid; the default
159
+ // respects the range so out-of-grid shapes stay free.
160
+ const items = includeAll ? allDashboardItems(ctx.store) : dashboardItems(ctx.store);
161
+ if (items.length === 0)
162
+ return;
163
+ const order = readingOrder(items, spec);
164
+ // flow → compact in reading order; absolute → snap each to the cell nearest its
165
+ // current position (gaps preserved), reading order breaking cell contention.
166
+ const placements = modeOf(ctx.store) === "absolute"
167
+ ? packAbsolute(boxesOf(ctx.store, order), spec)
168
+ : packSpans(sizedOrder(ctx.store, order), spec);
169
+ const moves = [];
170
+ for (const p of placements) {
171
+ const cur = ctx.store.getShape(p.id);
172
+ if (!cur)
173
+ continue;
174
+ if (cur.x === p.x && cur.y === p.y)
175
+ continue;
176
+ moves.push({ id: p.id, from: { x: cur.x, y: cur.y }, to: { x: p.x, y: p.y } });
177
+ }
178
+ if (moves.length === 0)
179
+ return;
180
+ ctx.commands.execute(guardedBatchCommand(ctx.store, moves));
181
+ }
182
+ /**
183
+ * Repack every item to its reading-order cell WITHOUT a command (guarded, no undo
184
+ * entry). For automatic layout the user didn't ask for — e.g. the scroll-lock's
185
+ * auto cell-width refit on resize — where polluting the undo stack would be wrong.
186
+ */
187
+ export function repackBoardTransient(ctx) {
188
+ const spec = specOf(ctx.store);
189
+ if (!spec)
190
+ return;
191
+ const items = dashboardItems(ctx.store);
192
+ if (items.length === 0)
193
+ return;
194
+ const order = readingOrder(items, spec);
195
+ const placements = modeOf(ctx.store) === "absolute"
196
+ ? packAbsolute(boxesOf(ctx.store, order), spec)
197
+ : packSpans(sizedOrder(ctx.store, order), spec);
198
+ runGuarded(() => {
199
+ for (const p of placements) {
200
+ const cur = ctx.store.getShape(p.id);
201
+ if (!cur || (cur.x === p.x && cur.y === p.y))
202
+ continue;
203
+ ctx.store.updateShape(p.id, { x: p.x, y: p.y });
204
+ }
205
+ });
206
+ }
207
+ /**
208
+ * Wire the live-reflow runtime. Returns a teardown that removes every listener
209
+ * and cancels any pending frame.
210
+ */
211
+ export function setupDashboard(ctx) {
212
+ // The shape currently under the pointer (never repositioned mid-drag), and its
213
+ // latest centre (used to derive the live drop index).
214
+ let draggingId = null;
215
+ // The item being resized (fit-to-grid): snapped to a whole-cell size on settle.
216
+ let resizingId = null;
217
+ let pendingPoint = { x: 0, y: 0 };
218
+ let frame = null;
219
+ // Drop is committed when the drag's `shape:updated` stream goes quiet for this
220
+ // long — a signal-independent end-of-drag detector, because the explicit drop
221
+ // events (`shapes:move-end` / `canvas:pointerup`) don't reliably reach here for
222
+ // a shape drag. `shapes:move-end`, when it does fire, commits sooner.
223
+ const SETTLE_MS = 120;
224
+ let settleTimer = null;
225
+ // Live-avoid dwell: the occupant only steps aside after the dragged item has
226
+ // hovered it for `swapDelay` ms. `avoidTimer` re-checks after the dwell even if
227
+ // the pointer holds still (no new `shape:updated` to drive a reflow frame).
228
+ let avoidCandidateId = null;
229
+ let avoidCandidateSince = 0;
230
+ let avoidTimer = null;
231
+ function clearAvoidTimer() {
232
+ if (avoidTimer !== null) {
233
+ clearTimeout(avoidTimer);
234
+ avoidTimer = null;
235
+ }
236
+ }
237
+ function armSettle() {
238
+ if (settleTimer !== null)
239
+ clearTimeout(settleTimer);
240
+ settleTimer = globalThis.setTimeout(() => {
241
+ settleTimer = null;
242
+ if (draggingId !== null) {
243
+ endDrag();
244
+ }
245
+ else if (resizingId !== null) {
246
+ // Resize settled: snap the size to whole cells + re-lay out, then clear.
247
+ const id = resizingId;
248
+ resizingId = null;
249
+ setDragTarget(null);
250
+ commitResizeToGrid(id);
251
+ }
252
+ else {
253
+ repackBoard(ctx);
254
+ }
255
+ }, SETTLE_MS);
256
+ }
257
+ function clearSettle() {
258
+ if (settleTimer !== null) {
259
+ clearTimeout(settleTimer);
260
+ settleTimer = null;
261
+ }
262
+ }
263
+ // Pre-drag positions captured once per drag, so the drop commits `from` = true
264
+ // pre-drag layout (a single clean undo).
265
+ const dragBefore = new Map();
266
+ // Ids currently treated as top-level grid items — lets `shape:removed` (which
267
+ // carries no shape) tell "an item was removed → reflow" from "a nested child
268
+ // was removed → leave the grid alone". Seeded after hydration; kept in sync.
269
+ const itemIds = new Set();
270
+ let itemsSeeded = false;
271
+ function seedItemIds() {
272
+ itemIds.clear();
273
+ for (const s of dashboardItems(ctx.store))
274
+ itemIds.add(s.id);
275
+ itemsSeeded = true;
276
+ }
277
+ // Slide-animate reflow only while this board is a dashboard.
278
+ function syncSlideActive() {
279
+ setSlideActive(getDashboardConfig(ctx.store) !== undefined);
280
+ }
281
+ function refreshItemMembership(id) {
282
+ const shape = ctx.store.getShape(id);
283
+ if (shape && isGridItem(ctx.store, shape))
284
+ itemIds.add(id);
285
+ else
286
+ itemIds.delete(id);
287
+ }
288
+ // Snapshot pre-drag positions the first time we notice a drag. Siblings are at
289
+ // rest here; the dragged shape's pre-drag position comes from the mutation's
290
+ // `before` (it has already moved by the time we observe it).
291
+ function captureBefore(draggedBefore) {
292
+ dragBefore.clear();
293
+ for (const s of dashboardItems(ctx.store))
294
+ dragBefore.set(s.id, { x: s.x, y: s.y });
295
+ dragBefore.set(draggedBefore.id, { x: draggedBefore.x, y: draggedBefore.y });
296
+ }
297
+ /** Highlight the cell a dragged item of `width×height` will land at `topLeft`. */
298
+ function publishHighlight(spec, topLeft, width, height) {
299
+ const span = spanOf(width, height, spec);
300
+ setDragTarget({
301
+ x: topLeft.x,
302
+ y: topLeft.y,
303
+ width: span.cols * spec.cellW + (span.cols - 1) * spec.gap,
304
+ height: span.rows * spec.cellH + (span.rows - 1) * spec.gap,
305
+ });
306
+ }
307
+ /** During a resize, highlight the whole-cell footprint the item will snap to
308
+ * (positioned at the cell nearest its current top-left). */
309
+ function highlightResizeTarget(spec, shape) {
310
+ const fit = fitSize(shape.width, shape.height, spec);
311
+ const span = spanOf(fit.width, fit.height, spec);
312
+ const cols = Math.max(1, Math.floor(spec.columns));
313
+ const cell = cellOfPoint(shape.x, shape.y, spec);
314
+ const col = Math.min(Math.max(0, cell.col), Math.max(0, cols - span.cols));
315
+ const tl = cellXY(col, Math.max(0, cell.row), spec);
316
+ setDragTarget({ x: tl.x, y: tl.y, width: fit.width, height: fit.height });
317
+ }
318
+ /** Snap a resized item to whole-cell size and re-lay out the board, as one
319
+ * undoable command. */
320
+ function commitResizeToGrid(id) {
321
+ const spec = specOf(ctx.store);
322
+ if (!spec)
323
+ return;
324
+ const shape = ctx.store.getShape(id);
325
+ if (!shape)
326
+ return;
327
+ const fit = fitSize(shape.width, shape.height, spec);
328
+ const items = dashboardItems(ctx.store);
329
+ const shapes = readingOrder(items, spec)
330
+ .map((iid) => ctx.store.getShape(iid))
331
+ .filter((s) => s !== undefined);
332
+ const sizeFor = (s) => s.id === id ? { width: fit.width, height: fit.height } : { width: s.width, height: s.height };
333
+ const placements = modeOf(ctx.store) === "absolute"
334
+ ? packAbsolute(shapes.map((s) => ({ id: s.id, x: s.x, y: s.y, ...sizeFor(s) })), spec)
335
+ : packSpans(shapes.map((s) => ({ id: s.id, ...sizeFor(s) })), spec);
336
+ const oldSize = { width: shape.width, height: shape.height };
337
+ const oldPos = new Map(items.map((s) => [s.id, { x: s.x, y: s.y }]));
338
+ const willChange = fit.width !== shape.width ||
339
+ fit.height !== shape.height ||
340
+ placements.some((p) => {
341
+ const s = ctx.store.getShape(p.id);
342
+ return s !== undefined && (s.x !== p.x || s.y !== p.y);
343
+ });
344
+ if (!willChange)
345
+ return;
346
+ const command = {
347
+ execute() {
348
+ runGuarded(() => {
349
+ ctx.store.updateShape(id, fit);
350
+ for (const p of placements)
351
+ ctx.store.updateShape(p.id, { x: p.x, y: p.y });
352
+ });
353
+ },
354
+ undo() {
355
+ runGuarded(() => {
356
+ ctx.store.updateShape(id, oldSize);
357
+ for (const [iid, pos] of oldPos)
358
+ ctx.store.updateShape(iid, pos);
359
+ });
360
+ },
361
+ };
362
+ ctx.commands.execute(command);
363
+ }
364
+ /** An item's cell from its RESTING position (dragBefore) — the layout as if no
365
+ * drag were in progress, so avoid math is stable across frames. */
366
+ function restingCell(id, spec, cols) {
367
+ const rest = dragBefore.get(id);
368
+ const s = ctx.store.getShape(id);
369
+ const x = rest?.x ?? s?.x ?? 0;
370
+ const y = rest?.y ?? s?.y ?? 0;
371
+ const w = s?.width ?? spec.cellW;
372
+ const h = s?.height ?? spec.cellH;
373
+ return cellContaining(x + w / 2, y + h / 2, spec, cols);
374
+ }
375
+ /** Pick the occupant that should get out of the dragged item's way and the cell it
376
+ * should move to (absolute + avoid). Computed from RESTING positions so it's the
377
+ * same during the live preview and on the final drop. Null when nothing to avoid. */
378
+ function resolveAvoid(draggedShape, draggedId, otherIds, spec) {
379
+ const cols = Math.max(1, Math.floor(spec.columns));
380
+ const center = centerOf(draggedShape);
381
+ const dCell = cellContaining(center.x, center.y, spec, cols);
382
+ let best = null;
383
+ for (const id of otherIds) {
384
+ const s = ctx.store.getShape(id);
385
+ if (!s)
386
+ continue;
387
+ const sc = restingCell(id, spec, cols);
388
+ if (sc.col === dCell.col && sc.row === dCell.row) {
389
+ best = s;
390
+ break;
391
+ }
392
+ }
393
+ if (!best) {
394
+ let bestRatio = 0;
395
+ for (const id of otherIds) {
396
+ const s = ctx.store.getShape(id);
397
+ if (!s)
398
+ continue;
399
+ const r = overlapRatio(draggedShape, s);
400
+ if (r > bestRatio) {
401
+ bestRatio = r;
402
+ best = s;
403
+ }
404
+ }
405
+ if (best && bestRatio < swapThresholdOf(ctx.store))
406
+ best = null;
407
+ }
408
+ if (!best)
409
+ return null;
410
+ const bCell = restingCell(best.id, spec, cols); // the dragged claims the occupant's cell
411
+ const occupied = new Set();
412
+ for (const id of otherIds) {
413
+ if (id === best.id)
414
+ continue;
415
+ const c = restingCell(id, spec, cols);
416
+ occupied.add(`${c.col},${c.row}`);
417
+ }
418
+ occupied.add(`${bCell.col},${bCell.row}`);
419
+ const cellFree = (c, r) => c >= 0 && c < cols && r >= 0 && !occupied.has(`${c},${r}`);
420
+ let avoid = nearestFreeCell(bCell.col, bCell.row, occupied, cols);
421
+ // Prefer pushing the avoider in the drag's direction (enables left/up).
422
+ const fromTL = dragBefore.get(draggedId);
423
+ if (fromTL) {
424
+ const origin = cellContaining(fromTL.x + draggedShape.width / 2, fromTL.y + draggedShape.height / 2, spec, cols);
425
+ const dCol = Math.sign(bCell.col - origin.col);
426
+ const dRow = Math.sign(bCell.row - origin.row);
427
+ const horizFirst = Math.abs(bCell.col - origin.col) >= Math.abs(bCell.row - origin.row);
428
+ const tries = horizFirst
429
+ ? [
430
+ [dCol, 0],
431
+ [0, dRow],
432
+ ]
433
+ : [
434
+ [0, dRow],
435
+ [dCol, 0],
436
+ ];
437
+ for (const [dc, dr] of tries) {
438
+ if ((dc !== 0 || dr !== 0) && cellFree(bCell.col + dc, bCell.row + dr)) {
439
+ avoid = { col: bCell.col + dc, row: bCell.row + dr };
440
+ break;
441
+ }
442
+ }
443
+ }
444
+ return avoid ? { best, bCell, avoid } : null;
445
+ }
446
+ function reflowDuringDrag() {
447
+ frame = null;
448
+ const dragged = draggingId;
449
+ if (dragged === null)
450
+ return;
451
+ const spec = specOf(ctx.store);
452
+ if (!spec)
453
+ return;
454
+ const items = dashboardItems(ctx.store);
455
+ if (!items.some((i) => i.id === dragged))
456
+ return;
457
+ const draggedShape = ctx.store.getShape(dragged);
458
+ if (!draggedShape)
459
+ return;
460
+ const otherIds = readingOrder(items, spec).filter((id) => id !== dragged);
461
+ if (modeOf(ctx.store) === "absolute") {
462
+ const cols = Math.max(1, Math.floor(spec.columns));
463
+ const dCell = cellContaining(centerOf(draggedShape).x, centerOf(draggedShape).y, spec, cols);
464
+ // LIVE avoid preview: restore siblings to rest, then push the one under the
465
+ // dragged item aside — WITHOUT moving/snapping the dragged item (it keeps
466
+ // tracking the pointer; it only snaps on drop). The push waits out the
467
+ // configured dwell (so merely passing over an item doesn't disturb it).
468
+ const res = swapOf(ctx.store) ? resolveAvoid(draggedShape, dragged, otherIds, spec) : null;
469
+ const targetId = res?.best.id ?? null;
470
+ if (targetId !== avoidCandidateId) {
471
+ avoidCandidateId = targetId;
472
+ avoidCandidateSince = nowMs();
473
+ }
474
+ const delay = swapDelayOf(ctx.store);
475
+ const dwell = nowMs() - avoidCandidateSince;
476
+ const active = targetId !== null && dwell >= delay;
477
+ runGuarded(() => {
478
+ for (const id of otherIds) {
479
+ const rest = dragBefore.get(id);
480
+ if (!rest)
481
+ continue;
482
+ const cur = ctx.store.getShape(id);
483
+ if (cur && (cur.x !== rest.x || cur.y !== rest.y))
484
+ ctx.store.updateShape(id, rest);
485
+ }
486
+ if (active && res) {
487
+ const tl = cellXY(res.avoid.col, res.avoid.row, spec);
488
+ ctx.store.updateShape(res.best.id, { x: tl.x, y: tl.y });
489
+ }
490
+ });
491
+ // Candidate pending but not yet due → re-check after the remaining dwell (the
492
+ // pointer may hold still, firing no further reflow).
493
+ clearAvoidTimer();
494
+ if (targetId !== null && !active) {
495
+ avoidTimer = globalThis.setTimeout(() => {
496
+ avoidTimer = null;
497
+ scheduleReflow();
498
+ }, Math.max(0, delay - dwell) + 1);
499
+ }
500
+ // Highlight the cell the dragged item will land in on release (the occupant's
501
+ // cell when avoiding, else the cell it's over).
502
+ const land = res ? res.bCell : dCell;
503
+ const landTL = cellXY(land.col, land.row, spec);
504
+ publishHighlight(spec, landTL, draggedShape.width, draggedShape.height);
505
+ return;
506
+ }
507
+ // flow: pack with the dragged item reserving its slot, then write everyone
508
+ // EXCEPT the dragged shape — opening the gap it'll drop into.
509
+ const target = targetIndexFromPoint(pendingPoint, placedBoxes(ctx.store, otherIds, spec), spec);
510
+ const order = [...otherIds];
511
+ order.splice(target, 0, dragged);
512
+ const placements = packSpans(sizedOrder(ctx.store, order), spec);
513
+ const dp = placements.find((p) => p.id === dragged);
514
+ if (dp)
515
+ publishHighlight(spec, dp, draggedShape.width, draggedShape.height);
516
+ runGuarded(() => {
517
+ for (const p of placements) {
518
+ if (p.id === dragged)
519
+ continue;
520
+ const cur = ctx.store.getShape(p.id);
521
+ if (!cur || (cur.x === p.x && cur.y === p.y))
522
+ continue;
523
+ ctx.store.updateShape(p.id, { x: p.x, y: p.y });
524
+ }
525
+ });
526
+ }
527
+ function scheduleReflow() {
528
+ if (frame !== null)
529
+ return; // coalesce to one write per frame
530
+ frame = scheduleFrame(reflowDuringDrag);
531
+ }
532
+ /** Commit placements as one guarded+undoable command. `dragBefore` gives each
533
+ * item's pre-drag position so undo restores the whole layout in one step. */
534
+ function commitPlacements(placements) {
535
+ const moves = [];
536
+ for (const p of placements) {
537
+ const cur = ctx.store.getShape(p.id);
538
+ if (!cur)
539
+ continue;
540
+ const from = dragBefore.get(p.id) ?? { x: cur.x, y: cur.y };
541
+ // Skip only when there's genuinely nothing to do: the shape is already at
542
+ // the target AND its pre-drag position was too (so undo needs no entry).
543
+ // Comparing `from` alone would wrongly skip a shape whose pre-drag position
544
+ // equals the target but which was dragged AWAY (its CURRENT position is the
545
+ // drop point) — that's the item that must snap back.
546
+ const atTarget = cur.x === p.x && cur.y === p.y;
547
+ const fromTarget = from.x === p.x && from.y === p.y;
548
+ if (atTarget && fromTarget)
549
+ continue;
550
+ moves.push({ id: p.id, from: { x: from.x, y: from.y }, to: { x: p.x, y: p.y } });
551
+ }
552
+ if (moves.length > 0)
553
+ ctx.commands.execute(guardedBatchCommand(ctx.store, moves));
554
+ }
555
+ function endDrag() {
556
+ clearSettle();
557
+ clearAvoidTimer();
558
+ avoidCandidateId = null;
559
+ setDragTarget(null); // clear the drop-target highlight
560
+ resizingId = null;
561
+ if (frame !== null) {
562
+ cancelFrame(frame);
563
+ frame = null;
564
+ }
565
+ const spec = specOf(ctx.store);
566
+ const dragged = draggingId;
567
+ draggingId = null;
568
+ // Keep the dropped item excluded THROUGH its snap commit below (so it jumps to
569
+ // its cell without sliding — only the surrounding items animate), then re-include
570
+ // it next frame so later reflows animate it normally.
571
+ if (dragged !== null)
572
+ scheduleFrame(() => clearSlideExclude(dragged));
573
+ if (!spec) {
574
+ dragBefore.clear();
575
+ return;
576
+ }
577
+ const mode = modeOf(ctx.store);
578
+ const items = dashboardItems(ctx.store);
579
+ const order = readingOrder(items, spec);
580
+ const draggedShape = dragged !== null ? ctx.store.getShape(dragged) : undefined;
581
+ const draggedOutOfRange = draggedShape !== undefined &&
582
+ freeOutOfRangeOf(ctx.store) &&
583
+ !isWithinGrid(draggedShape, spec);
584
+ if (dragged === null || !draggedShape || draggedOutOfRange) {
585
+ // Untracked drop (multi-select / vanished), OR the dragged item was taken
586
+ // OUT of the grid range → leave it free; just re-snap the remaining items
587
+ // (which already excludes it) to close the gap.
588
+ commitPlacements(mode === "absolute"
589
+ ? packAbsolute(boxesOf(ctx.store, order), spec)
590
+ : packSpans(sizedOrder(ctx.store, order), spec));
591
+ dragBefore.clear();
592
+ return;
593
+ }
594
+ const otherIds = order.filter((id) => id !== dragged);
595
+ if (mode === "absolute" && swapOf(ctx.store)) {
596
+ // AVOID-on-drop: the dragged item snaps into the cell it's over; the item that
597
+ // sat there steps aside (pushed in the drag direction, else nearest free).
598
+ // Shared with the live preview via `resolveAvoid`, so the drop matches what was
599
+ // previewed. No occupant → normal absolute snap.
600
+ const res = resolveAvoid(draggedShape, dragged, otherIds, spec);
601
+ if (res) {
602
+ const dTL = cellXY(res.bCell.col, res.bCell.row, spec);
603
+ const aTL = cellXY(res.avoid.col, res.avoid.row, spec);
604
+ commitPlacements([
605
+ { id: dragged, x: dTL.x, y: dTL.y },
606
+ { id: res.best.id, x: aTL.x, y: aTL.y },
607
+ ]);
608
+ }
609
+ else {
610
+ const boxes = [...boxesOf(ctx.store, otherIds), boxOfShape(draggedShape)];
611
+ commitPlacements(packAbsolute(boxes, spec));
612
+ }
613
+ }
614
+ else if (mode === "absolute") {
615
+ // Dragged → cell nearest its dropped position; others keep their cells
616
+ // (processed first so they win their own cells).
617
+ const boxes = [...boxesOf(ctx.store, otherIds), boxOfShape(draggedShape)];
618
+ commitPlacements(packAbsolute(boxes, spec));
619
+ }
620
+ else {
621
+ // flow: insert the dragged item at the index its FINAL (dropped) centre
622
+ // lands on (recomputed so revert/replay churn can't corrupt it), compact.
623
+ const target = targetIndexFromPoint(centerOf(draggedShape), placedBoxes(ctx.store, otherIds, spec), spec);
624
+ otherIds.splice(target, 0, dragged);
625
+ commitPlacements(packSpans(sizedOrder(ctx.store, otherIds), spec));
626
+ }
627
+ dragBefore.clear();
628
+ }
629
+ const offMutation = ctx.store.onMutation((event) => {
630
+ if (dashboardWrites > 0)
631
+ return; // ignore our own writes (reflow / commit / undo)
632
+ if (event.type === "shape:updated") {
633
+ const after = event.payload.after;
634
+ const before = event.payload.before;
635
+ if (after)
636
+ refreshItemMembership(after.id);
637
+ if (!after || !isGridItem(ctx.store, after))
638
+ return;
639
+ const spec = specOf(ctx.store);
640
+ if (!spec)
641
+ return;
642
+ // A user drag is a SINGLE selected item whose position actually changed.
643
+ // This rejects multi-select drags (handled as a re-snap on drop), remote
644
+ // collaborators' edits, and style-only updates.
645
+ const selection = ctx.store.getSelection();
646
+ if (selection.size !== 1 || !selection.has(after.id))
647
+ return;
648
+ const sizeChanged = !before || before.width !== after.width || before.height !== after.height;
649
+ if (sizeChanged) {
650
+ // A RESIZE, not a reorder. Let it move at its ACTUAL size (no live
651
+ // write); with "fit to grid" on, show the cell footprint it'll snap to
652
+ // and apply the snap when the resize settles (like move → drop).
653
+ if (fitToGridOf(ctx.store)) {
654
+ resizingId = after.id;
655
+ highlightResizeTarget(spec, after);
656
+ }
657
+ armSettle();
658
+ return;
659
+ }
660
+ if (before && before.x === after.x && before.y === after.y)
661
+ return;
662
+ if (draggingId === null && before)
663
+ captureBefore(before); // first frame → snapshot
664
+ resizingId = null; // a move, not a resize
665
+ draggingId = after.id;
666
+ setSlideExclude(after.id); // the dragged item tracks the pointer (no transition)
667
+ pendingPoint = centerOf(after);
668
+ scheduleReflow();
669
+ // NB: no settle timer for a move — a mere pause must NOT commit (it would
670
+ // snap the dragged item to the grid mid-drag and prematurely fire the
671
+ // avoid). The drop is committed by `shapes:move-end` (or the
672
+ // `selection:changed` safety net), which fire only on actual release.
673
+ clearSettle();
674
+ return;
675
+ }
676
+ if (event.type === "selection:changed") {
677
+ // Safety net for a drag abandoned without a move-end (e.g. Escape): if the
678
+ // tracked item is no longer the sole selection, finalize now.
679
+ if (draggingId !== null) {
680
+ const sel = ctx.store.getSelection();
681
+ if (!(sel.size === 1 && sel.has(draggingId)))
682
+ endDrag();
683
+ }
684
+ return;
685
+ }
686
+ if (event.type === "shape:added" || event.type === "shape:removed") {
687
+ if (draggingId !== null)
688
+ return; // mid-reorder churn settles on drop
689
+ const id = event.payload.id;
690
+ syncSlideActive(); // enable/disable adds/removes the config singleton
691
+ if (event.type === "shape:added") {
692
+ const s = ctx.store.getShape(id);
693
+ if (!s || !isGridItem(ctx.store, s))
694
+ return; // nested / substrate → ignore
695
+ itemIds.add(id);
696
+ repackBoard(ctx);
697
+ return;
698
+ }
699
+ // shape:removed — reflow to close the gap only when a tracked top-level
700
+ // item was removed. Before the seed lands, fall back to repacking.
701
+ const wasItem = itemIds.delete(id);
702
+ if (wasItem || !itemsSeeded)
703
+ repackBoard(ctx);
704
+ }
705
+ });
706
+ // Drop: the select tool emits `shapes:move-end` after committing its move, so
707
+ // our snap lands last. This is the authoritative end-of-drag signal.
708
+ const offMoveEnd = ctx.events.on("shapes:move-end", () => {
709
+ endDrag();
710
+ });
711
+ // Seed the item set once shapes are present (read-only; harmless after teardown).
712
+ queueMicrotask(() => {
713
+ seedItemIds();
714
+ syncSlideActive();
715
+ });
716
+ return () => {
717
+ offMutation();
718
+ offMoveEnd();
719
+ clearSettle();
720
+ clearAvoidTimer();
721
+ if (frame !== null)
722
+ cancelFrame(frame);
723
+ teardownSlide();
724
+ };
725
+ }
726
+ //# sourceMappingURL=dashboard-runtime.js.map