@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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/config-ops.d.ts +48 -0
- package/dist/config-ops.d.ts.map +1 -0
- package/dist/config-ops.js +101 -0
- package/dist/config-ops.js.map +1 -0
- package/dist/dashboard-config-shape-def.d.ts +4 -0
- package/dist/dashboard-config-shape-def.d.ts.map +1 -0
- package/dist/dashboard-config-shape-def.js +33 -0
- package/dist/dashboard-config-shape-def.js.map +1 -0
- package/dist/dashboard-config-shape.d.ts +71 -0
- package/dist/dashboard-config-shape.d.ts.map +1 -0
- package/dist/dashboard-config-shape.js +51 -0
- package/dist/dashboard-config-shape.js.map +1 -0
- package/dist/dashboard-runtime.d.ts +23 -0
- package/dist/dashboard-runtime.d.ts.map +1 -0
- package/dist/dashboard-runtime.js +726 -0
- package/dist/dashboard-runtime.js.map +1 -0
- package/dist/dashboard-service.d.ts +76 -0
- package/dist/dashboard-service.d.ts.map +1 -0
- package/dist/dashboard-service.js +287 -0
- package/dist/dashboard-service.js.map +1 -0
- package/dist/drag-target-store.d.ts +11 -0
- package/dist/drag-target-store.d.ts.map +1 -0
- package/dist/drag-target-store.js +33 -0
- package/dist/drag-target-store.js.map +1 -0
- package/dist/grid-overlay.d.ts +16 -0
- package/dist/grid-overlay.d.ts.map +1 -0
- package/dist/grid-overlay.js +111 -0
- package/dist/grid-overlay.js.map +1 -0
- package/dist/grid.d.ts +110 -0
- package/dist/grid.d.ts.map +1 -0
- package/dist/grid.js +214 -0
- package/dist/grid.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/items.d.ts +25 -0
- package/dist/items.d.ts.map +1 -0
- package/dist/items.js +59 -0
- package/dist/items.js.map +1 -0
- package/dist/order.d.ts +10 -0
- package/dist/order.d.ts.map +1 -0
- package/dist/order.js +15 -0
- package/dist/order.js.map +1 -0
- package/dist/plugin.d.ts +12 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +59 -0
- package/dist/plugin.js.map +1 -0
- package/dist/register-dashboard-hud.d.ts +5 -0
- package/dist/register-dashboard-hud.d.ts.map +1 -0
- package/dist/register-dashboard-hud.js +173 -0
- package/dist/register-dashboard-hud.js.map +1 -0
- package/dist/slide-animation.d.ts +10 -0
- package/dist/slide-animation.d.ts.map +1 -0
- package/dist/slide-animation.js +67 -0
- package/dist/slide-animation.js.map +1 -0
- package/dist/viewport-lock.d.ts +4 -0
- package/dist/viewport-lock.d.ts.map +1 -0
- package/dist/viewport-lock.js +172 -0
- package/dist/viewport-lock.js.map +1 -0
- 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
|