@humanspeak/svelte-motion 0.5.1 → 0.5.3

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.
@@ -0,0 +1,426 @@
1
+ import { cancelFrame, frame, frameData, isPrimaryPointer } from 'motion-dom';
2
+ /**
3
+ * Brand we stamp on already-wrapped handlers so passing them back through
4
+ * `wrapHandlers` (e.g. by a future middleware layer) doesn't double-defer
5
+ * — that would compound frame latency invisibly per wrap depth. Symbol
6
+ * scoping keeps it private to this module.
7
+ */
8
+ const WRAPPED_BRAND = Symbol('svelte-motion:pan:wrapped');
9
+ const wrapUpdate = (handler, isAlive) => {
10
+ if (!handler)
11
+ return undefined;
12
+ if (handler[WRAPPED_BRAND])
13
+ return handler;
14
+ const wrapped = (event, info) => {
15
+ frame.update(() => {
16
+ // `isAlive` flips false on teardown; any frame.update closure
17
+ // queued before teardown but not yet flushed will see this and
18
+ // short-circuit — that's our cancellation path for the
19
+ // otherwise-uncancellable anonymous closures `frame.update`
20
+ // accepts.
21
+ if (!isAlive())
22
+ return;
23
+ handler(event, info);
24
+ }, false, true);
25
+ };
26
+ Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true });
27
+ return wrapped;
28
+ };
29
+ const wrapPostRender = (handler, isAlive) => {
30
+ if (!handler)
31
+ return undefined;
32
+ if (handler[WRAPPED_BRAND])
33
+ return handler;
34
+ const wrapped = (event, info) => {
35
+ frame.postRender(() => {
36
+ if (!isAlive())
37
+ return;
38
+ handler(event, info);
39
+ });
40
+ };
41
+ Object.defineProperty(wrapped, WRAPPED_BRAND, { value: true });
42
+ return wrapped;
43
+ };
44
+ const wrapHandlers = (handlers, isAlive) => ({
45
+ onSessionStart: wrapUpdate(handlers.onSessionStart, isAlive),
46
+ onStart: wrapUpdate(handlers.onStart, isAlive),
47
+ onMove: wrapUpdate(handlers.onMove, isAlive),
48
+ onEnd: wrapPostRender(handlers.onEnd, isAlive),
49
+ onSessionEnd: wrapPostRender(handlers.onSessionEnd, isAlive)
50
+ });
51
+ const overflowStyles = new Set(['auto', 'scroll']);
52
+ const millisecondsToSeconds = (ms) => ms / 1000;
53
+ const secondsToMilliseconds = (s) => s * 1000;
54
+ const subtractPoint = (a, b) => ({
55
+ x: a.x - b.x,
56
+ y: a.y - b.y
57
+ });
58
+ const distance2D = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
59
+ /**
60
+ * Compute velocity (px/s) from the history of timestamped points,
61
+ * looking back `timeDelta` seconds for stability. Matches upstream's
62
+ * `getVelocity` including the hold-then-flick safeguard (skip
63
+ * history[0] if it's > 2× timeDelta old AND there are alternatives).
64
+ */
65
+ const getVelocity = (history, timeDelta) => {
66
+ if (history.length < 2)
67
+ return { x: 0, y: 0 };
68
+ let i = history.length - 1;
69
+ let timestampedPoint = null;
70
+ const lastPoint = history[history.length - 1];
71
+ while (i >= 0) {
72
+ timestampedPoint = history[i];
73
+ if (lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta)) {
74
+ break;
75
+ }
76
+ i--;
77
+ }
78
+ if (!timestampedPoint)
79
+ return { x: 0, y: 0 };
80
+ if (timestampedPoint === history[0] &&
81
+ history.length > 2 &&
82
+ lastPoint.timestamp - timestampedPoint.timestamp > secondsToMilliseconds(timeDelta) * 2) {
83
+ timestampedPoint = history[1];
84
+ }
85
+ const time = millisecondsToSeconds(lastPoint.timestamp - timestampedPoint.timestamp);
86
+ if (time === 0)
87
+ return { x: 0, y: 0 };
88
+ const v = {
89
+ x: (lastPoint.x - timestampedPoint.x) / time,
90
+ y: (lastPoint.y - timestampedPoint.y) / time
91
+ };
92
+ if (v.x === Infinity)
93
+ v.x = 0;
94
+ if (v.y === Infinity)
95
+ v.y = 0;
96
+ return v;
97
+ };
98
+ const getPanInfo = (point, history) => ({
99
+ point,
100
+ delta: subtractPoint(point, history[history.length - 1]),
101
+ offset: subtractPoint(point, history[0]),
102
+ velocity: getVelocity(history, 0.1)
103
+ });
104
+ const extractEventPoint = (event) => ({
105
+ x: event.pageX,
106
+ y: event.pageY
107
+ });
108
+ /**
109
+ * Attach a pan gesture session to `el`. Returns a cleanup function that
110
+ * tears down the pointerdown listener and ends any in-flight session,
111
+ * with a `.update(next)` method for hot-swapping handlers mid-gesture.
112
+ *
113
+ * Internally a fresh `PanSession` spawns on each pointerdown — the
114
+ * outer attachment just keeps the pointerdown listener alive across the
115
+ * element's lifetime.
116
+ *
117
+ * SSR-safe: returns a no-op cleanup if `window` is undefined. The Svelte
118
+ * `$effect` consumer never fires on the server anyway, but defending the
119
+ * boundary lets the module load cleanly in node-only test runners.
120
+ *
121
+ * Lifecycle guarantee: when the returned cleanup runs mid-gesture, the
122
+ * session synthesizes `onEnd` + `onSessionEnd` against the raw handlers
123
+ * BEFORE removing listeners (see `PanSession.dispatchTerminal`). Hosts
124
+ * (e.g. `_MotionContainer`'s pan `$effect`) can put their `whilePan`
125
+ * revert logic inside the user-supplied `onEnd` and rely on it firing
126
+ * exactly once per gesture — whether the user released or the host
127
+ * forced teardown.
128
+ *
129
+ * @param el Target element to bind `pointerdown` on. Move/up/cancel
130
+ * events are listened for on the element's owning window so a fast
131
+ * swipe past the element's bounds keeps the gesture alive.
132
+ * @param handlers Pan lifecycle handlers. Any subset of
133
+ * `onSessionStart` (fires on pointerdown), `onStart` (fires the first
134
+ * time the cumulative offset crosses `distanceThreshold`), `onMove`
135
+ * (per-frame-throttled on every pointermove past threshold), `onEnd`
136
+ * (fires on pointerup/cancel if `onStart` ever fired), `onSessionEnd`
137
+ * (fires on every pointerup/cancel where a pointermove occurred).
138
+ * @param options Per-session config. `distanceThreshold` (default 3px)
139
+ * gates the start callback; `contextWindow` overrides the owning
140
+ * window (use for shadow-root / iframe scenarios).
141
+ * @returns A cleanup function with an attached `.update(next)` method.
142
+ * Calling the cleanup ends the session + removes the pointerdown
143
+ * listener. Calling `.update(next)` swaps handlers in place on the
144
+ * live session without rebuilding it — the canonical Svelte pattern
145
+ * for inline arrow handlers that change identity each render.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * const cleanup = attachPan(node, {
150
+ * onStart: (_event, info) => console.log('start', info.offset),
151
+ * onMove: (_event, info) => x.set(info.offset.x),
152
+ * onEnd: (_event, info) => {
153
+ * if (Math.abs(info.velocity.x) > 600) commit()
154
+ * else animate(x, 0, { type: 'spring' })
155
+ * }
156
+ * })
157
+ *
158
+ * // Later, swap handlers without ending the live gesture:
159
+ * cleanup.update({ onMove: (_e, info) => x.set(info.offset.x * 2) })
160
+ *
161
+ * // On unmount:
162
+ * cleanup()
163
+ * ```
164
+ */
165
+ export const attachPan = (el, handlers, options = {}) => {
166
+ if (typeof window === 'undefined') {
167
+ const noop = () => { };
168
+ return Object.assign(noop, { update: () => { } });
169
+ }
170
+ const contextWindow = options.contextWindow ?? el.ownerDocument?.defaultView ?? window;
171
+ const distanceThreshold = options.distanceThreshold ?? 3;
172
+ let session = null;
173
+ let rawHandlers = handlers;
174
+ // Liveness flag the wrapped handler closures consult before invoking
175
+ // the user callback. Flips false at teardown so any frame.update /
176
+ // frame.postRender callbacks queued before teardown — but not yet
177
+ // flushed — see the flag and skip dispatch. This is our only way to
178
+ // cancel the anonymous closures the wrappers schedule (frame.update
179
+ // doesn't return a handle we can store per call).
180
+ let isAlive = true;
181
+ const aliveGuard = () => isAlive;
182
+ // Frame-scheduled mirror of the live handlers — onSessionStart / onStart /
183
+ // onMove are queued onto motion-dom's `update` step, onEnd / onSessionEnd
184
+ // onto `postRender`. This is the wrap upstream applies via `asyncHandler`
185
+ // + `frame.postRender` in PanGesture.createPanHandlers; see the
186
+ // wrapUpdate / wrapPostRender helpers at the top of this file for the
187
+ // rationale. PanSession itself stays scheduler-unaware (and synchronous,
188
+ // for testability) — the scheduling lives at the `attachPan` boundary.
189
+ let liveHandlers = wrapHandlers(handlers, aliveGuard);
190
+ const onPointerDown = (event) => {
191
+ // Match upstream: ignore non-primary pointers (multi-touch, right-click).
192
+ if (!isPrimaryPointer(event))
193
+ return;
194
+ // Defensively end any prior session before overwriting the reference.
195
+ // Without this, a second primary pointerdown that arrives before the
196
+ // first pointerup orphans the prior session's contextWindow listeners.
197
+ session?.end();
198
+ session = new PanSession(event, liveHandlers, {
199
+ distanceThreshold,
200
+ contextWindow,
201
+ element: el
202
+ });
203
+ };
204
+ el.addEventListener('pointerdown', onPointerDown);
205
+ const update = (next) => {
206
+ rawHandlers = next;
207
+ liveHandlers = wrapHandlers(next, aliveGuard);
208
+ session?.updateHandlers(liveHandlers);
209
+ };
210
+ const teardown = () => {
211
+ // Synthesize the gesture's terminal lifecycle BEFORE flipping
212
+ // `isAlive`, so a host that tears us down mid-pan (effect re-run,
213
+ // component unmount) still sees a balanced onPanEnd / onPanSessionEnd
214
+ // pair. Dispatched against the *raw* (unwrapped) handlers so the
215
+ // delivery is synchronous — the wrapped lane would otherwise queue
216
+ // the callbacks onto frame.postRender just for them to be cancelled
217
+ // by the `isAlive = false` line immediately below.
218
+ if (session) {
219
+ session.dispatchTerminal(rawHandlers);
220
+ session.end();
221
+ session = null;
222
+ }
223
+ isAlive = false;
224
+ el.removeEventListener('pointerdown', onPointerDown);
225
+ };
226
+ return Object.assign(teardown, { update });
227
+ };
228
+ class PanSession {
229
+ history = [];
230
+ startEvent = null;
231
+ lastMoveEvent = null;
232
+ lastMovePoint = null;
233
+ handlers = {};
234
+ contextWindow = window;
235
+ distanceThreshold = 3;
236
+ element = null;
237
+ scrollPositions = new Map();
238
+ /**
239
+ * Idempotency flag — set the first time the gesture's terminal
240
+ * lifecycle pair (`onEnd` + `onSessionEnd`) fires. Both
241
+ * `handlePointerUp` (the natural release path) and
242
+ * `dispatchTerminal` (the forced-teardown path called by
243
+ * `attachPan.teardown`) check this and bail if already dispatched.
244
+ * Without it, a normal pointerup followed by a host-side teardown
245
+ * (e.g. `$effect` cleanup, component unmount) would replay
246
+ * `onEnd`/`onSessionEnd` against handlers that already saw them.
247
+ */
248
+ terminalDispatched = false;
249
+ removeScrollListeners = null;
250
+ removeListeners = null;
251
+ constructor(event, handlers, opts) {
252
+ // Bail on non-primary pointers. Properties keep their declared
253
+ // defaults so TypeScript sees them initialized regardless of which
254
+ // constructor branch ran.
255
+ if (!isPrimaryPointer(event))
256
+ return;
257
+ this.handlers = handlers;
258
+ this.contextWindow = opts.contextWindow;
259
+ this.distanceThreshold = opts.distanceThreshold;
260
+ this.element = opts.element;
261
+ const point = extractEventPoint(event);
262
+ this.history = [{ ...point, timestamp: frameData.timestamp }];
263
+ this.handlers.onSessionStart?.(event, getPanInfo(point, this.history));
264
+ const moveHandler = (e) => this.handlePointerMove(e);
265
+ const upHandler = (e) => this.handlePointerUp(e);
266
+ this.contextWindow.addEventListener('pointermove', moveHandler);
267
+ this.contextWindow.addEventListener('pointerup', upHandler);
268
+ this.contextWindow.addEventListener('pointercancel', upHandler);
269
+ this.removeListeners = () => {
270
+ this.contextWindow.removeEventListener('pointermove', moveHandler);
271
+ this.contextWindow.removeEventListener('pointerup', upHandler);
272
+ this.contextWindow.removeEventListener('pointercancel', upHandler);
273
+ };
274
+ if (this.element)
275
+ this.startScrollTracking(this.element);
276
+ }
277
+ updateHandlers(handlers) {
278
+ this.handlers = handlers;
279
+ }
280
+ end() {
281
+ this.removeListeners?.();
282
+ this.removeListeners = null;
283
+ this.removeScrollListeners?.();
284
+ this.removeScrollListeners = null;
285
+ this.scrollPositions.clear();
286
+ cancelFrame(this.updatePoint);
287
+ }
288
+ /**
289
+ * Synthesize the gesture's terminal lifecycle pair (`onEnd` then
290
+ * `onSessionEnd`) against the supplied *raw* (unwrapped) handlers,
291
+ * using the last observed event + point as the synthetic terminal
292
+ * sample. Called by `attachPan.teardown` when a host kills the
293
+ * session mid-gesture — without this, an `$effect` re-run that
294
+ * tears down the attachment silently strands the consumer's state
295
+ * machine in an "in-progress" state (whilePan keyframes never
296
+ * revert, threshold-based commit decisions never run).
297
+ *
298
+ * Bypasses the frame-loop wrappers deliberately: the wrapped
299
+ * handlers would queue to `frame.postRender` only for the
300
+ * about-to-flip `isAlive` flag in attachPan to cancel them. Raw
301
+ * dispatch keeps the lifecycle synchronous with teardown.
302
+ *
303
+ * No-op when no pointermove ever fired — matches the
304
+ * `handlePointerUp` no-movement contract upstream uses.
305
+ */
306
+ dispatchTerminal(rawHandlers) {
307
+ if (this.terminalDispatched)
308
+ return;
309
+ if (!(this.lastMoveEvent && this.lastMovePoint))
310
+ return;
311
+ const info = getPanInfo(this.lastMovePoint, this.history);
312
+ if (this.startEvent)
313
+ rawHandlers.onEnd?.(this.lastMoveEvent, info);
314
+ rawHandlers.onSessionEnd?.(this.lastMoveEvent, info);
315
+ this.terminalDispatched = true;
316
+ }
317
+ handlePointerMove = (event) => {
318
+ this.lastMoveEvent = event;
319
+ this.lastMovePoint = extractEventPoint(event);
320
+ // Per-frame throttle so a 1000hz mouse doesn't drown handlers.
321
+ frame.update(this.updatePoint, true);
322
+ };
323
+ handlePointerUp = (event) => {
324
+ this.end();
325
+ if (this.terminalDispatched)
326
+ return;
327
+ if (!(this.lastMoveEvent && this.lastMovePoint)) {
328
+ // No pointermove ever fired — match upstream framer-motion
329
+ // (`packages/framer-motion/src/gestures/pan/PanSession.ts`
330
+ // ~line 320) and return WITHOUT firing onEnd / onSessionEnd.
331
+ // Consumers that want a "tap" signal should use the press /
332
+ // tap gesture instead. This prevents a spurious
333
+ // onPanSessionStart → onPanSessionEnd pair on every plain
334
+ // click of a pan-enabled element.
335
+ return;
336
+ }
337
+ const finalPoint = event.type === 'pointercancel' ? this.lastMovePoint : extractEventPoint(event);
338
+ const info = getPanInfo(finalPoint, this.history);
339
+ if (this.startEvent)
340
+ this.handlers.onEnd?.(event, info);
341
+ this.handlers.onSessionEnd?.(event, info);
342
+ // Mark idempotent so a later forced teardown via
343
+ // `dispatchTerminal` doesn't replay this pair.
344
+ this.terminalDispatched = true;
345
+ };
346
+ updatePoint = () => {
347
+ if (!(this.lastMoveEvent && this.lastMovePoint))
348
+ return;
349
+ const info = getPanInfo(this.lastMovePoint, this.history);
350
+ const panAlreadyStarted = this.startEvent !== null;
351
+ const pastThreshold = distance2D(info.offset, { x: 0, y: 0 }) >= this.distanceThreshold;
352
+ if (!panAlreadyStarted && !pastThreshold)
353
+ return;
354
+ this.history.push({ ...this.lastMovePoint, timestamp: frameData.timestamp });
355
+ if (!panAlreadyStarted) {
356
+ this.handlers.onStart?.(this.lastMoveEvent, info);
357
+ this.startEvent = this.lastMoveEvent;
358
+ }
359
+ this.handlers.onMove?.(this.lastMoveEvent, info);
360
+ };
361
+ /**
362
+ * Track scrollable ancestors so we can compensate for scroll deltas
363
+ * during the gesture — mirrors upstream's `startScrollTracking`.
364
+ * For element scrolls: adjust `history[0]` so offset stays sane
365
+ * (pageX/pageY unaffected by element scroll). For window scrolls:
366
+ * adjust `lastMovePoint` (pageX/pageY shift with window scroll).
367
+ */
368
+ startScrollTracking(element) {
369
+ let current = element.parentElement;
370
+ while (current) {
371
+ const style = getComputedStyle(current);
372
+ if (overflowStyles.has(style.overflowX) || overflowStyles.has(style.overflowY)) {
373
+ this.scrollPositions.set(current, {
374
+ x: current.scrollLeft,
375
+ y: current.scrollTop
376
+ });
377
+ }
378
+ current = current.parentElement;
379
+ }
380
+ this.scrollPositions.set(this.contextWindow, {
381
+ x: this.contextWindow.scrollX,
382
+ y: this.contextWindow.scrollY
383
+ });
384
+ const onElementScroll = (event) => {
385
+ this.handleScroll(event.target);
386
+ };
387
+ const onWindowScroll = () => {
388
+ this.handleScroll(this.contextWindow);
389
+ };
390
+ this.contextWindow.addEventListener('scroll', onElementScroll, { capture: true });
391
+ this.contextWindow.addEventListener('scroll', onWindowScroll);
392
+ this.removeScrollListeners = () => {
393
+ this.contextWindow.removeEventListener('scroll', onElementScroll, {
394
+ capture: true
395
+ });
396
+ this.contextWindow.removeEventListener('scroll', onWindowScroll);
397
+ };
398
+ }
399
+ handleScroll(target) {
400
+ const initial = this.scrollPositions.get(target);
401
+ if (!initial)
402
+ return;
403
+ const isWindow = target === this.contextWindow;
404
+ const current = isWindow
405
+ ? { x: this.contextWindow.scrollX, y: this.contextWindow.scrollY }
406
+ : {
407
+ x: target.scrollLeft,
408
+ y: target.scrollTop
409
+ };
410
+ const delta = { x: current.x - initial.x, y: current.y - initial.y };
411
+ if (delta.x === 0 && delta.y === 0)
412
+ return;
413
+ if (isWindow) {
414
+ if (this.lastMovePoint) {
415
+ this.lastMovePoint.x += delta.x;
416
+ this.lastMovePoint.y += delta.y;
417
+ }
418
+ }
419
+ else if (this.history.length > 0) {
420
+ this.history[0].x -= delta.x;
421
+ this.history[0].y -= delta.y;
422
+ }
423
+ this.scrollPositions.set(target, current);
424
+ frame.update(this.updatePoint, true);
425
+ }
426
+ }