mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
feat(PhotoViewer): add entry/exit animation (#110)
Co-authored-by: Innei <tukon479@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -33,7 +33,8 @@ export const ExifPanel: FC<{
|
||||
exifData: PickedExif | null
|
||||
|
||||
onClose?: () => void
|
||||
}> = ({ currentPhoto, exifData, onClose }) => {
|
||||
visible?: boolean
|
||||
}> = ({ currentPhoto, exifData, onClose, visible = true }) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMobile()
|
||||
const formattedExifData = formatExifData(exifData)
|
||||
@@ -66,14 +67,15 @@ export const ExifPanel: FC<{
|
||||
...(isMobile ? { y: 100 } : { x: 100 }),
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
...(isMobile ? { y: 0 } : { x: 0 }),
|
||||
opacity: visible ? 1 : 0,
|
||||
...(isMobile ? { y: visible ? 0 : 100 } : { x: visible ? 0 : 100 }),
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
...(isMobile ? { y: 100 } : { x: 100 }),
|
||||
}}
|
||||
transition={Spring.presets.smooth}
|
||||
style={{ pointerEvents: visible ? 'auto' : 'none' }}
|
||||
>
|
||||
<div className="mb-4 flex shrink-0 items-center justify-between p-4 pb-0">
|
||||
<h3 className={`${isMobile ? 'text-base' : 'text-lg'} font-semibold`}>
|
||||
|
||||
@@ -29,7 +29,8 @@ export const GalleryThumbnail: FC<{
|
||||
currentIndex: number
|
||||
photos: PhotoManifest[]
|
||||
onIndexChange: (index: number) => void
|
||||
}> = ({ currentIndex, photos, onIndexChange }) => {
|
||||
visible?: boolean
|
||||
}> = ({ currentIndex, photos, onIndexChange, visible = true }) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const isMobile = useMobile()
|
||||
@@ -100,10 +101,14 @@ export const GalleryThumbnail: FC<{
|
||||
return (
|
||||
<m.div
|
||||
className="bg-material-medium pb-safe z-10 shrink-0"
|
||||
initial={{ y: 100 }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: 100 }}
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{
|
||||
y: visible ? 0 : 48,
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
style={{ pointerEvents: visible ? 'auto' : 'none' }}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
@@ -24,6 +23,8 @@ import { Spring } from '~/lib/spring'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import { Thumbhash } from '../thumbhash'
|
||||
import { PhotoViewerTransitionPreview } from './animations/PhotoViewerTransitionPreview'
|
||||
import { usePhotoViewerTransitions } from './animations/usePhotoViewerTransitions'
|
||||
import { ExifPanel } from './ExifPanel'
|
||||
import { GalleryThumbnail } from './GalleryThumbnail'
|
||||
import type { LoadingIndicatorRef } from './LoadingIndicator'
|
||||
@@ -38,6 +39,7 @@ interface PhotoViewerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onIndexChange: (index: number) => void
|
||||
triggerElement: HTMLElement | null
|
||||
}
|
||||
|
||||
export const PhotoViewer = ({
|
||||
@@ -46,19 +48,37 @@ export const PhotoViewer = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
triggerElement,
|
||||
}: PhotoViewerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const swiperRef = useRef<SwiperType | null>(null)
|
||||
const [isImageZoomed, setIsImageZoomed] = useState(false)
|
||||
const [showExifPanel, setShowExifPanel] = useState(false)
|
||||
const [currentBlobSrc, setCurrentBlobSrc] = useState<string | null>(null)
|
||||
const isMobile = useMobile()
|
||||
|
||||
const isMobile = useMobile()
|
||||
const currentPhoto = photos[currentIndex]
|
||||
|
||||
// 当 PhotoViewer 关闭时重置缩放状态和面板状态
|
||||
useLayoutEffect(() => {
|
||||
const {
|
||||
containerRef,
|
||||
entryTransition,
|
||||
exitTransition,
|
||||
isViewerContentVisible,
|
||||
isEntryAnimating,
|
||||
shouldRenderBackdrop,
|
||||
thumbHash: transitionThumbHash,
|
||||
shouldRenderThumbhash,
|
||||
handleEntryAnimationComplete,
|
||||
handleExitAnimationComplete,
|
||||
} = usePhotoViewerTransitions({
|
||||
isOpen,
|
||||
triggerElement,
|
||||
currentPhoto,
|
||||
currentBlobSrc,
|
||||
isMobile,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsImageZoomed(false)
|
||||
setShowExifPanel(false)
|
||||
@@ -145,13 +165,16 @@ export const PhotoViewer = ({
|
||||
|
||||
if (!currentPhoto) return null
|
||||
|
||||
const currentThumbHash = transitionThumbHash
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
{shouldRenderBackdrop && (
|
||||
<m.div
|
||||
key="photo-viewer-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
animate={{ opacity: isOpen ? 1 : 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="bg-material-opaque fixed inset-0"
|
||||
@@ -161,39 +184,53 @@ export const PhotoViewer = ({
|
||||
{/* 固定背景层防止透出 */}
|
||||
{/* 交叉溶解的 Blurhash 背景 */}
|
||||
<AnimatePresence mode="sync">
|
||||
{isOpen && currentPhoto.thumbHash && (
|
||||
{shouldRenderThumbhash && (
|
||||
<m.div
|
||||
key={currentPhoto.id}
|
||||
key={`${currentPhoto.id}-thumbhash`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
animate={{ opacity: isOpen ? 1 : 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="fixed inset-0"
|
||||
>
|
||||
<Thumbhash
|
||||
thumbHash={currentPhoto.thumbHash}
|
||||
className="size-fill scale-110"
|
||||
/>
|
||||
{currentThumbHash && (
|
||||
<Thumbhash
|
||||
thumbHash={currentThumbHash}
|
||||
className="size-fill scale-110"
|
||||
/>
|
||||
)}
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
<m.div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ touchAction: isMobile ? 'manipulation' : 'none' }}
|
||||
style={{
|
||||
touchAction: isMobile ? 'manipulation' : 'none',
|
||||
pointerEvents:
|
||||
!isViewerContentVisible || isEntryAnimating ? 'none' : 'auto',
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isViewerContentVisible ? 1 : 0 }}
|
||||
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="group relative flex min-h-0 min-w-0 flex-1">
|
||||
<m.div
|
||||
className="group relative flex min-h-0 min-w-0 flex-1"
|
||||
animate={{ opacity: isViewerContentVisible ? 1 : 0 }}
|
||||
transition={Spring.presets.snappy}
|
||||
>
|
||||
{/* 顶部工具栏 */}
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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`}
|
||||
@@ -244,6 +281,14 @@ export const PhotoViewer = ({
|
||||
<ReactionButton
|
||||
photoId={currentPhoto.id}
|
||||
className="absolute right-4 bottom-4"
|
||||
style={{
|
||||
opacity: isViewerContentVisible ? 1 : 0,
|
||||
transition: 'opacity 180ms ease',
|
||||
pointerEvents:
|
||||
!isViewerContentVisible || isEntryAnimating
|
||||
? 'none'
|
||||
: 'auto',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -273,6 +318,8 @@ export const PhotoViewer = ({
|
||||
>
|
||||
{photos.map((photo, index) => {
|
||||
const isCurrentImage = index === currentIndex
|
||||
const hideCurrentImage =
|
||||
isEntryAnimating && isCurrentImage
|
||||
return (
|
||||
<SwiperSlide
|
||||
key={photo.id}
|
||||
@@ -285,6 +332,11 @@ export const PhotoViewer = ({
|
||||
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}
|
||||
@@ -349,13 +401,14 @@ export const PhotoViewer = ({
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
|
||||
<Suspense>
|
||||
<GalleryThumbnail
|
||||
currentIndex={currentIndex}
|
||||
photos={photos}
|
||||
onIndexChange={onIndexChange}
|
||||
visible={isViewerContentVisible}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -368,6 +421,7 @@ export const PhotoViewer = ({
|
||||
<ExifPanel
|
||||
currentPhoto={currentPhoto}
|
||||
exifData={currentPhoto.exif}
|
||||
visible={isViewerContentVisible}
|
||||
onClose={
|
||||
isMobile ? () => setShowExifPanel(false) : undefined
|
||||
}
|
||||
@@ -376,9 +430,23 @@ export const PhotoViewer = ({
|
||||
</AnimatePresenceOnlyMobile>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{entryTransition && (
|
||||
<PhotoViewerTransitionPreview
|
||||
key={`${entryTransition.variant}-${entryTransition.photoId}`}
|
||||
transition={entryTransition}
|
||||
onComplete={handleEntryAnimationComplete}
|
||||
/>
|
||||
)}
|
||||
{exitTransition && (
|
||||
<PhotoViewerTransitionPreview
|
||||
key={`${exitTransition.variant}-${exitTransition.photoId}`}
|
||||
transition={exitTransition}
|
||||
onComplete={handleExitAnimationComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FluentEmoji, getEmoji } from '@lobehub/fluent-emoji'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { produce } from 'immer'
|
||||
import { AnimatePresence, m } from 'motion/react'
|
||||
import type { RefObject } from 'react'
|
||||
import type { CSSProperties, RefObject } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -21,6 +21,7 @@ interface ReactionButtonProps {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
photoId: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
const reactionButton = tv({
|
||||
@@ -87,6 +88,7 @@ export const ReactionButton = ({
|
||||
className,
|
||||
disabled = false,
|
||||
photoId,
|
||||
style,
|
||||
}: ReactionButtonProps) => {
|
||||
const [panelElement, setPanelElement] = useState<HTMLDivElement | null>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -131,7 +133,7 @@ export const ReactionButton = ({
|
||||
const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
return (
|
||||
<div className={clsxm(styles.base(), className)}>
|
||||
<div className={clsxm(styles.base(), className)} style={style}>
|
||||
<DropdownMenu.Root open={isOpen}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<m.button
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { m } from 'motion/react'
|
||||
|
||||
import { Thumbhash } from '~/components/ui/thumbhash'
|
||||
import { Spring } from '~/lib/spring'
|
||||
|
||||
import type { PhotoViewerTransition } from './types'
|
||||
|
||||
interface PhotoViewerTransitionPreviewProps {
|
||||
transition: PhotoViewerTransition
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export const PhotoViewerTransitionPreview = ({
|
||||
transition,
|
||||
onComplete,
|
||||
}: PhotoViewerTransitionPreviewProps) => {
|
||||
const baseTransition = Spring.snappy(0.5)
|
||||
const thumbHash =
|
||||
typeof transition.thumbHash === 'string' ? transition.thumbHash : null
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className="pointer-events-none fixed top-0 left-0 z-[40]"
|
||||
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,
|
||||
}}
|
||||
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 && (
|
||||
<Thumbhash
|
||||
thumbHash={thumbHash}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={transition.imageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
21
apps/web/src/components/ui/photo-viewer/animations/types.ts
Normal file
21
apps/web/src/components/ui/photo-viewer/animations/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface AnimationFrameRect {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
borderRadius: number
|
||||
}
|
||||
|
||||
export type PhotoViewerTransitionVariant = 'entry' | 'exit'
|
||||
|
||||
export interface PhotoViewerTransitionState {
|
||||
photoId: string
|
||||
imageSrc: string
|
||||
thumbHash?: string | null
|
||||
from: AnimationFrameRect
|
||||
to: AnimationFrameRect
|
||||
}
|
||||
|
||||
export type PhotoViewerTransition = PhotoViewerTransitionState & {
|
||||
variant: PhotoViewerTransitionVariant
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { RefObject } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import type {
|
||||
AnimationFrameRect,
|
||||
PhotoViewerTransition,
|
||||
PhotoViewerTransitionState,
|
||||
} from './types'
|
||||
import {
|
||||
computeViewerImageFrame,
|
||||
escapeAttributeValue,
|
||||
getBorderRadius,
|
||||
} from './utils'
|
||||
|
||||
interface UsePhotoViewerTransitionsParams {
|
||||
isOpen: boolean
|
||||
triggerElement: HTMLElement | null
|
||||
currentPhoto: PhotoManifest | undefined
|
||||
currentBlobSrc: string | null
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
interface UsePhotoViewerTransitionsResult {
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
entryTransition: PhotoViewerTransition | null
|
||||
exitTransition: PhotoViewerTransition | null
|
||||
isViewerContentVisible: boolean
|
||||
isEntryAnimating: boolean
|
||||
shouldRenderBackdrop: boolean
|
||||
thumbHash: string | null
|
||||
shouldRenderThumbhash: boolean
|
||||
handleEntryAnimationComplete: () => void
|
||||
handleExitAnimationComplete: () => void
|
||||
}
|
||||
|
||||
export const usePhotoViewerTransitions = ({
|
||||
isOpen,
|
||||
triggerElement,
|
||||
currentPhoto,
|
||||
currentBlobSrc,
|
||||
isMobile,
|
||||
}: UsePhotoViewerTransitionsParams): UsePhotoViewerTransitionsResult => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const cachedTriggerRef = useRef<HTMLElement | null>(triggerElement)
|
||||
const wasOpenRef = useRef(isOpen)
|
||||
const viewerBoundsRef = useRef<DOMRect | null>(null)
|
||||
const hiddenTriggerRef = useRef<HTMLElement | null>(null)
|
||||
const hiddenTriggerPrevVisibilityRef = useRef<string | null>(null)
|
||||
const viewerImageFrameRef = useRef<AnimationFrameRect | null>(null)
|
||||
|
||||
const [entryTransition, setEntryTransition] =
|
||||
useState<PhotoViewerTransition | null>(null)
|
||||
const [exitTransition, setExitTransition] =
|
||||
useState<PhotoViewerTransition | null>(null)
|
||||
const [isViewerContentVisible, setIsViewerContentVisible] = useState(false)
|
||||
|
||||
const restoreTriggerElementVisibility = useCallback(() => {
|
||||
const trigger = hiddenTriggerRef.current
|
||||
if (trigger) {
|
||||
const prevVisibility = hiddenTriggerPrevVisibilityRef.current
|
||||
if (prevVisibility !== null && prevVisibility !== undefined) {
|
||||
trigger.style.visibility = prevVisibility
|
||||
} else {
|
||||
trigger.style.removeProperty('visibility')
|
||||
}
|
||||
}
|
||||
hiddenTriggerRef.current = null
|
||||
hiddenTriggerPrevVisibilityRef.current = null
|
||||
}, [])
|
||||
|
||||
const resolveTriggerElement = useCallback((): HTMLElement | null => {
|
||||
if (!currentPhoto) return null
|
||||
|
||||
if (triggerElement && triggerElement.isConnected) {
|
||||
cachedTriggerRef.current = triggerElement
|
||||
return triggerElement
|
||||
}
|
||||
|
||||
const selector = `[data-photo-id="${escapeAttributeValue(currentPhoto.id)}"]`
|
||||
const liveTriggerEl =
|
||||
typeof document === 'undefined'
|
||||
? null
|
||||
: document.querySelector<HTMLElement>(selector)
|
||||
|
||||
if (liveTriggerEl && liveTriggerEl.isConnected) {
|
||||
cachedTriggerRef.current = liveTriggerEl
|
||||
return liveTriggerEl
|
||||
}
|
||||
|
||||
if (cachedTriggerRef.current && cachedTriggerRef.current.isConnected) {
|
||||
return cachedTriggerRef.current
|
||||
}
|
||||
|
||||
return null
|
||||
}, [currentPhoto, triggerElement])
|
||||
|
||||
useEffect(() => {
|
||||
if (triggerElement) {
|
||||
cachedTriggerRef.current = triggerElement
|
||||
}
|
||||
}, [triggerElement])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
restoreTriggerElementVisibility()
|
||||
}
|
||||
}, [restoreTriggerElementVisibility])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setEntryTransition(null)
|
||||
setIsViewerContentVisible(false)
|
||||
viewerImageFrameRef.current = null
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
resolveTriggerElement()
|
||||
}, [isOpen, resolveTriggerElement])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen || !currentPhoto) return
|
||||
if (entryTransition || isViewerContentVisible) return
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
setIsViewerContentVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
const triggerEl = resolveTriggerElement()
|
||||
|
||||
if (!triggerEl) {
|
||||
setIsViewerContentVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
const fromRect = triggerEl.getBoundingClientRect()
|
||||
const viewportRect =
|
||||
viewerBoundsRef.current ??
|
||||
containerRef.current?.getBoundingClientRect() ??
|
||||
null
|
||||
const targetFrame = computeViewerImageFrame(
|
||||
currentPhoto,
|
||||
viewportRect,
|
||||
isMobile,
|
||||
)
|
||||
|
||||
if (
|
||||
!fromRect.width ||
|
||||
!fromRect.height ||
|
||||
!targetFrame.width ||
|
||||
!targetFrame.height
|
||||
) {
|
||||
setIsViewerContentVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
const imageSrc =
|
||||
currentBlobSrc ||
|
||||
currentPhoto.thumbnailUrl ||
|
||||
currentPhoto.originalUrl ||
|
||||
null
|
||||
|
||||
if (!imageSrc) {
|
||||
setIsViewerContentVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
hiddenTriggerRef.current = triggerEl
|
||||
hiddenTriggerPrevVisibilityRef.current = triggerEl.style.visibility || null
|
||||
triggerEl.style.visibility = 'hidden'
|
||||
|
||||
const triggerBorderRadius = getBorderRadius(
|
||||
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,
|
||||
}
|
||||
|
||||
const frameForAnimation = viewerImageFrameRef.current
|
||||
|
||||
const transitionState: PhotoViewerTransitionState = {
|
||||
photoId: currentPhoto.id,
|
||||
imageSrc,
|
||||
thumbHash: currentPhoto.thumbHash,
|
||||
from: {
|
||||
left: fromRect.left,
|
||||
top: fromRect.top,
|
||||
width: fromRect.width,
|
||||
height: fromRect.height,
|
||||
borderRadius: triggerBorderRadius,
|
||||
},
|
||||
to: {
|
||||
left: frameForAnimation.left,
|
||||
top: frameForAnimation.top,
|
||||
width: frameForAnimation.width,
|
||||
height: frameForAnimation.height,
|
||||
borderRadius: frameForAnimation.borderRadius,
|
||||
},
|
||||
}
|
||||
|
||||
setEntryTransition({ ...transitionState, variant: 'entry' })
|
||||
}, [
|
||||
isOpen,
|
||||
currentPhoto,
|
||||
entryTransition,
|
||||
isViewerContentVisible,
|
||||
currentBlobSrc,
|
||||
isMobile,
|
||||
resolveTriggerElement,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
wasOpenRef.current = true
|
||||
setExitTransition(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!wasOpenRef.current || !currentPhoto) {
|
||||
wasOpenRef.current = false
|
||||
restoreTriggerElementVisibility()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
wasOpenRef.current = false
|
||||
restoreTriggerElementVisibility()
|
||||
return
|
||||
}
|
||||
|
||||
const triggerEl = resolveTriggerElement()
|
||||
|
||||
if (!triggerEl || !triggerEl.isConnected) {
|
||||
wasOpenRef.current = false
|
||||
restoreTriggerElementVisibility()
|
||||
setExitTransition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const targetRect = triggerEl.getBoundingClientRect()
|
||||
if (!targetRect.width || !targetRect.height) {
|
||||
wasOpenRef.current = false
|
||||
restoreTriggerElementVisibility()
|
||||
setExitTransition(null)
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
if (!viewerFrame.width || !viewerFrame.height) {
|
||||
wasOpenRef.current = false
|
||||
restoreTriggerElementVisibility()
|
||||
setExitTransition(null)
|
||||
return
|
||||
}
|
||||
|
||||
viewerImageFrameRef.current = viewerFrame
|
||||
|
||||
const borderRadius = getBorderRadius(
|
||||
triggerEl instanceof HTMLImageElement && triggerEl.parentElement
|
||||
? triggerEl.parentElement
|
||||
: triggerEl,
|
||||
)
|
||||
|
||||
const imageSrc =
|
||||
currentBlobSrc ||
|
||||
currentPhoto.thumbnailUrl ||
|
||||
currentPhoto.originalUrl ||
|
||||
null
|
||||
|
||||
if (!imageSrc) {
|
||||
wasOpenRef.current = false
|
||||
restoreTriggerElementVisibility()
|
||||
setExitTransition(null)
|
||||
return
|
||||
}
|
||||
|
||||
restoreTriggerElementVisibility()
|
||||
hiddenTriggerRef.current = triggerEl
|
||||
hiddenTriggerPrevVisibilityRef.current = triggerEl.style.visibility || null
|
||||
triggerEl.style.visibility = 'hidden'
|
||||
|
||||
const transitionState: PhotoViewerTransitionState = {
|
||||
photoId: currentPhoto.id,
|
||||
imageSrc,
|
||||
thumbHash: currentPhoto.thumbHash,
|
||||
from: {
|
||||
left: viewerFrame.left,
|
||||
top: viewerFrame.top,
|
||||
width: viewerFrame.width,
|
||||
height: viewerFrame.height,
|
||||
borderRadius: viewerFrame.borderRadius,
|
||||
},
|
||||
to: {
|
||||
left: targetRect.left,
|
||||
top: targetRect.top,
|
||||
width: targetRect.width,
|
||||
height: targetRect.height,
|
||||
borderRadius,
|
||||
},
|
||||
}
|
||||
|
||||
setExitTransition({ ...transitionState, variant: 'exit' })
|
||||
|
||||
wasOpenRef.current = false
|
||||
}, [
|
||||
isOpen,
|
||||
currentPhoto,
|
||||
currentBlobSrc,
|
||||
isMobile,
|
||||
resolveTriggerElement,
|
||||
restoreTriggerElementVisibility,
|
||||
])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const updateBounds = () => {
|
||||
if (containerRef.current) {
|
||||
viewerBoundsRef.current = containerRef.current.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
|
||||
updateBounds()
|
||||
window.addEventListener('resize', updateBounds)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateBounds)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleEntryAnimationComplete = useCallback(() => {
|
||||
setIsViewerContentVisible(true)
|
||||
setEntryTransition(null)
|
||||
}, [])
|
||||
|
||||
const handleExitAnimationComplete = useCallback(() => {
|
||||
restoreTriggerElementVisibility()
|
||||
setExitTransition(null)
|
||||
}, [restoreTriggerElementVisibility])
|
||||
|
||||
const isEntryAnimating = Boolean(entryTransition)
|
||||
const shouldRenderBackdrop =
|
||||
isOpen || Boolean(exitTransition) || Boolean(entryTransition)
|
||||
|
||||
const thumbHash =
|
||||
typeof currentPhoto?.thumbHash === 'string' ? currentPhoto.thumbHash : null
|
||||
const shouldRenderThumbhash = shouldRenderBackdrop && Boolean(thumbHash)
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
entryTransition,
|
||||
exitTransition,
|
||||
isViewerContentVisible,
|
||||
isEntryAnimating,
|
||||
shouldRenderBackdrop,
|
||||
thumbHash,
|
||||
shouldRenderThumbhash,
|
||||
handleEntryAnimationComplete,
|
||||
handleExitAnimationComplete,
|
||||
}
|
||||
}
|
||||
90
apps/web/src/components/ui/photo-viewer/animations/utils.ts
Normal file
90
apps/web/src/components/ui/photo-viewer/animations/utils.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import type { AnimationFrameRect } from './types'
|
||||
|
||||
export const DESKTOP_EXIF_PANEL_WIDTH_REM = 20
|
||||
|
||||
const THUMBNAIL_SIZE = {
|
||||
mobile: 48,
|
||||
desktop: 64,
|
||||
} as const
|
||||
|
||||
const THUMBNAIL_PADDING = {
|
||||
mobile: 12,
|
||||
desktop: 16,
|
||||
} as const
|
||||
|
||||
export const escapeAttributeValue = (value: string) => {
|
||||
if (typeof window !== 'undefined' && window.CSS?.escape) {
|
||||
return window.CSS.escape(value)
|
||||
}
|
||||
|
||||
return value.replaceAll(/['\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
const getRootFontSize = () => {
|
||||
if (typeof window === 'undefined') return 16
|
||||
const value = window.getComputedStyle(document.documentElement).fontSize
|
||||
const parsed = Number.parseFloat(value || '16')
|
||||
return Number.isNaN(parsed) ? 16 : parsed
|
||||
}
|
||||
|
||||
export const getBorderRadius = (element: Element | null) => {
|
||||
if (typeof window === 'undefined' || !element) return 0
|
||||
|
||||
const computedStyle = window.getComputedStyle(element)
|
||||
const radiusCandidates = [
|
||||
computedStyle.borderRadius,
|
||||
computedStyle.borderTopLeftRadius,
|
||||
computedStyle.borderTopRightRadius,
|
||||
].filter((value) => value && value !== '0px')
|
||||
|
||||
if (radiusCandidates.length === 0) return 0
|
||||
|
||||
const parsed = Number.parseFloat(radiusCandidates[0] || '0')
|
||||
if (Number.isNaN(parsed)) return 0
|
||||
return Math.max(0, parsed)
|
||||
}
|
||||
|
||||
export const computeViewerImageFrame = (
|
||||
photo: PhotoManifest,
|
||||
viewportRect: DOMRect | null,
|
||||
isMobile: boolean,
|
||||
): AnimationFrameRect => {
|
||||
const baseFontSize = getRootFontSize()
|
||||
const exifWidth = isMobile ? 0 : DESKTOP_EXIF_PANEL_WIDTH_REM * baseFontSize
|
||||
const thumbnailHeight = isMobile
|
||||
? THUMBNAIL_SIZE.mobile + THUMBNAIL_PADDING.mobile * 2
|
||||
: THUMBNAIL_SIZE.desktop + THUMBNAIL_PADDING.desktop * 2
|
||||
|
||||
const viewportWidth = viewportRect?.width ?? window.innerWidth
|
||||
const viewportHeight = viewportRect?.height ?? window.innerHeight
|
||||
const viewportLeft = viewportRect?.left ?? 0
|
||||
const viewportTop = viewportRect?.top ?? 0
|
||||
|
||||
const contentWidth = Math.max(0, viewportWidth - exifWidth)
|
||||
const contentHeight = Math.max(0, viewportHeight - thumbnailHeight)
|
||||
|
||||
const photoWidth = photo.width || contentWidth
|
||||
const photoHeight = photo.height || contentHeight || 1
|
||||
const photoAspect = photoWidth / photoHeight || 1
|
||||
|
||||
let displayWidth = contentWidth
|
||||
let displayHeight = contentWidth / photoAspect
|
||||
|
||||
if (displayHeight > contentHeight) {
|
||||
displayHeight = contentHeight
|
||||
displayWidth = contentHeight * photoAspect
|
||||
}
|
||||
|
||||
const left = viewportLeft + (contentWidth - displayWidth) / 2
|
||||
const top = viewportTop + (contentHeight - displayHeight) / 2
|
||||
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width: displayWidth,
|
||||
height: displayHeight,
|
||||
borderRadius: 0,
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,13 @@ export const MasonryPhotoItem = ({
|
||||
|
||||
const handleClick = () => {
|
||||
const photoIndex = photos.findIndex((photo) => photo.id === data.id)
|
||||
if (photoIndex !== -1 && imageRef.current) {
|
||||
photoViewer.openViewer(photoIndex, imageRef.current)
|
||||
if (photoIndex !== -1) {
|
||||
const triggerEl =
|
||||
imageRef.current?.parentElement instanceof HTMLElement
|
||||
? imageRef.current.parentElement
|
||||
: imageRef.current
|
||||
|
||||
photoViewer.openViewer(photoIndex, triggerEl ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export const Component = () => {
|
||||
photos={photos}
|
||||
currentIndex={photoViewer.currentIndex}
|
||||
isOpen={photoViewer.isOpen}
|
||||
triggerElement={photoViewer.triggerElement}
|
||||
onClose={photoViewer.closeViewer}
|
||||
onIndexChange={photoViewer.goToIndex}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user