@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.
- package/.rush/temp/chunked-rush-logs/gesture-handler.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/gesture-handler.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/gesture-handler.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/gesture-handler.ts +1211 -0
- package/index.css +5 -0
- package/index.ts +29 -0
- package/package.json +52 -0
- package/rush-logs/gesture-handler.apply-exports.cache.log +1 -0
- package/rush-logs/gesture-handler.apply-exports.log +1 -0
- package/rush-logs/gesture-handler.build_docs.cache.log +1 -0
- package/rush-logs/gesture-handler.build_docs.log +1 -0
- package/rush-logs/gesture-handler.build_package-metas.cache.log +1 -0
- package/rush-logs/gesture-handler.build_package-metas.log +1 -0
- package/src/gesture-handler.css +207 -0
- package/support/custom-elements.json +989 -0
- package/support/demos/carousel.html +44 -0
- package/support/demos/pinch.html +10 -0
- package/support/demos/sheet.html +50 -0
- package/support/demos/swipe.html +24 -0
- package/support/dist-docs/gesture-handler.md +339 -0
- package/support/docs/README.md +92 -0
- package/support/package-meta.json +489 -0
- package/support/tests/carousel.view.test.ts +67 -0
- package/support/tests/gesture-handler.test.ts +625 -0
- package/support/tests/pointer-utils.ts +62 -0
- package/support/tests/sheet.view.test.ts +112 -0
- package/support/tests/swipe.view.test.ts +44 -0
- 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
|
+
}
|