@sigx/lynx-gestures 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.
@@ -0,0 +1,53 @@
1
+ import type { Signal } from '@sigx/lynx';
2
+ export interface TouchPoint {
3
+ identifier: number;
4
+ x: number;
5
+ y: number;
6
+ pageX: number;
7
+ pageY: number;
8
+ clientX: number;
9
+ clientY: number;
10
+ }
11
+ export interface TouchEvent {
12
+ touches: TouchPoint[];
13
+ changedTouches: TouchPoint[];
14
+ }
15
+ export type GesturePhase = 'idle' | 'began' | 'active' | 'ended' | 'cancelled';
16
+ export interface GestureHandlers {
17
+ bindtouchstart?: (e: TouchEvent) => void;
18
+ bindtouchmove?: (e: TouchEvent) => void;
19
+ bindtouchend?: (e: TouchEvent) => void;
20
+ bindtouchcancel?: (e: TouchEvent) => void;
21
+ }
22
+ export interface PinchState {
23
+ phase: GesturePhase;
24
+ scale: number;
25
+ focalX: number;
26
+ focalY: number;
27
+ }
28
+ export interface UsePinchOptions {
29
+ onPinch?: (state: PinchState) => void;
30
+ }
31
+ export interface UsePinchReturn {
32
+ state: Signal<PinchState>;
33
+ handlers: GestureHandlers;
34
+ reset: () => void;
35
+ }
36
+ export interface RotationState {
37
+ phase: GesturePhase;
38
+ /** Cumulative rotation in radians since gesture start (signed). */
39
+ rotation: number;
40
+ /** Angular velocity in radians/ms. */
41
+ velocity: number;
42
+ focalX: number;
43
+ focalY: number;
44
+ }
45
+ export interface UseRotationOptions {
46
+ onRotation?: (state: RotationState) => void;
47
+ }
48
+ export interface UseRotationReturn {
49
+ state: Signal<RotationState>;
50
+ handlers: GestureHandlers;
51
+ reset: () => void;
52
+ }
53
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAWzC,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,cAAc,EAAE,UAAU,EAAE,CAAC;CAC9B;AAMD,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,WAAW,CAAC;AAM/E,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,CAAC;IACzC,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,CAAC;IACxC,YAAY,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,CAAC;IACvC,eAAe,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,CAAC;CAC3C;AAMD,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,YAAY,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACvC;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;IAC1B,QAAQ,EAAE,eAAe,CAAC;IAC1B,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAMD,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,YAAY,CAAC;IACpB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC,aAAa,CAAC,CAAC;IAC7B,QAAQ,EAAE,eAAe,CAAC;IAC1B,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB"}
@@ -0,0 +1,14 @@
1
+ import type { UsePinchOptions, UsePinchReturn } from './types.js';
2
+ /**
3
+ * Two-finger pinch/zoom gesture.
4
+ *
5
+ * Tracks fingers manually since Lynx fires separate touchstart events per
6
+ * finger (each with touches.length=1). Uses proximity-based matching on
7
+ * touchmove since Lynx identifiers are unreliable across events.
8
+ *
9
+ * NOTE: Requires a device/environment that delivers multi-touch events to
10
+ * the same element. Some Lynx hosts (e.g. Lynx Explorer on emulator) may
11
+ * not support this — test on a physical device.
12
+ */
13
+ export declare function usePinch(options?: UsePinchOptions): UsePinchReturn;
14
+ //# sourceMappingURL=use-pinch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-pinch.d.ts","sourceRoot":"","sources":["../src/use-pinch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAsC,MAAM,YAAY,CAAC;AAGtG;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CAAC,OAAO,GAAE,eAAoB,GAAG,cAAc,CA0FtE"}
@@ -0,0 +1,13 @@
1
+ import type { UseRotationOptions, UseRotationReturn } from './types.js';
2
+ /**
3
+ * Two-finger rotation gesture.
4
+ *
5
+ * Tracks the angle of the line between two fingers; reports cumulative rotation
6
+ * in radians from gesture start. Like usePinch, uses proximity-based finger
7
+ * matching on touchmove (Lynx touch identifiers are not stable across events).
8
+ *
9
+ * NOTE: requires multi-touch delivery to the same element. Some Lynx hosts
10
+ * (Lynx Explorer on emulator) may not support this — test on a physical device.
11
+ */
12
+ export declare function useRotation(options?: UseRotationOptions): UseRotationReturn;
13
+ //# sourceMappingURL=use-rotation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-rotation.d.ts","sourceRoot":"","sources":["../src/use-rotation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,kBAAkB,EAClB,iBAAiB,EAIlB,MAAM,YAAY,CAAC;AAGpB;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,iBAAiB,CA4G/E"}
@@ -0,0 +1,7 @@
1
+ export declare function distance(x1: number, y1: number, x2: number, y2: number): number;
2
+ export declare function midpoint(x1: number, y1: number, x2: number, y2: number): [number, number];
3
+ /** Signed angle in radians from p1 to p2, range (-π, π]. */
4
+ export declare function angle(x1: number, y1: number, x2: number, y2: number): number;
5
+ /** Shortest signed angular delta between two radians, range (-π, π]. */
6
+ export declare function angleDelta(from: number, to: number): number;
7
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAMA,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAEzF;AAED,4DAA4D;AAC5D,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAE5E;AAED,wEAAwE;AACxE,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAK3D"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@sigx/lynx-gestures",
3
+ "version": "0.1.0",
4
+ "description": "Gesture system for sigx-lynx - declarative composables for tap, pan, pinch, swipe, long press",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "dist"
14
+ ],
15
+ "keywords": [
16
+ "sigx",
17
+ "lynx",
18
+ "gestures",
19
+ "touch",
20
+ "pan",
21
+ "pinch",
22
+ "swipe",
23
+ "reactive"
24
+ ],
25
+ "author": "Andreas Ekdahl",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "@sigx/lynx": "^0.1.4"
29
+ },
30
+ "devDependencies": {
31
+ "@lynx-js/react": "^0.119.0",
32
+ "typescript": "^6.0.3",
33
+ "vite": "^8.0.12",
34
+ "@sigx/lynx-plugin": "^0.2.7",
35
+ "@sigx/lynx-runtime-main": "^0.2.7",
36
+ "@sigx/lynx-testing": "^0.2.6"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/signalxjs/lynx.git",
41
+ "directory": "packages/lynx-gestures"
42
+ },
43
+ "homepage": "https://github.com/signalxjs/lynx/tree/main/packages/lynx-gestures",
44
+ "bugs": {
45
+ "url": "https://github.com/signalxjs/lynx/issues"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "vite build && tsgo --emitDeclarationOnly",
52
+ "dev": "vite build --watch"
53
+ }
54
+ }
@@ -0,0 +1,450 @@
1
+ import {
2
+ component,
3
+ useMainThreadRef,
4
+ useSharedValue,
5
+ useAnimatedStyle,
6
+ runOnBackground,
7
+ Gesture,
8
+ useGestureDetector,
9
+ type SharedValue,
10
+ type Define,
11
+ type MainThread,
12
+ } from '@sigx/lynx';
13
+ import { useScrollContext } from '../scroll-context.js';
14
+
15
+ export interface DragEndDetail {
16
+ x: number;
17
+ y: number;
18
+ vx: number;
19
+ vy: number;
20
+ }
21
+
22
+ /**
23
+ * Edge-scroll configuration for `<Draggable edgeScroll>`. Either `true` for
24
+ * default tuning, or an object overriding the defaults.
25
+ */
26
+ export type EdgeScrollConfig = boolean | {
27
+ /** Distance from viewport edge in pt where auto-scroll engages. Default 50. */
28
+ threshold?: number;
29
+ /** Maximum scroll velocity in pt/sec at the edge. Default 800. */
30
+ maxSpeed?: number;
31
+ };
32
+
33
+ export type DraggableProps =
34
+ & Define.Prop<'axis', 'x' | 'y' | 'both', false>
35
+ & Define.Prop<'threshold', number, false>
36
+ & Define.Prop<'snapBack', boolean, false>
37
+ & Define.Prop<'minX', number, false>
38
+ & Define.Prop<'maxX', number, false>
39
+ & Define.Prop<'minY', number, false>
40
+ & Define.Prop<'maxY', number, false>
41
+ & Define.Prop<'translateX', SharedValue<number>, false>
42
+ & Define.Prop<'translateY', SharedValue<number>, false>
43
+ & Define.Prop<'edgeScroll', EdgeScrollConfig, false>
44
+ & Define.Prop<'class', string, false>
45
+ & Define.Prop<'style', Record<string, string | number>, false>
46
+ & Define.Slot<'default'>
47
+ & Define.Event<'dragStart', { x: number; y: number }>
48
+ & Define.Event<'dragEnd', DragEndDetail>;
49
+
50
+ interface DragMTState {
51
+ startPageX: number;
52
+ startPageY: number;
53
+ offsetX: number;
54
+ offsetY: number;
55
+ prevPageX: number;
56
+ prevPageY: number;
57
+ prevTime: number;
58
+ vx: number;
59
+ vy: number;
60
+ // Phase 2.13 edge-scroll state. Populated lazily in onStart when edgeScroll
61
+ // is enabled and a parent ScrollView is in scope. Read by the rAF tick
62
+ // closure scheduled from onUpdate.
63
+ lastPageX: number;
64
+ lastPageY: number;
65
+ scrollViewLeft: number;
66
+ scrollViewTop: number;
67
+ scrollViewWidth: number;
68
+ scrollViewHeight: number;
69
+ edgeScrollActive: boolean;
70
+ /**
71
+ * Last observed parent ScrollView offset, sampled inside the rAF tick.
72
+ * Used to compute the *actual* scroll delta between frames so the
73
+ * compensation matches what the native scroll-view delivered (zero when
74
+ * it clamped at top/bottom). Without this, holding past the top edge
75
+ * keeps adding negative delta to ty even though the page can't scroll
76
+ * any further, drifting the box off-screen.
77
+ */
78
+ lastScrollX: number;
79
+ lastScrollY: number;
80
+ }
81
+
82
+ /**
83
+ * MT-thread draggable container, built on the native gesture arena via
84
+ * `Gesture.Pan()`. The bound element's transform is driven by two
85
+ * `useAnimatedStyle` bindings (one per axis) — the same primitive any user
86
+ * could compose. The Pan onUpdate worklet writes to the SharedValues; the
87
+ * bridge applies the transform on the next flush boundary, composing the
88
+ * two bindings into a single `setStyleProperties({ transform })` call.
89
+ *
90
+ * Because the visible position is bridge-driven rather than written directly
91
+ * by the worklet, external animation of `translateX`/`translateY` (e.g.
92
+ * `withSpring(tx, 0)` to spring back to origin after release) moves the
93
+ * element visually for free — the binding picks up whichever SV write
94
+ * happened most recently, regardless of who wrote it.
95
+ *
96
+ * `dragStart` and `dragEnd` are dispatched to BG via `runOnBackground` (low
97
+ * frequency, cross-thread is fine).
98
+ *
99
+ * Unlike the prior `bindtouch*`-based implementation, the native pan gesture
100
+ * arena handles multi-touch correctly (secondary fingers don't cancel the
101
+ * primary drag).
102
+ *
103
+ * **Scroll composition** (Phase 2.12.3): Lynx's `<scroll-view>` doesn't
104
+ * participate in the new gesture arena, so without coordination both pan
105
+ * and scroll would fire concurrently. `<Draggable>` reads `useScrollContext`
106
+ * at setup; if a parent `<ScrollView>` is in scope, the BG-side dragStart/
107
+ * dragEnd flips `scrollCtx.dragging` automatically — the parent's
108
+ * `enable-scroll` is gated on that signal, so the UIKit pan recognizer
109
+ * yields for the duration of the drag. No consumer wiring required.
110
+ *
111
+ * **Edge-scroll** (Phase 2.13): pass `edgeScroll` to auto-scroll the parent
112
+ * `<ScrollView>` when the finger nears its viewport edge during a drag —
113
+ * the standard drag-to-reorder pattern (Apple Mail, iOS Reminders). The
114
+ * scroll axis follows `scrollOrientation` as published through the context.
115
+ * Inside the threshold zone the scroll velocity ramps from 0 at the
116
+ * threshold boundary to `maxSpeed` at the edge. Quietly no-ops if
117
+ * `edgeScroll` is unset OR the Draggable isn't nested in a ScrollView.
118
+ *
119
+ * Note on the native event payload: Lynx's pan handler emits `pageX`/`pageY`
120
+ * but no `translationX`/`velocityX` — we compute deltas and velocity from
121
+ * pageX/pageY ourselves (same as the prior touch-based implementation).
122
+ */
123
+ export const Draggable = component<DraggableProps>(({ props, slots, emit }) => {
124
+ const elRef = useMainThreadRef<MainThread.Element | null>(null);
125
+
126
+ // Always allocate fallback SharedValues — hooks must run unconditionally.
127
+ const ownTx = useSharedValue(0);
128
+ const ownTy = useSharedValue(0);
129
+
130
+ // Pick once at setup so useAnimatedStyle bindings + worklet `_c` captures
131
+ // hold stable refs. Convention (also used by <ScrollView>): gesture-prop
132
+ // SVs are allocated once at the parent and don't swap across renders.
133
+ const tx = props.translateX ?? ownTx;
134
+ const ty = props.translateY ?? ownTy;
135
+
136
+ // Bridge tx/ty → element transform on every flush boundary. Composes with
137
+ // any external animation that mutates the same SVs.
138
+ useAnimatedStyle(elRef, tx, 'translateX');
139
+ useAnimatedStyle(elRef, ty, 'translateY');
140
+
141
+ const drag = useMainThreadRef<DragMTState>({
142
+ startPageX: 0, startPageY: 0,
143
+ offsetX: 0, offsetY: 0,
144
+ prevPageX: 0, prevPageY: 0, prevTime: 0,
145
+ vx: 0, vy: 0,
146
+ lastPageX: 0, lastPageY: 0,
147
+ scrollViewLeft: 0, scrollViewTop: 0,
148
+ scrollViewWidth: 0, scrollViewHeight: 0,
149
+ edgeScrollActive: false,
150
+ lastScrollX: 0, lastScrollY: 0,
151
+ });
152
+
153
+ // Coordinate with the parent <ScrollView> (Phase 2.12.3): toggle its
154
+ // dragging signal during our gesture so its UIScrollView pan recognizer
155
+ // yields. Null when no ancestor ScrollView; the BG arrows below null-check.
156
+ const scrollCtx = useScrollContext();
157
+
158
+ // Pan config is read once at setup. Worklet bodies capture the snapshot
159
+ // via SWC's `_c` mechanism; runtime prop changes won't update an active
160
+ // gesture, but axis/threshold/clamps are render-stable in practice.
161
+ const axis = props.axis ?? 'both';
162
+ const threshold = props.threshold ?? 0;
163
+ const snapBack = props.snapBack ?? false;
164
+ const minX = props.minX;
165
+ const maxX = props.maxX;
166
+ const minY = props.minY;
167
+ const maxY = props.maxY;
168
+
169
+ // Phase 2.13: edge-scroll config. Normalized to plain numbers/booleans so
170
+ // worklet `_c` captures stay shape-stable. `edgeScrollEnabled` gates the
171
+ // viewport-measurement and rAF-tick paths in onStart/onUpdate; falsey
172
+ // means the new code paths short-circuit and behave identically to the
173
+ // pre-2.13 Draggable.
174
+ const edgeScrollProp = props.edgeScroll ?? false;
175
+ const edgeScrollEnabled = edgeScrollProp !== false;
176
+ const edgeScrollThreshold = (typeof edgeScrollProp === 'object' && edgeScrollProp.threshold) || 50;
177
+ const edgeScrollMaxSpeed = (typeof edgeScrollProp === 'object' && edgeScrollProp.maxSpeed) || 800;
178
+ // Captured at setup so the onUpdate tick reads a stable axis. `<ScrollView>`
179
+ // captures `scroll-orientation` once at setup too, so this stays consistent.
180
+ const scrollOrientation: 'vertical' | 'horizontal' = scrollCtx?.scrollOrientation ?? 'vertical';
181
+
182
+ const pan = Gesture.Pan()
183
+ .minDistance(threshold)
184
+ // Empty onBegin is load-bearing on iOS: LynxPanGestureHandler bails out
185
+ // of onStart/onEnd unless `_isInvokedBegin` is YES, and that flag is
186
+ // only set inside the native onBegin handler — which itself short-
187
+ // circuits when no callback is registered. Registering any onBegin
188
+ // (even a no-op) gates the begin path open so onStart and onEnd fire.
189
+ .onBegin(() => {
190
+ 'main thread';
191
+ })
192
+ .onStart((e: any) => {
193
+ 'main thread';
194
+ // Pan event payload: { type, timestamp, target, currentTarget,
195
+ // params: { pageX, pageY, x, y, clientX, clientY,
196
+ // scrollX, scrollY, isAtStart, isAtEnd,
197
+ // type } , detail: <copy of params> }
198
+ // pageX/pageY are nested under params; the top-level event has only
199
+ // dispatch metadata.
200
+ const p = e && e.params;
201
+ const pageX = (p && p.pageX) || 0;
202
+ const pageY = (p && p.pageY) || 0;
203
+ drag.current.startPageX = pageX;
204
+ drag.current.startPageY = pageY;
205
+ drag.current.offsetX = tx.current.value;
206
+ drag.current.offsetY = ty.current.value;
207
+ drag.current.prevPageX = pageX;
208
+ drag.current.prevPageY = pageY;
209
+ drag.current.prevTime = Date.now();
210
+ drag.current.vx = 0;
211
+ drag.current.vy = 0;
212
+ drag.current.lastPageX = pageX;
213
+ drag.current.lastPageY = pageY;
214
+ drag.current.edgeScrollActive = false;
215
+ // Lazy viewport measurement for edge-scroll. Reads computed size of
216
+ // the parent <scroll-view> via the ref the context publishes; assumes
217
+ // page-rooted (top-left at page origin) which holds for the showcase
218
+ // and the standard "list takes the whole screen" pattern. Refine via
219
+ // a `boundingClientRect` invoke (returns Promise — needs async-stash)
220
+ // if a nested-scroll-view consumer hits it.
221
+ if (edgeScrollEnabled && scrollCtx) {
222
+ const svRef = scrollCtx.scrollViewRef.current;
223
+ if (svRef) {
224
+ // Use `boundingClientRect` over `getComputedStyleProperty`: it
225
+ // returns page-relative geometry that's consistent across iOS +
226
+ // Android. `getComputedStyleProperty('height')` sometimes returns
227
+ // unresolved `100vh`-style strings or content heights on Android,
228
+ // which made the bottom-edge zone unreachable on Pixel.
229
+ //
230
+ // The invoke is async (Promise-based) — the rect lands a tick or
231
+ // two after this call, so the first few onUpdate frames may see
232
+ // scrollView{Width,Height}=0 and skip the rAF schedule. By the
233
+ // time the user has dragged anywhere meaningful, the rect is
234
+ // populated and edge-scroll engages.
235
+ const rectP = svRef.invoke('boundingClientRect', {});
236
+ if (rectP && typeof rectP.then === 'function') {
237
+ rectP.then((rect: unknown) => {
238
+ if (!rect || typeof rect !== 'object') return;
239
+ const r = rect as { left?: number; top?: number; width?: number; height?: number };
240
+ drag.current.scrollViewLeft = r.left || 0;
241
+ drag.current.scrollViewTop = r.top || 0;
242
+ drag.current.scrollViewWidth = r.width || 0;
243
+ drag.current.scrollViewHeight = r.height || 0;
244
+ }).catch(() => {});
245
+ }
246
+ }
247
+ // Seed last-known scroll offsets so the first tick's "actual delta"
248
+ // baselines correctly. After the first frame, the rAF tick keeps
249
+ // these in sync with the live offsetX/Y SVs.
250
+ drag.current.lastScrollX = scrollCtx.offsetX.current.value;
251
+ drag.current.lastScrollY = scrollCtx.offsetY.current.value;
252
+ }
253
+ runOnBackground(() => {
254
+ if (scrollCtx) scrollCtx.dragging.value = true;
255
+ emit('dragStart', { x: tx.current.value, y: ty.current.value });
256
+ })();
257
+ })
258
+ .onUpdate((e: any) => {
259
+ 'main thread';
260
+ const p = e && e.params;
261
+ const pageX = (p && p.pageX) || 0;
262
+ const pageY = (p && p.pageY) || 0;
263
+ let dx = pageX - drag.current.startPageX;
264
+ let dy = pageY - drag.current.startPageY;
265
+ if (axis === 'x') dy = 0;
266
+ else if (axis === 'y') dx = 0;
267
+ let newX = drag.current.offsetX + dx;
268
+ let newY = drag.current.offsetY + dy;
269
+ if (minX !== undefined && newX < minX) newX = minX;
270
+ if (maxX !== undefined && newX > maxX) newX = maxX;
271
+ if (minY !== undefined && newY < minY) newY = minY;
272
+ if (maxY !== undefined && newY > maxY) newY = maxY;
273
+ const now = Date.now();
274
+ const dt = Math.max(now - drag.current.prevTime, 1);
275
+ drag.current.vx = (pageX - drag.current.prevPageX) / dt;
276
+ drag.current.vy = (pageY - drag.current.prevPageY) / dt;
277
+ drag.current.prevPageX = pageX;
278
+ drag.current.prevPageY = pageY;
279
+ drag.current.prevTime = now;
280
+ drag.current.lastPageX = pageX;
281
+ drag.current.lastPageY = pageY;
282
+ tx.current.value = newX;
283
+ ty.current.value = newY;
284
+ // Drive the useAnimatedStyle bindings on the same frame. Inlined
285
+ // (rather than calling an imported helper) because plain function
286
+ // imports don't survive worklet `_c` capture — same constraint as
287
+ // <ScrollView> and @sigx/lynx-motion's animate().
288
+ const __flush = (globalThis as Record<string, unknown>)['__FlushElementTree'] as (() => void) | undefined;
289
+ if (__flush) __flush();
290
+ // Phase 2.13 edge-scroll: enter the rAF loop when the finger crosses
291
+ // into the threshold zone. The tick closure self-cancels when
292
+ // `edgeScrollActive` flips false (onEnd) or the finger leaves the
293
+ // zone (zero velocity).
294
+ if (edgeScrollEnabled && scrollCtx && !drag.current.edgeScrollActive) {
295
+ const w = drag.current.scrollViewWidth;
296
+ const h = drag.current.scrollViewHeight;
297
+ if (w > 0 && h > 0) {
298
+ let inEdge = false;
299
+ if (scrollOrientation === 'vertical') {
300
+ const top = drag.current.scrollViewTop;
301
+ const py = drag.current.lastPageY;
302
+ const topDist = py - top;
303
+ const botDist = (top + h) - py;
304
+ inEdge = topDist < edgeScrollThreshold || botDist < edgeScrollThreshold;
305
+ } else {
306
+ const left = drag.current.scrollViewLeft;
307
+ const px = drag.current.lastPageX;
308
+ const leftDist = px - left;
309
+ const rightDist = (left + w) - px;
310
+ inEdge = leftDist < edgeScrollThreshold || rightDist < edgeScrollThreshold;
311
+ }
312
+ if (inEdge) {
313
+ drag.current.edgeScrollActive = true;
314
+ // Inner arrow runs on MT (we're already inside an MT worklet
315
+ // body); rAF stashes the closure across frames in the MT VM.
316
+ // No `'main thread'` directive needed — the directive marks
317
+ // function bodies that cross threads, and we never leave MT.
318
+ const tick = (): void => {
319
+ if (!drag.current.edgeScrollActive) return;
320
+ const ref = scrollCtx.scrollViewRef.current;
321
+ if (!ref) {
322
+ drag.current.edgeScrollActive = false;
323
+ return;
324
+ }
325
+ let velocity = 0;
326
+ if (scrollOrientation === 'vertical') {
327
+ const py2 = drag.current.lastPageY;
328
+ const top2 = drag.current.scrollViewTop;
329
+ const h2 = drag.current.scrollViewHeight;
330
+ const topDist2 = py2 - top2;
331
+ const botDist2 = (top2 + h2) - py2;
332
+ if (topDist2 < edgeScrollThreshold) {
333
+ const t = topDist2 < 0 ? 0 : topDist2;
334
+ velocity = -edgeScrollMaxSpeed * (1 - t / edgeScrollThreshold);
335
+ } else if (botDist2 < edgeScrollThreshold) {
336
+ const t = botDist2 < 0 ? 0 : botDist2;
337
+ velocity = edgeScrollMaxSpeed * (1 - t / edgeScrollThreshold);
338
+ }
339
+ } else {
340
+ const px2 = drag.current.lastPageX;
341
+ const left2 = drag.current.scrollViewLeft;
342
+ const w2 = drag.current.scrollViewWidth;
343
+ const leftDist2 = px2 - left2;
344
+ const rightDist2 = (left2 + w2) - px2;
345
+ if (leftDist2 < edgeScrollThreshold) {
346
+ const t = leftDist2 < 0 ? 0 : leftDist2;
347
+ velocity = -edgeScrollMaxSpeed * (1 - t / edgeScrollThreshold);
348
+ } else if (rightDist2 < edgeScrollThreshold) {
349
+ const t = rightDist2 < 0 ? 0 : rightDist2;
350
+ velocity = edgeScrollMaxSpeed * (1 - t / edgeScrollThreshold);
351
+ }
352
+ }
353
+ if (velocity === 0) {
354
+ drag.current.edgeScrollActive = false;
355
+ return;
356
+ }
357
+ // Scroll-delta compensation: when content scrolls by `delta`,
358
+ // the Draggable's layout position moves by `-delta` (it's a
359
+ // child of the content). Without compensation the box drifts
360
+ // away from the finger as the page scrolls.
361
+ //
362
+ // We use the *actual* delivered scroll delta (read from
363
+ // offsetX/Y the bindscroll worklet maintains), not the
364
+ // velocity-based request. When the scroll-view clamps at the
365
+ // top/bottom (already at the edge), the actual delta is zero
366
+ // and we skip compensation — otherwise the box would keep
367
+ // drifting off-screen as we issue scrollBy calls the native
368
+ // side rejects.
369
+ //
370
+ // There's a one-frame lag (this tick reads the previous
371
+ // frame's actual delta), but it's imperceptible at 60fps.
372
+ const currScrollX = scrollCtx.offsetX.current.value;
373
+ const currScrollY = scrollCtx.offsetY.current.value;
374
+ const actualDX = currScrollX - drag.current.lastScrollX;
375
+ const actualDY = currScrollY - drag.current.lastScrollY;
376
+ drag.current.lastScrollX = currScrollX;
377
+ drag.current.lastScrollY = currScrollY;
378
+ if (scrollOrientation === 'vertical') {
379
+ if (actualDY !== 0) {
380
+ drag.current.offsetY += actualDY;
381
+ ty.current.value += actualDY;
382
+ }
383
+ } else {
384
+ if (actualDX !== 0) {
385
+ drag.current.offsetX += actualDX;
386
+ tx.current.value += actualDX;
387
+ }
388
+ }
389
+ // 60 fps tick → offset (pt/frame) = velocity (pt/sec) / 60.
390
+ // scrollBy on a vertical scroll-view ignores the X component
391
+ // of the offset (and vice-versa per
392
+ // LynxUIScrollViewInternal.m:269), so a single signed
393
+ // `offset` works for both axes.
394
+ //
395
+ // `invoke()` already calls `__FlushElementTree()` internally
396
+ // (`MTElementWrapper.invoke` sequences `__InvokeUIMethod` →
397
+ // `__FlushElementTree` synchronously inside the Promise
398
+ // constructor), which picks up the SV write above. No
399
+ // explicit flush needed — adding one would double the
400
+ // per-frame work and contributes to scroll stutter.
401
+ const delta = velocity / 60;
402
+ const p2 = ref.invoke('scrollBy', { offset: delta });
403
+ if (p2 && typeof p2.catch === 'function') p2.catch(() => {});
404
+ const raf = (globalThis as Record<string, unknown>)['requestAnimationFrame'] as
405
+ ((cb: () => void) => void) | undefined;
406
+ if (raf) raf(tick);
407
+ else drag.current.edgeScrollActive = false;
408
+ };
409
+ const raf = (globalThis as Record<string, unknown>)['requestAnimationFrame'] as
410
+ ((cb: () => void) => void) | undefined;
411
+ if (raf) raf(tick);
412
+ else drag.current.edgeScrollActive = false;
413
+ }
414
+ }
415
+ }
416
+ })
417
+ .onEnd(() => {
418
+ 'main thread';
419
+ // Stop the edge-scroll rAF loop (if any). The tick self-cancels next
420
+ // frame on the flag flip.
421
+ drag.current.edgeScrollActive = false;
422
+ if (snapBack) {
423
+ tx.current.value = 0;
424
+ ty.current.value = 0;
425
+ const __flush = (globalThis as Record<string, unknown>)['__FlushElementTree'] as (() => void) | undefined;
426
+ if (__flush) __flush();
427
+ }
428
+ runOnBackground(() => {
429
+ if (scrollCtx) scrollCtx.dragging.value = false;
430
+ emit('dragEnd', {
431
+ x: tx.current.value,
432
+ y: ty.current.value,
433
+ vx: drag.current.vx,
434
+ vy: drag.current.vy,
435
+ });
436
+ })();
437
+ });
438
+
439
+ useGestureDetector(elRef, pan);
440
+
441
+ return () => (
442
+ <view
443
+ class={props.class}
444
+ style={props.style}
445
+ main-thread:ref={elRef}
446
+ >
447
+ {slots.default?.()}
448
+ </view>
449
+ );
450
+ });