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:
ChrAlpha
2025-09-24 00:43:46 +08:00
committed by GitHub
parent d62ecb34f1
commit eed6062388
10 changed files with 680 additions and 31 deletions

View File

@@ -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`}>

View File

@@ -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}

View File

@@ -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}
/>
)}
</>
)
}

View File

@@ -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

View File

@@ -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>
)
}

View 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
}

View File

@@ -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,
}
}

View 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,
}
}

View File

@@ -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)
}
}

View File

@@ -31,6 +31,7 @@ export const Component = () => {
photos={photos}
currentIndex={photoViewer.currentIndex}
isOpen={photoViewer.isOpen}
triggerElement={photoViewer.triggerElement}
onClose={photoViewer.closeViewer}
onIndexChange={photoViewer.goToIndex}
/>