@unrulysystems/native-motion 0.1.0-alpha.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
- package/package.json +57 -0
- package/react-native.config.js +14 -0
- package/src/native/driver/instantWindow.ts +31 -0
- package/src/native/driver/strictModeReplay.ts +20 -0
- package/src/native/driver/uiLayoutEngine.ts +845 -0
- package/src/native/driver/uiLayoutGraph.ts +338 -0
- package/src/native/driver/uiValueChannel.ts +4640 -0
- package/src/native/driver/workletDriver.ts +3860 -0
- package/src/native/motion/AnimatePresence.tsx +1605 -0
- package/src/native/motion/LayoutGroup.tsx +163 -0
- package/src/native/motion/MotionConfig.tsx +400 -0
- package/src/native/motion/MotionRoot.tsx +250 -0
- package/src/native/motion/MotionView.tsx +6761 -0
- package/src/native/motion/addScaleCorrector.ts +97 -0
- package/src/native/motion/colorEndpointUiFeed.ts +32 -0
- package/src/native/motion/colorProjection.ts +39 -0
- package/src/native/motion/composeTransform.ts +271 -0
- package/src/native/motion/constraintMeasure.ts +157 -0
- package/src/native/motion/deferredPendingSnapshots.ts +25 -0
- package/src/native/motion/discreteProjection.ts +36 -0
- package/src/native/motion/dragAncestorPanContext.ts +28 -0
- package/src/native/motion/dragControls.ts +152 -0
- package/src/native/motion/dragGestureWiring.ts +1168 -0
- package/src/native/motion/dragHandoffBinding.ts +179 -0
- package/src/native/motion/dragHubStream.ts +756 -0
- package/src/native/motion/dragPropagationLock.ts +57 -0
- package/src/native/motion/driverValueChannel.ts +3288 -0
- package/src/native/motion/externalDragDriver.ts +214 -0
- package/src/native/motion/frameData.ts +31 -0
- package/src/native/motion/gestureBinding.ts +107 -0
- package/src/native/motion/gestureStateGate.ts +563 -0
- package/src/native/motion/gestureStateResolver.ts +286 -0
- package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
- package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
- package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
- package/src/native/motion/imperativeAnimate.ts +816 -0
- package/src/native/motion/keyframeTiming.ts +7 -0
- package/src/native/motion/layoutIdBinding.ts +261 -0
- package/src/native/motion/layoutIdFlightConfig.ts +51 -0
- package/src/native/motion/layoutProjection.ts +89 -0
- package/src/native/motion/layoutScroll.ts +204 -0
- package/src/native/motion/layoutTransition.ts +1302 -0
- package/src/native/motion/lengthLayoutContext.tsx +100 -0
- package/src/native/motion/lengthLayoutHost.ts +44 -0
- package/src/native/motion/mappedKeys.ts +184 -0
- package/src/native/motion/motionViewController.ts +3180 -0
- package/src/native/motion/nativeHostMarker.ts +22 -0
- package/src/native/motion/panSessionWiring.ts +127 -0
- package/src/native/motion/pathTransition.ts +101 -0
- package/src/native/motion/popLayout.ts +176 -0
- package/src/native/motion/presenceBinding.ts +382 -0
- package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
- package/src/native/motion/scrollValues.ts +260 -0
- package/src/native/motion/serializablePayload.ts +151 -0
- package/src/native/motion/severity.ts +16 -0
- package/src/native/motion/shippedSurface.ts +843 -0
- package/src/native/motion/staticLengthGate.ts +86 -0
- package/src/native/motion/styleBaseGate.ts +72 -0
- package/src/native/motion/styleValueBinding.ts +657 -0
- package/src/native/motion/systemGestureExclusion.ts +61 -0
- package/src/native/motion/tapGestureWiring.ts +215 -0
- package/src/native/motion/transformOrder.ts +38 -0
- package/src/native/motion/transformStringBinding.ts +200 -0
- package/src/native/motion/transformTemplateGate.ts +45 -0
- package/src/native/motion/transitionGate.ts +435 -0
- package/src/native/motion/useAnimate.ts +52 -0
- package/src/native/motion/useCycle.ts +44 -0
- package/src/native/motion/useInstantTransition.ts +68 -0
- package/src/native/motion/useReducedMotion.ts +58 -0
- package/src/native/motion/useScroll.ts +162 -0
- package/src/native/motion/useViewportScroll.ts +29 -0
- package/src/native/motion/valueChannel.ts +7121 -0
- package/src/native/motion/valueHooks.ts +901 -0
- package/src/native/motion/variantChildRegistry.ts +67 -0
- package/src/native/motion/variantContext.tsx +172 -0
- package/src/native/motion/variantProps.ts +598 -0
- package/src/native.ts +159 -0
- package/src/verification/harnessMetrics.ts +78 -0
- package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
- package/src/verification/probe/WorkletParityProbe.tsx +162 -0
- package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
- package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
- package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
- package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
- package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
- package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
- package/src/verification/screens/ConformanceScreen.tsx +573 -0
- package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
- package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
- package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
- package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
- package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
- package/src/verification/screens/GestureChecksScreen.tsx +685 -0
- package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
- package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
- package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
- package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
- package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
- package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
- package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
- package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
- package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
- package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
- package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
- package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
- package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
- package/src/verification/screens/conformanceBanner.ts +21 -0
- package/src/verification/screens/proofConsoleTap.ts +18 -0
- package/src/verification.ts +47 -0
- package/src/web.ts +98 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
// REQ-GESTURE-026 / REQ-API-043 (R15 D3) — MotionRoot hub touch stream (packet Finding 8).
|
|
2
|
+
//
|
|
3
|
+
// The pin's DragControls.start creates a window-level PanSession so a sibling handle works.
|
|
4
|
+
// Native has no window pointer tracking: MotionRoot hosts a manual-activation pan that
|
|
5
|
+
// records touches, stays inert until a hub start correlates a touch, then activates and
|
|
6
|
+
// drives the subscribed wiring with the recognizer's own translation/velocity stream
|
|
7
|
+
// (REQ-GESTURE-014 — no estimator).
|
|
8
|
+
//
|
|
9
|
+
// Pure stream state machine — MotionRoot binds it to Gesture.Pan(); MotionView registers
|
|
10
|
+
// its wiring as the hub-start target.
|
|
11
|
+
|
|
12
|
+
import type { DragControlOptions } from '@unrulysystems/native-motion-core'
|
|
13
|
+
import type { DragGestureWiring } from './dragGestureWiring'
|
|
14
|
+
import type { DragPanEventShape } from './dragHandoffBinding'
|
|
15
|
+
|
|
16
|
+
/** Screen-space point used for touch↔start correlation. */
|
|
17
|
+
export interface HubTouchPoint {
|
|
18
|
+
readonly id: number
|
|
19
|
+
readonly absoluteX: number
|
|
20
|
+
readonly absoluteY: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A pending down-touch recorded by the root pan before a hub start arms it. */
|
|
24
|
+
export interface RecordedHubTouch {
|
|
25
|
+
readonly point: HubTouchPoint
|
|
26
|
+
readonly recordedAtMs: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Page-point correction from MotionConfig (REQ-API-043 ingest boundary). */
|
|
30
|
+
export type HubPagePointTransform = (point: { readonly x: number; readonly y: number }) => {
|
|
31
|
+
readonly x: number
|
|
32
|
+
readonly y: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One armed hub session: correlated touch + wiring + start options. */
|
|
36
|
+
export interface ArmedHubSession {
|
|
37
|
+
readonly touchId: number
|
|
38
|
+
readonly originAbsoluteX: number
|
|
39
|
+
readonly originAbsoluteY: number
|
|
40
|
+
readonly wiring: DragGestureWiring
|
|
41
|
+
readonly options: DragControlOptions
|
|
42
|
+
/** True after the first onStart / activation past distanceThreshold. */
|
|
43
|
+
activated: boolean
|
|
44
|
+
/**
|
|
45
|
+
* When true, pan updates are held until snapToCursor's measure lands — the sanctioned
|
|
46
|
+
* G-INV-2 jump must complete before tracking (REQ-GESTURE-026; seal review major).
|
|
47
|
+
*/
|
|
48
|
+
pendingSnap: boolean
|
|
49
|
+
/** Grab-origin absolute position for snapToCursor / dead-zone math (screen-raw). */
|
|
50
|
+
readonly startAbsoluteX: number
|
|
51
|
+
readonly startAbsoluteY: number
|
|
52
|
+
/** Optional page-point transform applied to absolute coords at the event feed. */
|
|
53
|
+
readonly transformPagePoint: HubPagePointTransform | undefined
|
|
54
|
+
/** Arm-time report sink for transform/lifecycle failures (G-INV-8; never silent). */
|
|
55
|
+
readonly report: ((error: Error) => void) | undefined
|
|
56
|
+
/** Latest translated payload (for hub stop/cancel with real absolute coords). */
|
|
57
|
+
lastPayload:
|
|
58
|
+
| (DragPanEventShape & { readonly absoluteX: number; readonly absoluteY: number })
|
|
59
|
+
| null
|
|
60
|
+
/** Monotonic arm epoch — stale snap measure callbacks refuse when mismatched. */
|
|
61
|
+
readonly armEpoch: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type HubPanPayload = DragPanEventShape & {
|
|
65
|
+
readonly absoluteX: number
|
|
66
|
+
readonly absoluteY: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// U7d (REQ-API-030 global form, packet F1/F2) — the tap-observation channel. The pin's global
|
|
70
|
+
// press observes window pointer events without competing; the hub stream is its native analog:
|
|
71
|
+
// an ADDITIVE subscriber view of the root touches. Observation is unconditional — a child
|
|
72
|
+
// recognizer claiming the touch (or an armed hub drag) never suppresses it (F2).
|
|
73
|
+
export interface HubTapTouchPoint {
|
|
74
|
+
readonly id: number
|
|
75
|
+
readonly absoluteX: number
|
|
76
|
+
readonly absoluteY: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface HubTapObserver {
|
|
80
|
+
/** `primary` is true for the FIRST touch of a multi-touch sequence (pin isPrimaryPointer). */
|
|
81
|
+
onTouchDown(touch: HubTapTouchPoint, primary: boolean): void
|
|
82
|
+
onTouchUp(touch: HubTapTouchPoint): void
|
|
83
|
+
onTouchCancel(touch: HubTapTouchPoint): void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface DragHubStream {
|
|
87
|
+
/** Record a down-touch (root pan onTouchesDown). */
|
|
88
|
+
recordTouch(point: HubTouchPoint, nowMs?: number): void
|
|
89
|
+
/** Drop a touch that ended without arming (onTouchesUp/Cancelled). */
|
|
90
|
+
forgetTouch(id: number): void
|
|
91
|
+
/**
|
|
92
|
+
* Arm the stream for a hub-originated session.
|
|
93
|
+
* Correlates `startPoint` against recorded touches (nearest within TOLERANCE_PX).
|
|
94
|
+
* Ambiguous multi-touch (two equally near) fails loud when severity is development.
|
|
95
|
+
* Returns false when no touch correlates (fail-closed — no silent free-drag).
|
|
96
|
+
*/
|
|
97
|
+
arm(input: {
|
|
98
|
+
readonly wiring: DragGestureWiring
|
|
99
|
+
readonly options: DragControlOptions
|
|
100
|
+
readonly startPoint: { readonly absoluteX: number; readonly absoluteY: number }
|
|
101
|
+
readonly report?: ((error: Error) => void) | undefined
|
|
102
|
+
/** When true, hold pan feed until `releaseSnapGate()` (snapToCursor measure). */
|
|
103
|
+
readonly pendingSnap?: boolean | undefined
|
|
104
|
+
/** Page-point correction applied to absolute coords at the event feed. */
|
|
105
|
+
readonly transformPagePoint?: HubPagePointTransform | undefined
|
|
106
|
+
}): boolean
|
|
107
|
+
/** Current armed session, if any. */
|
|
108
|
+
readonly armed: ArmedHubSession | null
|
|
109
|
+
/**
|
|
110
|
+
* Current arm epoch (0 when never armed). Snap measure callbacks capture the epoch at
|
|
111
|
+
* start and refuse to apply when it no longer matches (cancel / re-arm raced the measure).
|
|
112
|
+
*/
|
|
113
|
+
readonly armEpoch: number
|
|
114
|
+
/**
|
|
115
|
+
* Root pan activated — feed start to the wiring with translation relative to arm origin.
|
|
116
|
+
* distanceThreshold gates the first activation (default 3, pin PanSession).
|
|
117
|
+
*/
|
|
118
|
+
onPanStart(
|
|
119
|
+
event: DragPanEventShape & { readonly absoluteX?: number; readonly absoluteY?: number },
|
|
120
|
+
): void
|
|
121
|
+
onPanUpdate(
|
|
122
|
+
event: DragPanEventShape & { readonly absoluteX?: number; readonly absoluteY?: number },
|
|
123
|
+
): void
|
|
124
|
+
onPanEnd(event: DragPanEventShape, success: boolean): void
|
|
125
|
+
onPanFinalize(event: DragPanEventShape, success: boolean): void
|
|
126
|
+
/**
|
|
127
|
+
* Clear the snap gate so tracking may begin (called after snapToCursor measure/write).
|
|
128
|
+
* Flushes a held first-move if one arrived while pending.
|
|
129
|
+
*/
|
|
130
|
+
releaseSnapGate(): void
|
|
131
|
+
/**
|
|
132
|
+
* Hub cancel/stop terminal: if activated, end the wiring with the latest payload
|
|
133
|
+
* (absolute coords preserved); always disarm so later moves cannot revive the session.
|
|
134
|
+
*/
|
|
135
|
+
terminate(success: boolean): void
|
|
136
|
+
/** Clear armed state without wiring end (prefer `terminate` for product cancel/stop). */
|
|
137
|
+
disarm(): void
|
|
138
|
+
/** Bound for recorded-but-unarmed touch retention (ms). */
|
|
139
|
+
readonly touchTtlMs: number
|
|
140
|
+
/**
|
|
141
|
+
* U7d: subscribe to the root tap-observation channel. Returns the unsubscribe. Dispatch is
|
|
142
|
+
* synchronous from the root pan's touch handlers, never suppressed by recognizer claims.
|
|
143
|
+
*/
|
|
144
|
+
subscribeTapObserver(observer: HubTapObserver): () => void
|
|
145
|
+
/**
|
|
146
|
+
* Root pan onTouchesDown feed — CHANGED touches only (one notification per physical down).
|
|
147
|
+
* The stream owns the multi-touch law: the first touch of a sequence notifies `primary`.
|
|
148
|
+
*/
|
|
149
|
+
notifyTapDown(touch: HubTapTouchPoint): void
|
|
150
|
+
/** Root pan onTouchesUp feed (changed touches). Untracked ids notify nothing. */
|
|
151
|
+
notifyTapUp(touch: HubTapTouchPoint): void
|
|
152
|
+
/** Root pan onTouchesCancelled feed (changed touches) — the press's only cancel source. */
|
|
153
|
+
notifyTapCancel(touch: HubTapTouchPoint): void
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Correlation radius (screen px) for matching start() to a recorded touch. */
|
|
157
|
+
export const HUB_TOUCH_CORRELATION_PX = 24
|
|
158
|
+
/** Unarmed touches older than this are dropped on the next record (bounded retention). */
|
|
159
|
+
export const HUB_TOUCH_TTL_MS = 2000
|
|
160
|
+
/** Pin PanSession default distanceThreshold. */
|
|
161
|
+
export const HUB_DEFAULT_DISTANCE_THRESHOLD = 3
|
|
162
|
+
|
|
163
|
+
function hypot(dx: number, dy: number): number {
|
|
164
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function eventTranslation(
|
|
168
|
+
event: DragPanEventShape & { readonly absoluteX?: number; readonly absoluteY?: number },
|
|
169
|
+
originX: number,
|
|
170
|
+
originY: number,
|
|
171
|
+
transform?: HubPagePointTransform,
|
|
172
|
+
): HubPanPayload {
|
|
173
|
+
// Prefer recognizer translation when present (after activation); otherwise derive from absolute.
|
|
174
|
+
// ALWAYS carry absoluteX/Y — handlePanStart's buildInfo (REQ-API-020 DragInfo) requires them
|
|
175
|
+
// (device crash 2026-08-05: stripped absolutes → loud absoluteX undefined on hub touch-feed).
|
|
176
|
+
const hasTx = typeof event.translationX === 'number' && Number.isFinite(event.translationX)
|
|
177
|
+
const hasTy = typeof event.translationY === 'number' && Number.isFinite(event.translationY)
|
|
178
|
+
let ax =
|
|
179
|
+
typeof event.absoluteX === 'number' && Number.isFinite(event.absoluteX)
|
|
180
|
+
? event.absoluteX
|
|
181
|
+
: hasTx
|
|
182
|
+
? originX + (event.translationX as number)
|
|
183
|
+
: originX
|
|
184
|
+
let ay =
|
|
185
|
+
typeof event.absoluteY === 'number' && Number.isFinite(event.absoluteY)
|
|
186
|
+
? event.absoluteY
|
|
187
|
+
: hasTy
|
|
188
|
+
? originY + (event.translationY as number)
|
|
189
|
+
: originY
|
|
190
|
+
// Ingest-boundary page-point correction (REQ-API-043): absolute coords only — translations
|
|
191
|
+
// stay screen-delta so dead-zone / distanceThreshold stay correlation-space consistent.
|
|
192
|
+
// Transform throws are reported then rethrown for the caller to terminate (G-INV-7/8;
|
|
193
|
+
// seal review r7 major — never silent raw-coordinate fallback).
|
|
194
|
+
if (transform !== undefined) {
|
|
195
|
+
const corrected = transform({ x: ax, y: ay })
|
|
196
|
+
ax = corrected.x
|
|
197
|
+
ay = corrected.y
|
|
198
|
+
}
|
|
199
|
+
if (hasTx && hasTy) {
|
|
200
|
+
return {
|
|
201
|
+
absoluteX: ax,
|
|
202
|
+
absoluteY: ay,
|
|
203
|
+
translationX: event.translationX,
|
|
204
|
+
translationY: event.translationY,
|
|
205
|
+
velocityX: typeof event.velocityX === 'number' ? event.velocityX : 0,
|
|
206
|
+
velocityY: typeof event.velocityY === 'number' ? event.velocityY : 0,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
absoluteX: ax,
|
|
211
|
+
absoluteY: ay,
|
|
212
|
+
translationX: ax - originX,
|
|
213
|
+
translationY: ay - originY,
|
|
214
|
+
velocityX: typeof event.velocityX === 'number' ? event.velocityX : 0,
|
|
215
|
+
velocityY: typeof event.velocityY === 'number' ? event.velocityY : 0,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function originPayload(session: ArmedHubSession): HubPanPayload {
|
|
220
|
+
// Prefer lastPayload; origin is only used when no move landed. Transform failure here
|
|
221
|
+
// must not throw out of terminate and must not call report (dev report throws before
|
|
222
|
+
// cleanup — seal review r8 major). Raw origin is the safe terminal fallback.
|
|
223
|
+
let ax = session.originAbsoluteX
|
|
224
|
+
let ay = session.originAbsoluteY
|
|
225
|
+
if (session.transformPagePoint !== undefined) {
|
|
226
|
+
try {
|
|
227
|
+
const corrected = session.transformPagePoint({ x: ax, y: ay })
|
|
228
|
+
ax = corrected.x
|
|
229
|
+
ay = corrected.y
|
|
230
|
+
} catch {
|
|
231
|
+
// Keep raw origin — never throw / report from this pure helper.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
absoluteX: ax,
|
|
236
|
+
absoluteY: ay,
|
|
237
|
+
translationX: 0,
|
|
238
|
+
translationY: 0,
|
|
239
|
+
velocityX: 0,
|
|
240
|
+
velocityY: 0,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function asError(error: unknown, fallback: string): Error {
|
|
245
|
+
return error instanceof Error ? error : new Error(fallback)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Sentinel distinct from every possible thrown value (including `undefined`). */
|
|
249
|
+
const NO_RETAINED = Symbol('dragHubStream.noRetained')
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Create a pure hub stream. MotionRoot owns one instance for the tree lifetime.
|
|
253
|
+
*/
|
|
254
|
+
export function createDragHubStream(options?: {
|
|
255
|
+
readonly touchTtlMs?: number
|
|
256
|
+
readonly correlationPx?: number
|
|
257
|
+
readonly now?: () => number
|
|
258
|
+
}): DragHubStream {
|
|
259
|
+
const touchTtlMs = options?.touchTtlMs ?? HUB_TOUCH_TTL_MS
|
|
260
|
+
const correlationPx = options?.correlationPx ?? HUB_TOUCH_CORRELATION_PX
|
|
261
|
+
const now = options?.now ?? (() => Date.now())
|
|
262
|
+
|
|
263
|
+
const recorded = new Map<number, RecordedHubTouch>()
|
|
264
|
+
let armed: ArmedHubSession | null = null
|
|
265
|
+
let armEpoch = 0
|
|
266
|
+
|
|
267
|
+
// U7d tap-observation channel state. Deliberately separate from `recorded` (the drag
|
|
268
|
+
// correlation store): recorded entries TTL-prune, but an observed touch lives exactly
|
|
269
|
+
// down→up/cancel — a long-held touch must not age out of the press's primary tracking.
|
|
270
|
+
const tapObservers = new Set<HubTapObserver>()
|
|
271
|
+
const activeTapTouches = new Map<number, HubTapTouchPoint>()
|
|
272
|
+
|
|
273
|
+
function prune(nowMs: number): void {
|
|
274
|
+
for (const [id, touch] of recorded) {
|
|
275
|
+
if (nowMs - touch.recordedAtMs > touchTtlMs) recorded.delete(id)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Clear stream arm state after wiring terminal paths (always, G-INV-7). */
|
|
280
|
+
function clearArmed(session: ArmedHubSession): void {
|
|
281
|
+
recorded.delete(session.touchId)
|
|
282
|
+
recorded.delete(-1)
|
|
283
|
+
armEpoch += 1
|
|
284
|
+
armed = null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Run end then finalize, retaining the first failure. Cleanup always runs first;
|
|
289
|
+
* report/rethrow only AFTER clear so a throwing dev reporter cannot strand armed (r8).
|
|
290
|
+
*/
|
|
291
|
+
function endFinalizeRetain(
|
|
292
|
+
session: ArmedHubSession,
|
|
293
|
+
payload: HubPanPayload,
|
|
294
|
+
success: boolean,
|
|
295
|
+
): unknown {
|
|
296
|
+
let retained: unknown = NO_RETAINED
|
|
297
|
+
if (session.activated) {
|
|
298
|
+
try {
|
|
299
|
+
session.wiring.handlePanEnd(payload, success)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
retained = error
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
session.wiring.handlePanFinalize(payload, success)
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (retained === NO_RETAINED) retained = error
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return retained
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function reportAfterClear(session: ArmedHubSession, retained: unknown): void {
|
|
313
|
+
// NO_RETAINED is the only “none” sentinel — `throw undefined` is still a failure (r9 minor).
|
|
314
|
+
if (retained === NO_RETAINED) return
|
|
315
|
+
const err = asError(retained, 'DragControls: terminal wiring callback failed')
|
|
316
|
+
session.report?.(err)
|
|
317
|
+
throw retained === undefined
|
|
318
|
+
? new Error('DragControls: terminal wiring callback failed (threw undefined)')
|
|
319
|
+
: retained
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Translate event; on transform throw abort session AFTER cleanup (G-INV-7/8; seal r7/r8). */
|
|
323
|
+
function translateOrAbort(
|
|
324
|
+
event: DragPanEventShape & { readonly absoluteX?: number; readonly absoluteY?: number },
|
|
325
|
+
): HubPanPayload | null {
|
|
326
|
+
if (armed === null) return null
|
|
327
|
+
try {
|
|
328
|
+
return eventTranslation(
|
|
329
|
+
event,
|
|
330
|
+
armed.originAbsoluteX,
|
|
331
|
+
armed.originAbsoluteY,
|
|
332
|
+
armed.transformPagePoint,
|
|
333
|
+
)
|
|
334
|
+
} catch (error) {
|
|
335
|
+
// Abort without re-entering transform: use lastPayload or raw origin.
|
|
336
|
+
// NEVER report before cleanup — development report throws (seal r8 major).
|
|
337
|
+
const session = armed
|
|
338
|
+
const payload =
|
|
339
|
+
session.lastPayload ??
|
|
340
|
+
({
|
|
341
|
+
absoluteX: session.originAbsoluteX,
|
|
342
|
+
absoluteY: session.originAbsoluteY,
|
|
343
|
+
translationX: 0,
|
|
344
|
+
translationY: 0,
|
|
345
|
+
velocityX: 0,
|
|
346
|
+
velocityY: 0,
|
|
347
|
+
} satisfies HubPanPayload)
|
|
348
|
+
const retained = endFinalizeRetain(session, payload, false)
|
|
349
|
+
clearArmed(session)
|
|
350
|
+
// Prefer the transform failure as the primary report; still surface wiring failures.
|
|
351
|
+
const primary = error
|
|
352
|
+
try {
|
|
353
|
+
session.report?.(
|
|
354
|
+
asError(primary, 'DragControls: transformPagePoint failed during pan feed'),
|
|
355
|
+
)
|
|
356
|
+
} catch {
|
|
357
|
+
// report threw (dev) — still rethrow primary below
|
|
358
|
+
}
|
|
359
|
+
if (retained !== NO_RETAINED) {
|
|
360
|
+
try {
|
|
361
|
+
session.report?.(asError(retained, 'DragControls: terminal wiring callback failed'))
|
|
362
|
+
} catch {
|
|
363
|
+
/* primary rethrow wins */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
throw asError(primary, 'DragControls: transformPagePoint failed during pan feed')
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
touchTtlMs,
|
|
372
|
+
get armed() {
|
|
373
|
+
return armed
|
|
374
|
+
},
|
|
375
|
+
get armEpoch() {
|
|
376
|
+
return armEpoch
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
recordTouch(point, nowMs = now()) {
|
|
380
|
+
prune(nowMs)
|
|
381
|
+
recorded.set(point.id, { point, recordedAtMs: nowMs })
|
|
382
|
+
// Same-frame recovery: hub arm may have used synthetic touch id -1 when Pressable
|
|
383
|
+
// onPressIn beat onTouchesDown. When the real touch lands, rebind armed.touchId so
|
|
384
|
+
// MotionRoot's touchId-filtered feed accepts moves (seal review r4 major). Drop the
|
|
385
|
+
// synthetic -1 entry so a later same-frame start is not blocked by recorded.size > 0
|
|
386
|
+
// with a stale synthetic (seal review r7 major).
|
|
387
|
+
if (
|
|
388
|
+
armed !== null &&
|
|
389
|
+
armed.touchId === -1 &&
|
|
390
|
+
hypot(point.absoluteX - armed.originAbsoluteX, point.absoluteY - armed.originAbsoluteY) <=
|
|
391
|
+
correlationPx
|
|
392
|
+
) {
|
|
393
|
+
recorded.delete(-1)
|
|
394
|
+
armed = { ...armed, touchId: point.id }
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
forgetTouch(id) {
|
|
399
|
+
recorded.delete(id)
|
|
400
|
+
// Do NOT terminate an activated session here — onTouchesUp fires before recognizer
|
|
401
|
+
// onEnd on Android; ending early drops real velocity (seal review r2 major).
|
|
402
|
+
// Pre-activation arm for this touch is cleared by terminate from onTouchesUp.
|
|
403
|
+
// Synthetic -1: any real-touch forget means the press ended before rebind (Issue 3).
|
|
404
|
+
if (armed !== null && !armed.activated && (armed.touchId === id || armed.touchId === -1)) {
|
|
405
|
+
recorded.delete(-1)
|
|
406
|
+
armed = null
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
arm(input) {
|
|
411
|
+
const nowMs = now()
|
|
412
|
+
prune(nowMs)
|
|
413
|
+
// Same-frame start race (device product walk 2026-08-05): Pressable.onPressIn runs on the
|
|
414
|
+
// JS thread BEFORE the root pan's onTouchesDown worklet→JS hop lands, so `recorded` can
|
|
415
|
+
// still be empty when start() arms. The pin's PanSession does not need a prior record —
|
|
416
|
+
// the start event IS the touch. Synthesize a touch from the start point so arm succeeds;
|
|
417
|
+
// the next onTouchesMove still activates the recognizer (Finding 8).
|
|
418
|
+
if (recorded.size === 0) {
|
|
419
|
+
recorded.set(-1, {
|
|
420
|
+
point: {
|
|
421
|
+
id: -1,
|
|
422
|
+
absoluteX: input.startPoint.absoluteX,
|
|
423
|
+
absoluteY: input.startPoint.absoluteY,
|
|
424
|
+
},
|
|
425
|
+
recordedAtMs: nowMs,
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
// Nearest recorded touch within correlation radius.
|
|
429
|
+
let best: { id: number; dist: number; touch: RecordedHubTouch } | null = null
|
|
430
|
+
let secondDist = Number.POSITIVE_INFINITY
|
|
431
|
+
for (const [id, touch] of recorded) {
|
|
432
|
+
const dist = hypot(
|
|
433
|
+
touch.point.absoluteX - input.startPoint.absoluteX,
|
|
434
|
+
touch.point.absoluteY - input.startPoint.absoluteY,
|
|
435
|
+
)
|
|
436
|
+
if (dist > correlationPx) continue
|
|
437
|
+
if (best === null || dist < best.dist) {
|
|
438
|
+
secondDist = best?.dist ?? Number.POSITIVE_INFINITY
|
|
439
|
+
best = { id, dist, touch }
|
|
440
|
+
} else if (dist < secondDist) {
|
|
441
|
+
secondDist = dist
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (best === null) {
|
|
445
|
+
input.report?.(
|
|
446
|
+
new Error(
|
|
447
|
+
`DragControls.start: no recorded touch within ${correlationPx}px of the start point ` +
|
|
448
|
+
`(REQ-GESTURE-026; packet Finding 8).`,
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
return false
|
|
452
|
+
}
|
|
453
|
+
// Ambiguous: two touches equally near (within 1px of each other as nearest).
|
|
454
|
+
if (Number.isFinite(secondDist) && Math.abs(secondDist - best.dist) < 1) {
|
|
455
|
+
input.report?.(
|
|
456
|
+
new Error(
|
|
457
|
+
'DragControls.start: ambiguous multi-touch correlation — refuse rather than guess ' +
|
|
458
|
+
'(REQ-GESTURE-026; packet Finding 8).',
|
|
459
|
+
),
|
|
460
|
+
)
|
|
461
|
+
return false
|
|
462
|
+
}
|
|
463
|
+
// One session at a time: terminal any prior arm BEFORE overwrite so an activated
|
|
464
|
+
// session cannot strand the app-global lock (adversarial seal Issue 4).
|
|
465
|
+
// Multi-subscriber last-writer remains documented; this makes re-arm well-defined.
|
|
466
|
+
if (armed !== null) {
|
|
467
|
+
try {
|
|
468
|
+
terminateSession(false)
|
|
469
|
+
} catch {
|
|
470
|
+
// Prior terminal failures are reported inside terminate; still install the new arm.
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
armEpoch += 1
|
|
474
|
+
armed = {
|
|
475
|
+
touchId: best.id,
|
|
476
|
+
originAbsoluteX: best.touch.point.absoluteX,
|
|
477
|
+
originAbsoluteY: best.touch.point.absoluteY,
|
|
478
|
+
wiring: input.wiring,
|
|
479
|
+
options: input.options,
|
|
480
|
+
activated: false,
|
|
481
|
+
pendingSnap: input.pendingSnap === true,
|
|
482
|
+
startAbsoluteX: input.startPoint.absoluteX,
|
|
483
|
+
startAbsoluteY: input.startPoint.absoluteY,
|
|
484
|
+
transformPagePoint: input.transformPagePoint,
|
|
485
|
+
report: input.report,
|
|
486
|
+
lastPayload: null,
|
|
487
|
+
armEpoch,
|
|
488
|
+
}
|
|
489
|
+
return true
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
onPanStart(event) {
|
|
493
|
+
if (armed === null) return
|
|
494
|
+
const translated = translateOrAbort(event)
|
|
495
|
+
if (translated === null) return
|
|
496
|
+
armed.lastPayload = translated
|
|
497
|
+
if (armed.pendingSnap) return // hold until snapToCursor lands
|
|
498
|
+
const threshold =
|
|
499
|
+
typeof armed.options.distanceThreshold === 'number'
|
|
500
|
+
? armed.options.distanceThreshold
|
|
501
|
+
: HUB_DEFAULT_DISTANCE_THRESHOLD
|
|
502
|
+
const moved = hypot(
|
|
503
|
+
typeof translated.translationX === 'number' ? translated.translationX : 0,
|
|
504
|
+
typeof translated.translationY === 'number' ? translated.translationY : 0,
|
|
505
|
+
)
|
|
506
|
+
if (moved < threshold && !armed.activated) {
|
|
507
|
+
// Below threshold: wait for updates (pin distanceThreshold gate).
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
if (!armed.activated) {
|
|
511
|
+
armed.activated = true
|
|
512
|
+
armed.wiring.handlePanStart(translated)
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
onPanUpdate(event) {
|
|
517
|
+
if (armed === null) return
|
|
518
|
+
const translated = translateOrAbort(event)
|
|
519
|
+
if (translated === null) return
|
|
520
|
+
armed.lastPayload = translated
|
|
521
|
+
if (armed.pendingSnap) return // hold until snapToCursor lands
|
|
522
|
+
const threshold =
|
|
523
|
+
typeof armed.options.distanceThreshold === 'number'
|
|
524
|
+
? armed.options.distanceThreshold
|
|
525
|
+
: HUB_DEFAULT_DISTANCE_THRESHOLD
|
|
526
|
+
if (!armed.activated) {
|
|
527
|
+
const moved = hypot(
|
|
528
|
+
typeof translated.translationX === 'number' ? translated.translationX : 0,
|
|
529
|
+
typeof translated.translationY === 'number' ? translated.translationY : 0,
|
|
530
|
+
)
|
|
531
|
+
if (moved < threshold) return
|
|
532
|
+
armed.activated = true
|
|
533
|
+
armed.wiring.handlePanStart(translated)
|
|
534
|
+
}
|
|
535
|
+
armed.wiring.handlePanUpdate(translated)
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
onPanEnd(event, success) {
|
|
539
|
+
if (armed === null) return
|
|
540
|
+
const translated = translateOrAbort(event)
|
|
541
|
+
if (translated === null) return
|
|
542
|
+
armed.lastPayload = translated
|
|
543
|
+
if (armed.activated) {
|
|
544
|
+
// End may throw (consumer onDragEnd) — leave armed for finalize (RNGH ordering).
|
|
545
|
+
armed.wiring.handlePanEnd(translated, success)
|
|
546
|
+
}
|
|
547
|
+
// Keep armed until finalize (RNGH ordering).
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
onPanFinalize(event, success) {
|
|
551
|
+
if (armed === null) return
|
|
552
|
+
// Always clear armed even if finalize/end wiring throws (G-INV-7; seal review r6 major).
|
|
553
|
+
// Transform failure still finalizes with lastPayload/raw origin so the app-global lock
|
|
554
|
+
// releases (seal review r8 major).
|
|
555
|
+
const session = armed
|
|
556
|
+
let retained: unknown = NO_RETAINED
|
|
557
|
+
let transformError: unknown = NO_RETAINED
|
|
558
|
+
let translated: HubPanPayload
|
|
559
|
+
try {
|
|
560
|
+
translated = eventTranslation(
|
|
561
|
+
event,
|
|
562
|
+
session.originAbsoluteX,
|
|
563
|
+
session.originAbsoluteY,
|
|
564
|
+
session.transformPagePoint,
|
|
565
|
+
)
|
|
566
|
+
} catch (error) {
|
|
567
|
+
transformError = error
|
|
568
|
+
translated =
|
|
569
|
+
session.lastPayload ??
|
|
570
|
+
({
|
|
571
|
+
absoluteX: session.originAbsoluteX,
|
|
572
|
+
absoluteY: session.originAbsoluteY,
|
|
573
|
+
translationX: 0,
|
|
574
|
+
translationY: 0,
|
|
575
|
+
velocityX: 0,
|
|
576
|
+
velocityY: 0,
|
|
577
|
+
} satisfies HubPanPayload)
|
|
578
|
+
}
|
|
579
|
+
if (session.activated) {
|
|
580
|
+
try {
|
|
581
|
+
session.wiring.handlePanFinalize(translated, success)
|
|
582
|
+
} catch (error) {
|
|
583
|
+
retained = error
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
clearArmed(session)
|
|
587
|
+
if (transformError !== NO_RETAINED) {
|
|
588
|
+
try {
|
|
589
|
+
session.report?.(
|
|
590
|
+
asError(transformError, 'DragControls: transformPagePoint failed at finalize'),
|
|
591
|
+
)
|
|
592
|
+
} catch {
|
|
593
|
+
/* report may throw in development */
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (retained !== NO_RETAINED) reportAfterClear(session, retained)
|
|
597
|
+
if (transformError !== NO_RETAINED) {
|
|
598
|
+
throw transformError === undefined
|
|
599
|
+
? new Error('DragControls: transformPagePoint failed at finalize (threw undefined)')
|
|
600
|
+
: asError(transformError, 'DragControls: transformPagePoint failed at finalize')
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
releaseSnapGate() {
|
|
605
|
+
if (armed === null || !armed.pendingSnap) return
|
|
606
|
+
armed.pendingSnap = false
|
|
607
|
+
// If a move was held, flush it as the first start/update so the session begins
|
|
608
|
+
// after the sanctioned jump (REQ-GESTURE-026).
|
|
609
|
+
const held = armed.lastPayload
|
|
610
|
+
if (held === null) return
|
|
611
|
+
const threshold =
|
|
612
|
+
typeof armed.options.distanceThreshold === 'number'
|
|
613
|
+
? armed.options.distanceThreshold
|
|
614
|
+
: HUB_DEFAULT_DISTANCE_THRESHOLD
|
|
615
|
+
if (!armed.activated) {
|
|
616
|
+
const moved = hypot(
|
|
617
|
+
typeof held.translationX === 'number' ? held.translationX : 0,
|
|
618
|
+
typeof held.translationY === 'number' ? held.translationY : 0,
|
|
619
|
+
)
|
|
620
|
+
if (moved < threshold) return
|
|
621
|
+
armed.activated = true
|
|
622
|
+
armed.wiring.handlePanStart(held)
|
|
623
|
+
}
|
|
624
|
+
armed.wiring.handlePanUpdate(held)
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
terminate(success) {
|
|
628
|
+
terminateSession(success)
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
disarm() {
|
|
632
|
+
if (armed !== null) armEpoch += 1
|
|
633
|
+
armed = null
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
subscribeTapObserver(observer) {
|
|
637
|
+
tapObservers.add(observer)
|
|
638
|
+
return () => {
|
|
639
|
+
tapObservers.delete(observer)
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
notifyTapDown(touch) {
|
|
644
|
+
// The hub stream's multi-touch law: the FIRST touch of a sequence is the primary
|
|
645
|
+
// pointer (pin isPrimaryPointer); every concurrent later touch is secondary.
|
|
646
|
+
const primary = activeTapTouches.size === 0
|
|
647
|
+
activeTapTouches.set(touch.id, touch)
|
|
648
|
+
for (const observer of tapObservers) observer.onTouchDown(touch, primary)
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
notifyTapUp(touch) {
|
|
652
|
+
// Untracked id (a down this channel never saw) notifies nothing — no phantom events.
|
|
653
|
+
if (!activeTapTouches.delete(touch.id)) return
|
|
654
|
+
for (const observer of tapObservers) observer.onTouchUp(touch)
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
notifyTapCancel(touch) {
|
|
658
|
+
if (!activeTapTouches.delete(touch.id)) return
|
|
659
|
+
for (const observer of tapObservers) observer.onTouchCancel(touch)
|
|
660
|
+
},
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Shared terminal path for `terminate` and arm-overwrite (Issue 4).
|
|
665
|
+
* Always clear armed + bump epoch even if wiring callbacks throw (G-INV-7).
|
|
666
|
+
* End and finalize run separately so an onDragEnd throw still reaches finalize
|
|
667
|
+
* (lock release) — seal review r6. Report/rethrow only AFTER clear (seal r8).
|
|
668
|
+
*/
|
|
669
|
+
function terminateSession(success: boolean): void {
|
|
670
|
+
if (armed === null) return
|
|
671
|
+
const session = armed
|
|
672
|
+
// Flush pending snap gate before terminal: a fast gesture can touch-up before
|
|
673
|
+
// measureInWindow lands; without this the held threshold-crossing move is dropped
|
|
674
|
+
// and activated never becomes true so end/finalize are skipped (seal review r11 major).
|
|
675
|
+
if (session.pendingSnap) {
|
|
676
|
+
session.pendingSnap = false
|
|
677
|
+
const held = session.lastPayload
|
|
678
|
+
if (held !== null && !session.activated) {
|
|
679
|
+
const threshold =
|
|
680
|
+
typeof session.options.distanceThreshold === 'number'
|
|
681
|
+
? session.options.distanceThreshold
|
|
682
|
+
: HUB_DEFAULT_DISTANCE_THRESHOLD
|
|
683
|
+
const moved = hypot(
|
|
684
|
+
typeof held.translationX === 'number' ? held.translationX : 0,
|
|
685
|
+
typeof held.translationY === 'number' ? held.translationY : 0,
|
|
686
|
+
)
|
|
687
|
+
if (moved >= threshold) {
|
|
688
|
+
session.activated = true
|
|
689
|
+
try {
|
|
690
|
+
session.wiring.handlePanStart(held)
|
|
691
|
+
// Mirror releaseSnapGate: held sample is also the first update.
|
|
692
|
+
session.wiring.handlePanUpdate(held)
|
|
693
|
+
} catch (error) {
|
|
694
|
+
// Start/update threw after sessions may hold the lock — finalize MUST run
|
|
695
|
+
// so the app-global lock frees (G-INV-7; adversarial seal Issue 1).
|
|
696
|
+
const payload = held
|
|
697
|
+
const finalizeRetained = endFinalizeRetain(session, payload, false)
|
|
698
|
+
clearArmed(session)
|
|
699
|
+
// Prefer the start failure as primary; still surface finalize failure.
|
|
700
|
+
try {
|
|
701
|
+
session.report?.(
|
|
702
|
+
asError(error, 'DragControls: handlePanStart failed during pendingSnap flush'),
|
|
703
|
+
)
|
|
704
|
+
} catch {
|
|
705
|
+
/* report may throw in development */
|
|
706
|
+
}
|
|
707
|
+
if (finalizeRetained !== NO_RETAINED) {
|
|
708
|
+
try {
|
|
709
|
+
session.report?.(
|
|
710
|
+
asError(finalizeRetained, 'DragControls: terminal wiring callback failed'),
|
|
711
|
+
)
|
|
712
|
+
} catch {
|
|
713
|
+
/* primary rethrow wins */
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
throw asError(error, 'DragControls: handlePanStart failed during pendingSnap flush')
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const payload = session.lastPayload ?? originPayload(session)
|
|
722
|
+
const retained = endFinalizeRetain(session, payload, success)
|
|
723
|
+
clearArmed(session)
|
|
724
|
+
reportAfterClear(session, retained)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Extract a screen-space point from a hub start event (RN GestureResponderEvent or PointerEvent).
|
|
730
|
+
* Fail-closed: returns null when no usable coordinates.
|
|
731
|
+
*/
|
|
732
|
+
export function extractHubStartPoint(
|
|
733
|
+
event: unknown,
|
|
734
|
+
): { readonly absoluteX: number; readonly absoluteY: number } | null {
|
|
735
|
+
if (event === null || event === undefined || typeof event !== 'object') return null
|
|
736
|
+
const e = event as Record<string, unknown>
|
|
737
|
+
// React Native: nativeEvent.pageX/pageY
|
|
738
|
+
const native = e['nativeEvent']
|
|
739
|
+
if (native !== null && native !== undefined && typeof native === 'object') {
|
|
740
|
+
const n = native as Record<string, unknown>
|
|
741
|
+
if (typeof n['pageX'] === 'number' && typeof n['pageY'] === 'number') {
|
|
742
|
+
return { absoluteX: n['pageX'], absoluteY: n['pageY'] }
|
|
743
|
+
}
|
|
744
|
+
if (typeof n['absoluteX'] === 'number' && typeof n['absoluteY'] === 'number') {
|
|
745
|
+
return { absoluteX: n['absoluteX'], absoluteY: n['absoluteY'] }
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
// DOM PointerEvent / React PointerEvent
|
|
749
|
+
if (typeof e['pageX'] === 'number' && typeof e['pageY'] === 'number') {
|
|
750
|
+
return { absoluteX: e['pageX'], absoluteY: e['pageY'] }
|
|
751
|
+
}
|
|
752
|
+
if (typeof e['clientX'] === 'number' && typeof e['clientY'] === 'number') {
|
|
753
|
+
return { absoluteX: e['clientX'], absoluteY: e['clientY'] }
|
|
754
|
+
}
|
|
755
|
+
return null
|
|
756
|
+
}
|