feat: enhance media preview with drag-to-close functionality and animation

- Implement drag-to-close feature for the media preview modal, allowing users to dismiss the modal by dragging.
- Introduce dynamic scaling and opacity adjustments during drag interactions.
- Update modal animations using Framer Motion for smoother transitions.
- Refactor child component handling to support zoom state changes and improve overall structure.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-06-19 18:21:28 +08:00
parent 15b8d79567
commit 2ea072524c
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
1 changed files with 194 additions and 44 deletions

View File

@ -11,7 +11,9 @@ import { stopPropagation } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import useEmblaCarousel from "embla-carousel-react"
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
import { useAnimationControls } from "motion/react"
import type { FC } from "react"
import * as React from "react"
import { Fragment, useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import type { ReactZoomPanPinchRef, ReactZoomPanPinchState } from "react-zoom-pan-pinch"
@ -24,41 +26,164 @@ import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { useCurrentModal } from "../modal/stacked/hooks"
import { VideoPlayer } from "./VideoPlayer"
// Calculate the dynamic scale value and offset
const calculateDragTransforms = (x: number, y: number) => {
// Minimum scale to 0.7, maximum keep 1.0
const maxDistance = 300
const dragDistance = Math.hypot(x, y)
const progress = Math.min(dragDistance / maxDistance, 1)
const scale = 1 - progress * 0.3 // From 1.0 to 0.7
// Calculate the opacity, minimum to 0.5
const opacity = 1 - progress * 0.5
return { scale, opacity, x, y }
}
// Framer Motion variants
const modalVariants = {
initial: { scale: 0.94, opacity: 0 },
visible: { scale: 1, opacity: 1, x: 0, y: 0 },
exit: { scale: 0.94, opacity: 0 },
closing: (dragOffset: { x: number; y: number }) => ({
scale: 0.3,
x: dragOffset.x,
y: dragOffset.y,
opacity: 0,
}),
}
const Wrapper: FC<{
src: string
children: [React.ReactNode, React.ReactNode | undefined] | React.ReactNode
children:
| [React.ReactNode, React.ReactNode | undefined]
| React.ReactNode
| ((
onZoomChange: (isZoomed: boolean) => void,
) => [React.ReactNode, React.ReactNode | undefined] | React.ReactNode)
className?: string
}> = ({ children, src }) => {
onZoomChange?: (isZoomed: boolean) => void
canDragClose?: boolean
}> = ({ children, src, onZoomChange, canDragClose = true }) => {
const containerRef = useRef<HTMLDivElement>(null)
const { dismiss } = useCurrentModal()
const controls = useAnimationControls()
const isArray = Array.isArray(children)
const hasSideContent = isArray && !!children[1]
// Drag close state
const [isImageZoomed, setIsImageZoomed] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
// Combined zoom change callback
const handleZoomChange = useCallback(
(isZoomed: boolean) => {
setIsImageZoomed(isZoomed)
onZoomChange?.(isZoomed)
},
[onZoomChange],
)
const renderedChildren = typeof children === "function" ? children(handleZoomChange) : children
const isArray = Array.isArray(renderedChildren)
const hasSideContent = isArray && !!renderedChildren[1]
const enableDragClose = !isImageZoomed && canDragClose
const handleDrag = useCallback(
(_: any, info: any) => {
if (!isDragging) return
const { offset } = info
setDragOffset(offset)
// Real-time update the transform when dragging
const dragTransforms = calculateDragTransforms(offset.x, offset.y)
controls.set({
scale: dragTransforms.scale,
x: offset.x * 0.3,
y: offset.y * 0.3,
opacity: dragTransforms.opacity,
})
},
[isDragging, controls],
)
const handleDragEnd = useCallback(
async (_: any, info: any) => {
const { offset, velocity } = info
// Calculate the drag distance and velocity
const dragDistance = Math.hypot(offset.x, offset.y)
const velocityDistance = Math.hypot(velocity.x, velocity.y)
// If the drag distance is greater than 100px or the overall drag distance is greater than 150px or the velocity is greater than 300, close the modal
const shouldClose =
offset.y > 100 || dragDistance > 150 || velocity.y > 300 || velocityDistance > 500
if (shouldClose) {
// Execute the closing animation
await controls.start("closing", {
type: "spring",
stiffness: 400,
damping: 40,
duration: 0.3,
})
dismiss()
} else {
// Reset to normal state
setIsDragging(false)
setDragOffset({ x: 0, y: 0 })
controls.start("visible", {
...Spring.presets.snappy,
})
}
},
[controls, dismiss],
)
const handleDragStart = useCallback(() => {
setIsDragging(true)
}, [])
// Initialize the animation
useEffect(() => {
controls.start("visible", {
...Spring.presets.snappy,
})
}, [controls])
return (
<div ref={containerRef} className="fixed inset-0">
<m.div
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.94, opacity: 0 }}
transition={Spring.presets.snappy}
variants={modalVariants}
initial="initial"
animate={controls}
exit="exit"
custom={dragOffset}
className="bg-material-medium-dark flex size-full backdrop-blur"
drag={enableDragClose}
dragConstraints={{ top: 0, bottom: 300, left: -200, right: 200 }}
dragElastic={{ top: 0, bottom: 0.3, left: 0.2, right: 0.2 }}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
style={{
cursor: enableDragClose ? (isDragging ? "grabbing" : "grab") : "default",
}}
>
<div
className={cn(
"group/left relative flex h-full w-0 grow overflow-hidden",
hasSideContent ? "min-w-96 items-center justify-center" : "px-6",
hasSideContent ? "min-w-96 items-center justify-center" : "",
)}
>
<HeaderActions src={src} />
{isArray ? children[0] : children}
{isArray ? renderedChildren[0] : renderedChildren}
</div>
{hasSideContent ? (
<div
className="bg-background box-border flex h-full w-[400px] min-w-0 shrink-0 flex-col px-2 pt-1"
onClick={stopPropagation}
>
{children[1]}
{isArray ? renderedChildren[1] : null}
</div>
) : undefined}
</m.div>
@ -93,7 +218,7 @@ const HeaderActions: FC<{
<HeaderButton
description={t("common:words.close")}
className="ml-3 !bg-[#121212] !opacity-100"
className="ml-3 !border-red-500/20 !bg-red-600/30 !opacity-100 hover:!bg-red-600/50"
onClick={dismiss}
>
<i className="i-mgc-close-cute-re" />
@ -103,7 +228,7 @@ const HeaderActions: FC<{
}
const HeaderButton: FC<{
description: string
description?: string
onClick: () => void
className?: string
children: React.ReactNode
@ -111,24 +236,52 @@ const HeaderButton: FC<{
return (
<Tooltip>
<TooltipTrigger asChild>
<button
<m.button
type="button"
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className={cn(
"hover:bg-material-ultra-thick-dark cursor-button backdrop-blur-background pointer-events-auto flex size-10 items-center justify-center rounded-full bg-transparent text-white duration-200 hover:text-white",
"text-lg opacity-0 transition-opacity group-hover/left:opacity-100",
// Base styles with modern glass morphism - perfect 1:1 circle
"pointer-events-auto relative flex size-10 items-center justify-center rounded-full",
"bg-black/20 text-white backdrop-blur-md",
// Border and shadow for depth
"border border-white/10 shadow-lg shadow-black/25",
// Opacity and transition
"opacity-0 transition-all duration-300 ease-out group-hover/left:opacity-100",
// Text size
"text-lg",
className,
)}
initial={{ scale: 1 }}
whileHover={{
scale: 1.1,
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderColor: "rgba(255, 255, 255, 0.2)",
}}
whileTap={{ scale: 0.95 }}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
>
{children}
</button>
{/* Glass effect overlay */}
<div className="absolute inset-0 rounded-full bg-gradient-to-t from-white/5 to-white/20 opacity-0 transition-opacity duration-300 hover:opacity-100" />
{/* Icon container */}
<div className="center relative z-10 flex">{children}</div>
{/* Subtle inner shadow for depth */}
<div className="absolute inset-0 rounded-full shadow-inner shadow-black/10" />
</m.button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{description}</TooltipContent>
</TooltipPortal>
{description && (
<TooltipPortal>
<TooltipContent>{description}</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
)
}
@ -139,7 +292,8 @@ export const PreviewMediaContent: FC<{
media: PreviewMediaProps[]
initialIndex?: number
children?: React.ReactNode
}> = ({ media, initialIndex = 0, children }) => {
onZoomChange?: (isZoomed: boolean) => void
}> = ({ media, initialIndex = 0, children, onZoomChange }) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, startIndex: initialIndex }, [
WheelGesturesPlugin(),
])
@ -179,8 +333,8 @@ export const PreviewMediaContent: FC<{
const { type } = media[0]!
const isVideo = type === "video"
return (
<Wrapper src={src}>
{[
<Wrapper src={src} onZoomChange={onZoomChange} canDragClose>
{(handleZoomChange) => [
<Fragment key={src}>
{isVideo ? (
<VideoPlayer
@ -200,6 +354,7 @@ export const PreviewMediaContent: FC<{
height={media[0]!.height}
width={media[0]!.width}
blurhash={media[0]!.blurhash}
onZoomChange={handleZoomChange}
/>
)}
</Fragment>,
@ -209,8 +364,8 @@ export const PreviewMediaContent: FC<{
)
}
return (
<Wrapper src={currentMedia!.url}>
{[
<Wrapper src={currentMedia!.url} onZoomChange={onZoomChange} canDragClose={false}>
{(handleZoomChange) => [
<div key={"left"} className="group size-full overflow-hidden" ref={emblaRef}>
<div className="flex size-full">
{media.map((med) => (
@ -234,6 +389,7 @@ export const PreviewMediaContent: FC<{
height={med.height}
width={med.width}
blurhash={med.blurhash}
onZoomChange={handleZoomChange}
/>
)}
</div>
@ -241,34 +397,26 @@ export const PreviewMediaContent: FC<{
</div>
{currentSlideIndex > 0 && (
<button
type="button"
className={`bg-material-medium absolute left-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:left-4 lg:size-10`}
onClick={(e) => {
e.stopPropagation()
<HeaderButton
className={`absolute left-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:left-4 lg:size-10`}
onClick={() => {
emblaApi?.scrollPrev()
}}
>
<i className={`i-mingcute-left-line text-lg lg:text-xl`} />
</button>
</HeaderButton>
)}
{currentSlideIndex < media.length - 1 && (
<button
type="button"
className={`bg-material-medium absolute right-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:right-4 lg:size-10`}
onClick={(e) => {
e.stopPropagation()
<HeaderButton
className={`absolute right-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:right-4 lg:size-10`}
onClick={() => {
emblaApi?.scrollNext()
}}
>
<i className={`i-mingcute-right-line text-lg lg:text-xl`} />
</button>
</HeaderButton>
)}
<div className="absolute bottom-4 right-4 z-30 tabular-nums">
{currentSlideIndex + 1} / {media.length}
</div>
</div>,
children,
]}
@ -282,8 +430,9 @@ const FallbackableImage: FC<
containerClassName?: string
fallbackUrl?: string
blurhash?: string
onZoomChange?: (isZoomed: boolean) => void
}
> = ({ src, fallbackUrl, containerClassName }) => {
> = ({ src, fallbackUrl, containerClassName, onZoomChange }) => {
const [currentSrc, setCurrentSrc] = useState(() => replaceImgUrlIfNeed(src))
const [isAllError, setIsAllError] = useState(false)
@ -334,6 +483,7 @@ const FallbackableImage: FC<
highResLoaded={!isLoading}
onLoad={() => setIsLoading(false)}
onError={handleError}
onZoomChange={onZoomChange}
/>
)}
{isAllError && (