From 2ea072524c72b85d44f5c8d49971732c4e38bb59 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 19 Jun 2025 18:21:28 +0800 Subject: [PATCH] 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 --- .../ui/media/PreviewMediaContent.tsx | 238 ++++++++++++++---- 1 file changed, 194 insertions(+), 44 deletions(-) diff --git a/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx b/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx index 44460739e..3a9f90106 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx @@ -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(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 (
- {isArray ? children[0] : children} + {isArray ? renderedChildren[0] : renderedChildren}
{hasSideContent ? (
- {children[1]} + {isArray ? renderedChildren[1] : null}
) : undefined}
@@ -93,7 +218,7 @@ const HeaderActions: FC<{ @@ -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 ( - + {/* Glass effect overlay */} +
+ + {/* Icon container */} +
{children}
+ + {/* Subtle inner shadow for depth */} +
+ - - {description} - + {description && ( + + {description} + + )} ) } @@ -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 ( - - {[ + + {(handleZoomChange) => [ {isVideo ? ( )} , @@ -209,8 +364,8 @@ export const PreviewMediaContent: FC<{ ) } return ( - - {[ + + {(handleZoomChange) => [
{media.map((med) => ( @@ -234,6 +389,7 @@ export const PreviewMediaContent: FC<{ height={med.height} width={med.width} blurhash={med.blurhash} + onZoomChange={handleZoomChange} /> )}
@@ -241,34 +397,26 @@ export const PreviewMediaContent: FC<{
{currentSlideIndex > 0 && ( - + )} {currentSlideIndex < media.length - 1 && ( - + )} - -
- {currentSlideIndex + 1} / {media.length} -
, 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 && (