diff --git a/SPECS.md b/SPECS.md index 13d017e..efd2dec 100644 --- a/SPECS.md +++ b/SPECS.md @@ -44,17 +44,17 @@ Class component (`React.Component`) using React Native's `PanResponder` and `Ani | `zoomEnabled` | `boolean` | `true` | Enable/disable zooming dynamically. **Transitioning from `true` to `false` immediately snaps zoom to `initialZoom`** via a non-animated `zoomAnim.setValue()` in `componentDidUpdate` — bypasses `onZoomBefore`/`onZoomAfter` entirely (but `onTransform` does fire via `zoomTransformListenerId`; see the `onTransform` row for the cross-reference, and see the `zoomTo(level, zoomCenter)` Listener Pattern for the chimera-state caveat if this transition happens while a `zoomTo(level, zoomCenter)` animation is in-flight — the still-registered `zoomToListenerId` may run against the snap and produce an intermediate fire with new `zoomLevel` but stale `offsetX`/`offsetY`). Re-enabling does not restore the previous zoom level. If `initialZoom=0`, the reset is skipped (falsy guard). Asymmetric with `panEnabled`, which has no equivalent reset | | `panEnabled` | `boolean` | `true` | Enable/disable panning dynamically. **Mid-gesture `false→true` toggle causes a pan jump** — `_handleShifting` returns early (line 786-792) **before** `_calcOffsetShiftSinceLastGestureState` updates `lastGestureCenterPosition`, so blocked frames leave the reference stale. On the first unblocked frame, the accumulated finger-displacement-during-blocked-period collapses into a single-frame pan jump (analogous to the `onZoomBefore`-blocking hazard at line 107). Same jump occurs when `disablePanOnInitialZoom` auto-unblocks as zoom crosses above `initialZoom`. Consumers needing frame-level pan gating without this hazard should use `onShiftingBefore` returning `true`, which is safe because `_calcOffsetShiftSinceLastGestureState` runs BEFORE the `onShiftingBefore` check in `_setNewOffsetPosition` — references stay current on blocked frames | | `initialZoom` | `number` | `1` | Zoom level on startup. **`0` is silently ignored** at startup — the constructor uses a plain truthy guard (`if (this.props.initialZoom)`), so `0` is falsy and `zoomLevel` stays at the internal default of `1`. Asymmetric with `initialOffsetX`/`initialOffsetY` which correctly use `!= null` guards and accept `0` | -| `maxZoom` | `number` | `1.5` | Maximum zoom level. `null` = unlimited pinch zoom, but disables double-tap zoom entirely (see Double-Tap Zoom section) | +| `maxZoom` | `number` | `1.5` | Maximum zoom level. `null` = unlimited pinch zoom; double-tap still cycles back using a derived three-step ceiling when `zoomStep` is set (see Double-Tap Zoom section) | | `minZoom` | `number` | `0.5` | Minimum zoom level | | `initialOffsetX` | `number` | `0` | Starting horizontal offset | | `initialOffsetY` | `number` | `0` | Starting vertical offset | | `zoomStep` | `number` | `0.5` | Zoom increment on double tap | | `pinchToZoomInSensitivity` | `number` | `1` | Resistance to zoom in (0-10, higher = less sensitive). **`null` silently disables pinch zoom-in** — the `== null` guard in `_handlePinching` returns early AFTER `onZoomBefore` has already fired, so every zoom-in pinch frame fires `onZoomBefore` without a matching `onZoomAfter`, breaking matched-pair state machines. The null-sensitivity early return sits AFTER `lastGestureTouchDistance` is updated but BEFORE `_calcOffsetShiftSinceLastGestureState` runs, so blocked frames leave **only `lastGestureCenterPosition` stale** (not `lastGestureTouchDistance`). A mid-gesture transition from `null` to a numeric value produces a single-frame **pan-center jump** (no zoom jump) on the first unblocked frame — asymmetric with the `onZoomBefore`-blocking path in the callbacks table (line 107), which leaves both references stale and produces zoom jump + pan-center jump | | `pinchToZoomOutSensitivity` | `number` | `1` | Resistance to zoom out (0-10, higher = less sensitive). **`null` silently disables pinch zoom-out** — same early-return pattern as `pinchToZoomInSensitivity=null`: `onZoomBefore` fires, then sensitivity null-guard returns, `onZoomAfter` never fires for affected frames. Same partial-stale-reference side effect as `pinchToZoomInSensitivity=null`: blocked frames leave only `lastGestureCenterPosition` stale, so a mid-gesture transition from `null` to a numeric value produces a single-frame **pan-center jump** (no zoom jump) | -| `movementSensibility` | `number` | `1` | Pan movement resistance (0.5-5, higher = less sensitive). **`0` or any falsy value silently disables panning entirely** — a truthy guard in `_calcOffsetShiftSinceLastGestureState` short-circuits (also prevents division-by-zero). Same falsy-guard trap pattern as `doubleTapDelay=0`, `zoomBy(0)`, and `maxZoom=null` | +| `movementSensibility` | `number` | `1` | Pan movement resistance (0.5-5, higher = less sensitive). **`0` or any falsy value silently disables panning entirely** — a truthy guard in `_calcOffsetShiftSinceLastGestureState` short-circuits (also prevents division-by-zero). Same falsy-guard trap pattern as `doubleTapDelay=0` and `zoomBy(0)` | | `disablePanOnInitialZoom` | `boolean` | `false` | Block panning when at initial zoom level. **Uses strict `===` equality** (`this.zoomLevel === this.props.initialZoom` in `_handleShifting`) — any floating-point drift from pinch-in-then-pinch-out cycles leaves `zoomLevel` at values like `0.9999997` or `1.0000003` rather than exactly `initialZoom`, and the block silently disengages even though the view is visually at `initialZoom`. Asymmetric with `_getNextZoomStep`, which uses `.toFixed(2)` tolerance for an analogous comparison against `maxZoom`. Consumers needing a reliable lock should re-check via `onShiftingBefore` using a tolerance comparison (e.g. `parseFloat(zoomLevel.toFixed(2)) === parseFloat(initialZoom.toFixed(2))`) | | `doubleTapDelay` | `number` | `300` | Max ms between taps for double-tap detection. **`0` silently disables double-tap detection** — the truthy guard in `_resolveAndHandleTap` short-circuits, so every tap is treated as a single tap. **`0` combined with `visualTouchFeedbackEnabled={true}` (the default) causes a fatal crash** ("Text strings must be rendered within a `` component") because the render path `doubleTapDelay && ` short-circuits to the numeric `0` (not `false`/`null`) and React Native cannot render `0` as a child of a non-Text `View`. Additionally `_addTouch` still runs unconditionally on every tap so the `touches` state array grows unbounded. **To safely set `doubleTapDelay={0}`, consumers MUST also set `visualTouchFeedbackEnabled={false}`.** (Pre-existing behavior, not introduced by this PR.) | -| `doubleTapZoomToCenter` | `boolean` | - | Double tap always zooms to view center instead of tap point. **Known bug:** currently passes `{x:0,y:0}` which anchors to top-left, not center (see Coordinate System § Known Issue) | +| `doubleTapZoomToCenter` | `boolean` | - | Double tap always zooms to the viewport center (`{x: originalWidth/2, y: originalHeight/2}`) instead of the tap point | ### Content Dimensions @@ -76,10 +76,10 @@ Class component (`React.Component`) using React Native's `PanResponder` and `Ani |------|------|---------|-------------| | `staticPinPosition` | `Vec2D` | `undefined` | Pin position in **component/viewport coordinates** (component-relative pixels, same space as CSS `left`/`top` on the pin View; used directly as the pinch zoom center and as the `viewportPosition` input to `viewportPositionToImagePosition`). Enables the pin when set. Not to be confused with content coordinates — a pin at `{x: contentWidth/2}` on a smaller viewport will render off-screen | | `staticPinIcon` | `ReactElement` | built-in pin image | Custom pin icon | -| `onStaticPinPositionChange` | `(pos: Vec2D) => void` | - | Fires when pin's content position changes. Debounced (100ms) during active gestures; fires immediately at gesture end and after single-tap animation (may double-fire if a debounced call is pending) | -| `onStaticPinPositionMove` | `(pos: Vec2D) => void` | - | Fires on every transform frame with pin's current content position. Shares the `_invokeOnTransform()` path with `onTransform`, so inherits the same three caveats: (1) **fires twice per pan/pinch frame** during active gestures (dual `panAnim`/`zoomAnim` listeners); (2) also fires from `componentDidUpdate` on layout measurement changes (first layout, rotation) and programmatic `staticPinPosition` prop changes — not only during gestures; (3) during `zoomTo(level, zoomCenter)` animation frames the callback fires twice per frame and the **first** fire delivers a geometrically wrong pin content position because `offsetX`/`offsetY` are stale (new `zoomLevel` applied before `panAnim` has been updated — see zoomTo() Listener Pattern). Only the second fire per zoomTo frame is correct. This stale-value problem is unique to the programmatic `zoomTo(level, zoomCenter)` path; gesture frames assign offsets manually before calling `setValue()` so both fires carry correct values. `onStaticPinPositionChange` (debounced) is unaffected because both fires happen in the same tick and the debounce queue overwrites the stale value before the 100ms timer fires | -| `onStaticPinPress` | `(evt) => void` | - | Tap on the pin (short press, under `longPressDuration`). **Stale-closure caveat:** the callback is captured by `StaticPin`'s `PanResponder` at mount time (`React.useRef(PanResponder.create({ onPanResponderRelease: (evt) => onPress?.(evt) })).current`) and is never refreshed — subsequent prop changes to `onStaticPinPress` are silently ignored at the gesture layer. An inline lambda that closes over changing state (e.g. `() => console.log(count)`) will always see the initial-render values. The `longPressDuration` prop correctly avoids this via an update-per-render `pressDurationRef`, but the press callbacks do not forward through a ref. Consumers must use a stable function (module-level, `useCallback` with no changing deps, or their own ref-forwarding wrapper) or accept the stale-closure behavior | -| `onStaticPinLongPress` | `(evt) => void` | - | Long press on the pin. **Fires at release if held ≥ `longPressDuration`, NOT mid-hold** — `StaticPin.tsx`'s `onPanResponderRelease` computes `Date.now() - tapTime` at release and branches on the duration; no `setTimeout` is scheduled. This contrasts with content `onLongPress`, which fires mid-hold via `setTimeout` in `_handlePanResponderGrant`. Consumers wiring haptics or context menus to trigger mid-hold must use content `onLongPress`, not this pin-specific variant. **Same stale-closure caveat as `onStaticPinPress`:** captured at mount inside `PanResponder.create`, never refreshed. Pass a stable function reference or accept the captured-at-mount behavior | +| `onStaticPinPositionChange` | `(pos: Vec2D) => void` | - | Fires when pin's content position changes. Debounced (100ms) during active transforms; any pending delivery is synchronously flushed at gesture end, after single-tap pan animation completion, and after natural `zoomTo()` completion when `staticPinPosition` is set | +| `onStaticPinPositionMove` | `(pos: Vec2D) => void` | - | Fires on every transform frame with pin's current content position. Shares the `_invokeOnTransform()` path with `onTransform`, so inherits the same three caveats: (1) **fires once per pan frame, but twice per pinch frame** during active gestures — `_setNewOffsetPosition()` now updates only `panAnim`, while `_handlePinching()` still updates both `panAnim` and `zoomAnim`; (2) also fires from `componentDidUpdate` on layout measurement changes (first layout, rotation) and programmatic `staticPinPosition` prop changes — not only during gestures; (3) during `zoomTo(level, zoomCenter)` animation frames the callback fires twice per frame and the **first** fire delivers a geometrically wrong pin content position because `offsetX`/`offsetY` are stale (new `zoomLevel` applied before `panAnim` has been updated — see zoomTo() Listener Pattern). Only the second fire per zoomTo frame is correct. This stale-value problem is unique to the programmatic `zoomTo(level, zoomCenter)` path; gesture frames assign offsets manually before calling `setValue()` so both fires carry correct values. `onStaticPinPositionChange` (debounced) is unaffected because both fires happen in the same tick and the debounce queue overwrites the stale value before the 100ms timer fires | +| `onStaticPinPress` | `(evt) => void` | - | Tap on the pin (short press, under `longPressDuration`). The latest callback identity is observed on every render via `onPressRef`, so swapping `onStaticPinPress` after mount is honored | +| `onStaticPinLongPress` | `(evt) => void` | - | Long press on the pin. **Fires at release if held ≥ `longPressDuration`, NOT mid-hold** — `StaticPin.tsx`'s `onPanResponderRelease` computes `Date.now() - tapTime` at release and branches on the duration; no `setTimeout` is scheduled. This contrasts with content `onLongPress`, which fires mid-hold via `setTimeout` in `_handlePanResponderGrant`. Consumers wiring haptics or context menus to trigger mid-hold must use content `onLongPress`, not this pin-specific variant. The latest callback identity is observed on every render via `onLongPressRef`, so swapping `onStaticPinLongPress` after mount is honored | | `pinProps` | `ViewProps` | `{}` | Extra props passed to pin wrapper. `style` is extracted and applied separately from other props. **`pinProps.style` has higher precedence than internal positioning styles** — it is placed last in the pin's style array (`[{left, top}, styles.pinWrapper, { opacity, transform: [translateY: -pinSize.height, translateX: -pinSize.width/2] }, pinStyle]`), so a consumer passing a `transform` key in `pinProps.style` will **entirely replace the internal anchor transforms** (React Native style arrays replace rather than merge conflicting keys), causing the pin to render from its top-left corner instead of bottom-center with no runtime error. Safe workaround for visual rotate/scale effects: wrap the pin icon content in a `View` and apply `transform` to that wrapper rather than through `pinProps.style` | ### External Animated Values @@ -102,7 +102,7 @@ Most callbacks receive `(event, gestureState, zoomableViewEventObject)`. Excepti | Callback | When | Signature exception | |----------|------|---------------------| -| `onTransform` | Every pan/zoom frame. Also fires from `componentDidUpdate`: **twice on first layout** (init block + measurements-changed block both execute in the same call — the init block sets `onTransformInvocationInitialized=true`, which immediately satisfies the measurements-changed block's guard), **once on rotation** (only the measurements-changed block runs), **once on programmatic `staticPinPosition` prop change**, and **once on `zoomEnabled` `true→false` transition** (when `initialZoom` is truthy — `componentDidUpdate` calls `zoomAnim.setValue(initialZoom)`, firing `zoomTransformListenerId`; see the `zoomEnabled` Prop row). Fires **twice per pan/pinch frame** during active gestures because `_setNewOffsetPosition` and `_handlePinching` both call `panAnim.setValue()` AND `zoomAnim.setValue()`, each of which independently triggers `_invokeOnTransform()` via its listener. Also fires **twice per call** from programmatic `moveTo()`/`moveBy()` (same dual-listener path via `_setNewOffsetPosition`) — **three times per call when the programmatic `moveTo()`/`moveBy()` cancels an in-flight `zoomTo(level, zoomCenter)` animation** (the `zoomToListenerId` listener on `zoomAnim` triggers a second `panAnim.setValue()` call before the `start()` end-callback removes the listener, producing an extra `panTransformListenerId` fire; see `moveTo()`/`moveBy()` Public Methods entries). Fires **once** from `moveStaticPinTo()` (per instant call, or per animation frame on the animated path — fires even though `moveStaticPinTo` bypasses `_setNewOffsetPosition`/`onShiftingBefore`/`After`). Additionally, `zoomTo(level, zoomCenter)` animation frames fire twice per frame with the first fire carrying stale `offsetX`/`offsetY` (see zoomTo() Listener Pattern). Consumers dispatching state updates should deduplicate. **First-layout vs rotation ordering relative to `onLayout`:** on first layout the three events interleave as `onTransform #1 → onLayout → onTransform #2` (the init block fires `_invokeOnTransform` before `onLayout` commits measurements; `onLayout` then saves them; the measurements-changed block fires a second `_invokeOnTransform` after). On rotation only one `onTransform` fires, and `onLayout` precedes it: `onLayout → onTransform`. Consumers guarding `onTransform` processing on an `onLayout`-set readiness flag work correctly only because the first-layout `onTransform #1` falls before the flag is set and is skipped; a consumer assuming the rotation ordering applies to first layout would be surprised | Receives only `ZoomableViewEvent` (no event/gestureState) | +| `onTransform` | Every pan/zoom frame. Also fires from `componentDidUpdate`: **twice on first layout** (init block + measurements-changed block both execute in the same call — the init block sets `onTransformInvocationInitialized=true`, which immediately satisfies the measurements-changed block's guard), **once on rotation** (only the measurements-changed block runs), **once on programmatic `staticPinPosition` prop change**, and **once on `zoomEnabled` `true→false` transition** (when `initialZoom` is truthy — `componentDidUpdate` calls `zoomAnim.setValue(initialZoom)`, firing `zoomTransformListenerId`; see the `zoomEnabled` Prop row). Fires **once per pan frame, but twice per pinch frame** during active gestures because `_setNewOffsetPosition()` now updates only `panAnim`, while `_handlePinching()` still updates both `panAnim` and `zoomAnim`, each of which independently triggers `_invokeOnTransform()` via its listener. Also fires **once per call** from programmatic `moveTo()`/`moveBy()` — they now cancel in-flight `zoomTo()` animations via `zoomAnim.stopAnimation()` plus `zoomToListenerId` removal before routing through `_setNewOffsetPosition()`, so there is no extra zoom-listener cascade. Fires **once** from `moveStaticPinTo()` (per instant call, or per animation frame on the animated path — fires even though `moveStaticPinTo` bypasses `_setNewOffsetPosition`/`onShiftingBefore`/`After`). Additionally, `zoomTo(level, zoomCenter)` animation frames fire twice per frame with the first fire carrying stale `offsetX`/`offsetY` (see zoomTo() Listener Pattern). Consumers dispatching state updates should deduplicate. **First-layout vs rotation ordering relative to `onLayout`:** on first layout the three events interleave as `onTransform #1 → onLayout → onTransform #2` (the init block fires `_invokeOnTransform` before `onLayout` commits measurements; `onLayout` then saves them; the measurements-changed block fires a second `_invokeOnTransform` after). On rotation only one `onTransform` fires, and `onLayout` precedes it: `onLayout → onTransform`. Consumers guarding `onTransform` processing on an `onLayout`-set readiness flag work correctly only because the first-layout `onTransform #1` falls before the flag is set and is skipped; a consumer assuming the rotation ordering applies to first layout would be surprised | Receives only `ZoomableViewEvent` (no event/gestureState) | | `onLayout` | Internal measurements change. See the `onTransform` row for ordering relative to `onTransform` on first layout (`onTransform #1 → onLayout → onTransform #2`) vs rotation (`onLayout → onTransform`) | Receives `{ nativeEvent: { layout } }` | | `onSingleTap` | Single tap confirmed (after double-tap delay) | `(event, zoomableViewEventObject)` — no gestureState | | `onDoubleTapBefore` | Before double-tap zoom executes | `(event, zoomableViewEventObject)` — no gestureState | @@ -112,12 +112,12 @@ Most callbacks receive `(event, gestureState, zoomableViewEventObject)`. Excepti | `onShiftingAfter` | After pan frame applies. **Return value is ignored** — unlike `onShiftingBefore` (where returning `true` blocks the frame), `onShiftingAfter`'s declared `boolean` return type is misleading; the call site does not capture it, so returning `true` has no effect | `event` and `gestureState` are `null` — null-guard required | | `onShiftingEnd` | Pan gesture ends. **Fires based on `gestureType` classification, not on whether any pan frame was actually applied.** `gestureType` is set to `'shift'` as soon as a 1-finger move exceeds 2px on either axis — BEFORE any blocking check runs. Blocking checks (`panEnabled=false`, `disablePanOnInitialZoom` at `initialZoom`, or `onShiftingBefore` returning `true`) cause `_handleShifting` / `_setNewOffsetPosition` to return early without clearing `gestureType`. At gesture end `onShiftingEnd` still fires because `gestureType === 'shift'`. Consumers using these flags as a pan lock will receive `onShiftingEnd` after every >2px finger movement even though zero pan frames were applied. | | | `onZoomBefore` | Fires on every pinch frame (real event/gestureState) AND at start of `zoomTo()` (null, null). Return `true` blocks pinch frames only — ignored during `zoomTo()`. **Blocked pinch frames do NOT update the gesture-tracking reference values** `lastGestureTouchDistance` or `lastGestureCenterPosition`: `_handlePinching` returns at line 617 before reaching `this.lastGestureTouchDistance = distance` (line 626) or the `_calcOffsetShiftSinceLastGestureState(gestureCenterPoint)` call at line 697 (which is where `lastGestureCenterPosition` is updated). If the consumer blocks several consecutive frames and then stops blocking (conditional mid-gesture gating), the next unblocked frame computes `distance / lastGestureTouchDistance` and `dx/dy` against stale references captured before the block, producing a sudden **zoom jump AND pan-center jump** equal to the accumulated blocked delta. Workaround: prefer coarse gating (use `zoomEnabled={false}` or a parent-level responder block) over mid-gesture `onZoomBefore` blocks, or accept that unblock transitions will produce a single-frame jump. | During `zoomTo()`: `event` and `gestureState` are `null` — null-guard required | -| `onZoomAfter` | After each pinch frame (real event/gestureState) AND synchronously at end of `zoomTo()` invocation before animation frames run (null, null) — `zoomLevel` in the event reflects the pre-animation value, not the target | During `zoomTo()`: `event` and `gestureState` are `null` — null-guard required | +| `onZoomAfter` | After each pinch frame (real event/gestureState) AND after `zoomTo()` completes naturally (null, null). During `zoomTo()`, it does not fire on interrupted/cancelled animations or after unmount; the event reflects the final post-animation state, and any pending static-pin change has already been flushed | During `zoomTo()`: `event` and `gestureState` are `null` — null-guard required | | `onZoomEnd` | Pinch gesture ends | | | `onPanResponderGrant` | Gesture responder acquired | | | `onPanResponderEnd` | Gesture responder released — fires on normal release AND as the first step of termination (the terminate handler calls `_handlePanResponderEnd` before firing `onPanResponderTerminate`) | | | `onPanResponderMove` | Every move frame. Return `true` to intercept (prevents default handling) | | -| `onPanResponderTerminate` | Responder taken by another component. **Not** mutually exclusive with `onPanResponderEnd`: on termination, `onPanResponderEnd` fires first, then (if `gestureType==='pinch'`) `onZoomEnd` or (if `gestureType==='shift'`) `onShiftingEnd`, then the immediate `onStaticPinPositionChange` (if `staticPinPosition`, `contentWidth`, `contentHeight` are all set — `_handlePanResponderEnd` calls `_updateStaticPin` synchronously), then `onPanResponderTerminate`. Synchronous callback count per termination (excluding `onStaticPinPositionChange`): **3 when gestureType is `'pinch'` or `'shift'`; 2 when gestureType is `null` and the tap resolves as a first-tap (singleTapTimeoutId scheduled for async fire, no synchronous tap callbacks); 6 when gestureType is `null` and the tap resolves as a double-tap (onDoubleTapBefore + onZoomBefore + onZoomAfter + onDoubleTapAfter fire synchronously inside `_handlePanResponderEnd` before `onPanResponderTerminate`); 4 when gestureType is `null` on the double-tap path with `zoomEnabled=false` (onZoomBefore/onZoomAfter skipped via `zoomTo`'s early return)**. Add **+1 to each** when `staticPinPosition`, `contentWidth`, and `contentHeight` are all configured. When gestureType is `null`, `_handlePanResponderEnd` also invokes `_resolveAndHandleTap`. For a **double-tap** (second tap within `doubleTapDelay`) the tap callbacks `onDoubleTapBefore`, `onZoomBefore`, `onZoomAfter`, `onDoubleTapAfter` all fire **synchronously** inside `_handlePanResponderEnd` before `onPanResponderTerminate` runs — no setTimeout is involved (consistent with the `gestureStarted` caveat in Public Methods). Only `onSingleTap` is truly asynchronous: for a first tap, `_resolveAndHandleTap` schedules `singleTapTimeoutId` and `onSingleTap` fires `doubleTapDelay` ms later, after `onPanResponderTerminate` has already completed. | | +| `onPanResponderTerminate` | Responder taken by another component. **Not** mutually exclusive with `onPanResponderEnd`: on termination, `onPanResponderEnd` fires first, then (if `gestureType==='pinch'`) `onZoomEnd` or (if `gestureType==='shift'`) `onShiftingEnd`, then any pending `onStaticPinPositionChange` is synchronously flushed (if `staticPinPosition`, `contentWidth`, and `contentHeight` are configured), then `onPanResponderTerminate`. Synchronous callback count per termination (excluding `onStaticPinPositionChange`): **3 when `gestureType` is `'pinch'` or `'shift'`; 2 when `gestureType` is `null` and the tap resolves as a first tap (singleTapTimeoutId scheduled for async fire, no synchronous tap callbacks); 5 when `gestureType` is `null` and the tap resolves as a double-tap (`onDoubleTapBefore` + `onZoomBefore` + `onDoubleTapAfter` fire synchronously inside `_handlePanResponderEnd` before `onPanResponderTerminate`); 4 when `gestureType` is `null` on the double-tap path with `zoomEnabled=false` (`onZoomBefore` skipped via `zoomTo`'s early return)**. Add **+1** when a pending static-pin change exists and the flush emits. `onZoomAfter` is no longer part of the synchronous termination sequence; it fires later only if the `zoomTo()` animation finishes naturally and the component is still mounted. | | | `onPanResponderTerminationRequest` | Another component wants responder. Return `true` to allow. **Default when not provided: deny (`false`)** — component never yields to another responder. To allow embedding in `ScrollView` or React Navigation, provide this callback returning `true` | | | `onShouldBlockNativeResponder` | Block native responder. Default: `true` | | | `onStartShouldSetPanResponder` | Before gesture responder is set | `(event, gestureState, zoomableViewEventObject, alwaysFalse)` — 4 args; **the 4th arg is hardcoded `false`** (not computed from any internal state or base-component result — misleadingly named historically) and the return value is ignored (component always claims responder) | @@ -149,16 +149,16 @@ Animate to a specific zoom level. `zoomCenter` specifies the point in top-left-r Zoom by a delta from current level. Defaults to `zoomStep` if delta is `0`, `null`, or `undefined` (uses `||=`, so any falsy value triggers the default). If `zoomStep` is also falsy, the call is a no-op. ### `moveTo(newOffsetX: number, newOffsetY: number): void` -Move the viewport so a specific position in the zoom subject is centered. **Requires layout measurement to have completed** — the method reads `originalWidth`/`originalHeight` from state and silently no-ops (returns with no error) if either is `0` (i.e., before `onLayout` fires). Calls from `componentDidMount`, from `useEffect` with empty deps, or from refs before first layout will be silently dropped. Fires `onShiftingBefore`/`onShiftingAfter` via `_setNewOffsetPosition`, and fires `onTransform` **twice per call** normally, but **three times per call when cancelling an in-flight `zoomTo(level, zoomCenter)` animation** — the `zoomToListenerId` listener on `zoomAnim` triggers a second `panAnim.setValue()` call before the `start()` end-callback removes the listener, producing an extra `panTransformListenerId` fire. **Not gated by `panEnabled` or `disablePanOnInitialZoom`** — those only apply to gesture-driven panning; programmatic calls pan freely. **Cancels in-flight `zoomTo()` animations silently** — `_setNewOffsetPosition` calls `zoomAnim.setValue(this.zoomLevel)` unconditionally, and `Animated.Value.setValue()` implicitly `stopAnimation()`s any running animation on that value. The `zoomTo()` start callback fires with `finished=false`, and in the `zoomCenter` case the third `onTransform` fire is itself a consumer-visible signal of the cancellation. +Move the viewport so a specific position in the zoom subject is centered. **Requires layout measurement to have completed** — the method reads `originalWidth`/`originalHeight` from state and silently no-ops (returns with no error) if either is `0` (i.e., before `onLayout` fires). Calls from `componentDidMount`, from `useEffect` with empty deps, or from refs before first layout will be silently dropped. Fires `onShiftingBefore`/`onShiftingAfter` via `_setNewOffsetPosition`, and fires `onTransform` **once per call** via the `panAnim` update. **Not gated by `panEnabled` or `disablePanOnInitialZoom`** — those only apply to gesture-driven panning; programmatic calls pan freely. **Cancels in-flight `zoomTo()` animations silently before applying the pan** — `moveTo()` now calls `zoomAnim.stopAnimation()` and removes any active `zoomToListenerId` first, so the next zoom frame cannot overwrite the requested offset. The stopped zoom level is then used for the move calculation. ### `moveBy(offsetChangeX: number, offsetChangeY: number): void` -Shift the viewport by a pixel offset. Unlike `moveTo()`, has no layout-measurement prerequisite — works immediately on mount because it operates on current offset values, not measured dimensions. Fires `onShiftingBefore`/`onShiftingAfter` via `_setNewOffsetPosition`, and fires `onTransform` **twice per call** normally, but **three times per call when cancelling an in-flight `zoomTo(level, zoomCenter)` animation** (same `zoomToListenerId` cascade as `moveTo()`). **Not gated by `panEnabled` or `disablePanOnInitialZoom`** — same caveat as `moveTo()`. **Cancels in-flight `zoomTo()` animations silently** — same `zoomAnim.setValue()` side effect as `moveTo()`. +Shift the viewport by a pixel offset. Unlike `moveTo()`, has no layout-measurement prerequisite — works immediately on mount because it operates on current offset values, not measured dimensions. Fires `onShiftingBefore`/`onShiftingAfter` via `_setNewOffsetPosition`, and fires `onTransform` **once per call** via the `panAnim` update. **Not gated by `panEnabled` or `disablePanOnInitialZoom`** — same caveat as `moveTo()`. **Cancels in-flight `zoomTo()` animations silently before applying the pan** — same `zoomAnim.stopAnimation()` + `zoomToListenerId` removal path as `moveTo()`. ### `moveStaticPinTo(position: Vec2D, duration?: number): void` -Pan the view so the static pin points at `position` in content coordinates. Requires `staticPinPosition`, `contentWidth`, and `contentHeight` to be set. If `duration` is truthy, animates the pan via `Animated.timing`; **any falsy `duration` (`0`, `undefined`, `null`) takes the instant `panAnim.setValue()` path** — the code uses a plain `if (duration)` guard, so `duration=0` does NOT produce a 0ms animated path, it takes the synchronous path. Same falsy-guard trap pattern as `doubleTapDelay=0` and `movementSensibility=0`. **Does not fire `onShiftingBefore`/`onShiftingAfter`** — sets offsets directly without routing through `_setNewOffsetPosition`, bypassing the onShifting gate entirely. Unlike `moveTo()`/`moveBy()`, consumers' `onShiftingBefore` gate cannot block this method. **Still fires `onTransform` once per instant call, or once per animation frame for the animated path** (via the direct `panAnim.setValue()` call). **Not gated by `panEnabled` or `disablePanOnInitialZoom`** — bypasses both flags via direct `panAnim` manipulation without routing through `_handleShifting`. **Does NOT cancel in-flight `zoomTo(level, zoomCenter)` animations** — unlike `moveTo()`/`moveBy()`, this method only touches `panAnim` and never calls `zoomAnim.setValue()`, so any running `zoomToListenerId` stays registered. On the next `zoomAnim` animation frame, the zoomTo listener calls `panAnim.setValue()` with a zoom-centered position. The consequence differs between the instant and animated paths: (a) **instant path (`duration` falsy):** `panAnim.setValue()` places the pin synchronously, and ~16ms later the zoomTo listener overwrites that alignment with a zoom-centered position; (b) **animated path (`duration` truthy):** `Animated.timing` is **cancelled** by the zoomTo listener's `panAnim.setValue()` call (React Native's `setValue` implicitly calls `stopAnimation()`) before the pin reaches its target. Additionally, `panListenerId` has updated `this.offsetX`/`this.offsetY` to **intermediate animation values** during the ~16ms before cancellation, so the zoom centering is computed from a stale intermediate position, not from the `moveStaticPinTo` target. The animated path provides no consumer-visible cancellation signal — `Animated.timing(...).start()` is invoked without a completion callback. To reliably reposition during a zoom animation, call `moveTo()`/`moveBy()` first (which cancels the zoomTo) or wait for `zoomTo()` to complete before calling `moveStaticPinTo()`. +Pan the view so the static pin points at `position` in content coordinates. Requires `staticPinPosition`, `contentWidth`, and `contentHeight` to be set. If `duration` is truthy, animates the pan via `Animated.timing`; **any falsy `duration` (`0`, `undefined`, `null`) takes the instant `panAnim.setValue()` path** — the code uses a plain `if (duration)` guard, so `duration=0` does NOT produce a 0ms animated path, it takes the synchronous path. Same falsy-guard trap pattern as `doubleTapDelay=0` and `movementSensibility=0`. **Does not fire `onShiftingBefore`/`onShiftingAfter`** — sets offsets directly without routing through `_setNewOffsetPosition`, bypassing the onShifting gate entirely. Unlike `moveTo()`/`moveBy()`, consumers' `onShiftingBefore` gate cannot block this method. **Still fires `onTransform` once per instant call, or once per animation frame for the animated path** (via the direct `panAnim.setValue()` call). **Not gated by `panEnabled` or `disablePanOnInitialZoom`** — bypasses both flags via direct `panAnim` manipulation without routing through `_handleShifting`. **Does NOT cancel in-flight `zoomTo(level, zoomCenter)` animations** — unlike `moveTo()`/`moveBy()`, this method only touches `panAnim` and never calls the programmatic-pan cancellation path (`zoomAnim.stopAnimation()` + `zoomToListenerId` removal). On the next `zoomAnim` animation frame, the zoomTo listener calls `panAnim.setValue()` with a zoom-centered position. The consequence differs between the instant and animated paths: (a) **instant path (`duration` falsy):** `panAnim.setValue()` places the pin synchronously, and ~16ms later the zoomTo listener overwrites that alignment with a zoom-centered position; (b) **animated path (`duration` truthy):** `Animated.timing` is **cancelled** by the zoomTo listener's `panAnim.setValue()` call (React Native's `setValue` implicitly calls `stopAnimation()`) before the pin reaches its target. Additionally, `panListenerId` has updated `this.offsetX`/`this.offsetY` to **intermediate animation values** during the ~16ms before cancellation, so the zoom centering is computed from a stale intermediate position, not from the `moveStaticPinTo` target. The animated path provides no consumer-visible cancellation signal — `Animated.timing(...).start()` is invoked without a completion callback. To reliably reposition during a zoom animation, call `moveTo()`/`moveBy()` first (which now stops the zoom animation before panning) or wait for `zoomTo()` to complete before calling `moveStaticPinTo()`. ### `gestureStarted: boolean` (read-only) -Whether a gesture is currently in progress. Useful for consumers to suppress their own updates during active interaction. **Caveat:** `gestureStarted` is reset to `false` as the **last** operation of `_handlePanResponderEnd` — it remains `true` throughout the entire end-callback sequence. For a **double-tap release** the sequence is (in order) `onDoubleTapBefore`, `onZoomBefore`, `onZoomAfter`, `onDoubleTapAfter`, `onPanResponderEnd`, then the immediate `onStaticPinPositionChange` fired from `_updateStaticPin` — all fire before `gestureStarted` is reset, because `_resolveAndHandleTap` runs synchronously inside `_handlePanResponderEnd` (before the reset). `onZoomEnd`/`onShiftingEnd` do **not** fire on the double-tap path because both are gated on `gestureType === 'pinch'` / `'shift'`, but double-tap runs only when `gestureType === null`. For **pinch or pan gesture releases** the sequence is `onPanResponderEnd`, then `onZoomEnd` (if `gestureType==='pinch'`) or `onShiftingEnd` (if `gestureType==='shift'`), then the immediate `onStaticPinPositionChange` — the pre-resolve double-tap callbacks do not fire. Consumers cannot read `gestureStarted` inside any of these callbacks to distinguish "gesture ending" from "mid-gesture." +Whether a gesture is currently in progress. Useful for consumers to suppress their own updates during active interaction. **Caveat:** `gestureStarted` is reset to `false` as the **last** operation of `_handlePanResponderEnd` — it remains `true` throughout the synchronous end-callback sequence. For a **double-tap release** the synchronous sequence is `onDoubleTapBefore`, `onZoomBefore`, `onDoubleTapAfter`, `onPanResponderEnd`, then any flushed `onStaticPinPositionChange` — all before `gestureStarted` is reset, because `_resolveAndHandleTap` runs synchronously inside `_handlePanResponderEnd` (before the reset). `onZoomAfter` no longer participates in this synchronous sequence; it fires later only after the `zoomTo()` animation finishes naturally, by which point `gestureStarted` is already `false`. `onZoomEnd`/`onShiftingEnd` do **not** fire on the double-tap path because both are gated on `gestureType === 'pinch'` / `'shift'`, but double-tap runs only when `gestureType === null`. For **pinch or pan gesture releases** the sequence is `onPanResponderEnd`, then `onZoomEnd` (if `gestureType==='pinch'`) or `onShiftingEnd` (if `gestureType==='shift'`), then any flushed `onStaticPinPositionChange`. Consumers cannot read `gestureStarted` inside any of these synchronous callbacks to distinguish "gesture ending" from "mid-gesture." --- @@ -169,7 +169,7 @@ Uses `PanResponder` with `onStartShouldSetPanResponder: true` (always claims the ### Classification Rules - **1 finger, moved >2px**: `gestureType = 'shift'` (pan) - **2 fingers**: `gestureType = 'pinch'` (zoom) -- **3+ fingers**: Gesture ends (via `_handlePanResponderEnd`), only 1-2 touch supported. **`_handlePanResponderEnd` is invoked twice** for any 3+-finger interaction: once synchronously from the 3+-finger branch in `_handlePanResponderMove` (line 537), and a second time from the natural `onPanResponderRelease`/`onPanResponderTerminate` when the user lifts fingers. The first call resets `gestureType` to `null` (line 501), so the second call satisfies `if (!this.gestureType)` at line 464 and unconditionally invokes `_resolveAndHandleTap`. Two concrete consumer-visible consequences follow: (A) placing 3+ fingers simultaneously with no prior movement triggers spurious tap resolution. **Primary outcome — quick touch (fingers lifted within `doubleTapDelay` of the move-handler call, the typical case):** Call 1 of `_handlePanResponderEnd` enters the else-branch of `_resolveAndHandleTap`, setting `doubleTapFirstTapReleaseTimestamp=T1` and scheduling `singleTapTimeoutId`. Call 2 then finds the timestamp, cancels the pending timeout, and fires `onDoubleTapBefore` + full zoom animation + `onDoubleTapAfter` — this is the **default** behavior, driven by Call 1 creating the timestamp that Call 2 detects within the same gesture (no prior user tap is required). **Edge case — slow hold (fingers held longer than `doubleTapDelay` before lifting):** Call 1's timeout expires and fires `onSingleTap` once; Call 2 then finds a cleared timestamp, re-enters the else-branch, schedules a second timeout, and fires **a second `onSingleTap`** — two spurious `onSingleTap` callbacks total, not one; (B) **`onPanResponderEnd` fires twice per 3+-finger release** (once per `_handlePanResponderEnd` call) but **`onZoomEnd`/`onShiftingEnd` fires exactly once** — only during the first call, while `gestureType` is still `'pinch'`/`'shift'`; the first call then resets `gestureType=null` (line 501), so when the second call reads it the guard fails and neither zoom-end nor shifting-end callback fires again. The immediate `onStaticPinPositionChange` fired from `_updateStaticPin` (when `staticPinPosition`, `contentWidth`, `contentHeight` are all set) also fires twice because `_updateStaticPin` runs unconditionally at the end of every `_handlePanResponderEnd` call regardless of `gestureType`. Consumers deduplicating end-callback counts should scope deduplication to `onPanResponderEnd` and immediate `onStaticPinPositionChange` only — **applying the same deduplication to `onZoomEnd`/`onShiftingEnd` would suppress the single legitimate fire**, since they are not double-fired; (C) when a **classified gesture** (`gestureType='shift'` or `'pinch'`) is interrupted by a 3rd finger, the first `_handlePanResponderEnd` call skips `_resolveAndHandleTap` (because `gestureType` is non-null) but resets `gestureType=null` at line 501; the second call (on finger lift) then satisfies `!this.gestureType` and unconditionally invokes `_resolveAndHandleTap`. The outcome depends on whether a prior single-tap within `doubleTapDelay` left a `doubleTapFirstTapReleaseTimestamp` (not cleared by `_handlePanResponderGrant`): (C1) **no prior timestamp (the common case):** the else branch fires — scheduling `singleTapTimeoutId` — and a spurious `onSingleTap` fires `doubleTapDelay` ms after all fingers lift; (C2) **prior single-tap within `doubleTapDelay` (e.g., the user tapped, then immediately started a pinch/pan, then a 3rd finger joined):** the second call's `_resolveAndHandleTap` finds the timestamp still valid, cancels any pending timeout, and fires the **double-tap zoom path** — `onDoubleTapBefore`, `onZoomBefore`, `onZoomAfter`, `onDoubleTapAfter` + full zoom animation — instead of scheduling `singleTapTimeoutId`. This is distinct from (A), which covers the no-prior-movement case where both calls enter tap resolution. Any consumer using `onSingleTap` for navigation/selection will see it fire unexpectedly after a pan or pinch gesture interrupted by an accidental 3rd finger, and a prior-tap consumer may see a spurious full double-tap zoom instead. +- **3+ fingers**: Gesture ends (via `_handlePanResponderEnd`), only 1-2 touch supported. **`_handlePanResponderEnd` is invoked twice** for any 3+-finger interaction: once synchronously from the 3+-finger branch in `_handlePanResponderMove` (line 537), and a second time from the natural `onPanResponderRelease`/`onPanResponderTerminate` when the user lifts fingers. The first call resets `gestureType` to `null` (line 501), so the second call satisfies `if (!this.gestureType)` at line 464 and unconditionally invokes `_resolveAndHandleTap`. Two concrete consumer-visible consequences follow: (A) placing 3+ fingers simultaneously with no prior movement triggers spurious tap resolution. **Primary outcome — quick touch (fingers lifted within `doubleTapDelay` of the move-handler call, the typical case):** Call 1 of `_handlePanResponderEnd` enters the else-branch of `_resolveAndHandleTap`, setting `doubleTapFirstTapReleaseTimestamp=T1` and scheduling `singleTapTimeoutId`. Call 2 then finds the timestamp, cancels the pending timeout, and fires `onDoubleTapBefore` + full zoom animation + `onDoubleTapAfter` — this is the **default** behavior, driven by Call 1 creating the timestamp that Call 2 detects within the same gesture (no prior user tap is required). **Edge case — slow hold (fingers held longer than `doubleTapDelay` before lifting):** Call 1's timeout expires and fires `onSingleTap` once; Call 2 then finds a cleared timestamp, re-enters the else-branch, schedules a second timeout, and fires **a second `onSingleTap`** — two spurious `onSingleTap` callbacks total, not one; (B) **`onPanResponderEnd` fires twice per 3+-finger release** (once per `_handlePanResponderEnd` call) but **`onZoomEnd`/`onShiftingEnd` fires exactly once** — only during the first call, while `gestureType` is still `'pinch'`/`'shift'`; the first call then resets `gestureType=null` (line 501), so when the second call reads it the guard fails and neither zoom-end nor shifting-end callback fires again. Any pending `onStaticPinPositionChange` is flushed at the end of each `_handlePanResponderEnd` call, but only the first flush normally emits because it drains the debounce timer; unlike the old `_updateStaticPin` path, 3+-finger releases no longer guarantee a double-fire of `onStaticPinPositionChange`. Consumers deduplicating end-callback counts should scope deduplication to `onPanResponderEnd` only — **applying the same deduplication to `onZoomEnd`/`onShiftingEnd` would suppress the single legitimate fire**, since they are not double-fired; (C) when a **classified gesture** (`gestureType='shift'` or `'pinch'`) is interrupted by a 3rd finger, the first `_handlePanResponderEnd` call skips `_resolveAndHandleTap` (because `gestureType` is non-null) but resets `gestureType=null` at line 501; the second call (on finger lift) then satisfies `!this.gestureType` and unconditionally invokes `_resolveAndHandleTap`. By the time the gesture was classified, `_handlePanResponderMove` had already cleared `doubleTapFirstTapReleaseTimestamp` on the transition into `'pinch'` or `'shift'` (see Tap Handling § Timeout Cleanup), so the second call always lands in the else branch — scheduling `singleTapTimeoutId` and firing a spurious `onSingleTap` `doubleTapDelay` ms after all fingers lift. This is distinct from (A), which covers the no-prior-movement case where both calls enter tap resolution. Any consumer using `onSingleTap` for navigation/selection will see it fire unexpectedly after a pan or pinch gesture interrupted by an accidental 3rd finger. - **No movement**: `gestureType` stays `null` → treated as tap on release ### Gesture Lifecycle @@ -197,10 +197,10 @@ When switching from pinch to shift (or vice versa), `lastGestureCenterPosition` 2. When already at `maxZoom` (detected via `zoomLevel.toFixed(2) === maxZoom.toFixed(2)` — 2-decimal precision, ~0.005 tolerance), returns `initialZoom` 3. Otherwise returns the computed step - Example cycle for `initialZoom=1, maxZoom=2, zoomStep=0.5`: `1 → 1.5 → 2 (clamped, not 2.25) → 1 → ...` — three distinct cycle states, not two -- **When `maxZoom` is `null`:** double-tap zoom is disabled entirely. `onDoubleTapBefore` fires but no zoom occurs and `onDoubleTapAfter` is never called (the `maxZoom == null` guard at the top of `_getNextZoomStep()` returns `undefined` unconditionally, for every call at any zoom level). Pinch zoom is unaffected. -- **When `zoomStep` is `null`:** double-tap zoom is disabled **only when not at `maxZoom`** — the guard ordering matters. `_getNextZoomStep()` checks `zoomLevel == maxZoom` BEFORE `zoomStep == null`, so at `maxZoom` the reset to `initialZoom` still executes: both `onDoubleTapBefore` and `onDoubleTapAfter` fire, `zoomTo(initialZoom)` runs with a real animation. At non-maxZoom levels, `zoomStep=null` returns `undefined` (only `onDoubleTapBefore` fires). This is NOT identical to `maxZoom=null`, which disables ALL double-taps unconditionally. +- **When `maxZoom` is `null`:** double-tap zoom still works. `_getNextZoomStep()` derives an `effectiveMax` of `(initialZoom ?? 1) * (1 + zoomStep)^3`, so the double-tap cycle becomes `initialZoom → step 1 → step 2 → step 3 → initialZoom` instead of growing indefinitely. Pinch zoom remains unlimited. +- **When `zoomStep` is `null`:** double-tap zoom is disabled **only when not at the effective max** — the guard ordering matters. `_getNextZoomStep()` checks `zoomLevel == maxZoom` BEFORE `zoomStep == null`, so with a configured `maxZoom`, being at max still resets to `initialZoom`: both `onDoubleTapBefore` and `onDoubleTapAfter` fire, and `zoomTo(initialZoom)` runs with a real animation. At non-maxZoom levels, `zoomStep=null` returns `undefined` (only `onDoubleTapBefore` fires). This is distinct from `maxZoom=null`, which now cycles back using the derived three-step ceiling instead of disabling double-tap entirely. - **When `zoomEnabled` is `false`:** BOTH `onDoubleTapBefore` AND `onDoubleTapAfter` fire despite no zoom animation running. `_getNextZoomStep()` does not check `zoomEnabled`, so it returns a valid next step; `zoomTo()` is then called, bails out early (`!this.props.zoomEnabled` returns `false`), but `_handleDoubleTap` does not check the return value and fires `onDoubleTapAfter` unconditionally with a synthetic `zoomLevel` override equal to the would-be target. Consumers relying on the Before/After pair as a state-change signal will see a matched pair indistinguishable from a successful zoom even though the view did not change. -- Zoom center = tap position. When `doubleTapZoomToCenter` is set, the intended behavior is to anchor zoom at the view center, but due to a pre-existing bug the code passes `{x:0, y:0}` — which in the top-left-relative viewport coordinate system anchors to the top-left corner, not the center (see Props API row and Coordinate System § Known Issue) +- Zoom center = tap position. When `doubleTapZoomToCenter` is set, the zoom anchor is the true viewport center: `{x: originalWidth/2, y: originalHeight/2}` - Uses `zoomTo()` internally ### zoomTo() Listener Pattern @@ -260,21 +260,20 @@ StaticPin has its own `PanResponder` that intercepts touches on the pin before t - If `hasDragged === false` (brief touch, no movement): **nothing fires** — asymmetric with release behavior, which would have evaluated the same brief touch as tap/long-press and fired `onStaticPinPress`/`onStaticPinLongPress`. Consumers relying on `onStaticPinPress` to observe every brief touch will silently miss termination-path taps ### Pin Position Updates -- `onStaticPinPositionMove`: fires on every `onTransform` frame with the pin's content-space position (via `viewportPositionToImagePosition`). **Inherits `onTransform`'s trigger caveats:** fires twice per pan/pinch frame during active gestures (dual `panAnim`/`zoomAnim` listeners), also fires from `componentDidUpdate` on layout measurement changes (first layout, rotation) and programmatic `staticPinPosition` prop changes, and fires from programmatic `moveTo()`/`moveBy()` (twice per call normally, **three times per call when cancelling an in-flight `zoomTo(level, zoomCenter)` animation** — same `zoomToListenerId` cascade documented on the `moveTo()`/`moveBy()` Public Methods entries) and `moveStaticPinTo()` (once per instant call, or per animation frame) +- `onStaticPinPositionMove`: fires on every `onTransform` frame with the pin's content-space position (via `viewportPositionToImagePosition`). **Inherits `onTransform`'s trigger caveats:** fires once per pan frame, twice per pinch frame during active gestures, also fires from `componentDidUpdate` on layout measurement changes (first layout, rotation) and programmatic `staticPinPosition` prop changes, and fires from programmatic `moveTo()`/`moveBy()` (once per call, after cancelling any active `zoomTo()` before applying the pan) and `moveStaticPinTo()` (once per instant call, or per animation frame) - `onStaticPinPositionChange`: has two call paths: - **Debounced (100ms):** Fired via `_invokeOnTransform` from every site that calls it — active gesture frames, single-tap pan animation frames (when `staticPinPosition` is set, the 200ms animation documented in Single-Tap Pan-to-Pin fires `_invokeOnTransform` on every JS frame), `componentDidUpdate` on layout measurement changes (first layout, rotation) and programmatic `staticPinPosition` prop changes, and programmatic `moveTo()`/`moveBy()`/`moveStaticPinTo()` calls (same trigger surface as `onStaticPinPositionMove`). Uses lodash `debounce`, cancelled on unmount - - **Immediate:** Fired directly via `_updateStaticPin` at gesture end and after single-tap animation completion — no rate limiting. **`gestureStarted` is NOT yet reset to `false` when this fires at gesture end** — `_handlePanResponderEnd` calls `_updateStaticPin()` at line 498 and only sets `this.gestureStarted = false` at line 502 (the very last operation). Consumers cannot use `ref.current.gestureStarted` inside `onStaticPinPositionChange` to discriminate the immediate gesture-end call from mid-gesture debounced calls — the flag is `true` for both. This same ordering affects `onPanResponderEnd`, `onZoomEnd`, and `onShiftingEnd`: all of them fire while `gestureStarted` is still `true`. - - **Double-fire risk:** At gesture end, if a debounced call is pending from the last transform frame, the consumer receives one immediate call followed by a second debounced call ~100ms later + - **Flushed pending delivery:** Gesture end, single-tap pan animation completion, and natural `zoomTo()` completion all call `debouncedOnStaticPinPositionChange.flush()` instead of issuing a separate direct callback. `flush()` synchronously delivers at most one pending debounced call and clears the timer, so the gesture-end and single-tap completion paths no longer double-fire. **`gestureStarted` is NOT yet reset to `false` during the gesture-end flush** — `_handlePanResponderEnd` calls `flush()` before setting `this.gestureStarted = false`, so consumers still cannot use `ref.current.gestureStarted` inside that synchronous delivery to distinguish "gesture ending" from "mid-gesture." In contrast, the `zoomTo()` completion flush happens later, after the animation, when `gestureStarted` is already `false`. - Both callbacks require `contentWidth` and `contentHeight` to be set ### Single-Tap Pan-to-Pin -When `staticPinPosition` is set and user single-taps the content (not the pin), the view animates (200ms) to center on the tap position relative to the pin. The `_updateStaticPin` callback only fires if the animation completes (`finished === true`) and the component is still mounted. **During the 200ms animation, `panTransformListenerId` fires `_invokeOnTransform()` on every JS frame** (~12 frames at 60fps), generating per-frame calls to `onTransform`, `onStaticPinPositionMove`, and `debouncedOnStaticPinPositionChange`. Only the final `onStaticPinPositionChange` (immediate, non-debounced) fires from `_updateStaticPin` at animation completion. Consumers building analytics or rate-limited side effects on these callbacks will observe ~12 callback invocations from what appears to be a single tap. +When `staticPinPosition` is set and user single-taps the content (not the pin), the view animates (200ms) to center on the tap position relative to the pin. If the animation completes (`finished === true`) and the component is still mounted, the pending debounced `onStaticPinPositionChange` is synchronously flushed at animation completion. **During the 200ms animation, `panTransformListenerId` fires `_invokeOnTransform()` on every JS frame** (~12 frames at 60fps), generating per-frame calls to `onTransform`, `onStaticPinPositionMove`, and `debouncedOnStaticPinPositionChange`. The completion path delivers only the final flushed `onStaticPinPositionChange`, not a second separate immediate callback. --- ## Tap Handling -Tap resolution runs when no gesture type was classified (no movement detected). **Ordering:** `_resolveAndHandleTap` is called synchronously inside `_handlePanResponderEnd` *before* the `onPanResponderEnd` consumer callback fires. For a double-tap, all tap callbacks (`onDoubleTapBefore`, `onZoomBefore`, `onZoomAfter`, `onDoubleTapAfter`) complete synchronously before `onPanResponderEnd` — consistent with the `gestureStarted` section above. For a single-tap, the `singleTapTimeoutId` is *scheduled* before `onPanResponderEnd` fires, but the actual `onSingleTap` callback arrives asynchronously after `doubleTapDelay` ms — so `onPanResponderEnd` fires approximately `doubleTapDelay` ms *before* `onSingleTap` and can serve as a pre-tap hook for single-tap. The blanket "cannot use `onPanResponderEnd` as a pre-tap hook" only applies to the double-tap path, where all tap callbacks run synchronously before `onPanResponderEnd`. +Tap resolution runs when no gesture type was classified (no movement detected). **Ordering:** `_resolveAndHandleTap` is called synchronously inside `_handlePanResponderEnd` *before* the `onPanResponderEnd` consumer callback fires. For a double-tap, the synchronous tap callbacks are `onDoubleTapBefore`, `onZoomBefore`, and `onDoubleTapAfter`; `onZoomAfter` now arrives later, only if the `zoomTo()` animation finishes naturally. For a single-tap, the `singleTapTimeoutId` is *scheduled* before `onPanResponderEnd` fires, but the actual `onSingleTap` callback arrives asynchronously after `doubleTapDelay` ms — so `onPanResponderEnd` fires approximately `doubleTapDelay` ms *before* `onSingleTap` and can serve as a pre-tap hook for single-tap. The blanket "cannot use `onPanResponderEnd` as a pre-tap hook" only applies to the synchronous double-tap callbacks. ### Single vs Double-Tap Disambiguation `_resolveAndHandleTap` uses a delayed-resolution pattern: @@ -284,8 +283,8 @@ Tap resolution runs when no gesture type was classified (no movement detected). 3. **No second tap (timeout fires):** Clears saved state. If `staticPinPosition` is set, starts a 200ms pan animation toward the tap position relative to the pin. Then fires `onSingleTap` callback (animation is already in progress when callback runs). ### Timeout Cleanup -- `singleTapTimeoutId` is cleared on: double-tap detection and `componentWillUnmount` (not cleared on new gesture start — a tap followed by immediate pan within `doubleTapDelay` will fire `onSingleTap` mid-gesture) -- `doubleTapFirstTapReleaseTimestamp` is cleared on: double-tap detection and single-tap timeout fire +- `singleTapTimeoutId` is cleared on: double-tap detection, new gesture start (`_handlePanResponderGrant` clears it before starting any long-press timer, so a tap followed by an immediate pan/long-press within `doubleTapDelay` suppresses the pending `onSingleTap`), single-tap timeout fire, and `componentWillUnmount` +- `doubleTapFirstTapReleaseTimestamp` is cleared on: double-tap detection, single-tap timeout fire, transition into a `pinch` or `shift` gesture (pan-responder move), and long-press timer fire. The `pinch`/`shift` and long-press paths prevent tap→drag→tap and tap→long-press→release sequences within `doubleTapDelay` from spuriously matching as a double-tap. ### Long-press-then-release fires `onSingleTap` too A long-press-then-release with no movement satisfies the same `gestureType === null` condition as a tap, so it enters the tap-resolution path on release. The sequence: (1) `_handlePanResponderGrant` starts `longPressTimeout`; (2) after `longPressDuration` ms the timer fires `onLongPress` and sets `longPressTimeout = null`; (3) on release, `gestureType` is still `null` so `_handlePanResponderEnd` calls `_resolveAndHandleTap`; (4) the `if (this.longPressTimeout)` guard at the start of `_handlePanResponderEnd` is false because the timer already fired and nulled itself — there is no `longPressOccurred` sentinel to suppress tap resolution; (5) `_resolveAndHandleTap` schedules `singleTapTimeoutId` which fires `onSingleTap` `doubleTapDelay` ms later. **Consequence:** any long-press-then-release fires BOTH `onLongPress` (at `longPressDuration` ms) AND `onSingleTap` (at `longPressDuration + doubleTapDelay` ms, default ~1000 ms). `visualTouchFeedbackEnabled` also renders the post-long-press touch circle via `_addTouch`. `gestureStarted` cannot be used to filter this — it is `false` by the time the async `onSingleTap` callback fires. Consumers combining `onLongPress` with `onSingleTap` must de-duplicate in their own code. @@ -318,7 +317,7 @@ Sets `mounted = false`, then tears down in order: ### Mounted Guards `if (!this.mounted) return` is checked in: - `measureZoomSubject` (outer and inner timeout) -- `_updateStaticPin` animation completion callback +- `zoomTo()` animation completion callback - `_removeTouch` ### stopAnimation with Callback @@ -346,8 +345,6 @@ This captures the final animated value into the JS-side mirrors, preventing drif ### Zoom Center Coordinates In `zoomTo()` and double-tap, zoom center is in component viewport space with top-left origin: `{ x: 0, y: 0 }` = top-left corner of the zoom subject; `{ x: originalWidth/2, y: originalHeight/2 }` = true center. Computed as `pageX - originalPageX` / `pageY - originalPageY`. -**Known issue:** `doubleTapZoomToCenter` passes `{ x: 0, y: 0 }` intending to mean "center", but this actually anchors the zoom to the top-left corner. This is a pre-existing code bug. - ### moveStaticPinTo Math ``` offsetX = contentWidth/2 - position.x + (staticPinPosition.x - originalWidth/2) / zoomLevel diff --git a/src/ReactNativeZoomableView.tsx b/src/ReactNativeZoomableView.tsx index ee59fa3..46ed004 100644 --- a/src/ReactNativeZoomableView.tsx +++ b/src/ReactNativeZoomableView.tsx @@ -305,8 +305,8 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< // dev simulates mount → unmount → remount during development; the cleanup // below sets isMounted.current = false on the simulated unmount, so without // this re-set the second mount would observe the ref as permanently false - // for the lifetime of the component — silently dropping the - // _updateStaticPin call inside _fireSingleTapTimerBody and breaking + // for the lifetime of the component — silently dropping the debounced + // pin flush inside _fireSingleTapTimerBody and breaking // onStaticPinPositionChange after a single-tap pan. Mirrors the class // component's `this.mounted = true` in componentDidMount. isMounted.current = true; @@ -535,6 +535,12 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< longPressTimeout.current = setTimeout(() => { _fireOnLongPress(e, gestureState); longPressTimeout.current = undefined; + // After a confirmed long-press, clear pending double-tap state so the + // subsequent release does not match the prior tap's timestamp and + // spuriously fire onDoubleTap. Matters when longPressDuration < + // doubleTapDelay. + delete doubleTapFirstTapReleaseTimestamp.current; + delete doubleTapFirstTap.current; }, props.longPressDuration); } @@ -546,16 +552,19 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< // ticks, which can lag the native value. Without the callback form, a new // gesture starting mid-animation would compute its first frame against a // stale JS mirror and produce a visible offset/zoom drift (SPECS.md - // "stopAnimation with Callback"). + // "stopAnimation with Callback"). For zoom we route through + // `_cancelInFlightZoomToAnimation()` so any in-flight `zoomTo(zoomCenter)` + // also has its temporary pan-sync listener removed — without that, the + // listener keeps firing on every `zoomAnim.setValue()` in `_handlePinching` + // and overwrites the gesture-computed offset with one anchored at the + // cancelled zoomTo's center. panAnim.current.x.stopAnimation((x) => { offsetX.current = x; }); panAnim.current.y.stopAnimation((y) => { offsetY.current = y; }); - zoomAnim.current.stopAnimation((zoom) => { - zoomLevel.current = zoom; - }); + _cancelInFlightZoomToAnimation(); gestureStarted.current = true; }); @@ -592,7 +601,11 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< } if (props.staticPinPosition) { - _updateStaticPin(); + // Flush the pending debounced onStaticPinPositionChange so the final + // post-gesture pin position is delivered synchronously. A direct + // (non-debounced) call here would double-fire (immediate + debounce + // timer ~100ms later). + debouncedOnStaticPinPositionChange.flush(); } gestureType.current = undefined; @@ -650,6 +663,11 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< e, gestureState ); + // Clear stale double-tap state on pinch start. Without this, a + // tap-then-pinch-then-tap sequence within doubleTapDelay can match + // the first tap's timestamp and spuriously fire onDoubleTap. + delete doubleTapFirstTapReleaseTimestamp.current; + delete doubleTapFirstTap.current; } gestureType.current = 'pinch'; _handlePinching(e, gestureState); @@ -672,6 +690,13 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< const { dx, dy } = gestureState; const isShiftGesture = Math.abs(dx) > 2 || Math.abs(dy) > 2; if (isShiftGesture) { + // Clear stale double-tap state when a drag actually starts. Without + // this, a tap-pan-tap sequence within doubleTapDelay would match + // the first tap's timestamp and spuriously fire onDoubleTap. + if (gestureType.current !== 'shift') { + delete doubleTapFirstTapReleaseTimestamp.current; + delete doubleTapFirstTap.current; + } gestureType.current = 'shift'; _handleShifting(gestureState); } @@ -916,7 +941,6 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< offsetY.current = newOffsetY; panAnim.current.setValue({ x: offsetX.current, y: offsetY.current }); - zoomAnim.current.setValue(zoomLevel.current); onShiftingAfter?.(null, null, _getZoomableViewEventObject()); } @@ -960,7 +984,13 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< // isMounted guard mirrors the class's `this.mounted` check: when the // consumer owns panAnim, unmount cleanup skips stopAnimation() so // the animation can complete with finished=true post-unmount. - if (finished && isMounted.current) _updateStaticPin(); + if (finished && isMounted.current) { + // Flush the pending debounced onStaticPinPositionChange so the + // final post-animation pin position is delivered synchronously. + // A direct (non-debounced) call here caused a double-fire + // (immediate + debounce timer ~100ms later). + debouncedOnStaticPinPositionChange.flush(); + } }); } @@ -1056,12 +1086,6 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< }); }); - const _updateStaticPin = useLatestCallback(() => { - const position = _staticPinPosition(); - if (!position) return; - props.onStaticPinPositionChange?.(position); - }); - const _addTouch = useLatestCallback((touch: TouchPoint) => { touches.current.push(touch); setStateTouches([...touches.current]); @@ -1098,10 +1122,12 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< y: e.nativeEvent.pageY - originalPageY, }; - // if doubleTapZoomToCenter enabled -> always zoom to center instead + // if doubleTapZoomToCenter enabled -> always zoom to center instead. + // publicZoomTo expects viewport-relative coordinates where center is + // (originalWidth/2, originalHeight/2) — not (0,0). See publicZoomTo JSDoc. if (doubleTapZoomToCenter) { - zoomPositionCoordinates.x = 0; - zoomPositionCoordinates.y = 0; + zoomPositionCoordinates.x = originalWidth / 2; + zoomPositionCoordinates.y = originalHeight / 2; } publicZoomTo(nextZoomStep, zoomPositionCoordinates); @@ -1121,27 +1147,84 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< const _getNextZoomStep = useLatestCallback(() => { const { zoomStep, maxZoom, initialZoom } = props; - if (maxZoom == null) return; - - if (zoomLevel.current.toFixed(2) === maxZoom.toFixed(2)) { + // Cycle-back when at a configured maxZoom must be checked BEFORE + // the zoomStep guard — otherwise users with zoomStep={null} and + // a configured maxZoom lose the reset-to-initialZoom behavior on + // double-tap at max zoom. + if ( + maxZoom != null && + zoomLevel.current.toFixed(2) === maxZoom.toFixed(2) + ) { return initialZoom; } + // If no zoomStep is configured, there is no increment to compute. if (zoomStep == null) return; + // Determine the effective ceiling for double-tap cycling. + // When maxZoom is null (unlimited zoom), use a default of 3 zoom + // steps from initialZoom so double-tap still cycles back — otherwise + // every tap would grow zoom indefinitely with no reset path. + const effectiveMax = + maxZoom != null + ? maxZoom + : (initialZoom ?? 1) * Math.pow(1 + zoomStep, 3); + + // This cycle-back is only reachable when maxZoom == null; when + // maxZoom != null the equivalent check above already returned. + if (zoomLevel.current.toFixed(2) === effectiveMax.toFixed(2)) { + return initialZoom; + } + const nextZoomStep = zoomLevel.current * (1 + zoomStep); - if (nextZoomStep > maxZoom) { - return maxZoom; + if (nextZoomStep > effectiveMax) { + return effectiveMax; } return nextZoomStep; }); + // Read props.staticPinPosition / props.onZoomAfter at fire time, not at + // schedule time. The .start() completion callback below runs ~animation + // duration after publicZoomTo is invoked; without this wrapper the inner + // lambda would close over the props snapshot at schedule time and miss + // any parent re-render during the animation. Mirrors the pattern in + // _fireSingleTapTimerBody and StaticPin's onPress/onLongPress refs. + const _onPublicZoomToAnimationComplete = useLatestCallback( + ({ + finished, + capturedListenerId, + }: { + finished: boolean; + capturedListenerId?: string; + }) => { + if (!isMounted.current) return; + if (capturedListenerId) { + zoomAnim.current.removeListener(capturedListenerId); + if (zoomToListenerId.current === capturedListenerId) { + zoomToListenerId.current = undefined; + } + } + if (finished) { + // Flush any pending debounced static-pin position change so + // consumers observing pin position in onZoomAfter see the final + // post-animation value, matching the pattern in + // _handlePanResponderEnd. + if (props.staticPinPosition) { + debouncedOnStaticPinPositionChange.flush(); + } + props.onZoomAfter?.(null, null, _getZoomableViewEventObject()); + } + } + ); + /** * Zooms to a specific level. A "zoom center" can be provided, which specifies * the point that will remain in the same position on the screen after the zoom. - * The coordinates of the zoom center is relative to the zoom subject. - * { x: 0, y: 0 } is the very center of the zoom subject. + * The coordinates of the zoom center are viewport-relative (in pixels). + * { x: 0, y: 0 } is the top-left corner of the viewport. + * To zoom to the center of the viewport, use + * { x: originalWidth / 2, y: originalHeight / 2 }. * * @param newZoomLevel * @param zoomCenter - If not supplied, the container's center is the zoom center @@ -1149,8 +1232,8 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< const publicZoomTo = useLatestCallback( (newZoomLevel: number, zoomCenter?: Vec2D) => { if (!props.zoomEnabled) return false; - if (props.maxZoom && newZoomLevel > props.maxZoom) return false; - if (props.minZoom && newZoomLevel < props.minZoom) return false; + if (props.maxZoom != null && newZoomLevel > props.maxZoom) return false; + if (props.minZoom != null && newZoomLevel < props.minZoom) return false; props.onZoomBefore?.(null, null, _getZoomableViewEventObject()); @@ -1202,18 +1285,14 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< // overwritten — so reading the ref at fire time would read listener2. // Mirrors the class component's local-capture + identity-equality // pattern. - const listenerId = zoomToListenerId.current; - getZoomToAnimation(zoomAnim.current, newZoomLevel).start(() => { - if (listenerId) { - zoomAnim.current.removeListener(listenerId); - if (zoomToListenerId.current === listenerId) { - zoomToListenerId.current = undefined; - } + const capturedListenerId = zoomToListenerId.current; + getZoomToAnimation(zoomAnim.current, newZoomLevel).start( + ({ finished }) => { + _onPublicZoomToAnimationComplete({ finished, capturedListenerId }); } - }); + ); // == Zoom Animation Ends == - props.onZoomAfter?.(null, null, _getZoomableViewEventObject()); return true; } ); @@ -1235,6 +1314,34 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< return publicZoomTo(zoomLevel.current + zoomLevelChange); }); + /** + * Cancels any in-flight zoomTo() animation: stops zoomAnim and removes the + * pan-sync listener registered inside zoomTo(zoomCenter). Called by + * publicMoveTo / publicMoveBy / _handlePanResponderGrant before applying + * a programmatic pan or starting a new gesture so the cancelled zoomTo + * cannot overwrite the new offset on its next animation frame. + * + * @return {number} the zoom level at the moment of cancellation + */ + const _cancelInFlightZoomToAnimation = useLatestCallback(() => { + let stoppedZoomLevel = zoomLevel.current; + + // Programmatic pan should win over any active zoomTo animation. + // Stop the zoom first and remove its temporary pan-sync listener + // so the next zoom frame cannot overwrite the requested offset. + zoomAnim.current.stopAnimation((value) => { + stoppedZoomLevel = value; + zoomLevel.current = value; + }); + + if (zoomToListenerId.current) { + zoomAnim.current.removeListener(zoomToListenerId.current); + zoomToListenerId.current = undefined; + } + + return stoppedZoomLevel; + }); + /** * Moves the zoomed view to a specified position * Returns a promise when finished @@ -1248,8 +1355,9 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< (newOffsetX: number, newOffsetY: number) => { if (!originalWidth || !originalHeight) return; - const offsetX = (newOffsetX - originalWidth / 2) / zoomLevel.current; - const offsetY = (newOffsetY - originalHeight / 2) / zoomLevel.current; + const stoppedZoomLevel = _cancelInFlightZoomToAnimation(); + const offsetX = (newOffsetX - originalWidth / 2) / stoppedZoomLevel; + const offsetY = (newOffsetY - originalHeight / 2) / stoppedZoomLevel; _setNewOffsetPosition(-offsetX, -offsetY); } @@ -1267,12 +1375,11 @@ const ReactNativeZoomableView: ForwardRefRenderFunction< */ const publicMoveBy = useLatestCallback( (offsetChangeX: number, offsetChangeY: number) => { + const stoppedZoomLevel = _cancelInFlightZoomToAnimation(); const newOffsetX = - (offsetX.current * zoomLevel.current - offsetChangeX) / - zoomLevel.current; + (offsetX.current * stoppedZoomLevel - offsetChangeX) / stoppedZoomLevel; const newOffsetY = - (offsetY.current * zoomLevel.current - offsetChangeY) / - zoomLevel.current; + (offsetY.current * stoppedZoomLevel - offsetChangeY) / stoppedZoomLevel; _setNewOffsetPosition(newOffsetX, newOffsetY); } diff --git a/src/components/StaticPin.tsx b/src/components/StaticPin.tsx index 601c5a9..6740f55 100644 --- a/src/components/StaticPin.tsx +++ b/src/components/StaticPin.tsx @@ -53,6 +53,10 @@ export const StaticPin = ({ const pressDuration = longPressDuration ?? 500; const pressDurationRef = React.useRef(pressDuration); pressDurationRef.current = pressDuration; + const onPressRef = React.useRef(onPress); + onPressRef.current = onPress; + const onLongPressRef = React.useRef(onLongPress); + onLongPressRef.current = onLongPress; const transform = [ { translateY: -pinSize.height }, { translateX: -pinSize.width / 2 }, @@ -98,11 +102,11 @@ export const StaticPin = ({ return; } const dt = Date.now() - tapTime.current; - if (onPress && dt < pressDurationRef.current) { - onPress(evt); + if (onPressRef.current && dt < pressDurationRef.current) { + onPressRef.current(evt); } - if (onLongPress && dt >= pressDurationRef.current) { - onLongPress(evt); + if (onLongPressRef.current && dt >= pressDurationRef.current) { + onLongPressRef.current(evt); } }, onPanResponderTerminate: (evt, gestureState) => {