@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,1168 @@
|
|
|
1
|
+
// REQ-API-020/-022/-041 + REQ-GESTURE-022/-024 composition (FLAG 5a + R15 D1,
|
|
2
|
+
// specs/SPEC-GESTURE.md) — the drag gesture WIRING: the pure orchestrator MotionView plugs into
|
|
3
|
+
// Gesture.Pan(). It owns the protocol between the recognizer, the per-axis handoff bindings, and
|
|
4
|
+
// the abstract Driver:
|
|
5
|
+
//
|
|
6
|
+
// grab → ONE live-read per axis (REQ-DRIVER-025) + ONE interrupting write at the live values
|
|
7
|
+
// (REQ-DRIVER-024; a grab IS the interruption, REQ-GESTURE-012), sessions seeded C0
|
|
8
|
+
// update → per-axis session-constrained values → ONE write per recognizer event
|
|
9
|
+
// (REQ-GESTURE-002; REQ-GESTURE-024 per-axis channels, one recognizer lifecycle)
|
|
10
|
+
// release → ONE velocity-seeded `start` whose targets cover EVERY dragged axis
|
|
11
|
+
// (REQ-DRIVER-023; C0/C1 per REQ-GESTURE-013), spring = core's shared release config
|
|
12
|
+
// cancel → the same command shape with the handoffs' zero-velocity pre-grab return
|
|
13
|
+
//
|
|
14
|
+
// Nested drags ride Motion's global drag lock (REQ-GESTURE-024): a non-propagating element
|
|
15
|
+
// acquires the lock at grab and holds it until finalize — a second element's grab is swallowed
|
|
16
|
+
// whole (no read, no write, no callback); `dragPropagation` opts out both ways. Pure over the
|
|
17
|
+
// Driver contract so the reference driver proves the exact protocol the worklet driver receives
|
|
18
|
+
// on device. Callbacks carry Motion's (event, info) shape: DragInfo mirrors motion's PanInfo
|
|
19
|
+
// ({point, delta, offset, velocity}, each 2D) built from the recognizer's own fields —
|
|
20
|
+
// informational payloads validate fail-loud like every other recognizer read.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_DRAG_ELASTIC,
|
|
24
|
+
describeValue,
|
|
25
|
+
DRAG_INERTIA_DEFAULTS,
|
|
26
|
+
dragInertiaBounce,
|
|
27
|
+
GESTURE_RELEASE_SPRING,
|
|
28
|
+
progressWithinSpan,
|
|
29
|
+
resolveDirectionLockAxis,
|
|
30
|
+
resolveViewportConstraints,
|
|
31
|
+
resolvedDragAxes,
|
|
32
|
+
validateMeasureReplacementBox,
|
|
33
|
+
type AxisSpan,
|
|
34
|
+
type DragAxis,
|
|
35
|
+
type DragInfo,
|
|
36
|
+
type Driver,
|
|
37
|
+
type ElementHandle,
|
|
38
|
+
type GestureHandoffSession,
|
|
39
|
+
type MeasureDragConstraintsCallback,
|
|
40
|
+
type PropTarget,
|
|
41
|
+
type PropTransition,
|
|
42
|
+
type ReleaseHandoff,
|
|
43
|
+
type ResolvedDragConfig,
|
|
44
|
+
type Transition,
|
|
45
|
+
} from '@unrulysystems/native-motion-core'
|
|
46
|
+
import {
|
|
47
|
+
createDragHandoffBinding,
|
|
48
|
+
type DragHandoffBinding,
|
|
49
|
+
type DragPanEventShape,
|
|
50
|
+
} from './dragHandoffBinding'
|
|
51
|
+
import { sharedDragPropagationLock, type DragPropagationLock } from './dragPropagationLock'
|
|
52
|
+
import { wrapDriverForExternalDragValues, type ExternalDragValues } from './externalDragDriver'
|
|
53
|
+
|
|
54
|
+
export type { ExternalDragValues }
|
|
55
|
+
|
|
56
|
+
// `DragInfo` (Motion's PanInfo shape) is the shared core type so the (event, info) callback
|
|
57
|
+
// payload is identical cross-engine (REQ-API-020); built here natively from the recognizer's
|
|
58
|
+
// absolute/translation/velocity fields.
|
|
59
|
+
//
|
|
60
|
+
// The native event object is the recognizer's own payload (the platform-faithful analog of
|
|
61
|
+
// motion's DOM PointerEvent first argument — a recorded platform difference, not a divergence in
|
|
62
|
+
// the info shape).
|
|
63
|
+
export type DragEventCallback = (event: unknown, info: DragInfo) => void
|
|
64
|
+
|
|
65
|
+
export interface DragCallbacks {
|
|
66
|
+
readonly onDragStart?: DragEventCallback | undefined
|
|
67
|
+
readonly onDrag?: DragEventCallback | undefined
|
|
68
|
+
readonly onDragEnd?: DragEventCallback | undefined
|
|
69
|
+
// REQ-API-042 (R15 D2): the pin's measure hook — receives the measured viewport constraints
|
|
70
|
+
// at every grab (layout-relative, per the pin's BoundingBox) and may return a mutated box,
|
|
71
|
+
// which replaces the measured one for that grab. LATEST like every callback.
|
|
72
|
+
readonly onMeasureDragConstraints?: MeasureDragConstraintsCallback | undefined
|
|
73
|
+
// R15 D4 (REQ-API-044 / REQ-GESTURE-025): fires once when the direction lock is acquired.
|
|
74
|
+
readonly onDirectionLock?: ((axis: DragAxis) => void) | undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The measured boxes the grab-time seam returns: the draggable's CURRENT layout frame and the
|
|
78
|
+
// constraint element's frame, already in the same coordinate space (window coords).
|
|
79
|
+
export interface ConstraintsMeasureBoxes {
|
|
80
|
+
readonly draggableBox: { readonly x: AxisSpan; readonly y: AxisSpan }
|
|
81
|
+
readonly containerBox: { readonly x: AxisSpan; readonly y: AxisSpan }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The grab-time measurement seam (REQ-GESTURE-028): MotionView supplies a closure that
|
|
85
|
+
// measures ITS host (the draggable) and the constraint element via measureInWindow (async —
|
|
86
|
+
// the pin's synchronous page-box read has no native analog). The ref wrapper is passed so the
|
|
87
|
+
// seam resolves `.current` at the measure instant, never earlier.
|
|
88
|
+
export type ConstraintsMeasure = (container: {
|
|
89
|
+
readonly current: unknown
|
|
90
|
+
}) => Promise<ConstraintsMeasureBoxes>
|
|
91
|
+
|
|
92
|
+
export interface DragGestureWiringOptions {
|
|
93
|
+
readonly driver: Driver
|
|
94
|
+
readonly handle: ElementHandle
|
|
95
|
+
readonly config: ResolvedDragConfig
|
|
96
|
+
// U7f: latest resolved config at grab (pin getProps per session). When omitted, `config`
|
|
97
|
+
// is the session. Empty axes (`drag={false}`) no-op the grab.
|
|
98
|
+
readonly getConfig?: (() => ResolvedDragConfig) | undefined
|
|
99
|
+
readonly callbacks?: DragCallbacks
|
|
100
|
+
// REQ-API-041: default false — this element acquires the pin's per-axis global drag lock at
|
|
101
|
+
// grab; true skips the lock for itself (never acquires, never blocked).
|
|
102
|
+
readonly dragPropagation?: boolean | undefined
|
|
103
|
+
// The lock scope: app-global by default (the pin's module-global analog — review
|
|
104
|
+
// 97bf73b720a8, never a per-root scope); tests inject their own.
|
|
105
|
+
readonly lock?: DragPropagationLock | undefined
|
|
106
|
+
// REQ-GESTURE-028: REQUIRED when any axis carries constraintsRef (a host wiring bug without
|
|
107
|
+
// it, fail-loud at grab). The grab-time measure seam.
|
|
108
|
+
readonly measureConstraints?: ConstraintsMeasure | undefined
|
|
109
|
+
// The deferred-begin failure channel (REQ-GESTURE-028): an unmounted/unmeasurable container
|
|
110
|
+
// or a snap-vs-measured-bounds contradiction reports here — severity-adapted by the caller
|
|
111
|
+
// (review r4 major 5): MotionView's DEVELOPMENT adapter throws the typed error, which rides
|
|
112
|
+
// this async delivery as an unhandled rejection — the dev crash surface; production reports
|
|
113
|
+
// and refuses that grab, never a half-started session.
|
|
114
|
+
readonly reportConstraintFailure?: ((error: Error) => void) | undefined
|
|
115
|
+
// REQ-GESTURE-028 (review major 3): the mid-drag re-measure loop — while a ref-constrained
|
|
116
|
+
// drag is ACTIVE, re-measure the boxes per frame tick and re-resolve constraints + rescale
|
|
117
|
+
// the position on change (the pin's ResizeObserver + window-resize path,
|
|
118
|
+
// scalePositionWithinConstraints). When absent, the mid-drag re-measure simply never runs
|
|
119
|
+
// (the per-grab law still holds); MotionView injects the platform frame ticker, tests a
|
|
120
|
+
// manual one. Repeating: the callback fires once per frame until the returned cancel runs.
|
|
121
|
+
readonly frameTicker?: ((callback: () => void) => () => void) | undefined
|
|
122
|
+
/**
|
|
123
|
+
* R15 D5 (REQ-API-046): external `_dragX`/`_dragY` — when set for an axis, session live
|
|
124
|
+
* reads/writes/release land on the MotionValue instead of the host transform channel.
|
|
125
|
+
*/
|
|
126
|
+
readonly externalDragValues?: ExternalDragValues | undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The handlers MotionView binds onto Gesture.Pan() (runOnJS(true), the ratified JS routing).
|
|
130
|
+
export interface DragGestureWiring {
|
|
131
|
+
handlePanStart(event: DragPanEventShape): void
|
|
132
|
+
handlePanUpdate(event: DragPanEventShape): void
|
|
133
|
+
handlePanEnd(event: DragPanEventShape, success: boolean): void
|
|
134
|
+
handlePanFinalize(event: DragPanEventShape, success: boolean): void
|
|
135
|
+
/**
|
|
136
|
+
* R15 D3 (REQ-GESTURE-026): pin snapToCursor — re-seat each dragged axis so the pointer is at
|
|
137
|
+
* the element center. `layoutBox` is the VISUAL frame in page/window space (measure includes
|
|
138
|
+
* the live transform); formula `value = point − midpoint + current` (pin VisualElementDragControls
|
|
139
|
+
* snapToCursor — sanctioned G-INV-2 jump at start-invocation only).
|
|
140
|
+
*/
|
|
141
|
+
snapToCursor(
|
|
142
|
+
point: { readonly absoluteX: number; readonly absoluteY: number },
|
|
143
|
+
layoutBox: {
|
|
144
|
+
readonly x: number
|
|
145
|
+
readonly y: number
|
|
146
|
+
readonly width: number
|
|
147
|
+
readonly height: number
|
|
148
|
+
},
|
|
149
|
+
): void
|
|
150
|
+
// UNMOUNT teardown (review 1d6e5b08344c): RNGH drops the recognizer's handlers on unmount
|
|
151
|
+
// without dispatching a JS finalize, so an active drag whose host unmounts would hold the
|
|
152
|
+
// app-global lock forever — every later non-propagating drag in the tree blocked. MotionView
|
|
153
|
+
// calls this from its unmount cleanup: the lock releases and every later recognizer event
|
|
154
|
+
// (a handler can briefly outlive its host) no-ops. The session itself dies with the host —
|
|
155
|
+
// no cancel command is issued against a dead handle.
|
|
156
|
+
dispose(): void
|
|
157
|
+
// The per-axis handoff sessions, exposed for the verification seam (state assertions) — the
|
|
158
|
+
// wiring owns their lifecycle; consumers never call them directly.
|
|
159
|
+
readonly sessions: Readonly<Partial<Record<DragAxis, GestureHandoffSession>>>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The release spring is core's shared constant lifted into the public Transition shape — one
|
|
163
|
+
// config, never a duplicated literal. R15 D5 (REQ-API-045): dragTransition may override
|
|
164
|
+
// bounceStiffness/bounceDamping (pin inertia → spring mapping, curve-parity law).
|
|
165
|
+
function releaseTransitionFor(config: ResolvedDragConfig): Transition {
|
|
166
|
+
const t = config.dragTransition
|
|
167
|
+
return {
|
|
168
|
+
type: 'spring',
|
|
169
|
+
stiffness: t?.bounceStiffness ?? GESTURE_RELEASE_SPRING.stiffness,
|
|
170
|
+
damping: t?.bounceDamping ?? GESTURE_RELEASE_SPRING.damping,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// R16 (REQ-GESTURE-023/-027): the free-drag release's per-target inertia descriptor — the pin's
|
|
175
|
+
// drag-layer physics verbatim (timeConstant 750, power 0.8, restDelta 1, restSpeed 10), the
|
|
176
|
+
// elastic-conditional boundary-spring pair (dragElastic truthy → 200/40, falsy → 1e6/1e7) with
|
|
177
|
+
// `dragTransition` bounce overrides feeding the BOUNDARY spring (their pin meaning; the D5
|
|
178
|
+
// spring-mapping is superseded), and the grab-time bounds for the walls.
|
|
179
|
+
function inertiaTransitionFor(
|
|
180
|
+
config: ResolvedDragConfig,
|
|
181
|
+
axis: DragAxis,
|
|
182
|
+
handoff: ReleaseHandoff & { readonly curve: 'inertia' },
|
|
183
|
+
): PropTransition {
|
|
184
|
+
const axisConfig = config[axis]
|
|
185
|
+
// The pin selects the bounce pair from the ELEMENT dragElastic (default 0.35 truthy → 200/40);
|
|
186
|
+
// the resolved per-constraint elastic carries it when constraints exist.
|
|
187
|
+
const elastic =
|
|
188
|
+
axisConfig?.constraints?.elastic ?? axisConfig?.constraintsRef?.elastic ?? DEFAULT_DRAG_ELASTIC
|
|
189
|
+
const bounce = dragInertiaBounce(elastic)
|
|
190
|
+
const t = config.dragTransition
|
|
191
|
+
return {
|
|
192
|
+
type: 'inertia',
|
|
193
|
+
power: DRAG_INERTIA_DEFAULTS.power,
|
|
194
|
+
timeConstant: DRAG_INERTIA_DEFAULTS.timeConstant,
|
|
195
|
+
bounceStiffness: t?.bounceStiffness ?? bounce.bounceStiffness,
|
|
196
|
+
bounceDamping: t?.bounceDamping ?? bounce.bounceDamping,
|
|
197
|
+
restDelta: DRAG_INERTIA_DEFAULTS.restDelta,
|
|
198
|
+
restSpeed: DRAG_INERTIA_DEFAULTS.restSpeed,
|
|
199
|
+
...(handoff.min !== undefined ? { min: handoff.min } : {}),
|
|
200
|
+
...(handoff.max !== undefined ? { max: handoff.max } : {}),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function snapToOriginApplies(config: ResolvedDragConfig, axis: DragAxis): boolean {
|
|
205
|
+
const s = config.dragSnapToOrigin
|
|
206
|
+
if (s === true) return true
|
|
207
|
+
if (s === false) return false
|
|
208
|
+
return s === axis
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// REQ-GESTURE-028 (review r4 minor 7) — the deferred-begin's two bounds. The async measure
|
|
212
|
+
// must never retain the wiring forever: a native measureInWindow callback that never arrives
|
|
213
|
+
// would pin the lock and every buffered event indefinitely. measureInWindow lands in 1–2
|
|
214
|
+
// frames (~16–33ms); 1000ms is generous bounded headroom before the grab/poll fails loud.
|
|
215
|
+
const MEASURE_TIMEOUT_MS = 1000
|
|
216
|
+
// The pending grab's buffered updates are capped: a measure pending longer than 64 recognizer
|
|
217
|
+
// events is pathological — refuse the grab (fail-loud) rather than retain events indefinitely.
|
|
218
|
+
const MAX_BUFFERED_PAN_UPDATES = 64
|
|
219
|
+
|
|
220
|
+
function measureWithTimeout(
|
|
221
|
+
measure: ConstraintsMeasure,
|
|
222
|
+
ref: { readonly current: unknown },
|
|
223
|
+
): Promise<ConstraintsMeasureBoxes> {
|
|
224
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
225
|
+
const racing = new Promise<ConstraintsMeasureBoxes>((_resolve, reject) => {
|
|
226
|
+
timer = setTimeout(() => {
|
|
227
|
+
reject(
|
|
228
|
+
new Error(
|
|
229
|
+
`dragConstraints measure timed out after ${MEASURE_TIMEOUT_MS}ms — the native ` +
|
|
230
|
+
'measureInWindow callback never arrived (REQ-GESTURE-028; the bounded-wait law).',
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
}, MEASURE_TIMEOUT_MS)
|
|
234
|
+
})
|
|
235
|
+
return Promise.race([
|
|
236
|
+
measure(ref).finally(() => {
|
|
237
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
238
|
+
}),
|
|
239
|
+
racing,
|
|
240
|
+
])
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Same fail-loud field law as the binding: info payloads read the recognizer's 2D fields and a
|
|
244
|
+
// missing field is a platform/caller bug named at the boundary — never coerced.
|
|
245
|
+
function requireInfoNumber(value: unknown, field: string): number {
|
|
246
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
247
|
+
// describeValue (core, single-sourced) is a SAFE formatter (review r4 minor 844ee5e): a BigInt
|
|
248
|
+
// recognizer field must produce this typed error naming the field, never a raw JSON.stringify
|
|
249
|
+
// serialization TypeError.
|
|
250
|
+
throw new Error(
|
|
251
|
+
`drag wiring: Pan event field '${field}' is ${describeValue(value)} — a finite number ` +
|
|
252
|
+
'is required for the Motion (event, info) callback payload (REQ-API-020).',
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
return value
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface InfoEventShape extends DragPanEventShape {
|
|
259
|
+
readonly absoluteX?: unknown
|
|
260
|
+
readonly absoluteY?: unknown
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Where this element stands with Motion's global drag lock across one recognizer stream:
|
|
264
|
+
// 'open' (no grab yet, or propagating), 'acquired' (we hold it until finalize), 'blocked'
|
|
265
|
+
// (another element holds it — our whole stream stays inert).
|
|
266
|
+
type LockState = 'open' | 'acquired' | 'blocked'
|
|
267
|
+
|
|
268
|
+
export function createDragGestureWiring(options: DragGestureWiringOptions): DragGestureWiring {
|
|
269
|
+
const handle = options.handle
|
|
270
|
+
const getConfig = options.getConfig ?? (() => options.config)
|
|
271
|
+
let config = options.config
|
|
272
|
+
// R15 D5: optional external-value driver wrap (REQ-API-046) — all live/write/command sites
|
|
273
|
+
// below use this driver so external axes never touch the host transform channel.
|
|
274
|
+
const driver =
|
|
275
|
+
options.externalDragValues !== undefined
|
|
276
|
+
? wrapDriverForExternalDragValues(options.driver, handle, options.externalDragValues)
|
|
277
|
+
: options.driver
|
|
278
|
+
const callbacks = options.callbacks ?? {}
|
|
279
|
+
let dragPropagation = options.dragPropagation ?? config.dragPropagation
|
|
280
|
+
const lock = options.lock ?? sharedDragPropagationLock
|
|
281
|
+
// One binding per dragged axis (REQ-GESTURE-024: independent per-axis channels sharing this
|
|
282
|
+
// ONE recognizer lifecycle). Single-axis drags get exactly one binding — no forked path.
|
|
283
|
+
// U7f: `drag={false}` is zero-axis; bindings install at grab from the live session config.
|
|
284
|
+
let axes = resolvedDragAxes(config)
|
|
285
|
+
const bindings: Partial<Record<DragAxis, DragHandoffBinding>> = {}
|
|
286
|
+
const sessions: Partial<Record<DragAxis, GestureHandoffSession>> = {}
|
|
287
|
+
const installBindings = (next: ResolvedDragConfig): readonly DragAxis[] => {
|
|
288
|
+
const nextAxes = resolvedDragAxes(next)
|
|
289
|
+
config = next
|
|
290
|
+
axes = nextAxes
|
|
291
|
+
dragPropagation = options.dragPropagation ?? next.dragPropagation
|
|
292
|
+
for (const axis of ['x', 'y'] as const) {
|
|
293
|
+
delete bindings[axis]
|
|
294
|
+
delete sessions[axis]
|
|
295
|
+
}
|
|
296
|
+
for (const axis of nextAxes) {
|
|
297
|
+
const binding = createDragHandoffBinding(axis, next[axis]!, next.dragMomentum)
|
|
298
|
+
bindings[axis] = binding
|
|
299
|
+
sessions[axis] = binding.session
|
|
300
|
+
}
|
|
301
|
+
return nextAxes
|
|
302
|
+
}
|
|
303
|
+
if (axes.length > 0) {
|
|
304
|
+
installBindings(config)
|
|
305
|
+
}
|
|
306
|
+
// The lock owner's identity: this wiring (one per mounted drag element). A dedicated token —
|
|
307
|
+
// never the handle (driver-owned) or the callbacks (consumer-owned).
|
|
308
|
+
const lockOwner: object = {}
|
|
309
|
+
let lockState: LockState = 'open'
|
|
310
|
+
// Set by dispose() (unmount): late recognizer events no-op and the held lock is freed.
|
|
311
|
+
let disposed = false
|
|
312
|
+
// Per-gesture delta base: the previous event's point; null between gestures.
|
|
313
|
+
let lastPoint: { x: number; y: number } | null = null
|
|
314
|
+
// Absolute prop values at grab (per axis) — DragInfo.offset is constrained session value minus
|
|
315
|
+
// these seeds (element drag displacement), not raw recognizer translation.
|
|
316
|
+
const grabSeeds: Partial<Record<DragAxis, number>> = {}
|
|
317
|
+
// R15 D4 (REQ-GESTURE-025): session-scoped direction lock; null until threshold crossed.
|
|
318
|
+
let lockedAxis: DragAxis | null = null
|
|
319
|
+
let directionLockNotified = false
|
|
320
|
+
// The deferred-begin state (REQ-GESTURE-028): a grab carrying element-ref constraints holds
|
|
321
|
+
// its full validated start event + buffered updates while the grab-time measure is in flight
|
|
322
|
+
// (native measureInWindow is async; the value holds C0 until the measured bounds land).
|
|
323
|
+
interface PendingDeferredGrab {
|
|
324
|
+
readonly event: InfoEventShape
|
|
325
|
+
readonly info: DragInfo
|
|
326
|
+
readonly updates: InfoEventShape[]
|
|
327
|
+
// The recognizer may END before the measure lands (a quick flick — review r6 major 1):
|
|
328
|
+
// the end (and its finalize) QUEUE here, never silently dropped (G-INV-8). When the
|
|
329
|
+
// measure lands the FULL lifecycle flushes: begin → buffered updates → the release
|
|
330
|
+
// handoff → finalize. Only dispose / the buffer cap / a measure failure cancels.
|
|
331
|
+
endEvent?: { readonly event: DragPanEventShape; readonly success: boolean }
|
|
332
|
+
finalized?: boolean
|
|
333
|
+
}
|
|
334
|
+
let pendingGrab: PendingDeferredGrab | null = null
|
|
335
|
+
// The deferred begin ABORTED (the grab-time measure failed): the rest of THIS recognizer
|
|
336
|
+
// stream is dead — trailing updates and ends no-op (the pin's "the element simply does not
|
|
337
|
+
// drag"), never a session call against an idle session (the 2026-08-04 device crash class:
|
|
338
|
+
// update after measure failure → active() in 'idle'). The NEXT pan start re-arms.
|
|
339
|
+
let grabAborted = false
|
|
340
|
+
|
|
341
|
+
// Cancel a pending deferred begin (dispose, the bounded-buffer overflow, a host error): drop
|
|
342
|
+
// the queued lifecycle and free the lock — the drag never began, so no callbacks and no
|
|
343
|
+
// session (Motion's end-without-start is nothing). The grab-instant interrupt already froze
|
|
344
|
+
// any in-flight animation AT the grab value — the cancel target equals the frozen value
|
|
345
|
+
// (pre-grab == grab instant), so no return handoff is owed.
|
|
346
|
+
function cancelPendingGrab(): void {
|
|
347
|
+
if (pendingGrab === null) return
|
|
348
|
+
pendingGrab = null
|
|
349
|
+
grabAborted = true
|
|
350
|
+
if (lockState === 'acquired') {
|
|
351
|
+
lock.release(lockOwner)
|
|
352
|
+
lockState = 'open'
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The mid-drag re-measure loop (REQ-GESTURE-028, review major 3 — the pin's ResizeObserver +
|
|
357
|
+
// window-resize scalePositionWithinConstraints path): while a ref-constrained drag is ACTIVE
|
|
358
|
+
// the boxes re-measure per frame tick; on change the constraints re-resolve AND the position
|
|
359
|
+
// rescales to preserve the relative progress within the new bounds. `lastResolvedPerAxis`
|
|
360
|
+
// tracks the bounds the active grab began (or last rescaled) with — the diff + the progress
|
|
361
|
+
// base. The loop is self-terminating (no active ref session → cancel) and bounded (one
|
|
362
|
+
// async measure in flight; the stream's abort/dispose paths stop it).
|
|
363
|
+
let stopMeasurePoll: (() => void) | null = null
|
|
364
|
+
let pollMeasureInFlight = false
|
|
365
|
+
let lastResolvedPerAxis: Partial<Record<DragAxis, AxisSpan>> = {}
|
|
366
|
+
|
|
367
|
+
function stopPoll(): void {
|
|
368
|
+
stopMeasurePoll?.()
|
|
369
|
+
stopMeasurePoll = null
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function tickMeasurePoll(): void {
|
|
373
|
+
if (pollMeasureInFlight || pendingGrab !== null) return
|
|
374
|
+
const activeRefAxes = axes.filter(
|
|
375
|
+
(axis) =>
|
|
376
|
+
config[axis]?.constraintsRef !== undefined && bindings[axis]!.session.state() === 'active',
|
|
377
|
+
)
|
|
378
|
+
if (activeRefAxes.length === 0) {
|
|
379
|
+
stopPoll()
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
pollMeasureInFlight = true
|
|
383
|
+
const epoch = gestureEpoch
|
|
384
|
+
void measureWithTimeout(
|
|
385
|
+
options.measureConstraints!,
|
|
386
|
+
config[activeRefAxes[0]!]!.constraintsRef!.ref,
|
|
387
|
+
).then(
|
|
388
|
+
(boxes) => {
|
|
389
|
+
pollMeasureInFlight = false
|
|
390
|
+
// Stale resolves die: the stream aborted/disposed, or the epoch moved (a release or a
|
|
391
|
+
// re-grab happened while this measure was in flight — review r2 major 2).
|
|
392
|
+
if (disposed || grabAborted || epoch !== gestureEpoch) return
|
|
393
|
+
try {
|
|
394
|
+
adoptMeasuredBoxes(boxes, activeRefAxes)
|
|
395
|
+
} catch (error) {
|
|
396
|
+
// A throwing measure hook mid-drag: report and stop the poll — the active grab keeps
|
|
397
|
+
// its last resolved bounds (symmetric to the measure-failure path).
|
|
398
|
+
stopPoll()
|
|
399
|
+
options.reportConstraintFailure?.(
|
|
400
|
+
error instanceof Error ? error : new Error(describeValue(error)),
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
(cause) => {
|
|
405
|
+
pollMeasureInFlight = false
|
|
406
|
+
// The same staleness guard as the fulfillment branch (review r4 major 4): a poll
|
|
407
|
+
// rejected after abort/dispose/epoch-move is DISCARDED — never a stale report, never a
|
|
408
|
+
// stopPoll on the ticker now serving the next gesture.
|
|
409
|
+
if (disposed || grabAborted || epoch !== gestureEpoch) return
|
|
410
|
+
// A mid-drag measure failure (the container unmounted): report through the channel and
|
|
411
|
+
// stop the poll — the active grab keeps its last resolved bounds (the pin's invariant
|
|
412
|
+
// surfaces on its own channel; the stream is NOT aborted — the gesture itself is fine).
|
|
413
|
+
stopPoll()
|
|
414
|
+
options.reportConstraintFailure?.(
|
|
415
|
+
cause instanceof Error ? cause : new Error(describeValue(cause)),
|
|
416
|
+
)
|
|
417
|
+
},
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Adopt a poll resolve PER AXIS (review r13 major 1): an axis whose container and draggable
|
|
422
|
+
// boxes moved by the SAME delta is invariant on that axis (unchanged, or a shared ancestor
|
|
423
|
+
// translate/scroll) — never fabricated by pairing a stale draggable with a moved container.
|
|
424
|
+
// A container-only change on an axis adopts with the last adopted (coherent) draggable span
|
|
425
|
+
// for THAT axis; a draggable-only change on an axis is lag (or the deferred app shift) —
|
|
426
|
+
// never adopted. The rescale diffs per axis, so untouched axes are untouched.
|
|
427
|
+
function adoptMeasuredBoxes(boxes: ConstraintsMeasureBoxes, refAxes_: readonly DragAxis[]): void {
|
|
428
|
+
const adopted = lastAdoptedBoxes
|
|
429
|
+
if (adopted === null) return
|
|
430
|
+
let adoptX = false
|
|
431
|
+
let adoptY = false
|
|
432
|
+
let invX = true
|
|
433
|
+
let invY = true
|
|
434
|
+
for (const axis of ['x', 'y'] as const) {
|
|
435
|
+
const draggableDeltaMin = boxes.draggableBox[axis].min - adopted.draggableBox[axis].min
|
|
436
|
+
const draggableDeltaMax = boxes.draggableBox[axis].max - adopted.draggableBox[axis].max
|
|
437
|
+
const containerDeltaMin = boxes.containerBox[axis].min - adopted.containerBox[axis].min
|
|
438
|
+
const containerDeltaMax = boxes.containerBox[axis].max - adopted.containerBox[axis].max
|
|
439
|
+
if (draggableDeltaMin === containerDeltaMin && draggableDeltaMax === containerDeltaMax) {
|
|
440
|
+
continue // invariant on this axis
|
|
441
|
+
}
|
|
442
|
+
if (axis === 'x') invX = false
|
|
443
|
+
else invY = false
|
|
444
|
+
if (sameSpan(boxes.containerBox[axis], adopted.containerBox[axis])) continue // lag — never adopted
|
|
445
|
+
if (axis === 'x') adoptX = true
|
|
446
|
+
else adoptY = true
|
|
447
|
+
}
|
|
448
|
+
if (adoptX || adoptY) {
|
|
449
|
+
// Per axis: invariant axes pair the SAMPLE's boxes (their relative geometry is unchanged);
|
|
450
|
+
// changed axes — adopting OR lag-only — pair the ADOPTED draggable span (never the
|
|
451
|
+
// lag-suspect sample side, review r14 major 3) with the current container span.
|
|
452
|
+
const coherentBoxes: ConstraintsMeasureBoxes = {
|
|
453
|
+
draggableBox: {
|
|
454
|
+
x: invX ? boxes.draggableBox.x : adopted.draggableBox.x,
|
|
455
|
+
y: invY ? boxes.draggableBox.y : adopted.draggableBox.y,
|
|
456
|
+
},
|
|
457
|
+
containerBox: boxes.containerBox,
|
|
458
|
+
}
|
|
459
|
+
lastAdoptedBoxes = coherentBoxes
|
|
460
|
+
rescaleAfterBoxChange(applyMeasureHook(coherentBoxes, refAxes_), refAxes_)
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
if (invX && invY) {
|
|
464
|
+
lastAdoptedBoxes = boxes // a shared translate on every axis — track, never fabricate
|
|
465
|
+
}
|
|
466
|
+
// else: lag-only changes — keep the baseline (the lag must not poison it).
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Re-resolve + rescale after the measured boxes changed (the pin's math: preserve the
|
|
470
|
+
// relative progress within the old bounds — calcOrigin — applied to the new bounds — mix).
|
|
471
|
+
// The measure hook already ran (adoptMeasuredBoxes' shared seam).
|
|
472
|
+
function rescaleAfterBoxChange(
|
|
473
|
+
finalPerAxis: Partial<Record<DragAxis, AxisSpan>>,
|
|
474
|
+
refAxes_: readonly DragAxis[],
|
|
475
|
+
): void {
|
|
476
|
+
const values: Record<string, number> = {}
|
|
477
|
+
let changed = false
|
|
478
|
+
for (const axis of refAxes_) {
|
|
479
|
+
const next = finalPerAxis[axis]!
|
|
480
|
+
const prev = lastResolvedPerAxis[axis]
|
|
481
|
+
if (prev === undefined || (prev.min === next.min && prev.max === next.max)) continue
|
|
482
|
+
lastResolvedPerAxis[axis] = next
|
|
483
|
+
const session = bindings[axis]!.session
|
|
484
|
+
if (session.state() !== 'active') continue
|
|
485
|
+
const live = driver.liveFor(handle, axis)
|
|
486
|
+
// The progress-preserving rescale requires FINITE spans on both sides: an open-sided
|
|
487
|
+
// span (a legal hook replacement) has no proportional position to preserve — keep the
|
|
488
|
+
// live value (the constraints still update; review r4 major 3 — never write NaN).
|
|
489
|
+
const rescaled =
|
|
490
|
+
Number.isFinite(prev.min) &&
|
|
491
|
+
Number.isFinite(prev.max) &&
|
|
492
|
+
Number.isFinite(next.min) &&
|
|
493
|
+
Number.isFinite(next.max)
|
|
494
|
+
? next.min + (next.max - next.min) * progressWithinSpan(live.value, prev)
|
|
495
|
+
: live.value
|
|
496
|
+
session.updateConstraints(
|
|
497
|
+
{ min: next.min, max: next.max, elastic: config[axis]!.constraintsRef!.elastic },
|
|
498
|
+
rescaled,
|
|
499
|
+
)
|
|
500
|
+
values[axis] = rescaled
|
|
501
|
+
changed = true
|
|
502
|
+
}
|
|
503
|
+
if (changed) driver.write(handle, values)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function startMeasurePoll(): void {
|
|
507
|
+
if (options.frameTicker === undefined || stopMeasurePoll !== null) return
|
|
508
|
+
stopMeasurePoll = options.frameTicker(tickMeasurePoll)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// The hook's replacement box is a boundary INPUT, validated immediately by CORE's single
|
|
512
|
+
// cross-engine verdict (review r3 major 3 + r7 major 3): complete, finite or
|
|
513
|
+
// correct-polarity-infinite, ordered. A malformed replacement is a typed refusal —
|
|
514
|
+
// severity-adapted by the caller's report channel (the begin aborts the grab; the poll
|
|
515
|
+
// keeps its last resolved bounds).
|
|
516
|
+
|
|
517
|
+
// The pin's measure-hook seam, shared by the grab-time begin AND the mid-drag re-measure
|
|
518
|
+
// (the pin invokes resolveRefConstraints — hook included — on BOTH paths, review r2 major 1):
|
|
519
|
+
// compute calcViewportConstraints over BOTH axes of the measured boxes (the complete
|
|
520
|
+
// geometry, never a fabricated zero span on the non-dragged axis — review r1 major 2),
|
|
521
|
+
// offer it to onMeasureDragConstraints ONCE (the hasMutatedConstraints seam), and use the
|
|
522
|
+
// possibly-replaced bounds per ref axis.
|
|
523
|
+
function applyMeasureHook(
|
|
524
|
+
boxes: ConstraintsMeasureBoxes,
|
|
525
|
+
refAxes_: readonly DragAxis[],
|
|
526
|
+
): Partial<Record<DragAxis, AxisSpan>> {
|
|
527
|
+
const measuredBoth = {
|
|
528
|
+
x: resolveViewportConstraints(boxes.draggableBox.x, boxes.containerBox.x),
|
|
529
|
+
y: resolveViewportConstraints(boxes.draggableBox.y, boxes.containerBox.y),
|
|
530
|
+
}
|
|
531
|
+
let finalPerAxis: Partial<Record<DragAxis, AxisSpan>> = {}
|
|
532
|
+
for (const axis of refAxes_) {
|
|
533
|
+
finalPerAxis[axis] = measuredBoth[axis]
|
|
534
|
+
}
|
|
535
|
+
if (callbacks.onMeasureDragConstraints !== undefined) {
|
|
536
|
+
// The hook's box is a boundary input; a THROWING hook or a malformed replacement refuses
|
|
537
|
+
// the PROPERTY, never the gesture (review r14 major 4, BRIEF's single severity policy —
|
|
538
|
+
// cross-engine: web's wrapper drops the replacement and keeps motion's own measured box).
|
|
539
|
+
// The measured bounds stand; the failure reports through the channel per sighting.
|
|
540
|
+
try {
|
|
541
|
+
const replaced = callbacks.onMeasureDragConstraints({
|
|
542
|
+
top: measuredBoth.y.min,
|
|
543
|
+
left: measuredBoth.x.min,
|
|
544
|
+
bottom: measuredBoth.y.max,
|
|
545
|
+
right: measuredBoth.x.max,
|
|
546
|
+
})
|
|
547
|
+
if (replaced) {
|
|
548
|
+
// The pin's truthiness no-op (hasMutatedConstraints = !!userConstraints): any falsy
|
|
549
|
+
// return means "keep the measured box". A TRUTHY return is validated by core's single
|
|
550
|
+
// verdict (complete/finite/ordered or the typed refusal).
|
|
551
|
+
const legal = validateMeasureReplacementBox(replaced)
|
|
552
|
+
finalPerAxis = {}
|
|
553
|
+
for (const axis of refAxes_) {
|
|
554
|
+
finalPerAxis[axis] =
|
|
555
|
+
axis === 'x'
|
|
556
|
+
? { min: legal.left, max: legal.right }
|
|
557
|
+
: { min: legal.top, max: legal.bottom }
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
} catch (error) {
|
|
561
|
+
options.reportConstraintFailure?.(
|
|
562
|
+
error instanceof Error ? error : new Error(describeValue(error)),
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return finalPerAxis
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// The gesture epoch (review r2 major 2): bumped at every grab and every stream end — an
|
|
570
|
+
// async poll dispatched under one gesture resolves under the epoch check, so a stale
|
|
571
|
+
// measure (release + rapid re-grab meanwhile) is discarded, never accepted into the new
|
|
572
|
+
// gesture's session. The deferred begin's own promise rides the pendingGrab token already.
|
|
573
|
+
let gestureEpoch = 0
|
|
574
|
+
// The adoption rule (2026-08-04 walk finding, review r3 major 2 + r5 major 2): only a
|
|
575
|
+
// CONTAINER-side change is adopted, and only with the LAST ADOPTED draggable span — never
|
|
576
|
+
// the poll sample's draggable box. The draggable's layout is static under an active drag
|
|
577
|
+
// (the drag moves via transform), so any sample-side change there is the async measure's
|
|
578
|
+
// frame lag (the finger moves between the UI measure and the JS delivery; under steady
|
|
579
|
+
// motion two consecutive lagged samples can coincide, which is why the earlier
|
|
580
|
+
// two-sightings debounce still drifted). The container side carries no finger lag, and a
|
|
581
|
+
// continuously animated container re-resolves every frame. An app-driven draggable layout
|
|
582
|
+
// shift mid-drag is the pin's didUpdate delta-follow path — deferred (Finding D2e).
|
|
583
|
+
let lastAdoptedBoxes: ConstraintsMeasureBoxes | null = null
|
|
584
|
+
|
|
585
|
+
function sameSpan(a: AxisSpan, b: AxisSpan): boolean {
|
|
586
|
+
return a.min === b.min && a.max === b.max
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function buildInfo(
|
|
590
|
+
event: InfoEventShape,
|
|
591
|
+
offsetOverride?: { readonly x: number; readonly y: number },
|
|
592
|
+
): DragInfo {
|
|
593
|
+
const point = {
|
|
594
|
+
x: requireInfoNumber(event.absoluteX, 'absoluteX'),
|
|
595
|
+
y: requireInfoNumber(event.absoluteY, 'absoluteY'),
|
|
596
|
+
}
|
|
597
|
+
// Default offset is raw translation (grab path before sessions have advanced). Callers that
|
|
598
|
+
// have just applied session updates pass constrained element displacement (value − grabSeed).
|
|
599
|
+
const offset = offsetOverride ?? {
|
|
600
|
+
x: requireInfoNumber(event.translationX, 'translationX'),
|
|
601
|
+
y: requireInfoNumber(event.translationY, 'translationY'),
|
|
602
|
+
}
|
|
603
|
+
const info: DragInfo = {
|
|
604
|
+
point,
|
|
605
|
+
delta:
|
|
606
|
+
lastPoint === null
|
|
607
|
+
? { x: 0, y: 0 }
|
|
608
|
+
: { x: point.x - lastPoint.x, y: point.y - lastPoint.y },
|
|
609
|
+
offset,
|
|
610
|
+
velocity: {
|
|
611
|
+
x: requireInfoNumber(event.velocityX, 'velocityX'),
|
|
612
|
+
y: requireInfoNumber(event.velocityY, 'velocityY'),
|
|
613
|
+
},
|
|
614
|
+
}
|
|
615
|
+
lastPoint = point
|
|
616
|
+
return info
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function constrainedOffset(): { x: number; y: number } {
|
|
620
|
+
const axisOffset = (axis: DragAxis): number => {
|
|
621
|
+
const binding = bindings[axis]
|
|
622
|
+
const seed = grabSeeds[axis]
|
|
623
|
+
if (binding === undefined || seed === undefined || binding.session.state() !== 'active') {
|
|
624
|
+
return 0
|
|
625
|
+
}
|
|
626
|
+
return binding.session.value() - seed
|
|
627
|
+
}
|
|
628
|
+
return { x: axisOffset('x'), y: axisOffset('y') }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function anySessionActive(): boolean {
|
|
632
|
+
return axes.some((axis) => bindings[axis]!.session.state() === 'active')
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Release and cancel share the command shape: the sessions' handoffs ARE the seed
|
|
636
|
+
// (REQ-GESTURE-022); the driver's spring integrates them on the UI thread (REQ-DRIVER-023).
|
|
637
|
+
// ONE command covers every handed-off axis (REQ-GESTURE-024) so the release edge is atomic.
|
|
638
|
+
//
|
|
639
|
+
// TERMINAL TOTALITY (G-INV-7, review r4 major 759836): mutate-THEN-info. The handoffs are
|
|
640
|
+
// already computed from the release-critical fields (axis translations/velocities), so the
|
|
641
|
+
// release/cancel command lands FIRST — the gesture's real outcome (settling to the targets).
|
|
642
|
+
// Only THEN is the callback `info` built: a malformed INFO-ONLY field (absoluteX/Y) throws
|
|
643
|
+
// AFTER the gesture has terminated cleanly (sessions idle, driver settling), losing the
|
|
644
|
+
// callback but never stranding the gesture active. This is deliberately OPPOSITE
|
|
645
|
+
// handlePanStart (validate-before-mutate): a bad GRAB must not interrupt the in-flight
|
|
646
|
+
// animation; a bad terminal must still end.
|
|
647
|
+
function issueHandoffs(
|
|
648
|
+
handoffs: Readonly<Partial<Record<DragAxis, ReleaseHandoff>>>,
|
|
649
|
+
event: InfoEventShape,
|
|
650
|
+
): void {
|
|
651
|
+
const targets: Record<string, PropTarget> = {}
|
|
652
|
+
for (const axis of axes) {
|
|
653
|
+
const handoff = handoffs[axis]
|
|
654
|
+
if (handoff === undefined) continue
|
|
655
|
+
targets[axis] =
|
|
656
|
+
handoff.curve === 'inertia'
|
|
657
|
+
? // Free-drag release (R16): the two-phase inertia generator rides the per-target
|
|
658
|
+
// transition; `to` is the mirror terminal (the clamped ideal), the descriptor owns
|
|
659
|
+
// the curve.
|
|
660
|
+
{
|
|
661
|
+
to: handoff.target,
|
|
662
|
+
from: handoff.from,
|
|
663
|
+
velocity: handoff.velocity,
|
|
664
|
+
transition: inertiaTransitionFor(config, axis, handoff),
|
|
665
|
+
}
|
|
666
|
+
: { to: handoff.target, from: handoff.from, velocity: handoff.velocity }
|
|
667
|
+
}
|
|
668
|
+
if (Object.keys(targets).length === 0) return // nothing active handed off (defensive)
|
|
669
|
+
driver.command(handle, {
|
|
670
|
+
kind: 'start',
|
|
671
|
+
targets,
|
|
672
|
+
transition: releaseTransitionFor(config),
|
|
673
|
+
// The finger let go (or the gesture cancelled): THIS start is the release edge — it ends
|
|
674
|
+
// the gesture's ownership of the axis props so the settle spring takes over with the
|
|
675
|
+
// seeded platform velocity. A declarative `animate`/mount command during the drag never
|
|
676
|
+
// carries this, so it can never masquerade as the release (review r8 major 24).
|
|
677
|
+
releasesGesture: true,
|
|
678
|
+
})
|
|
679
|
+
const info = buildInfo(event) // AFTER the command — a malformed info-only field throws terminated
|
|
680
|
+
lastPoint = null // the gesture is over; the next grab starts a fresh delta base
|
|
681
|
+
callbacks.onDragEnd?.(event, info)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Complete a deferred begin once the grab-time measure lands (REQ-GESTURE-028): compute the
|
|
685
|
+
// pin's viewport constraints per axis, apply the measure hook, validate snap against the
|
|
686
|
+
// measured bounds, then run the EXACT grab protocol of the immediate path (live-read →
|
|
687
|
+
// per-axis begin with the measured bounds → interrupting write → onDragStart) and flush the
|
|
688
|
+
// buffered updates in order.
|
|
689
|
+
function beginAfterMeasure(
|
|
690
|
+
pending: PendingDeferredGrab,
|
|
691
|
+
boxes: ConstraintsMeasureBoxes,
|
|
692
|
+
refAxes: readonly DragAxis[],
|
|
693
|
+
): void {
|
|
694
|
+
try {
|
|
695
|
+
// The complete measured box + the measure hook, through the SHARED seam (review r2
|
|
696
|
+
// major 1 — the pin applies the hook on every resolveRefConstraints path).
|
|
697
|
+
const finalPerAxis = applyMeasureHook(boxes, refAxes)
|
|
698
|
+
// Snap-vs-measured-bounds (the numeric path's contradiction law at the grab-time measure,
|
|
699
|
+
// REQ-GESTURE-015): a snap outside the measured bounds would settle out of range.
|
|
700
|
+
for (const axis of refAxes) {
|
|
701
|
+
const snap = config[axis]?.snap
|
|
702
|
+
const bounds = finalPerAxis[axis]!
|
|
703
|
+
if (snap !== undefined) {
|
|
704
|
+
for (const point of snap.points) {
|
|
705
|
+
if (point < bounds.min || point > bounds.max) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`dragSnapPoints.${axis} contains ${point}, outside the measured dragConstraints ` +
|
|
708
|
+
`[${bounds.min}, ${bounds.max}] — a snap outside the hard bounds would settle ` +
|
|
709
|
+
'out of range (REQ-GESTURE-015).',
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const values: Record<string, number> = {}
|
|
716
|
+
const velocities: Record<string, number> = {}
|
|
717
|
+
for (const axis of axes) {
|
|
718
|
+
const live = driver.liveFor(handle, axis)
|
|
719
|
+
const measured = finalPerAxis[axis]
|
|
720
|
+
bindings[axis]!.onStart(
|
|
721
|
+
pending.event,
|
|
722
|
+
{ value: live.value },
|
|
723
|
+
measured === undefined
|
|
724
|
+
? undefined
|
|
725
|
+
: {
|
|
726
|
+
min: measured.min,
|
|
727
|
+
max: measured.max,
|
|
728
|
+
elastic: config[axis]!.constraintsRef!.elastic,
|
|
729
|
+
},
|
|
730
|
+
)
|
|
731
|
+
values[axis] = live.value
|
|
732
|
+
velocities[axis] = live.velocity
|
|
733
|
+
}
|
|
734
|
+
driver.write(handle, values, velocities)
|
|
735
|
+
callbacks.onDragStart?.(pending.event, pending.info)
|
|
736
|
+
for (const update of pending.updates) applyPanUpdate(update)
|
|
737
|
+
// The grab began with the measured bounds: arm the mid-drag re-measure loop (review
|
|
738
|
+
// major 3) with THESE bounds as the diff/progress base and the adoption marker.
|
|
739
|
+
lastResolvedPerAxis = { ...finalPerAxis }
|
|
740
|
+
lastAdoptedBoxes = boxes
|
|
741
|
+
startMeasurePoll()
|
|
742
|
+
// A queued end/finalize (the flick that ended before the measure landed, review r6
|
|
743
|
+
// major 1) flushes through the NORMAL lifecycle — the release handoff (or the
|
|
744
|
+
// cancel-to-pre-grab) starts, then finalize runs its totality/lock law. No motion is
|
|
745
|
+
// ever silently dropped (G-INV-8).
|
|
746
|
+
if (pending.endEvent !== undefined) {
|
|
747
|
+
routeEnd(pending.endEvent.event, pending.endEvent.success)
|
|
748
|
+
}
|
|
749
|
+
if (pending.finalized === true) {
|
|
750
|
+
routeFinalize(pending.endEvent?.event ?? pending.event)
|
|
751
|
+
}
|
|
752
|
+
} catch (error) {
|
|
753
|
+
let finalizeFailure: Error | undefined
|
|
754
|
+
if (anySessionActive()) {
|
|
755
|
+
// Post-activation failure — a throwing consumer callback during the begin OR the
|
|
756
|
+
// queued end/finalize replay (review r7 major 1): the immediate path relies on the
|
|
757
|
+
// LATER recognizer finalize to clean up, but here the queued terminal events were
|
|
758
|
+
// already consumed — nothing later cancels. Force the totality path NOW: cancel the
|
|
759
|
+
// active sessions to pre-grab and free the lock, then report through the channel.
|
|
760
|
+
grabAborted = true
|
|
761
|
+
try {
|
|
762
|
+
routeFinalize(pending.event)
|
|
763
|
+
} catch (secondary) {
|
|
764
|
+
// The forced finalize can itself fail (review r8 major 3) — capture, never swallow;
|
|
765
|
+
// it reports after the primary below.
|
|
766
|
+
finalizeFailure =
|
|
767
|
+
secondary instanceof Error ? secondary : new Error(describeValue(secondary))
|
|
768
|
+
}
|
|
769
|
+
} else {
|
|
770
|
+
// Pre-activation failure (snap validation, the hook, liveFor): the begin never completed,
|
|
771
|
+
// so the rest of the recognizer stream is DEAD (review major 1 — a trailing update
|
|
772
|
+
// crashed active()-in-'idle' here). A POST-activation failure (a throwing consumer
|
|
773
|
+
// callback) keeps the gesture: the active session owns the lock until finalize (the D1 law).
|
|
774
|
+
grabAborted = true
|
|
775
|
+
if (lockState === 'acquired') {
|
|
776
|
+
lock.release(lockOwner)
|
|
777
|
+
lockState = 'open'
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
options.reportConstraintFailure?.(
|
|
781
|
+
error instanceof Error ? error : new Error(describeValue(error)),
|
|
782
|
+
)
|
|
783
|
+
if (finalizeFailure !== undefined) {
|
|
784
|
+
// The forced finalize's own failure (driver/command/info/consumer — G-INV-7's own
|
|
785
|
+
// vocabulary) rides the channel AFTER the primary, never swallowed (review r8 major 3;
|
|
786
|
+
// a suppressed secondary would hide a terminal-handoff failure).
|
|
787
|
+
options.reportConstraintFailure?.(finalizeFailure)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// The update hot path, shared by the recognizer handler and the deferred-begin flush
|
|
793
|
+
// (REQ-GESTURE-002: one write per recognizer event — both axes in one write,
|
|
794
|
+
// REQ-GESTURE-024). Validate the full event before mutating (major 6): buildInfo first,
|
|
795
|
+
// then each session owns its axis's per-event decision (dead-zone, clamp, elastic) and the
|
|
796
|
+
// constrained values land together. The write carries the recognizer's per-axis velocities
|
|
797
|
+
// — the platform numbers, no estimator (release-side C1, G-INV-3): liveFor tracks the
|
|
798
|
+
// finger velocity so the release seam is continuous (review r4 c14e15).
|
|
799
|
+
function applyPanUpdate(event: InfoEventShape): void {
|
|
800
|
+
// Pin updateAxis (VisualElementDragControls.ts:320): on→off mid-drag freezes further
|
|
801
|
+
// writes. Live empty axes means drag={false} this render.
|
|
802
|
+
const liveAxes = resolvedDragAxes(getConfig())
|
|
803
|
+
if (liveAxes.length === 0) return
|
|
804
|
+
// Direction lock uses RAW pointer translation (pin: lock before constraints apply).
|
|
805
|
+
const rawX = requireInfoNumber(event.translationX, 'translationX')
|
|
806
|
+
const rawY = requireInfoNumber(event.translationY, 'translationY')
|
|
807
|
+
if (config.dragDirectionLock) {
|
|
808
|
+
if (lockedAxis === null) {
|
|
809
|
+
const next = resolveDirectionLockAxis(rawX, rawY)
|
|
810
|
+
if (next === null) return
|
|
811
|
+
lockedAxis = next
|
|
812
|
+
if (!directionLockNotified) {
|
|
813
|
+
directionLockNotified = true
|
|
814
|
+
callbacks.onDirectionLock?.(next)
|
|
815
|
+
}
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const activeAxes = (
|
|
820
|
+
lockedAxis === null ? axes : axes.filter((axis) => axis === lockedAxis)
|
|
821
|
+
).filter((axis) => liveAxes.includes(axis))
|
|
822
|
+
if (activeAxes.length === 0) return
|
|
823
|
+
const values: Record<string, number> = {}
|
|
824
|
+
const velocities: Record<string, number> = {}
|
|
825
|
+
const velX = requireInfoNumber(event.velocityX, 'velocityX')
|
|
826
|
+
const velY = requireInfoNumber(event.velocityY, 'velocityY')
|
|
827
|
+
for (const axis of activeAxes) {
|
|
828
|
+
values[axis] = bindings[axis]!.onUpdate(event)
|
|
829
|
+
velocities[axis] = axis === 'x' ? velX : velY
|
|
830
|
+
}
|
|
831
|
+
driver.write(handle, values, velocities)
|
|
832
|
+
// DragInfo after session updates: offset is constrained element displacement (value − grab).
|
|
833
|
+
const info = buildInfo(event, constrainedOffset())
|
|
834
|
+
callbacks.onDrag?.(event, info)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// The successful-release path (REQ-GESTURE-022), shared by handlePanEnd and the
|
|
838
|
+
// deferred-begin's queued-end flush (review r6 major 1 — one lifecycle, never forked).
|
|
839
|
+
function routeEnd(event: DragPanEventShape, success: boolean): void {
|
|
840
|
+
if (!success) {
|
|
841
|
+
// Unsuccessful end (RNGH fires onEnd(event,false) before onFinalize): NOT a release —
|
|
842
|
+
// finalize owns the cancel. routeEnd(false) is a no-op, so there is nothing to do here
|
|
843
|
+
// and nothing to validate (no release fields consumed).
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
// Successful release, in three ordered acts PER AXIS that the pin would startAnimation
|
|
847
|
+
// for (shouldDrag / currentDirection). R15 D4 seal H1/H1′: under dragDirectionLock a locked
|
|
848
|
+
// session must not free-drag the unlocked axis on end — beginRelease of a never-updated
|
|
849
|
+
// axis still sees raw finger translation as "moved" and would catch-up + seed velocity.
|
|
850
|
+
// Unlock axes cancel-to-live in the SAME issueHandoffs batch (pin: one onDragEnd; never
|
|
851
|
+
// leave them active for routeFinalize to fire a second terminal).
|
|
852
|
+
const releaseAxes: readonly DragAxis[] =
|
|
853
|
+
config.dragDirectionLock && lockedAxis !== null
|
|
854
|
+
? axes.filter((axis) => axis === lockedAxis)
|
|
855
|
+
: axes
|
|
856
|
+
// 1. COMMIT the final drag sample on every release axis (validate-then-apply; a malformed
|
|
857
|
+
// field throws WITHOUT ending that axis's session, so finalize — the totality guarantor —
|
|
858
|
+
// cancels it). The end translation is never dropped (G-INV-8; the M1 session commits
|
|
859
|
+
// it too, r11 major 32).
|
|
860
|
+
const releases: Partial<
|
|
861
|
+
Record<DragAxis, { fingerValue: number; velocity: number; moved: boolean }>
|
|
862
|
+
> = {}
|
|
863
|
+
for (const axis of releaseAxes) releases[axis] = bindings[axis]!.beginRelease(event)
|
|
864
|
+
// 2. Catch the driver up to the finger's final position on every MOVED axis — decided by
|
|
865
|
+
// the RAW recognizer translation, NOT the constrained value (r12 major 10): a finger
|
|
866
|
+
// dragging PAST a hard bound moves while its clamped value stays pinned at the bound,
|
|
867
|
+
// and that movement must re-anchor the contention to the bound. A held axis does NOT
|
|
868
|
+
// write, so a contending declarative drift (REQ-DRIVER-026) remains its release origin
|
|
869
|
+
// — the animate wrote last (last-writer-wins). ONE write covers every moved axis.
|
|
870
|
+
const catchUp: Record<string, number> = {}
|
|
871
|
+
const catchUpVelocities: Record<string, number> = {}
|
|
872
|
+
for (const axis of releaseAxes) {
|
|
873
|
+
const release = releases[axis]!
|
|
874
|
+
if (release.moved) {
|
|
875
|
+
catchUp[axis] = release.fingerValue
|
|
876
|
+
catchUpVelocities[axis] = release.velocity
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (Object.keys(catchUp).length > 0) driver.write(handle, catchUp, catchUpVelocities)
|
|
880
|
+
// 3. A moved axis starts from the validated constrained sample just written. The live
|
|
881
|
+
// driver may queue that write on the UI thread, so a synchronous read here can still
|
|
882
|
+
// see the pre-sample value. A held axis performs no write and MUST read the actual live
|
|
883
|
+
// contending drift (REQ-GESTURE-022/REQ-DRIVER-025). Then issueHandoffs mutates-then-
|
|
884
|
+
// info (terminal totality, r4 759836) with ONE atomic release command.
|
|
885
|
+
const handoffs: Partial<Record<DragAxis, ReleaseHandoff>> = {}
|
|
886
|
+
for (const axis of releaseAxes) {
|
|
887
|
+
const release = releases[axis]!
|
|
888
|
+
const origin = release.moved ? release.fingerValue : driver.liveFor(handle, axis).value
|
|
889
|
+
// dragMomentum=false is gated INSIDE the session (R16): the projection velocity itself is
|
|
890
|
+
// zeroed (settle-at-release), so the handoff arrives already gated — no wiring-side gate.
|
|
891
|
+
let handoff = bindings[axis]!.finishRelease(release.velocity, origin)
|
|
892
|
+
// R15 D5: dragSnapToOrigin forces the release target to the origin on the named axes.
|
|
893
|
+
if (snapToOriginApplies(config, axis)) {
|
|
894
|
+
handoff =
|
|
895
|
+
handoff.curve === 'inertia'
|
|
896
|
+
? // The pin's zero-bounds inertia ({min:0,max:0}): the generator's boundary spring
|
|
897
|
+
// pulls the decay to 0 (R16 — REQ-GESTURE-027).
|
|
898
|
+
{
|
|
899
|
+
curve: 'inertia',
|
|
900
|
+
from: handoff.from,
|
|
901
|
+
velocity: handoff.velocity,
|
|
902
|
+
target: 0,
|
|
903
|
+
min: 0,
|
|
904
|
+
max: 0,
|
|
905
|
+
}
|
|
906
|
+
: { curve: 'spring', from: handoff.from, velocity: handoff.velocity, target: 0 }
|
|
907
|
+
}
|
|
908
|
+
handoffs[axis] = handoff
|
|
909
|
+
}
|
|
910
|
+
// Direction-lock unlock axes: cancel-to-live (no free-drag) in this same handoff batch so
|
|
911
|
+
// finalize sees all sessions idle and does not re-issue onDragEnd (seal H1′).
|
|
912
|
+
if (config.dragDirectionLock && lockedAxis !== null) {
|
|
913
|
+
for (const axis of axes) {
|
|
914
|
+
if (axis === lockedAxis) continue
|
|
915
|
+
if (bindings[axis]!.session.state() !== 'active') continue
|
|
916
|
+
const liveOrigin = driver.liveFor(handle, axis).value
|
|
917
|
+
const cancelHandoff = bindings[axis]!.routeFinalize(false, liveOrigin)
|
|
918
|
+
if (cancelHandoff !== null) handoffs[axis] = cancelHandoff
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
issueHandoffs(handoffs, event)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// The finalize totality path (G-INV-7), shared by handlePanFinalize and the deferred-begin's
|
|
925
|
+
// queued-finalize flush (review r6 major 1).
|
|
926
|
+
function routeFinalize(event: DragPanEventShape): void {
|
|
927
|
+
const state = lockState
|
|
928
|
+
try {
|
|
929
|
+
if (state === 'blocked') return
|
|
930
|
+
// Totality guarantor (G-INV-7, review r4 759836): finalize ALWAYS runs last. If any
|
|
931
|
+
// axis's session is still active — an unsuccessful end, OR a successful end whose
|
|
932
|
+
// release-critical fields were malformed and threw — force the cancel to the pre-grab
|
|
933
|
+
// value (event-independent) via issueHandoffs BEFORE any info build. A successful
|
|
934
|
+
// release already left every session idle, so this returns. (The RNGH `success` flag is
|
|
935
|
+
// not needed for the decision — session state is the totality signal.)
|
|
936
|
+
if (!anySessionActive()) return
|
|
937
|
+
const handoffs: Partial<Record<DragAxis, ReleaseHandoff>> = {}
|
|
938
|
+
for (const axis of axes) {
|
|
939
|
+
if (bindings[axis]!.session.state() !== 'active') continue
|
|
940
|
+
// The cancel ORIGIN is the driver's LIVE value on that axis at this instant
|
|
941
|
+
// (REQ-GESTURE-022, symmetric to the release path): starting the return spring from
|
|
942
|
+
// the finger would rewind a contending declarative drift (REQ-DRIVER-026), a visible
|
|
943
|
+
// backward jump (r11 major 31). The read is harmless (no mutation, never throws for
|
|
944
|
+
// the registered axis) so it precedes the totality-guaranteed cancel.
|
|
945
|
+
const releaseOrigin = driver.liveFor(handle, axis).value
|
|
946
|
+
const handoff = bindings[axis]!.routeFinalize(false, releaseOrigin)
|
|
947
|
+
if (handoff !== null) handoffs[axis] = handoff
|
|
948
|
+
}
|
|
949
|
+
issueHandoffs(handoffs, event)
|
|
950
|
+
} finally {
|
|
951
|
+
// The recognizer stream is over (finalize always runs last): free the lock so the
|
|
952
|
+
// element blocked behind us can drag next, and reset for the next grab.
|
|
953
|
+
if (state === 'acquired') lock.release(lockOwner)
|
|
954
|
+
lockState = 'open'
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return {
|
|
959
|
+
snapToCursor(point, layoutBox) {
|
|
960
|
+
if (disposed) return
|
|
961
|
+
// Pin: layout measurement includes the current transform — midpoint is the visual center.
|
|
962
|
+
const midX = layoutBox.x + layoutBox.width / 2
|
|
963
|
+
const midY = layoutBox.y + layoutBox.height / 2
|
|
964
|
+
const values: Partial<Record<DragAxis, number>> = {}
|
|
965
|
+
const velocities: Partial<Record<DragAxis, number>> = {}
|
|
966
|
+
for (const axis of axes) {
|
|
967
|
+
const live = driver.liveFor(handle, axis)
|
|
968
|
+
const mid = axis === 'x' ? midX : midY
|
|
969
|
+
const pointer = axis === 'x' ? point.absoluteX : point.absoluteY
|
|
970
|
+
values[axis] = pointer - mid + live.value
|
|
971
|
+
velocities[axis] = live.velocity
|
|
972
|
+
}
|
|
973
|
+
if (Object.keys(values).length > 0) driver.write(handle, values, velocities)
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
handlePanStart(event) {
|
|
977
|
+
if (disposed) return
|
|
978
|
+
const live = getConfig()
|
|
979
|
+
if (installBindings(live).length === 0) return
|
|
980
|
+
grabAborted = false // a fresh grab re-arms after an aborted deferred begin
|
|
981
|
+
gestureEpoch += 1 // a new gesture: in-flight polls from any previous one resolve stale
|
|
982
|
+
// Direction lock is session-scoped — every grab starts unlocked (REQ-GESTURE-025).
|
|
983
|
+
lockedAxis = null
|
|
984
|
+
directionLockNotified = false
|
|
985
|
+
// REQ-GESTURE-028 deferred-begin PRE-GATE: ref constraints REQUIRE the measure seam — a
|
|
986
|
+
// host wiring bug, fail-loud BEFORE the lock is ever touched (review r4 minor 5: the
|
|
987
|
+
// error path used to leak the acquired lock and wedge every later drag).
|
|
988
|
+
const refAxes = axes.filter((axis) => config[axis]?.constraintsRef !== undefined)
|
|
989
|
+
if (refAxes.length > 0 && options.measureConstraints === undefined) {
|
|
990
|
+
throw new Error(
|
|
991
|
+
'drag wiring: constraintsRef requires the measureConstraints seam (REQ-GESTURE-028; host wiring law).',
|
|
992
|
+
)
|
|
993
|
+
}
|
|
994
|
+
// The lock gate precedes EVERYTHING (REQ-GESTURE-024): a lock-blocked grab consumes
|
|
995
|
+
// nothing — no validation, no read, no write, no callback — exactly Motion's "the element
|
|
996
|
+
// simply does not drag" while another non-propagating element holds the lock.
|
|
997
|
+
if (!dragPropagation) {
|
|
998
|
+
// The pin's per-axis lock (motion-dom set-active): a single-axis drag takes its axis;
|
|
999
|
+
// a both-axes drag needs BOTH axes free (atomic — never a partial hold).
|
|
1000
|
+
if (lock.acquire(lockOwner, axes)) {
|
|
1001
|
+
lockState = 'acquired'
|
|
1002
|
+
} else {
|
|
1003
|
+
lockState = 'blocked'
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// REQ-GESTURE-028 deferred-begin branch: element-ref constraints measure at grab and
|
|
1008
|
+
// begin when the bounds land (native-motion re-measures at EVERY grab — the freshness
|
|
1009
|
+
// superset of the pin's cache-and-invalidate machinery; native measureInWindow is
|
|
1010
|
+
// async, so the value holds C0 meanwhile).
|
|
1011
|
+
if (refAxes.length > 0) {
|
|
1012
|
+
try {
|
|
1013
|
+
lastPoint = null
|
|
1014
|
+
const info = buildInfo(event)
|
|
1015
|
+
// The grab-instant interrupt (REQ-GESTURE-012/-022, review r4 major 2): the live read
|
|
1016
|
+
// + interrupting write happen NOW — an in-flight animation freezes AT the grab (the
|
|
1017
|
+
// pin's synchronous start does this inline). The deferred begin re-reads live when
|
|
1018
|
+
// the measure lands (the same frozen numbers unless a declarative drift contended
|
|
1019
|
+
// meanwhile — REQ-DRIVER-026's live-read law covers that).
|
|
1020
|
+
const interruptValues: Record<string, number> = {}
|
|
1021
|
+
const interruptVelocities: Record<string, number> = {}
|
|
1022
|
+
for (const axis of axes) {
|
|
1023
|
+
const live = driver.liveFor(handle, axis)
|
|
1024
|
+
interruptValues[axis] = live.value
|
|
1025
|
+
interruptVelocities[axis] = live.velocity
|
|
1026
|
+
}
|
|
1027
|
+
driver.write(handle, interruptValues, interruptVelocities)
|
|
1028
|
+
const pending: PendingDeferredGrab = { event, info, updates: [] }
|
|
1029
|
+
pendingGrab = pending
|
|
1030
|
+
void measureWithTimeout(
|
|
1031
|
+
// Non-null: the pre-gate above throws when the seam is absent for ref axes.
|
|
1032
|
+
options.measureConstraints!,
|
|
1033
|
+
config[refAxes[0]!]!.constraintsRef!.ref,
|
|
1034
|
+
).then(
|
|
1035
|
+
(boxes) => {
|
|
1036
|
+
if (pendingGrab !== pending) return // cancelled meanwhile (finalize/end/dispose)
|
|
1037
|
+
pendingGrab = null
|
|
1038
|
+
beginAfterMeasure(pending, boxes, refAxes)
|
|
1039
|
+
},
|
|
1040
|
+
(cause) => {
|
|
1041
|
+
if (pendingGrab !== pending) return
|
|
1042
|
+
pendingGrab = null
|
|
1043
|
+
grabAborted = true
|
|
1044
|
+
if (lockState === 'acquired') {
|
|
1045
|
+
lock.release(lockOwner)
|
|
1046
|
+
lockState = 'open'
|
|
1047
|
+
}
|
|
1048
|
+
options.reportConstraintFailure?.(
|
|
1049
|
+
cause instanceof Error ? cause : new Error(describeValue(cause)),
|
|
1050
|
+
)
|
|
1051
|
+
},
|
|
1052
|
+
)
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
cancelPendingGrab()
|
|
1055
|
+
// The failure may precede the pending-grab assignment (buildInfo / liveFor / the
|
|
1056
|
+
// interrupt write) — cancelPendingGrab no-ops then, so free the just-acquired lock
|
|
1057
|
+
// directly (review r5 major 1; the immediate path's catch law: release only when no
|
|
1058
|
+
// session is active).
|
|
1059
|
+
if (lockState === 'acquired' && !anySessionActive()) {
|
|
1060
|
+
lock.release(lockOwner)
|
|
1061
|
+
lockState = 'open'
|
|
1062
|
+
}
|
|
1063
|
+
throw error
|
|
1064
|
+
}
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
try {
|
|
1068
|
+
// VALIDATE the FULL consumed event BEFORE any driver/session mutation (review major 6,
|
|
1069
|
+
// G-INV-7/-8): buildInfo reads + fail-loud-validates every field the start path consumes
|
|
1070
|
+
// — BOTH axes' translations (for begin) AND the DragInfo fields (translationX/Y,
|
|
1071
|
+
// velocityX/Y, absoluteX/Y) the onDragStart callback needs. A malformed field throws
|
|
1072
|
+
// HERE, before liveFor/begin/write, so the in-flight animation is never interrupted and
|
|
1073
|
+
// no held-active state is established (buildInfo only touches lastPoint, reset first for
|
|
1074
|
+
// a fresh grab's zero delta — never the driver or sessions). Only after full validation
|
|
1075
|
+
// does the write interrupt at the grabbed values (REQ-DRIVER-024) so the element renders
|
|
1076
|
+
// the same numbers the sessions seeded from (C0, REQ-GESTURE-012). The grab preserves
|
|
1077
|
+
// the interrupted animations' velocities (grab-side C1, G-INV-3): the write carries
|
|
1078
|
+
// live.velocity per axis so liveFor stays continuous across the grab edge until the
|
|
1079
|
+
// first update reports the finger velocity (review r4 c14e15).
|
|
1080
|
+
lastPoint = null
|
|
1081
|
+
for (const axis of axes) delete grabSeeds[axis]
|
|
1082
|
+
const info = buildInfo(event)
|
|
1083
|
+
const values: Record<string, number> = {}
|
|
1084
|
+
const velocities: Record<string, number> = {}
|
|
1085
|
+
for (const axis of axes) {
|
|
1086
|
+
const live = driver.liveFor(handle, axis)
|
|
1087
|
+
bindings[axis]!.onStart(event, { value: live.value })
|
|
1088
|
+
grabSeeds[axis] = live.value
|
|
1089
|
+
values[axis] = live.value
|
|
1090
|
+
velocities[axis] = live.velocity
|
|
1091
|
+
}
|
|
1092
|
+
driver.write(handle, values, velocities)
|
|
1093
|
+
callbacks.onDragStart?.(event, info)
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
// Free the just-acquired lock ONLY when the failure left no session active (a
|
|
1096
|
+
// pre-mutation validation failure). A POST-activation failure — e.g. a throwing
|
|
1097
|
+
// consumer onDragStart after the sessions began — must NOT free it: the active
|
|
1098
|
+
// session owns the lock until its finalize, and releasing here would admit a
|
|
1099
|
+
// second non-propagating drag while the first still runs (review round-4 major —
|
|
1100
|
+
// the global exclusion contract).
|
|
1101
|
+
if (lockState === 'acquired' && !anySessionActive()) {
|
|
1102
|
+
lock.release(lockOwner)
|
|
1103
|
+
lockState = 'open'
|
|
1104
|
+
}
|
|
1105
|
+
throw error
|
|
1106
|
+
}
|
|
1107
|
+
},
|
|
1108
|
+
|
|
1109
|
+
handlePanUpdate(event) {
|
|
1110
|
+
if (disposed || lockState === 'blocked' || grabAborted) return
|
|
1111
|
+
// A deferred begin is in flight (REQ-GESTURE-028): buffer updates — they flush in order
|
|
1112
|
+
// when the measured bounds land (fail-loud validation happens at the flush).
|
|
1113
|
+
if (pendingGrab !== null) {
|
|
1114
|
+
// Bounded buffer (review r4 minor 7): a measure pending past the cap is pathological —
|
|
1115
|
+
// refuse the grab through the channel rather than retain events indefinitely.
|
|
1116
|
+
if (pendingGrab.updates.length >= MAX_BUFFERED_PAN_UPDATES) {
|
|
1117
|
+
cancelPendingGrab()
|
|
1118
|
+
options.reportConstraintFailure?.(
|
|
1119
|
+
new Error(
|
|
1120
|
+
`deferred begin buffered ${MAX_BUFFERED_PAN_UPDATES} pan updates without the ` +
|
|
1121
|
+
'measure landing — the grab is refused (REQ-GESTURE-028; the bounded-buffer law).',
|
|
1122
|
+
),
|
|
1123
|
+
)
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
pendingGrab.updates.push(event)
|
|
1127
|
+
return
|
|
1128
|
+
}
|
|
1129
|
+
applyPanUpdate(event)
|
|
1130
|
+
},
|
|
1131
|
+
|
|
1132
|
+
handlePanEnd(event, success) {
|
|
1133
|
+
if (disposed || lockState === 'blocked' || grabAborted) return
|
|
1134
|
+
gestureEpoch += 1 // the stream ends: an in-flight poll resolves stale from here
|
|
1135
|
+
// An end before the measure lands QUEUES on the pending grab (review r6 major 1 — never
|
|
1136
|
+
// a silent drop): the full lifecycle flushes when the measure resolves.
|
|
1137
|
+
if (pendingGrab !== null) {
|
|
1138
|
+
pendingGrab.endEvent = { event, success }
|
|
1139
|
+
return
|
|
1140
|
+
}
|
|
1141
|
+
routeEnd(event, success)
|
|
1142
|
+
},
|
|
1143
|
+
|
|
1144
|
+
handlePanFinalize(event) {
|
|
1145
|
+
if (disposed) return
|
|
1146
|
+
gestureEpoch += 1 // finalize is the terminal edge: in-flight polls resolve stale
|
|
1147
|
+
// A finalize before the measure lands queues with the pending end (review r6 major 1).
|
|
1148
|
+
if (pendingGrab !== null) {
|
|
1149
|
+
pendingGrab.finalized = true
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
routeFinalize(event)
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1155
|
+
dispose() {
|
|
1156
|
+
// Idempotent: MotionView's cleanup and a stray recognizer path may both reach here. The
|
|
1157
|
+
// lock frees only if THIS wiring holds it (release is owner-checked by the lock itself).
|
|
1158
|
+
if (disposed) return
|
|
1159
|
+
disposed = true
|
|
1160
|
+
stopPoll() // the mid-drag re-measure loop dies with its wiring
|
|
1161
|
+
cancelPendingGrab() // a measure in flight never begins after unmount (review r4 major 1)
|
|
1162
|
+
if (lockState === 'acquired') lock.release(lockOwner)
|
|
1163
|
+
lockState = 'open'
|
|
1164
|
+
},
|
|
1165
|
+
|
|
1166
|
+
sessions,
|
|
1167
|
+
}
|
|
1168
|
+
}
|