diff --git a/apps/docs/content/docs/components/image-viewer.mdx b/apps/docs/content/docs/components/image-viewer.mdx
index 3d9fbdcf..a3c78512 100644
--- a/apps/docs/content/docs/components/image-viewer.mdx
+++ b/apps/docs/content/docs/components/image-viewer.mdx
@@ -114,6 +114,26 @@ Several triggers under one root. Opening any of them opens at its page, and swip
```
+### Right to left
+
+`dir` sets which way the pages run. In `rtl` the next picture sits to the left and a swipe to the right turns to it. The counter, `index` and the screen reader's next and previous still count in page order, and `dir` defaults to `ltr`. Lay the triggers out the same way round: in an app laid out left to right, `flex-row-reverse` puts the first picture on the right, as here. Under a right-to-left layout rows already run that way, so keep `flex-row` and pass `dir={useDirection()}`.
+
+```tsx
+
+
+ {photos.map((photo) => (
+
+ ))}
+
+
+```
+
### Captions and a larger copy
`caption` is shown under the picture while it is open; a single tap on the picture hides it along with the close button. `fullSource` loads when the viewer opens and replaces `source` once it arrives, so the page can carry small thumbnails.
@@ -239,6 +259,7 @@ A portrait, a landscape and a panorama, each fitted whole to the screen from a t
| `closeLabel` | `string` | `'Close'` | Read out for the close button. |
| `inset` | `number` | `16` | Space kept between the open picture and the edges of the screen, in points. Measured from the safe area at the top and bottom, and the larger of the two is used for both, so the picture stays centred. `0` fills the screen. |
| `cornerRadius` | `number` | `16` | Corner radius of the open picture, in points. The corners stay this size on screen while the picture is zoomed or dragged. `0` squares them. |
+| `dir` | `'ltr' \| 'rtl'` | `'ltr'` | Which way the gallery runs. In `rtl` the next page sits to the left and a swipe to the right turns to it. Page numbers, `index` and the screen reader's next and previous stay in page order either way. To follow the reading direction, pass `useDirection()`. |
### ImageViewer.Trigger
diff --git a/apps/docs/public/r/image-viewer.json b/apps/docs/public/r/image-viewer.json
index 541828fd..3146a738 100644
--- a/apps/docs/public/r/image-viewer.json
+++ b/apps/docs/public/r/image-viewer.json
@@ -24,12 +24,12 @@
{
"path": "ui/image-viewer-geometry.ts",
"type": "registry:ui",
- "content": "/**\n * The arithmetic behind the image viewer, kept apart from the component.\n *\n * Every function here runs on the UI thread inside a gesture or an animated\n * style, so each is a worklet. None of them touches React Native, which is what\n * lets the contract tests run them in Node.\n */\n\nexport interface Size {\n width: number;\n height: number;\n}\n\nexport interface Rect extends Size {\n x: number;\n y: number;\n}\n\n/** How much a rubber-banded overshoot gives. Lower is stiffer. */\nexport const RUBBER_BAND = 0.55;\n\nexport function clamp(value: number, min: number, max: number): number {\n 'worklet';\n return Math.min(Math.max(value, min), max);\n}\n\nexport function lerp(from: number, to: number, t: number): number {\n 'worklet';\n return from + (to - from) * t;\n}\n\nexport function lerpRect(from: Rect, to: Rect, t: number): Rect {\n 'worklet';\n return {\n x: lerp(from.x, to.x, t),\n y: lerp(from.y, to.y, t),\n width: lerp(from.width, to.width, t),\n height: lerp(from.height, to.height, t),\n };\n}\n\n/**\n * The largest rect with the image's proportions that fits inside the box,\n * centred in it. An image with no known size fills the box.\n */\nexport function fitContain(image: Size, box: Size): Rect {\n 'worklet';\n if (image.width <= 0 || image.height <= 0) {\n return { x: 0, y: 0, width: box.width, height: box.height };\n }\n const scale = Math.min(box.width / image.width, box.height / image.height);\n const width = image.width * scale;\n const height = image.height * scale;\n return { x: (box.width - width) / 2, y: (box.height - height) / 2, width, height };\n}\n\n/** `fitContain` inside a rect that does not start at the origin. */\nexport function fitWithin(image: Size, box: Rect): Rect {\n 'worklet';\n const fitted = fitContain(image, box);\n return { ...fitted, x: box.x + fitted.x, y: box.y + fitted.y };\n}\n\n/**\n * The smallest size with the image's proportions that covers the frame.\n *\n * A thumbnail crops its picture to its box; the viewer shows the whole of it.\n * Drawing the image at this size inside a frame that grows from one to the\n * other is what makes the crop open out instead of the picture jumping.\n */\nexport function coverSize(image: Size, frame: Size): Size {\n 'worklet';\n if (image.width <= 0 || image.height <= 0) {\n return { width: frame.width, height: frame.height };\n }\n const scale = Math.max(frame.width / image.width, frame.height / image.height);\n return { width: image.width * scale, height: image.height * scale };\n}\n\n/**\n * How far past its limit a value is allowed to travel, given how far it was\n * pushed. The resistance grows with the push and never quite reaches `dimension`.\n */\nexport function rubberBand(overshoot: number, dimension: number, coefficient?: number): number {\n 'worklet';\n if (dimension <= 0) return 0;\n const give = coefficient ?? RUBBER_BAND;\n return (1 - 1 / ((overshoot * give) / dimension + 1)) * dimension;\n}\n\n/** Clamps to `[min, max]`, letting the value stretch past either end with resistance. */\nexport function rubberBandClamp(\n value: number,\n min: number,\n max: number,\n dimension: number,\n coefficient?: number\n): number {\n 'worklet';\n const clamped = clamp(value, min, max);\n const overshoot = Math.abs(value - clamped);\n if (overshoot === 0) return clamped;\n const sign = value < clamped ? -1 : 1;\n return clamped + sign * rubberBand(overshoot, dimension, coefficient);\n}\n\n/**\n * How far a zoomed image may be moved along one axis before its edge comes away\n * from the screen's. Zero while the scaled image is smaller than the screen,\n * which keeps it centred on that axis.\n */\nexport function panBound(fitted: number, scale: number, viewport: number): number {\n 'worklet';\n return Math.max(0, (fitted * scale - viewport) / 2);\n}\n\n/**\n * The translation that keeps a point under the fingers while the scale changes.\n *\n * `offset` is where that point sat relative to the image's centre when the\n * pinch began, at `fromScale`; `focal` is the fingers' position now, relative\n * to the screen's centre. The result is measured from the screen's centre too.\n */\nexport function focalTranslation(\n focal: number,\n offset: number,\n fromScale: number,\n toScale: number\n): number {\n 'worklet';\n if (fromScale === 0) return focal;\n return focal - offset * (toScale / fromScale);\n}\n\n/**\n * The page a released pager settles on. Where it would have coasted to decides\n * it rather than where it was let go, so a short flick still turns the page —\n * but never by more than one, which is what a flick means.\n */\nexport function snapPage(\n offset: number,\n velocity: number,\n current: number,\n count: number,\n pageWidth: number\n): number {\n 'worklet';\n if (count <= 1 || pageWidth <= 0) return 0;\n const projected = -(offset + velocity * 0.25) / pageWidth;\n const target = Math.round(projected);\n return clamp(clamp(target, current - 1, current + 1), 0, count - 1);\n}\n\n/** How far a drag has to travel, counting where it was flung, to close the viewer. */\nexport const DISMISS_DISTANCE = 110;\n\n/**\n * Whether a released drag closes the viewer.\n *\n * Measured on where the image was heading rather than where it was dropped, so\n * a quick flick closes it from a short distance and a slow drag that is let go\n * of carefully does not. A throw back towards the centre cancels.\n */\nexport function shouldDismiss(translation: number, velocity: number): boolean {\n 'worklet';\n const projected = translation + velocity * 0.2;\n if (Math.abs(projected) <= DISMISS_DISTANCE) return false;\n return translation === 0 || Math.sign(projected) === Math.sign(translation);\n}\n\n/**\n * How much of the backdrop is left while the image is dragged away from the\n * centre. It is mostly gone by the time the drag would close the viewer, so the\n * page coming back into focus is the warning that letting go will close it.\n */\nexport function dragFade(distance: number, viewport: number): number {\n 'worklet';\n if (viewport <= 0) return 1;\n return 1 - clamp(Math.abs(distance) / (viewport * 0.4), 0, 1) * 0.85;\n}\n\n/** How small the image gets while it is dragged. Never below 0.8 of its size. */\nexport function dragScale(distance: number, viewport: number): number {\n 'worklet';\n if (viewport <= 0) return 1;\n return 1 - clamp(Math.abs(distance) / viewport, 0, 1) * 0.2;\n}\n\n/** Whether a point lands on a rect that has been scaled about its centre and moved. */\nexport function hitsRect(\n px: number,\n py: number,\n rect: Rect,\n scale: number,\n tx: number,\n ty: number\n): boolean {\n 'worklet';\n const cx = rect.x + rect.width / 2 + tx;\n const cy = rect.y + rect.height / 2 + ty;\n return (\n Math.abs(px - cx) <= (rect.width * scale) / 2 &&\n Math.abs(py - cy) <= (rect.height * scale) / 2\n );\n}\n"
+ "content": "/**\n * The arithmetic behind the image viewer, kept apart from the component.\n *\n * Every function here runs on the UI thread inside a gesture or an animated\n * style, so each is a worklet. None of them touches React Native, which is what\n * lets the contract tests run them in Node.\n */\n\nexport interface Size {\n width: number;\n height: number;\n}\n\nexport interface Rect extends Size {\n x: number;\n y: number;\n}\n\n/** How much a rubber-banded overshoot gives. Lower is stiffer. */\nexport const RUBBER_BAND = 0.55;\n\nexport function clamp(value: number, min: number, max: number): number {\n 'worklet';\n return Math.min(Math.max(value, min), max);\n}\n\nexport function lerp(from: number, to: number, t: number): number {\n 'worklet';\n return from + (to - from) * t;\n}\n\nexport function lerpRect(from: Rect, to: Rect, t: number): Rect {\n 'worklet';\n return {\n x: lerp(from.x, to.x, t),\n y: lerp(from.y, to.y, t),\n width: lerp(from.width, to.width, t),\n height: lerp(from.height, to.height, t),\n };\n}\n\n/**\n * The largest rect with the image's proportions that fits inside the box,\n * centred in it. An image with no known size fills the box.\n */\nexport function fitContain(image: Size, box: Size): Rect {\n 'worklet';\n if (image.width <= 0 || image.height <= 0) {\n return { x: 0, y: 0, width: box.width, height: box.height };\n }\n const scale = Math.min(box.width / image.width, box.height / image.height);\n const width = image.width * scale;\n const height = image.height * scale;\n return { x: (box.width - width) / 2, y: (box.height - height) / 2, width, height };\n}\n\n/** `fitContain` inside a rect that does not start at the origin. */\nexport function fitWithin(image: Size, box: Rect): Rect {\n 'worklet';\n const fitted = fitContain(image, box);\n return { ...fitted, x: box.x + fitted.x, y: box.y + fitted.y };\n}\n\n/**\n * The smallest size with the image's proportions that covers the frame.\n *\n * A thumbnail crops its picture to its box; the viewer shows the whole of it.\n * Drawing the image at this size inside a frame that grows from one to the\n * other is what makes the crop open out instead of the picture jumping.\n */\nexport function coverSize(image: Size, frame: Size): Size {\n 'worklet';\n if (image.width <= 0 || image.height <= 0) {\n return { width: frame.width, height: frame.height };\n }\n const scale = Math.max(frame.width / image.width, frame.height / image.height);\n return { width: image.width * scale, height: image.height * scale };\n}\n\n/**\n * How far past its limit a value is allowed to travel, given how far it was\n * pushed. The resistance grows with the push and never quite reaches `dimension`.\n */\nexport function rubberBand(overshoot: number, dimension: number, coefficient?: number): number {\n 'worklet';\n if (dimension <= 0) return 0;\n const give = coefficient ?? RUBBER_BAND;\n return (1 - 1 / ((overshoot * give) / dimension + 1)) * dimension;\n}\n\n/** Clamps to `[min, max]`, letting the value stretch past either end with resistance. */\nexport function rubberBandClamp(\n value: number,\n min: number,\n max: number,\n dimension: number,\n coefficient?: number\n): number {\n 'worklet';\n const clamped = clamp(value, min, max);\n const overshoot = Math.abs(value - clamped);\n if (overshoot === 0) return clamped;\n const sign = value < clamped ? -1 : 1;\n return clamped + sign * rubberBand(overshoot, dimension, coefficient);\n}\n\n/**\n * How far a zoomed image may be moved along one axis before its edge comes away\n * from the screen's. Zero while the scaled image is smaller than the screen,\n * which keeps it centred on that axis.\n */\nexport function panBound(fitted: number, scale: number, viewport: number): number {\n 'worklet';\n return Math.max(0, (fitted * scale - viewport) / 2);\n}\n\n/**\n * The translation that keeps a point under the fingers while the scale changes.\n *\n * `offset` is where that point sat relative to the image's centre when the\n * pinch began, at `fromScale`; `focal` is the fingers' position now, relative\n * to the screen's centre. The result is measured from the screen's centre too.\n */\nexport function focalTranslation(\n focal: number,\n offset: number,\n fromScale: number,\n toScale: number\n): number {\n 'worklet';\n if (fromScale === 0) return focal;\n return focal - offset * (toScale / fromScale);\n}\n\n/**\n * The page a released pager settles on. Where it would have coasted to decides\n * it rather than where it was let go, so a short flick still turns the page —\n * but never by more than one, which is what a flick means.\n */\nexport function snapPage(\n offset: number,\n velocity: number,\n current: number,\n count: number,\n pageWidth: number\n): number {\n 'worklet';\n if (count <= 1 || pageWidth <= 0) return 0;\n const projected = -(offset + velocity * 0.25) / pageWidth;\n const target = Math.round(projected);\n return clamp(clamp(target, current - 1, current + 1), 0, count - 1);\n}\n\n/** What a one-finger drag on a picture at its fitted size is for. */\nexport type DragIntent = 'undecided' | 'page' | 'dismiss';\n\n/**\n * Sideways across a gallery turns the page; anything else drags the picture\n * away. A drag that has not moved yet is `undecided`: some gesture-handler\n * versions report the activating event with no translation at all, and\n * comparing zero with zero would take every swipe as a dismiss.\n */\nexport function dragIntent(translationX: number, translationY: number, count: number): DragIntent {\n 'worklet';\n if (translationX === 0 && translationY === 0) return 'undecided';\n return count > 1 && Math.abs(translationX) > Math.abs(translationY) ? 'page' : 'dismiss';\n}\n\n/** How far a drag has to travel, counting where it was flung, to close the viewer. */\nexport const DISMISS_DISTANCE = 110;\n\n/**\n * Whether a released drag closes the viewer.\n *\n * Measured on where the image was heading rather than where it was dropped, so\n * a quick flick closes it from a short distance and a slow drag that is let go\n * of carefully does not. A throw back towards the centre cancels.\n */\nexport function shouldDismiss(translation: number, velocity: number): boolean {\n 'worklet';\n const projected = translation + velocity * 0.2;\n if (Math.abs(projected) <= DISMISS_DISTANCE) return false;\n return translation === 0 || Math.sign(projected) === Math.sign(translation);\n}\n\n/**\n * How much of the backdrop is left while the image is dragged away from the\n * centre. It is mostly gone by the time the drag would close the viewer, so the\n * page coming back into focus is the warning that letting go will close it.\n */\nexport function dragFade(distance: number, viewport: number): number {\n 'worklet';\n if (viewport <= 0) return 1;\n return 1 - clamp(Math.abs(distance) / (viewport * 0.4), 0, 1) * 0.85;\n}\n\n/** How small the image gets while it is dragged. Never below 0.8 of its size. */\nexport function dragScale(distance: number, viewport: number): number {\n 'worklet';\n if (viewport <= 0) return 1;\n return 1 - clamp(Math.abs(distance) / viewport, 0, 1) * 0.2;\n}\n\n/** Whether a point lands on a rect that has been scaled about its centre and moved. */\nexport function hitsRect(\n px: number,\n py: number,\n rect: Rect,\n scale: number,\n tx: number,\n ty: number\n): boolean {\n 'worklet';\n const cx = rect.x + rect.width / 2 + tx;\n const cy = rect.y + rect.height / 2 + ty;\n return (\n Math.abs(px - cx) <= (rect.width * scale) / 2 &&\n Math.abs(py - cy) <= (rect.height * scale) / 2\n );\n}\n"
},
{
"path": "ui/image-viewer.tsx",
"type": "registry:ui",
- "content": "/**\n * ImageViewer — a picture that opens out of the page to fill the screen.\n *\n * Pressing a trigger lifts its image from where it sits, grows it to the whole\n * picture in the middle of the screen, and blurs the page behind it. From there\n * it can be pinched and double-tapped to zoom, swiped sideways to the next\n * image under the same root, and dragged away to put it back.\n *\n * ```tsx\n * \n * {photos.map((photo) => (\n * \n * ))}\n * \n * ```\n *\n * ## The image travels; nothing appears\n *\n * The viewer never fades a second copy of the picture in over the first. The\n * thumbnail is hidden and the same image is drawn in a frame that starts at the\n * thumbnail's exact rectangle and corner radius and ends at the fitted one. The\n * picture inside that frame is always drawn at the size that *covers* it, so the\n * crop a thumbnail shows opens out into the whole image as the frame grows — and\n * closes back into the crop on the way home. That continuity is the point: the\n * picture on screen is the one that was pressed, not a picture of it.\n *\n * The frame animates its bounds rather than a transform, because a transform\n * cannot change a crop. It is one view with one child, so the layout pass it\n * costs per frame is small.\n *\n * ## Every trigger under one root is a page\n *\n * Triggers register themselves with the root as they mount, in render order or\n * by an explicit `index`. Opening one opens the gallery at its page, and the\n * thumbnail hidden underneath follows the page — close on the third image and it\n * flies back into the third thumbnail.\n *\n * ## One gesture surface, decided at the start\n *\n * Pinching, panning a zoomed image, turning the page and dragging to dismiss all\n * begin as a one- or two-finger drag, and which one it is gets decided the\n * moment it starts: two fingers zoom, one finger on a zoomed image pans it, one\n * finger moving sideways across a gallery turns the page, and anything else\n * drags the image away. Deciding once keeps a drag from changing its mind\n * halfway, which is what makes a viewer feel loose.\n */\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from 'react';\nimport {\n Image,\n Pressable,\n StyleSheet,\n View,\n useWindowDimensions,\n type AccessibilityActionEvent,\n type ImageSourcePropType,\n type LayoutChangeEvent,\n type PressableProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n cancelAnimation,\n runOnJS,\n runOnUI,\n useAnimatedProps,\n useAnimatedStyle,\n useDerivedValue,\n useReducedMotion,\n useSharedValue,\n withDecay,\n withDelay,\n withSpring,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\nimport { useCSSVariable } from 'uniwind';\nimport { XIcon } from '@/components/ui/icons';\nimport { useControllableState } from '@/components/ui/controllable-state';\nimport { ModalPortal } from '@/components/ui/portal';\nimport { Scrim } from '@/components/ui/scrim';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { useBackHandler } from '@/hooks/use-back-handler';\nimport { useThemeMode } from '@/hooks/use-theme';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport {\n clamp,\n coverSize,\n dragFade,\n dragScale,\n fitWithin,\n focalTranslation,\n hitsRect,\n lerp,\n lerpRect,\n panBound,\n rubberBandClamp,\n shouldDismiss,\n snapPage,\n type Rect,\n type Size,\n} from '@/components/ui/image-viewer-geometry';\n\n/** Space between two pages of a gallery, so a page turn reads as two pictures. */\nconst PAGE_GAP = 16;\n\n/**\n * Out of the page. Just short of critically damped: enough give that the\n * picture seems to arrive rather than stop, not enough to wobble.\n */\nconst OPEN_SPRING = { damping: 30, stiffness: 260, mass: 1 } as const;\n\n/**\n * Back into the page. Clamped, because a frame that overshoots its thumbnail\n * shrinks past it and grows back — a visible bounce at the one moment the image\n * is supposed to be settling into place.\n */\nconst CLOSE_SPRING = { damping: 32, stiffness: 300, mass: 1, overshootClamping: true } as const;\n\n/** Zoom settling and page turns. Firm, so a flick lands without drifting. */\nconst SETTLE_SPRING = { damping: 34, stiffness: 320, mass: 1 } as const;\n\n/**\n * Margin between the open picture and the screen, in points. Enough to show the\n * blur on all four sides, so the picture reads as held up over the page rather\n * than as a new screen that happens to be a photograph.\n */\nconst DEFAULT_INSET = 16;\n\n/** Corners of the open picture, matching the library's large surfaces. */\nconst DEFAULT_CORNER_RADIUS = 16;\n\n/** How strongly the blur comes up behind the picture. */\nconst BLUR_INTENSITY = 50;\n\nconst PHASE_OPENING = 0;\nconst PHASE_OPEN = 1;\nconst PHASE_CLOSING = 2;\n\nconst MODE_NONE = 0;\nconst MODE_PAN = 1;\nconst MODE_PAGE = 2;\nconst MODE_DISMISS = 3;\n\n/* -------------------------------------------------------------------------- */\n/* Registry */\n/* -------------------------------------------------------------------------- */\n\ninterface ViewerItem {\n id: string;\n order: number;\n index?: number;\n source: ImageSourcePropType;\n fullSource?: ImageSourcePropType;\n alt?: string;\n caption?: ReactNode;\n radius: number;\n size: Size | null;\n measure: (done: (rect: Rect | null) => void) => void;\n}\n\n/**\n * The triggers under one root, in page order.\n *\n * An external store rather than state on the root, because a trigger updating\n * its caption should re-render the open viewer and nothing else — root state\n * would re-render every trigger, which would register again, which would\n * re-render the root.\n */\nclass ViewerStore {\n private items = new Map();\n private listeners = new Set<() => void>();\n private counter = 0;\n private version = 0;\n private sorted: ViewerItem[] = [];\n\n nextOrder = () => this.counter++;\n\n set = (item: ViewerItem) => {\n this.items.set(item.id, item);\n this.emit();\n };\n\n delete = (id: string) => {\n if (this.items.delete(id)) this.emit();\n };\n\n list = (): ViewerItem[] => this.sorted;\n\n subscribe = (listener: () => void) => {\n this.listeners.add(listener);\n return () => {\n this.listeners.delete(listener);\n };\n };\n\n getSnapshot = () => this.version;\n\n private emit() {\n this.version += 1;\n this.sorted = [...this.items.values()].sort(\n (a, b) => (a.index ?? a.order) - (b.index ?? b.order)\n );\n this.listeners.forEach((listener) => listener());\n }\n}\n\ninterface ViewerContextValue {\n store: ViewerStore;\n /** The trigger whose picture is out on the screen, and so hidden in the page. */\n hiddenId: string | null;\n openItem: (id: string) => void;\n}\n\nconst ViewerContext = createContext(null);\n\nfunction useViewer(part: string): ViewerContextValue {\n const context = useContext(ViewerContext);\n if (!context) throw new Error(`${part} must be used inside .`);\n return context;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root */\n/* -------------------------------------------------------------------------- */\n\nexport interface ImageViewerProps {\n children?: ReactNode;\n /** Controlled open state. */\n open?: boolean;\n /** Initial open state when uncontrolled. */\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Controlled page — which trigger's image is showing, in page order. */\n index?: number;\n /** Initial page when uncontrolled. */\n defaultIndex?: number;\n onIndexChange?: (index: number) => void;\n /**\n * Blur the page behind the picture. Needs the optional `expo-blur`, and dims\n * instead without it. Reduce Transparency draws an opaque backdrop.\n */\n blur?: boolean;\n /** How far a pinch can zoom in, as a multiple of the fitted size. */\n maxScale?: number;\n /** Where a double tap zooms to, as a multiple of the fitted size. */\n doubleTapScale?: number;\n /** Tick as a drag to dismiss begins. Needs the optional `expo-haptics`. */\n haptics?: boolean;\n /** Draw the close button. Tapping outside the picture and dragging it away still close it. */\n showClose?: boolean;\n /** Read out for the close button. */\n closeLabel?: string;\n /**\n * Space kept between the open picture and the edges of the screen, in points.\n * Measured from the safe area at the top and bottom, and the larger of the\n * two is used for both, so the picture stays centred. `0` fills the screen.\n */\n inset?: number;\n /**\n * Corner radius of the open picture, in points. The corners stay this size on\n * screen while the picture is zoomed or dragged. `0` squares them.\n */\n cornerRadius?: number;\n}\n\nfunction ImageViewerRoot({\n children,\n open,\n defaultOpen = false,\n onOpenChange,\n index,\n defaultIndex = 0,\n onIndexChange,\n blur = true,\n maxScale = 4,\n doubleTapScale = 2.5,\n haptics = true,\n showClose = true,\n closeLabel = 'Close',\n inset = DEFAULT_INSET,\n cornerRadius = DEFAULT_CORNER_RADIUS,\n}: ImageViewerProps) {\n const storeRef = useRef(null);\n storeRef.current ??= new ViewerStore();\n const store = storeRef.current;\n\n const { value: isOpen, setValue: setOpen } = useControllableState({\n value: open,\n defaultValue: defaultOpen,\n onChange: onOpenChange,\n });\n const { value: page, setValue: setPage } = useControllableState({\n value: index,\n defaultValue: defaultIndex,\n onChange: onIndexChange,\n });\n\n // Mounted from the moment it opens until the picture is back in the page,\n // which is after `open` has already gone false.\n const [mounted, setMounted] = useState(isOpen);\n if (isOpen && !mounted) setMounted(true);\n const [hiddenId, setHiddenId] = useState(null);\n\n const openItem = useCallback(\n (id: string) => {\n const at = store.list().findIndex((item) => item.id === id);\n if (at < 0) return;\n setPage(at);\n setOpen(true);\n },\n [setOpen, setPage, store]\n );\n\n const requestClose = useCallback(() => setOpen(false), [setOpen]);\n const exited = useCallback(() => {\n setMounted(false);\n setHiddenId(null);\n }, []);\n\n useBackHandler(isOpen, requestClose);\n\n const context = useMemo(\n () => ({ store, hiddenId, openItem }),\n [store, hiddenId, openItem]\n );\n\n return (\n \n {children}\n {mounted ? (\n \n \n \n ) : null}\n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Trigger */\n/* -------------------------------------------------------------------------- */\n\nexport interface ImageViewerTriggerProps\n extends Omit {\n /**\n * Classes on the pressable. Without `children` the image fills it, so give it\n * a size — `h-48 w-full`, `aspect-square flex-1`.\n */\n className?: string;\n /** The picture. Shown in the page and, until `fullSource` loads, in the viewer. */\n source: ImageSourcePropType;\n /**\n * A larger copy to show once the viewer is open. It loads when the viewer\n * opens and replaces `source` when it arrives, so a feed can carry thumbnails.\n */\n fullSource?: ImageSourcePropType;\n /** Described for a screen reader, on the trigger and in the viewer. */\n alt?: string;\n /** Shown under the picture while it is open. A string is set as text. */\n caption?: ReactNode;\n /** Page order within the root. Render order when left out. */\n index?: number;\n /**\n * The corner radius the picture has in the page, in points, so the flight\n * starts from the same shape. Match it to the `rounded-*` class you gave the\n * trigger — a class cannot be read back.\n */\n radius?: number;\n /**\n * The image's own width and height, when known. Saves looking it up, and is\n * the only way the viewer knows the proportions before a remote image loads.\n */\n width?: number;\n height?: number;\n /** Nothing opens, and the press is not reported. */\n disabled?: boolean;\n /**\n * Draw the picture yourself — a `Post.Media`, a card. It should show `source`\n * cropped to fill, the way the viewer's own flight starts. Left out, the\n * trigger draws `source` to fill its box.\n */\n children?: ReactNode;\n}\n\n/** The image's proportions from what can be known without loading it. */\nfunction knownSize(source: ImageSourcePropType, width?: number, height?: number): Size | null {\n if (width && height) return { width, height };\n if (typeof source === 'number') {\n const asset = Image.resolveAssetSource(source);\n if (asset?.width && asset?.height) return { width: asset.width, height: asset.height };\n }\n return null;\n}\n\nfunction uriOf(source: ImageSourcePropType): string | undefined {\n if (source && typeof source === 'object' && !Array.isArray(source)) {\n return (source as { uri?: string }).uri;\n }\n return undefined;\n}\n\n/**\n * The thing pressed to open the viewer.\n *\n * It registers its picture with the root, so the gallery is simply every\n * trigger under that root. While its picture is out on the screen it is drawn\n * transparent — not removed, so the page does not reflow under the blur and the\n * flight home has somewhere to land.\n */\nfunction ImageViewerTrigger({\n className,\n source,\n fullSource,\n alt,\n caption,\n index,\n radius = 0,\n width,\n height,\n disabled = false,\n children,\n onPress,\n onLayout,\n ...props\n}: ImageViewerTriggerProps) {\n const { store, hiddenId, openItem } = useViewer('ImageViewer.Trigger');\n const id = useId();\n const ref = useRef(null);\n const orderRef = useRef(null);\n orderRef.current ??= store.nextOrder();\n\n const [size, setSize] = useState(() => knownSize(source, width, height));\n const [box, setBox] = useState(null);\n const uri = uriOf(source);\n\n useEffect(() => {\n const known = knownSize(source, width, height);\n if (known) {\n setSize(known);\n return;\n }\n if (!uri) return;\n let live = true;\n Image.getSize(\n uri,\n (w, h) => {\n if (live) setSize({ width: w, height: h });\n },\n () => {}\n );\n return () => {\n live = false;\n };\n // `source` is compared through its uri; an inline `{ uri }` is new every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [uri, width, height]);\n\n const measure = useCallback((done: (rect: Rect | null) => void) => {\n const node = ref.current;\n if (!node) {\n done(null);\n return;\n }\n node.measureInWindow((x, y, w, h) => {\n done(w > 0 && h > 0 ? { x, y, width: w, height: h } : null);\n });\n }, []);\n\n useLayoutEffect(() => {\n store.set({\n id,\n order: orderRef.current ?? 0,\n index,\n source,\n fullSource,\n alt,\n caption,\n radius,\n // The box's own proportions stand in until the image's are known, which\n // is right for a thumbnail that shows the picture uncropped and close\n // enough for one that does not.\n size: size ?? box,\n measure,\n });\n }, [store, id, index, source, fullSource, alt, caption, radius, size, box, measure]);\n\n useLayoutEffect(() => () => store.delete(id), [store, id]);\n\n const handleLayout = (event: LayoutChangeEvent) => {\n onLayout?.(event);\n const { width: w, height: h } = event.nativeEvent.layout;\n if (w > 0 && h > 0) setBox({ width: w, height: h });\n };\n\n return (\n {\n onPress?.(event);\n openItem(id);\n }}\n onLayout={handleLayout}\n className={cn('overflow-hidden', className)}\n style={[radius ? { borderRadius: radius } : null, { opacity: hiddenId === id ? 0 : 1 }]}\n {...props}\n >\n {children ?? (\n {\n if (size) return;\n const loaded = event.nativeEvent.source;\n if (loaded?.width && loaded?.height) {\n setSize({ width: loaded.width, height: loaded.height });\n }\n }}\n style={StyleSheet.absoluteFill}\n />\n )}\n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Overlay */\n/* -------------------------------------------------------------------------- */\n\ninterface ViewerOverlayProps {\n store: ViewerStore;\n open: boolean;\n index: number;\n onIndexChange: (index: number) => void;\n onRequestClose: () => void;\n onExited: () => void;\n onShow: (id: string | null) => void;\n blur: boolean;\n maxScale: number;\n doubleTapScale: number;\n haptics: boolean;\n showClose: boolean;\n closeLabel: string;\n inset: number;\n cornerRadius: number;\n}\n\nfunction ViewerOverlay({\n store,\n open,\n index,\n onIndexChange,\n onRequestClose,\n onExited,\n onShow,\n blur,\n maxScale,\n doubleTapScale,\n haptics,\n showClose,\n closeLabel,\n inset,\n cornerRadius,\n}: ViewerOverlayProps) {\n const version = useSyncExternalStore(store.subscribe, store.getSnapshot);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n const items = useMemo(() => store.list(), [store, version]);\n const count = items.length;\n const page = count ? clamp(index, 0, count - 1) : 0;\n const item = items[page];\n\n const { width: W, height: H } = useWindowDimensions();\n const insets = useSafeAreaInsets();\n const reduced = useReducedMotion();\n const { mode } = useThemeMode();\n const foreground = useCSSVariable('--color-foreground');\n const pageWidth = W + PAGE_GAP;\n\n /*\n * The area the picture is fitted into. The same margin on both sides of each\n * axis, so the fitted picture is centred on the screen — the zoom and pan\n * arithmetic is measured from the screen's centre and relies on that.\n */\n const box = useMemo(() => {\n const vertical = Math.max(insets.top, insets.bottom) + inset;\n return {\n x: inset,\n y: vertical,\n width: Math.max(W - inset * 2, 1),\n height: Math.max(H - vertical * 2, 1),\n };\n }, [H, W, inset, insets.bottom, insets.top]);\n\n const fit = useMemo(\n () => fitWithin(item?.size ?? box, box),\n [item?.size, box]\n );\n\n // The flight, 0 in the page and 1 on the screen.\n const t = useSharedValue(0);\n const phase = useSharedValue(PHASE_OPENING);\n const origin = useSharedValue(null);\n const radius = useSharedValue(0);\n\n // Zoom of the current page, measured from the screen's centre.\n const scale = useSharedValue(1);\n const tx = useSharedValue(0);\n const ty = useSharedValue(0);\n const startScale = useSharedValue(1);\n const startX = useSharedValue(0);\n const startY = useSharedValue(0);\n const focalOffsetX = useSharedValue(0);\n const focalOffsetY = useSharedValue(0);\n const lastFocalX = useSharedValue(0);\n const lastFocalY = useSharedValue(0);\n const pinching = useSharedValue(false);\n\n // Dragging the picture away.\n const dragX = useSharedValue(0);\n const dragY = useSharedValue(0);\n const shrink = useSharedValue(1);\n const fade = useSharedValue(1);\n\n // The gallery.\n const pagerX = useSharedValue(-page * pageWidth);\n const pagerStart = useSharedValue(0);\n const activePage = useSharedValue(page);\n\n const gestureMode = useSharedValue(MODE_NONE);\n const chrome = useSharedValue(0);\n const chromeOn = useSharedValue(true);\n\n const backdrop = useDerivedValue(() => clamp(t.value, 0, 1) * fade.value);\n\n const itemRef = useRef(item);\n itemRef.current = item;\n\n /**\n * Where the current page's thumbnail is now. Measured again on every page\n * change and before every close, because the flight home has to land where\n * the thumbnail actually is. A thumbnail that has left the screen gives no\n * rectangle, and the picture fades instead of flying somewhere invisible.\n */\n const measureCurrent = useCallback(\n (then?: () => void) => {\n const current = itemRef.current;\n if (!current) {\n origin.value = null;\n then?.();\n return;\n }\n current.measure((rect) => {\n const onScreen =\n rect !== null &&\n rect.y + rect.height > 0 &&\n rect.y < H &&\n rect.x + rect.width > 0 &&\n rect.x < W;\n origin.value = onScreen ? rect : null;\n radius.value = current.radius;\n then?.();\n });\n },\n [H, W, origin, radius]\n );\n\n const startClose = useCallback(\n (velocityX: number, velocityY: number) => {\n 'worklet';\n phase.value = PHASE_CLOSING;\n gestureMode.value = MODE_NONE;\n chrome.value = withTiming(0, { duration: 120 });\n const done = (finished?: boolean) => {\n 'worklet';\n if (finished && phase.value === PHASE_CLOSING) runOnJS(onExited)();\n };\n if (reduced) {\n t.value = withTiming(0, { duration: 180 }, done);\n return;\n }\n t.value = withSpring(0, CLOSE_SPRING, done);\n dragX.value = withSpring(0, { ...CLOSE_SPRING, velocity: velocityX });\n dragY.value = withSpring(0, { ...CLOSE_SPRING, velocity: velocityY });\n shrink.value = withSpring(1, CLOSE_SPRING);\n scale.value = withSpring(1, CLOSE_SPRING);\n tx.value = withSpring(0, CLOSE_SPRING);\n ty.value = withSpring(0, CLOSE_SPRING);\n },\n [chrome, dragX, dragY, gestureMode, onExited, phase, reduced, scale, shrink, t, tx, ty]\n );\n\n const settleOpen = useCallback(() => {\n phase.value = PHASE_OPENING;\n const done = (finished?: boolean) => {\n 'worklet';\n if (finished) phase.value = PHASE_OPEN;\n };\n t.value = reduced ? withTiming(1, { duration: 200 }, done) : withSpring(1, OPEN_SPRING, done);\n fade.value = withTiming(1, { duration: 150 });\n chrome.value = withDelay(120, withTiming(chromeOn.value ? 1 : 0, { duration: 200 }));\n }, [chrome, chromeOn, fade, phase, reduced, t]);\n\n // Opening and closing follow `open`, whoever changed it.\n const hasOpened = useRef(false);\n useEffect(() => {\n if (!itemRef.current) {\n onRequestClose();\n onExited();\n return;\n }\n if (open) {\n if (hasOpened.current) {\n settleOpen();\n return;\n }\n hasOpened.current = true;\n activePage.value = page;\n pagerX.value = -page * pageWidth;\n measureCurrent(() => {\n onShow(itemRef.current?.id ?? null);\n settleOpen();\n });\n return;\n }\n // A gesture that closed the viewer has already started the flight home.\n if (phase.value === PHASE_CLOSING) return;\n measureCurrent(() => runOnUI(startClose)(0, 0));\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [open]);\n\n // A page change from anywhere: the pager, the accessibility actions, or the owner.\n const firstPage = useRef(true);\n useEffect(() => {\n if (firstPage.current) {\n firstPage.current = false;\n return;\n }\n if (!itemRef.current) return;\n if (activePage.value !== page) {\n activePage.value = page;\n scale.value = 1;\n tx.value = 0;\n ty.value = 0;\n pagerX.value = reduced\n ? -page * pageWidth\n : withSpring(-page * pageWidth, SETTLE_SPRING);\n }\n measureCurrent();\n onShow(itemRef.current.id);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [page]);\n\n // A rotation re-fits everything, and a zoom that no longer means anything goes.\n const firstSize = useRef(true);\n useEffect(() => {\n if (firstSize.current) {\n firstSize.current = false;\n return;\n }\n cancelAnimation(pagerX);\n pagerX.value = -activePage.value * pageWidth;\n scale.value = 1;\n tx.value = 0;\n ty.value = 0;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [W, H]);\n\n const settleZoom = useCallback(() => {\n 'worklet';\n const target = clamp(scale.value, 1, maxScale);\n if (target <= 1.01) {\n scale.value = withSpring(1, SETTLE_SPRING);\n tx.value = withSpring(0, SETTLE_SPRING);\n ty.value = withSpring(0, SETTLE_SPRING);\n chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });\n return;\n }\n const nextX =\n target === scale.value\n ? tx.value\n : focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, target);\n const nextY =\n target === scale.value\n ? ty.value\n : focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, target);\n const boundX = panBound(fit.width, target, W);\n const boundY = panBound(fit.height, target, H);\n scale.value = withSpring(target, SETTLE_SPRING);\n tx.value = withSpring(clamp(nextX, -boundX, boundX), SETTLE_SPRING);\n ty.value = withSpring(clamp(nextY, -boundY, boundY), SETTLE_SPRING);\n }, [\n H,\n W,\n chrome,\n chromeOn,\n fit.height,\n fit.width,\n focalOffsetX,\n focalOffsetY,\n lastFocalX,\n lastFocalY,\n maxScale,\n scale,\n startScale,\n tx,\n ty,\n ]);\n\n const gesture = useMemo(() => {\n const pinch = Gesture.Pinch()\n .onStart((event) => {\n 'worklet';\n if (phase.value !== PHASE_OPEN) return;\n pinching.value = true;\n cancelAnimation(scale);\n cancelAnimation(tx);\n cancelAnimation(ty);\n startScale.value = scale.value;\n lastFocalX.value = event.focalX - W / 2;\n lastFocalY.value = event.focalY - H / 2;\n focalOffsetX.value = lastFocalX.value - tx.value;\n focalOffsetY.value = lastFocalY.value - ty.value;\n chrome.value = withTiming(0, { duration: 120 });\n })\n .onUpdate((event) => {\n 'worklet';\n if (!pinching.value) return;\n const next = rubberBandClamp(startScale.value * event.scale, 1, maxScale, maxScale);\n lastFocalX.value = event.focalX - W / 2;\n lastFocalY.value = event.focalY - H / 2;\n scale.value = next;\n tx.value = focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, next);\n ty.value = focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, next);\n })\n .onEnd(() => {\n 'worklet';\n if (!pinching.value) return;\n pinching.value = false;\n settleZoom();\n });\n\n const pan = Gesture.Pan()\n .maxPointers(1)\n .minDistance(8)\n .onStart((event) => {\n 'worklet';\n if (phase.value !== PHASE_OPEN || pinching.value) {\n gestureMode.value = MODE_NONE;\n return;\n }\n if (scale.value > 1.01) {\n gestureMode.value = MODE_PAN;\n cancelAnimation(tx);\n cancelAnimation(ty);\n startX.value = tx.value;\n startY.value = ty.value;\n return;\n }\n if (count > 1 && Math.abs(event.translationX) > Math.abs(event.translationY)) {\n gestureMode.value = MODE_PAGE;\n cancelAnimation(pagerX);\n pagerStart.value = pagerX.value;\n return;\n }\n gestureMode.value = MODE_DISMISS;\n chrome.value = withTiming(0, { duration: 120 });\n if (haptics) runOnJS(selectionTick)();\n })\n .onUpdate((event) => {\n 'worklet';\n if (gestureMode.value === MODE_PAN) {\n const boundX = panBound(fit.width, scale.value, W);\n const boundY = panBound(fit.height, scale.value, H);\n tx.value = rubberBandClamp(startX.value + event.translationX, -boundX, boundX, W);\n ty.value = rubberBandClamp(startY.value + event.translationY, -boundY, boundY, H);\n } else if (gestureMode.value === MODE_PAGE) {\n pagerX.value = rubberBandClamp(\n pagerStart.value + event.translationX,\n -(count - 1) * pageWidth,\n 0,\n W\n );\n } else if (gestureMode.value === MODE_DISMISS) {\n dragX.value = event.translationX;\n dragY.value = event.translationY;\n shrink.value = dragScale(event.translationY, H);\n fade.value = dragFade(event.translationY, H);\n }\n })\n .onEnd((event, success) => {\n 'worklet';\n const current = gestureMode.value;\n gestureMode.value = MODE_NONE;\n if (current === MODE_PAN) {\n const boundX = panBound(fit.width, scale.value, W);\n const boundY = panBound(fit.height, scale.value, H);\n if (boundX === 0 || Math.abs(tx.value) > boundX) {\n tx.value = withSpring(clamp(tx.value, -boundX, boundX), SETTLE_SPRING);\n } else {\n tx.value = withDecay({ velocity: event.velocityX, clamp: [-boundX, boundX] });\n }\n if (boundY === 0 || Math.abs(ty.value) > boundY) {\n ty.value = withSpring(clamp(ty.value, -boundY, boundY), SETTLE_SPRING);\n } else {\n ty.value = withDecay({ velocity: event.velocityY, clamp: [-boundY, boundY] });\n }\n } else if (current === MODE_PAGE) {\n const target = snapPage(pagerX.value, event.velocityX, activePage.value, count, pageWidth);\n pagerX.value = withSpring(-target * pageWidth, {\n ...SETTLE_SPRING,\n velocity: event.velocityX,\n });\n if (target !== activePage.value) {\n activePage.value = target;\n runOnJS(onIndexChange)(target);\n }\n } else if (current === MODE_DISMISS) {\n if (success && shouldDismiss(event.translationY, event.velocityY)) {\n startClose(event.velocityX, event.velocityY);\n runOnJS(onRequestClose)();\n return;\n }\n dragX.value = withSpring(0, SETTLE_SPRING);\n dragY.value = withSpring(0, SETTLE_SPRING);\n shrink.value = withSpring(1, SETTLE_SPRING);\n fade.value = withTiming(1, { duration: 180 });\n chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });\n }\n });\n\n const doubleTap = Gesture.Tap()\n .numberOfTaps(2)\n .maxDelay(250)\n .maxDistance(24)\n .onEnd((event, success) => {\n 'worklet';\n if (!success || phase.value !== PHASE_OPEN) return;\n if (scale.value > 1.01) {\n scale.value = withSpring(1, SETTLE_SPRING);\n tx.value = withSpring(0, SETTLE_SPRING);\n ty.value = withSpring(0, SETTLE_SPRING);\n chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });\n return;\n }\n const target = Math.min(doubleTapScale, maxScale);\n const focalX = event.x - W / 2;\n const focalY = event.y - H / 2;\n const boundX = panBound(fit.width, target, W);\n const boundY = panBound(fit.height, target, H);\n scale.value = withSpring(target, SETTLE_SPRING);\n tx.value = withSpring(\n clamp(focalTranslation(focalX, focalX, 1, target), -boundX, boundX),\n SETTLE_SPRING\n );\n ty.value = withSpring(\n clamp(focalTranslation(focalY, focalY, 1, target), -boundY, boundY),\n SETTLE_SPRING\n );\n chrome.value = withTiming(0, { duration: 120 });\n });\n\n const singleTap = Gesture.Tap()\n .maxDistance(10)\n .onEnd((event, success) => {\n 'worklet';\n if (!success || phase.value !== PHASE_OPEN) return;\n const onImage = hitsRect(event.x, event.y, fit, scale.value, tx.value, ty.value);\n if (!onImage) {\n startClose(0, 0);\n runOnJS(onRequestClose)();\n return;\n }\n chromeOn.value = !chromeOn.value;\n chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });\n });\n\n return Gesture.Race(\n Gesture.Simultaneous(pinch, pan),\n Gesture.Exclusive(doubleTap, singleTap)\n );\n }, [\n H,\n W,\n activePage,\n chrome,\n chromeOn,\n count,\n doubleTapScale,\n dragX,\n dragY,\n fade,\n fit,\n focalOffsetX,\n focalOffsetY,\n gestureMode,\n haptics,\n lastFocalX,\n lastFocalY,\n maxScale,\n onIndexChange,\n onRequestClose,\n pageWidth,\n pagerStart,\n pagerX,\n phase,\n pinching,\n scale,\n settleZoom,\n shrink,\n startClose,\n startScale,\n startX,\n startY,\n tx,\n ty,\n ]);\n\n const pagerStyle = useAnimatedStyle(() => ({ transform: [{ translateX: pagerX.value }] }));\n\n const chromeStyle = useAnimatedStyle(() => ({\n opacity: chrome.value * clamp(t.value, 0, 1) * fade.value,\n }));\n const chromeProps = useAnimatedProps(() => ({\n pointerEvents: (chrome.value > 0.5 ? 'box-none' : 'none') as 'box-none' | 'none',\n }));\n\n const onAccessibilityAction = (event: AccessibilityActionEvent) => {\n const name = event.nativeEvent.actionName;\n if (name === 'increment' && page < count - 1) onIndexChange(page + 1);\n else if (name === 'decrement' && page > 0) onIndexChange(page - 1);\n else if (name === 'escape' || name === 'magicTap') onRequestClose();\n };\n\n if (!item) return null;\n\n const visiblePages = items\n .map((entry, at) => ({ entry, at }))\n .filter(({ at }) => Math.abs(at - page) <= 1);\n\n const iconColour = typeof foreground === 'string' ? foreground : '#0a0a0a';\n const caption = item.caption;\n\n return (\n \n \n\n \n 1 ? 'adjustable' : 'image'}\n accessibilityLabel={item.alt}\n accessibilityValue={count > 1 ? { text: `${page + 1} of ${count}` } : undefined}\n accessibilityActions={[\n ...(count > 1 ? [{ name: 'increment' }, { name: 'decrement' }] : []),\n { name: 'escape' },\n { name: 'magicTap' },\n ]}\n onAccessibilityAction={onAccessibilityAction}\n >\n \n {visiblePages.map(({ entry, at }) => (\n \n ))}\n \n \n \n\n \n \n {/* Close at the leading edge, where the platform puts the way out of\n a full-screen view — and clear of the trailing corner, where an\n app's own floating controls tend to live. */}\n {showClose ? (\n \n \n \n ) : (\n \n )}\n {count > 1 ? (\n \n \n {`${page + 1} of ${count}`}\n \n \n ) : null}\n \n \n\n {caption !== undefined && caption !== null ? (\n \n {textChildren(caption, (text) => (\n \n {text}\n \n ))}\n \n ) : null}\n \n \n );\n}\n\ninterface ViewerPageProps {\n item: ViewerItem;\n at: number;\n left: number;\n viewport: Size;\n box: Rect;\n cornerRadius: number;\n reduced: boolean;\n activePage: SharedValue;\n t: SharedValue;\n origin: SharedValue;\n radius: SharedValue;\n scale: SharedValue;\n tx: SharedValue;\n ty: SharedValue;\n dragX: SharedValue;\n dragY: SharedValue;\n shrink: SharedValue;\n}\n\n/**\n * One picture in the pager.\n *\n * Only the current page flies, zooms and drags; its neighbours sit fitted and\n * still, waiting to be turned to. That is also why one set of zoom values is\n * enough for the whole gallery — a page is reset before it stops being current.\n */\nfunction ViewerPage({\n item,\n at,\n left,\n viewport,\n box,\n cornerRadius,\n reduced,\n activePage,\n t,\n origin,\n radius,\n scale,\n tx,\n ty,\n dragX,\n dragY,\n shrink,\n}: ViewerPageProps) {\n const size = item.size ?? box;\n const fit = fitWithin(size, box);\n const [fullLoaded, setFullLoaded] = useState(false);\n\n const frameStyle = useAnimatedStyle(() => {\n if (activePage.value !== at) {\n return {\n left: fit.x,\n top: fit.y,\n width: fit.width,\n height: fit.height,\n borderRadius: cornerRadius,\n opacity: 1,\n transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }],\n };\n }\n const progress = t.value;\n const from = origin.value;\n const flies = from !== null && !reduced;\n const rect = flies ? lerpRect(from, fit, progress) : fit;\n const settled = clamp(progress, 0, 1);\n const visualScale = scale.value * shrink.value * (flies ? 1 : lerp(0.92, 1, settled));\n // The radius is drawn inside the scale, so it is divided back out: a\n // picture zoomed to four times keeps corners the size they were, rather\n // than rounding into a pill.\n const corner = flies ? lerp(radius.value, cornerRadius, progress) : cornerRadius;\n return {\n left: rect.x,\n top: rect.y,\n width: Math.max(rect.width, 1),\n height: Math.max(rect.height, 1),\n borderRadius: Math.max(0, corner) / Math.max(visualScale, 0.01),\n opacity: flies ? 1 : settled,\n transform: [\n { translateX: tx.value + dragX.value },\n { translateY: ty.value + dragY.value },\n { scale: visualScale },\n ],\n };\n });\n\n const pictureStyle = useAnimatedStyle(() => {\n const active = activePage.value === at;\n const from = origin.value;\n const flies = active && from !== null && !reduced;\n const rect = flies ? lerpRect(from, fit, t.value) : fit;\n const cover = coverSize(size, rect);\n return {\n left: (rect.width - cover.width) / 2,\n top: (rect.height - cover.height) / 2,\n width: cover.width,\n height: cover.height,\n };\n });\n\n return (\n \n \n \n \n {item.fullSource ? (\n setFullLoaded(true)}\n style={[StyleSheet.absoluteFill, { opacity: fullLoaded ? 1 : 0 }]}\n />\n ) : null}\n \n \n \n );\n}\n\nexport const ImageViewer = Object.assign(ImageViewerRoot, {\n Trigger: ImageViewerTrigger,\n});\n"
+ "content": "/**\n * ImageViewer — a picture that opens out of the page to fill the screen.\n *\n * Pressing a trigger lifts its image from where it sits, grows it to the whole\n * picture in the middle of the screen, and blurs the page behind it. From there\n * it can be pinched and double-tapped to zoom, swiped sideways to the next\n * image under the same root, and dragged away to put it back.\n *\n * ```tsx\n * \n * {photos.map((photo) => (\n * \n * ))}\n * \n * ```\n *\n * ## The image travels; nothing appears\n *\n * The viewer never fades a second copy of the picture in over the first. The\n * thumbnail is hidden and the same image is drawn in a frame that starts at the\n * thumbnail's exact rectangle and corner radius and ends at the fitted one. The\n * picture inside that frame is always drawn at the size that *covers* it, so the\n * crop a thumbnail shows opens out into the whole image as the frame grows — and\n * closes back into the crop on the way home. That continuity is the point: the\n * picture on screen is the one that was pressed, not a picture of it.\n *\n * The frame animates its bounds rather than a transform, because a transform\n * cannot change a crop. It is one view with one child, so the layout pass it\n * costs per frame is small.\n *\n * ## Every trigger under one root is a page\n *\n * Triggers register themselves with the root as they mount, in render order or\n * by an explicit `index`. Opening one opens the gallery at its page, and the\n * thumbnail hidden underneath follows the page — close on the third image and it\n * flies back into the third thumbnail.\n *\n * ## One gesture surface, decided at the start\n *\n * Pinching, panning a zoomed image, turning the page and dragging to dismiss all\n * begin as a one- or two-finger drag, and which one it is gets decided as\n * soon as it moves: two fingers zoom, one finger on a zoomed image pans it, one\n * finger moving sideways across a gallery turns the page, and anything else\n * drags the image away. Deciding once keeps a drag from changing its mind\n * halfway, which is what makes a viewer feel loose.\n */\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from 'react';\nimport {\n Image,\n Pressable,\n StyleSheet,\n View,\n useWindowDimensions,\n type AccessibilityActionEvent,\n type ImageSourcePropType,\n type LayoutChangeEvent,\n type PressableProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n cancelAnimation,\n runOnJS,\n runOnUI,\n useAnimatedProps,\n useAnimatedStyle,\n useDerivedValue,\n useReducedMotion,\n useSharedValue,\n withDecay,\n withDelay,\n withSpring,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\nimport { useCSSVariable } from 'uniwind';\nimport { XIcon } from '@/components/ui/icons';\nimport { useControllableState } from '@/components/ui/controllable-state';\nimport { ModalPortal } from '@/components/ui/portal';\nimport { Scrim } from '@/components/ui/scrim';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { useBackHandler } from '@/hooks/use-back-handler';\nimport { useThemeMode } from '@/hooks/use-theme';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport {\n clamp,\n coverSize,\n dragFade,\n dragIntent,\n dragScale,\n fitWithin,\n focalTranslation,\n hitsRect,\n lerp,\n lerpRect,\n panBound,\n rubberBandClamp,\n shouldDismiss,\n snapPage,\n type Rect,\n type Size,\n} from '@/components/ui/image-viewer-geometry';\n\n/** Space between two pages of a gallery, so a page turn reads as two pictures. */\nconst PAGE_GAP = 16;\n\n/**\n * Out of the page. Just short of critically damped: enough give that the\n * picture seems to arrive rather than stop, not enough to wobble.\n */\nconst OPEN_SPRING = { damping: 30, stiffness: 260, mass: 1 } as const;\n\n/**\n * Back into the page. Clamped, because a frame that overshoots its thumbnail\n * shrinks past it and grows back — a visible bounce at the one moment the image\n * is supposed to be settling into place.\n */\nconst CLOSE_SPRING = { damping: 32, stiffness: 300, mass: 1, overshootClamping: true } as const;\n\n/** Zoom settling and page turns. Firm, so a flick lands without drifting. */\nconst SETTLE_SPRING = { damping: 34, stiffness: 320, mass: 1 } as const;\n\n/**\n * Margin between the open picture and the screen, in points. Enough to show the\n * blur on all four sides, so the picture reads as held up over the page rather\n * than as a new screen that happens to be a photograph.\n */\nconst DEFAULT_INSET = 16;\n\n/** Corners of the open picture, matching the library's large surfaces. */\nconst DEFAULT_CORNER_RADIUS = 16;\n\n/** How strongly the blur comes up behind the picture. */\nconst BLUR_INTENSITY = 50;\n\nconst PHASE_OPENING = 0;\nconst PHASE_OPEN = 1;\nconst PHASE_CLOSING = 2;\n\n/** Pins a subtree's `left` to the screen's left under a right-to-left layout. */\nconst PHYSICAL = { direction: 'ltr' } as const;\n\nconst MODE_NONE = 0;\nconst MODE_PAN = 1;\nconst MODE_PAGE = 2;\nconst MODE_DISMISS = 3;\n/** The pan has started but not yet moved, so there is no direction to decide on. */\nconst MODE_UNDECIDED = 4;\n\n/* -------------------------------------------------------------------------- */\n/* Registry */\n/* -------------------------------------------------------------------------- */\n\ninterface ViewerItem {\n id: string;\n order: number;\n index?: number;\n source: ImageSourcePropType;\n fullSource?: ImageSourcePropType;\n alt?: string;\n caption?: ReactNode;\n radius: number;\n size: Size | null;\n measure: (done: (rect: Rect | null) => void) => void;\n}\n\n/**\n * The triggers under one root, in page order.\n *\n * An external store rather than state on the root, because a trigger updating\n * its caption should re-render the open viewer and nothing else — root state\n * would re-render every trigger, which would register again, which would\n * re-render the root.\n */\nclass ViewerStore {\n private items = new Map();\n private listeners = new Set<() => void>();\n private counter = 0;\n private version = 0;\n private sorted: ViewerItem[] = [];\n\n nextOrder = () => this.counter++;\n\n set = (item: ViewerItem) => {\n this.items.set(item.id, item);\n this.emit();\n };\n\n delete = (id: string) => {\n if (this.items.delete(id)) this.emit();\n };\n\n list = (): ViewerItem[] => this.sorted;\n\n subscribe = (listener: () => void) => {\n this.listeners.add(listener);\n return () => {\n this.listeners.delete(listener);\n };\n };\n\n getSnapshot = () => this.version;\n\n private emit() {\n this.version += 1;\n this.sorted = [...this.items.values()].sort(\n (a, b) => (a.index ?? a.order) - (b.index ?? b.order)\n );\n this.listeners.forEach((listener) => listener());\n }\n}\n\ninterface ViewerContextValue {\n store: ViewerStore;\n /** The trigger whose picture is out on the screen, and so hidden in the page. */\n hiddenId: string | null;\n openItem: (id: string) => void;\n}\n\nconst ViewerContext = createContext(null);\n\nfunction useViewer(part: string): ViewerContextValue {\n const context = useContext(ViewerContext);\n if (!context) throw new Error(`${part} must be used inside .`);\n return context;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root */\n/* -------------------------------------------------------------------------- */\n\nexport interface ImageViewerProps {\n children?: ReactNode;\n /** Controlled open state. */\n open?: boolean;\n /** Initial open state when uncontrolled. */\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Controlled page — which trigger's image is showing, in page order. */\n index?: number;\n /** Initial page when uncontrolled. */\n defaultIndex?: number;\n onIndexChange?: (index: number) => void;\n /**\n * Blur the page behind the picture. Needs the optional `expo-blur`, and dims\n * instead without it. Reduce Transparency draws an opaque backdrop.\n */\n blur?: boolean;\n /** How far a pinch can zoom in, as a multiple of the fitted size. */\n maxScale?: number;\n /** Where a double tap zooms to, as a multiple of the fitted size. */\n doubleTapScale?: number;\n /** Tick as a drag to dismiss begins. Needs the optional `expo-haptics`. */\n haptics?: boolean;\n /** Draw the close button. Tapping outside the picture and dragging it away still close it. */\n showClose?: boolean;\n /** Read out for the close button. */\n closeLabel?: string;\n /**\n * Space kept between the open picture and the edges of the screen, in points.\n * Measured from the safe area at the top and bottom, and the larger of the\n * two is used for both, so the picture stays centred. `0` fills the screen.\n */\n inset?: number;\n /**\n * Corner radius of the open picture, in points. The corners stay this size on\n * screen while the picture is zoomed or dragged. `0` squares them.\n */\n cornerRadius?: number;\n /**\n * Which way the gallery runs. In `rtl` the next page sits to the left and a\n * swipe to the right turns to it. Page numbers, `index` and the screen\n * reader's next and previous stay in page order either way. To follow the\n * reading direction, pass `useDirection()`.\n */\n dir?: 'ltr' | 'rtl';\n}\n\nfunction ImageViewerRoot({\n children,\n open,\n defaultOpen = false,\n onOpenChange,\n index,\n defaultIndex = 0,\n onIndexChange,\n blur = true,\n maxScale = 4,\n doubleTapScale = 2.5,\n haptics = true,\n showClose = true,\n closeLabel = 'Close',\n inset = DEFAULT_INSET,\n cornerRadius = DEFAULT_CORNER_RADIUS,\n dir = 'ltr',\n}: ImageViewerProps) {\n const storeRef = useRef(null);\n storeRef.current ??= new ViewerStore();\n const store = storeRef.current;\n\n const { value: isOpen, setValue: setOpen } = useControllableState({\n value: open,\n defaultValue: defaultOpen,\n onChange: onOpenChange,\n });\n const { value: page, setValue: setPage } = useControllableState({\n value: index,\n defaultValue: defaultIndex,\n onChange: onIndexChange,\n });\n\n // Mounted from the moment it opens until the picture is back in the page,\n // which is after `open` has already gone false.\n const [mounted, setMounted] = useState(isOpen);\n if (isOpen && !mounted) setMounted(true);\n const [hiddenId, setHiddenId] = useState(null);\n\n const openItem = useCallback(\n (id: string) => {\n const at = store.list().findIndex((item) => item.id === id);\n if (at < 0) return;\n setPage(at);\n setOpen(true);\n },\n [setOpen, setPage, store]\n );\n\n const requestClose = useCallback(() => setOpen(false), [setOpen]);\n const exited = useCallback(() => {\n setMounted(false);\n setHiddenId(null);\n }, []);\n\n useBackHandler(isOpen, requestClose);\n\n const context = useMemo(\n () => ({ store, hiddenId, openItem }),\n [store, hiddenId, openItem]\n );\n\n return (\n \n {children}\n {mounted ? (\n \n \n \n ) : null}\n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Trigger */\n/* -------------------------------------------------------------------------- */\n\nexport interface ImageViewerTriggerProps\n extends Omit {\n /**\n * Classes on the pressable. Without `children` the image fills it, so give it\n * a size — `h-48 w-full`, `aspect-square flex-1`.\n */\n className?: string;\n /** The picture. Shown in the page and, until `fullSource` loads, in the viewer. */\n source: ImageSourcePropType;\n /**\n * A larger copy to show once the viewer is open. It loads when the viewer\n * opens and replaces `source` when it arrives, so a feed can carry thumbnails.\n */\n fullSource?: ImageSourcePropType;\n /** Described for a screen reader, on the trigger and in the viewer. */\n alt?: string;\n /** Shown under the picture while it is open. A string is set as text. */\n caption?: ReactNode;\n /** Page order within the root. Render order when left out. */\n index?: number;\n /**\n * The corner radius the picture has in the page, in points, so the flight\n * starts from the same shape. Match it to the `rounded-*` class you gave the\n * trigger — a class cannot be read back.\n */\n radius?: number;\n /**\n * The image's own width and height, when known. Saves looking it up, and is\n * the only way the viewer knows the proportions before a remote image loads.\n */\n width?: number;\n height?: number;\n /** Nothing opens, and the press is not reported. */\n disabled?: boolean;\n /**\n * Draw the picture yourself — a `Post.Media`, a card. It should show `source`\n * cropped to fill, the way the viewer's own flight starts. Left out, the\n * trigger draws `source` to fill its box.\n */\n children?: ReactNode;\n}\n\n/** The image's proportions from what can be known without loading it. */\nfunction knownSize(source: ImageSourcePropType, width?: number, height?: number): Size | null {\n if (width && height) return { width, height };\n if (typeof source === 'number') {\n const asset = Image.resolveAssetSource(source);\n if (asset?.width && asset?.height) return { width: asset.width, height: asset.height };\n }\n return null;\n}\n\nfunction uriOf(source: ImageSourcePropType): string | undefined {\n if (source && typeof source === 'object' && !Array.isArray(source)) {\n return (source as { uri?: string }).uri;\n }\n return undefined;\n}\n\n/**\n * The thing pressed to open the viewer.\n *\n * It registers its picture with the root, so the gallery is simply every\n * trigger under that root. While its picture is out on the screen it is drawn\n * transparent — not removed, so the page does not reflow under the blur and the\n * flight home has somewhere to land.\n */\nfunction ImageViewerTrigger({\n className,\n source,\n fullSource,\n alt,\n caption,\n index,\n radius = 0,\n width,\n height,\n disabled = false,\n children,\n onPress,\n onLayout,\n ...props\n}: ImageViewerTriggerProps) {\n const { store, hiddenId, openItem } = useViewer('ImageViewer.Trigger');\n const id = useId();\n const ref = useRef(null);\n const orderRef = useRef(null);\n orderRef.current ??= store.nextOrder();\n\n const [size, setSize] = useState(() => knownSize(source, width, height));\n const [box, setBox] = useState(null);\n const uri = uriOf(source);\n\n useEffect(() => {\n const known = knownSize(source, width, height);\n if (known) {\n setSize(known);\n return;\n }\n if (!uri) return;\n let live = true;\n Image.getSize(\n uri,\n (w, h) => {\n if (live) setSize({ width: w, height: h });\n },\n () => {}\n );\n return () => {\n live = false;\n };\n // `source` is compared through its uri; an inline `{ uri }` is new every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [uri, width, height]);\n\n const measure = useCallback((done: (rect: Rect | null) => void) => {\n const node = ref.current;\n if (!node) {\n done(null);\n return;\n }\n node.measureInWindow((x, y, w, h) => {\n done(w > 0 && h > 0 ? { x, y, width: w, height: h } : null);\n });\n }, []);\n\n useLayoutEffect(() => {\n store.set({\n id,\n order: orderRef.current ?? 0,\n index,\n source,\n fullSource,\n alt,\n caption,\n radius,\n // The box's own proportions stand in until the image's are known, which\n // is right for a thumbnail that shows the picture uncropped and close\n // enough for one that does not.\n size: size ?? box,\n measure,\n });\n }, [store, id, index, source, fullSource, alt, caption, radius, size, box, measure]);\n\n useLayoutEffect(() => () => store.delete(id), [store, id]);\n\n const handleLayout = (event: LayoutChangeEvent) => {\n onLayout?.(event);\n const { width: w, height: h } = event.nativeEvent.layout;\n if (w > 0 && h > 0) setBox({ width: w, height: h });\n };\n\n return (\n {\n onPress?.(event);\n openItem(id);\n }}\n onLayout={handleLayout}\n className={cn('overflow-hidden', className)}\n style={[radius ? { borderRadius: radius } : null, { opacity: hiddenId === id ? 0 : 1 }]}\n {...props}\n >\n {children ?? (\n {\n if (size) return;\n const loaded = event.nativeEvent.source;\n if (loaded?.width && loaded?.height) {\n setSize({ width: loaded.width, height: loaded.height });\n }\n }}\n style={StyleSheet.absoluteFill}\n />\n )}\n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Overlay */\n/* -------------------------------------------------------------------------- */\n\ninterface ViewerOverlayProps {\n store: ViewerStore;\n open: boolean;\n index: number;\n onIndexChange: (index: number) => void;\n onRequestClose: () => void;\n onExited: () => void;\n onShow: (id: string | null) => void;\n blur: boolean;\n maxScale: number;\n doubleTapScale: number;\n haptics: boolean;\n showClose: boolean;\n closeLabel: string;\n inset: number;\n cornerRadius: number;\n /** `-1` when the pages run right to left. */\n sign: 1 | -1;\n}\n\nfunction ViewerOverlay({\n store,\n open,\n index,\n onIndexChange,\n onRequestClose,\n onExited,\n onShow,\n blur,\n maxScale,\n doubleTapScale,\n haptics,\n showClose,\n closeLabel,\n inset,\n cornerRadius,\n sign,\n}: ViewerOverlayProps) {\n const version = useSyncExternalStore(store.subscribe, store.getSnapshot);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n const items = useMemo(() => store.list(), [store, version]);\n const count = items.length;\n const page = count ? clamp(index, 0, count - 1) : 0;\n const item = items[page];\n\n const { width: W, height: H } = useWindowDimensions();\n const insets = useSafeAreaInsets();\n const reduced = useReducedMotion();\n const { mode } = useThemeMode();\n const foreground = useCSSVariable('--color-foreground');\n const pageWidth = W + PAGE_GAP;\n\n /*\n * The area the picture is fitted into. The same margin on both sides of each\n * axis, so the fitted picture is centred on the screen — the zoom and pan\n * arithmetic is measured from the screen's centre and relies on that.\n */\n const box = useMemo(() => {\n const vertical = Math.max(insets.top, insets.bottom) + inset;\n return {\n x: inset,\n y: vertical,\n width: Math.max(W - inset * 2, 1),\n height: Math.max(H - vertical * 2, 1),\n };\n }, [H, W, inset, insets.bottom, insets.top]);\n\n const fit = useMemo(\n () => fitWithin(item?.size ?? box, box),\n [item?.size, box]\n );\n\n // The flight, 0 in the page and 1 on the screen.\n const t = useSharedValue(0);\n const phase = useSharedValue(PHASE_OPENING);\n const origin = useSharedValue(null);\n const radius = useSharedValue(0);\n\n // Zoom of the current page, measured from the screen's centre.\n const scale = useSharedValue(1);\n const tx = useSharedValue(0);\n const ty = useSharedValue(0);\n const startScale = useSharedValue(1);\n const startX = useSharedValue(0);\n const startY = useSharedValue(0);\n const focalOffsetX = useSharedValue(0);\n const focalOffsetY = useSharedValue(0);\n const lastFocalX = useSharedValue(0);\n const lastFocalY = useSharedValue(0);\n const pinching = useSharedValue(false);\n\n // Dragging the picture away.\n const dragX = useSharedValue(0);\n const dragY = useSharedValue(0);\n const shrink = useSharedValue(1);\n const fade = useSharedValue(1);\n\n // The gallery, in page order: `sign` mirrors it on the way to the screen.\n const pagerX = useSharedValue(-page * pageWidth);\n const pagerStart = useSharedValue(0);\n const activePage = useSharedValue(page);\n\n const gestureMode = useSharedValue(MODE_NONE);\n const chrome = useSharedValue(0);\n const chromeOn = useSharedValue(true);\n\n const backdrop = useDerivedValue(() => clamp(t.value, 0, 1) * fade.value);\n\n const itemRef = useRef(item);\n itemRef.current = item;\n\n /**\n * Where the current page's thumbnail is now. Measured again on every page\n * change and before every close, because the flight home has to land where\n * the thumbnail actually is. A thumbnail that has left the screen gives no\n * rectangle, and the picture fades instead of flying somewhere invisible.\n */\n const measureCurrent = useCallback(\n (then?: () => void) => {\n const current = itemRef.current;\n if (!current) {\n origin.value = null;\n then?.();\n return;\n }\n current.measure((rect) => {\n const onScreen =\n rect !== null &&\n rect.y + rect.height > 0 &&\n rect.y < H &&\n rect.x + rect.width > 0 &&\n rect.x < W;\n origin.value = onScreen ? rect : null;\n radius.value = current.radius;\n then?.();\n });\n },\n [H, W, origin, radius]\n );\n\n const startClose = useCallback(\n (velocityX: number, velocityY: number) => {\n 'worklet';\n phase.value = PHASE_CLOSING;\n gestureMode.value = MODE_NONE;\n chrome.value = withTiming(0, { duration: 120 });\n const done = (finished?: boolean) => {\n 'worklet';\n if (finished && phase.value === PHASE_CLOSING) runOnJS(onExited)();\n };\n if (reduced) {\n t.value = withTiming(0, { duration: 180 }, done);\n return;\n }\n t.value = withSpring(0, CLOSE_SPRING, done);\n dragX.value = withSpring(0, { ...CLOSE_SPRING, velocity: velocityX });\n dragY.value = withSpring(0, { ...CLOSE_SPRING, velocity: velocityY });\n shrink.value = withSpring(1, CLOSE_SPRING);\n scale.value = withSpring(1, CLOSE_SPRING);\n tx.value = withSpring(0, CLOSE_SPRING);\n ty.value = withSpring(0, CLOSE_SPRING);\n },\n [chrome, dragX, dragY, gestureMode, onExited, phase, reduced, scale, shrink, t, tx, ty]\n );\n\n /**\n * A drag or a tap outside closing the viewer. Measured again first, like a\n * close from `open`: the measure taken when the page turned can predate the\n * owner scrolling the new page's thumbnail into place.\n */\n const closeFromGesture = useCallback(\n (velocityX: number, velocityY: number) => {\n measureCurrent(() => runOnUI(startClose)(velocityX, velocityY));\n onRequestClose();\n },\n [measureCurrent, onRequestClose, startClose]\n );\n\n const settleOpen = useCallback(() => {\n phase.value = PHASE_OPENING;\n const done = (finished?: boolean) => {\n 'worklet';\n if (finished) phase.value = PHASE_OPEN;\n };\n t.value = reduced ? withTiming(1, { duration: 200 }, done) : withSpring(1, OPEN_SPRING, done);\n fade.value = withTiming(1, { duration: 150 });\n chrome.value = withDelay(120, withTiming(chromeOn.value ? 1 : 0, { duration: 200 }));\n }, [chrome, chromeOn, fade, phase, reduced, t]);\n\n // Opening and closing follow `open`, whoever changed it.\n const hasOpened = useRef(false);\n useEffect(() => {\n if (!itemRef.current) {\n onRequestClose();\n onExited();\n return;\n }\n if (open) {\n if (hasOpened.current) {\n settleOpen();\n return;\n }\n hasOpened.current = true;\n activePage.value = page;\n pagerX.value = -page * pageWidth;\n measureCurrent(() => {\n onShow(itemRef.current?.id ?? null);\n settleOpen();\n });\n return;\n }\n // A gesture that closed the viewer has already started the flight home.\n if (phase.value === PHASE_CLOSING) return;\n measureCurrent(() => runOnUI(startClose)(0, 0));\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [open]);\n\n // A page change from anywhere: the pager, the accessibility actions, or the owner.\n const firstPage = useRef(true);\n useEffect(() => {\n if (firstPage.current) {\n firstPage.current = false;\n return;\n }\n if (!itemRef.current) return;\n if (activePage.value !== page) {\n activePage.value = page;\n scale.value = 1;\n tx.value = 0;\n ty.value = 0;\n pagerX.value = reduced\n ? -page * pageWidth\n : withSpring(-page * pageWidth, SETTLE_SPRING);\n }\n measureCurrent();\n onShow(itemRef.current.id);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [page]);\n\n // A rotation re-fits everything, and a zoom that no longer means anything goes.\n const firstSize = useRef(true);\n useEffect(() => {\n if (firstSize.current) {\n firstSize.current = false;\n return;\n }\n cancelAnimation(pagerX);\n pagerX.value = -activePage.value * pageWidth;\n scale.value = 1;\n tx.value = 0;\n ty.value = 0;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [W, H]);\n\n const settleZoom = useCallback(() => {\n 'worklet';\n const target = clamp(scale.value, 1, maxScale);\n if (target <= 1.01) {\n scale.value = withSpring(1, SETTLE_SPRING);\n tx.value = withSpring(0, SETTLE_SPRING);\n ty.value = withSpring(0, SETTLE_SPRING);\n chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });\n return;\n }\n const nextX =\n target === scale.value\n ? tx.value\n : focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, target);\n const nextY =\n target === scale.value\n ? ty.value\n : focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, target);\n const boundX = panBound(fit.width, target, W);\n const boundY = panBound(fit.height, target, H);\n scale.value = withSpring(target, SETTLE_SPRING);\n tx.value = withSpring(clamp(nextX, -boundX, boundX), SETTLE_SPRING);\n ty.value = withSpring(clamp(nextY, -boundY, boundY), SETTLE_SPRING);\n }, [\n H,\n W,\n chrome,\n chromeOn,\n fit.height,\n fit.width,\n focalOffsetX,\n focalOffsetY,\n lastFocalX,\n lastFocalY,\n maxScale,\n scale,\n startScale,\n tx,\n ty,\n ]);\n\n const gesture = useMemo(() => {\n const pinch = Gesture.Pinch()\n .onStart((event) => {\n 'worklet';\n if (phase.value !== PHASE_OPEN) return;\n pinching.value = true;\n cancelAnimation(scale);\n cancelAnimation(tx);\n cancelAnimation(ty);\n startScale.value = scale.value;\n lastFocalX.value = event.focalX - W / 2;\n lastFocalY.value = event.focalY - H / 2;\n focalOffsetX.value = lastFocalX.value - tx.value;\n focalOffsetY.value = lastFocalY.value - ty.value;\n chrome.value = withTiming(0, { duration: 120 });\n })\n .onUpdate((event) => {\n 'worklet';\n if (!pinching.value) return;\n const next = rubberBandClamp(startScale.value * event.scale, 1, maxScale, maxScale);\n lastFocalX.value = event.focalX - W / 2;\n lastFocalY.value = event.focalY - H / 2;\n scale.value = next;\n tx.value = focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, next);\n ty.value = focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, next);\n })\n .onEnd(() => {\n 'worklet';\n if (!pinching.value) return;\n pinching.value = false;\n settleZoom();\n });\n\n const decide = (translationX: number, translationY: number) => {\n 'worklet';\n const intent = dragIntent(translationX, translationY, count);\n if (intent === 'undecided') {\n gestureMode.value = MODE_UNDECIDED;\n return;\n }\n if (intent === 'page') {\n gestureMode.value = MODE_PAGE;\n cancelAnimation(pagerX);\n pagerStart.value = pagerX.value;\n return;\n }\n gestureMode.value = MODE_DISMISS;\n chrome.value = withTiming(0, { duration: 120 });\n if (haptics) runOnJS(selectionTick)();\n };\n\n /**\n * Decides a pan that activated without moving, from the first event that\n * has. Only while the viewer is still open and no pinch has started since:\n * a close or a second finger arriving first leaves the drag doing nothing.\n */\n const resolve = (translationX: number, translationY: number) => {\n 'worklet';\n if (gestureMode.value !== MODE_UNDECIDED) return;\n if (phase.value !== PHASE_OPEN || pinching.value) {\n gestureMode.value = MODE_NONE;\n return;\n }\n decide(translationX, translationY);\n };\n\n /** Moves whatever the drag was decided to move. */\n const follow = (translationX: number, translationY: number) => {\n 'worklet';\n if (gestureMode.value === MODE_PAN) {\n const boundX = panBound(fit.width, scale.value, W);\n const boundY = panBound(fit.height, scale.value, H);\n tx.value = rubberBandClamp(startX.value + translationX, -boundX, boundX, W);\n ty.value = rubberBandClamp(startY.value + translationY, -boundY, boundY, H);\n } else if (gestureMode.value === MODE_PAGE) {\n pagerX.value = rubberBandClamp(\n pagerStart.value + sign * translationX,\n -(count - 1) * pageWidth,\n 0,\n W\n );\n } else if (gestureMode.value === MODE_DISMISS) {\n dragX.value = translationX;\n dragY.value = translationY;\n shrink.value = dragScale(translationY, H);\n fade.value = dragFade(translationY, H);\n }\n };\n\n const pan = Gesture.Pan()\n .maxPointers(1)\n .minDistance(8)\n .onStart((event) => {\n 'worklet';\n if (phase.value !== PHASE_OPEN || pinching.value) {\n gestureMode.value = MODE_NONE;\n return;\n }\n if (scale.value > 1.01) {\n gestureMode.value = MODE_PAN;\n cancelAnimation(tx);\n cancelAnimation(ty);\n startX.value = tx.value;\n startY.value = ty.value;\n return;\n }\n decide(event.translationX, event.translationY);\n })\n .onUpdate((event) => {\n 'worklet';\n // Activated without a translation: the first update that has moved decides.\n resolve(event.translationX, event.translationY);\n follow(event.translationX, event.translationY);\n })\n .onEnd((event, success) => {\n 'worklet';\n // A quick flick can end before any update has moved, so the end event\n // may be the first to: decide on it and apply it before acting.\n if (gestureMode.value === MODE_UNDECIDED) {\n resolve(event.translationX, event.translationY);\n follow(event.translationX, event.translationY);\n }\n const current = gestureMode.value;\n gestureMode.value = MODE_NONE;\n if (current === MODE_PAN) {\n const boundX = panBound(fit.width, scale.value, W);\n const boundY = panBound(fit.height, scale.value, H);\n if (boundX === 0 || Math.abs(tx.value) > boundX) {\n tx.value = withSpring(clamp(tx.value, -boundX, boundX), SETTLE_SPRING);\n } else {\n tx.value = withDecay({ velocity: event.velocityX, clamp: [-boundX, boundX] });\n }\n if (boundY === 0 || Math.abs(ty.value) > boundY) {\n ty.value = withSpring(clamp(ty.value, -boundY, boundY), SETTLE_SPRING);\n } else {\n ty.value = withDecay({ velocity: event.velocityY, clamp: [-boundY, boundY] });\n }\n } else if (current === MODE_PAGE) {\n const velocity = sign * event.velocityX;\n const target = snapPage(pagerX.value, velocity, activePage.value, count, pageWidth);\n pagerX.value = withSpring(-target * pageWidth, { ...SETTLE_SPRING, velocity });\n if (target !== activePage.value) {\n activePage.value = target;\n runOnJS(onIndexChange)(target);\n }\n } else if (current === MODE_DISMISS) {\n if (success && shouldDismiss(event.translationY, event.velocityY)) {\n // Closing now, before the measure: no other gesture starts, and the\n // `open` effect leaves the flight home to `closeFromGesture`.\n phase.value = PHASE_CLOSING;\n runOnJS(closeFromGesture)(event.velocityX, event.velocityY);\n return;\n }\n dragX.value = withSpring(0, SETTLE_SPRING);\n dragY.value = withSpring(0, SETTLE_SPRING);\n shrink.value = withSpring(1, SETTLE_SPRING);\n fade.value = withTiming(1, { duration: 180 });\n chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });\n }\n });\n\n const doubleTap = Gesture.Tap()\n .numberOfTaps(2)\n .maxDelay(250)\n .maxDistance(24)\n .onEnd((event, success) => {\n 'worklet';\n if (!success || phase.value !== PHASE_OPEN) return;\n if (scale.value > 1.01) {\n scale.value = withSpring(1, SETTLE_SPRING);\n tx.value = withSpring(0, SETTLE_SPRING);\n ty.value = withSpring(0, SETTLE_SPRING);\n chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });\n return;\n }\n const target = Math.min(doubleTapScale, maxScale);\n const focalX = event.x - W / 2;\n const focalY = event.y - H / 2;\n const boundX = panBound(fit.width, target, W);\n const boundY = panBound(fit.height, target, H);\n scale.value = withSpring(target, SETTLE_SPRING);\n tx.value = withSpring(\n clamp(focalTranslation(focalX, focalX, 1, target), -boundX, boundX),\n SETTLE_SPRING\n );\n ty.value = withSpring(\n clamp(focalTranslation(focalY, focalY, 1, target), -boundY, boundY),\n SETTLE_SPRING\n );\n chrome.value = withTiming(0, { duration: 120 });\n });\n\n const singleTap = Gesture.Tap()\n .maxDistance(10)\n .onEnd((event, success) => {\n 'worklet';\n if (!success || phase.value !== PHASE_OPEN) return;\n const onImage = hitsRect(event.x, event.y, fit, scale.value, tx.value, ty.value);\n if (!onImage) {\n phase.value = PHASE_CLOSING;\n runOnJS(closeFromGesture)(0, 0);\n return;\n }\n chromeOn.value = !chromeOn.value;\n chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });\n });\n\n return Gesture.Race(\n Gesture.Simultaneous(pinch, pan),\n Gesture.Exclusive(doubleTap, singleTap)\n );\n }, [\n H,\n W,\n activePage,\n chrome,\n chromeOn,\n count,\n doubleTapScale,\n dragX,\n dragY,\n fade,\n fit,\n focalOffsetX,\n focalOffsetY,\n gestureMode,\n haptics,\n lastFocalX,\n lastFocalY,\n maxScale,\n onIndexChange,\n onRequestClose,\n pageWidth,\n pagerStart,\n pagerX,\n phase,\n pinching,\n scale,\n settleZoom,\n shrink,\n closeFromGesture,\n sign,\n startScale,\n startX,\n startY,\n tx,\n ty,\n ]);\n\n const pagerStyle = useAnimatedStyle(() => ({\n transform: [{ translateX: sign * pagerX.value }],\n }));\n\n const chromeStyle = useAnimatedStyle(() => ({\n opacity: chrome.value * clamp(t.value, 0, 1) * fade.value,\n }));\n const chromeProps = useAnimatedProps(() => ({\n pointerEvents: (chrome.value > 0.5 ? 'box-none' : 'none') as 'box-none' | 'none',\n }));\n\n const onAccessibilityAction = (event: AccessibilityActionEvent) => {\n const name = event.nativeEvent.actionName;\n if (name === 'increment' && page < count - 1) onIndexChange(page + 1);\n else if (name === 'decrement' && page > 0) onIndexChange(page - 1);\n else if (name === 'escape' || name === 'magicTap') onRequestClose();\n };\n\n if (!item) return null;\n\n const visiblePages = items\n .map((entry, at) => ({ entry, at }))\n .filter(({ at }) => Math.abs(at - page) <= 1);\n\n const iconColour = typeof foreground === 'string' ? foreground : '#0a0a0a';\n const caption = item.caption;\n\n return (\n \n \n\n \n 1 ? 'adjustable' : 'image'}\n accessibilityLabel={item.alt}\n accessibilityValue={count > 1 ? { text: `${page + 1} of ${count}` } : undefined}\n accessibilityActions={[\n ...(count > 1 ? [{ name: 'increment' }, { name: 'decrement' }] : []),\n { name: 'escape' },\n { name: 'magicTap' },\n ]}\n onAccessibilityAction={onAccessibilityAction}\n >\n {/* Laid out left to right whatever the app's direction: the pages and\n the flight are placed in measured screen pixels, which a right-to-left\n layout would mirror. `sign` does the mirroring the gallery wants. */}\n \n {visiblePages.map(({ entry, at }) => (\n \n ))}\n \n \n \n\n \n \n {/* Close at the leading edge, where the platform puts the way out of\n a full-screen view — and clear of the trailing corner, where an\n app's own floating controls tend to live. */}\n {showClose ? (\n \n \n \n ) : (\n \n )}\n {count > 1 ? (\n \n \n {`${page + 1} of ${count}`}\n \n \n ) : null}\n \n \n\n {caption !== undefined && caption !== null ? (\n \n {textChildren(caption, (text) => (\n \n {text}\n \n ))}\n \n ) : null}\n \n \n );\n}\n\ninterface ViewerPageProps {\n item: ViewerItem;\n at: number;\n left: number;\n viewport: Size;\n box: Rect;\n cornerRadius: number;\n reduced: boolean;\n activePage: SharedValue;\n t: SharedValue;\n origin: SharedValue;\n radius: SharedValue;\n scale: SharedValue;\n tx: SharedValue;\n ty: SharedValue;\n dragX: SharedValue;\n dragY: SharedValue;\n shrink: SharedValue;\n}\n\n/**\n * One picture in the pager.\n *\n * Only the current page flies, zooms and drags; its neighbours sit fitted and\n * still, waiting to be turned to. That is also why one set of zoom values is\n * enough for the whole gallery — a page is reset before it stops being current.\n */\nfunction ViewerPage({\n item,\n at,\n left,\n viewport,\n box,\n cornerRadius,\n reduced,\n activePage,\n t,\n origin,\n radius,\n scale,\n tx,\n ty,\n dragX,\n dragY,\n shrink,\n}: ViewerPageProps) {\n const size = item.size ?? box;\n const fit = fitWithin(size, box);\n const [fullLoaded, setFullLoaded] = useState(false);\n\n const frameStyle = useAnimatedStyle(() => {\n if (activePage.value !== at) {\n return {\n left: fit.x,\n top: fit.y,\n width: fit.width,\n height: fit.height,\n borderRadius: cornerRadius,\n opacity: 1,\n transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }],\n };\n }\n const progress = t.value;\n const from = origin.value;\n const flies = from !== null && !reduced;\n const rect = flies ? lerpRect(from, fit, progress) : fit;\n const settled = clamp(progress, 0, 1);\n const visualScale = scale.value * shrink.value * (flies ? 1 : lerp(0.92, 1, settled));\n // The radius is drawn inside the scale, so it is divided back out: a\n // picture zoomed to four times keeps corners the size they were, rather\n // than rounding into a pill.\n const corner = flies ? lerp(radius.value, cornerRadius, progress) : cornerRadius;\n return {\n left: rect.x,\n top: rect.y,\n width: Math.max(rect.width, 1),\n height: Math.max(rect.height, 1),\n borderRadius: Math.max(0, corner) / Math.max(visualScale, 0.01),\n opacity: flies ? 1 : settled,\n transform: [\n { translateX: tx.value + dragX.value },\n { translateY: ty.value + dragY.value },\n { scale: visualScale },\n ],\n };\n });\n\n const pictureStyle = useAnimatedStyle(() => {\n const active = activePage.value === at;\n const from = origin.value;\n const flies = active && from !== null && !reduced;\n const rect = flies ? lerpRect(from, fit, t.value) : fit;\n const cover = coverSize(size, rect);\n return {\n left: (rect.width - cover.width) / 2,\n top: (rect.height - cover.height) / 2,\n width: cover.width,\n height: cover.height,\n };\n });\n\n return (\n \n \n \n \n {item.fullSource ? (\n setFullLoaded(true)}\n style={[StyleSheet.absoluteFill, { opacity: fullLoaded ? 1 : 0 }]}\n />\n ) : null}\n \n \n \n );\n}\n\nexport const ImageViewer = Object.assign(ImageViewerRoot, {\n Trigger: ImageViewerTrigger,\n});\n"
}
]
}
diff --git a/apps/docs/public/skills/panelui/components/image-viewer.md b/apps/docs/public/skills/panelui/components/image-viewer.md
index 19470c38..360dc678 100644
--- a/apps/docs/public/skills/panelui/components/image-viewer.md
+++ b/apps/docs/public/skills/panelui/components/image-viewer.md
@@ -42,6 +42,7 @@ import { ImageViewer } from 'panelui-native';
| `closeLabel` | `string` | `Close` | Read out for the close button. |
| `inset` | `number` | `16` | Space kept between the open picture and the edges of the screen, in points. Measured from the safe area at the top and bottom, and the larger of the two is used for both, so the picture stays centred. `0` fills the screen. |
| `cornerRadius` | `number` | `16` | Corner radius of the open picture, in points. The corners stay this size on screen while the picture is zoomed or dragged. `0` squares them. |
+| `dir` | `'ltr' \| 'rtl'` | `ltr` | Which way the gallery runs. In `rtl` the next page sits to the left and a swipe to the right turns to it. Page numbers, `index` and the screen reader's next and previous stay in page order either way. To follow the reading direction, pass `useDirection()`. |
#### `ImageViewerTriggerProps`
diff --git a/apps/docs/scripts/usage/image-viewer.json b/apps/docs/scripts/usage/image-viewer.json
index 5ccf21b1..af749683 100644
--- a/apps/docs/scripts/usage/image-viewer.json
+++ b/apps/docs/scripts/usage/image-viewer.json
@@ -44,6 +44,11 @@
"alt": "ImageViewer — A gallery."
}
},
+ {
+ "title": "Right to left",
+ "description": "`dir` sets which way the pages run. In `rtl` the next picture sits to the left and a swipe to the right turns to it. The counter, `index` and the screen reader's next and previous still count in page order, and `dir` defaults to `ltr`. Lay the triggers out the same way round: in an app laid out left to right, `flex-row-reverse` puts the first picture on the right, as here. Under a right-to-left layout rows already run that way, so keep `flex-row` and pass `dir={useDirection()}`.",
+ "code": "\n \n {photos.map((photo) => (\n \n ))}\n \n"
+ },
{
"title": "Captions and a larger copy",
"description": "`caption` is shown under the picture while it is open; a single tap on the picture hides it along with the close button. `fullSource` loads when the viewer opens and replaces `source` once it arrives, so the page can carry small thumbnails.",
diff --git a/apps/example/src/data/components.demo-signatures.generated.json b/apps/example/src/data/components.demo-signatures.generated.json
index d33eb46d..14cf45b6 100644
--- a/apps/example/src/data/components.demo-signatures.generated.json
+++ b/apps/example/src/data/components.demo-signatures.generated.json
@@ -199,7 +199,7 @@
},
{
"slug": "image-viewer",
- "signature": "689f358eedec7f861aaaace2f164296a96465a39ed4d57f16f200f1ceef8ff6f"
+ "signature": "e2c71455a45c28327b2369c7bb706e36479a1dd500d738d60b3bfb1fc2f17177"
},
{
"slug": "input",
diff --git a/apps/example/src/data/demos/chunk-15.tsx b/apps/example/src/data/demos/chunk-15.tsx
index 7f02df11..93657257 100644
--- a/apps/example/src/data/demos/chunk-15.tsx
+++ b/apps/example/src/data/demos/chunk-15.tsx
@@ -1650,6 +1650,25 @@ function ImageViewerRowDemo() {
);
}
+function ImageViewerRightToLeftDemo() {
+ return (
+
+
+ {ROW_OF_THREE.map((entry) => (
+
+ ))}
+
+
+ );
+}
+
interface JournalPhoto {
id: string;
alt: string;
@@ -1999,6 +2018,7 @@ export const ENTRIES: ComponentEntry[] = [
demos: [
{ label: 'One photo', render: () => },
{ label: 'Three under one root', render: () => },
+ { label: 'Right to left', render: () => },
{
label: 'Photo journal',
id: 'journal',
diff --git a/packages/panelui/src/components/image-viewer/image-viewer-geometry.ts b/packages/panelui/src/components/image-viewer/image-viewer-geometry.ts
index f739f1d6..3c258c9a 100644
--- a/packages/panelui/src/components/image-viewer/image-viewer-geometry.ts
+++ b/packages/panelui/src/components/image-viewer/image-viewer-geometry.ts
@@ -151,6 +151,21 @@ export function snapPage(
return clamp(clamp(target, current - 1, current + 1), 0, count - 1);
}
+/** What a one-finger drag on a picture at its fitted size is for. */
+export type DragIntent = 'undecided' | 'page' | 'dismiss';
+
+/**
+ * Sideways across a gallery turns the page; anything else drags the picture
+ * away. A drag that has not moved yet is `undecided`: some gesture-handler
+ * versions report the activating event with no translation at all, and
+ * comparing zero with zero would take every swipe as a dismiss.
+ */
+export function dragIntent(translationX: number, translationY: number, count: number): DragIntent {
+ 'worklet';
+ if (translationX === 0 && translationY === 0) return 'undecided';
+ return count > 1 && Math.abs(translationX) > Math.abs(translationY) ? 'page' : 'dismiss';
+}
+
/** How far a drag has to travel, counting where it was flung, to close the viewer. */
export const DISMISS_DISTANCE = 110;
diff --git a/packages/panelui/src/components/image-viewer/index.tsx b/packages/panelui/src/components/image-viewer/index.tsx
index 8ebc4b67..e615bcb8 100644
--- a/packages/panelui/src/components/image-viewer/index.tsx
+++ b/packages/panelui/src/components/image-viewer/index.tsx
@@ -44,8 +44,8 @@
* ## One gesture surface, decided at the start
*
* Pinching, panning a zoomed image, turning the page and dragging to dismiss all
- * begin as a one- or two-finger drag, and which one it is gets decided the
- * moment it starts: two fingers zoom, one finger on a zoomed image pans it, one
+ * begin as a one- or two-finger drag, and which one it is gets decided as
+ * soon as it moves: two fingers zoom, one finger on a zoomed image pans it, one
* finger moving sideways across a gallery turns the page, and anything else
* drags the image away. Deciding once keeps a drag from changing its mind
* halfway, which is what makes a viewer feel loose.
@@ -105,6 +105,7 @@ import {
clamp,
coverSize,
dragFade,
+ dragIntent,
dragScale,
fitWithin,
focalTranslation,
@@ -155,10 +156,15 @@ const PHASE_OPENING = 0;
const PHASE_OPEN = 1;
const PHASE_CLOSING = 2;
+/** Pins a subtree's `left` to the screen's left under a right-to-left layout. */
+const PHYSICAL = { direction: 'ltr' } as const;
+
const MODE_NONE = 0;
const MODE_PAN = 1;
const MODE_PAGE = 2;
const MODE_DISMISS = 3;
+/** The pan has started but not yet moved, so there is no direction to decide on. */
+const MODE_UNDECIDED = 4;
/* -------------------------------------------------------------------------- */
/* Registry */
@@ -280,6 +286,13 @@ export interface ImageViewerProps {
* screen while the picture is zoomed or dragged. `0` squares them.
*/
cornerRadius?: number;
+ /**
+ * Which way the gallery runs. In `rtl` the next page sits to the left and a
+ * swipe to the right turns to it. Page numbers, `index` and the screen
+ * reader's next and previous stay in page order either way. To follow the
+ * reading direction, pass `useDirection()`.
+ */
+ dir?: 'ltr' | 'rtl';
}
function ImageViewerRoot({
@@ -298,6 +311,7 @@ function ImageViewerRoot({
closeLabel = 'Close',
inset = DEFAULT_INSET,
cornerRadius = DEFAULT_CORNER_RADIUS,
+ dir = 'ltr',
}: ImageViewerProps) {
const storeRef = useRef(null);
storeRef.current ??= new ViewerStore();
@@ -364,6 +378,7 @@ function ImageViewerRoot({
closeLabel={closeLabel}
inset={inset}
cornerRadius={cornerRadius}
+ sign={dir === 'rtl' ? -1 : 1}
/>
) : null}
@@ -584,6 +599,8 @@ interface ViewerOverlayProps {
closeLabel: string;
inset: number;
cornerRadius: number;
+ /** `-1` when the pages run right to left. */
+ sign: 1 | -1;
}
function ViewerOverlay({
@@ -602,6 +619,7 @@ function ViewerOverlay({
closeLabel,
inset,
cornerRadius,
+ sign,
}: ViewerOverlayProps) {
const version = useSyncExternalStore(store.subscribe, store.getSnapshot);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -662,7 +680,7 @@ function ViewerOverlay({
const shrink = useSharedValue(1);
const fade = useSharedValue(1);
- // The gallery.
+ // The gallery, in page order: `sign` mirrors it on the way to the screen.
const pagerX = useSharedValue(-page * pageWidth);
const pagerStart = useSharedValue(0);
const activePage = useSharedValue(page);
@@ -730,6 +748,19 @@ function ViewerOverlay({
[chrome, dragX, dragY, gestureMode, onExited, phase, reduced, scale, shrink, t, tx, ty]
);
+ /**
+ * A drag or a tap outside closing the viewer. Measured again first, like a
+ * close from `open`: the measure taken when the page turned can predate the
+ * owner scrolling the new page's thumbnail into place.
+ */
+ const closeFromGesture = useCallback(
+ (velocityX: number, velocityY: number) => {
+ measureCurrent(() => runOnUI(startClose)(velocityX, velocityY));
+ onRequestClose();
+ },
+ [measureCurrent, onRequestClose, startClose]
+ );
+
const settleOpen = useCallback(() => {
phase.value = PHASE_OPENING;
const done = (finished?: boolean) => {
@@ -880,6 +911,62 @@ function ViewerOverlay({
settleZoom();
});
+ const decide = (translationX: number, translationY: number) => {
+ 'worklet';
+ const intent = dragIntent(translationX, translationY, count);
+ if (intent === 'undecided') {
+ gestureMode.value = MODE_UNDECIDED;
+ return;
+ }
+ if (intent === 'page') {
+ gestureMode.value = MODE_PAGE;
+ cancelAnimation(pagerX);
+ pagerStart.value = pagerX.value;
+ return;
+ }
+ gestureMode.value = MODE_DISMISS;
+ chrome.value = withTiming(0, { duration: 120 });
+ if (haptics) runOnJS(selectionTick)();
+ };
+
+ /**
+ * Decides a pan that activated without moving, from the first event that
+ * has. Only while the viewer is still open and no pinch has started since:
+ * a close or a second finger arriving first leaves the drag doing nothing.
+ */
+ const resolve = (translationX: number, translationY: number) => {
+ 'worklet';
+ if (gestureMode.value !== MODE_UNDECIDED) return;
+ if (phase.value !== PHASE_OPEN || pinching.value) {
+ gestureMode.value = MODE_NONE;
+ return;
+ }
+ decide(translationX, translationY);
+ };
+
+ /** Moves whatever the drag was decided to move. */
+ const follow = (translationX: number, translationY: number) => {
+ 'worklet';
+ if (gestureMode.value === MODE_PAN) {
+ const boundX = panBound(fit.width, scale.value, W);
+ const boundY = panBound(fit.height, scale.value, H);
+ tx.value = rubberBandClamp(startX.value + translationX, -boundX, boundX, W);
+ ty.value = rubberBandClamp(startY.value + translationY, -boundY, boundY, H);
+ } else if (gestureMode.value === MODE_PAGE) {
+ pagerX.value = rubberBandClamp(
+ pagerStart.value + sign * translationX,
+ -(count - 1) * pageWidth,
+ 0,
+ W
+ );
+ } else if (gestureMode.value === MODE_DISMISS) {
+ dragX.value = translationX;
+ dragY.value = translationY;
+ shrink.value = dragScale(translationY, H);
+ fade.value = dragFade(translationY, H);
+ }
+ };
+
const pan = Gesture.Pan()
.maxPointers(1)
.minDistance(8)
@@ -897,39 +984,22 @@ function ViewerOverlay({
startY.value = ty.value;
return;
}
- if (count > 1 && Math.abs(event.translationX) > Math.abs(event.translationY)) {
- gestureMode.value = MODE_PAGE;
- cancelAnimation(pagerX);
- pagerStart.value = pagerX.value;
- return;
- }
- gestureMode.value = MODE_DISMISS;
- chrome.value = withTiming(0, { duration: 120 });
- if (haptics) runOnJS(selectionTick)();
+ decide(event.translationX, event.translationY);
})
.onUpdate((event) => {
'worklet';
- if (gestureMode.value === MODE_PAN) {
- const boundX = panBound(fit.width, scale.value, W);
- const boundY = panBound(fit.height, scale.value, H);
- tx.value = rubberBandClamp(startX.value + event.translationX, -boundX, boundX, W);
- ty.value = rubberBandClamp(startY.value + event.translationY, -boundY, boundY, H);
- } else if (gestureMode.value === MODE_PAGE) {
- pagerX.value = rubberBandClamp(
- pagerStart.value + event.translationX,
- -(count - 1) * pageWidth,
- 0,
- W
- );
- } else if (gestureMode.value === MODE_DISMISS) {
- dragX.value = event.translationX;
- dragY.value = event.translationY;
- shrink.value = dragScale(event.translationY, H);
- fade.value = dragFade(event.translationY, H);
- }
+ // Activated without a translation: the first update that has moved decides.
+ resolve(event.translationX, event.translationY);
+ follow(event.translationX, event.translationY);
})
.onEnd((event, success) => {
'worklet';
+ // A quick flick can end before any update has moved, so the end event
+ // may be the first to: decide on it and apply it before acting.
+ if (gestureMode.value === MODE_UNDECIDED) {
+ resolve(event.translationX, event.translationY);
+ follow(event.translationX, event.translationY);
+ }
const current = gestureMode.value;
gestureMode.value = MODE_NONE;
if (current === MODE_PAN) {
@@ -946,19 +1016,19 @@ function ViewerOverlay({
ty.value = withDecay({ velocity: event.velocityY, clamp: [-boundY, boundY] });
}
} else if (current === MODE_PAGE) {
- const target = snapPage(pagerX.value, event.velocityX, activePage.value, count, pageWidth);
- pagerX.value = withSpring(-target * pageWidth, {
- ...SETTLE_SPRING,
- velocity: event.velocityX,
- });
+ const velocity = sign * event.velocityX;
+ const target = snapPage(pagerX.value, velocity, activePage.value, count, pageWidth);
+ pagerX.value = withSpring(-target * pageWidth, { ...SETTLE_SPRING, velocity });
if (target !== activePage.value) {
activePage.value = target;
runOnJS(onIndexChange)(target);
}
} else if (current === MODE_DISMISS) {
if (success && shouldDismiss(event.translationY, event.velocityY)) {
- startClose(event.velocityX, event.velocityY);
- runOnJS(onRequestClose)();
+ // Closing now, before the measure: no other gesture starts, and the
+ // `open` effect leaves the flight home to `closeFromGesture`.
+ phase.value = PHASE_CLOSING;
+ runOnJS(closeFromGesture)(event.velocityX, event.velocityY);
return;
}
dragX.value = withSpring(0, SETTLE_SPRING);
@@ -1007,8 +1077,8 @@ function ViewerOverlay({
if (!success || phase.value !== PHASE_OPEN) return;
const onImage = hitsRect(event.x, event.y, fit, scale.value, tx.value, ty.value);
if (!onImage) {
- startClose(0, 0);
- runOnJS(onRequestClose)();
+ phase.value = PHASE_CLOSING;
+ runOnJS(closeFromGesture)(0, 0);
return;
}
chromeOn.value = !chromeOn.value;
@@ -1048,7 +1118,8 @@ function ViewerOverlay({
scale,
settleZoom,
shrink,
- startClose,
+ closeFromGesture,
+ sign,
startScale,
startX,
startY,
@@ -1056,7 +1127,9 @@ function ViewerOverlay({
ty,
]);
- const pagerStyle = useAnimatedStyle(() => ({ transform: [{ translateX: pagerX.value }] }));
+ const pagerStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: sign * pagerX.value }],
+ }));
const chromeStyle = useAnimatedStyle(() => ({
opacity: chrome.value * clamp(t.value, 0, 1) * fade.value,
@@ -1115,13 +1188,19 @@ function ViewerOverlay({
]}
onAccessibilityAction={onAccessibilityAction}
>
-
+ {/* Laid out left to right whatever the app's direction: the pages and
+ the flight are placed in measured screen pixels, which a right-to-left
+ layout would mirror. `sign` does the mirroring the gallery wants. */}
+
{visiblePages.map(({ entry, at }) => (
assert.equal(snapPage(0, 0, 0, 1, width), 0);
});
+/**
+ * Runs a pan the way the viewer does: the activating event, then updates until
+ * one of them decides.
+ */
+function intentOf(events, count = 3) {
+ for (const [x, y] of events) {
+ const intent = dragIntent(x, y, count);
+ if (intent !== 'undecided') return intent;
+ }
+ return 'undecided';
+}
+
+test('ImageViewer decides page or dismiss on the activating event when it has moved (gesture-handler 2)', () => {
+ assert.equal(intentOf([[-12, 2], [-30, 4]]), 'page');
+ assert.equal(intentOf([[12, 2], [30, 4]]), 'page');
+ assert.equal(intentOf([[2, 12], [3, 40]]), 'dismiss');
+ assert.equal(intentOf([[-2, -12]]), 'dismiss');
+ // A single picture has no page to turn to.
+ assert.equal(intentOf([[-12, 2]], 1), 'dismiss');
+});
+
+test('ImageViewer waits for the first move when the activating event has none (gesture-handler 3)', () => {
+ assert.equal(dragIntent(0, 0, 3), 'undecided');
+ // Next page left to right, and right to left: either way it is sideways.
+ assert.equal(intentOf([[0, 0], [-12, 2], [-30, 4]]), 'page');
+ assert.equal(intentOf([[0, 0], [12, 2], [30, 4]]), 'page');
+ assert.equal(intentOf([[0, 0], [0, 0], [9, -1]]), 'page');
+ // Up or down drags the picture away.
+ assert.equal(intentOf([[0, 0], [1, 14]]), 'dismiss');
+ assert.equal(intentOf([[0, 0], [-1, -14]]), 'dismiss');
+ assert.equal(intentOf([[0, 0], [12, 2]], 1), 'dismiss');
+ // A pan that never moves decides nothing, and so neither pages nor dismisses.
+ assert.equal(intentOf([[0, 0], [0, 0]]), 'undecided');
+});
+
+test('ImageViewer turns a right-to-left gallery with the swipe mirrored into page order', () => {
+ const width = 416;
+ const rtl = -1;
+ // A swipe to the right, mirrored, is a step towards the next page.
+ assert.equal(snapPage(rtl * 300, rtl * 0, 0, 5, width), 1);
+ assert.equal(snapPage(rtl * 100, rtl * 1200, 0, 5, width), 1);
+ // And a swipe to the left goes back.
+ assert.equal(snapPage(-width + rtl * -300, rtl * -1200, 1, 5, width), 0);
+});
+
test('ImageViewer closes on a long drag or a flick, and not on a throw back', () => {
assert.equal(shouldDismiss(40, 0), false);
assert.equal(shouldDismiss(140, 0), true);
diff --git a/skills/panelui/components/image-viewer.md b/skills/panelui/components/image-viewer.md
index 19470c38..360dc678 100644
--- a/skills/panelui/components/image-viewer.md
+++ b/skills/panelui/components/image-viewer.md
@@ -42,6 +42,7 @@ import { ImageViewer } from 'panelui-native';
| `closeLabel` | `string` | `Close` | Read out for the close button. |
| `inset` | `number` | `16` | Space kept between the open picture and the edges of the screen, in points. Measured from the safe area at the top and bottom, and the larger of the two is used for both, so the picture stays centred. `0` fills the screen. |
| `cornerRadius` | `number` | `16` | Corner radius of the open picture, in points. The corners stay this size on screen while the picture is zoomed or dragged. `0` squares them. |
+| `dir` | `'ltr' \| 'rtl'` | `ltr` | Which way the gallery runs. In `rtl` the next page sits to the left and a swipe to the right turns to it. Page numbers, `index` and the screen reader's next and previous stay in page order either way. To follow the reading direction, pass `useDirection()`. |
#### `ImageViewerTriggerProps`