feat(viewer): new vertical gesture and animation on mobile (#240)

This commit is contained in:
Chrys
2026-04-18 22:20:11 +08:00
committed by GitHub
parent cf4e4bf115
commit d25fd9dae4
12 changed files with 1236 additions and 287 deletions

View File

@@ -11,6 +11,8 @@ export const DOMImageViewer: FC<DOMImageViewerProps> = ({
onZoomChange,
minZoom,
maxZoom,
enableZoom = true,
enablePan = true,
src,
alt,
highResLoaded,
@@ -52,6 +54,7 @@ export const DOMImageViewer: FC<DOMImageViewerProps> = ({
// 双击切换 1x/fitToScreenScale缩放中心为指针位置
const handleDoubleClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!enableZoom) return
const instance = activeRef.current?.instance
if (!instance) return
const wrapper = instance.wrapperComponent
@@ -82,7 +85,7 @@ export const DOMImageViewer: FC<DOMImageViewerProps> = ({
activeRef.current?.setTransform(0, 0, 1, 200, 'easeInOutCubic')
}
},
[activeRef],
[activeRef, enableZoom],
)
return (
@@ -94,12 +97,17 @@ export const DOMImageViewer: FC<DOMImageViewerProps> = ({
maxScale={maxZoom}
wheel={{
step: 0.1,
wheelDisabled: !enableZoom,
}}
pinch={{
step: 0.5,
disabled: !enableZoom,
}}
doubleClick={{
disabled: true, // 禁用内置双击
disabled: true, // 禁用内置双击,使用自定义逻辑保证 1x/fit 切换
}}
panning={{
disabled: !enablePan,
}}
limitToBounds={true}
centerOnInit={true}

View File

@@ -0,0 +1,159 @@
import type { PickedExif } from '@afilmory/builder'
import { MobileTabGroup, MobileTabItem } from '@afilmory/ui'
import { useQuery } from '@tanstack/react-query'
import { m, type MotionValue, useTransform } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { injectConfig } from '~/config'
import { useViewport } from '~/hooks/useViewport'
import { commentsApi } from '~/lib/api/comments'
import { ExifPanelContent } from '~/modules/metadata/ExifPanel'
import { CommentsPanel } from '~/modules/social/comments'
import type { PhotoManifest } from '~/types/photo'
type Tab = 'info' | 'comments'
interface MobilePhotoInspectorSheetProps {
currentPhoto: PhotoManifest
exifData: PickedExif | null
progress: MotionValue<number>
onClose: () => void
}
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
const easeOutCubic = (value: number) => 1 - Math.pow(1 - value, 3)
export const MobilePhotoInspectorSheet = ({
currentPhoto,
exifData,
progress,
onClose,
}: MobilePhotoInspectorSheetProps) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<Tab>('info')
const [isInteractive, setIsInteractive] = useState(false)
const viewportHeight = useViewport((value) => value.h) || (typeof window !== 'undefined' ? window.innerHeight : 844)
const sheetHeight = useMemo(() => clamp(viewportHeight * 0.68, 360, viewportHeight - 72), [viewportHeight])
const showSocialFeatures = injectConfig.useCloud
const { data: commentCount } = useQuery({
queryKey: ['comment-count', currentPhoto.id],
queryFn: () => commentsApi.count(currentPhoto.id),
enabled: showSocialFeatures,
})
const hasComments = (commentCount?.count ?? 0) > 0
useEffect(() => {
setActiveTab('info')
}, [currentPhoto.id])
useEffect(() => {
const unsubscribe = progress.on('change', (latest) => {
setIsInteractive(clamp(latest, 0, 1) > 0.02)
})
return () => {
unsubscribe()
}
}, [progress])
const clampedProgress = useTransform(() => clamp(progress.get(), 0, 1))
const sheetProgress = useTransform(() => easeOutCubic(clampedProgress.get()))
const sheetY = useTransform(() => (1 - sheetProgress.get()) * (sheetHeight + 28))
const sheetOpacity = useTransform(() => clamp(clampedProgress.get() * 1.6, 0, 1))
const sheetScale = useTransform(() => 0.965 + sheetProgress.get() * 0.035)
return (
<m.div
className="pointer-events-none fixed inset-x-0 bottom-0 z-30 flex justify-center"
aria-hidden={!isInteractive}
style={{
y: sheetY,
opacity: sheetOpacity,
}}
>
<m.div
className="bg-material-ultra-thick border-accent/20 pointer-events-auto relative flex w-full max-w-screen-lg flex-col overflow-hidden rounded-t-[28px] border text-white backdrop-blur-3xl"
style={{
height: sheetHeight,
scale: sheetScale,
transformOrigin: '50% 100%',
boxShadow:
'0 -20px 64px color-mix(in srgb, var(--color-accent) 16%, transparent), 0 -8px 28px rgba(0, 0, 0, 0.32)',
pointerEvents: isInteractive ? 'auto' : 'none',
}}
>
<div
className="pointer-events-none absolute inset-0 rounded-t-[28px]"
style={{
background:
'linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 16%, color-mix(in srgb, var(--color-accent) 7%, transparent))',
}}
/>
<div className="relative z-10 flex shrink-0 flex-col px-4 pt-3">
<div className="mb-3 flex items-center justify-center">
<div className="h-1.5 w-11 rounded-full bg-white/20" />
</div>
<div className="relative">
{showSocialFeatures ? (
<MobileTabGroup
value={activeTab}
onValueChanged={(value) => setActiveTab(value as Tab)}
className="mr-12"
>
<MobileTabItem
value="info"
label={
<div className="flex items-center">
<i className="i-mingcute-information-line mr-1.5 text-base" />
{t('inspector.tab.info')}
</div>
}
/>
<MobileTabItem
value="comments"
label={
<div className="flex items-center">
<i className="i-mingcute-comment-line mr-1.5 text-base" />
{t('inspector.tab.comments')}
{hasComments && <div className="bg-accent ml-1.5 size-1.5 rounded-full" />}
</div>
}
/>
</MobileTabGroup>
) : (
<div className="px-2 pb-1 text-sm font-medium text-white/70">{t('exif.header.title')}</div>
)}
<button
type="button"
className="hover:bg-accent/10 absolute top-1 right-0 flex size-9 items-center justify-center rounded-xl text-white/80 transition-colors hover:text-white"
onClick={onClose}
aria-label="Close details"
>
<i className="i-mingcute-close-line text-lg" />
</button>
</div>
</div>
<div className="relative z-10 flex min-h-0 flex-1 flex-col">
{activeTab === 'info' ? (
<ExifPanelContent
currentPhoto={currentPhoto}
exifData={exifData}
rootClassName="min-h-0 flex-1"
viewportClassName="px-4 pb-[calc(env(safe-area-inset-bottom)+20px)] **:select-text"
/>
) : (
<div className="min-h-0 flex-1 pb-[calc(env(safe-area-inset-bottom)+8px)]">
<CommentsPanel photoId={currentPhoto.id} />
</div>
)}
</div>
</m.div>
</m.div>
)
}

View File

@@ -21,15 +21,21 @@ import type { PhotoManifest } from '~/types/photo'
import { ReactionRail } from '../social'
import { PhotoViewerTransitionPreview } from './animations/PhotoViewerTransitionPreview'
import type { AnimationFrameRect } from './animations/types'
import { usePhotoViewerTransitions } from './animations/usePhotoViewerTransitions'
import { computeViewerImageFrame, projectViewerImageFrame } from './animations/utils'
import { GalleryThumbnail } from './GalleryThumbnail'
import { MobilePhotoInspectorSheet } from './MobilePhotoInspectorSheet'
import { ProgressiveImage } from './ProgressiveImage'
import type { MobilePhotoViewerDismissSnapshot } from './usePhotoViewerMobileInteractions'
import { usePhotoViewerMobileInteractions } from './usePhotoViewerMobileInteractions'
interface PhotoViewerProps {
photos: PhotoManifest[]
currentIndex: number
isOpen: boolean
onClose: () => void
onDragDismiss?: (frame: AnimationFrameRect) => void
onIndexChange: (index: number) => void
triggerElement: HTMLElement | null
onExitComplete?: () => void
@@ -40,6 +46,7 @@ export const PhotoViewer = ({
currentIndex,
isOpen,
onClose,
onDragDismiss,
onIndexChange,
triggerElement,
onExitComplete,
@@ -48,11 +55,11 @@ export const PhotoViewer = ({
const isMobile = useMobile()
const swiperRef = useRef<SwiperType | null>(null)
const [isImageZoomed, setIsImageZoomed] = useState(false)
const [isInspectorVisible, setIsInspectorVisible] = useState(!isMobile)
const [isDesktopInspectorVisible, setIsDesktopInspectorVisible] = useState(!isMobile)
const [currentBlobSrc, setCurrentBlobSrc] = useState<string | null>(null)
const [dragDismissExitFrame, setDragDismissExitFrame] = useState<AnimationFrameRect | null>(null)
const currentPhoto = photos[currentIndex]
const {
containerRef,
entryTransition,
@@ -62,9 +69,11 @@ export const PhotoViewer = ({
shouldRenderBackdrop,
thumbHash: transitionThumbHash,
shouldRenderThumbhash,
handleEntryAnimationComplete,
handleEntryTransitionReady,
handleEntryTransitionComplete,
handleExitAnimationComplete,
} = usePhotoViewerTransitions({
exitOverrideFrame: dragDismissExitFrame,
isOpen,
triggerElement,
currentPhoto,
@@ -73,13 +82,75 @@ export const PhotoViewer = ({
onExitComplete,
})
const handleCloseRequest = useCallback(() => {
setDragDismissExitFrame(null)
onClose()
}, [onClose])
const handleDragDismiss = useCallback(
(snapshot: MobilePhotoViewerDismissSnapshot) => {
if (!currentPhoto) {
handleCloseRequest()
return
}
const viewportRect =
containerRef.current?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight)
const baseFrame = computeViewerImageFrame(currentPhoto, viewportRect, true)
const projectedFrame = projectViewerImageFrame(baseFrame, viewportRect, snapshot)
setDragDismissExitFrame(projectedFrame)
onDragDismiss?.(projectedFrame)
onClose()
},
[containerRef, currentPhoto, handleCloseRequest, onClose, onDragDismiss],
)
const {
bindStage,
closeInspector,
dismissX,
inspectorProgress,
isInspectorVisible: isMobileInspectorVisible,
isVerticalGestureActive,
reset: resetMobileInteractions,
stageHintOpacity,
stageHintY,
thumbnailsOpacity,
thumbnailsY,
toggleInspector,
viewerBorderRadius,
viewerLiftY,
viewerRotate,
viewerScale,
backdropOpacity,
chromeOpacity,
chromeY,
} = usePhotoViewerMobileInteractions({
enabled: isMobile && isOpen,
isImageZoomed,
onDismiss: handleDragDismiss,
})
const isInspectorVisible = isMobile ? isMobileInspectorVisible : isDesktopInspectorVisible
const isMobileChromeInteractive = !isMobile || !isMobileInspectorVisible
const mobileChromeButtonClassName = isMobileChromeInteractive ? 'pointer-events-auto' : 'pointer-events-none'
useEffect(() => {
if (isOpen) {
setDragDismissExitFrame(null)
}
}, [isOpen])
useEffect(() => {
if (!isOpen) {
setIsImageZoomed(false)
setIsInspectorVisible(!isMobile)
setIsDesktopInspectorVisible(!isMobile)
setCurrentBlobSrc(null)
if (!dragDismissExitFrame) {
resetMobileInteractions()
}
}
}, [isMobile, isOpen])
}, [dragDismissExitFrame, isMobile, isOpen, resetMobileInteractions])
const handlePrevious = useCallback(() => {
if (currentIndex > 0) {
@@ -101,13 +172,17 @@ export const PhotoViewer = ({
swiperRef.current.slideTo(currentIndex, 300)
}
// 切换图片时重置缩放状态
setDragDismissExitFrame(null)
setIsImageZoomed(false)
}, [currentIndex])
if (isMobile) {
resetMobileInteractions()
}
}, [currentIndex, isMobile, resetMobileInteractions])
// 当图片缩放状态改变时,控制 Swiper 的触摸行为
useEffect(() => {
if (swiperRef.current) {
if (isImageZoomed) {
if (isImageZoomed || (isMobile && (isVerticalGestureActive || isInspectorVisible))) {
// 图片被缩放时,禁用 Swiper 的触摸滑动
swiperRef.current.allowTouchMove = false
} else {
@@ -115,7 +190,7 @@ export const PhotoViewer = ({
swiperRef.current.allowTouchMove = true
}
}
}, [isImageZoomed])
}, [isImageZoomed, isInspectorVisible, isMobile, isVerticalGestureActive])
const loadingIndicatorRef = useRef<LoadingIndicatorRef>(null)
// 处理图片缩放状态变化
@@ -128,6 +203,12 @@ export const PhotoViewer = ({
setCurrentBlobSrc(blobSrc)
}, [])
useEffect(() => {
if (isMobile && isImageZoomed && isInspectorVisible) {
closeInspector()
}
}, [closeInspector, isImageZoomed, isInspectorVisible, isMobile])
// 键盘导航
useEffect(() => {
if (!isOpen) return
@@ -146,7 +227,7 @@ export const PhotoViewer = ({
}
case 'Escape': {
event.preventDefault()
onClose()
handleCloseRequest()
break
}
}
@@ -156,7 +237,7 @@ export const PhotoViewer = ({
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handlePrevious, handleNext, onClose])
}, [isOpen, handleCloseRequest, handlePrevious, handleNext])
if (!currentPhoto) return null
@@ -172,8 +253,13 @@ export const PhotoViewer = ({
animate={{ opacity: isOpen ? 1 : 0 }}
exit={{ opacity: 0 }}
transition={Spring.presets.snappy}
className="bg-material-opaque fixed inset-0"
/>
className="fixed inset-0"
>
<m.div
className="bg-material-opaque absolute inset-0"
style={isMobile ? { opacity: backdropOpacity } : undefined}
/>
</m.div>
)}
</AnimatePresence>
{/* 固定背景层防止透出 */}
@@ -202,207 +288,262 @@ export const PhotoViewer = ({
touchAction: isMobile ? 'manipulation' : 'none',
pointerEvents: !isViewerContentVisible || isEntryAnimating ? 'none' : 'auto',
}}
initial={{ opacity: 0 }}
animate={{ opacity: isViewerContentVisible ? 1 : 0 }}
initial={false}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={Spring.presets.snappy}
>
<div className={`flex size-full ${isMobile ? 'flex-col' : 'flex-row'}`}>
<div className="z-1 flex min-h-0 min-w-0 flex-1 flex-col">
<div className="z-1 flex min-h-0 min-w-0 flex-1 flex-col" {...(isMobile ? bindStage() : {})}>
<m.div
className="group/photo-viewer relative flex min-h-0 min-w-0 flex-1"
animate={{ opacity: isViewerContentVisible ? 1 : 0 }}
transition={Spring.presets.snappy}
className={`flex min-h-0 min-w-0 flex-1 flex-col ${isMobile ? 'overflow-hidden' : ''}`}
style={
isMobile
? {
x: dismissX,
y: viewerLiftY,
scale: viewerScale,
rotate: viewerRotate,
borderRadius: viewerBorderRadius,
transformOrigin: '50% 18%',
touchAction: 'none',
}
: undefined
}
>
{/* 顶部工具栏 */}
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: isViewerContentVisible ? 1 : 0 }}
exit={{ opacity: 0 }}
transition={Spring.presets.snappy}
className={`pointer-events-none absolute ${isMobile ? 'top-2 right-2 left-2' : 'top-4 right-4 left-4'} z-30 flex items-center justify-between`}
className="group/photo-viewer relative flex min-h-0 min-w-0 flex-1"
initial={false}
animate={{ opacity: 1 }}
>
{/* 左侧工具按钮 */}
<div className="flex items-center gap-2">
{/* 信息按钮 - 在移动设备上显示 */}
{isMobile && (
<button
type="button"
className={`bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40 ${isInspectorVisible ? 'bg-accent' : ''}`}
onClick={() => setIsInspectorVisible((visible) => !visible)}
>
<i className="i-mingcute-information-line" />
</button>
)}
</div>
{/* 顶部工具栏 */}
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: isViewerContentVisible ? 1 : 0 }}
exit={{ opacity: 0 }}
transition={Spring.presets.snappy}
className={`pointer-events-none absolute ${isMobile ? 'top-2 right-2 left-2' : 'top-4 right-4 left-4'} z-30 flex items-center justify-between`}
style={isMobile ? { opacity: chromeOpacity, y: chromeY } : undefined}
>
{/* 左侧工具按钮 */}
<div className="flex items-center gap-2">
{/* 信息按钮 - 在移动设备上显示 */}
{isMobile && (
<button
type="button"
disabled={!isMobileChromeInteractive}
className={`bg-material-ultra-thick ${mobileChromeButtonClassName} flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40 disabled:cursor-default ${isInspectorVisible ? 'bg-accent' : ''}`}
onClick={toggleInspector}
>
<i className="i-mingcute-information-line" />
</button>
)}
</div>
{/* 右侧按钮组 */}
<div className="flex items-center gap-2">
{/* 分享按钮 */}
<ShareModal
photo={currentPhoto}
blobSrc={currentBlobSrc || undefined}
trigger={
{/* 右侧按钮组 */}
<div className="flex items-center gap-2">
{/* 分享按钮 */}
<ShareModal
photo={currentPhoto}
blobSrc={currentBlobSrc || undefined}
trigger={
<button
type="button"
disabled={!isMobileChromeInteractive}
className={`bg-material-ultra-thick ${mobileChromeButtonClassName} flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40 disabled:cursor-default`}
title={t('photo.share.title')}
>
<i className="i-mingcute-share-2-line" />
</button>
}
/>
{/* 展开信息面板(桌面端在折叠时显示) */}
{!isMobile && !isInspectorVisible && (
<button
type="button"
className="bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40"
title={t('photo.share.title')}
onClick={() => setIsDesktopInspectorVisible(true)}
title={t('inspector.tab.info')}
>
<i className="i-mingcute-share-2-line" />
<i className="i-lucide-panel-right-open" />
</button>
}
/>
)}
{/* 展开信息面板(桌面端在折叠时显示) */}
{!isMobile && !isInspectorVisible && (
{/* 关闭按钮 */}
<button
type="button"
className="bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40"
onClick={() => setIsInspectorVisible(true)}
title={t('inspector.tab.info')}
disabled={!isMobileChromeInteractive}
className={`bg-material-ultra-thick ${mobileChromeButtonClassName} flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40 disabled:cursor-default`}
onClick={handleCloseRequest}
>
<i className="i-lucide-panel-right-open" />
<i className="i-mingcute-close-line" />
</button>
</div>
</m.div>
{/* 加载指示器 */}
<LoadingIndicator ref={loadingIndicatorRef} />
<div
className="relative flex h-full w-full items-center justify-center"
style={{
touchAction: isMobile ? 'pan-x pinch-zoom' : 'pan-y',
}}
>
{/* Swiper 容器 */}
<Swiper
modules={[Navigation, Virtual]}
spaceBetween={0}
slidesPerView={1}
initialSlide={currentIndex}
virtual
onSwiper={(swiper) => {
swiperRef.current = swiper
swiper.allowTouchMove =
!isImageZoomed && !(isMobile && (isVerticalGestureActive || isInspectorVisible))
}}
onSlideChange={(swiper) => {
onIndexChange(swiper.activeIndex)
}}
className="h-full w-full"
style={{ touchAction: isMobile ? 'pan-x' : 'pan-y' }}
>
{photos.map((photo, index) => {
const isCurrentImage = index === currentIndex
const hideCurrentImage = isCurrentImage && isEntryAnimating && !isViewerContentVisible
return (
<SwiperSlide
key={photo.id}
className="flex items-center justify-center"
virtualIndex={index}
>
<ReactionRail photoId={photo.id} />
<m.div
initial={{ opacity: 0.5, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={Spring.presets.smooth}
className="relative flex h-full w-full items-center justify-center"
style={{
visibility: hideCurrentImage ? 'hidden' : 'visible',
}}
>
<ProgressiveImage
loadingIndicatorRef={loadingIndicatorRef}
isCurrentImage={isCurrentImage}
src={photo.originalUrl}
thumbnailSrc={photo.thumbnailUrl}
alt={photo.title}
width={isCurrentImage ? currentPhoto.width : undefined}
height={isCurrentImage ? currentPhoto.height : undefined}
className="h-full w-full object-contain"
enablePan={isCurrentImage ? !isMobile || isImageZoomed : true}
enableZoom={true}
shouldRenderHighRes={isViewerContentVisible && isOpen}
onZoomChange={isCurrentImage ? handleZoomChange : undefined}
onBlobSrcChange={isCurrentImage ? handleBlobSrcChange : undefined}
// Video source (Live Photo or Motion Photo)
videoSource={
photo.video?.type === 'motion-photo'
? {
type: 'motion-photo',
imageUrl: photo.originalUrl,
offset: photo.video.offset,
size: photo.video.size,
presentationTimestamp: photo.video.presentationTimestamp,
}
: photo.video?.type === 'live-photo'
? {
type: 'live-photo',
videoUrl: photo.video.videoUrl,
}
: { type: 'none' }
}
shouldAutoPlayVideoOnce={isCurrentImage}
// HDR props
isHDR={photo.isHDR}
/>
</m.div>
</SwiperSlide>
)
})}
</Swiper>
{isMobile && (
<m.div
className="bg-material-ultra-thick pointer-events-none absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full px-3 py-1 text-xs text-white/70 backdrop-blur-xl"
style={{ opacity: stageHintOpacity, y: stageHintY }}
>
<i className="i-mingcute-arrow-up-line text-sm" />
<i className="i-mingcute-information-line text-sm" />
<span className="h-3 w-px bg-white/10" />
<i className="i-mingcute-arrow-down-line text-sm" />
<i className="i-mingcute-close-line text-sm" />
</m.div>
)}
{/* 关闭按钮 */}
<button
type="button"
className="bg-material-ultra-thick pointer-events-auto flex size-8 items-center justify-center rounded-full text-white backdrop-blur-2xl duration-200 hover:bg-black/40"
onClick={onClose}
>
<i className="i-mingcute-close-line" />
</button>
{/* 自定义导航按钮 */}
{!isMobile && (
<Fragment>
{currentIndex > 0 && (
<button
type="button"
className={`bg-material-medium absolute top-1/2 left-4 z-20 flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover/photo-viewer:opacity-100 hover:bg-black/40`}
onClick={handlePrevious}
>
<i className={`i-mingcute-left-line text-xl`} />
</button>
)}
{currentIndex < photos.length - 1 && (
<button
type="button"
className={`bg-material-medium absolute top-1/2 right-4 z-20 flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover/photo-viewer:opacity-100 hover:bg-black/40`}
onClick={handleNext}
>
<i className={`i-mingcute-right-line text-xl`} />
</button>
)}
</Fragment>
)}
</div>
</m.div>
{/* 加载指示器 */}
<LoadingIndicator ref={loadingIndicatorRef} />
{/* Swiper 容器 */}
<Swiper
modules={[Navigation, Virtual]}
spaceBetween={0}
slidesPerView={1}
initialSlide={currentIndex}
virtual
onSwiper={(swiper) => {
swiperRef.current = swiper
// 初始化时确保触摸滑动是启用的
swiper.allowTouchMove = !isImageZoomed
}}
onSlideChange={(swiper) => {
onIndexChange(swiper.activeIndex)
}}
className="h-full w-full"
style={{ touchAction: isMobile ? 'pan-x' : 'pan-y' }}
<m.div
style={isMobile ? { opacity: thumbnailsOpacity, y: thumbnailsY } : undefined}
className={isMobile && isInspectorVisible ? 'pointer-events-none' : undefined}
>
{photos.map((photo, index) => {
const isCurrentImage = index === currentIndex
const hideCurrentImage = isEntryAnimating && isCurrentImage
return (
<SwiperSlide key={photo.id} className="flex items-center justify-center" virtualIndex={index}>
<ReactionRail photoId={photo.id} />
<m.div
initial={{ opacity: 0.5, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={Spring.presets.smooth}
className="relative flex h-full w-full items-center justify-center"
style={{
visibility: hideCurrentImage ? 'hidden' : 'visible',
}}
>
<ProgressiveImage
loadingIndicatorRef={loadingIndicatorRef}
isCurrentImage={isCurrentImage}
src={photo.originalUrl}
thumbnailSrc={photo.thumbnailUrl}
alt={photo.title}
width={isCurrentImage ? currentPhoto.width : undefined}
height={isCurrentImage ? currentPhoto.height : undefined}
className="h-full w-full object-contain"
enablePan={isCurrentImage ? !isMobile || isImageZoomed : true}
enableZoom={true}
shouldRenderHighRes={isViewerContentVisible && isOpen}
onZoomChange={isCurrentImage ? handleZoomChange : undefined}
onBlobSrcChange={isCurrentImage ? handleBlobSrcChange : undefined}
// Video source (Live Photo or Motion Photo)
videoSource={
photo.video?.type === 'motion-photo'
? {
type: 'motion-photo',
imageUrl: photo.originalUrl,
offset: photo.video.offset,
size: photo.video.size,
presentationTimestamp: photo.video.presentationTimestamp,
}
: photo.video?.type === 'live-photo'
? {
type: 'live-photo',
videoUrl: photo.video.videoUrl,
}
: { type: 'none' }
}
shouldAutoPlayVideoOnce={isCurrentImage}
// HDR props
isHDR={photo.isHDR}
/>
</m.div>
</SwiperSlide>
)
})}
</Swiper>
{/* 自定义导航按钮 */}
{!isMobile && (
<Fragment>
{currentIndex > 0 && (
<button
type="button"
className={`bg-material-medium absolute top-1/2 left-4 z-20 flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover/photo-viewer:opacity-100 hover:bg-black/40`}
onClick={handlePrevious}
>
<i className={`i-mingcute-left-line text-xl`} />
</button>
)}
{currentIndex < photos.length - 1 && (
<button
type="button"
className={`bg-material-medium absolute top-1/2 right-4 z-20 flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover/photo-viewer:opacity-100 hover:bg-black/40`}
onClick={handleNext}
>
<i className={`i-mingcute-right-line text-xl`} />
</button>
)}
</Fragment>
)}
<Suspense>
<GalleryThumbnail
currentIndex={currentIndex}
photos={photos}
onIndexChange={onIndexChange}
visible={isViewerContentVisible}
/>
</Suspense>
</m.div>
</m.div>
<Suspense>
<GalleryThumbnail
currentIndex={currentIndex}
photos={photos}
onIndexChange={onIndexChange}
visible={isViewerContentVisible}
/>
</Suspense>
</div>
{/* PhotoInspector - 根据设备与折叠状态展示 */}
<Suspense>
<AnimatePresenceOnlyMobile>
{isInspectorVisible && (
{isMobile ? (
<MobilePhotoInspectorSheet
currentPhoto={currentPhoto}
exifData={currentPhoto.exif}
progress={inspectorProgress}
onClose={closeInspector}
/>
) : (
isInspectorVisible && (
<PhotoInspector
currentPhoto={currentPhoto}
exifData={currentPhoto.exif}
visible={isInspectorVisible && isViewerContentVisible}
onClose={() => setIsInspectorVisible(false)}
onClose={() => setIsDesktopInspectorVisible(false)}
/>
)}
</AnimatePresenceOnlyMobile>
)
)}
</Suspense>
</div>
</m.div>
@@ -412,7 +553,8 @@ export const PhotoViewer = ({
<PhotoViewerTransitionPreview
key={`${entryTransition.variant}-${entryTransition.photoId}`}
transition={entryTransition}
onComplete={handleEntryAnimationComplete}
onReady={handleEntryTransitionReady}
onComplete={handleEntryTransitionComplete}
/>
)}
{exitTransition && (
@@ -425,9 +567,3 @@ export const PhotoViewer = ({
</>
)
}
const AnimatePresenceOnlyMobile = ({ children }: { children: React.ReactNode }) => {
const isMobile = useMobile()
if (!isMobile) return children
return <AnimatePresence>{children}</AnimatePresence>
}

View File

@@ -1,13 +1,14 @@
import { clsxm } from '@afilmory/utils'
import { WebGLImageViewer } from '@afilmory/webgl-viewer'
import { AnimatePresence, m } from 'motion/react'
import { useCallback, useMemo, useRef } from 'react'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'
import { useMediaQuery } from 'usehooks-ts'
import { useShowContextMenu } from '~/atoms/context-menu'
import { SlidingNumber } from '~/components/ui/number/SlidingNumber'
import { isMobileDevice } from '~/lib/device-viewport'
import { canUseWebGL } from '~/lib/feature'
import { HDRBadge } from '~/modules/media/HDRBadge'
import { LivePhotoBadge } from '~/modules/media/LivePhotoBadge'
@@ -24,6 +25,8 @@ import {
} from './hooks'
import type { ProgressiveImageProps, WebGLImageViewerRef } from './types'
const loadedThumbnailSrcSet = new Set<string>()
export const ProgressiveImage = ({
src,
thumbnailSrc,
@@ -35,6 +38,8 @@ export const ProgressiveImage = ({
onProgress,
onZoomChange,
onBlobSrcChange,
enableZoom = true,
enablePan = true,
maxZoom = 20,
minZoom = 1,
isCurrentImage = false,
@@ -104,8 +109,34 @@ export const ProgressiveImage = ({
const handleWebGLLoadingStateChange = useWebGLLoadingState(loadingIndicatorRef)
const handleThumbnailLoad = useCallback(() => {
if (thumbnailSrc) {
loadedThumbnailSrcSet.add(thumbnailSrc)
}
setState.setIsThumbnailLoaded(true)
}, [setState])
}, [setState, thumbnailSrc])
useLayoutEffect(() => {
if (!thumbnailSrc) {
setState.setIsThumbnailLoaded(false)
return
}
const thumbnailElement = thumbnailRef.current
const isAlreadyLoaded =
loadedThumbnailSrcSet.has(thumbnailSrc) ||
(thumbnailElement?.currentSrc?.includes(thumbnailSrc) &&
thumbnailElement.complete &&
thumbnailElement.naturalWidth > 0) ||
(thumbnailElement?.src === thumbnailSrc && thumbnailElement.complete && thumbnailElement.naturalWidth > 0)
if (isAlreadyLoaded) {
loadedThumbnailSrcSet.add(thumbnailSrc)
setState.setIsThumbnailLoaded(true)
return
}
setState.setIsThumbnailLoaded(false)
}, [setState, thumbnailSrc])
const showContextMenu = useShowContextMenu()
@@ -113,6 +144,31 @@ export const ProgressiveImage = ({
// Only use HDR if the browser supports it and the image is HDR
const shouldUseHDR = isHDR && isHDRSupported
const webglPinchConfig = useMemo(
() => ({
step: 0.5,
disabled: !enableZoom,
}),
[enableZoom],
)
const webglDoubleClickConfig = useMemo(
() => ({
step: 2,
disabled: !enableZoom,
mode: 'toggle' as const,
animationTime: 200,
}),
[enableZoom],
)
const webglPanningConfig = useMemo(
() => ({
disabled: !enablePan,
}),
[enablePan],
)
return (
<div
className={clsxm('relative overflow-hidden', className)}
@@ -153,6 +209,8 @@ export const ProgressiveImage = ({
onZoomChange={onDOMTransformed}
minZoom={minZoom}
maxZoom={maxZoom}
enableZoom={enableZoom}
enablePan={enablePan}
src={blobSrc}
alt={alt}
highResLoaded={highResLoaded}
@@ -182,12 +240,15 @@ export const ProgressiveImage = ({
initialScale={1}
minScale={minZoom}
maxScale={maxZoom}
pinch={webglPinchConfig}
doubleClick={webglDoubleClickConfig}
panning={webglPanningConfig}
limitToBounds={true}
centerOnInit={true}
smooth={true}
onZoomChange={onTransformed}
onLoadingStateChange={handleWebGLLoadingStateChange}
debug={import.meta.env.DEV}
debug={import.meta.env.DEV && !isMobileDevice}
/>
)}
</div>

View File

@@ -1,40 +1,115 @@
import { Thumbhash } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { animate, m, useMotionValue } from 'motion/react'
import { useEffect, useRef } from 'react'
import type { PhotoViewerTransition } from './types'
interface PhotoViewerTransitionPreviewProps {
transition: PhotoViewerTransition
onReady?: () => void
onComplete: () => void
}
export const PhotoViewerTransitionPreview = ({ transition, onComplete }: PhotoViewerTransitionPreviewProps) => {
const baseTransition = Spring.snappy(0.5)
export const PhotoViewerTransitionPreview = ({
transition,
onReady,
onComplete,
}: PhotoViewerTransitionPreviewProps) => {
const baseTransition = {
duration: 0.42,
ease: [0.22, 1, 0.36, 1] as const,
}
const entryFadeOutTransition = {
duration: 0.22,
ease: [0.32, 0.72, 0, 1] as const,
}
const thumbHash = typeof transition.thumbHash === 'string' ? transition.thumbHash : null
const x = useMotionValue(transition.from.left)
const y = useMotionValue(transition.from.top)
const width = useMotionValue(transition.from.width)
const height = useMotionValue(transition.from.height)
const borderRadius = useMotionValue(transition.from.borderRadius)
const rotate = useMotionValue(transition.from.rotate)
const opacity = useMotionValue(1)
const hasReadyRef = useRef(false)
const hasCompletedRef = useRef(false)
useEffect(() => {
opacity.set(1)
hasReadyRef.current = false
hasCompletedRef.current = false
const complete = () => {
if (hasCompletedRef.current) return
hasCompletedRef.current = true
onComplete()
}
const ready = () => {
if (hasReadyRef.current) return
hasReadyRef.current = true
if (transition.variant === 'entry') {
onReady?.()
const fadeAnimation = animate(opacity, 0, {
...entryFadeOutTransition,
onComplete: complete,
})
animations.push(fadeAnimation)
return
}
complete()
}
const animations = [
animate(x, transition.to.left, baseTransition),
animate(y, transition.to.top, baseTransition),
animate(width, transition.to.width, {
...baseTransition,
onComplete: ready,
}),
animate(height, transition.to.height, baseTransition),
animate(borderRadius, transition.to.borderRadius, baseTransition),
animate(rotate, transition.to.rotate, baseTransition),
]
return () => {
animations.forEach((animation) => animation.stop())
}
}, [
borderRadius,
height,
onReady,
onComplete,
opacity,
rotate,
transition.to.borderRadius,
transition.to.height,
transition.to.left,
transition.to.rotate,
transition.to.top,
transition.to.width,
transition.variant,
width,
x,
y,
])
return (
<m.div
className="pointer-events-none fixed top-0 left-0 z-40"
className="pointer-events-none fixed top-0 left-0 z-[60]"
data-variant={`photo-viewer-transition-${transition.variant}`}
initial={{
x: transition.from.left,
y: transition.from.top,
width: transition.from.width,
height: transition.from.height,
borderRadius: transition.from.borderRadius,
opacity: 1,
style={{
x,
y,
width,
height,
borderRadius,
opacity,
rotate,
transformOrigin: '50% 50%',
}}
animate={{
x: transition.to.left,
y: transition.to.top,
width: transition.to.width,
height: transition.to.height,
borderRadius: transition.to.borderRadius,
opacity: 1,
}}
transition={baseTransition}
onAnimationComplete={onComplete}
>
<div className="relative h-full w-full overflow-hidden bg-black">
{thumbHash && (

View File

@@ -4,6 +4,7 @@ export interface AnimationFrameRect {
width: number
height: number
borderRadius: number
rotate: number
}
export type PhotoViewerTransitionVariant = 'entry' | 'exit'

View File

@@ -7,6 +7,7 @@ import type { AnimationFrameRect, PhotoViewerTransition, PhotoViewerTransitionSt
import { computeViewerImageFrame, escapeAttributeValue, getBorderRadius } from './utils'
interface UsePhotoViewerTransitionsParams {
exitOverrideFrame?: AnimationFrameRect | null
isOpen: boolean
triggerElement: HTMLElement | null
currentPhoto: PhotoManifest | undefined
@@ -24,11 +25,13 @@ interface UsePhotoViewerTransitionsResult {
shouldRenderBackdrop: boolean
thumbHash: string | null
shouldRenderThumbhash: boolean
handleEntryAnimationComplete: () => void
handleEntryTransitionReady: () => void
handleEntryTransitionComplete: () => void
handleExitAnimationComplete: () => void
}
export const usePhotoViewerTransitions = ({
exitOverrideFrame = null,
isOpen,
triggerElement,
currentPhoto,
@@ -157,16 +160,16 @@ export const usePhotoViewerTransitions = ({
triggerEl instanceof HTMLImageElement && triggerEl.parentElement ? triggerEl.parentElement : triggerEl,
)
setIsViewerContentVisible(true)
viewerImageFrameRef.current = {
left: targetFrame.left,
top: targetFrame.top,
width: targetFrame.width,
height: targetFrame.height,
borderRadius: targetFrame.borderRadius,
rotate: targetFrame.rotate,
}
const frameForAnimation = viewerImageFrameRef.current
const frameForAnimation = viewerImageFrameRef.current ?? targetFrame
const transitionState: PhotoViewerTransitionState = {
photoId: currentPhoto.id,
@@ -178,6 +181,7 @@ export const usePhotoViewerTransitions = ({
width: fromRect.width,
height: fromRect.height,
borderRadius: triggerBorderRadius,
rotate: 0,
},
to: {
left: frameForAnimation.left,
@@ -185,6 +189,7 @@ export const usePhotoViewerTransitions = ({
width: frameForAnimation.width,
height: frameForAnimation.height,
borderRadius: frameForAnimation.borderRadius,
rotate: frameForAnimation.rotate,
},
}
@@ -234,13 +239,7 @@ export const usePhotoViewerTransitions = ({
const viewportRect = viewerBoundsRef.current ?? containerRef.current?.getBoundingClientRect() ?? null
const computedFrame = computeViewerImageFrame(currentPhoto, viewportRect, isMobile)
const viewerFrame = viewerImageFrameRef.current ?? {
left: computedFrame.left,
top: computedFrame.top,
width: computedFrame.width,
height: computedFrame.height,
borderRadius: computedFrame.borderRadius,
}
const viewerFrame = exitOverrideFrame ?? viewerImageFrameRef.current ?? computedFrame
if (!viewerFrame.width || !viewerFrame.height) {
wasOpenRef.current = false
@@ -279,6 +278,7 @@ export const usePhotoViewerTransitions = ({
width: viewerFrame.width,
height: viewerFrame.height,
borderRadius: viewerFrame.borderRadius,
rotate: viewerFrame.rotate,
},
to: {
left: targetRect.left,
@@ -286,6 +286,7 @@ export const usePhotoViewerTransitions = ({
width: targetRect.width,
height: targetRect.height,
borderRadius,
rotate: 0,
},
}
@@ -296,6 +297,7 @@ export const usePhotoViewerTransitions = ({
isOpen,
currentPhoto,
currentBlobSrc,
exitOverrideFrame,
isMobile,
resolveTriggerElement,
restoreTriggerElementVisibility,
@@ -320,8 +322,11 @@ export const usePhotoViewerTransitions = ({
}
}, [isOpen])
const handleEntryAnimationComplete = useCallback(() => {
const handleEntryTransitionReady = useCallback(() => {
setIsViewerContentVisible(true)
}, [])
const handleEntryTransitionComplete = useCallback(() => {
setEntryTransition(null)
}, [])
@@ -346,7 +351,8 @@ export const usePhotoViewerTransitions = ({
shouldRenderBackdrop,
thumbHash,
shouldRenderThumbhash,
handleEntryAnimationComplete,
handleEntryTransitionReady,
handleEntryTransitionComplete,
handleExitAnimationComplete,
}
}

View File

@@ -78,5 +78,39 @@ export const computeViewerImageFrame = (
width: displayWidth,
height: displayHeight,
borderRadius: 0,
rotate: 0,
}
}
interface ViewerFrameTransformSnapshot {
borderRadius: number
rotate: number
scale: number
translateX: number
translateY: number
}
interface ViewportRectLike {
height: number
left: number
top: number
width: number
}
export const projectViewerImageFrame = (
frame: AnimationFrameRect,
viewportRect: ViewportRectLike,
snapshot: ViewerFrameTransformSnapshot,
): AnimationFrameRect => {
const originX = viewportRect.left + viewportRect.width * 0.5
const originY = viewportRect.top + viewportRect.height * 0.18
return {
left: originX + (frame.left - originX) * snapshot.scale + snapshot.translateX,
top: originY + (frame.top - originY) * snapshot.scale + snapshot.translateY,
width: frame.width * snapshot.scale,
height: frame.height * snapshot.scale,
borderRadius: snapshot.borderRadius,
rotate: snapshot.rotate,
}
}

View File

@@ -54,6 +54,8 @@ export interface DOMImageViewerProps {
onZoomChange?: (isZoomed: boolean, scale: number) => any
minZoom: number
maxZoom: number
enableZoom?: boolean
enablePan?: boolean
src: string
alt: string
highResLoaded: boolean

View File

@@ -0,0 +1,358 @@
import { Spring } from '@afilmory/utils'
import { useDrag } from '@use-gesture/react'
import { animate, useMotionValue, useTransform } from 'motion/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useViewport } from '~/hooks/useViewport'
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
const easeOutCubic = (value: number) => 1 - Math.pow(1 - value, 3)
const easeOutQuad = (value: number) => 1 - Math.pow(1 - value, 2)
interface MobilePhotoViewerInteractionsOptions {
enabled: boolean
isImageZoomed: boolean
onDismiss: (snapshot: MobilePhotoViewerDismissSnapshot) => void
}
export interface MobilePhotoViewerDismissSnapshot {
borderRadius: number
rotate: number
scale: number
translateX: number
translateY: number
}
export const usePhotoViewerMobileInteractions = ({
enabled,
isImageZoomed,
onDismiss,
}: MobilePhotoViewerInteractionsOptions) => {
const viewport = useViewport((value) => ({ width: value.w, height: value.h }))
const viewportWidth = viewport.width || (typeof window !== 'undefined' ? window.innerWidth : 390)
const viewportHeight = viewport.height || (typeof window !== 'undefined' ? window.innerHeight : 844)
const inspectorRevealDistance = useMemo(() => clamp(viewportHeight * 0.34, 220, 320), [viewportHeight])
const dismissThreshold = useMemo(() => clamp(viewportHeight * 0.18, 120, 180), [viewportHeight])
const dismissTravel = useMemo(() => viewportHeight + 160, [viewportHeight])
const inspectorProgress = useMotionValue(0)
const dismissX = useMotionValue(0)
const dismissY = useMotionValue(0)
const [isInspectorVisible, setIsInspectorVisible] = useState(false)
const [isVerticalGestureActive, setIsVerticalGestureActive] = useState(false)
const animationControlsRef = useRef<ReturnType<typeof animate>[]>([])
const isClosingRef = useRef(false)
const registerAnimation = useCallback((animation: ReturnType<typeof animate>) => {
animationControlsRef.current.push(animation)
return animation
}, [])
const stopAnimations = useCallback(() => {
animationControlsRef.current.forEach((animation) => animation.stop())
animationControlsRef.current = []
}, [])
const reset = useCallback(() => {
stopAnimations()
isClosingRef.current = false
setIsInspectorVisible(false)
setIsVerticalGestureActive(false)
inspectorProgress.set(0)
dismissX.set(0)
dismissY.set(0)
}, [dismissX, dismissY, inspectorProgress, stopAnimations])
useEffect(() => {
const unsubscribe = inspectorProgress.on('change', (latest) => {
setIsInspectorVisible(clamp(latest, 0, 1) > 0.02)
})
return () => {
unsubscribe()
}
}, [inspectorProgress])
useEffect(() => {
if (!enabled) {
reset()
}
}, [enabled, reset])
const springValue = useCallback(
(value: typeof inspectorProgress | typeof dismissX | typeof dismissY, to: number, velocity = 0) => {
return registerAnimation(
animate(value, to, {
...Spring.presets.smooth,
velocity,
}),
)
},
[registerAnimation],
)
const getDismissPresentationSnapshot = useCallback(
(translateX = dismissX.get(), translateY = dismissY.get()): MobilePhotoViewerDismissSnapshot => {
const dismissRatio = clamp(translateY / Math.max(dismissTravel, 1), 0, 1)
const dismissVisual = easeOutQuad(dismissRatio)
return {
translateX,
translateY,
scale: clamp(1 - dismissVisual * 0.13, 0.8, 1),
rotate: (translateX / Math.max(viewportWidth, 1)) * (4.5 + dismissVisual * 2.5),
borderRadius: dismissVisual * 22,
}
},
[dismissTravel, dismissX, dismissY, viewportWidth],
)
const settleInspector = useCallback(
(open: boolean, velocity = 0) => {
stopAnimations()
if (isClosingRef.current) return
const clampedVelocity = clamp(velocity, -2.2, 2.2)
const settleVelocity = open ? Math.max(clampedVelocity, 0) : Math.min(clampedVelocity, 0)
registerAnimation(
animate(inspectorProgress, open ? 1 : 0, {
...Spring.smooth(open ? 0.32 : 0.28),
velocity: settleVelocity,
}),
)
registerAnimation(animate(dismissX, 0, Spring.smooth(0.26)))
registerAnimation(animate(dismissY, 0, Spring.smooth(open ? 0.28 : 0.24)))
},
[dismissX, dismissY, inspectorProgress, registerAnimation, stopAnimations],
)
const dismissWithThrow = useCallback(
(velocityX: number, velocityY: number) => {
if (isClosingRef.current) return
isClosingRef.current = true
setIsInspectorVisible(false)
setIsVerticalGestureActive(false)
stopAnimations()
inspectorProgress.set(0)
const clampedVelocityX = clamp(velocityX, -2.2, 2.2)
const clampedVelocityY = clamp(velocityY, 0.72, 2.8)
const currentX = dismissX.get()
const currentY = dismissY.get()
const throwDistanceY = clamp(viewportHeight * (0.06 + clampedVelocityY * 0.045), 56, 168)
const throwDistanceX = clamp(clampedVelocityX * viewportWidth * 0.12, -viewportWidth * 0.16, viewportWidth * 0.16)
const targetX = clamp(currentX + throwDistanceX, -viewportWidth * 0.28, viewportWidth * 0.28)
const targetY = clamp(currentY + throwDistanceY, currentY + 40, viewportHeight * 0.42)
registerAnimation(
animate(dismissX, targetX, {
...Spring.snappy(0.18, 0.06),
velocity: clampedVelocityX * viewportWidth * 0.14,
}),
)
registerAnimation(
animate(dismissY, targetY, {
...Spring.snappy(0.18, 0.04),
velocity: Math.max(clampedVelocityY * viewportHeight * 0.08, 160),
onComplete: () => {
onDismiss(getDismissPresentationSnapshot(targetX, targetY))
},
}),
)
},
[
dismissX,
dismissY,
getDismissPresentationSnapshot,
inspectorProgress,
onDismiss,
registerAnimation,
stopAnimations,
viewportHeight,
viewportWidth,
],
)
const openInspector = useCallback(() => {
settleInspector(true)
}, [settleInspector])
const closeInspector = useCallback(() => {
settleInspector(false)
}, [settleInspector])
const toggleInspector = useCallback(() => {
settleInspector(inspectorProgress.get() <= 0.5)
}, [inspectorProgress, settleInspector])
const bindStage = useDrag(
({ active, axis, event, first, last, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy], memo }) => {
if (!enabled || isImageZoomed || isClosingRef.current) {
if (last) {
setIsVerticalGestureActive(false)
}
return memo
}
const start =
memo ??
({
inspectorPixels: inspectorProgress.get() * inspectorRevealDistance,
startedWithInspectorOpen: inspectorProgress.get() > 0.02,
ignore: false,
} as const)
if (first && event.target instanceof HTMLElement) {
const isInteractiveTarget = Boolean(
event.target.closest('button, a, [role="button"], [data-viewer-interactive]'),
)
if (isInteractiveTarget) {
return {
...start,
ignore: true,
}
}
}
if (start.ignore) {
if (last) {
setIsVerticalGestureActive(false)
}
return start
}
if (axis && axis !== 'y') {
if (last) {
setIsVerticalGestureActive(false)
}
return start
}
if (active) {
stopAnimations()
setIsVerticalGestureActive(true)
const nextInspectorPixels = start.inspectorPixels - my
const startedWithInspectorOpen = start.startedWithInspectorOpen || start.inspectorPixels > 0
if (startedWithInspectorOpen) {
inspectorProgress.set(clamp(nextInspectorPixels / inspectorRevealDistance, 0, 1))
dismissX.set(0)
dismissY.set(0)
} else if (nextInspectorPixels > 0) {
inspectorProgress.set(clamp(nextInspectorPixels / inspectorRevealDistance, 0, 1))
dismissX.set(0)
dismissY.set(0)
} else {
const nextDismissY = clamp(-nextInspectorPixels, 0, dismissTravel)
const dismissRatio = nextDismissY / Math.max(dismissTravel, 1)
inspectorProgress.set(0)
dismissY.set(nextDismissY)
dismissX.set(clamp(mx * (0.18 + dismissRatio * 0.12), -viewportWidth * 0.24, viewportWidth * 0.24))
}
}
if (last) {
setIsVerticalGestureActive(false)
const startedWithInspectorOpen =
start.startedWithInspectorOpen || start.inspectorPixels > 0 || inspectorProgress.get() > 0.02
const inspectorReleaseVelocity = -dy * vy
if (startedWithInspectorOpen) {
springValue(dismissX, 0)
springValue(dismissY, 0)
const currentProgress = inspectorProgress.get()
const shouldOpen = currentProgress > 0.42 || (dy < 0 && vy > 0.2)
settleInspector(shouldOpen, inspectorReleaseVelocity)
return start
}
const dismissDistance = dismissY.get()
if (dismissDistance > dismissThreshold || (dy > 0 && vy > 0.65 && my > 36)) {
dismissWithThrow(vx * (dx === 0 ? 1 : dx), Math.max(vy, 0.72))
return start
}
springValue(dismissX, 0)
springValue(dismissY, 0)
const currentProgress = inspectorProgress.get()
const shouldOpen = currentProgress > 0.42 || (dy < 0 && vy > 0.2)
settleInspector(shouldOpen, inspectorReleaseVelocity)
}
return start
},
{
axis: 'lock',
filterTaps: true,
threshold: 10,
pointer: { touch: true, capture: false },
rubberband: 0.12,
},
)
const dismissProgress = useTransform(dismissY, [0, dismissTravel], [0, 1])
const inspectorClampedProgress = useTransform(() => clamp(inspectorProgress.get(), 0, 1))
const inspectorVisualProgress = useTransform(() => easeOutCubic(inspectorClampedProgress.get()))
const dismissVisualProgress = useTransform(() => easeOutQuad(dismissProgress.get()))
const viewerScale = useTransform(() =>
clamp(1 - inspectorVisualProgress.get() * 0.022 - dismissVisualProgress.get() * 0.13, 0.8, 1),
)
const viewerRotate = useTransform(
() => (dismissX.get() / Math.max(viewportWidth, 1)) * (4.5 + dismissVisualProgress.get() * 2.5),
)
const viewerLiftY = useTransform(
() => dismissY.get() - inspectorVisualProgress.get() * 18 - dismissVisualProgress.get() * 8,
)
const viewerBorderRadius = useTransform(() => inspectorVisualProgress.get() * 14 + dismissVisualProgress.get() * 22)
const backdropOpacity = useTransform(() =>
clamp(1 - dismissVisualProgress.get() * 0.84 - inspectorVisualProgress.get() * 0.08, 0.08, 1),
)
const chromeOpacity = useTransform(() =>
clamp(1 - inspectorVisualProgress.get() * 0.92 - dismissVisualProgress.get() * 0.52, 0, 1),
)
const chromeY = useTransform(
() => dismissY.get() * 0.08 - inspectorVisualProgress.get() * 12 - dismissVisualProgress.get() * 6,
)
const thumbnailsOpacity = useTransform(() => {
return clamp(1 - inspectorVisualProgress.get() * 1.05 - dismissVisualProgress.get() * 0.58, 0, 1)
})
const thumbnailsY = useTransform(() => inspectorVisualProgress.get() * 18 + dismissVisualProgress.get() * 10)
const stageHintOpacity = useTransform(() =>
clamp(0.42 - inspectorVisualProgress.get() * 0.66 - dismissVisualProgress.get() * 0.54, 0, 0.42),
)
const stageHintY = useTransform(() => inspectorVisualProgress.get() * 10 + dismissVisualProgress.get() * 12)
return {
bindStage,
closeInspector,
dismissX,
dismissY,
inspectorProgress: inspectorClampedProgress,
isInspectorVisible,
isVerticalGestureActive,
openInspector,
reset,
settleInspector,
stageHintOpacity,
stageHintY,
thumbnailsOpacity,
thumbnailsY,
toggleInspector,
viewerBorderRadius,
viewerLiftY,
viewerRotate,
viewerScale,
backdropOpacity,
chromeOpacity,
chromeY,
}
}