rn-pdf-king 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.eslintrc.js +5 -0
  2. package/README.md +148 -0
  3. package/android/build.gradle +55 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/expo/modules/rnpdfking/PdfKing.kt +693 -0
  6. package/android/src/main/java/expo/modules/rnpdfking/RnPdfKingModule.kt +163 -0
  7. package/android/src/main/java/expo/modules/rnpdfking/RnPdfKingView.kt +184 -0
  8. package/build/PdfDocument.d.ts +19 -0
  9. package/build/PdfDocument.d.ts.map +1 -0
  10. package/build/PdfDocument.js +81 -0
  11. package/build/PdfDocument.js.map +1 -0
  12. package/build/PdfPage.d.ts +24 -0
  13. package/build/PdfPage.d.ts.map +1 -0
  14. package/build/PdfPage.js +13 -0
  15. package/build/PdfPage.js.map +1 -0
  16. package/build/RnPdfKing.types.d.ts +48 -0
  17. package/build/RnPdfKing.types.d.ts.map +1 -0
  18. package/build/RnPdfKing.types.js +2 -0
  19. package/build/RnPdfKing.types.js.map +1 -0
  20. package/build/RnPdfKingModule.d.ts +13 -0
  21. package/build/RnPdfKingModule.d.ts.map +1 -0
  22. package/build/RnPdfKingModule.js +4 -0
  23. package/build/RnPdfKingModule.js.map +1 -0
  24. package/build/RnPdfKingModule.web.d.ts +13 -0
  25. package/build/RnPdfKingModule.web.d.ts.map +1 -0
  26. package/build/RnPdfKingModule.web.js +21 -0
  27. package/build/RnPdfKingModule.web.js.map +1 -0
  28. package/build/RnPdfKingView.d.ts +4 -0
  29. package/build/RnPdfKingView.d.ts.map +1 -0
  30. package/build/RnPdfKingView.js +7 -0
  31. package/build/RnPdfKingView.js.map +1 -0
  32. package/build/RnPdfKingView.web.d.ts +4 -0
  33. package/build/RnPdfKingView.web.d.ts.map +1 -0
  34. package/build/RnPdfKingView.web.js +7 -0
  35. package/build/RnPdfKingView.web.js.map +1 -0
  36. package/build/ZoomableList.d.ts +37 -0
  37. package/build/ZoomableList.d.ts.map +1 -0
  38. package/build/ZoomableList.js +289 -0
  39. package/build/ZoomableList.js.map +1 -0
  40. package/build/ZoomablePage.d.ts +10 -0
  41. package/build/ZoomablePage.d.ts.map +1 -0
  42. package/build/ZoomablePage.js +15 -0
  43. package/build/ZoomablePage.js.map +1 -0
  44. package/build/ZoomablePdfPage.d.ts +10 -0
  45. package/build/ZoomablePdfPage.d.ts.map +1 -0
  46. package/build/ZoomablePdfPage.js +17 -0
  47. package/build/ZoomablePdfPage.js.map +1 -0
  48. package/build/index.d.ts +8 -0
  49. package/build/index.d.ts.map +1 -0
  50. package/build/index.js +10 -0
  51. package/build/index.js.map +1 -0
  52. package/build/zoom/constants.d.ts +36 -0
  53. package/build/zoom/constants.d.ts.map +1 -0
  54. package/build/zoom/constants.js +36 -0
  55. package/build/zoom/constants.js.map +1 -0
  56. package/build/zoom/index.d.ts +255 -0
  57. package/build/zoom/index.d.ts.map +1 -0
  58. package/build/zoom/index.js +783 -0
  59. package/build/zoom/index.js.map +1 -0
  60. package/build/zoom/utils.d.ts +55 -0
  61. package/build/zoom/utils.d.ts.map +1 -0
  62. package/build/zoom/utils.js +66 -0
  63. package/build/zoom/utils.js.map +1 -0
  64. package/bun.lock +2217 -0
  65. package/expo-module.config.json +9 -0
  66. package/ios/RnPdfKing.podspec +29 -0
  67. package/ios/RnPdfKingModule.swift +48 -0
  68. package/ios/RnPdfKingView.swift +38 -0
  69. package/package.json +45 -0
  70. package/src/PdfDocument.tsx +115 -0
  71. package/src/PdfPage.tsx +57 -0
  72. package/src/RnPdfKing.types.ts +32 -0
  73. package/src/RnPdfKingModule.ts +15 -0
  74. package/src/RnPdfKingModule.web.ts +24 -0
  75. package/src/RnPdfKingView.tsx +11 -0
  76. package/src/RnPdfKingView.web.tsx +15 -0
  77. package/src/ZoomableList.tsx +438 -0
  78. package/src/ZoomablePage.tsx +31 -0
  79. package/src/ZoomablePdfPage.tsx +34 -0
  80. package/src/index.ts +9 -0
  81. package/src/zoom/constants.ts +40 -0
  82. package/src/zoom/index.tsx +1267 -0
  83. package/src/zoom/utils.ts +96 -0
  84. package/tsconfig.json +9 -0
@@ -0,0 +1,783 @@
1
+ import React, { useCallback, useMemo } from 'react';
2
+ import { View, } from 'react-native';
3
+ import { Gesture, GestureDetector, GestureHandlerRootView, State, } from 'react-native-gesture-handler';
4
+ import Animated, { Easing, runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withDecay, withSpring, withTiming, scrollTo, } from 'react-native-reanimated';
5
+ import { ANIMATION_DURATION, MAX_SCALE, TAP_MAX_DELTA, DOUBLE_TAP_SCALE, } from './constants'; // Allow over-zoom by 50%
6
+ import { clamp } from './utils';
7
+ import { StyleSheet } from 'react-native';
8
+ const styles = StyleSheet.create({
9
+ container: {
10
+ flex: 1,
11
+ overflow: 'hidden',
12
+ },
13
+ });
14
+ // Rubber band factor for over-scroll/over-zoom
15
+ const RUBBER_BAND_FACTOR = 0.55;
16
+ const MIN_OVER_SCALE = 0.5; // Allow zooming out to 50% for rubber band
17
+ // Apple Photos spring animation config
18
+ // Uses critically damped spring (dampingRatio ≈ 1) with fast response
19
+ // Reference: iOS UISpringTimingParameters defaults
20
+ const SPRING_CONFIG = {
21
+ damping: 20,
22
+ stiffness: 250,
23
+ mass: 0.5,
24
+ overshootClamping: false,
25
+ };
26
+ /**
27
+ * Apple Photos-style zoom gesture hook
28
+ *
29
+ * Key principles from Apple Photos:
30
+ * 1. Transform order: translate first, then scale (scale around center)
31
+ * 2. Focal point stays under finger during pinch
32
+ * 3. Rubber band effect when over-zooming or at boundaries
33
+ * 4. Smooth spring animations for snap-back
34
+ * 5. Momentum-based panning with boundary bounce
35
+ */
36
+ export function useZoomGesture(props = {}) {
37
+ const { animationFunction = withTiming, animationConfig, doubleTapConfig, minScale = 1, maxScale = MAX_SCALE, enableGallerySwipe = false, parentScrollRef, currentIndex = 0, itemWidth = 0, onPanningUpdate, onPanningStart, onPanningEnd, onPanningStarted, onPinchingStarted, onPinchingStopped, disableVerticalPan = false, parentAnimatedScrollRef, } = props;
38
+ // Boolean flag for worklet (refs can't be passed to worklets)
39
+ const hasParentScroll = !!parentScrollRef && itemWidth > 0;
40
+ // ============== STATE ==============
41
+ // Scale state - single source of truth
42
+ const scale = useSharedValue(1);
43
+ const savedScale = useSharedValue(1);
44
+ // Translation state (in screen coordinates)
45
+ const translateX = useSharedValue(0);
46
+ const translateY = useSharedValue(0);
47
+ const savedTranslateX = useSharedValue(0);
48
+ const savedTranslateY = useSharedValue(0);
49
+ // Container and content dimensions
50
+ const containerDimensions = useSharedValue({ width: 0, height: 0 });
51
+ const contentDimensions = useSharedValue({ width: 1, height: 1 });
52
+ // Pinch gesture state
53
+ const pinchFocalX = useSharedValue(0);
54
+ const pinchFocalY = useSharedValue(0);
55
+ const isPinching = useSharedValue(false);
56
+ // Pan gesture state for rubber band effect
57
+ const isPanning = useSharedValue(false);
58
+ // Edge swipe state for Apple Photos-style gallery navigation
59
+ const isAtLeftEdge = useSharedValue(false);
60
+ const isAtRightEdge = useSharedValue(false);
61
+ const panStartX = useSharedValue(0);
62
+ const accumulatedOverflow = useSharedValue(0); // Track overflow for snap decision
63
+ const virtualTranslationX = useSharedValue(0);
64
+ const virtualTranslationY = useSharedValue(0);
65
+ const startScrollY = useSharedValue(0);
66
+ const currentScrollY = useSharedValue(0);
67
+ // Tracking state
68
+ const isZoomedIn = useSharedValue(false);
69
+ const zoomGestureLastTime = useSharedValue(0);
70
+ // ============== HELPERS ==============
71
+ const withAnimation = useCallback((toValue, config) => {
72
+ 'worklet';
73
+ return animationFunction(toValue, {
74
+ duration: ANIMATION_DURATION,
75
+ easing: Easing.out(Easing.cubic),
76
+ ...config,
77
+ ...animationConfig,
78
+ });
79
+ }, [animationFunction, animationConfig]);
80
+ /**
81
+ * Calculate the maximum translation bounds for a given scale
82
+ * This ensures the content edges don't go past the container edges
83
+ *
84
+ * Apple Photos algorithm:
85
+ * - Content is centered in container
86
+ * - Translation bounds = half of how much scaled content exceeds container
87
+ * - When content fits inside container, bounds = 0 (no panning allowed)
88
+ *
89
+ * IMPORTANT: Use actual contentDimensions from onLayoutContent, not calculated
90
+ * aspect-fit size. Layout system may round dimensions differently than our math.
91
+ */
92
+ const getTranslateBounds = useCallback((currentScale) => {
93
+ 'worklet';
94
+ const container = containerDimensions.value;
95
+ // Use actual measured content dimensions, not calculated aspect-fit size
96
+ // This ensures bounds match exactly what's rendered on screen
97
+ const content = contentDimensions.value;
98
+ // Scaled content dimensions
99
+ const scaledWidth = content.width * currentScale;
100
+ const scaledHeight = content.height * currentScale;
101
+ // How much the scaled content exceeds the container
102
+ // When scaledSize <= containerSize, excess = 0 (content fits, no panning)
103
+ // When scaledSize > containerSize, excess = scaledSize - containerSize
104
+ const excessWidth = Math.max(0, scaledWidth - container.width);
105
+ const excessHeight = Math.max(0, scaledHeight - container.height);
106
+ // Max translation = half the excess (content can pan from edge to edge)
107
+ // Subtract small padding to ensure content always overlaps edges
108
+ // This prevents subpixel gaps from floating-point rounding
109
+ const safetyPadding = 1;
110
+ return {
111
+ maxX: Math.max(0, Math.floor(excessWidth / 2) - safetyPadding),
112
+ maxY: Math.max(0, Math.floor(excessHeight / 2) - safetyPadding),
113
+ };
114
+ }, [containerDimensions, contentDimensions]);
115
+ /**
116
+ * Clamp translation to valid bounds
117
+ */
118
+ const clampTranslation = useCallback((tx, ty, currentScale) => {
119
+ 'worklet';
120
+ const bounds = getTranslateBounds(currentScale);
121
+ return {
122
+ x: clamp(tx, -bounds.maxX, bounds.maxX),
123
+ y: clamp(ty, -bounds.maxY, bounds.maxY),
124
+ };
125
+ }, [getTranslateBounds]);
126
+ /**
127
+ * Apply rubber band effect to a value that's outside bounds
128
+ * Apple Photos uses this for smooth over-scroll feeling
129
+ */
130
+ const rubberBand = useCallback((value, min, max, dimension) => {
131
+ 'worklet';
132
+ if (value < min) {
133
+ const overscroll = min - value;
134
+ return min - (1 - (1 / ((overscroll * RUBBER_BAND_FACTOR / dimension) + 1))) * dimension;
135
+ }
136
+ if (value > max) {
137
+ const overscroll = value - max;
138
+ return max + (1 - (1 / ((overscroll * RUBBER_BAND_FACTOR / dimension) + 1))) * dimension;
139
+ }
140
+ return value;
141
+ }, []);
142
+ /**
143
+ * Apply rubber band to translation during gesture
144
+ */
145
+ const applyRubberBandTranslation = useCallback((tx, ty, currentScale) => {
146
+ 'worklet';
147
+ const container = containerDimensions.value;
148
+ const bounds = getTranslateBounds(currentScale);
149
+ return {
150
+ x: rubberBand(tx, -bounds.maxX, bounds.maxX, container.width),
151
+ y: rubberBand(ty, -bounds.maxY, bounds.maxY, container.height),
152
+ };
153
+ }, [containerDimensions, getTranslateBounds, rubberBand]);
154
+ /**
155
+ * Apply boundary constraints with spring animation (rubber band effect)
156
+ */
157
+ const applyBoundaryConstraints = useCallback((targetScale, animate = true) => {
158
+ 'worklet';
159
+ const clampedScale = clamp(targetScale, minScale, maxScale);
160
+ const { x: clampedX, y: clampedY } = clampTranslation(translateX.value, translateY.value, clampedScale);
161
+ if (animate) {
162
+ // Apple uses spring animation for snap-back
163
+ // Using gentle spring config to avoid excessive bounce (fix for #51)
164
+ scale.value = withSpring(clampedScale, SPRING_CONFIG);
165
+ translateX.value = withSpring(clampedX, SPRING_CONFIG);
166
+ translateY.value = withSpring(clampedY, SPRING_CONFIG);
167
+ }
168
+ else {
169
+ scale.value = clampedScale;
170
+ translateX.value = clampedX;
171
+ translateY.value = clampedY;
172
+ }
173
+ savedScale.value = clampedScale;
174
+ savedTranslateX.value = clampedX;
175
+ savedTranslateY.value = clampedY;
176
+ isZoomedIn.value = clampedScale > minScale;
177
+ }, [
178
+ scale,
179
+ translateX,
180
+ translateY,
181
+ savedScale,
182
+ savedTranslateX,
183
+ savedTranslateY,
184
+ isZoomedIn,
185
+ clampTranslation,
186
+ minScale,
187
+ maxScale,
188
+ ]);
189
+ // ============== ZOOM ACTIONS ==============
190
+ /**
191
+ * Zoom in to a point (double-tap)
192
+ * Apple Photos behavior: zoom to 2x (or configured scale) centered on tap point
193
+ */
194
+ const zoomIn = useCallback((focalX, focalY) => {
195
+ 'worklet';
196
+ const container = containerDimensions.value;
197
+ const targetScale = doubleTapConfig?.defaultScale
198
+ ?? doubleTapConfig?.minZoomScale
199
+ ?? DOUBLE_TAP_SCALE;
200
+ const clampedTargetScale = clamp(targetScale, doubleTapConfig?.minZoomScale ?? minScale, doubleTapConfig?.maxZoomScale ?? maxScale);
201
+ // Container center
202
+ const centerX = container.width / 2;
203
+ const centerY = container.height / 2;
204
+ // Current state
205
+ const currentScale = scale.value;
206
+ const currentTx = translateX.value;
207
+ const currentTy = translateY.value;
208
+ // Focal point offset from center (in screen coords)
209
+ const focalOffsetX = focalX - centerX;
210
+ const focalOffsetY = focalY - centerY;
211
+ // Calculate new translation to keep focal point stationary
212
+ // The focal point in content space: (focalOffset - translate) / scale
213
+ // After zoom: newTranslate = focalOffset - contentPoint * newScale
214
+ const contentPointX = (focalOffsetX - currentTx) / currentScale;
215
+ const contentPointY = (focalOffsetY - currentTy) / currentScale;
216
+ let newTx = focalOffsetX - contentPointX * clampedTargetScale;
217
+ let newTy = focalOffsetY - contentPointY * clampedTargetScale;
218
+ // Clamp to bounds
219
+ const clamped = clampTranslation(newTx, newTy, clampedTargetScale);
220
+ newTx = clamped.x;
221
+ newTy = clamped.y;
222
+ // Animate
223
+ scale.value = withAnimation(clampedTargetScale);
224
+ translateX.value = withAnimation(newTx);
225
+ translateY.value = withAnimation(newTy);
226
+ savedScale.value = clampedTargetScale;
227
+ savedTranslateX.value = newTx;
228
+ savedTranslateY.value = newTy;
229
+ isZoomedIn.value = true;
230
+ }, [
231
+ containerDimensions,
232
+ scale,
233
+ translateX,
234
+ translateY,
235
+ savedScale,
236
+ savedTranslateX,
237
+ savedTranslateY,
238
+ isZoomedIn,
239
+ doubleTapConfig,
240
+ withAnimation,
241
+ clampTranslation,
242
+ minScale,
243
+ maxScale,
244
+ ]);
245
+ /**
246
+ * Zoom out to minimum scale
247
+ */
248
+ const zoomOut = useCallback(() => {
249
+ 'worklet';
250
+ scale.value = withAnimation(minScale);
251
+ translateX.value = withAnimation(0);
252
+ translateY.value = withAnimation(0);
253
+ savedScale.value = minScale;
254
+ savedTranslateX.value = 0;
255
+ savedTranslateY.value = 0;
256
+ isZoomedIn.value = false;
257
+ }, [
258
+ scale,
259
+ translateX,
260
+ translateY,
261
+ savedScale,
262
+ savedTranslateX,
263
+ savedTranslateY,
264
+ isZoomedIn,
265
+ withAnimation,
266
+ minScale,
267
+ ]);
268
+ /**
269
+ * Handle double tap
270
+ */
271
+ const onDoubleTap = useCallback((x, y) => {
272
+ 'worklet';
273
+ if (isZoomedIn.value)
274
+ zoomOut();
275
+ else
276
+ zoomIn(x, y);
277
+ }, [isZoomedIn, zoomIn, zoomOut]);
278
+ // ============== LAYOUT HANDLERS ==============
279
+ const onLayout = useCallback(({ nativeEvent: { layout: { width, height } } }) => {
280
+ containerDimensions.value = { width, height };
281
+ }, [containerDimensions]);
282
+ const onLayoutContent = useCallback(({ nativeEvent: { layout: { width, height } } }) => {
283
+ contentDimensions.value = { width, height };
284
+ }, [contentDimensions]);
285
+ // ============== GESTURE HANDLERS ==============
286
+ const updateZoomGestureLastTime = useCallback(() => {
287
+ 'worklet';
288
+ zoomGestureLastTime.value = Date.now();
289
+ }, [zoomGestureLastTime]);
290
+ // Callback for scrolling parent from worklet
291
+ // Compatible with FlatList/ScrollView from react-native, react-native-gesture-handler,
292
+ // and react-native-reanimated (Animated.FlatList/ScrollView)
293
+ const scrollParent = useCallback((offset, animated = false) => {
294
+ if (!parentScrollRef?.current)
295
+ return;
296
+ const ref = parentScrollRef.current;
297
+ // Duck-typing to support all FlatList/ScrollView implementations
298
+ if (ref.scrollToOffset)
299
+ ref.scrollToOffset({ offset, animated });
300
+ else if (ref.scrollTo)
301
+ ref.scrollTo({ x: offset, animated });
302
+ }, [parentScrollRef]);
303
+ // Delayed zoom reset after snap animation completes
304
+ const resetZoomDelayed = useCallback((delay = 300) => {
305
+ setTimeout(() => {
306
+ scale.value = withSpring(minScale, SPRING_CONFIG);
307
+ translateX.value = withSpring(0, SPRING_CONFIG);
308
+ translateY.value = withSpring(0, SPRING_CONFIG);
309
+ savedScale.value = minScale;
310
+ savedTranslateX.value = 0;
311
+ savedTranslateY.value = 0;
312
+ isZoomedIn.value = false;
313
+ }, delay);
314
+ }, [
315
+ scale,
316
+ translateX,
317
+ translateY,
318
+ savedScale,
319
+ savedTranslateX,
320
+ savedTranslateY,
321
+ isZoomedIn,
322
+ minScale,
323
+ ]);
324
+ const zoomGesture = useMemo(() => {
325
+ // ========== DOUBLE TAP ==========
326
+ const tapGesture = Gesture.Tap()
327
+ .numberOfTaps(2)
328
+ .maxDeltaX(TAP_MAX_DELTA)
329
+ .maxDeltaY(TAP_MAX_DELTA)
330
+ .onEnd((event) => {
331
+ 'worklet';
332
+ updateZoomGestureLastTime();
333
+ onDoubleTap(event.x, event.y);
334
+ });
335
+ // ========== PAN GESTURE ==========
336
+ // Apple Photos: 1 finger when zoomed in, 2 fingers when at 1x
337
+ // With enableGallerySwipe + parentScrollRef: seamless edge scrolling
338
+ const panGesture = Gesture.Pan()
339
+ .manualActivation(true)
340
+ .onTouchesDown((e) => {
341
+ 'worklet';
342
+ // Store initial touch position and edge state
343
+ if (e.numberOfTouches >= 1) {
344
+ const bounds = getTranslateBounds(scale.value);
345
+ const edgeThreshold = 2;
346
+ // Check current edge state
347
+ // At left edge: translateX is at maxX (content shifted right, showing left of image)
348
+ // At right edge: translateX is at -maxX (content shifted left, showing right of image)
349
+ isAtLeftEdge.value = translateX.value >= bounds.maxX - edgeThreshold;
350
+ isAtRightEdge.value = translateX.value <= -bounds.maxX + edgeThreshold;
351
+ panStartX.value = e.allTouches[0].x;
352
+ }
353
+ })
354
+ .onTouchesMove((e, state) => {
355
+ 'worklet';
356
+ if (e.state === State.ACTIVE)
357
+ return; // Already activated
358
+ if ([State.UNDETERMINED, State.BEGAN].includes(e.state)) {
359
+ const zoomed = scale.value > minScale + 0.01; // Small threshold to avoid float issues
360
+ // 2 finger pan always works (for pinch-pan combo)
361
+ if (e.numberOfTouches === 2) {
362
+ state.activate();
363
+ return;
364
+ }
365
+ // Not zoomed - don't activate (let parent handle)
366
+ if (!zoomed) {
367
+ state.fail();
368
+ return;
369
+ }
370
+ // Zoomed with 1 finger
371
+ // If we have parentScrollRef - always activate, we'll handle scrolling ourselves
372
+ if (enableGallerySwipe && hasParentScroll) {
373
+ state.activate();
374
+ return;
375
+ }
376
+ // Legacy mode: check for edge swipe
377
+ if (enableGallerySwipe && e.numberOfTouches === 1) {
378
+ const touch = e.allTouches[0];
379
+ const deltaX = touch.x - panStartX.value;
380
+ const bounds = getTranslateBounds(scale.value);
381
+ const absDeltaX = Math.abs(deltaX);
382
+ // If no horizontal panning is possible, let parent handle
383
+ if (bounds.maxX === 0) {
384
+ state.fail();
385
+ return;
386
+ }
387
+ // Wait for sufficient movement before deciding
388
+ const decisionThreshold = 5;
389
+ if (absDeltaX < decisionThreshold)
390
+ return; // Not enough movement yet, don't decide
391
+ // Check if swiping beyond edge
392
+ // At left edge and swiping right -> let parent handle (go to prev image)
393
+ // At right edge and swiping left -> let parent handle (go to next image)
394
+ if (isAtLeftEdge.value && deltaX > 0) {
395
+ state.fail();
396
+ return;
397
+ }
398
+ if (isAtRightEdge.value && deltaX < 0) {
399
+ state.fail();
400
+ return;
401
+ }
402
+ }
403
+ // Activate for normal zoomed panning
404
+ state.activate();
405
+ }
406
+ })
407
+ .onStart(() => {
408
+ 'worklet';
409
+ updateZoomGestureLastTime();
410
+ isPanning.value = true;
411
+ accumulatedOverflow.value = 0; // Reset overflow tracking
412
+ virtualTranslationX.value = 0;
413
+ virtualTranslationY.value = 0;
414
+ startScrollY.value = currentScrollY.value;
415
+ // Save current position
416
+ savedTranslateX.value = translateX.value;
417
+ savedTranslateY.value = translateY.value;
418
+ if (onPanningStart)
419
+ runOnJS(onPanningStart)();
420
+ if (onPanningStarted)
421
+ runOnJS(onPanningStarted)();
422
+ })
423
+ .onUpdate((event) => {
424
+ 'worklet';
425
+ const bounds = getTranslateBounds(scale.value);
426
+ // Calculate new translation
427
+ let newTx = savedTranslateX.value + event.translationX;
428
+ const newTy = savedTranslateY.value + event.translationY;
429
+ // Apple Photos seamless scrolling with parentScrollRef
430
+ if (enableGallerySwipe && hasParentScroll) {
431
+ // Calculate overflow (how much we're trying to go past the edge)
432
+ let overflow = 0;
433
+ if (newTx > bounds.maxX) {
434
+ // Trying to go past left edge (swiping right)
435
+ overflow = newTx - bounds.maxX;
436
+ newTx = bounds.maxX;
437
+ }
438
+ else if (newTx < -bounds.maxX) {
439
+ // Trying to go past right edge (swiping left)
440
+ overflow = newTx + bounds.maxX; // negative value
441
+ newTx = -bounds.maxX;
442
+ }
443
+ // If there's overflow, scroll the parent FlatList
444
+ if (overflow !== 0) {
445
+ accumulatedOverflow.value = overflow;
446
+ const targetOffset = currentIndex * itemWidth - overflow;
447
+ // Scroll parent without animation for smooth tracking
448
+ runOnJS(scrollParent)(targetOffset, false);
449
+ // Lock vertical movement while scrolling parent
450
+ translateX.value = newTx;
451
+ if (onPanningUpdate)
452
+ runOnJS(onPanningUpdate)({
453
+ x: translateX.value,
454
+ y: translateY.value,
455
+ translationX: event.translationX,
456
+ translationY: event.translationY,
457
+ velocityX: event.velocityX,
458
+ velocityY: event.velocityY
459
+ });
460
+ return;
461
+ }
462
+ else {
463
+ accumulatedOverflow.value = 0;
464
+ }
465
+ }
466
+ // Apply rubber band to both axes (handles local panning even if gallery swipe is enabled)
467
+ const rubber = applyRubberBandTranslation(newTx, newTy, scale.value);
468
+ translateX.value = rubber.x;
469
+ if (!disableVerticalPan)
470
+ translateY.value = rubber.y;
471
+ virtualTranslationX.value = event.translationX;
472
+ virtualTranslationY.value = event.translationY;
473
+ if (parentAnimatedScrollRef && scale.value > 1) {
474
+ scrollTo(parentAnimatedScrollRef, 0, startScrollY.value - event.translationY, false);
475
+ }
476
+ })
477
+ .onEnd((event) => {
478
+ 'worklet';
479
+ updateZoomGestureLastTime();
480
+ isPanning.value = false;
481
+ const currentScale = scale.value;
482
+ const bounds = getTranslateBounds(currentScale);
483
+ // Handle snap for parent scroll (Apple Photos behavior)
484
+ if (enableGallerySwipe && hasParentScroll && accumulatedOverflow.value !== 0) {
485
+ const overflow = accumulatedOverflow.value;
486
+ const velocity = event.velocityX;
487
+ const snapThreshold = itemWidth * 0.3; // 30% of item width
488
+ // Determine if we should snap to next/prev or back to current
489
+ // Snap to next/prev if: overflow > threshold OR high velocity in same direction
490
+ const shouldSnapToNext = overflow < -snapThreshold || (overflow < 0 && velocity < -500);
491
+ const shouldSnapToPrev = overflow > snapThreshold || (overflow > 0 && velocity > 500);
492
+ if (shouldSnapToNext) {
493
+ // Snap to next image - scroll to next index
494
+ const nextOffset = (currentIndex + 1) * itemWidth;
495
+ runOnJS(scrollParent)(nextOffset, true);
496
+ // Reset zoom after snap animation completes
497
+ runOnJS(resetZoomDelayed)(300);
498
+ }
499
+ else if (shouldSnapToPrev) {
500
+ // Snap to previous image - scroll to prev index
501
+ const prevOffset = (currentIndex - 1) * itemWidth;
502
+ runOnJS(scrollParent)(prevOffset, true);
503
+ // Reset zoom after snap animation completes
504
+ runOnJS(resetZoomDelayed)(300);
505
+ }
506
+ else {
507
+ // Snap back to current image
508
+ const currentOffset = currentIndex * itemWidth;
509
+ runOnJS(scrollParent)(currentOffset, true);
510
+ }
511
+ accumulatedOverflow.value = 0;
512
+ return;
513
+ }
514
+ // Check if we're outside bounds
515
+ const currentTx = translateX.value;
516
+ const currentTy = translateY.value;
517
+ const isOutOfBoundsX = currentTx < -bounds.maxX || currentTx > bounds.maxX;
518
+ const isOutOfBoundsY = currentTy < -bounds.maxY || currentTy > bounds.maxY;
519
+ if (isOutOfBoundsX || (isOutOfBoundsY && !disableVerticalPan)) {
520
+ // Spring back to bounds with gentle animation (fix for #51)
521
+ translateX.value = withSpring(clamp(currentTx, -bounds.maxX, bounds.maxX), SPRING_CONFIG);
522
+ if (!disableVerticalPan) {
523
+ translateY.value = withSpring(clamp(currentTy, -bounds.maxY, bounds.maxY), SPRING_CONFIG);
524
+ }
525
+ }
526
+ else if (!disableVerticalPan) {
527
+ // Apply momentum with clamping (decay with rubber band)
528
+ translateX.value = withDecay({
529
+ velocity: event.velocityX,
530
+ clamp: [-bounds.maxX, bounds.maxX],
531
+ rubberBandEffect: true,
532
+ rubberBandFactor: 0.6,
533
+ });
534
+ translateY.value = withDecay({
535
+ velocity: event.velocityY,
536
+ clamp: [-bounds.maxY, bounds.maxY],
537
+ rubberBandEffect: true,
538
+ rubberBandFactor: 0.6,
539
+ });
540
+ }
541
+ else {
542
+ // Vertical momentum for parent scroll
543
+ virtualTranslationY.value = withDecay({
544
+ velocity: event.velocityY,
545
+ });
546
+ translateX.value = withDecay({
547
+ velocity: event.velocityX,
548
+ clamp: [-bounds.maxX, bounds.maxX],
549
+ rubberBandEffect: true,
550
+ rubberBandFactor: 0.6,
551
+ });
552
+ }
553
+ // Update saved values
554
+ savedTranslateX.value = clamp(savedTranslateX.value + event.translationX, -bounds.maxX, bounds.maxX);
555
+ savedTranslateY.value = clamp(savedTranslateY.value + event.translationY, -bounds.maxY, bounds.maxY);
556
+ if (onPanningEnd)
557
+ runOnJS(onPanningEnd)();
558
+ })
559
+ .onTouchesCancelled(() => {
560
+ 'worklet';
561
+ isPanning.value = false;
562
+ })
563
+ .minDistance(0)
564
+ .minPointers(1)
565
+ .maxPointers(2);
566
+ // ========== PINCH GESTURE ==========
567
+ // Apple Photos: dynamic focal point tracking during pinch
568
+ const pinchGesture = Gesture.Pinch()
569
+ .onTouchesDown((e, state) => {
570
+ 'worklet';
571
+ // Immediately activate pinch when 2 fingers touch
572
+ // This prevents horizontal FlatList from stealing the gesture on Android
573
+ if (e.numberOfTouches === 2)
574
+ state.activate();
575
+ })
576
+ .onStart((event) => {
577
+ 'worklet';
578
+ updateZoomGestureLastTime();
579
+ isPinching.value = true;
580
+ // Save current state
581
+ savedScale.value = scale.value;
582
+ savedTranslateX.value = translateX.value;
583
+ savedTranslateY.value = translateY.value;
584
+ // Save initial focal point
585
+ pinchFocalX.value = event.focalX;
586
+ pinchFocalY.value = event.focalY;
587
+ if (onPinchingStarted)
588
+ runOnJS(onPinchingStarted)();
589
+ })
590
+ .onUpdate((event) => {
591
+ 'worklet';
592
+ const container = containerDimensions.value;
593
+ const centerX = container.width / 2;
594
+ const centerY = container.height / 2;
595
+ // New scale with rubber band limits
596
+ let newScale = savedScale.value * event.scale;
597
+ // Apply rubber band to scale
598
+ if (newScale < minScale) {
599
+ // Rubber band for zoom out below minScale
600
+ const overZoom = minScale - newScale;
601
+ newScale = minScale - overZoom * RUBBER_BAND_FACTOR;
602
+ newScale = Math.max(newScale, minScale * MIN_OVER_SCALE);
603
+ }
604
+ else if (newScale > maxScale) {
605
+ // Rubber band for zoom in above max
606
+ const overZoom = newScale - maxScale;
607
+ newScale = maxScale + overZoom * RUBBER_BAND_FACTOR;
608
+ newScale = Math.min(newScale, maxScale * 1.5);
609
+ }
610
+ // Dynamic focal point - Apple Photos updates focal point during gesture
611
+ // This makes the gesture feel more natural when fingers move
612
+ const currentFocalX = event.focalX;
613
+ const currentFocalY = event.focalY;
614
+ // Blend between initial and current focal point
615
+ // This creates smoother behavior than pure dynamic tracking
616
+ const focalBlend = 0.3; // 30% tracking of finger movement
617
+ const effectiveFocalX = pinchFocalX.value + (currentFocalX - pinchFocalX.value) * focalBlend;
618
+ const effectiveFocalY = pinchFocalY.value + (currentFocalY - pinchFocalY.value) * focalBlend;
619
+ // Focal point offset from container center
620
+ const focalOffsetX = effectiveFocalX - centerX;
621
+ const focalOffsetY = effectiveFocalY - centerY;
622
+ // Apple Photos focal point algorithm
623
+ const scaleRatio = newScale / savedScale.value;
624
+ const newTx = focalOffsetX * (1 - scaleRatio) + savedTranslateX.value * scaleRatio;
625
+ const newTy = focalOffsetY * (1 - scaleRatio) + savedTranslateY.value * scaleRatio;
626
+ // Apply directly (rubber band already applied to scale)
627
+ scale.value = newScale;
628
+ translateX.value = newTx;
629
+ translateY.value = newTy;
630
+ })
631
+ .onEnd(() => {
632
+ 'worklet';
633
+ updateZoomGestureLastTime();
634
+ isPinching.value = false;
635
+ // Check previous zoom state before applying constraints
636
+ const wasZoomed = isZoomedIn.value;
637
+ // Apply boundary constraints with spring animation
638
+ applyBoundaryConstraints(scale.value, true);
639
+ // Update isZoomedIn state
640
+ const finalScale = clamp(scale.value, minScale, maxScale);
641
+ const isNowZoomed = finalScale > minScale;
642
+ if (wasZoomed !== isNowZoomed)
643
+ isZoomedIn.value = isNowZoomed;
644
+ if (onPinchingStopped)
645
+ runOnJS(onPinchingStopped)();
646
+ });
647
+ return Gesture.Simultaneous(tapGesture, panGesture, pinchGesture);
648
+ }, [
649
+ updateZoomGestureLastTime,
650
+ onDoubleTap,
651
+ scale,
652
+ translateX,
653
+ translateY,
654
+ savedScale,
655
+ savedTranslateX,
656
+ savedTranslateY,
657
+ pinchFocalX,
658
+ pinchFocalY,
659
+ containerDimensions,
660
+ isPinching,
661
+ isPanning,
662
+ getTranslateBounds,
663
+ applyBoundaryConstraints,
664
+ applyRubberBandTranslation,
665
+ minScale,
666
+ maxScale,
667
+ isZoomedIn,
668
+ enableGallerySwipe,
669
+ isAtLeftEdge,
670
+ isAtRightEdge,
671
+ panStartX,
672
+ hasParentScroll,
673
+ currentIndex,
674
+ itemWidth,
675
+ scrollParent,
676
+ accumulatedOverflow,
677
+ resetZoomDelayed,
678
+ onPanningUpdate,
679
+ onPanningStart,
680
+ onPanningEnd,
681
+ disableVerticalPan,
682
+ parentAnimatedScrollRef,
683
+ ]);
684
+ // Handle momentum scrolling on UI thread
685
+ useAnimatedReaction(() => virtualTranslationY.value, (val) => {
686
+ if (parentAnimatedScrollRef && scale.value > 1 && !isPanning.value) {
687
+ scrollTo(parentAnimatedScrollRef, 0, startScrollY.value - val, false);
688
+ }
689
+ });
690
+ // ============== ANIMATED STYLE ==============
691
+ // Transform order: translate first, then scale
692
+ // This means scale happens around the center of the View
693
+ //
694
+ // Apple Photos rendering approach:
695
+ // - Use exact floating-point values for smooth animations
696
+ // - The content View should have explicit dimensions matching aspect ratio
697
+ // - overflow: hidden on container clips any subpixel overflow
698
+ const contentContainerAnimatedStyle = useAnimatedStyle(() => ({
699
+ transform: [
700
+ { translateX: translateX.value },
701
+ { translateY: translateY.value },
702
+ { scale: scale.value },
703
+ ],
704
+ }));
705
+ return {
706
+ zoomGesture,
707
+ contentContainerAnimatedStyle,
708
+ onLayout,
709
+ onLayoutContent,
710
+ zoomOut: () => {
711
+ 'worklet';
712
+ zoomOut();
713
+ },
714
+ isZoomedIn,
715
+ zoomGestureLastTime,
716
+ scale,
717
+ virtualTranslationX,
718
+ virtualTranslationY,
719
+ translateX,
720
+ translateY,
721
+ onScroll: (e) => {
722
+ 'worklet';
723
+ currentScrollY.value = e.nativeEvent.contentOffset.y;
724
+ },
725
+ };
726
+ }
727
+ /**
728
+ * Zoom component that provides pinch, pan, and double-tap gestures for zooming content
729
+ * Implements Apple Photos-style zoom behavior
730
+ *
731
+ * @example
732
+ * ```tsx
733
+ * <Zoom
734
+ * doubleTapConfig={{
735
+ * defaultScale: 2,
736
+ * minZoomScale: 1,
737
+ * maxZoomScale: 5,
738
+ * }}
739
+ * >
740
+ * <Image source={{ uri: 'https://example.com/image.jpg' }} />
741
+ * </Zoom>
742
+ * ```
743
+ */
744
+ export default function Zoom(props) {
745
+ const { style, contentContainerStyle, children, onZoomChange, onZoomStateChange, onPanningUpdate, onPanningStart, onPanningEnd, disableVerticalPan, ...rest } = props;
746
+ const { zoomGesture, onLayout, onLayoutContent, contentContainerAnimatedStyle, scale, isZoomedIn, virtualTranslationX, virtualTranslationY, translateX: transX, translateY: transY, } = useZoomGesture({ ...rest, onPanningUpdate, onPanningStart, onPanningEnd, disableVerticalPan });
747
+ // Bridge panning updates to JS callback
748
+ useAnimatedReaction(() => ({
749
+ tx: virtualTranslationX.value,
750
+ ty: virtualTranslationY.value,
751
+ }), (current, previous) => {
752
+ if (onPanningUpdate && (current.tx !== previous?.tx || current.ty !== previous?.ty)) {
753
+ runOnJS(onPanningUpdate)({
754
+ x: transX.value,
755
+ y: transY.value,
756
+ translationX: current.tx,
757
+ translationY: current.ty,
758
+ velocityX: 0,
759
+ velocityY: 0,
760
+ });
761
+ }
762
+ }, [onPanningUpdate, transX, transY]);
763
+ // Bridge scale changes to JS callback if provided
764
+ useAnimatedReaction(() => scale.value, (currentScale, previousScale) => {
765
+ if (onZoomChange && currentScale !== previousScale)
766
+ runOnJS(onZoomChange)(currentScale);
767
+ }, [onZoomChange]);
768
+ // Bridge zoom state changes to JS callback if provided
769
+ useAnimatedReaction(() => isZoomedIn.value, (currentIsZoomed, previousIsZoomed) => {
770
+ if (onZoomStateChange && currentIsZoomed !== previousIsZoomed)
771
+ runOnJS(onZoomStateChange)(currentIsZoomed);
772
+ }, [onZoomStateChange]);
773
+ return (<GestureHandlerRootView style={[styles.container, style]}>
774
+ <GestureDetector gesture={zoomGesture}>
775
+ <View style={styles.container} onLayout={onLayout} collapsable={false}>
776
+ <Animated.View style={[contentContainerAnimatedStyle, contentContainerStyle]} onLayout={onLayoutContent}>
777
+ {children}
778
+ </Animated.View>
779
+ </View>
780
+ </GestureDetector>
781
+ </GestureHandlerRootView>);
782
+ }
783
+ //# sourceMappingURL=index.js.map