From 21a824c64e7cff5a21b92bb267d8d72982ada349 Mon Sep 17 00:00:00 2001 From: hoanggbao00 <78465334+hoanggbao00@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:41:13 +0700 Subject: [PATCH 1/6] fix(image-viewer): decide page or dismiss on the first move With react-native-gesture-handler 3 the pan's activating event carries no translation, so `|translationX| > |translationY|` compared 0 with 0 and every drag was taken as a drag to dismiss: a sideways swipe in a gallery pulled the picture off instead of turning the page. The decision moves into `dragIntent()`, which calls a drag that has not moved yet `undecided`. The pan then waits, and the first update that has moved decides between paging and dismissing with the same rule. With gesture-handler 2, which reports the translation on activation, it decides at the start as before. The contract tests run both sequences. --- apps/docs/public/r/image-viewer.json | 4 +- .../image-viewer/image-viewer-geometry.ts | 15 ++++++++ .../src/components/image-viewer/index.tsx | 37 +++++++++++++------ .../test/image-viewer-geometry.test.mjs | 36 ++++++++++++++++++ 4 files changed, 79 insertions(+), 13 deletions(-) diff --git a/apps/docs/public/r/image-viewer.json b/apps/docs/public/r/image-viewer.json index 541828fd..80d412ac 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\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\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 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 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 if (gestureMode.value === MODE_UNDECIDED) decide(event.translationX, event.translationY);\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" } ] } 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..43c874c3 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, @@ -159,6 +160,8 @@ 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 */ @@ -880,6 +883,24 @@ 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)(); + }; + const pan = Gesture.Pan() .maxPointers(1) .minDistance(8) @@ -897,18 +918,12 @@ 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'; + // Activated without a translation: the first update that has moved decides. + if (gestureMode.value === MODE_UNDECIDED) decide(event.translationX, event.translationY); if (gestureMode.value === MODE_PAN) { const boundX = panBound(fit.width, scale.value, W); const boundY = panBound(fit.height, scale.value, H); diff --git a/packages/panelui/test/image-viewer-geometry.test.mjs b/packages/panelui/test/image-viewer-geometry.test.mjs index c5b77662..58087b1f 100644 --- a/packages/panelui/test/image-viewer-geometry.test.mjs +++ b/packages/panelui/test/image-viewer-geometry.test.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { coverSize, dragFade, + dragIntent, dragScale, fitContain, fitWithin, @@ -102,6 +103,41 @@ test('ImageViewer turns at most one page, from where a flick was heading', () => 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 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); From 540faff4f8378a567c8f71f6c1f0eef1a94a321b Mon Sep 17 00:00:00 2001 From: hoanggbao00 <78465334+hoanggbao00@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:41:13 +0700 Subject: [PATCH 2/6] fix(image-viewer): measure the thumbnail again before a gesture closes Closing by changing `open` measures the current thumbnail before the flight home, but a drag away or a tap outside the picture started the flight from the rectangle measured when the page last turned. An owner that scrolls its own carousel to follow `onIndexChange` moves the thumbnail after that measure, so the picture flew back to where the thumbnail used to be. Both gestures now mark the viewer as closing on the UI thread at once, measure, and then start the same flight. --- apps/docs/public/r/image-viewer.json | 2 +- .../src/components/image-viewer/index.tsx | 25 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/docs/public/r/image-viewer.json b/apps/docs/public/r/image-viewer.json index 80d412ac..96257903 100644 --- a/apps/docs/public/r/image-viewer.json +++ b/apps/docs/public/r/image-viewer.json @@ -29,7 +29,7 @@ { "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 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\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\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 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 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 if (gestureMode.value === MODE_UNDECIDED) decide(event.translationX, event.translationY);\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\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\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 /**\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 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 if (gestureMode.value === MODE_UNDECIDED) decide(event.translationX, event.translationY);\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 // 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 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" } ] } diff --git a/packages/panelui/src/components/image-viewer/index.tsx b/packages/panelui/src/components/image-viewer/index.tsx index 43c874c3..cea3cc88 100644 --- a/packages/panelui/src/components/image-viewer/index.tsx +++ b/packages/panelui/src/components/image-viewer/index.tsx @@ -733,6 +733,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) => { @@ -972,8 +985,10 @@ function ViewerOverlay({ } } 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); @@ -1022,8 +1037,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; @@ -1063,7 +1078,7 @@ function ViewerOverlay({ scale, settleZoom, shrink, - startClose, + closeFromGesture, startScale, startX, startY, From f10439eb8ea5f2ffdbfbb50797e08d09a9f440f8 Mon Sep 17 00:00:00 2001 From: hoanggbao00 <78465334+hoanggbao00@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:41:13 +0700 Subject: [PATCH 3/6] feat(image-viewer): page right to left with `dir` A gallery can now run right to left: with `dir="rtl"` the next picture sits to the left and a swipe to the right turns to it. It defaults to `ltr`, so nothing changes for existing apps; passing `useDirection()` follows the reading direction. The pager still counts in page order and only its offset on screen is mirrored, so the counter, `index`, `onIndexChange` and the screen reader's increment and decrement keep meaning next and previous. The pager is pinned to a left-to-right layout, because the pages and the flight are placed in measured screen pixels that an RTL layout would mirror a second time. The docs gain a Right to left example and the example app a demo. --- .../content/docs/components/image-viewer.mdx | 21 ++++++++++ apps/docs/public/r/image-viewer.json | 2 +- .../skills/panelui/components/image-viewer.md | 1 + apps/docs/scripts/usage/image-viewer.json | 5 +++ apps/example/src/data/demos/chunk-15.tsx | 20 +++++++++ .../src/components/image-viewer/index.tsx | 42 ++++++++++++++----- .../test/image-viewer-geometry.test.mjs | 10 +++++ skills/panelui/components/image-viewer.md | 1 + 8 files changed, 91 insertions(+), 11 deletions(-) diff --git a/apps/docs/content/docs/components/image-viewer.mdx b/apps/docs/content/docs/components/image-viewer.mdx index 3d9fbdcf..324ce9d3 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, so lay the triggers out the same way round. The counter, `index` and the screen reader's next and previous still count in page order. `dir` defaults to `ltr`; to follow the reading direction, pass `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 96257903..514d2ae0 100644 --- a/apps/docs/public/r/image-viewer.json +++ b/apps/docs/public/r/image-viewer.json @@ -29,7 +29,7 @@ { "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 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\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\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 /**\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 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 if (gestureMode.value === MODE_UNDECIDED) decide(event.translationX, event.translationY);\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 // 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 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 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 if (gestureMode.value === MODE_UNDECIDED) decide(event.translationX, event.translationY);\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 + sign * 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 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..84f922b2 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, so lay the triggers out the same way round. The counter, `index` and the screen reader's next and previous still count in page order. `dir` defaults to `ltr`; to follow the reading direction, pass `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/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/index.tsx b/packages/panelui/src/components/image-viewer/index.tsx index cea3cc88..0f029b5b 100644 --- a/packages/panelui/src/components/image-viewer/index.tsx +++ b/packages/panelui/src/components/image-viewer/index.tsx @@ -156,6 +156,9 @@ 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; @@ -283,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({ @@ -301,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(); @@ -367,6 +378,7 @@ function ImageViewerRoot({ closeLabel={closeLabel} inset={inset} cornerRadius={cornerRadius} + sign={dir === 'rtl' ? -1 : 1} /> ) : null} @@ -587,6 +599,8 @@ interface ViewerOverlayProps { closeLabel: string; inset: number; cornerRadius: number; + /** `-1` when the pages run right to left. */ + sign: 1 | -1; } function ViewerOverlay({ @@ -605,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 @@ -665,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); @@ -944,7 +959,7 @@ function ViewerOverlay({ ty.value = rubberBandClamp(startY.value + event.translationY, -boundY, boundY, H); } else if (gestureMode.value === MODE_PAGE) { pagerX.value = rubberBandClamp( - pagerStart.value + event.translationX, + pagerStart.value + sign * event.translationX, -(count - 1) * pageWidth, 0, W @@ -974,11 +989,9 @@ 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); @@ -1079,6 +1092,7 @@ function ViewerOverlay({ settleZoom, shrink, closeFromGesture, + sign, startScale, startX, startY, @@ -1086,7 +1100,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, @@ -1145,13 +1161,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 }) => ( { + 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` From 73a56441e6d252117f88287aac2aced5d2b6dbec Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Thu, 22 Oct 2026 18:14:31 +0300 Subject: [PATCH 4/6] fix(image-viewer): settle a pan that ends before it moves A pan that activates without a translation waits for the first update that has moved to decide between paging and dismissing. A quick flick can end before any update arrives, and the end event then carried the only movement: the drag was dropped, neither turning the page nor closing. The end event now decides and applies its translation before the gesture acts on it. Deciding late also means a pinch or a close can start in between. An undecided pan now settles to nothing unless the viewer is still open and no pinch has begun, so neither can turn into a drag away. Co-Authored-By: Claude Opus 5.5 --- apps/docs/public/r/image-viewer.json | 2 +- .../src/components/image-viewer/index.tsx | 65 +++++++++++++------ 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/apps/docs/public/r/image-viewer.json b/apps/docs/public/r/image-viewer.json index 514d2ae0..3146a738 100644 --- a/apps/docs/public/r/image-viewer.json +++ b/apps/docs/public/r/image-viewer.json @@ -29,7 +29,7 @@ { "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 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 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 if (gestureMode.value === MODE_UNDECIDED) decide(event.translationX, event.translationY);\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 + sign * 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 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" + "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/packages/panelui/src/components/image-viewer/index.tsx b/packages/panelui/src/components/image-viewer/index.tsx index 0f029b5b..e615bcb8 100644 --- a/packages/panelui/src/components/image-viewer/index.tsx +++ b/packages/panelui/src/components/image-viewer/index.tsx @@ -929,6 +929,44 @@ function ViewerOverlay({ 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) @@ -951,28 +989,17 @@ function ViewerOverlay({ .onUpdate((event) => { 'worklet'; // Activated without a translation: the first update that has moved decides. - if (gestureMode.value === MODE_UNDECIDED) decide(event.translationX, event.translationY); - 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 + sign * 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); - } + 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) { From e98bd7f5bae671646fa086256a0da6f40de61774 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Thu, 22 Oct 2026 18:14:31 +0300 Subject: [PATCH 5/6] docs(image-viewer): match the trigger row to the layout direction The Right to left example reverses its row with `flex-row-reverse`, which is right in an app laid out left to right. Under a right-to-left layout rows already run that way, and reversing one again puts the first picture on the wrong side. The description now says which goes with which: `flex-row-reverse` left to right, `flex-row` with `dir={useDirection()}` right to left. Co-Authored-By: Claude Opus 5.5 --- apps/docs/content/docs/components/image-viewer.mdx | 2 +- apps/docs/scripts/usage/image-viewer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/components/image-viewer.mdx b/apps/docs/content/docs/components/image-viewer.mdx index 324ce9d3..a3c78512 100644 --- a/apps/docs/content/docs/components/image-viewer.mdx +++ b/apps/docs/content/docs/components/image-viewer.mdx @@ -116,7 +116,7 @@ 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, so lay the triggers out the same way round. The counter, `index` and the screen reader's next and previous still count in page order. `dir` defaults to `ltr`; to follow the reading direction, pass `useDirection()`. +`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 diff --git a/apps/docs/scripts/usage/image-viewer.json b/apps/docs/scripts/usage/image-viewer.json index 84f922b2..af749683 100644 --- a/apps/docs/scripts/usage/image-viewer.json +++ b/apps/docs/scripts/usage/image-viewer.json @@ -46,7 +46,7 @@ }, { "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, so lay the triggers out the same way round. The counter, `index` and the screen reader's next and previous still count in page order. `dir` defaults to `ltr`; to follow the reading direction, pass `useDirection()`.", + "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" }, { From 8bf763337636147bf05db386d69e7d9ec3acfeb4 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Thu, 22 Oct 2026 18:28:03 +0300 Subject: [PATCH 6/6] chore(example): regenerate the demo metadata for Right to left The new ImageViewer demo changed the screen's demo signature, and the generated metadata the example's CI check compares against was left behind. Co-Authored-By: Claude Opus 5.5 --- apps/example/src/data/components.demo-signatures.generated.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",