@excom/gesture-handler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.rush/temp/chunked-rush-logs/gesture-handler.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/gesture-handler.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/gesture-handler.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +6 -0
  15. package/gesture-handler.ts +1211 -0
  16. package/index.css +5 -0
  17. package/index.ts +29 -0
  18. package/package.json +52 -0
  19. package/rush-logs/gesture-handler.apply-exports.cache.log +1 -0
  20. package/rush-logs/gesture-handler.apply-exports.log +1 -0
  21. package/rush-logs/gesture-handler.build_docs.cache.log +1 -0
  22. package/rush-logs/gesture-handler.build_docs.log +1 -0
  23. package/rush-logs/gesture-handler.build_package-metas.cache.log +1 -0
  24. package/rush-logs/gesture-handler.build_package-metas.log +1 -0
  25. package/src/gesture-handler.css +207 -0
  26. package/support/custom-elements.json +989 -0
  27. package/support/demos/carousel.html +44 -0
  28. package/support/demos/pinch.html +10 -0
  29. package/support/demos/sheet.html +50 -0
  30. package/support/demos/swipe.html +24 -0
  31. package/support/dist-docs/gesture-handler.md +339 -0
  32. package/support/docs/README.md +92 -0
  33. package/support/package-meta.json +489 -0
  34. package/support/tests/carousel.view.test.ts +67 -0
  35. package/support/tests/gesture-handler.test.ts +625 -0
  36. package/support/tests/pointer-utils.ts +62 -0
  37. package/support/tests/sheet.view.test.ts +112 -0
  38. package/support/tests/swipe.view.test.ts +44 -0
  39. package/tsconfig.json +5 -0
@@ -0,0 +1,1211 @@
1
+ import { selectAll, selectOne } from "@excom/kit-utils";
2
+ import {
3
+ ConstructorType,
4
+ Neutron,
5
+ TEvent,
6
+ TokenList,
7
+ } from "@excom/neutron";
8
+
9
+ export type GestureType =
10
+ | "pan"
11
+ | "pan-x"
12
+ | "pan-y"
13
+ | "pinch"
14
+ | "rotate"
15
+ | "swipe"
16
+ | "tap"
17
+ | "double-tap"
18
+ | "long-press";
19
+
20
+ export type GestureDirection = "left" | "right" | "up" | "down";
21
+
22
+ /**
23
+ * One gesture frame, written every frame as `--gesture-<kebab key>` on the
24
+ * element (`dx` → `--gesture-dx`). Everything derivable from these
25
+ * (`--gesture-distance`, `-angle`, `-progress-px`, `-x-ratio`, `-y-ratio`)
26
+ * is a CSS declaration, not a JS write.
27
+ */
28
+ export interface GestureHandlerValues {
29
+ /** Pointer centroid vs element box (px) */
30
+ x: number;
31
+ y: number;
32
+ /** Travel since start (px) */
33
+ dx: number;
34
+ dy: number;
35
+ /** Velocity (px/ms) last ~100 ms */
36
+ vx: number;
37
+ vy: number;
38
+ /** Along `progress-axis`: fraction of range (`progress-min`..`progress-max`) */
39
+ progress: number;
40
+ /** Pinch ratio (`1` = unchanged) + rotation (deg) */
41
+ scale: number;
42
+ rotate: number;
43
+ pointers: number;
44
+ }
45
+
46
+ /** `provision` and `-start` / `-move` / `-end` / `-cancel` detail. */
47
+ export interface GestureHandlerProvision extends GestureHandlerValues {
48
+ type: GestureType | null;
49
+ /** Pointer type that opened the gesture — `touch` / `pen` / `mouse` */
50
+ pointerType: string;
51
+ /** Travel length (px) — what `threshold-px` is measured against */
52
+ distance: number;
53
+ /** Measured range along `progress-axis` (px), constant per gesture */
54
+ rangePx: number;
55
+ durationMs: number;
56
+ /** `snap-points` target (`-end` only) */
57
+ snap: number | null;
58
+ /** Swipe direction (`-end` only) */
59
+ swipe: GestureDirection | null;
60
+ }
61
+
62
+ /**
63
+ * Each event's `type` / `detail`, spelled out rather than through one
64
+ * generic: the manifest analyzer expands aliases textually, and a generic
65
+ * would leave `GestureHandlerEvent<…>` in the docs instead of the shape.
66
+ */
67
+ export type GestureHandlerStartEvent = TEvent & {
68
+ type: "gesture-handler-start";
69
+ detail: GestureHandlerProvision;
70
+ };
71
+ export type GestureHandlerMoveEvent = TEvent & {
72
+ type: "gesture-handler-move";
73
+ detail: GestureHandlerProvision;
74
+ };
75
+ export type GestureHandlerEndEvent = TEvent & {
76
+ type: "gesture-handler-end";
77
+ detail: GestureHandlerProvision;
78
+ };
79
+ export type GestureHandlerCancelEvent = TEvent & {
80
+ type: "gesture-handler-cancel";
81
+ detail: GestureHandlerProvision;
82
+ };
83
+ export type GestureHandlerSwipeEvent = TEvent & {
84
+ type: "gesture-handler-swipe" | `gesture-handler-swipe-${GestureDirection}`;
85
+ detail: { direction: GestureDirection; velocity: number };
86
+ };
87
+ export type GestureHandlerPointEvent = TEvent & {
88
+ type:
89
+ | "gesture-handler-tap"
90
+ | "gesture-handler-double-tap"
91
+ | "gesture-handler-long-press";
92
+ detail: { x: number; y: number };
93
+ };
94
+ export type GestureHandlerSnapEvent = TEvent & {
95
+ type: "gesture-handler-snap";
96
+ detail: { value: number; index: number };
97
+ };
98
+
99
+ type Vec = [number, number];
100
+ type Pointer = { x: number; y: number };
101
+
102
+ /** Private state of one gesture, pointerdown → release. */
103
+ interface Session {
104
+ pointerType: string;
105
+ pointers: Map<number, Pointer>;
106
+ rect: DOMRect;
107
+ t0: number;
108
+ /** Centroid travel across pointer-count changes */
109
+ acc: Vec;
110
+ /** Centroid at last pointer-count change */
111
+ base: Vec;
112
+ baseDist: number;
113
+ baseAngle: number;
114
+ scaleBase: number;
115
+ rotateBase: number;
116
+ /** `[t, x, y]` centroid samples in velocity window */
117
+ samples: [number, number, number][];
118
+ /** Travel length of the last frame — the `threshold-px` measure */
119
+ distance: number;
120
+ moved: boolean;
121
+ armed: boolean;
122
+ rejected: boolean;
123
+ /** Pointer ids captured since recognition */
124
+ captured: Set<number>;
125
+ longPressed: boolean;
126
+ type: GestureType | null;
127
+ axis: Vec;
128
+ rangePx: number;
129
+ offsetPx: number;
130
+ values: GestureHandlerValues;
131
+ }
132
+
133
+ /** Pointerdown inside a `handoff-ref` container, held until first move decides scroll vs handoff. */
134
+ interface Handoff {
135
+ pointerId: number;
136
+ pointerType: string;
137
+ x: number;
138
+ y: number;
139
+ container: Element;
140
+ }
141
+
142
+ type Handle = ReturnType<typeof setTimeout>;
143
+
144
+ /** Release (`"end"`) or abort (`"cancel"`) — a `_flush` that also tears down. */
145
+ type Phase = "end" | "cancel";
146
+
147
+ /**
148
+ * Methods as the element exposes them (bound, element argument stripped).
149
+ * Declared up front through `withTypes` so one `defineMethods` can define
150
+ * them all and each can reach its siblings through `element`.
151
+ */
152
+ interface Methods {
153
+ _handleHold: () => void;
154
+ _flush: (phase?: Phase) => void;
155
+ _emitSnap: (detail: GestureHandlerSnapEvent["detail"]) => void;
156
+ _handleMove: (e: PointerEvent) => void;
157
+ _handleUp: (e: PointerEvent) => void;
158
+ _handleHandoff: EventListener;
159
+ }
160
+
161
+ /**
162
+ * Every event the element emits, by short name — the config prefixes each
163
+ * with the tag for `emit`, `emits`, `onEventDefault` and `addListener` alike.
164
+ */
165
+ const EVENTS = Object.fromEntries(
166
+ [
167
+ "start",
168
+ "move",
169
+ "end",
170
+ "cancel",
171
+ "swipe",
172
+ "swipe-left",
173
+ "swipe-right",
174
+ "swipe-up",
175
+ "swipe-down",
176
+ "tap",
177
+ "double-tap",
178
+ "long-press",
179
+ "snap",
180
+ ].map((name) => [name, { prefixWithTag: true }])
181
+ );
182
+ const AXES: Record<GestureDirection, Vec> = {
183
+ right: [1, 0],
184
+ left: [-1, 0],
185
+ down: [0, 1],
186
+ up: [0, -1],
187
+ };
188
+ const OPPOSITE: Record<GestureDirection, GestureDirection> = {
189
+ right: "left",
190
+ left: "right",
191
+ down: "up",
192
+ up: "down",
193
+ };
194
+ const PAN_TYPES = ["pan", "pan-x", "pan-y"];
195
+ const MULTI_TYPES: GestureType[] = ["pinch", "rotate"];
196
+ const VELOCITY_WINDOW_MS = 100;
197
+ /** Travel (px) before a `handoff-ref` move is judged */
198
+ const HANDOFF_MIN_PX = 3;
199
+ /** Slack (px) still counted as at scroll limit */
200
+ const LIMIT_EPSILON = 1;
201
+ /** Extra travel time (ms) when projecting snap target */
202
+ const PROJECTION_MS = 120;
203
+ const WINDOW_EVENTS = ["pointermove", "pointerup", "pointercancel"];
204
+ /**
205
+ * The window listeners one session lives on: `addListener` while it is open,
206
+ * the same arguments as `removeListener` when it ends.
207
+ */
208
+ const windowListeners = (
209
+ { _handleMove, _handleUp }: El,
210
+ effect: "addListener" | "removeListener"
211
+ ) =>
212
+ WINDOW_EVENTS.map((name) => ({
213
+ [effect]: [
214
+ name,
215
+ name === "pointermove" ? _handleMove : _handleUp,
216
+ { target: window },
217
+ ],
218
+ }));
219
+ /** On the element while `handoff-ref` is set */
220
+ const HANDOFF_EVENTS = [
221
+ "touchmove",
222
+ "pointermove",
223
+ "pointerup",
224
+ "pointercancel",
225
+ ];
226
+
227
+ /** Number → CSS value; `digits` is what a frame is worth reading. */
228
+ const unit =
229
+ (suffix: string, digits = 2) =>
230
+ (v: number) =>
231
+ `${round(v, digits)}${suffix}`;
232
+ const px = unit("px");
233
+ const deg = unit("deg");
234
+ const num = unit("", 4);
235
+
236
+ /** Formatter per frame value (`--gesture-<kebab key>`), written every frame. */
237
+ const VAR_FORMAT: Record<keyof GestureHandlerValues, (v: number) => string> = {
238
+ x: px,
239
+ y: px,
240
+ dx: px,
241
+ dy: px,
242
+ vx: num,
243
+ vy: num,
244
+ progress: num,
245
+ scale: num,
246
+ rotate: deg,
247
+ pointers: num,
248
+ };
249
+ /** Constant for the whole gesture: written once, when the session begins. */
250
+ const SESSION_FORMAT = { rangePx: px, width: px, height: px };
251
+ type SessionValues = Record<keyof typeof SESSION_FORMAT, number>;
252
+
253
+ /**
254
+ * Pointer gestures as tag-prefixed events; each frame as `--gesture-*` on
255
+ * the element so CSS descendants follow via `var()` — no per-frame script.
256
+ * Wrap the touched surface. Scope starts with `from-ref` (handle),
257
+ * `from-edge` (edge swipe), `handoff-ref` (scroll container hands over at
258
+ * its limit).
259
+ *
260
+ * @summary Swipe / pan / pinch / tap recognition as events + CSS variables.
261
+ *
262
+ * @example
263
+ * <gesture-handler gesture-types="pan-y swipe" progress-axis="up" range-ref=":scope > content-drawer" snap-points="0 1">
264
+ * <quark-sheet>
265
+ * @on gesture-handler-start { content-drawer { is-scrubbing: ""; } }
266
+ * @on gesture-handler-end { content-drawer { is-open: event.detail.snap == 1; is-scrubbing: none; } }
267
+ * </quark-sheet>
268
+ * <content-drawer>…</content-drawer>
269
+ * </gesture-handler>
270
+ *
271
+ * @fires gesture-handler-start - Gesture recognized: pan passed `threshold-px`
272
+ * (after `arm-after` if set), or a second finger for `pinch` / `rotate`.
273
+ * After `gesture-type` + `provision` set. `detail` = provision. Not
274
+ * cancelable — gate with `is-disabled`.
275
+ * @type GestureHandlerStartEvent
276
+ * @fires gesture-handler-move - Once per frame while a recognized gesture
277
+ * moves, only with `should-emit-move`. `detail` = provision. `--gesture-*`
278
+ * always updates, event or not.
279
+ * @type GestureHandlerMoveEvent
280
+ * @fires gesture-handler-end - Last pointer up after a recognized gesture.
281
+ * After `is-active` unset, `last-gesture` + `provision` set, and after
282
+ * `gesture-handler-swipe` if one was recognized. `detail.snap` =
283
+ * `snap-points` target from position / velocity / swipe (`null` without
284
+ * `snap-points`); `detail.swipe` = swipe direction. Default action: write
285
+ * `--gesture-progress` = `detail.snap`, which settles with a CSS
286
+ * transition (`--gesture-snap-duration` / `--gesture-snap-ease`), then
287
+ * `gesture-handler-snap`. `preventDefault()` leaves values where the
288
+ * finger left them. Persist until next gesture.
289
+ * @type GestureHandlerEndEvent
290
+ * @fires gesture-handler-snap - The settle transition reached the snap
291
+ * point (at once when there is nothing to animate; never when a new
292
+ * gesture interrupts it). `detail` = `{ value, index }` into
293
+ * `snap-points`. Commit here when consumer CSS follows
294
+ * `--gesture-progress` until the end.
295
+ * @type GestureHandlerSnapEvent
296
+ * @fires gesture-handler-cancel - Recognized gesture cut short: browser
297
+ * took pointer (`pointercancel`, usually native scroll), `is-disabled`
298
+ * set, or element left the document. `detail` = provision. No `-end`, no
299
+ * glide.
300
+ * @type GestureHandlerCancelEvent
301
+ * @fires gesture-handler-swipe - On release, velocity along dominant axis
302
+ * ≥ `swipe-min-velocity` and direction allowed by `swipe-directions`.
303
+ * `detail` = `{ direction, velocity }` (px/ms). Direction twin fires next
304
+ * (`gesture-handler-swipe-left` / `-right` / `-up` / `-down`).
305
+ * @type GestureHandlerSwipeEvent
306
+ * @fires gesture-handler-tap - Down and up without moving past
307
+ * `threshold-px`, within `long-press-ms`. `detail` = `{ x, y }` relative
308
+ * to the element.
309
+ * @type GestureHandlerPointEvent
310
+ * @fires gesture-handler-double-tap - Second tap within `double-tap-ms` of
311
+ * the previous (after its `gesture-handler-tap`). `detail` = `{ x, y }`.
312
+ * Pair consumed; a third tap starts over.
313
+ * @type GestureHandlerPointEvent
314
+ * @fires gesture-handler-long-press - Pointer stayed down without moving
315
+ * for `long-press-ms`. `detail` = `{ x, y }`.
316
+ * @type GestureHandlerPointEvent
317
+ */
318
+ export const GestureHandler = Neutron({
319
+ tag: "gesture-handler",
320
+ events: EVENTS,
321
+ props: {
322
+ // options
323
+ /**
324
+ * @option
325
+ * Gestures to recognize. `pan-x` / `pan-y` one axis (other stays native
326
+ * scroll); `pan` both; `pinch` / `rotate` need two fingers; `swipe`
327
+ * velocity on release; `tap` / `double-tap` / `long-press` fire events.
328
+ * @values pan | pan-x | pan-y | pinch | rotate | swipe | tap | double-tap | long-press
329
+ * @default pan
330
+ */
331
+ gestureTypes: {
332
+ type: TokenList,
333
+ defaultValue: () => ["pan"] as string[],
334
+ },
335
+ /**
336
+ * @option
337
+ * Only start when pointerdown is inside this descendant (`:scope`-relative
338
+ * selector) — a drag handle, the sheet itself. Combined with `from-edge`,
339
+ * either qualifies.
340
+ * @values <CSS Selector>
341
+ */
342
+ fromRef: String,
343
+ /**
344
+ * @option
345
+ * Only start within `edge-px` of these edges — edge swipes (back nav,
346
+ * pulling a closed sheet up).
347
+ * @values left | right | top | bottom
348
+ */
349
+ fromEdge: TokenList,
350
+ /**
351
+ * @option
352
+ * Width of `from-edge` start zone (px).
353
+ * @default 40
354
+ */
355
+ edgePx: { type: Number, defaultValue: () => 40 },
356
+ /**
357
+ * @option
358
+ * Scroll containers that hand overscroll to the gesture (`:scope`-relative
359
+ * selector, comma list matches several) — e.g. a sheet closed by pulling
360
+ * its own content down.
361
+ *
362
+ * Pointerdown inside one scrolls natively; gesture takes over only when
363
+ * first move runs along `progress-axis`, container is at that scroll
364
+ * limit, and `progress-offset` still has room that way. Additive to
365
+ * `from-ref` / `from-edge`.
366
+ * @values <CSS Selector>
367
+ */
368
+ handoffRef: String,
369
+ /**
370
+ * @option
371
+ * Pointer types that can start a gesture. Add `mouse` for desktop drag.
372
+ * @values touch | pen | mouse
373
+ * @default touch pen
374
+ */
375
+ pointerTypes: {
376
+ type: TokenList,
377
+ defaultValue: () => ["touch", "pen"] as string[],
378
+ },
379
+ /**
380
+ * @option
381
+ * Extra pointers beyond this many are ignored. Unset = `2` when `pinch`
382
+ * / `rotate` listed, else `1`.
383
+ */
384
+ maxPointers: Number,
385
+ /**
386
+ * @option
387
+ * Movement (px) before a pan is recognized. Taps / native scroll stay
388
+ * untouched below it.
389
+ * @default 8
390
+ */
391
+ thresholdPx: { type: Number, defaultValue: () => 8 },
392
+ /**
393
+ * @option
394
+ * A free `pan` locks to its dominant axis once recognized (`gesture-type`
395
+ * becomes `pan-x` / `pan-y`).
396
+ */
397
+ lockAxis: Boolean,
398
+ /**
399
+ * @option
400
+ * Pans only arm after this gesture — `long-press` for hold-then-drag.
401
+ * @values long-press
402
+ */
403
+ armAfter: String,
404
+ /**
405
+ * @option
406
+ * Hold time (ms) for `long-press`; also tap time limit.
407
+ * @default 500
408
+ */
409
+ longPressMs: { type: Number, defaultValue: () => 500 },
410
+ /**
411
+ * @option
412
+ * Max gap (ms) between taps for `double-tap`.
413
+ * @default 300
414
+ */
415
+ doubleTapMs: { type: Number, defaultValue: () => 300 },
416
+ /**
417
+ * @option
418
+ * Release velocity (px/ms) that counts as a swipe.
419
+ * @default 0.5
420
+ */
421
+ swipeMinVelocity: { type: Number, defaultValue: () => 0.5 },
422
+ /**
423
+ * @option
424
+ * Swipe directions to report. Unset = all four.
425
+ * @values left | right | up | down
426
+ */
427
+ swipeDirections: TokenList,
428
+ /**
429
+ * @option
430
+ * Direction `--gesture-progress` grows. Unset = `down` for a `pan-y`-only
431
+ * element, else `right`.
432
+ * @values up | down | left | right
433
+ */
434
+ progressAxis: String,
435
+ /**
436
+ * @option
437
+ * Element whose size along `progress-axis` is the range of `progress`
438
+ * `0`..`1` (`:scope`-relative, read once per gesture) — sheet being
439
+ * dragged, slide being swiped.
440
+ * @values <CSS Selector>
441
+ */
442
+ rangeRef: String,
443
+ /**
444
+ * @option
445
+ * Literal range (px) instead of `range-ref`.
446
+ */
447
+ rangePx: Number,
448
+ /**
449
+ * @option
450
+ * Lower bound of `progress`.
451
+ * @default 0
452
+ */
453
+ progressMin: { type: Number, defaultValue: () => 0 },
454
+ /**
455
+ * @option
456
+ * Upper bound of `progress`.
457
+ * @default 1
458
+ */
459
+ progressMax: { type: Number, defaultValue: () => 1 },
460
+ /**
461
+ * @option
462
+ * Progress the gesture starts from. Set from a rule that reads the driven
463
+ * element's state (`1` while a sheet is open) so dragging it closed
464
+ * starts full.
465
+ * @default 0
466
+ */
467
+ progressOffset: { type: Number, defaultValue: () => 0 },
468
+ /**
469
+ * @option
470
+ * Rubber-band past `progress-min` / `progress-max`: `0` clamps, `0.3`
471
+ * overshoots at a third of travel.
472
+ * @default 0
473
+ */
474
+ overshootResistance: { type: Number, defaultValue: () => 0 },
475
+ /**
476
+ * @option
477
+ * Progress values to settle on after release (`0 0.5 1`). Target picked
478
+ * from position, fling velocity and swipe direction, reported as
479
+ * `detail.snap` on `-end`, settled on by the default action (a CSS
480
+ * transition, `--gesture-snap-duration` / `--gesture-snap-ease`).
481
+ * @values <number>…
482
+ */
483
+ snapPoints: TokenList,
484
+ /**
485
+ * @option
486
+ * Fire `gesture-handler-move` every frame. Off by default; `--gesture-*`
487
+ * is enough for CSS.
488
+ */
489
+ shouldEmitMove: Boolean,
490
+ /**
491
+ * @option
492
+ * @state
493
+ * Ignore new pointers; a gesture in progress is cancelled. State-driven
494
+ * veto.
495
+ */
496
+ isDisabled: Boolean,
497
+ // state
498
+ /**
499
+ * @state
500
+ * A pointer is down on the surface. Set from first pointer until release,
501
+ * so it also covers taps and pre-threshold phase.
502
+ */
503
+ isActive: Boolean,
504
+ /**
505
+ * @state
506
+ * Recognized gesture in progress, unset before recognition and after
507
+ * release.
508
+ * @values pan | pan-x | pan-y | pinch | rotate
509
+ */
510
+ gestureType: String,
511
+ /**
512
+ * @state
513
+ * Pointers currently down.
514
+ */
515
+ pointerCount: Number,
516
+ /**
517
+ * @state
518
+ * Dominant travel direction of gesture in progress.
519
+ * @values left | right | up | down
520
+ */
521
+ gestureDirection: String,
522
+ /**
523
+ * @state
524
+ * What last gesture turned out to be — style a "just swiped" state from it.
525
+ * @values pan | pan-x | pan-y | pinch | rotate | swipe-left | swipe-right | swipe-up | swipe-down | tap | double-tap | long-press
526
+ */
527
+ lastGesture: String,
528
+ /**
529
+ * @provision
530
+ * Last `-start` / `-end` / `-cancel` snapshot (`type`, travel, velocity,
531
+ * progress, `snap`, `swipe`, …). Not an attribute; per-frame values live
532
+ * in `--gesture-*`.
533
+ * @type GestureHandlerProvision
534
+ */
535
+ provision: Object as unknown as ConstructorType<GestureHandlerProvision>,
536
+ // private
537
+ _session: {
538
+ type: Object as unknown as ConstructorType<Session>,
539
+ attr: false,
540
+ },
541
+ _handoff: {
542
+ type: Object as unknown as ConstructorType<Handoff>,
543
+ attr: false,
544
+ },
545
+ _raf: { type: Object as unknown as ConstructorType<Handle>, attr: false },
546
+ _holdTimer: {
547
+ type: Object as unknown as ConstructorType<Handle>,
548
+ attr: false,
549
+ },
550
+ _lastTapAt: { type: Number, attr: false },
551
+ },
552
+ }).withTypes<Methods>();
553
+
554
+ type El = typeof GestureHandler.CustomElement;
555
+
556
+ GestureHandler.defineMethods({
557
+ /** Long-press timer: fire event and/or arm the pan. */
558
+ _handleHold: ({ _session, gestureTypes, armAfter }) => {
559
+ if (!_session || _session.moved) return;
560
+ _session.longPressed = true;
561
+ _session.armed = _session.armed || armAfter === "long-press";
562
+ return (
563
+ gestureTypes.includes("long-press") && {
564
+ lastGesture: "long-press",
565
+ emit: ["long-press", { detail: point(_session) }],
566
+ }
567
+ );
568
+ },
569
+ /**
570
+ * One frame: recompute, write `--gesture-*`, recognize, announce. With a
571
+ * `phase` it is also the last frame — the release or abort that tears the
572
+ * session down (a flick that never got a frame is recognized here, so
573
+ * `-start` and `-end` both fire).
574
+ */
575
+ _flush: (element, phase?: Phase) => {
576
+ const { _session: s, _raf, _holdTimer } = element;
577
+ if (!s) return { _raf: null };
578
+ compute(s, element);
579
+ writeVars(element, VAR_FORMAT, s.values);
580
+ const recognized =
581
+ !s.type &&
582
+ phase !== "cancel" &&
583
+ (!s.rejected || s.pointers.size >= 2) &&
584
+ recognize(element, s);
585
+ if (recognized) {
586
+ s.type = recognized;
587
+ capturePointers(element, s);
588
+ } else if (s.moved && !s.type) {
589
+ s.rejected = true;
590
+ }
591
+ const { dx, dy } = s.values;
592
+ const gestureDirection = s.moved ? dominantDirection(dx, dy) : null;
593
+ const provision = snapshot(s);
594
+ const frame: unknown[] = [
595
+ { _raf: null },
596
+ gestureDirection !== element.gestureDirection && { gestureDirection },
597
+ !!recognized && {
598
+ gestureType: recognized,
599
+ provision,
600
+ emit: ["start", { detail: provision }],
601
+ },
602
+ ];
603
+ if (!phase) {
604
+ return [
605
+ ...frame,
606
+ !recognized &&
607
+ !!s.type &&
608
+ !!element.shouldEmitMove && {
609
+ emit: ["move", { detail: provision }],
610
+ },
611
+ ];
612
+ }
613
+ cancelAnimationFrame(_raf as number);
614
+ clearTimeout(_holdTimer ?? undefined);
615
+ const effects: unknown[] = [
616
+ ...frame,
617
+ {
618
+ _session: null,
619
+ _raf: null,
620
+ _holdTimer: null,
621
+ isActive: false,
622
+ gestureType: null,
623
+ pointerCount: null,
624
+ },
625
+ ...windowListeners(element, "removeListener"),
626
+ ];
627
+ if (phase === "cancel") {
628
+ return [
629
+ ...effects,
630
+ !!s.type && {
631
+ provision,
632
+ emit: ["cancel", { detail: provision }],
633
+ },
634
+ ];
635
+ }
636
+ if (!s.type) {
637
+ return [...effects, ...tapEffects(element, s)];
638
+ }
639
+ const swipe = detectSwipe(element, s);
640
+ const snap = pickSnap(element, s, swipe);
641
+ const detail = { ...provision, snap, swipe };
642
+ return [
643
+ ...effects,
644
+ { lastGesture: swipe ? `swipe-${swipe}` : s.type, provision: detail },
645
+ !!swipe && {
646
+ emits: ["swipe", `swipe-${swipe}`].map((type) => [
647
+ type,
648
+ { detail: { direction: swipe, velocity: speed(s.values) } },
649
+ ]),
650
+ },
651
+ { emit: ["end", { detail }] },
652
+ ];
653
+ },
654
+ /** The settle transition landed (or there was none). */
655
+ _emitSnap: (_element, detail: GestureHandlerSnapEvent["detail"]) => ({
656
+ emit: ["snap", { detail }],
657
+ }),
658
+ /** Window `pointermove`: track the pointer, schedule a frame. */
659
+ _handleMove: (element, e: PointerEvent) => {
660
+ const { _session: s, _raf } = element;
661
+ const pointer = s?.pointers.get(e.pointerId);
662
+ if (!s || !pointer) return;
663
+ pointer.x = e.clientX;
664
+ pointer.y = e.clientY;
665
+ return !_raf && { _raf: scheduleFrame(element) };
666
+ },
667
+ /** Release / cancel of one pointer; last one settles. */
668
+ _handleUp: (element, e: PointerEvent) => {
669
+ const { _session: s, _raf } = element;
670
+ const pointer = s?.pointers.get(e.pointerId);
671
+ if (!s || !pointer) return;
672
+ pointer.x = e.clientX;
673
+ pointer.y = e.clientY;
674
+ // Last pointer settles with release still in session so fling velocity survives.
675
+ if (s.pointers.size === 1) {
676
+ return { _flush: [e.type === "pointercancel" ? "cancel" : "end"] };
677
+ }
678
+ rebase(s, () => s.pointers.delete(e.pointerId));
679
+ return [
680
+ { pointerCount: s.pointers.size },
681
+ !_raf && { _raf: scheduleFrame(element) },
682
+ ];
683
+ },
684
+ /**
685
+ * `handoff-ref`: first move leaves container scrolling or cancels it and
686
+ * opens a session.
687
+ */
688
+ _handleHandoff: (element, event: Event) => handoffEffects(element, event),
689
+ })
690
+ .onPropSet("handoffRef", ({ _handleHandoff }) => ({
691
+ addListeners: HANDOFF_EVENTS.map((name) => [
692
+ name,
693
+ _handleHandoff,
694
+ // Only `touchmove` can cancel: browser scroll stops only on first move
695
+ { passive: name !== "touchmove" },
696
+ ]),
697
+ }))
698
+ .onPropUnset("handoffRef", ({ _handleHandoff }) => ({
699
+ removeListeners: HANDOFF_EVENTS.map((name) => [name, _handleHandoff]),
700
+ }))
701
+ .onEvent("pointerdown", (element, event) => {
702
+ const e = event as unknown as PointerEvent;
703
+ if (!accepts(element, e)) {
704
+ // Inside `handoff-ref` container, browser scrolls first; first move decides handoff.
705
+ return { _handoff: pendingHandoff(element, e) };
706
+ }
707
+ const { _session, _raf } = element;
708
+ const session = _session || createSession(element, e.pointerType);
709
+ rebase(session, () =>
710
+ session.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
711
+ );
712
+ if (session.type) capturePointers(element, session);
713
+ return [
714
+ ...(_session ? [] : beginEffects(element, session)),
715
+ { _handoff: null, pointerCount: session.pointers.size },
716
+ !_raf && { _raf: scheduleFrame(element) },
717
+ ];
718
+ })
719
+ /**
720
+ * Settle on the snap point. One write, then CSS eases it (the element's
721
+ * own `transition` on `--gesture-progress`, off while `is-active`). The
722
+ * running transition tells us when it lands, and by rejecting, that a
723
+ * new gesture took over; nothing to animate means it already landed.
724
+ */
725
+ .onEventDefault("end", (element, e) => {
726
+ const { snap } = e.detail as GestureHandlerProvision;
727
+ const points = snapPoints(element);
728
+ if (snap === null || !points.length) return;
729
+ const detail = {
730
+ value: snap,
731
+ index: (element.snapPoints || []).map(Number).indexOf(snap),
732
+ };
733
+ element.style.setProperty("--gesture-progress", num(snap));
734
+ const settle = (element.getAnimations?.() ?? []).find(
735
+ (animation) =>
736
+ (animation as Animation & { transitionProperty?: string })
737
+ .transitionProperty === "--gesture-progress"
738
+ );
739
+ if (!settle) return { _emitSnap: [detail] };
740
+ settle.finished.then(
741
+ () => element._emitSnap(detail),
742
+ () => {} // cancelled by the next gesture
743
+ );
744
+ })
745
+ .onPropSet("isDisabled", ({ _session, _handoff }) => [
746
+ !!_handoff && { _handoff: null },
747
+ !!_session && { _flush: ["cancel"] },
748
+ ])
749
+ .onDisconnected(
750
+ // DOM move keeps tracked window listeners; real removal cancels
751
+ ({ isMoving, _session }) =>
752
+ !isMoving && !!_session && { _flush: ["cancel"] }
753
+ );
754
+
755
+ const now = () => performance.now();
756
+ const round = (v: number, digits = 2) => {
757
+ const f = 10 ** digits;
758
+ return Math.round(v * f) / f || 0;
759
+ };
760
+ /** `requestAnimationFrame` passes a timestamp; `_flush`'s argument is a phase. */
761
+ const scheduleFrame = (element: El) =>
762
+ requestAnimationFrame(() => element._flush());
763
+ const kebab = (key: string) =>
764
+ key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
765
+ const speed = ({ vx, vy }: GestureHandlerValues) =>
766
+ round(Math.hypot(vx, vy), 4);
767
+
768
+ /** `snap-points` inside `progress-min`..`progress-max` (a rule may narrow bounds per state). */
769
+ const snapPoints = ({ snapPoints: points, progressMin, progressMax }: El) =>
770
+ (points || [])
771
+ .map(Number)
772
+ .filter((n) => n >= progressMin && n <= progressMax);
773
+
774
+ const maxPointers = ({ maxPointers: max, gestureTypes }: El) =>
775
+ max ?? (MULTI_TYPES.some((t) => gestureTypes.includes(t)) ? 2 : 1);
776
+
777
+ function accepts(element: El, e: PointerEvent): boolean {
778
+ const { isDisabled, pointerTypes, _session } = element;
779
+ if (isDisabled || e.button > 0 || !pointerTypes.includes(e.pointerType)) {
780
+ return false;
781
+ }
782
+ if (_session) {
783
+ return (
784
+ _session.pointerType === e.pointerType &&
785
+ _session.pointers.size < maxPointers(element)
786
+ );
787
+ }
788
+ return originAllowed(element, e);
789
+ }
790
+
791
+ /**
792
+ * `from-ref` / `from-edge`: neither set = anywhere; both set = either.
793
+ * Start inside a `handoff-ref` container is never immediate — waits for
794
+ * first move, unless `from-ref` / `from-edge` already claimed it.
795
+ */
796
+ function originAllowed(element: El, e: PointerEvent): boolean {
797
+ const { fromRef, fromEdge, edgePx, handoffRef } = element;
798
+ if (!fromRef && !fromEdge?.length && !handoffRef) return true;
799
+ if (
800
+ fromRef &&
801
+ selectOne(fromRef, { scope: element })?.contains(e.target as Node)
802
+ ) {
803
+ return true;
804
+ }
805
+ if (fromEdge?.length) {
806
+ const r = element.getBoundingClientRect();
807
+ const insets: Record<string, number> = {
808
+ left: e.clientX - r.left,
809
+ right: r.right - e.clientX,
810
+ top: e.clientY - r.top,
811
+ bottom: r.bottom - e.clientY,
812
+ };
813
+ if (fromEdge.some((edge) => insets[edge] <= edgePx)) return true;
814
+ }
815
+ if (handoffContainer(element, e.target)) return false;
816
+ return !fromRef && !fromEdge?.length;
817
+ }
818
+
819
+ /** Innermost `handoff-ref` container this node sits in, if any (matches
820
+ * in document order; last containing match is nearest — a scrolling editor
821
+ * inside a non-scrolling sheet must be judged by its own scroll, not the sheet's). */
822
+ function handoffContainer(element: El, node: EventTarget | null) {
823
+ const { handoffRef } = element;
824
+ if (!handoffRef || !node) return null;
825
+ const containers = (selectAll(handoffRef, { scope: element }) || []).filter(
826
+ (container) => container.contains(node as Node)
827
+ );
828
+ return containers[containers.length - 1] || null;
829
+ }
830
+
831
+ function pendingHandoff(element: El, e: PointerEvent): Handoff | null {
832
+ const { isDisabled, pointerTypes, _session } = element;
833
+ if (_session || isDisabled || e.button > 0) return null;
834
+ if (!pointerTypes.includes(e.pointerType)) return null;
835
+ const container = handoffContainer(element, e.target);
836
+ return (
837
+ container && {
838
+ pointerId: e.pointerId,
839
+ pointerType: e.pointerType,
840
+ x: e.clientX,
841
+ y: e.clientY,
842
+ container,
843
+ }
844
+ );
845
+ }
846
+
847
+ /** Direction `--gesture-progress` grows, as `createSession` reads it. */
848
+ function progressDirection({
849
+ gestureTypes,
850
+ progressAxis,
851
+ }: El): GestureDirection {
852
+ const yOnly =
853
+ gestureTypes.includes("pan-y") && !gestureTypes.includes("pan-x");
854
+ const named = (progressAxis ||
855
+ (yOnly ? "down" : "right")) as GestureDirection;
856
+ return AXES[named] ? named : "right";
857
+ }
858
+
859
+ /** Is container scrolled as far as a finger moving `direction` can take it? */
860
+ function atScrollLimit(container: Element, direction: GestureDirection) {
861
+ const { scrollTop, scrollLeft, scrollHeight, scrollWidth } = container;
862
+ const { clientHeight, clientWidth } = container;
863
+ switch (direction) {
864
+ case "down":
865
+ return scrollTop <= LIMIT_EPSILON;
866
+ case "up":
867
+ return scrollTop >= scrollHeight - clientHeight - LIMIT_EPSILON;
868
+ case "right":
869
+ return scrollLeft <= LIMIT_EPSILON;
870
+ default:
871
+ return scrollLeft >= scrollWidth - clientWidth - LIMIT_EPSILON;
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Does this first move hand over? Must run along `progress-axis`, find
877
+ * container at its limit that way, and have progress left to travel.
878
+ */
879
+ function handsOver(element: El, container: Element, dx: number, dy: number) {
880
+ const forward = progressDirection(element);
881
+ const [ax, ay] = AXES[forward];
882
+ const along = dx * ax + dy * ay;
883
+ const across = Math.abs(dx * ay - dy * ax);
884
+ if (Math.abs(along) < HANDOFF_MIN_PX || Math.abs(along) <= across) {
885
+ return false;
886
+ }
887
+ const { progressOffset, progressMin, progressMax } = element;
888
+ const headroom =
889
+ along > 0 ? progressOffset < progressMax : progressOffset > progressMin;
890
+ return (
891
+ headroom &&
892
+ atScrollLimit(container, along > 0 ? forward : OPPOSITE[forward])
893
+ );
894
+ }
895
+
896
+ function handoffEffects(element: El, event: Event): unknown[] | undefined {
897
+ const { _handoff: h, _session } = element;
898
+ if (!h || _session) return;
899
+ if (event.type !== "touchmove" && event.type !== "pointermove") {
900
+ return [{ _handoff: null }]; // released or taken by browser
901
+ }
902
+ const touch = (event as TouchEvent).touches?.[0];
903
+ // Touches judged on `touchmove` (scroll still cancellable); mouse has no native drag-scroll
904
+ const pointer = touch || (event as PointerEvent);
905
+ if (!touch) {
906
+ const e = event as PointerEvent;
907
+ if (e.pointerType !== "mouse" || e.pointerId !== h.pointerId) return;
908
+ }
909
+ const dx = pointer.clientX - h.x;
910
+ const dy = pointer.clientY - h.y;
911
+ if (Math.hypot(dx, dy) < HANDOFF_MIN_PX) return; // too early to tell
912
+ if (!handsOver(element, h.container, dx, dy)) {
913
+ return [{ _handoff: null }]; // container scrolls; leave it
914
+ }
915
+ if (touch) event.preventDefault(); // no native scroll for this sequence
916
+ const session = createSession(element, h.pointerType);
917
+ rebase(session, () => session.pointers.set(h.pointerId, { x: h.x, y: h.y }));
918
+ return [
919
+ ...beginEffects(element, session),
920
+ { _handoff: null, pointerCount: 1 },
921
+ {
922
+ _handleMove: [
923
+ {
924
+ pointerId: h.pointerId,
925
+ clientX: pointer.clientX,
926
+ clientY: pointer.clientY,
927
+ },
928
+ ],
929
+ },
930
+ ];
931
+ }
932
+
933
+ /** Range, axis, start offset — read once when gesture begins. */
934
+ function createSession(element: El, pointerType: string): Session {
935
+ const { rangeRef, rangePx, progressOffset } = element;
936
+ const axis = AXES[progressDirection(element)];
937
+ const target = rangeRef ? selectOne(rangeRef, { scope: element }) : null;
938
+ const range =
939
+ rangePx ??
940
+ (target ? (axis[0] ? target.offsetWidth : target.offsetHeight) : 0);
941
+ const rect = element.getBoundingClientRect();
942
+ return {
943
+ pointerType,
944
+ pointers: new Map(),
945
+ rect,
946
+ t0: now(),
947
+ acc: [0, 0],
948
+ base: [0, 0],
949
+ baseDist: 0,
950
+ baseAngle: 0,
951
+ scaleBase: 1,
952
+ rotateBase: 0,
953
+ samples: [],
954
+ distance: 0,
955
+ moved: false,
956
+ armed: !element.armAfter,
957
+ rejected: false,
958
+ captured: new Set(),
959
+ longPressed: false,
960
+ type: null,
961
+ axis,
962
+ rangePx: range,
963
+ offsetPx: progressOffset * range,
964
+ values: {
965
+ ...restingValues(),
966
+ progress: range ? progressOffset : 0,
967
+ },
968
+ };
969
+ }
970
+
971
+ /** Every frame value at rest — the write table is the list of them. */
972
+ const restingValues = () =>
973
+ ({
974
+ ...Object.keys(VAR_FORMAT).reduce((all, key) => ({ ...all, [key]: 0 }), {}),
975
+ scale: 1,
976
+ }) as GestureHandlerValues;
977
+
978
+ /** Open-session effects: the per-gesture constants, state, hold timer, window listeners. */
979
+ function beginEffects(element: El, session: Session): unknown[] {
980
+ const { _handleHold, longPressMs } = element;
981
+ const { width, height } = session.rect;
982
+ writeVars(element, SESSION_FORMAT, {
983
+ rangePx: session.rangePx,
984
+ width,
985
+ height,
986
+ } satisfies SessionValues);
987
+ return [
988
+ {
989
+ _session: session,
990
+ isActive: true,
991
+ _holdTimer: setTimeout(_handleHold, longPressMs),
992
+ },
993
+ ...windowListeners(element, "addListener"),
994
+ ];
995
+ }
996
+
997
+ function centroid(s: Session): Vec {
998
+ const pts = [...s.pointers.values()];
999
+ // Empty only in the `rebase` that seats the first pointer, where `base` is still the identity.
1000
+ if (!pts.length) return s.base;
1001
+ return [
1002
+ pts.reduce((sum, p) => sum + p.x, 0) / pts.length,
1003
+ pts.reduce((sum, p) => sum + p.y, 0) / pts.length,
1004
+ ];
1005
+ }
1006
+
1007
+ /** Keep travel / scale / rotation continuous across a pointer join or leave. */
1008
+ function rebase(s: Session, change: () => void) {
1009
+ const [cx, cy] = centroid(s);
1010
+ s.acc = [s.acc[0] + cx - s.base[0], s.acc[1] + cy - s.base[1]];
1011
+ s.scaleBase = s.values.scale;
1012
+ s.rotateBase = s.values.rotate;
1013
+ change();
1014
+ s.base = centroid(s);
1015
+ const [a, b] = [...s.pointers.values()];
1016
+ s.baseDist = b ? Math.hypot(b.x - a.x, b.y - a.y) : 0;
1017
+ s.baseAngle = b ? Math.atan2(b.y - a.y, b.x - a.x) : 0;
1018
+ // Seed velocity window at new base so a flick before first frame still has speed
1019
+ s.samples = [[now(), s.base[0], s.base[1]]];
1020
+ }
1021
+
1022
+ /** Capture after recognition so taps / clicks below stay native. */
1023
+ function capturePointers(element: El, s: Session) {
1024
+ s.pointers.forEach((_, id) => {
1025
+ if (s.captured.has(id)) return;
1026
+ s.captured.add(id);
1027
+ try {
1028
+ element.setPointerCapture(id);
1029
+ } catch {
1030
+ /* pointer already gone */
1031
+ }
1032
+ });
1033
+ }
1034
+
1035
+ function compute(
1036
+ s: Session,
1037
+ { progressMin, progressMax, overshootResistance, thresholdPx }: El
1038
+ ) {
1039
+ const [cx, cy] = centroid(s);
1040
+ const t = now();
1041
+ s.samples = [
1042
+ ...s.samples.filter(([st]) => t - st <= VELOCITY_WINDOW_MS),
1043
+ [t, cx, cy],
1044
+ ];
1045
+ const [t0, x0, y0] = s.samples[0];
1046
+ const dt = t - t0;
1047
+ const dx = s.acc[0] + cx - s.base[0];
1048
+ const dy = s.acc[1] + cy - s.base[1];
1049
+ const [a, b] = [...s.pointers.values()];
1050
+ const dist = b ? Math.hypot(b.x - a.x, b.y - a.y) : 0;
1051
+ const along = dx * s.axis[0] + dy * s.axis[1];
1052
+ const range = s.rangePx;
1053
+ const progressPx = range
1054
+ ? soft(
1055
+ s.offsetPx + along,
1056
+ progressMin * range,
1057
+ progressMax * range,
1058
+ overshootResistance
1059
+ )
1060
+ : s.offsetPx + along;
1061
+ const { left, top } = s.rect;
1062
+ s.distance = Math.hypot(dx, dy);
1063
+ s.moved ||= s.distance >= thresholdPx;
1064
+ s.values = {
1065
+ x: cx - left,
1066
+ y: cy - top,
1067
+ dx,
1068
+ dy,
1069
+ vx: dt ? (cx - x0) / dt : 0,
1070
+ vy: dt ? (cy - y0) / dt : 0,
1071
+ progress: range ? progressPx / range : 0,
1072
+ scale: b && s.baseDist ? s.scaleBase * (dist / s.baseDist) : s.scaleBase,
1073
+ rotate: b
1074
+ ? s.rotateBase +
1075
+ ((Math.atan2(b.y - a.y, b.x - a.x) - s.baseAngle) * 180) / Math.PI
1076
+ : s.rotateBase,
1077
+ pointers: s.pointers.size,
1078
+ };
1079
+ }
1080
+
1081
+ /** Clamp with rubber-banding past bounds. */
1082
+ const soft = (v: number, lo: number, hi: number, k: number) =>
1083
+ v < lo ? lo + (v - lo) * k : v > hi ? hi + (v - hi) * k : v;
1084
+
1085
+ function recognize(
1086
+ { gestureTypes: types, lockAxis }: El,
1087
+ s: Session
1088
+ ): GestureType | null {
1089
+ if (s.pointers.size >= 2) {
1090
+ return MULTI_TYPES.find((t) => types.includes(t)) || null;
1091
+ }
1092
+ if (!s.moved || !s.armed) return null;
1093
+ const { dx, dy } = s.values;
1094
+ const horizontal = Math.abs(dx) >= Math.abs(dy);
1095
+ const pans = types.filter((t) => PAN_TYPES.includes(t));
1096
+ const free =
1097
+ pans.includes("pan") || (!pans.length && types.includes("swipe"));
1098
+ if (free) return lockAxis ? (horizontal ? "pan-x" : "pan-y") : "pan";
1099
+ if (pans.includes("pan-x") && horizontal) return "pan-x";
1100
+ if (pans.includes("pan-y") && !horizontal) return "pan-y";
1101
+ return null;
1102
+ }
1103
+
1104
+ const dominantDirection = (dx: number, dy: number): GestureDirection =>
1105
+ Math.abs(dx) >= Math.abs(dy)
1106
+ ? dx < 0
1107
+ ? "left"
1108
+ : "right"
1109
+ : dy < 0
1110
+ ? "up"
1111
+ : "down";
1112
+
1113
+ function detectSwipe(
1114
+ { gestureTypes, swipeMinVelocity, swipeDirections }: El,
1115
+ s: Session
1116
+ ): GestureDirection | null {
1117
+ if (!gestureTypes.includes("swipe")) return null;
1118
+ const { vx, vy } = s.values;
1119
+ const horizontal = Math.abs(vx) >= Math.abs(vy);
1120
+ if (Math.abs(horizontal ? vx : vy) < swipeMinVelocity) return null;
1121
+ if (
1122
+ (s.type === "pan-x" && !horizontal) ||
1123
+ (s.type === "pan-y" && horizontal)
1124
+ ) {
1125
+ return null;
1126
+ }
1127
+ const direction = dominantDirection(vx, vy);
1128
+ return swipeDirections?.length && !swipeDirections.includes(direction)
1129
+ ? null
1130
+ : direction;
1131
+ }
1132
+
1133
+ /** Snap target: next point in swipe direction, else nearest to fling projection. */
1134
+ function pickSnap(
1135
+ element: El,
1136
+ s: Session,
1137
+ swipe: GestureDirection | null
1138
+ ): number | null {
1139
+ const points = snapPoints(element);
1140
+ if (!points.length) return null;
1141
+ const { progress, vx, vy } = s.values;
1142
+ const { rangePx } = s;
1143
+ const swipeAlong = swipe
1144
+ ? AXES[swipe][0] * s.axis[0] + AXES[swipe][1] * s.axis[1]
1145
+ : 0;
1146
+ const ahead = points.filter((p) =>
1147
+ swipeAlong > 0 ? p > progress : p < progress
1148
+ );
1149
+ if (swipeAlong && ahead.length) {
1150
+ return swipeAlong > 0 ? Math.min(...ahead) : Math.max(...ahead);
1151
+ }
1152
+ const vAlong = vx * s.axis[0] + vy * s.axis[1];
1153
+ const projected = rangePx
1154
+ ? progress + (vAlong * PROJECTION_MS) / rangePx
1155
+ : progress;
1156
+ return points.reduce((best, p) =>
1157
+ Math.abs(p - projected) < Math.abs(best - projected) ? p : best
1158
+ );
1159
+ }
1160
+
1161
+ /** Tap / double-tap on a release that never moved nor long-pressed. */
1162
+ function tapEffects(
1163
+ { gestureTypes, doubleTapMs, _lastTapAt }: El,
1164
+ s: Session
1165
+ ): unknown[] {
1166
+ if (s.moved || s.longPressed) return [];
1167
+ const t = now();
1168
+ const isDouble =
1169
+ gestureTypes.includes("double-tap") &&
1170
+ !!_lastTapAt &&
1171
+ t - _lastTapAt <= doubleTapMs;
1172
+ const detail = point(s);
1173
+ return [
1174
+ { _lastTapAt: isDouble ? null : t },
1175
+ gestureTypes.includes("tap") && {
1176
+ lastGesture: "tap",
1177
+ emit: ["tap", { detail }],
1178
+ },
1179
+ isDouble && {
1180
+ lastGesture: "double-tap",
1181
+ emit: ["double-tap", { detail }],
1182
+ },
1183
+ ];
1184
+ }
1185
+
1186
+ const point = (s: Session) => ({ x: round(s.values.x), y: round(s.values.y) });
1187
+
1188
+ const snapshot = (s: Session): GestureHandlerProvision => ({
1189
+ ...s.values,
1190
+ type: s.type,
1191
+ pointerType: s.pointerType,
1192
+ distance: s.distance,
1193
+ rangePx: s.rangePx,
1194
+ durationMs: round(now() - s.t0),
1195
+ snap: null,
1196
+ swipe: null,
1197
+ });
1198
+
1199
+ /** One table's values onto the element as `--gesture-<kebab key>`. */
1200
+ function writeVars<K extends string>(
1201
+ element: El,
1202
+ table: Record<K, (v: number) => string>,
1203
+ values: Record<K, number>
1204
+ ) {
1205
+ (Object.keys(table) as K[]).forEach((key) =>
1206
+ element.style.setProperty(
1207
+ `--gesture-${kebab(key)}`,
1208
+ table[key](values[key])
1209
+ )
1210
+ );
1211
+ }