mirror of
https://github.com/Afilmory/afilmory
synced 2026-05-04 19:57:25 +00:00
feat(viewer): new vertical gesture and animation on mobile (#240)
This commit is contained in:
@@ -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}
|
||||
|
||||
159
apps/web/src/modules/viewer/MobilePhotoInspectorSheet.tsx
Normal file
159
apps/web/src/modules/viewer/MobilePhotoInspectorSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface AnimationFrameRect {
|
||||
width: number
|
||||
height: number
|
||||
borderRadius: number
|
||||
rotate: number
|
||||
}
|
||||
|
||||
export type PhotoViewerTransitionVariant = 'entry' | 'exit'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
358
apps/web/src/modules/viewer/usePhotoViewerMobileInteractions.ts
Normal file
358
apps/web/src/modules/viewer/usePhotoViewerMobileInteractions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user