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:
parent
15b8d79567
commit
2ea072524c
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue