Refresh lightbox designs (#10330)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import {useCallback} from 'react'
|
||||
|
||||
import {shareImageModal} from '#/lib/media/manip'
|
||||
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
|
||||
import ImageView from '#/components/Lightbox/pager/ImagePager'
|
||||
import {useLightbox, useLightboxControls} from '#/components/Lightbox/state'
|
||||
|
||||
export function Lightbox() {
|
||||
const {activeLightbox} = useLightbox()
|
||||
const {closeLightbox} = useLightboxControls()
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
closeLightbox()
|
||||
}, [closeLightbox])
|
||||
|
||||
const saveImageToAlbum = useSaveImageToMediaLibrary()
|
||||
|
||||
return (
|
||||
<ImageView
|
||||
lightbox={activeLightbox}
|
||||
onRequestClose={onClose}
|
||||
onPressSave={saveImageToAlbum}
|
||||
onPressShare={uri => shareImageModal({uri})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
|
||||
import {saveImageToMediaLibrary} from '#/lib/media/manip'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {
|
||||
atoms as a,
|
||||
flatten,
|
||||
ThemeProvider,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Backdrop} from '#/components/Dialog'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
|
||||
import {
|
||||
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon,
|
||||
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
|
||||
} from '#/components/icons/Chevron'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {CircleChromeButton} from '#/components/Lightbox/chrome/CircleChromeButton'
|
||||
import {PagerDots} from '#/components/Lightbox/chrome/PagerDots'
|
||||
import {useLightbox, useLightboxControls} from '#/components/Lightbox/state'
|
||||
import {type ImageSource} from '#/components/Lightbox/types'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Lightbox() {
|
||||
const {activeLightbox} = useLightbox()
|
||||
const {closeLightbox} = useLightboxControls()
|
||||
const isActive = !!activeLightbox
|
||||
|
||||
if (!isActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
const initialIndex = activeLightbox.index
|
||||
const imgs = activeLightbox.images
|
||||
return (
|
||||
<ThemeProvider theme="dark">
|
||||
<LightboxContainer handleBackgroundPress={closeLightbox}>
|
||||
<LightboxGallery
|
||||
key={activeLightbox.id}
|
||||
imgs={imgs}
|
||||
initialIndex={initialIndex}
|
||||
onClose={closeLightbox}
|
||||
/>
|
||||
</LightboxContainer>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function LightboxContainer({
|
||||
children,
|
||||
handleBackgroundPress,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
handleBackgroundPress: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
FocusGuards.useFocusGuards()
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityHint={undefined}
|
||||
accessibilityLabel={_(msg`Close image viewer`)}
|
||||
onPress={handleBackgroundPress}
|
||||
style={[a.fixed, a.inset_0, a.z_10]}>
|
||||
<Backdrop />
|
||||
<RemoveScrollBar />
|
||||
<FocusScope.FocusScope loop trapped asChild>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={_(msg`Image viewer`)}
|
||||
style={{position: 'absolute', inset: 0}}>
|
||||
{children}
|
||||
</div>
|
||||
</FocusScope.FocusScope>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function LightboxGallery({
|
||||
imgs,
|
||||
initialIndex = 0,
|
||||
onClose,
|
||||
}: {
|
||||
imgs: ImageSource[]
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
const [index, setIndex] = useState(initialIndex)
|
||||
const [hasAnyLoaded, setAnyHasLoaded] = useState(false)
|
||||
const [isAltExpanded, setAltExpanded] = useState(false)
|
||||
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
const canGoLeft = index >= 1
|
||||
const canGoRight = index < imgs.length - 1
|
||||
const onPressLeft = useCallback(() => {
|
||||
if (canGoLeft) {
|
||||
setIndex(index - 1)
|
||||
}
|
||||
}, [index, canGoLeft])
|
||||
const onPressRight = useCallback(() => {
|
||||
if (canGoRight) {
|
||||
setIndex(index + 1)
|
||||
}
|
||||
}, [index, canGoRight])
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
onPressLeft()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
onPressRight()
|
||||
}
|
||||
},
|
||||
[onClose, onPressLeft, onPressRight],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onKeyDown])
|
||||
|
||||
// Push a history entry so the browser back button closes the lightbox
|
||||
// instead of navigating away from the page.
|
||||
const closedByPopStateRef = useRef(false)
|
||||
useEffect(() => {
|
||||
history.pushState({lightbox: true}, '')
|
||||
|
||||
const handlePopState = () => {
|
||||
closedByPopStateRef.current = true
|
||||
onClose()
|
||||
}
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState)
|
||||
// Only pop our entry if it's still the current one. If navigation
|
||||
// already pushed a new entry on top, leave the orphaned entry —
|
||||
// it shares the same URL so traversing through it is harmless.
|
||||
if (
|
||||
!closedByPopStateRef.current &&
|
||||
(history.state as {lightbox?: boolean})?.lightbox
|
||||
) {
|
||||
history.back()
|
||||
}
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const delayedFadeInAnim = !reduceMotionEnabled && [
|
||||
a.fade_in,
|
||||
{animationDelay: '0.2s', animationFillMode: 'both'},
|
||||
]
|
||||
|
||||
const img = imgs[index]
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
<View style={[a.flex_1, a.justify_center, a.align_center]}>
|
||||
<LightboxGalleryItem
|
||||
key={index}
|
||||
source={img.uri}
|
||||
alt={img.alt}
|
||||
type={img.type}
|
||||
hasAnyLoaded={hasAnyLoaded}
|
||||
onLoad={() => setAnyHasLoaded(true)}
|
||||
/>
|
||||
{canGoLeft && (
|
||||
<Button
|
||||
onPress={onPressLeft}
|
||||
style={[
|
||||
a.absolute,
|
||||
styles.leftBtn,
|
||||
styles.blurredBackdrop,
|
||||
a.transition_color,
|
||||
delayedFadeInAnim,
|
||||
]}
|
||||
hoverStyle={styles.blurredBackdropHover}
|
||||
color="secondary"
|
||||
label={_(msg`Previous image`)}
|
||||
shape="round"
|
||||
size={gtPhone ? 'large' : 'small'}>
|
||||
<ChevronLeftIcon
|
||||
size={gtPhone ? 'md' : 'sm'}
|
||||
style={{color: t.palette.white}}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{canGoRight && (
|
||||
<Button
|
||||
onPress={onPressRight}
|
||||
style={[
|
||||
a.absolute,
|
||||
styles.rightBtn,
|
||||
styles.blurredBackdrop,
|
||||
a.transition_color,
|
||||
delayedFadeInAnim,
|
||||
]}
|
||||
hoverStyle={styles.blurredBackdropHover}
|
||||
color="secondary"
|
||||
label={_(msg`Next image`)}
|
||||
shape="round"
|
||||
size={gtPhone ? 'large' : 'small'}>
|
||||
<ChevronRightIcon
|
||||
size={gtPhone ? 'md' : 'sm'}
|
||||
style={{color: t.palette.white}}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
{img.alt ? (
|
||||
<View
|
||||
style={[
|
||||
a.px_4xl,
|
||||
a.py_2xl,
|
||||
{backgroundColor: 'rgba(0, 0, 0, 0.45)'},
|
||||
delayedFadeInAnim,
|
||||
]}>
|
||||
<Pressable
|
||||
accessibilityLabel={_(msg`Expand alt text`)}
|
||||
accessibilityHint={_(
|
||||
msg`If alt text is long, toggles alt text expanded state`,
|
||||
)}
|
||||
onPress={() => {
|
||||
setAltExpanded(!isAltExpanded)
|
||||
}}>
|
||||
<Text
|
||||
style={[a.text_md, a.leading_snug, {color: '#fff'}]}
|
||||
numberOfLines={isAltExpanded ? 0 : 3}
|
||||
ellipsizeMode="tail">
|
||||
{img.alt}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{imgs.length > 1 && (
|
||||
<div aria-live="polite" aria-atomic="true" style={a.sr_only}>
|
||||
<Text>{_(msg`Image ${index + 1} of ${imgs.length}`)}</Text>
|
||||
</div>
|
||||
)}
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Image options`)}>
|
||||
{({props}) => (
|
||||
<Pressable
|
||||
{...props}
|
||||
accessible={false}
|
||||
style={[a.absolute, styles.menuBtn, delayedFadeInAnim]}>
|
||||
<CircleChromeButton
|
||||
icon={EllipsisIcon}
|
||||
iconStyle={{transform: [{rotate: '90deg'}]}}
|
||||
label={_(msg`Image options`)}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Share image`)}
|
||||
onPress={async () => {
|
||||
const url = img.uri
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
'share' in navigator &&
|
||||
navigator.share
|
||||
) {
|
||||
try {
|
||||
await navigator.share({url})
|
||||
} catch {
|
||||
// User cancelled or share failed; no-op
|
||||
}
|
||||
} else if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.clipboard
|
||||
) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
Toast.show(_(msg`Link copied to clipboard`))
|
||||
} catch {
|
||||
Toast.show(_(msg`Failed to copy link`), {type: 'error'})
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Share image</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ShareIcon} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Download image`)}
|
||||
onPress={() => {
|
||||
saveImageToMediaLibrary({uri: img.uri}).then(
|
||||
() => {
|
||||
Toast.show(_(msg`Image saved`))
|
||||
},
|
||||
() => {
|
||||
Toast.show(_(msg`Failed to save image`), {type: 'error'})
|
||||
},
|
||||
)
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Download image</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={DownloadIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
<View style={[a.absolute, styles.closeBtn, delayedFadeInAnim]}>
|
||||
<CircleChromeButton
|
||||
icon={XIcon}
|
||||
label={_(msg`Close image viewer`)}
|
||||
onPress={onClose}
|
||||
/>
|
||||
</View>
|
||||
{imgs.length > 1 && (
|
||||
<View
|
||||
style={[a.absolute, styles.pagerDots, delayedFadeInAnim]}
|
||||
pointerEvents="none">
|
||||
<PagerDots count={imgs.length} activeIndex={index} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function LightboxGalleryItem({
|
||||
source,
|
||||
alt,
|
||||
type,
|
||||
onLoad,
|
||||
hasAnyLoaded,
|
||||
}: {
|
||||
source: string
|
||||
alt: string | undefined
|
||||
type: ImageSource['type']
|
||||
onLoad: () => void
|
||||
hasAnyLoaded: boolean
|
||||
}) {
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
const [isFirstToLoad] = useState(!hasAnyLoaded)
|
||||
|
||||
/**
|
||||
* We want to show a zoom/fade in animation when the lightbox first opens.
|
||||
* To avoid showing it as we switch between images, we keep track in the parent
|
||||
* whether any image has loaded yet. We then save what the value of this is on first
|
||||
* render (as when it changes, we don't want to then *remove* then animation). when
|
||||
* the image loads, if this is the first image to load, we play the animation.
|
||||
*
|
||||
* We also use this `hasLoaded` state to show a loading indicator. This is on a 1s
|
||||
* delay and then a slow fade in to avoid flicker. -sfn
|
||||
*/
|
||||
const zoomInWhenReady =
|
||||
!reduceMotionEnabled &&
|
||||
isFirstToLoad &&
|
||||
(hasAnyLoaded
|
||||
? [a.zoom_fade_in, {animationDuration: '0.5s'}]
|
||||
: {opacity: 0})
|
||||
|
||||
const handleLoad = () => {
|
||||
setHasLoaded(true)
|
||||
onLoad()
|
||||
}
|
||||
|
||||
let image = null
|
||||
switch (type) {
|
||||
case 'circle-avi':
|
||||
case 'rect-avi':
|
||||
image = (
|
||||
<img
|
||||
src={source}
|
||||
style={flatten([
|
||||
styles.avi,
|
||||
{
|
||||
borderRadius:
|
||||
type === 'circle-avi' ? '50%' : type === 'rect-avi' ? '10%' : 0,
|
||||
},
|
||||
zoomInWhenReady,
|
||||
])}
|
||||
alt={alt}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'image':
|
||||
image = (
|
||||
<Image
|
||||
source={{uri: source}}
|
||||
alt={alt}
|
||||
style={[a.w_full, a.h_full, zoomInWhenReady]}
|
||||
onLoad={handleLoad}
|
||||
contentFit="contain"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{image}
|
||||
{!hasLoaded && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.fade_in,
|
||||
{
|
||||
opacity: 0,
|
||||
animationDuration: '500ms',
|
||||
animationDelay: '1s',
|
||||
animationFillMode: 'both',
|
||||
},
|
||||
]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
avi: {
|
||||
// @ts-ignore web-only
|
||||
maxWidth: `calc(min(400px, 100vw))`,
|
||||
// @ts-ignore web-only
|
||||
maxHeight: `calc(min(400px, 100vh))`,
|
||||
padding: 16,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
menuBtn: {
|
||||
top: 20,
|
||||
left: 20,
|
||||
},
|
||||
closeBtn: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
},
|
||||
pagerDots: {
|
||||
top: 20,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
leftBtn: {
|
||||
left: 20,
|
||||
right: 'auto',
|
||||
top: '50%',
|
||||
},
|
||||
rightBtn: {
|
||||
right: 20,
|
||||
left: 'auto',
|
||||
top: '50%',
|
||||
},
|
||||
blurredBackdrop: {
|
||||
backgroundColor: '#00000077',
|
||||
// @ts-expect-error web only -sfn
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
blurredBackdropHover: {
|
||||
backgroundColor: '#00000088',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import {type ComponentType} from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
type TextStyle,
|
||||
} from 'react-native'
|
||||
import {BlurView} from 'expo-blur'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {type Props as IconProps} from '#/components/icons/common'
|
||||
|
||||
type Props = {
|
||||
icon: ComponentType<IconProps>
|
||||
iconStyle?: StyleProp<TextStyle>
|
||||
label: string
|
||||
onPress?: PressableProps['onPress']
|
||||
testID?: string
|
||||
}
|
||||
|
||||
const SIZE = 44
|
||||
const RADIUS = 24
|
||||
const ICON = 24
|
||||
|
||||
export function CircleChromeButton({
|
||||
icon: Icon,
|
||||
iconStyle,
|
||||
label,
|
||||
onPress,
|
||||
testID,
|
||||
}: Props) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
|
||||
<BlurView intensity={20} tint="dark" style={styles.inner}>
|
||||
<Icon width={ICON} fill="#fff" style={iconStyle} />
|
||||
</BlurView>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
borderRadius: RADIUS,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
inner: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import {type ComponentType} from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
type TextStyle,
|
||||
View,
|
||||
} from 'react-native'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {type Props as IconProps} from '#/components/icons/common'
|
||||
|
||||
type Props = {
|
||||
icon: ComponentType<IconProps>
|
||||
iconStyle?: StyleProp<TextStyle>
|
||||
label: string
|
||||
onPress?: PressableProps['onPress']
|
||||
testID?: string
|
||||
}
|
||||
|
||||
const SIZE = 44
|
||||
const RADIUS = 24
|
||||
const ICON = 24
|
||||
|
||||
export function CircleChromeButton({
|
||||
icon: Icon,
|
||||
iconStyle,
|
||||
label,
|
||||
onPress,
|
||||
testID,
|
||||
}: Props) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
style={({pressed}) => [styles.root, pressed && styles.pressed]}>
|
||||
<View style={styles.inner}>
|
||||
<Icon width={ICON} fill="#fff" style={iconStyle} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
borderRadius: RADIUS,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
inner: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
// @ts-expect-error web-only
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import {useRef} from 'react'
|
||||
import {
|
||||
LayoutAnimation,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Props = {
|
||||
altText: string | undefined
|
||||
isAltExpanded: boolean
|
||||
onToggleAltExpanded: () => void
|
||||
}
|
||||
|
||||
export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
|
||||
const {_} = useLingui()
|
||||
const insets = useSafeAreaInsets()
|
||||
const isMomentumScrolling = useRef(false)
|
||||
|
||||
if (!altText) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.root, {paddingBottom: insets.bottom + 8}]}
|
||||
pointerEvents="box-none">
|
||||
<View style={[a.mx_md, styles.altWrap]}>
|
||||
<ScrollView
|
||||
scrollEnabled={isAltExpanded}
|
||||
onMomentumScrollBegin={() => {
|
||||
isMomentumScrolling.current = true
|
||||
}}
|
||||
onMomentumScrollEnd={() => {
|
||||
isMomentumScrolling.current = false
|
||||
}}
|
||||
contentContainerStyle={[a.px_md, a.py_sm]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Expand alt text`)}
|
||||
accessibilityHint=""
|
||||
onPress={() => {
|
||||
if (isMomentumScrolling.current) return
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 450,
|
||||
update: {type: 'spring', springDamping: 1},
|
||||
})
|
||||
onToggleAltExpanded()
|
||||
}}>
|
||||
<Text
|
||||
emoji
|
||||
selectable
|
||||
style={[a.text_sm, styles.altText]}
|
||||
numberOfLines={isAltExpanded ? undefined : 3}>
|
||||
{altText}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
altWrap: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
altText: {
|
||||
color: '#fff',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
|
||||
import {CircleChromeButton} from './CircleChromeButton'
|
||||
import {ImageMenu} from './ImageMenu'
|
||||
import {PagerDots} from './PagerDots'
|
||||
|
||||
type Props = {
|
||||
onRequestClose: () => void
|
||||
onPressShare: () => void
|
||||
onPressSave: () => void
|
||||
imageCount: number
|
||||
activeIndex: number
|
||||
}
|
||||
|
||||
export function Header({
|
||||
onRequestClose,
|
||||
onPressShare,
|
||||
onPressSave,
|
||||
imageCount,
|
||||
activeIndex,
|
||||
}: Props) {
|
||||
const {_} = useLingui()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.root,
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.px_md,
|
||||
{paddingTop: insets.top + 8},
|
||||
]}
|
||||
pointerEvents="box-none">
|
||||
<ImageMenu onPressShare={onPressShare} onPressSave={onPressSave} />
|
||||
<PagerDots count={imageCount} activeIndex={activeIndex} />
|
||||
<CircleChromeButton
|
||||
icon={CloseIcon}
|
||||
label={_(msg`Close image`)}
|
||||
onPress={onRequestClose}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {Modal, Pressable, StyleSheet, View} from 'react-native'
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||
import {type Props as IconProps} from '#/components/icons/common'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
||||
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {CircleChromeButton} from './CircleChromeButton'
|
||||
|
||||
type Props = {
|
||||
onPressShare: () => void
|
||||
onPressSave: () => void
|
||||
}
|
||||
|
||||
type Anchor = {x: number; y: number; width: number; height: number}
|
||||
|
||||
const MENU_WIDTH = 160
|
||||
const GAP = 6
|
||||
const CARD_BG = '#000000'
|
||||
const CARD_BORDER = '#232e3e'
|
||||
const ITEM_TEXT = '#f9fafb'
|
||||
|
||||
const SPRING_IN = {damping: 18, mass: 0.6, stiffness: 240}
|
||||
const TIMING_OUT = {duration: 150}
|
||||
|
||||
export function ImageMenu({onPressShare, onPressSave}: Props) {
|
||||
const {_} = useLingui()
|
||||
const triggerRef = useRef<View>(null)
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null)
|
||||
const progress = useSharedValue(0)
|
||||
|
||||
const open = () => {
|
||||
triggerRef.current?.measureInWindow((x, y, width, height) => {
|
||||
setAnchor({x, y, width, height})
|
||||
setIsMounted(true)
|
||||
progress.set(withSpring(1, SPRING_IN))
|
||||
})
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
progress.set(
|
||||
withTiming(0, TIMING_OUT, finished => {
|
||||
if (finished) {
|
||||
runOnJS(setIsMounted)(false)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const runAction = (action: () => void) => {
|
||||
close()
|
||||
action()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View ref={triggerRef} collapsable={false}>
|
||||
<CircleChromeButton
|
||||
icon={DotsIcon}
|
||||
iconStyle={{transform: [{rotate: '90deg'}]}}
|
||||
label={_(msg`Image options`)}
|
||||
onPress={open}
|
||||
/>
|
||||
</View>
|
||||
<Modal
|
||||
transparent
|
||||
visible={isMounted}
|
||||
animationType="none"
|
||||
onRequestClose={close}
|
||||
statusBarTranslucent>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Close menu`)}
|
||||
accessibilityHint=""
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={close}
|
||||
/>
|
||||
{anchor && (
|
||||
<MenuCard anchor={anchor} progress={progress}>
|
||||
<MenuItem
|
||||
icon={ShareIcon}
|
||||
label={_(msg`Share image`)}
|
||||
onPress={() => runAction(onPressShare)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={DownloadIcon}
|
||||
label={_(msg`Save image`)}
|
||||
onPress={() => runAction(onPressSave)}
|
||||
/>
|
||||
</MenuCard>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuCard({
|
||||
anchor,
|
||||
progress,
|
||||
children,
|
||||
}: {
|
||||
anchor: Anchor
|
||||
progress: ReturnType<typeof useSharedValue<number>>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: progress.get(),
|
||||
transform: [{scale: interpolate(progress.get(), [0, 1], [0.9, 1])}],
|
||||
}))
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
a.absolute,
|
||||
styles.card,
|
||||
{
|
||||
top: anchor.y + anchor.height + GAP,
|
||||
left: anchor.x,
|
||||
transformOrigin: 'top left',
|
||||
},
|
||||
animatedStyle,
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
icon: React.ComponentType<IconProps>
|
||||
label: string
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint=""
|
||||
onPress={onPress}
|
||||
style={({pressed}) => [styles.item, pressed && styles.itemPressed]}>
|
||||
<Icon width={18} fill={ITEM_TEXT} />
|
||||
<Text style={styles.itemText}>{label}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
width: MENU_WIDTH,
|
||||
padding: 8,
|
||||
borderRadius: 16,
|
||||
borderWidth: 0.5,
|
||||
borderColor: CARD_BORDER,
|
||||
backgroundColor: CARD_BG,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 4},
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
item: {
|
||||
height: 44,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
itemPressed: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
itemText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
lineHeight: 19.5,
|
||||
color: ITEM_TEXT,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
type Props = {
|
||||
count: number
|
||||
activeIndex: number
|
||||
}
|
||||
|
||||
const ACTIVE = 6
|
||||
const INACTIVE = 4
|
||||
const GAP = 5
|
||||
|
||||
export function PagerDots({count, activeIndex}: Props) {
|
||||
if (count <= 1) return null
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.justify_center, styles.row]}>
|
||||
{Array.from({length: count}).map((_, i) => {
|
||||
const isActive = i === activeIndex
|
||||
return (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
isActive ? styles.active : styles.inactive,
|
||||
isActive ? styles.activeDot : styles.inactiveDot,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
gap: GAP,
|
||||
},
|
||||
activeDot: {
|
||||
width: ACTIVE,
|
||||
height: ACTIVE,
|
||||
borderRadius: ACTIVE / 2,
|
||||
},
|
||||
inactiveDot: {
|
||||
width: INACTIVE,
|
||||
height: INACTIVE,
|
||||
borderRadius: INACTIVE / 2,
|
||||
},
|
||||
active: {
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
inactive: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export {Lightbox} from './Lightbox'
|
||||
@@ -0,0 +1,469 @@
|
||||
import {memo, useState} from 'react'
|
||||
import {ActivityIndicator, StyleSheet} from 'react-native'
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
type PanGesture,
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
type AnimatableValue,
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import {
|
||||
type Dimensions as ImageDimensions,
|
||||
type ImageSource,
|
||||
type LightboxTransforms,
|
||||
} from '../../types'
|
||||
import {
|
||||
applyRounding,
|
||||
createTransform,
|
||||
prependPan,
|
||||
prependPinch,
|
||||
prependTransform,
|
||||
readTransform,
|
||||
type TransformMatrix,
|
||||
} from '../transforms'
|
||||
|
||||
const MIN_SCREEN_ZOOM = 2
|
||||
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||
|
||||
const initialTransform = createTransform()
|
||||
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
onLoad: (dims: ImageDimensions) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
showControls: boolean
|
||||
measureSafeArea: () => {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
imageAspect: number | undefined
|
||||
imageDimensions: ImageDimensions | undefined
|
||||
dismissSwipePan: PanGesture
|
||||
transforms: Readonly<SharedValue<LightboxTransforms>>
|
||||
}
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onTap,
|
||||
onZoom,
|
||||
onLoad,
|
||||
isScrollViewBeingDragged,
|
||||
measureSafeArea,
|
||||
imageAspect,
|
||||
imageDimensions,
|
||||
dismissSwipePan,
|
||||
transforms,
|
||||
}: Props) => {
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const committedTransform = useSharedValue(initialTransform)
|
||||
const panTranslation = useSharedValue({x: 0, y: 0})
|
||||
const pinchOrigin = useSharedValue({x: 0, y: 0})
|
||||
const pinchScale = useSharedValue(1)
|
||||
const pinchTranslation = useSharedValue({x: 0, y: 0})
|
||||
const containerRef = useAnimatedRef()
|
||||
|
||||
// Keep track of when we're entering or leaving scaled rendering.
|
||||
// Note: DO NOT move any logic reading animated values outside this function.
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
if (pinchScale.get() !== 1) {
|
||||
// We're currently pinching.
|
||||
return true
|
||||
}
|
||||
const [, , committedScale] = readTransform(committedTransform.get())
|
||||
if (committedScale !== 1) {
|
||||
// We started from a pinched in state.
|
||||
return true
|
||||
}
|
||||
// We're at rest.
|
||||
return false
|
||||
},
|
||||
(nextIsScaled, prevIsScaled) => {
|
||||
if (nextIsScaled !== prevIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function handleZoom(nextIsScaled: boolean) {
|
||||
setIsScaled(nextIsScaled)
|
||||
onZoom(nextIsScaled)
|
||||
}
|
||||
|
||||
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
|
||||
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
|
||||
function getExtraTranslationToStayInBounds(
|
||||
candidateTransform: TransformMatrix,
|
||||
screenSize: {width: number; height: number},
|
||||
) {
|
||||
'worklet'
|
||||
if (!imageAspect) {
|
||||
return [0, 0]
|
||||
}
|
||||
const [nextTranslateX, nextTranslateY, nextScale] =
|
||||
readTransform(candidateTransform)
|
||||
const scaledDimensions = getScaledDimensions(
|
||||
imageAspect,
|
||||
nextScale,
|
||||
screenSize,
|
||||
)
|
||||
const clampedTranslateX = clampTranslation(
|
||||
nextTranslateX,
|
||||
scaledDimensions.width,
|
||||
screenSize.width,
|
||||
)
|
||||
const clampedTranslateY = clampTranslation(
|
||||
nextTranslateY,
|
||||
scaledDimensions.height,
|
||||
screenSize.height,
|
||||
)
|
||||
const dx = clampedTranslateX - nextTranslateX
|
||||
const dy = clampedTranslateY - nextTranslateY
|
||||
return [dx, dy]
|
||||
}
|
||||
|
||||
const pinch = Gesture.Pinch()
|
||||
.onStart(e => {
|
||||
'worklet'
|
||||
const screenSize = measureSafeArea()
|
||||
pinchOrigin.set({
|
||||
x: e.focalX - screenSize.width / 2,
|
||||
y: e.focalY - screenSize.height / 2,
|
||||
})
|
||||
})
|
||||
.onChange(e => {
|
||||
'worklet'
|
||||
const screenSize = measureSafeArea()
|
||||
if (!imageDimensions) {
|
||||
return
|
||||
}
|
||||
// Don't let the picture zoom in so close that it gets blurry.
|
||||
// Also, like in stock Android apps, don't let the user zoom out further than 1:1.
|
||||
const [, , committedScale] = readTransform(committedTransform.get())
|
||||
const maxCommittedScale = Math.max(
|
||||
MIN_SCREEN_ZOOM,
|
||||
(imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
|
||||
)
|
||||
const minPinchScale = 1 / committedScale
|
||||
const maxPinchScale = maxCommittedScale / committedScale
|
||||
const nextPinchScale = Math.min(
|
||||
Math.max(minPinchScale, e.scale),
|
||||
maxPinchScale,
|
||||
)
|
||||
pinchScale.set(nextPinchScale)
|
||||
|
||||
// Zooming out close to the corner could push us out of bounds, which we don't want on Android.
|
||||
// Calculate where we'll end up so we know how much to translate back to stay in bounds.
|
||||
const t = createTransform()
|
||||
prependPan(t, panTranslation.get())
|
||||
prependPinch(t, nextPinchScale, pinchOrigin.get(), pinchTranslation.get())
|
||||
prependTransform(t, committedTransform.get())
|
||||
const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const pt = pinchTranslation.get()
|
||||
pinchTranslation.set({
|
||||
x: pt.x + dx,
|
||||
y: pt.y + dy,
|
||||
})
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet'
|
||||
// Commit just the pinch.
|
||||
let t = createTransform()
|
||||
prependPinch(
|
||||
t,
|
||||
pinchScale.get(),
|
||||
pinchOrigin.get(),
|
||||
pinchTranslation.get(),
|
||||
)
|
||||
prependTransform(t, committedTransform.get())
|
||||
applyRounding(t)
|
||||
committedTransform.set(t)
|
||||
|
||||
// Reset just the pinch.
|
||||
pinchScale.set(1)
|
||||
pinchOrigin.set({x: 0, y: 0})
|
||||
pinchTranslation.set({x: 0, y: 0})
|
||||
})
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.averageTouches(true)
|
||||
// Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
|
||||
.minPointers(isScaled ? 1 : 2)
|
||||
.onChange(e => {
|
||||
'worklet'
|
||||
const screenSize = measureSafeArea()
|
||||
if (!imageDimensions) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextPanTranslation = {x: e.translationX, y: e.translationY}
|
||||
let t = createTransform()
|
||||
prependPan(t, nextPanTranslation)
|
||||
prependPinch(
|
||||
t,
|
||||
pinchScale.get(),
|
||||
pinchOrigin.get(),
|
||||
pinchTranslation.get(),
|
||||
)
|
||||
prependTransform(t, committedTransform.get())
|
||||
|
||||
// Prevent panning from going out of bounds.
|
||||
const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
|
||||
nextPanTranslation.x += dx
|
||||
nextPanTranslation.y += dy
|
||||
panTranslation.set(nextPanTranslation)
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet'
|
||||
// Commit just the pan.
|
||||
let t = createTransform()
|
||||
prependPan(t, panTranslation.get())
|
||||
prependTransform(t, committedTransform.get())
|
||||
applyRounding(t)
|
||||
committedTransform.set(t)
|
||||
|
||||
// Reset just the pan.
|
||||
panTranslation.set({x: 0, y: 0})
|
||||
})
|
||||
|
||||
const singleTap = Gesture.Tap().onEnd(() => {
|
||||
'worklet'
|
||||
runOnJS(onTap)()
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.onEnd(e => {
|
||||
'worklet'
|
||||
const screenSize = measureSafeArea()
|
||||
if (!imageDimensions || !imageAspect) {
|
||||
return
|
||||
}
|
||||
const [, , committedScale] = readTransform(committedTransform.get())
|
||||
if (committedScale !== 1) {
|
||||
// Go back to 1:1 using the identity vector.
|
||||
let t = createTransform()
|
||||
committedTransform.set(withClampedSpring(t))
|
||||
return
|
||||
}
|
||||
|
||||
// Try to zoom in so that we get rid of the black bars (whatever the orientation was).
|
||||
const screenAspect = screenSize.width / screenSize.height
|
||||
const candidateScale = Math.max(
|
||||
imageAspect / screenAspect,
|
||||
screenAspect / imageAspect,
|
||||
MIN_SCREEN_ZOOM,
|
||||
)
|
||||
// But don't zoom in so close that the picture gets blurry.
|
||||
const maxScale = Math.max(
|
||||
MIN_SCREEN_ZOOM,
|
||||
(imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM,
|
||||
)
|
||||
const scale = Math.min(candidateScale, maxScale)
|
||||
|
||||
// Calculate where we would be if the user pinched into the double tapped point.
|
||||
// We won't use this transform directly because it may go out of bounds.
|
||||
const candidateTransform = createTransform()
|
||||
const origin = {
|
||||
x: e.absoluteX - screenSize.width / 2,
|
||||
y: e.absoluteY - screenSize.height / 2,
|
||||
}
|
||||
prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
|
||||
|
||||
// Now we know how much we went out of bounds, so we can shoot correctly.
|
||||
const [dx, dy] = getExtraTranslationToStayInBounds(
|
||||
candidateTransform,
|
||||
screenSize,
|
||||
)
|
||||
const finalTransform = createTransform()
|
||||
prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
|
||||
committedTransform.set(withClampedSpring(finalTransform))
|
||||
})
|
||||
|
||||
const composedGesture = isScrollViewBeingDragged
|
||||
? // If the parent is not at rest, provide a no-op gesture.
|
||||
Gesture.Manual()
|
||||
: Gesture.Exclusive(
|
||||
dismissSwipePan,
|
||||
Gesture.Simultaneous(pinch, pan),
|
||||
doubleTap,
|
||||
singleTap,
|
||||
)
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
const {scaleAndMoveTransform, isHidden} = transforms.get()
|
||||
// Apply the active adjustments on top of the committed transform before the gestures.
|
||||
// This is matrix multiplication, so operations are applied in the reverse order.
|
||||
let t = createTransform()
|
||||
prependPan(t, panTranslation.get())
|
||||
prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get())
|
||||
prependTransform(t, committedTransform.get())
|
||||
const [translateX, translateY, scale] = readTransform(t)
|
||||
const manipulationTransform = [
|
||||
{translateX},
|
||||
{translateY: translateY},
|
||||
{scale},
|
||||
]
|
||||
const screenSize = measureSafeArea()
|
||||
return {
|
||||
opacity: isHidden ? 0 : 1,
|
||||
transform: scaleAndMoveTransform.concat(manipulationTransform),
|
||||
width: screenSize.width,
|
||||
maxHeight: screenSize.height,
|
||||
alignSelf: 'center',
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
}
|
||||
})
|
||||
|
||||
const imageCropStyle = useAnimatedStyle(() => {
|
||||
const {cropFrameTransform, borderRadius: br} = transforms.get()
|
||||
return {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
transform: cropFrameTransform,
|
||||
borderRadius: br,
|
||||
}
|
||||
})
|
||||
|
||||
const imageStyle = useAnimatedStyle(() => {
|
||||
const {cropContentTransform} = transforms.get()
|
||||
return {
|
||||
flex: 1,
|
||||
transform: cropContentTransform,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
const [showLoader, setShowLoader] = useState(false)
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
return transforms.get().isResting && !hasLoaded
|
||||
},
|
||||
(show, prevShow) => {
|
||||
if (!prevShow && show) {
|
||||
runOnJS(setShowLoader)(true)
|
||||
} else if (prevShow && !show) {
|
||||
runOnJS(setShowLoader)(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const type = imageSrc.type
|
||||
const borderRadius =
|
||||
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<Animated.View
|
||||
ref={containerRef}
|
||||
style={[styles.container]}
|
||||
renderToHardwareTextureAndroid>
|
||||
<Animated.View style={containerStyle}>
|
||||
{showLoader && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color="#FFF"
|
||||
style={styles.loading}
|
||||
/>
|
||||
)}
|
||||
<Animated.View style={imageCropStyle}>
|
||||
<Animated.View style={imageStyle}>
|
||||
<Image
|
||||
contentFit="contain"
|
||||
source={{uri: imageSrc.uri}}
|
||||
placeholderContentFit="contain"
|
||||
placeholder={{uri: imageSrc.thumbUri}}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
onLoad={
|
||||
hasLoaded
|
||||
? undefined
|
||||
: e => {
|
||||
setHasLoaded(true)
|
||||
onLoad({width: e.source.width, height: e.source.height})
|
||||
}
|
||||
}
|
||||
style={{flex: 1, borderRadius}}
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
cachePolicy="memory"
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loading: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
function getScaledDimensions(
|
||||
imageAspect: number,
|
||||
scale: number,
|
||||
screenSize: {width: number; height: number},
|
||||
): ImageDimensions {
|
||||
'worklet'
|
||||
const screenAspect = screenSize.width / screenSize.height
|
||||
const isLandscape = imageAspect > screenAspect
|
||||
if (isLandscape) {
|
||||
return {
|
||||
width: scale * screenSize.width,
|
||||
height: (scale * screenSize.width) / imageAspect,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
width: scale * screenSize.height * imageAspect,
|
||||
height: scale * screenSize.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clampTranslation(
|
||||
value: number,
|
||||
scaledSize: number,
|
||||
screenSize: number,
|
||||
): number {
|
||||
'worklet'
|
||||
// Figure out how much the user should be allowed to pan, and constrain the translation.
|
||||
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
|
||||
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
|
||||
return clampedValue
|
||||
}
|
||||
|
||||
function withClampedSpring<T extends AnimatableValue>(value: T): T {
|
||||
'worklet'
|
||||
return withSpring(value, {overshootClamping: true})
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {memo, useState} from 'react'
|
||||
import {ActivityIndicator, StyleSheet} from 'react-native'
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
type PanGesture,
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedProps,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import {
|
||||
type Dimensions as ImageDimensions,
|
||||
type ImageSource,
|
||||
type LightboxTransforms,
|
||||
} from '../../types'
|
||||
|
||||
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||
const MIN_SCREEN_ZOOM = 2
|
||||
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
onLoad: (dims: ImageDimensions) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
showControls: boolean
|
||||
measureSafeArea: () => {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
imageAspect: number | undefined
|
||||
imageDimensions: ImageDimensions | undefined
|
||||
dismissSwipePan: PanGesture
|
||||
transforms: Readonly<SharedValue<LightboxTransforms>>
|
||||
}
|
||||
|
||||
const ImageItem = ({
|
||||
imageSrc,
|
||||
onTap,
|
||||
onZoom,
|
||||
onLoad,
|
||||
showControls,
|
||||
measureSafeArea,
|
||||
imageAspect,
|
||||
imageDimensions,
|
||||
dismissSwipePan,
|
||||
transforms,
|
||||
}: Props) => {
|
||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||
const [scaled, setScaled] = useState(false)
|
||||
const isDragging = useSharedValue(false)
|
||||
const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
|
||||
const maxZoomScale = Math.max(
|
||||
MIN_SCREEN_ZOOM,
|
||||
imageDimensions
|
||||
? (imageDimensions.width / screenSizeDelayedForJSThreadOnly.width) *
|
||||
MAX_ORIGINAL_IMAGE_ZOOM
|
||||
: 1,
|
||||
)
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll(e) {
|
||||
'worklet'
|
||||
const nextIsScaled = e.zoomScale > 1
|
||||
if (scaled !== nextIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
}
|
||||
},
|
||||
onBeginDrag() {
|
||||
'worklet'
|
||||
isDragging.value = true
|
||||
},
|
||||
onEndDrag() {
|
||||
'worklet'
|
||||
isDragging.value = false
|
||||
},
|
||||
})
|
||||
|
||||
function handleZoom(nextIsScaled: boolean) {
|
||||
onZoom(nextIsScaled)
|
||||
setScaled(nextIsScaled)
|
||||
}
|
||||
|
||||
function zoomTo(nextZoomRect: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
||||
// @ts-ignore
|
||||
scrollResponderRef?.scrollResponderZoomTo({
|
||||
...nextZoomRect, // This rect is in screen coordinates
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
|
||||
const singleTap = Gesture.Tap().onEnd(() => {
|
||||
'worklet'
|
||||
runOnJS(onTap)()
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.onEnd(e => {
|
||||
'worklet'
|
||||
const screenSize = measureSafeArea()
|
||||
const {absoluteX, absoluteY} = e
|
||||
let nextZoomRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: screenSize.width,
|
||||
height: screenSize.height,
|
||||
}
|
||||
const willZoom = !scaled
|
||||
if (willZoom) {
|
||||
nextZoomRect = getZoomRectAfterDoubleTap(
|
||||
imageAspect,
|
||||
absoluteX,
|
||||
absoluteY,
|
||||
screenSize,
|
||||
)
|
||||
}
|
||||
runOnJS(zoomTo)(nextZoomRect)
|
||||
})
|
||||
|
||||
const composedGesture = Gesture.Exclusive(
|
||||
dismissSwipePan,
|
||||
doubleTap,
|
||||
singleTap,
|
||||
)
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
const {scaleAndMoveTransform, isHidden} = transforms.get()
|
||||
return {
|
||||
flex: 1,
|
||||
transform: scaleAndMoveTransform,
|
||||
opacity: isHidden ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
const imageCropStyle = useAnimatedStyle(() => {
|
||||
const screenSize = measureSafeArea()
|
||||
const {cropFrameTransform, borderRadius: br} = transforms.get()
|
||||
return {
|
||||
overflow: 'hidden',
|
||||
transform: cropFrameTransform,
|
||||
borderRadius: br,
|
||||
width: screenSize.width,
|
||||
maxHeight: screenSize.height,
|
||||
alignSelf: 'center',
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
const imageStyle = useAnimatedStyle(() => {
|
||||
const {cropContentTransform} = transforms.get()
|
||||
return {
|
||||
transform: cropContentTransform,
|
||||
width: '100%',
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
const [showLoader, setShowLoader] = useState(false)
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
return transforms.get().isResting && !hasLoaded
|
||||
},
|
||||
(show, prevShow) => {
|
||||
if (!prevShow && show) {
|
||||
runOnJS(setShowLoader)(true)
|
||||
} else if (prevShow && !show) {
|
||||
runOnJS(setShowLoader)(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const type = imageSrc.type
|
||||
const borderRadius =
|
||||
type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
|
||||
|
||||
const scrollViewProps = useAnimatedProps(() => ({
|
||||
// Don't allow bounce at 1:1 rest so it can be swiped away.
|
||||
bounces: scaled || isDragging.value,
|
||||
}))
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<Animated.ScrollView
|
||||
// @ts-ignore Something's up with the types here
|
||||
ref={scrollViewRef}
|
||||
pinchGestureEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
maximumZoomScale={maxZoomScale}
|
||||
onScroll={scrollHandler}
|
||||
style={containerStyle}
|
||||
animatedProps={scrollViewProps}
|
||||
centerContent>
|
||||
{showLoader && (
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
)}
|
||||
<Animated.View style={imageCropStyle}>
|
||||
<Animated.View style={imageStyle}>
|
||||
<Image
|
||||
contentFit="contain"
|
||||
source={{uri: imageSrc.uri}}
|
||||
placeholderContentFit="contain"
|
||||
placeholder={{uri: imageSrc.thumbUri}}
|
||||
style={{flex: 1, borderRadius}}
|
||||
accessibilityLabel={imageSrc.alt}
|
||||
accessibilityHint=""
|
||||
enableLiveTextInteraction={showControls && !scaled}
|
||||
accessibilityIgnoresInvertColors
|
||||
onLoad={
|
||||
hasLoaded
|
||||
? undefined
|
||||
: e => {
|
||||
setHasLoaded(true)
|
||||
onLoad({width: e.source.width, height: e.source.height})
|
||||
}
|
||||
}
|
||||
cachePolicy="memory"
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
image: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const getZoomRectAfterDoubleTap = (
|
||||
imageAspect: number | undefined,
|
||||
touchX: number,
|
||||
touchY: number,
|
||||
screenSize: {width: number; height: number},
|
||||
): {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
} => {
|
||||
'worklet'
|
||||
if (!imageAspect) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: screenSize.width,
|
||||
height: screenSize.height,
|
||||
}
|
||||
}
|
||||
|
||||
// First, let's figure out how much we want to zoom in.
|
||||
// We want to try to zoom in at least close enough to get rid of black bars.
|
||||
const screenAspect = screenSize.width / screenSize.height
|
||||
const zoom = Math.max(
|
||||
imageAspect / screenAspect,
|
||||
screenAspect / imageAspect,
|
||||
MIN_SCREEN_ZOOM,
|
||||
)
|
||||
// Unlike in the Android version, we don't constrain the *max* zoom level here.
|
||||
// Instead, this is done in the ScrollView props so that it constraints pinch too.
|
||||
|
||||
// Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
|
||||
// We already know the zoom level, so this gives us the rectangle size.
|
||||
let rectWidth = screenSize.width / zoom
|
||||
let rectHeight = screenSize.height / zoom
|
||||
|
||||
// Before we settle on the zoomed rect, figure out the safe area it has to be inside.
|
||||
// We don't want to introduce new black bars or make existing black bars unbalanced.
|
||||
let minX = 0
|
||||
let minY = 0
|
||||
let maxX = screenSize.width - rectWidth
|
||||
let maxY = screenSize.height - rectHeight
|
||||
if (imageAspect >= screenAspect) {
|
||||
// The image has horizontal black bars. Exclude them from the safe area.
|
||||
const renderedHeight = screenSize.width / imageAspect
|
||||
const horizontalBarHeight = (screenSize.height - renderedHeight) / 2
|
||||
minY += horizontalBarHeight
|
||||
maxY -= horizontalBarHeight
|
||||
} else {
|
||||
// The image has vertical black bars. Exclude them from the safe area.
|
||||
const renderedWidth = screenSize.height * imageAspect
|
||||
const verticalBarWidth = (screenSize.width - renderedWidth) / 2
|
||||
minX += verticalBarWidth
|
||||
maxX -= verticalBarWidth
|
||||
}
|
||||
|
||||
// Finally, we can position the rect according to its size and the safe area.
|
||||
let rectX
|
||||
if (maxX >= minX) {
|
||||
// Content fills the screen horizontally so we have horizontal wiggle room.
|
||||
// Try to keep the tapped point under the finger after zoom.
|
||||
rectX = touchX - touchX / zoom
|
||||
rectX = Math.min(rectX, maxX)
|
||||
rectX = Math.max(rectX, minX)
|
||||
} else {
|
||||
// Keep the rect centered on the screen so that black bars are balanced.
|
||||
rectX = screenSize.width / 2 - rectWidth / 2
|
||||
}
|
||||
let rectY
|
||||
if (maxY >= minY) {
|
||||
// Content fills the screen vertically so we have vertical wiggle room.
|
||||
// Try to keep the tapped point under the finger after zoom.
|
||||
rectY = touchY - touchY / zoom
|
||||
rectY = Math.min(rectY, maxY)
|
||||
rectY = Math.max(rectY, minY)
|
||||
} else {
|
||||
// Keep the rect centered on the screen so that black bars are balanced.
|
||||
rectY = screenSize.height / 2 - rectHeight / 2
|
||||
}
|
||||
|
||||
return {
|
||||
x: rectX,
|
||||
y: rectY,
|
||||
height: rectHeight,
|
||||
width: rectWidth,
|
||||
}
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
@@ -0,0 +1,39 @@
|
||||
// default implementation fallback for web
|
||||
|
||||
import {memo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type PanGesture} from 'react-native-gesture-handler'
|
||||
import {type SharedValue} from 'react-native-reanimated'
|
||||
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
import {
|
||||
type Dimensions as ImageDimensions,
|
||||
type ImageSource,
|
||||
type LightboxTransforms,
|
||||
} from '../../types'
|
||||
|
||||
type Props = {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
onLoad: (dims: Dimensions) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
showControls: boolean
|
||||
measureSafeArea: () => {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
imageAspect: number | undefined
|
||||
imageDimensions: ImageDimensions | undefined
|
||||
dismissSwipePan: PanGesture
|
||||
transforms: Readonly<SharedValue<LightboxTransforms>>
|
||||
}
|
||||
|
||||
const ImageItem = (_props: Props) => {
|
||||
return <View />
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
@@ -0,0 +1,774 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
// Original code copied and simplified from the link below as the codebase is currently not maintained:
|
||||
// https://github.com/jobtoday/react-native-image-viewing
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {PixelRatio, StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import {SystemBars} from 'react-native-edge-to-edge'
|
||||
import {Gesture} from 'react-native-gesture-handler'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
import Animated, {
|
||||
type AnimatableValue,
|
||||
type AnimatedRef,
|
||||
cancelAnimation,
|
||||
interpolate,
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
ReduceMotion,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
withDecay,
|
||||
withSpring,
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
import {useTheme} from '#/alf'
|
||||
import {setSystemUITheme} from '#/alf/util/systemUI'
|
||||
import {type Lightbox} from '#/components/Lightbox/state'
|
||||
import {IS_IOS} from '#/env'
|
||||
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
|
||||
import {Footer} from '../chrome/Footer'
|
||||
import {Header} from '../chrome/Header'
|
||||
import {
|
||||
type ImageSource,
|
||||
type LightboxTransforms,
|
||||
type Transform,
|
||||
} from '../types'
|
||||
import ImageItem from './ImageItem/ImageItem'
|
||||
|
||||
type Rect = {x: number; y: number; width: number; height: number}
|
||||
|
||||
const PORTRAIT_UP = ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
const PIXEL_RATIO = PixelRatio.get()
|
||||
|
||||
const SLOW_SPRING: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 300,
|
||||
stiffness: 800,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
const FAST_SPRING: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 900,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
|
||||
function canAnimate(lightbox: Lightbox): boolean {
|
||||
if (PlatformInfo.getIsReducedMotionEnabled()) {
|
||||
return false
|
||||
}
|
||||
const img = lightbox.images[lightbox.index]
|
||||
return !!img.thumbRect && !!(img.dimensions || img.thumbDimensions)
|
||||
}
|
||||
|
||||
export default function ImageViewRoot({
|
||||
lightbox: nextLightbox,
|
||||
onRequestClose,
|
||||
onPressSave,
|
||||
onPressShare,
|
||||
}: {
|
||||
lightbox: Lightbox | null
|
||||
onRequestClose: () => void
|
||||
onPressSave: (uri: string) => void
|
||||
onPressShare: (uri: string) => void
|
||||
}) {
|
||||
'use no memo'
|
||||
const ref = useAnimatedRef<View>()
|
||||
const [activeLightbox, setActiveLightbox] = useState(nextLightbox)
|
||||
const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
|
||||
'portrait',
|
||||
)
|
||||
const openProgress = useSharedValue(0)
|
||||
const thumbRects = useSharedValue<Record<number, MeasuredDimensions | null>>(
|
||||
{},
|
||||
)
|
||||
|
||||
if (!activeLightbox && nextLightbox) {
|
||||
setActiveLightbox(nextLightbox)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!nextLightbox) {
|
||||
return
|
||||
}
|
||||
|
||||
const initial: Record<number, MeasuredDimensions | null> = {}
|
||||
nextLightbox.images.forEach((img, i) => {
|
||||
initial[i] = img.thumbRect ?? null
|
||||
})
|
||||
thumbRects.set(initial)
|
||||
|
||||
const isAnimated = canAnimate(nextLightbox)
|
||||
|
||||
// https://github.com/software-mansion/react-native-reanimated/issues/6677
|
||||
rAF_FIXED(() => {
|
||||
openProgress.set(() =>
|
||||
isAnimated ? withClampedSpring(1, SLOW_SPRING) : 1,
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
// https://github.com/software-mansion/react-native-reanimated/issues/6677
|
||||
rAF_FIXED(() => {
|
||||
openProgress.set(() =>
|
||||
isAnimated ? withClampedSpring(0, SLOW_SPRING) : 0,
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [nextLightbox, openProgress, thumbRects])
|
||||
|
||||
const onFullyClosed = useCallback(() => {
|
||||
setActiveLightbox(null)
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
}, [thumbRects])
|
||||
|
||||
useAnimatedReaction(
|
||||
() => openProgress.get() === 0,
|
||||
(isGone, wasGone) => {
|
||||
if (isGone && !wasGone) {
|
||||
runOnJS(onFullyClosed)()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Delay the unlock until after we've finished the scale up animation.
|
||||
// It's complicated to do the same for locking it back so we don't attempt that.
|
||||
useAnimatedReaction(
|
||||
() => openProgress.get() === 1,
|
||||
(isOpen, wasOpen) => {
|
||||
if (isOpen && !wasOpen) {
|
||||
runOnJS(ScreenOrientation.unlockAsync)()
|
||||
} else if (!isOpen && wasOpen) {
|
||||
// default is PORTRAIT_UP - set via config plugin in app.config.js -sfn
|
||||
runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const onFlyAway = useCallback(() => {
|
||||
'worklet'
|
||||
openProgress.set(0)
|
||||
runOnJS(onRequestClose)()
|
||||
}, [onRequestClose, openProgress])
|
||||
|
||||
return (
|
||||
// Keep it always mounted to avoid flicker on the first frame.
|
||||
<View
|
||||
style={[styles.screen, !activeLightbox && styles.screenHidden]}
|
||||
aria-modal
|
||||
accessibilityViewIsModal
|
||||
aria-hidden={!activeLightbox}>
|
||||
<Animated.View
|
||||
ref={ref}
|
||||
style={{flex: 1}}
|
||||
collapsable={false}
|
||||
onLayout={e => {
|
||||
const layout = e.nativeEvent.layout
|
||||
setOrientation(
|
||||
layout.height > layout.width ? 'portrait' : 'landscape',
|
||||
)
|
||||
}}>
|
||||
{activeLightbox && (
|
||||
<ImageView
|
||||
key={activeLightbox.id + '-' + orientation}
|
||||
lightbox={activeLightbox}
|
||||
orientation={orientation}
|
||||
onRequestClose={onRequestClose}
|
||||
onPressSave={onPressSave}
|
||||
onPressShare={onPressShare}
|
||||
onFlyAway={onFlyAway}
|
||||
safeAreaRef={ref}
|
||||
openProgress={openProgress}
|
||||
thumbRects={thumbRects}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageView({
|
||||
lightbox,
|
||||
orientation,
|
||||
onRequestClose,
|
||||
onPressSave,
|
||||
onPressShare,
|
||||
onFlyAway,
|
||||
safeAreaRef,
|
||||
openProgress,
|
||||
thumbRects,
|
||||
}: {
|
||||
lightbox: Lightbox
|
||||
orientation: 'portrait' | 'landscape'
|
||||
onRequestClose: () => void
|
||||
onPressSave: (uri: string) => void
|
||||
onPressShare: (uri: string) => void
|
||||
onFlyAway: () => void
|
||||
safeAreaRef: AnimatedRef<View>
|
||||
openProgress: SharedValue<number>
|
||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||
}) {
|
||||
const {images, index: initialImageIndex} = lightbox
|
||||
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
|
||||
const [isScaled, setIsScaled] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [imageIndex, setImageIndex] = useState(initialImageIndex)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [isAltExpanded, setIsAltExpanded] = useState(false)
|
||||
const dismissSwipeTranslateY = useSharedValue(0)
|
||||
const isFlyingAway = useSharedValue(false)
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
if (openProgress.get() < 1) {
|
||||
return {
|
||||
pointerEvents: 'none',
|
||||
opacity: isAnimated ? 1 : 0,
|
||||
}
|
||||
}
|
||||
if (isFlyingAway.get()) {
|
||||
return {
|
||||
pointerEvents: 'none',
|
||||
opacity: 1,
|
||||
}
|
||||
}
|
||||
return {pointerEvents: 'auto', opacity: 1}
|
||||
})
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => {
|
||||
const screenSize = measure(safeAreaRef)
|
||||
let opacity = 1
|
||||
const openProgressValue = openProgress.get()
|
||||
if (openProgressValue < 1) {
|
||||
opacity = Math.sqrt(openProgressValue)
|
||||
} else if (screenSize && orientation === 'portrait') {
|
||||
const dragProgress = Math.min(
|
||||
Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2),
|
||||
1,
|
||||
)
|
||||
opacity -= dragProgress
|
||||
}
|
||||
const factor = IS_IOS ? 100 : 50
|
||||
return {
|
||||
opacity: Math.round(opacity * factor) / factor,
|
||||
}
|
||||
})
|
||||
|
||||
const animatedHeaderStyle = useAnimatedStyle(() => {
|
||||
const show = showControls && dismissSwipeTranslateY.get() === 0
|
||||
return {
|
||||
pointerEvents: show ? 'box-none' : 'none',
|
||||
opacity: withClampedSpring(
|
||||
show && openProgress.get() === 1 ? 1 : 0,
|
||||
FAST_SPRING,
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const animatedFooterStyle = useAnimatedStyle(() => {
|
||||
const show = showControls && dismissSwipeTranslateY.get() === 0
|
||||
return {
|
||||
flexGrow: 1,
|
||||
pointerEvents: show ? 'box-none' : 'none',
|
||||
opacity: withClampedSpring(
|
||||
show && openProgress.get() === 1 ? 1 : 0,
|
||||
FAST_SPRING,
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const handleRequestClose = useCallback(() => {
|
||||
const activeRef = images[imageIndex]?.thumbRef
|
||||
if (isAnimated && activeRef) {
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(activeRef)
|
||||
thumbRects.modify(rects => {
|
||||
'worklet'
|
||||
rects[imageIndex] = rect
|
||||
return rects
|
||||
})
|
||||
runOnJS(onRequestClose)()
|
||||
})()
|
||||
} else {
|
||||
onRequestClose()
|
||||
}
|
||||
}, [isAnimated, images, imageIndex, thumbRects, onRequestClose])
|
||||
|
||||
const onTap = useCallback(() => {
|
||||
setShowControls(show => !show)
|
||||
}, [])
|
||||
|
||||
const onZoom = useCallback((nextIsScaled: boolean) => {
|
||||
setIsScaled(nextIsScaled)
|
||||
if (nextIsScaled) {
|
||||
setShowControls(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
const screenSize = measure(safeAreaRef)
|
||||
return (
|
||||
!screenSize ||
|
||||
Math.abs(dismissSwipeTranslateY.get()) > screenSize.height
|
||||
)
|
||||
},
|
||||
(isOut, wasOut) => {
|
||||
if (isOut && !wasOut) {
|
||||
// Stop the animation from blocking the screen forever.
|
||||
cancelAnimation(dismissSwipeTranslateY)
|
||||
onFlyAway()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// style system ui on android
|
||||
const t = useTheme()
|
||||
useEffect(() => {
|
||||
setSystemUITheme('lightbox', t)
|
||||
return () => {
|
||||
setSystemUITheme('theme', t)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, containerStyle]}>
|
||||
<SystemBars
|
||||
style={{statusBar: 'light', navigationBar: 'light'}}
|
||||
hidden={{
|
||||
statusBar: isScaled || !showControls,
|
||||
navigationBar: false,
|
||||
}}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[styles.backdrop, backdropStyle]}
|
||||
renderToHardwareTextureAndroid
|
||||
/>
|
||||
<PagerView
|
||||
scrollEnabled={!isScaled}
|
||||
initialPage={initialImageIndex}
|
||||
onPageSelected={e => {
|
||||
setImageIndex(e.nativeEvent.position)
|
||||
setIsScaled(false)
|
||||
}}
|
||||
onPageScrollStateChanged={e => {
|
||||
setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
|
||||
}}
|
||||
overdrag={true}
|
||||
style={styles.pager}>
|
||||
{images.map((imageSrc, i) => (
|
||||
<View key={`${i}-${imageSrc.uri}`}>
|
||||
<LightboxImage
|
||||
onTap={onTap}
|
||||
onZoom={onZoom}
|
||||
imageSrc={imageSrc}
|
||||
onRequestClose={handleRequestClose}
|
||||
isScrollViewBeingDragged={isDragging}
|
||||
showControls={showControls}
|
||||
safeAreaRef={safeAreaRef}
|
||||
isScaled={isScaled}
|
||||
isFlyingAway={isFlyingAway}
|
||||
isActive={i === imageIndex}
|
||||
dismissSwipeTranslateY={dismissSwipeTranslateY}
|
||||
openProgress={openProgress}
|
||||
thumbRects={thumbRects}
|
||||
imageIndex={i}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</PagerView>
|
||||
<View style={styles.controls} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={animatedHeaderStyle}
|
||||
pointerEvents="box-none"
|
||||
renderToHardwareTextureAndroid>
|
||||
<Header
|
||||
onRequestClose={handleRequestClose}
|
||||
onPressShare={() => onPressShare(images[imageIndex].uri)}
|
||||
onPressSave={() => onPressSave(images[imageIndex].uri)}
|
||||
imageCount={images.length}
|
||||
activeIndex={imageIndex}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={animatedFooterStyle}
|
||||
pointerEvents="box-none"
|
||||
renderToHardwareTextureAndroid={!isAltExpanded}>
|
||||
<Footer
|
||||
altText={images[imageIndex].alt}
|
||||
isAltExpanded={isAltExpanded}
|
||||
onToggleAltExpanded={() => setIsAltExpanded(e => !e)}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function LightboxImage({
|
||||
imageSrc,
|
||||
onTap,
|
||||
onZoom,
|
||||
onRequestClose,
|
||||
isScrollViewBeingDragged,
|
||||
isScaled,
|
||||
isFlyingAway,
|
||||
isActive,
|
||||
showControls,
|
||||
safeAreaRef,
|
||||
openProgress,
|
||||
dismissSwipeTranslateY,
|
||||
thumbRects,
|
||||
imageIndex,
|
||||
}: {
|
||||
imageSrc: ImageSource
|
||||
onRequestClose: () => void
|
||||
onTap: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
isScrollViewBeingDragged: boolean
|
||||
isScaled: boolean
|
||||
isActive: boolean
|
||||
isFlyingAway: SharedValue<boolean>
|
||||
showControls: boolean
|
||||
safeAreaRef: AnimatedRef<View>
|
||||
openProgress: SharedValue<number>
|
||||
dismissSwipeTranslateY: SharedValue<number>
|
||||
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
|
||||
imageIndex: number
|
||||
}) {
|
||||
const [fetchedDims, setFetchedDims] = useState<Dimensions | null>(null)
|
||||
const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions
|
||||
let imageAspect: number | undefined
|
||||
if (dims) {
|
||||
imageAspect = dims.width / dims.height
|
||||
if (Number.isNaN(imageAspect)) {
|
||||
imageAspect = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
width: widthDelayedForJSThreadOnly,
|
||||
height: heightDelayedForJSThreadOnly,
|
||||
} = useWindowDimensions()
|
||||
const measureSafeArea = useCallback(() => {
|
||||
'worklet'
|
||||
let safeArea: Rect | null = measure(safeAreaRef)
|
||||
if (!safeArea) {
|
||||
if (_WORKLET) {
|
||||
console.error('Expected to always be able to measure safe area.')
|
||||
}
|
||||
safeArea = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: widthDelayedForJSThreadOnly,
|
||||
height: heightDelayedForJSThreadOnly,
|
||||
}
|
||||
}
|
||||
return safeArea
|
||||
}, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly])
|
||||
|
||||
const {thumbRect: thumbRectJS, thumbBorderRadius} = imageSrc
|
||||
const transforms = useDerivedValue<LightboxTransforms>(() => {
|
||||
'worklet'
|
||||
const safeArea = measureSafeArea()
|
||||
const openProgressValue = openProgress.get()
|
||||
const dismissTranslateY =
|
||||
isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0
|
||||
|
||||
if (openProgressValue === 0 && isFlyingAway.get()) {
|
||||
return {
|
||||
isHidden: true,
|
||||
isResting: false,
|
||||
borderRadius: 0,
|
||||
scaleAndMoveTransform: [],
|
||||
cropFrameTransform: [],
|
||||
cropContentTransform: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive && imageAspect && openProgressValue < 1) {
|
||||
let thumbRect
|
||||
if (_WORKLET) {
|
||||
thumbRect = thumbRects.get()[imageIndex]
|
||||
} else {
|
||||
thumbRect = thumbRectJS
|
||||
}
|
||||
if (thumbRect) {
|
||||
return interpolateTransform(
|
||||
openProgressValue,
|
||||
thumbRect,
|
||||
safeArea,
|
||||
imageAspect,
|
||||
thumbBorderRadius,
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
isHidden: false,
|
||||
isResting: dismissTranslateY === 0,
|
||||
borderRadius: 0,
|
||||
scaleAndMoveTransform: [{translateY: dismissTranslateY}],
|
||||
cropFrameTransform: [],
|
||||
cropContentTransform: [],
|
||||
}
|
||||
})
|
||||
|
||||
const dismissSwipePan = Gesture.Pan()
|
||||
.enabled(isActive && !isScaled)
|
||||
.activeOffsetY([-10, 10])
|
||||
.failOffsetX([-10, 10])
|
||||
.maxPointers(1)
|
||||
.onUpdate(e => {
|
||||
'worklet'
|
||||
if (openProgress.get() !== 1 || isFlyingAway.get()) {
|
||||
return
|
||||
}
|
||||
dismissSwipeTranslateY.set(e.translationY)
|
||||
})
|
||||
.onEnd(e => {
|
||||
'worklet'
|
||||
if (openProgress.get() !== 1 || isFlyingAway.get()) {
|
||||
return
|
||||
}
|
||||
if (Math.abs(e.velocityY) > 200) {
|
||||
isFlyingAway.set(true)
|
||||
if (dismissSwipeTranslateY.get() === 0) {
|
||||
// HACK: If the initial value is 0, withDecay() animation doesn't start.
|
||||
// This is a bug in Reanimated, but for now we'll work around it like this.
|
||||
dismissSwipeTranslateY.set(1)
|
||||
}
|
||||
dismissSwipeTranslateY.set(() => {
|
||||
'worklet'
|
||||
return withDecay({
|
||||
velocity: e.velocityY,
|
||||
velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
|
||||
deceleration: 1, // Danger! This relies on the reaction below stopping it.
|
||||
reduceMotion: ReduceMotion.Never, // If this animation doesn't run, the image gets stuck - therefore override Reduce Motion
|
||||
})
|
||||
})
|
||||
} else {
|
||||
dismissSwipeTranslateY.set(() => {
|
||||
'worklet'
|
||||
return withSpring(0, {
|
||||
stiffness: 700,
|
||||
damping: 50,
|
||||
reduceMotion: ReduceMotion.Never,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ImageItem
|
||||
imageSrc={imageSrc}
|
||||
onTap={onTap}
|
||||
onZoom={onZoom}
|
||||
onRequestClose={onRequestClose}
|
||||
onLoad={setFetchedDims}
|
||||
isScrollViewBeingDragged={isScrollViewBeingDragged}
|
||||
showControls={showControls}
|
||||
measureSafeArea={measureSafeArea}
|
||||
imageAspect={imageAspect}
|
||||
imageDimensions={dims ?? undefined}
|
||||
dismissSwipePan={dismissSwipePan}
|
||||
transforms={transforms}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
screenHidden: {
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backdrop: {
|
||||
backgroundColor: '#000',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
gap: 20,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
pager: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
function interpolatePx(
|
||||
px: number,
|
||||
inputRange: readonly number[],
|
||||
outputRange: readonly number[],
|
||||
) {
|
||||
'worklet'
|
||||
const value = interpolate(px, inputRange, outputRange)
|
||||
return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO
|
||||
}
|
||||
|
||||
function interpolateTransform(
|
||||
progress: number,
|
||||
thumbnailDims: {
|
||||
pageX: number
|
||||
width: number
|
||||
pageY: number
|
||||
height: number
|
||||
},
|
||||
safeArea: {width: number; height: number; x: number; y: number},
|
||||
imageAspect: number,
|
||||
thumbBorderRadius?: number,
|
||||
): {
|
||||
scaleAndMoveTransform: Transform
|
||||
cropFrameTransform: Transform
|
||||
cropContentTransform: Transform
|
||||
borderRadius: number
|
||||
isResting: boolean
|
||||
isHidden: boolean
|
||||
} {
|
||||
'worklet'
|
||||
const thumbAspect = thumbnailDims.width / thumbnailDims.height
|
||||
let uncroppedInitialWidth
|
||||
let uncroppedInitialHeight
|
||||
if (imageAspect > thumbAspect) {
|
||||
uncroppedInitialWidth = thumbnailDims.height * imageAspect
|
||||
uncroppedInitialHeight = thumbnailDims.height
|
||||
} else {
|
||||
uncroppedInitialWidth = thumbnailDims.width
|
||||
uncroppedInitialHeight = thumbnailDims.width / imageAspect
|
||||
}
|
||||
const safeAreaAspect = safeArea.width / safeArea.height
|
||||
let finalWidth
|
||||
let finalHeight
|
||||
if (safeAreaAspect > imageAspect) {
|
||||
finalWidth = safeArea.height * imageAspect
|
||||
finalHeight = safeArea.height
|
||||
} else {
|
||||
finalWidth = safeArea.width
|
||||
finalHeight = safeArea.width / imageAspect
|
||||
}
|
||||
const initialScale = Math.min(
|
||||
uncroppedInitialWidth / finalWidth,
|
||||
uncroppedInitialHeight / finalHeight,
|
||||
)
|
||||
const croppedFinalWidth = thumbnailDims.width / initialScale
|
||||
const croppedFinalHeight = thumbnailDims.height / initialScale
|
||||
const screenCenterX = safeArea.width / 2
|
||||
const screenCenterY = safeArea.height / 2
|
||||
const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x
|
||||
const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y
|
||||
const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2
|
||||
const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2
|
||||
const initialTranslateX = thumbnailCenterX - screenCenterX
|
||||
const initialTranslateY = thumbnailCenterY - screenCenterY
|
||||
const scale = interpolate(progress, [0, 1], [initialScale, 1])
|
||||
const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0])
|
||||
const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0])
|
||||
const cropScaleX = interpolate(
|
||||
progress,
|
||||
[0, 1],
|
||||
[croppedFinalWidth / finalWidth, 1],
|
||||
)
|
||||
const cropScaleY = interpolate(
|
||||
progress,
|
||||
[0, 1],
|
||||
[croppedFinalHeight / finalHeight, 1],
|
||||
)
|
||||
// The border radius in the source thumbnail needs to be scaled to account
|
||||
// for the crop frame and overall scale so it visually matches at progress=0.
|
||||
const sourceBorderRadius = thumbBorderRadius ?? 0
|
||||
const initialCropScaleX = croppedFinalWidth / finalWidth
|
||||
const borderRadius = interpolate(
|
||||
progress,
|
||||
[0, 1],
|
||||
[sourceBorderRadius / (initialScale * initialCropScaleX), 0],
|
||||
)
|
||||
|
||||
return {
|
||||
isHidden: false,
|
||||
isResting: progress === 1,
|
||||
scaleAndMoveTransform: [{translateX}, {translateY}, {scale}],
|
||||
cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}],
|
||||
cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}],
|
||||
borderRadius,
|
||||
}
|
||||
}
|
||||
|
||||
function withClampedSpring<T extends AnimatableValue>(
|
||||
value: T,
|
||||
config: WithSpringConfig,
|
||||
): T {
|
||||
'worklet'
|
||||
return withSpring(value, {...config, overshootClamping: true})
|
||||
}
|
||||
|
||||
// We have to do this because we can't trust RN's rAF to fire in order.
|
||||
// https://github.com/facebook/react-native/issues/48005
|
||||
let isFrameScheduled = false
|
||||
let pendingFrameCallbacks: Array<() => void> = []
|
||||
function rAF_FIXED(callback: () => void) {
|
||||
pendingFrameCallbacks.push(callback)
|
||||
if (!isFrameScheduled) {
|
||||
isFrameScheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
const callbacks = pendingFrameCallbacks.slice()
|
||||
isFrameScheduled = false
|
||||
pendingFrameCallbacks = []
|
||||
let hasError = false
|
||||
let error
|
||||
for (let i = 0; i < callbacks.length; i++) {
|
||||
try {
|
||||
callbacks[i]()
|
||||
} catch (e) {
|
||||
hasError = true
|
||||
error = e
|
||||
}
|
||||
}
|
||||
if (hasError) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {type Position} from '../types'
|
||||
|
||||
export type TransformMatrix = [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]
|
||||
|
||||
// These are affine transforms. See explanation of every cell here:
|
||||
// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg
|
||||
|
||||
export function createTransform(): TransformMatrix {
|
||||
'worklet'
|
||||
return [1, 0, 0, 0, 1, 0, 0, 0, 1]
|
||||
}
|
||||
|
||||
export function applyRounding(t: TransformMatrix) {
|
||||
'worklet'
|
||||
t[2] = Math.round(t[2])
|
||||
t[5] = Math.round(t[5])
|
||||
// For example: 0.985, 0.99, 0.995, then 1:
|
||||
t[0] = Math.round(t[0] * 200) / 200
|
||||
t[4] = Math.round(t[0] * 200) / 200
|
||||
}
|
||||
|
||||
// We're using a limited subset (always scaling and translating while keeping aspect ratio) so
|
||||
// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.
|
||||
|
||||
// All write operations are applied in-place to avoid unnecessary allocations.
|
||||
|
||||
export function readTransform(t: TransformMatrix): [number, number, number] {
|
||||
'worklet'
|
||||
const scale = t[0]
|
||||
const translateX = t[2]
|
||||
const translateY = t[5]
|
||||
return [translateX, translateY, scale]
|
||||
}
|
||||
|
||||
export function prependTranslate(t: TransformMatrix, x: number, y: number) {
|
||||
'worklet'
|
||||
t[2] += t[0] * x + t[1] * y
|
||||
t[5] += t[3] * x + t[4] * y
|
||||
}
|
||||
|
||||
export function prependScale(t: TransformMatrix, value: number) {
|
||||
'worklet'
|
||||
t[0] *= value
|
||||
t[1] *= value
|
||||
t[3] *= value
|
||||
t[4] *= value
|
||||
}
|
||||
|
||||
export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {
|
||||
'worklet'
|
||||
// In-place matrix multiplication.
|
||||
const a00 = ta[0],
|
||||
a01 = ta[1],
|
||||
a02 = ta[2]
|
||||
const a10 = ta[3],
|
||||
a11 = ta[4],
|
||||
a12 = ta[5]
|
||||
const a20 = ta[6],
|
||||
a21 = ta[7],
|
||||
a22 = ta[8]
|
||||
ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6]
|
||||
ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7]
|
||||
ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8]
|
||||
ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6]
|
||||
ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7]
|
||||
ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8]
|
||||
ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6]
|
||||
ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7]
|
||||
ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8]
|
||||
}
|
||||
|
||||
export function prependPan(t: TransformMatrix, translation: Position) {
|
||||
'worklet'
|
||||
prependTranslate(t, translation.x, translation.y)
|
||||
}
|
||||
|
||||
export function prependPinch(
|
||||
t: TransformMatrix,
|
||||
scale: number,
|
||||
origin: Position,
|
||||
translation: Position,
|
||||
) {
|
||||
'worklet'
|
||||
prependTranslate(t, translation.x, translation.y)
|
||||
prependTranslate(t, origin.x, origin.y)
|
||||
prependScale(t, scale)
|
||||
prependTranslate(t, -origin.x, -origin.y)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
|
||||
import {
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
} from 'react-native-reanimated'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useHotkeysContext} from '#/lib/hotkeys'
|
||||
import {type ImageSource} from '#/components/Lightbox/types'
|
||||
|
||||
export type Lightbox = {
|
||||
id: string
|
||||
images: ImageSource[]
|
||||
index: number
|
||||
}
|
||||
|
||||
const LightboxContext = createContext<{
|
||||
activeLightbox: Lightbox | null
|
||||
}>({
|
||||
activeLightbox: null,
|
||||
})
|
||||
LightboxContext.displayName = 'LightboxContext'
|
||||
|
||||
const LightboxControlContext = createContext<{
|
||||
openLightbox: (lightbox: Omit<Lightbox, 'id'>) => void
|
||||
closeLightbox: () => boolean
|
||||
}>({
|
||||
openLightbox: () => {},
|
||||
closeLightbox: () => false,
|
||||
})
|
||||
LightboxControlContext.displayName = 'LightboxControlContext'
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [activeLightbox, setActiveLightbox] = useState<Lightbox | null>(null)
|
||||
const {disableScope, enableScope} = useHotkeysContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLightbox) {
|
||||
disableScope('global')
|
||||
} else {
|
||||
enableScope('global')
|
||||
}
|
||||
}, [activeLightbox, disableScope, enableScope])
|
||||
|
||||
const doOpen = useNonReactiveCallback((lightbox: Omit<Lightbox, 'id'>) => {
|
||||
setActiveLightbox(prevLightbox => {
|
||||
if (prevLightbox) {
|
||||
// Ignore duplicate open requests. If it's already open,
|
||||
// the user has to explicitly close the previous one first.
|
||||
return prevLightbox
|
||||
} else {
|
||||
return {...lightbox, id: nanoid()}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const openLightbox = useNonReactiveCallback(
|
||||
(lightbox: Omit<Lightbox, 'id'>) => {
|
||||
const thumbRef = lightbox.images[lightbox.index]?.thumbRef
|
||||
if (thumbRef) {
|
||||
// Measure the tapped image on the UI thread, then open with
|
||||
// the rect baked in so it's available from the first render.
|
||||
// Only the rect (plain data) goes through runOnJS — AnimatedRef
|
||||
// objects can't survive serialization across threads.
|
||||
const openWithRect = (rect: MeasuredDimensions | null) => {
|
||||
doOpen({
|
||||
...lightbox,
|
||||
images: lightbox.images.map((img, i) =>
|
||||
i === lightbox.index ? {...img, thumbRect: rect} : img,
|
||||
),
|
||||
})
|
||||
}
|
||||
runOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(thumbRef)
|
||||
runOnJS(openWithRect)(rect)
|
||||
})()
|
||||
} else {
|
||||
doOpen(lightbox)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const closeLightbox = useNonReactiveCallback(() => {
|
||||
let wasActive = !!activeLightbox
|
||||
setActiveLightbox(null)
|
||||
return wasActive
|
||||
})
|
||||
|
||||
const state = useMemo(
|
||||
() => ({
|
||||
activeLightbox,
|
||||
}),
|
||||
[activeLightbox],
|
||||
)
|
||||
|
||||
const methods = useMemo(
|
||||
() => ({
|
||||
openLightbox,
|
||||
closeLightbox,
|
||||
}),
|
||||
[openLightbox, closeLightbox],
|
||||
)
|
||||
|
||||
return (
|
||||
<LightboxContext.Provider value={state}>
|
||||
<LightboxControlContext.Provider value={methods}>
|
||||
{children}
|
||||
</LightboxControlContext.Provider>
|
||||
</LightboxContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLightbox() {
|
||||
return useContext(LightboxContext)
|
||||
}
|
||||
|
||||
export function useLightboxControls() {
|
||||
return useContext(LightboxControlContext)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import {type Component} from 'react'
|
||||
import {type TransformsStyle} from 'react-native'
|
||||
import {
|
||||
type AnimatedRef,
|
||||
type MeasuredDimensions,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
export type Dimensions = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type Position = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type ImageSource = {
|
||||
uri: string
|
||||
dimensions: Dimensions | null
|
||||
thumbUri: string
|
||||
thumbDimensions: Dimensions | null
|
||||
thumbRect: MeasuredDimensions | null
|
||||
thumbRef?: AnimatedRef<Component> | null
|
||||
thumbBorderRadius?: number
|
||||
alt?: string
|
||||
type: 'image' | 'circle-avi' | 'rect-avi'
|
||||
}
|
||||
|
||||
export type Transform = Exclude<
|
||||
TransformsStyle['transform'],
|
||||
string | undefined
|
||||
>
|
||||
|
||||
export type LightboxTransforms = {
|
||||
scaleAndMoveTransform: Transform
|
||||
cropFrameTransform: Transform
|
||||
cropContentTransform: Transform
|
||||
borderRadius: number
|
||||
isResting: boolean
|
||||
isHidden: boolean
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import {InteractionManager, View} from 'react-native'
|
||||
import {type AnimatedRef} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import {useLightboxControls} from '#/state/lightbox'
|
||||
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {Gallery} from '#/components/images/Gallery'
|
||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||
import {useLightboxControls} from '#/components/Lightbox/state'
|
||||
import {type Dimensions} from '#/components/Lightbox/types'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {type EmbedType} from '#/types/bsky/post'
|
||||
|
||||
@@ -3,8 +3,8 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
|
||||
import {type AppBskyEmbedImages} from '@atproto/api'
|
||||
|
||||
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {type Dimensions} from '#/components/Lightbox/types'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {GalleryItem} from './ImageLayoutGridItem'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user