elements-kit 0.18.2 → 0.19.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/dist/ui/overlay/index.css +70 -0
- package/dist/ui/overlay/index.d.mts +209 -0
- package/dist/ui/overlay/index.mjs +1001 -0
- package/dist/ui/overlay/overlay.css +332 -0
- package/dist/utilities/form-object.d.mts +96 -0
- package/dist/utilities/form-object.mjs +285 -0
- package/dist/utilities/form-object.test.d.mts +1 -0
- package/dist/utilities/form-object.test.mjs +408 -0
- package/package.json +5 -5
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
import { p as onCleanup, s as effect } from "../../lib-Dkc7cV5F.mjs";
|
|
2
|
+
import "../../signals/index.mjs";
|
|
3
|
+
import { createElementRect } from "../../utilities/element-rect.mjs";
|
|
4
|
+
//#region src/ui/overlay/constrain.ts
|
|
5
|
+
/**
|
|
6
|
+
* Resolves a custom property holding a length to pixels. Plain `px` values
|
|
7
|
+
* parse directly; anything else (`svh` / `calc()` / fractions of the
|
|
8
|
+
* constraint) resolves natively by measuring a hidden probe.
|
|
9
|
+
*/
|
|
10
|
+
function resolveVarPx(overlay, name, axis) {
|
|
11
|
+
const raw = getComputedStyle(overlay).getPropertyValue(name).trim();
|
|
12
|
+
if (/^-?\d+(\.\d+)?px$/.test(raw)) return parseFloat(raw);
|
|
13
|
+
const probe = document.createElement("div");
|
|
14
|
+
probe.style.position = "absolute";
|
|
15
|
+
probe.style.visibility = "hidden";
|
|
16
|
+
probe.style[axis] = `var(${name})`;
|
|
17
|
+
overlay.appendChild(probe);
|
|
18
|
+
const px = probe.getBoundingClientRect()[axis];
|
|
19
|
+
probe.remove();
|
|
20
|
+
return px;
|
|
21
|
+
}
|
|
22
|
+
/** Resolves the constraint rect every gesture bound derives from. */
|
|
23
|
+
function resolveConstraint(overlay) {
|
|
24
|
+
return {
|
|
25
|
+
top: resolveVarPx(overlay, "--overlay-constraint-top", "height"),
|
|
26
|
+
left: resolveVarPx(overlay, "--overlay-constraint-left", "width"),
|
|
27
|
+
width: resolveVarPx(overlay, "--overlay-constraint-width", "width"),
|
|
28
|
+
height: resolveVarPx(overlay, "--overlay-constraint-height", "height")
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Confines an overlay to a rect source by syncing it into the
|
|
33
|
+
* `--overlay-constraint-*` variables. Pass an `Element` (observed via
|
|
34
|
+
* `ResizeObserver` through `createElementRect`) or a custom
|
|
35
|
+
* {@link ConstraintRect}. Every location clamp and gesture bound derives
|
|
36
|
+
* from those variables, so the overlay re-clamps when the rect changes.
|
|
37
|
+
*
|
|
38
|
+
* Caveat: `ResizeObserver` fires on size changes — a container that moves
|
|
39
|
+
* without resizing (e.g. page scroll) does not retrigger the sync.
|
|
40
|
+
*
|
|
41
|
+
* Registers its cleanup with the current scope (`onCleanup`) and also
|
|
42
|
+
* returns it as `dispose` / `Symbol.dispose`; disposing removes the
|
|
43
|
+
* variables, restoring the viewport constraint.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { constrainOverlay } from "elements-kit/ui/overlay";
|
|
48
|
+
*
|
|
49
|
+
* const panel = document.querySelector("dialog.x-overlay")!;
|
|
50
|
+
* constrainOverlay(panel, document.querySelector("main")!);
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
function constrainOverlay(overlay, source) {
|
|
54
|
+
const owned = source instanceof Element;
|
|
55
|
+
const rect = owned ? createElementRect(source) : source;
|
|
56
|
+
const stop = effect(() => {
|
|
57
|
+
overlay.style.setProperty("--overlay-constraint-top", `${rect.top()}px`);
|
|
58
|
+
overlay.style.setProperty("--overlay-constraint-left", `${rect.left()}px`);
|
|
59
|
+
overlay.style.setProperty("--overlay-constraint-width", `${rect.width()}px`);
|
|
60
|
+
overlay.style.setProperty("--overlay-constraint-height", `${rect.height()}px`);
|
|
61
|
+
});
|
|
62
|
+
const dispose = () => {
|
|
63
|
+
stop();
|
|
64
|
+
if (owned) rect[Symbol.dispose]();
|
|
65
|
+
overlay.style.removeProperty("--overlay-constraint-top");
|
|
66
|
+
overlay.style.removeProperty("--overlay-constraint-left");
|
|
67
|
+
overlay.style.removeProperty("--overlay-constraint-width");
|
|
68
|
+
overlay.style.removeProperty("--overlay-constraint-height");
|
|
69
|
+
};
|
|
70
|
+
onCleanup(dispose);
|
|
71
|
+
return {
|
|
72
|
+
dispose,
|
|
73
|
+
[Symbol.dispose]: dispose
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/ui/overlay/resize-strategy.ts
|
|
78
|
+
/**
|
|
79
|
+
* Resize strategies — pluggable policy for a resize drag's live bounds
|
|
80
|
+
* and resting size. Pure (no DOM); the gesture injects the context.
|
|
81
|
+
* Built-ins: `freeResize` (default), `detents`.
|
|
82
|
+
*/
|
|
83
|
+
/** How far (ms) a release velocity is projected when picking a rest. */
|
|
84
|
+
const PROJECTION_MS = 160;
|
|
85
|
+
/** Clamp `value` into `[min, max]`. */
|
|
86
|
+
function clamp(value, min, max) {
|
|
87
|
+
return Math.min(Math.max(value, min), max);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Picks the index of the detent closest to the released size, projected
|
|
91
|
+
* along the release velocity (px/ms, positive = shrinking). Returns `-1`
|
|
92
|
+
* when the gesture should dismiss instead.
|
|
93
|
+
*/
|
|
94
|
+
function closestDetent(sizePx, detentsPx, velocityPxPerMs = 0, dismissible = false, velocityThreshold = .5) {
|
|
95
|
+
const projected = sizePx - velocityPxPerMs * 160;
|
|
96
|
+
if (dismissible) {
|
|
97
|
+
const smallest = detentsPx[0] ?? 0;
|
|
98
|
+
if (projected < smallest / 2) return -1;
|
|
99
|
+
if (sizePx < smallest && velocityPxPerMs > velocityThreshold) return -1;
|
|
100
|
+
}
|
|
101
|
+
let best = 0;
|
|
102
|
+
for (let i = 1; i < detentsPx.length; i++) if (Math.abs(detentsPx[i] - projected) < Math.abs(detentsPx[best] - projected)) best = i;
|
|
103
|
+
return best;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Free resize: drag to any size within the room; a flick or shrink past
|
|
107
|
+
* the minimum dismisses. The default strategy.
|
|
108
|
+
*/
|
|
109
|
+
function freeResize(opts) {
|
|
110
|
+
return {
|
|
111
|
+
bounds: (ctx) => [opts?.min ?? ctx.min, ctx.max],
|
|
112
|
+
rest: (ctx) => {
|
|
113
|
+
const lo = opts?.min ?? ctx.min;
|
|
114
|
+
const projected = ctx.size - ctx.velocity * 160;
|
|
115
|
+
if (ctx.dismissible && (projected < lo / 2 || ctx.size < lo && ctx.velocity > ctx.velocityThreshold)) return null;
|
|
116
|
+
return clamp(ctx.size, lo, ctx.max);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Snap to discrete steps — each a fraction of the constraint along the
|
|
122
|
+
* axis (number `0–1`) or a CSS length (string). Flick-aware; shrinking
|
|
123
|
+
* past the smallest step dismisses.
|
|
124
|
+
*/
|
|
125
|
+
function detents(steps) {
|
|
126
|
+
const resolved = (ctx) => steps.map((s) => clamp(ctx.resolve(s), ctx.min, ctx.max)).sort((a, b) => a - b);
|
|
127
|
+
return {
|
|
128
|
+
bounds: (ctx) => {
|
|
129
|
+
const s = resolved(ctx);
|
|
130
|
+
return [s[0] ?? ctx.min, s[s.length - 1] ?? ctx.max];
|
|
131
|
+
},
|
|
132
|
+
rest: (ctx) => {
|
|
133
|
+
const s = resolved(ctx);
|
|
134
|
+
const i = closestDetent(ctx.size, s, ctx.velocity, ctx.dismissible, ctx.velocityThreshold);
|
|
135
|
+
return i === -1 ? null : s[i];
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Clamp with rubber-band resistance past either bound. */
|
|
140
|
+
function resist(value, min, max, resistance = 3) {
|
|
141
|
+
if (value > max) return max + (value - max) / resistance;
|
|
142
|
+
if (value < min) return min - (min - value) / resistance;
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The block / inline handle sides a `data-resize` value encodes. Edges
|
|
147
|
+
* name one axis (`block-start`, `inline-end` → the other side `null`);
|
|
148
|
+
* corners name both as a `start`/`end` pair, block side first
|
|
149
|
+
* (`end-end`, `start-end`).
|
|
150
|
+
*/
|
|
151
|
+
function parseResize(resize) {
|
|
152
|
+
const value = resize.trim();
|
|
153
|
+
const corner = /^(start|end)-(start|end)$/.exec(value);
|
|
154
|
+
if (corner) return {
|
|
155
|
+
block: corner[1],
|
|
156
|
+
inline: corner[2]
|
|
157
|
+
};
|
|
158
|
+
const block = /^block-(start|end)$/.exec(value);
|
|
159
|
+
if (block) return {
|
|
160
|
+
block: block[1],
|
|
161
|
+
inline: null
|
|
162
|
+
};
|
|
163
|
+
const inline = /^inline-(start|end)$/.exec(value);
|
|
164
|
+
if (inline) return {
|
|
165
|
+
block: null,
|
|
166
|
+
inline: inline[1]
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
block: null,
|
|
170
|
+
inline: null
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** New per-axis velocity (px/ms) from a move; keeps the prior value when
|
|
174
|
+
* the timestamp doesn't advance (synthetic events). */
|
|
175
|
+
function updateVelocity(prev, client, timeStamp) {
|
|
176
|
+
const dt = timeStamp - prev.lastTime;
|
|
177
|
+
if (dt > 0) return {
|
|
178
|
+
x: (client.x - prev.prev.x) / dt,
|
|
179
|
+
y: (client.y - prev.prev.y) / dt
|
|
180
|
+
};
|
|
181
|
+
return prev.velocity;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Which gesture a pointerdown engages, by zone priority: corner grip →
|
|
185
|
+
* move strip → whole-surface edge drag. Returns `null` when the pointer
|
|
186
|
+
* misses every zone.
|
|
187
|
+
*/
|
|
188
|
+
function detectEngagement(args) {
|
|
189
|
+
const { block, inline, draggable, rect, pointer, dir } = args;
|
|
190
|
+
const corner = block !== null && inline !== null;
|
|
191
|
+
const handleRight = inline === "end" === (dir === 1);
|
|
192
|
+
if (corner && Math.abs(pointer.x - (handleRight ? rect.right : rect.left)) <= 28 && Math.abs(pointer.y - (block === "end" ? rect.bottom : rect.top)) <= 28) return "resize";
|
|
193
|
+
const topCenterResize = block === "start" && inline === null;
|
|
194
|
+
if (draggable && pointer.y - rect.top >= 0 && pointer.y - rect.top <= 28 && (!topCenterResize || Math.abs(pointer.x - (dir === 1 ? rect.left : rect.right)) <= 28)) return "move";
|
|
195
|
+
if (block !== null && inline === null) return "block";
|
|
196
|
+
if (inline !== null && block === null) return "inline";
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Edge-drag setup: the growth `sign` (handle grows toward the pointer),
|
|
201
|
+
* the `anchorSign` for the location shift, and whether the handle-less
|
|
202
|
+
* edge already sits flush against the constraint (`docked` → the CSS clamp
|
|
203
|
+
* holds it, skip the shift).
|
|
204
|
+
*/
|
|
205
|
+
function edgeSetup(args) {
|
|
206
|
+
const { axis, side, rect, constraint, dir } = args;
|
|
207
|
+
if (axis === "block") {
|
|
208
|
+
const sign = side === "end" ? 1 : -1;
|
|
209
|
+
const anchorEdge = side === "start" ? rect.bottom : rect.top;
|
|
210
|
+
const constraintEdge = side === "start" ? constraint.top + constraint.height : constraint.top;
|
|
211
|
+
return {
|
|
212
|
+
sign,
|
|
213
|
+
anchorSign: sign,
|
|
214
|
+
docked: Math.abs(anchorEdge - constraintEdge) < 1
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const handleRight = side === "end" === (dir === 1);
|
|
218
|
+
const anchorEdge = handleRight ? rect.left : rect.right;
|
|
219
|
+
const constraintEdge = handleRight ? constraint.left : constraint.left + constraint.width;
|
|
220
|
+
return {
|
|
221
|
+
sign: (side === "end" ? 1 : -1) * dir,
|
|
222
|
+
anchorSign: handleRight ? 1 : -1,
|
|
223
|
+
docked: Math.abs(anchorEdge - constraintEdge) < 1
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Edge-drag resolution: from a target size + soft bounds, the rendered
|
|
228
|
+
* size and the slide offset (the value to write to `--overlay-dx/-dy`, or
|
|
229
|
+
* `null` to remove it). Both bounds rubber-band, but in different channels:
|
|
230
|
+
*
|
|
231
|
+
* - Above `hi` the size grows with resistance, but only up to the hard `max`
|
|
232
|
+
* (the room from the anchored edge to the constraint). Past the room the
|
|
233
|
+
* box cannot grow — `max-width`/`-height` caps it and the location clamp
|
|
234
|
+
* would shove the *anchored* edge inward — so the size pins to `max` and
|
|
235
|
+
* the resisted overshoot rides the unclamped slide instead, translating
|
|
236
|
+
* the whole surface past the edge (handle-side rubber-band). On release the
|
|
237
|
+
* strategy rests at the room and the slide clears, snapping it back in.
|
|
238
|
+
* - Below `lo` the size pins to `lo` and the surface slides toward its edge
|
|
239
|
+
* (the dismiss preview).
|
|
240
|
+
*
|
|
241
|
+
* With a soft `hi` below the room (e.g. a `detents` max) the size-band
|
|
242
|
+
* between `hi` and `max` renders normally before the slide takes over.
|
|
243
|
+
*/
|
|
244
|
+
function edgeDrag(args) {
|
|
245
|
+
const { target, lo, hi, sign, max = Infinity } = args;
|
|
246
|
+
if (target > hi) return slidePastRoom(hi + (target - hi) / 3, max, sign);
|
|
247
|
+
if (target < lo) return {
|
|
248
|
+
size: lo,
|
|
249
|
+
slide: -sign * (lo - Math.max(target, 0))
|
|
250
|
+
};
|
|
251
|
+
return {
|
|
252
|
+
size: target,
|
|
253
|
+
slide: null
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Cap a resisted size at the room `max`; route the resisted overshoot beyond
|
|
258
|
+
* it to the slide channel (translating the surface past the edge instead of
|
|
259
|
+
* letting the CSS clamp shove the anchored edge). `null` slide within the room.
|
|
260
|
+
* The handle-side rubber-band shared by the edge ({@link edgeDrag}) and corner
|
|
261
|
+
* resizes — both can only grow until the handle reaches the constraint.
|
|
262
|
+
*/
|
|
263
|
+
function slidePastRoom(resisted, max, sign) {
|
|
264
|
+
if (resisted <= max) return {
|
|
265
|
+
size: resisted,
|
|
266
|
+
slide: null
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
size: max,
|
|
270
|
+
slide: sign * (resisted - max)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Pin the opposite edge along one axis: shift the center-anchored location
|
|
275
|
+
* by half the size change. Returns the rect-relative center coordinate, or
|
|
276
|
+
* `null` when docked (the CSS clamp already holds that edge). The single
|
|
277
|
+
* source of anchoring truth for block, inline, and corner resizes.
|
|
278
|
+
*/
|
|
279
|
+
function anchor(args) {
|
|
280
|
+
const { axis, center0, constraint, anchorSign, startSize, size, docked } = args;
|
|
281
|
+
if (docked) return null;
|
|
282
|
+
const origin = axis === "width" ? constraint.left : constraint.top;
|
|
283
|
+
return (axis === "width" ? center0.x : center0.y) - origin + anchorSign * (size - startSize) / 2;
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/ui/overlay/move-session.ts
|
|
287
|
+
/** Move bounds on the box center — the same clamp the stylesheet applies to
|
|
288
|
+
* the persisted location point (center floor = half the box). */
|
|
289
|
+
function moveBounds(args) {
|
|
290
|
+
const { rect, constraint } = args;
|
|
291
|
+
const minX = constraint.left + rect.width / 2;
|
|
292
|
+
const minY = constraint.top + rect.height / 2;
|
|
293
|
+
return {
|
|
294
|
+
minX,
|
|
295
|
+
minY,
|
|
296
|
+
maxX: Math.max(constraint.left + constraint.width - rect.width / 2, minX),
|
|
297
|
+
maxY: Math.max(constraint.top + constraint.height - rect.height / 2, minY)
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** Live move offset (`--overlay-dx/-dy`): 1:1 inside the bounds, rubber-band
|
|
301
|
+
* resistance beyond them. */
|
|
302
|
+
function moveOffset(args) {
|
|
303
|
+
const { center0, client, start, bounds } = args;
|
|
304
|
+
return {
|
|
305
|
+
dx: resist(center0.x + (client.x - start.x), bounds.minX, bounds.maxX) - center0.x,
|
|
306
|
+
dy: resist(center0.y + (client.y - start.y), bounds.minY, bounds.maxY) - center0.y
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/** Rested move location (rect-relative), clamped inside the bounds. */
|
|
310
|
+
function moveRest(args) {
|
|
311
|
+
const { center0, client, start, bounds, constraint } = args;
|
|
312
|
+
const cx = clamp(center0.x + (client.x - start.x), bounds.minX, bounds.maxX);
|
|
313
|
+
const cy = clamp(center0.y + (client.y - start.y), bounds.minY, bounds.maxY);
|
|
314
|
+
return {
|
|
315
|
+
x: cx - constraint.left,
|
|
316
|
+
y: cy - constraint.top
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Whether a flicked move's projected center leaves the constraint (close). */
|
|
320
|
+
function projectedOutOfBounds(args) {
|
|
321
|
+
const { rect, velocity, constraint } = args;
|
|
322
|
+
const px = rect.left + rect.width / 2 + velocity.x * 160;
|
|
323
|
+
const py = rect.top + rect.height / 2 + velocity.y * 160;
|
|
324
|
+
return px < constraint.left || px > constraint.left + constraint.width || py < constraint.top || py > constraint.top + constraint.height;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Window move — x/y drag from the top strip, flick off the constraint to
|
|
328
|
+
* dismiss. Doesn't resize: writes only `offset` live and the rested x/y on
|
|
329
|
+
* release (so the I/O layer fires no `resizechange`).
|
|
330
|
+
*/
|
|
331
|
+
function moveSession(snap, deps, io) {
|
|
332
|
+
const bounds = moveBounds({
|
|
333
|
+
rect: snap.rect,
|
|
334
|
+
constraint: snap.constraint
|
|
335
|
+
});
|
|
336
|
+
return {
|
|
337
|
+
move(p) {
|
|
338
|
+
const { dx, dy } = moveOffset({
|
|
339
|
+
center0: snap.center0,
|
|
340
|
+
client: p.current,
|
|
341
|
+
start: p.start,
|
|
342
|
+
bounds
|
|
343
|
+
});
|
|
344
|
+
io.sync({ offset: {
|
|
345
|
+
dx,
|
|
346
|
+
dy
|
|
347
|
+
} });
|
|
348
|
+
},
|
|
349
|
+
release(p) {
|
|
350
|
+
if (deps.dismissible && projectedOutOfBounds({
|
|
351
|
+
rect: deps.liveRect(),
|
|
352
|
+
velocity: p.velocity,
|
|
353
|
+
constraint: snap.constraint
|
|
354
|
+
})) return io.dismiss();
|
|
355
|
+
const r = moveRest({
|
|
356
|
+
center0: snap.center0,
|
|
357
|
+
client: p.current,
|
|
358
|
+
start: p.start,
|
|
359
|
+
bounds,
|
|
360
|
+
constraint: snap.constraint
|
|
361
|
+
});
|
|
362
|
+
io.commit({
|
|
363
|
+
x: r.x,
|
|
364
|
+
y: r.y
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
cancel() {
|
|
368
|
+
io.revert([]);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
//#endregion
|
|
373
|
+
//#region src/ui/overlay/overlay-dom.ts
|
|
374
|
+
/**
|
|
375
|
+
* Overlay DOM — the only element-touching code. Two halves:
|
|
376
|
+
*
|
|
377
|
+
* - `createFrameIO`: reads the engage-time environment off the element
|
|
378
|
+
* (`engage`, turning layout into the plain-number {@link Snapshot} /
|
|
379
|
+
* {@link Resizer} / {@link MoveDeps} the pure modes consume) and renders
|
|
380
|
+
* their output back to the `--overlay-*` channels (`sync` / `commit`) or
|
|
381
|
+
* reverts/dismisses.
|
|
382
|
+
* - `createGestureRecognizer`: generic single-pointer drag plumbing
|
|
383
|
+
* (capture, velocity, the touch-scroll block) that drives a {@link Session}.
|
|
384
|
+
*
|
|
385
|
+
* Everything between — the mode reducers and their math — stays pure and
|
|
386
|
+
* element-free (`gesture-model` + the mode files).
|
|
387
|
+
*/
|
|
388
|
+
const CHANNEL = {
|
|
389
|
+
x: "--overlay-x",
|
|
390
|
+
y: "--overlay-y",
|
|
391
|
+
w: "--overlay-w",
|
|
392
|
+
h: "--overlay-h"
|
|
393
|
+
};
|
|
394
|
+
function createFrameIO(overlay, options) {
|
|
395
|
+
const { strategy, dismissible, velocityThreshold } = options;
|
|
396
|
+
const getProp = (name) => overlay.style.getPropertyValue(name);
|
|
397
|
+
const setLen = (name, px) => overlay.style.setProperty(name, `${px}px`);
|
|
398
|
+
/** Channels at engage — `revert`/`dismiss` restore these. */
|
|
399
|
+
let prev = {
|
|
400
|
+
x: "",
|
|
401
|
+
y: "",
|
|
402
|
+
w: "",
|
|
403
|
+
h: ""
|
|
404
|
+
};
|
|
405
|
+
const restoreChannel = (name, value) => {
|
|
406
|
+
if (value) overlay.style.setProperty(name, value);
|
|
407
|
+
else overlay.style.removeProperty(name);
|
|
408
|
+
};
|
|
409
|
+
const clearDrag = () => {
|
|
410
|
+
overlay.style.removeProperty("height");
|
|
411
|
+
overlay.style.removeProperty("width");
|
|
412
|
+
overlay.style.removeProperty("--overlay-dy");
|
|
413
|
+
overlay.style.removeProperty("--overlay-dx");
|
|
414
|
+
overlay.style.removeProperty("transition");
|
|
415
|
+
overlay.style.removeProperty("user-select");
|
|
416
|
+
overlay.style.removeProperty("-webkit-user-select");
|
|
417
|
+
};
|
|
418
|
+
const emitResize = () => {
|
|
419
|
+
overlay.dispatchEvent(new CustomEvent("resizechange", {
|
|
420
|
+
bubbles: true,
|
|
421
|
+
composed: true,
|
|
422
|
+
detail: {
|
|
423
|
+
width: getProp("--overlay-w") || void 0,
|
|
424
|
+
height: getProp("--overlay-h") || void 0
|
|
425
|
+
}
|
|
426
|
+
}));
|
|
427
|
+
};
|
|
428
|
+
/** Resolves a CSS length to px in the overlay's context (strategy steps). */
|
|
429
|
+
const probeLength = (value, axis) => {
|
|
430
|
+
const probe = document.createElement("div");
|
|
431
|
+
probe.style.position = "absolute";
|
|
432
|
+
probe.style.visibility = "hidden";
|
|
433
|
+
probe.style[axis] = value;
|
|
434
|
+
overlay.appendChild(probe);
|
|
435
|
+
const px = probe.getBoundingClientRect()[axis];
|
|
436
|
+
probe.remove();
|
|
437
|
+
return px;
|
|
438
|
+
};
|
|
439
|
+
const writeFrame = (frame) => {
|
|
440
|
+
for (const k of [
|
|
441
|
+
"x",
|
|
442
|
+
"y",
|
|
443
|
+
"w",
|
|
444
|
+
"h"
|
|
445
|
+
]) {
|
|
446
|
+
const v = frame[k];
|
|
447
|
+
if (v !== void 0) setLen(CHANNEL[k], v);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const applyOffset = (name, v) => {
|
|
451
|
+
if (v === void 0) return;
|
|
452
|
+
if (v === null) overlay.style.removeProperty(name);
|
|
453
|
+
else setLen(name, v);
|
|
454
|
+
};
|
|
455
|
+
return {
|
|
456
|
+
engage() {
|
|
457
|
+
const constraint = resolveConstraint(overlay);
|
|
458
|
+
const rect = overlay.getBoundingClientRect();
|
|
459
|
+
prev = {
|
|
460
|
+
x: getProp("--overlay-x"),
|
|
461
|
+
y: getProp("--overlay-y"),
|
|
462
|
+
w: getProp("--overlay-w"),
|
|
463
|
+
h: getProp("--overlay-h")
|
|
464
|
+
};
|
|
465
|
+
const dir = getComputedStyle(overlay).direction === "rtl" ? -1 : 1;
|
|
466
|
+
const ctx = (q) => ({
|
|
467
|
+
...q,
|
|
468
|
+
dismissible,
|
|
469
|
+
velocityThreshold,
|
|
470
|
+
resolve: (value) => typeof value === "number" ? value * (q.axis === "width" ? constraint.width : constraint.height) : probeLength(value, q.axis)
|
|
471
|
+
});
|
|
472
|
+
return {
|
|
473
|
+
snapshot: {
|
|
474
|
+
constraint,
|
|
475
|
+
rect,
|
|
476
|
+
center0: {
|
|
477
|
+
x: rect.left + rect.width / 2,
|
|
478
|
+
y: rect.top + rect.height / 2
|
|
479
|
+
},
|
|
480
|
+
dir
|
|
481
|
+
},
|
|
482
|
+
resizer: {
|
|
483
|
+
bounds: (a) => strategy.bounds ? strategy.bounds(ctx({
|
|
484
|
+
...a,
|
|
485
|
+
size: a.startSize,
|
|
486
|
+
velocity: 0
|
|
487
|
+
})) : [a.min, a.max],
|
|
488
|
+
rest: (a, drag) => strategy.rest(ctx({
|
|
489
|
+
...a,
|
|
490
|
+
...drag
|
|
491
|
+
}))
|
|
492
|
+
},
|
|
493
|
+
move: {
|
|
494
|
+
dismissible,
|
|
495
|
+
liveRect: () => overlay.getBoundingClientRect()
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
},
|
|
499
|
+
sync(patch) {
|
|
500
|
+
overlay.style.transition = "none";
|
|
501
|
+
if (patch.frame) {
|
|
502
|
+
const f = patch.frame;
|
|
503
|
+
if (f.w !== void 0) overlay.style.width = `${f.w}px`;
|
|
504
|
+
if (f.h !== void 0) overlay.style.height = `${f.h}px`;
|
|
505
|
+
if (f.x !== void 0) setLen("--overlay-x", f.x);
|
|
506
|
+
if (f.y !== void 0) setLen("--overlay-y", f.y);
|
|
507
|
+
}
|
|
508
|
+
if (patch.offset) {
|
|
509
|
+
applyOffset("--overlay-dx", patch.offset.dx);
|
|
510
|
+
applyOffset("--overlay-dy", patch.offset.dy);
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
commit(frame) {
|
|
514
|
+
clearDrag();
|
|
515
|
+
writeFrame(frame);
|
|
516
|
+
if (frame.w !== void 0 || frame.h !== void 0) emitResize();
|
|
517
|
+
},
|
|
518
|
+
dismiss() {
|
|
519
|
+
clearDrag();
|
|
520
|
+
restoreChannel("--overlay-x", prev.x);
|
|
521
|
+
restoreChannel("--overlay-y", prev.y);
|
|
522
|
+
restoreChannel("--overlay-w", prev.w);
|
|
523
|
+
restoreChannel("--overlay-h", prev.h);
|
|
524
|
+
if (overlay instanceof HTMLDialogElement && overlay.open) overlay.close();
|
|
525
|
+
else overlay.hidePopover?.();
|
|
526
|
+
},
|
|
527
|
+
revert(keys) {
|
|
528
|
+
clearDrag();
|
|
529
|
+
for (const k of keys) restoreChannel(CHANNEL[k], prev[k]);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* The pointer loop for `target`: single-pointer capture, velocity tracking,
|
|
535
|
+
* text-selection suppression, and a non-passive `touchmove` block while a
|
|
536
|
+
* drag is live. It owns the "is a drag active" state and drives the engaged
|
|
537
|
+
* {@link Session} — `move`/`release`/`cancel` on pointermove/up/cancel. The
|
|
538
|
+
* session owns the DOM writes (through the `io` it closed over); the
|
|
539
|
+
* recognizer knows nothing about channels.
|
|
540
|
+
*/
|
|
541
|
+
function createGestureRecognizer(target, { canEngage, engage }) {
|
|
542
|
+
let active = null;
|
|
543
|
+
let pointer = null;
|
|
544
|
+
const onPointerDown = (event) => {
|
|
545
|
+
if (active) return;
|
|
546
|
+
if (!canEngage(event)) return;
|
|
547
|
+
const session = engage(event);
|
|
548
|
+
if (!session) return;
|
|
549
|
+
active = session;
|
|
550
|
+
const c = {
|
|
551
|
+
x: event.clientX,
|
|
552
|
+
y: event.clientY
|
|
553
|
+
};
|
|
554
|
+
pointer = {
|
|
555
|
+
start: { ...c },
|
|
556
|
+
prev: { ...c },
|
|
557
|
+
current: { ...c },
|
|
558
|
+
lastTime: event.timeStamp,
|
|
559
|
+
velocity: {
|
|
560
|
+
x: 0,
|
|
561
|
+
y: 0
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
target.style.userSelect = "none";
|
|
565
|
+
target.style.setProperty("-webkit-user-select", "none");
|
|
566
|
+
target.setPointerCapture?.(event.pointerId);
|
|
567
|
+
};
|
|
568
|
+
const onPointerMove = (event) => {
|
|
569
|
+
if (!active || !pointer) return;
|
|
570
|
+
const c = {
|
|
571
|
+
x: event.clientX,
|
|
572
|
+
y: event.clientY
|
|
573
|
+
};
|
|
574
|
+
pointer.velocity = updateVelocity(pointer, c, event.timeStamp);
|
|
575
|
+
pointer.prev = c;
|
|
576
|
+
pointer.lastTime = event.timeStamp;
|
|
577
|
+
pointer.current = c;
|
|
578
|
+
active.move(pointer);
|
|
579
|
+
};
|
|
580
|
+
const onPointerUp = (event) => {
|
|
581
|
+
if (!active || !pointer) return;
|
|
582
|
+
pointer.current = {
|
|
583
|
+
x: event.clientX,
|
|
584
|
+
y: event.clientY
|
|
585
|
+
};
|
|
586
|
+
const session = active;
|
|
587
|
+
active = null;
|
|
588
|
+
target.releasePointerCapture?.(event.pointerId);
|
|
589
|
+
session.release(pointer);
|
|
590
|
+
pointer = null;
|
|
591
|
+
};
|
|
592
|
+
const onPointerCancel = () => {
|
|
593
|
+
if (!active) return;
|
|
594
|
+
const session = active;
|
|
595
|
+
active = null;
|
|
596
|
+
pointer = null;
|
|
597
|
+
session.cancel();
|
|
598
|
+
};
|
|
599
|
+
const blockScroll = (event) => {
|
|
600
|
+
if (active) event.preventDefault();
|
|
601
|
+
};
|
|
602
|
+
target.addEventListener("pointerdown", onPointerDown);
|
|
603
|
+
target.addEventListener("pointermove", onPointerMove);
|
|
604
|
+
target.addEventListener("pointerup", onPointerUp);
|
|
605
|
+
target.addEventListener("pointercancel", onPointerCancel);
|
|
606
|
+
target.addEventListener("touchmove", blockScroll, { passive: false });
|
|
607
|
+
return { dispose() {
|
|
608
|
+
target.removeEventListener("pointerdown", onPointerDown);
|
|
609
|
+
target.removeEventListener("pointermove", onPointerMove);
|
|
610
|
+
target.removeEventListener("pointerup", onPointerUp);
|
|
611
|
+
target.removeEventListener("pointercancel", onPointerCancel);
|
|
612
|
+
target.removeEventListener("touchmove", blockScroll);
|
|
613
|
+
} };
|
|
614
|
+
}
|
|
615
|
+
//#endregion
|
|
616
|
+
//#region src/ui/overlay/resize-height-session.ts
|
|
617
|
+
/**
|
|
618
|
+
* Height resize — the block-axis edge handle (sheets). One edge moves toward
|
|
619
|
+
* the pointer; the opposite edge is pinned by the location shift, or held by
|
|
620
|
+
* the CSS clamp when docked. Below the lower bound the surface slides away via
|
|
621
|
+
* `--overlay-dy`. Self-contained: the height-axis wiring lives here.
|
|
622
|
+
*/
|
|
623
|
+
function resizeHeightSession(snap, resizer, io, side) {
|
|
624
|
+
const startSize = snap.rect.height;
|
|
625
|
+
const { sign, anchorSign, docked } = edgeSetup({
|
|
626
|
+
axis: "block",
|
|
627
|
+
side,
|
|
628
|
+
rect: snap.rect,
|
|
629
|
+
constraint: snap.constraint,
|
|
630
|
+
dir: snap.dir
|
|
631
|
+
});
|
|
632
|
+
const hardMax = anchorSign > 0 ? snap.constraint.top + snap.constraint.height - snap.rect.top : snap.rect.bottom - snap.constraint.top;
|
|
633
|
+
const ax = {
|
|
634
|
+
axis: "height",
|
|
635
|
+
startSize,
|
|
636
|
+
min: 0,
|
|
637
|
+
max: hardMax
|
|
638
|
+
};
|
|
639
|
+
const anchorAt = (size) => anchor({
|
|
640
|
+
axis: "height",
|
|
641
|
+
center0: snap.center0,
|
|
642
|
+
constraint: snap.constraint,
|
|
643
|
+
anchorSign,
|
|
644
|
+
startSize,
|
|
645
|
+
size,
|
|
646
|
+
docked
|
|
647
|
+
});
|
|
648
|
+
return {
|
|
649
|
+
move(p) {
|
|
650
|
+
const target = startSize + sign * (p.current.y - p.start.y);
|
|
651
|
+
const [lo, hi] = resizer.bounds(ax);
|
|
652
|
+
const { size, slide } = edgeDrag({
|
|
653
|
+
target,
|
|
654
|
+
lo,
|
|
655
|
+
hi,
|
|
656
|
+
sign,
|
|
657
|
+
max: hardMax
|
|
658
|
+
});
|
|
659
|
+
const a = anchorAt(size);
|
|
660
|
+
io.sync({
|
|
661
|
+
frame: {
|
|
662
|
+
h: size,
|
|
663
|
+
...a !== null ? { y: a } : {}
|
|
664
|
+
},
|
|
665
|
+
offset: { dy: slide }
|
|
666
|
+
});
|
|
667
|
+
},
|
|
668
|
+
release(p) {
|
|
669
|
+
const target = startSize + sign * (p.current.y - p.start.y);
|
|
670
|
+
const r = resizer.rest(ax, {
|
|
671
|
+
size: target,
|
|
672
|
+
velocity: -sign * p.velocity.y
|
|
673
|
+
});
|
|
674
|
+
if (r === null) return io.dismiss();
|
|
675
|
+
const a = anchorAt(r);
|
|
676
|
+
io.commit({
|
|
677
|
+
h: r,
|
|
678
|
+
...a !== null ? { y: a } : {}
|
|
679
|
+
});
|
|
680
|
+
},
|
|
681
|
+
cancel() {
|
|
682
|
+
io.revert(["x", "y"]);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
//#endregion
|
|
687
|
+
//#region src/ui/overlay/resize-session.ts
|
|
688
|
+
/** Corner resize: minimum width / height (free mode). */
|
|
689
|
+
const MIN_RESIZE_W = 240;
|
|
690
|
+
const MIN_RESIZE_H = 160;
|
|
691
|
+
/** Corner-resize signs + which physical corner the handle is on. */
|
|
692
|
+
function cornerSetup(args) {
|
|
693
|
+
const { block, inline, dir } = args;
|
|
694
|
+
return {
|
|
695
|
+
signX: (inline === "end" ? 1 : -1) * dir,
|
|
696
|
+
signY: block === "end" ? 1 : -1,
|
|
697
|
+
handleRight: inline === "end" === (dir === 1)
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Corner-resize bounds: the opposite corner stays anchored, so the room
|
|
702
|
+
* toward the handle-side constraint edges caps the size (the grip never
|
|
703
|
+
* leaves the constraint). The strategy bounds the width; the height is a
|
|
704
|
+
* free clamp.
|
|
705
|
+
*/
|
|
706
|
+
function cornerBounds(args) {
|
|
707
|
+
const { rect, constraint, block, handleRight } = args;
|
|
708
|
+
const maxW = handleRight ? constraint.left + constraint.width - rect.left : rect.right - constraint.left;
|
|
709
|
+
return {
|
|
710
|
+
maxW,
|
|
711
|
+
maxH: block === "end" ? constraint.top + constraint.height - rect.top : rect.bottom - constraint.top,
|
|
712
|
+
hardMin: MIN_RESIZE_W,
|
|
713
|
+
hardMax: maxW
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Corner grip — free 2D resize, opposite corner anchored. The two axes are
|
|
718
|
+
* asymmetric: the width follows the strategy (and alone decides dismissal),
|
|
719
|
+
* the height is a free clamp `[MIN_RESIZE_H, maxH]` that never dismisses.
|
|
720
|
+
*/
|
|
721
|
+
function resizeSession(snap, resizer, io, block, inline) {
|
|
722
|
+
const { signX, signY, handleRight } = cornerSetup({
|
|
723
|
+
block,
|
|
724
|
+
inline,
|
|
725
|
+
dir: snap.dir
|
|
726
|
+
});
|
|
727
|
+
const { maxH, hardMin, hardMax } = cornerBounds({
|
|
728
|
+
rect: snap.rect,
|
|
729
|
+
constraint: snap.constraint,
|
|
730
|
+
block,
|
|
731
|
+
handleRight
|
|
732
|
+
});
|
|
733
|
+
const startW = snap.rect.width;
|
|
734
|
+
const startH = snap.rect.height;
|
|
735
|
+
const ax = {
|
|
736
|
+
axis: "width",
|
|
737
|
+
startSize: startW,
|
|
738
|
+
min: hardMin,
|
|
739
|
+
max: hardMax
|
|
740
|
+
};
|
|
741
|
+
const frameFor = (w, h) => ({
|
|
742
|
+
w,
|
|
743
|
+
h,
|
|
744
|
+
x: anchor({
|
|
745
|
+
axis: "width",
|
|
746
|
+
center0: snap.center0,
|
|
747
|
+
constraint: snap.constraint,
|
|
748
|
+
anchorSign: signX,
|
|
749
|
+
startSize: startW,
|
|
750
|
+
size: w,
|
|
751
|
+
docked: false
|
|
752
|
+
}),
|
|
753
|
+
y: anchor({
|
|
754
|
+
axis: "height",
|
|
755
|
+
center0: snap.center0,
|
|
756
|
+
constraint: snap.constraint,
|
|
757
|
+
anchorSign: signY,
|
|
758
|
+
startSize: startH,
|
|
759
|
+
size: h,
|
|
760
|
+
docked: false
|
|
761
|
+
})
|
|
762
|
+
});
|
|
763
|
+
return {
|
|
764
|
+
move(p) {
|
|
765
|
+
const [lo, hi] = resizer.bounds(ax);
|
|
766
|
+
const { size: w, slide: dx } = slidePastRoom(resist(startW + signX * (p.current.x - p.start.x), lo, hi), hardMax, signX);
|
|
767
|
+
const { size: h, slide: dy } = slidePastRoom(resist(startH + signY * (p.current.y - p.start.y), MIN_RESIZE_H, maxH), maxH, signY);
|
|
768
|
+
io.sync({
|
|
769
|
+
frame: frameFor(w, h),
|
|
770
|
+
offset: {
|
|
771
|
+
dx,
|
|
772
|
+
dy
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
},
|
|
776
|
+
release(p) {
|
|
777
|
+
const w = resizer.rest(ax, {
|
|
778
|
+
size: startW + signX * (p.current.x - p.start.x),
|
|
779
|
+
velocity: -p.velocity.x * signX
|
|
780
|
+
});
|
|
781
|
+
if (w === null) return io.dismiss();
|
|
782
|
+
const h = clamp(startH + signY * (p.current.y - p.start.y), MIN_RESIZE_H, maxH);
|
|
783
|
+
io.commit(frameFor(w, h));
|
|
784
|
+
},
|
|
785
|
+
cancel() {
|
|
786
|
+
io.revert([
|
|
787
|
+
"x",
|
|
788
|
+
"y",
|
|
789
|
+
"w",
|
|
790
|
+
"h"
|
|
791
|
+
]);
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
//#endregion
|
|
796
|
+
//#region src/ui/overlay/resize-width-session.ts
|
|
797
|
+
/**
|
|
798
|
+
* Width resize — the inline-axis edge handle (drawers). Mirror of
|
|
799
|
+
* `resizeHeightSession` on the width axis (the RTL sign flip is folded into
|
|
800
|
+
* `edgeSetup`); the opposite edge is pinned, and below the lower bound the
|
|
801
|
+
* surface slides via `--overlay-dx`. Self-contained: the width-axis wiring.
|
|
802
|
+
*/
|
|
803
|
+
function resizeWidthSession(snap, resizer, io, side) {
|
|
804
|
+
const startSize = snap.rect.width;
|
|
805
|
+
const { sign, anchorSign, docked } = edgeSetup({
|
|
806
|
+
axis: "inline",
|
|
807
|
+
side,
|
|
808
|
+
rect: snap.rect,
|
|
809
|
+
constraint: snap.constraint,
|
|
810
|
+
dir: snap.dir
|
|
811
|
+
});
|
|
812
|
+
const hardMax = anchorSign > 0 ? snap.constraint.left + snap.constraint.width - snap.rect.left : snap.rect.right - snap.constraint.left;
|
|
813
|
+
const ax = {
|
|
814
|
+
axis: "width",
|
|
815
|
+
startSize,
|
|
816
|
+
min: 0,
|
|
817
|
+
max: hardMax
|
|
818
|
+
};
|
|
819
|
+
const anchorAt = (size) => anchor({
|
|
820
|
+
axis: "width",
|
|
821
|
+
center0: snap.center0,
|
|
822
|
+
constraint: snap.constraint,
|
|
823
|
+
anchorSign,
|
|
824
|
+
startSize,
|
|
825
|
+
size,
|
|
826
|
+
docked
|
|
827
|
+
});
|
|
828
|
+
return {
|
|
829
|
+
move(p) {
|
|
830
|
+
const target = startSize + sign * (p.current.x - p.start.x);
|
|
831
|
+
const [lo, hi] = resizer.bounds(ax);
|
|
832
|
+
const { size, slide } = edgeDrag({
|
|
833
|
+
target,
|
|
834
|
+
lo,
|
|
835
|
+
hi,
|
|
836
|
+
sign,
|
|
837
|
+
max: hardMax
|
|
838
|
+
});
|
|
839
|
+
const a = anchorAt(size);
|
|
840
|
+
io.sync({
|
|
841
|
+
frame: {
|
|
842
|
+
w: size,
|
|
843
|
+
...a !== null ? { x: a } : {}
|
|
844
|
+
},
|
|
845
|
+
offset: { dx: slide }
|
|
846
|
+
});
|
|
847
|
+
},
|
|
848
|
+
release(p) {
|
|
849
|
+
const target = startSize + sign * (p.current.x - p.start.x);
|
|
850
|
+
const r = resizer.rest(ax, {
|
|
851
|
+
size: target,
|
|
852
|
+
velocity: -sign * p.velocity.x
|
|
853
|
+
});
|
|
854
|
+
if (r === null) return io.dismiss();
|
|
855
|
+
const a = anchorAt(r);
|
|
856
|
+
io.commit({
|
|
857
|
+
w: r,
|
|
858
|
+
...a !== null ? { x: a } : {}
|
|
859
|
+
});
|
|
860
|
+
},
|
|
861
|
+
cancel() {
|
|
862
|
+
io.revert(["x", "y"]);
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
//#endregion
|
|
867
|
+
//#region src/ui/overlay/gestures.ts
|
|
868
|
+
/** The size a `data-resize` value persists: block edges drive the height,
|
|
869
|
+
* everything else (inline edges, corners) the width. */
|
|
870
|
+
const resizeKey = (parsed) => parsed.block !== null && parsed.inline === null ? "h" : "w";
|
|
871
|
+
/** Build the drag session for an engaged zone. */
|
|
872
|
+
function selectSession(key, parsed, snapshot, resizer, move, io) {
|
|
873
|
+
if (key === "block") return resizeHeightSession(snapshot, resizer, io, parsed.block);
|
|
874
|
+
if (key === "inline") return resizeWidthSession(snapshot, resizer, io, parsed.inline);
|
|
875
|
+
if (key === "resize") return resizeSession(snapshot, resizer, io, parsed.block, parsed.inline);
|
|
876
|
+
return moveSession(snapshot, move, io);
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Opt-in pointer gestures for `.x-overlay`, dispatched by the gesture
|
|
880
|
+
* attributes (structure stays in markup; policy is options):
|
|
881
|
+
*
|
|
882
|
+
* - An edge word (`block-*` / `inline-*`) is a whole-surface size drag
|
|
883
|
+
* along that axis — block drags the height (sheets), inline the width
|
|
884
|
+
* (drawers), `:dir(rtl)` flips the inline sign. The side names the
|
|
885
|
+
* handle; the opposite edge stays put.
|
|
886
|
+
* - A corner word (`start-start` / … — block side first) is a desktop-
|
|
887
|
+
* window resize from a square zone at that corner, anchored at the
|
|
888
|
+
* opposite corner so the surface never grows past the constraint. The
|
|
889
|
+
* width follows the `resize` strategy; the height is a free clamp.
|
|
890
|
+
* - `data-draggable` moves the surface in x/y from the top strip,
|
|
891
|
+
* rubber-banding at the edges; flinging it off the constraint dismisses
|
|
892
|
+
* (when `dismissible`).
|
|
893
|
+
*
|
|
894
|
+
* Layered for testability: `gesture-model` owns the pure mode reducers +
|
|
895
|
+
* math, and `overlay-dom` owns all DOM contact (pointer plumbing + channel
|
|
896
|
+
* I/O). This function is the wiring — it picks a pure `Session` from
|
|
897
|
+
* `detectEngagement` and adapts it to the recognizer through the io.
|
|
898
|
+
* The `resize` strategy (`freeResize` by default, or `detents`) decides the
|
|
899
|
+
* rested size, written to the public `--overlay-w`/`--overlay-h` channels;
|
|
900
|
+
* CSS renders and animates them. JS never touches `translate`/`top`/`left`.
|
|
901
|
+
*
|
|
902
|
+
* Registers its cleanup with the current scope (`onCleanup`) and also
|
|
903
|
+
* returns it as `dispose` / `Symbol.dispose`.
|
|
904
|
+
*
|
|
905
|
+
* @example
|
|
906
|
+
* ```ts
|
|
907
|
+
* import { createOverlayGestures, detents } from "elements-kit/ui/overlay";
|
|
908
|
+
*
|
|
909
|
+
* const el = document.querySelector("dialog.x-overlay")!;
|
|
910
|
+
* createOverlayGestures(el, { resize: detents([0.25, 0.6, 0.9]) });
|
|
911
|
+
* el.addEventListener("resizechange", (e) => console.log(e.detail));
|
|
912
|
+
* ```
|
|
913
|
+
*/
|
|
914
|
+
function createOverlayGestures(overlay, options) {
|
|
915
|
+
const io = createFrameIO(overlay, {
|
|
916
|
+
strategy: options?.resize ?? freeResize(),
|
|
917
|
+
dismissible: options?.dismissible ?? true,
|
|
918
|
+
velocityThreshold: options?.velocityThreshold ?? .5
|
|
919
|
+
});
|
|
920
|
+
const canEngage = (event) => {
|
|
921
|
+
const resize = overlay.getAttribute("data-resize") ?? "";
|
|
922
|
+
const draggable = overlay.hasAttribute("data-draggable");
|
|
923
|
+
if (!resize && !draggable) return false;
|
|
924
|
+
if (overlay.getAttribute("data-anchor") === "element") return false;
|
|
925
|
+
const target = event.target;
|
|
926
|
+
if (target?.closest("button, a, label, input, select, textarea, [contenteditable]")) return false;
|
|
927
|
+
for (let el = target; el !== null && el !== overlay; el = el.parentElement) if (el.scrollTop > 0) return false;
|
|
928
|
+
return true;
|
|
929
|
+
};
|
|
930
|
+
const engage = (event) => {
|
|
931
|
+
const parsed = parseResize(overlay.getAttribute("data-resize") ?? "");
|
|
932
|
+
const draggable = overlay.hasAttribute("data-draggable");
|
|
933
|
+
const { snapshot, resizer, move } = io.engage();
|
|
934
|
+
const key = detectEngagement({
|
|
935
|
+
...parsed,
|
|
936
|
+
draggable,
|
|
937
|
+
rect: snapshot.rect,
|
|
938
|
+
pointer: {
|
|
939
|
+
x: event.clientX,
|
|
940
|
+
y: event.clientY
|
|
941
|
+
},
|
|
942
|
+
dir: snapshot.dir
|
|
943
|
+
});
|
|
944
|
+
if (!key) return null;
|
|
945
|
+
return selectSession(key, parsed, snapshot, resizer, move, io);
|
|
946
|
+
};
|
|
947
|
+
const recognizer = createGestureRecognizer(overlay, {
|
|
948
|
+
canEngage,
|
|
949
|
+
engage
|
|
950
|
+
});
|
|
951
|
+
const dispose = () => recognizer.dispose();
|
|
952
|
+
onCleanup(dispose);
|
|
953
|
+
return {
|
|
954
|
+
resize(size) {
|
|
955
|
+
const key = resizeKey(parseResize(overlay.getAttribute("data-resize") ?? ""));
|
|
956
|
+
io.commit({ [key]: size });
|
|
957
|
+
},
|
|
958
|
+
dispose,
|
|
959
|
+
[Symbol.dispose]: dispose
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
//#endregion
|
|
963
|
+
//#region src/ui/overlay/overlay.ts
|
|
964
|
+
/**
|
|
965
|
+
* One-call wiring for an interactive `.x-overlay`: optional constraint
|
|
966
|
+
* sync + pointer gestures, under a single disposable. Structure stays in
|
|
967
|
+
* markup (`data-resize` / `data-draggable`); policy is config.
|
|
968
|
+
*
|
|
969
|
+
* Registers its cleanup with the current scope (`onCleanup`) and also
|
|
970
|
+
* returns it as `dispose` / `Symbol.dispose`.
|
|
971
|
+
*
|
|
972
|
+
* @example
|
|
973
|
+
* ```ts
|
|
974
|
+
* import { overlay, detents } from "elements-kit/ui/overlay";
|
|
975
|
+
*
|
|
976
|
+
* const el = document.querySelector("dialog.x-overlay")!;
|
|
977
|
+
* const o = overlay(el, {
|
|
978
|
+
* constrain: document.querySelector("main")!,
|
|
979
|
+
* resize: detents([0.25, 0.6, 0.9]),
|
|
980
|
+
* onResize: (size) => console.log(size),
|
|
981
|
+
* });
|
|
982
|
+
* ```
|
|
983
|
+
*/
|
|
984
|
+
function overlay(el, config) {
|
|
985
|
+
const { constrain, ...gestureOptions } = config ?? {};
|
|
986
|
+
let constraint = null;
|
|
987
|
+
if (constrain) constraint = constrainOverlay(el, constrain);
|
|
988
|
+
const gestures = createOverlayGestures(el, gestureOptions);
|
|
989
|
+
const dispose = () => {
|
|
990
|
+
gestures.dispose();
|
|
991
|
+
constraint?.dispose();
|
|
992
|
+
};
|
|
993
|
+
onCleanup(dispose);
|
|
994
|
+
return {
|
|
995
|
+
resize: (size) => gestures.resize(size),
|
|
996
|
+
dispose,
|
|
997
|
+
[Symbol.dispose]: dispose
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
//#endregion
|
|
1001
|
+
export { PROJECTION_MS, clamp, closestDetent, constrainOverlay, createOverlayGestures, detents, freeResize, overlay, resolveConstraint };
|