'use client'; import { useCallback, useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import useEmblaCarousel from 'embla-carousel-react'; import { useGesture } from '@use-gesture/react'; import { motion, useMotionValue, useSpring, animate, useMotionValueEvent } from 'framer-motion'; import { X, FileText, FileArchive, FileSpreadsheet, Image as ImageIcon, Video, Music, File, Download, ZoomIn, ZoomOut, RotateCw, ChevronLeft, ChevronRight, Maximize2, HelpCircle, ArrowLeft, ArrowRight, Plus, Minus, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Kbd } from '@/components/ui/kbd'; import { cn, downloadFromFile } from '@/lib/utils'; import { formatBytes } from '@/lib/format'; // ==================== 工具函数 ==================== /** * 根据文件 MIME 类型返回对应的图标 */ export const getFileIcon = (type: string | undefined) => { if (!!type) { if (type.startsWith('image/')) return ; if (type.startsWith('video/')) return ; if (type.startsWith('audio/')) return ; if (type.includes('pdf')) return ; if (type.includes('word') || type.includes('doc')) return ; if (type.includes('excel') || type.includes('sheet')) return ; if (type.includes('zip') || type.includes('rar') || type.includes('7z')) return ; } return ; }; // ==================== 类型定义 ==================== /** * 文件预览项的基础接口 */ export interface FilePreviewItem { /** 文件唯一标识 */ id: string; /** 文件名称 */ name: string; /** 文件大小(字节) */ size: number; /** 文件 MIME 类型 */ type?: string; /** 预览 URL(用于图片预览) */ preview?: string; /** 上传进度(0-100),undefined 表示未上传或已完成 */ progress?: number; /** 浏览器 File 对象(可选,用于下载功能) */ file?: File; } /** * 文件卡片预览组件的属性 */ export interface FileCardPreviewProps { /** 文件列表 */ files: FilePreviewItem[]; /** 外层容器的自定义类名 */ className?: string; /** 网格容器的自定义类名 */ gridClassName?: string; /** 是否禁用操作按钮 */ disabled?: boolean; /** 是否显示下载按钮 */ showDownload?: boolean; /** 是否显示删除按钮 */ showRemove?: boolean; /** 是否显示文件信息(文件名和大小) */ showFileInfo?: boolean; /** 删除文件的回调函数 */ onRemove?: (id: string, file: FilePreviewItem) => void; /** 下载文件的回调函数(如果不提供,将使用默认的 downloadFromFile) */ onDownload?: (id: string, file: FilePreviewItem) => void; /** 点击文件卡片的回调函数 */ onClick?: (id: string, file: FilePreviewItem) => void; } // ==================== 文件卡片预览组件 ==================== /** * 文件卡片预览组件 * * 用于以卡片网格形式展示文件列表,支持图片预览、文件信息显示、下载和删除操作。 * * 特性: * - 响应式网格布局(移动端 1 列,平板 2 列,桌面 3 列) * - 图片文件显示预览图,其他文件显示对应图标 * - 支持上传进度显示(圆形进度条) * - PC 端悬停显示操作按钮和文件信息 * - 移动端点击激活显示操作按钮和文件信息 * - 可自定义是否显示下载、删除按钮和文件信息 * * @example * ```tsx * console.log('Remove', id)} * /> * ``` */ export function FileCardPreview({ files, className, gridClassName, disabled = false, showDownload = true, showRemove = true, showFileInfo = true, onRemove, onDownload, onClick, }: FileCardPreviewProps) { const [activeFileId, setActiveFileId] = useState(null); const [carouselOpen, setCarouselOpen] = useState(false); const [carouselIndex, setCarouselIndex] = useState(0); const handleRemove = useCallback( (id: string, e: React.MouseEvent) => { e.stopPropagation(); const fileItem = files.find((f) => f.id === id); if (fileItem && onRemove) { onRemove(id, fileItem); } setActiveFileId(null); }, [files, onRemove] ); const handleDownload = useCallback( (id: string, e: React.MouseEvent) => { e.stopPropagation(); const fileItem = files.find((f) => f.id === id); if (fileItem) { if (onDownload) { onDownload(id, fileItem); } else if (fileItem.file) { downloadFromFile(fileItem.file); } } }, [files, onDownload] ); const handlePreview = useCallback( (id: string, e: React.MouseEvent) => { e.stopPropagation(); const index = files.findIndex((f) => f.id === id); if (index !== -1) { setCarouselIndex(index); setCarouselOpen(true); } }, [files] ); const handleFileClick = useCallback( (id: string) => { const fileItem = files.find((f) => f.id === id); if (fileItem && onClick) { onClick(id, fileItem); } setActiveFileId((prev) => (prev === id ? null : id)); }, [files, onClick] ); if (files.length === 0) { return null; } return ( {files.map((fileItem) => ( handleFileClick(fileItem.id)} > {/* 文件预览区域 */} {fileItem.type?.startsWith('image/') && fileItem.preview ? ( ) : ( {getFileIcon(fileItem.type)} )} {/* 上传进度指示器 */} {fileItem.progress !== undefined && ( {/* 背景圆环 */} {/* 进度圆环 */} {Math.round(fileItem.progress)}% )} {/* PC端悬停或移动端点击后显示的操作按钮 */} {(showDownload || showRemove || fileItem.type?.startsWith('image/')) && ( {fileItem.type?.startsWith('image/') && ( handlePreview(fileItem.id, e)} type="button" variant="secondary" size="icon" disabled={disabled} className={cn( 'size-7', activeFileId === fileItem.id && 'pointer-events-auto' )} title="预览" > )} {showDownload && (fileItem.file || onDownload) && ( handleDownload(fileItem.id, e)} type="button" variant="secondary" size="icon" disabled={disabled} className={cn( 'size-7', activeFileId === fileItem.id && 'pointer-events-auto' )} title="下载" > )} {showRemove && onRemove && ( handleRemove(fileItem.id, e)} type="button" variant="secondary" size="icon" disabled={disabled} className={cn( 'size-7', activeFileId === fileItem.id && 'pointer-events-auto' )} title="删除" > )} )} {/* PC端悬停或移动端点击后显示的文件信息 */} {showFileInfo && ( {fileItem.name} {formatBytes(fileItem.size)} )} ))} {/* 轮播预览 */} setCarouselOpen(false)} onDownload={onDownload} /> ); } // ==================== 轮播预览组件 ==================== /** * 文件轮播预览组件的属性 */ export interface FileCarouselPreviewProps { /** 文件列表 */ files: FilePreviewItem[]; /** 初始显示的文件索引 */ initialIndex?: number; /** 是否打开预览 */ open: boolean; /** 关闭预览的回调 */ onClose: () => void; /** 下载文件的回调函数 */ onDownload?: (id: string, file: FilePreviewItem) => void; } /** * 文件轮播预览组件 * * 全屏图片查看器,支持缩放、旋转、拖拽、键盘导航等功能。 * * 特性: * - Portal 渲染(避免 z-index 问题) * - ESC 键关闭,缩放、旋转、拖拽均有快捷键支持 * - 缩放(按钮 + 滚轮)、旋转、拖拽移动(放大后) * - 下载图片 * - 平滑动画 * - 响应式设计 * - 触摸友好 * - ARIA 属性 * - 键盘导航 * * @example * ```tsx * const [open, setOpen] = useState(false); * const [index, setIndex] = useState(0); * * setOpen(false)} * /> * ``` */ export function FileCarouselPreview({ files, initialIndex = 0, open, onClose, onDownload, }: FileCarouselPreviewProps) { const [mounted, setMounted] = useState(false); const [emblaRef, emblaApi] = useEmblaCarousel({ startIndex: initialIndex, loop: false, duration: 20, // 设置切换动画时长(毫秒) }); const [currentIndex, setCurrentIndex] = useState(initialIndex); const [rotation, setRotation] = useState(0); const [showHelp, setShowHelp] = useState(false); const [currentScale, setCurrentScale] = useState(1); // 使用 framer-motion 的 motion values 和 spring 动画 const scaleMotion = useMotionValue(1); const xMotion = useMotionValue(0); const yMotion = useMotionValue(0); // 使用 useSpring 包装 motion values,添加弹性物理效果 const scale = useSpring(scaleMotion, { stiffness: 300, damping: 30 }); const x = useSpring(xMotion, { stiffness: 300, damping: 30 }); const y = useSpring(yMotion, { stiffness: 300, damping: 30 }); // 监听 scale 变化,更新 state 以触发按钮状态更新 useMotionValueEvent(scale, "change", (latest) => { setCurrentScale(latest); }); const imageRef = useRef(null); const containerRef = useRef(null); const thumbnailRef = useRef(null); // 确保组件在客户端挂载 useEffect(() => { setMounted(true); }, []); // 监听 embla 选中事件 useEffect(() => { if (!emblaApi) return; const onSelect = () => { setCurrentIndex(emblaApi.selectedScrollSnap()); // 切换图片时重置变换 animate(scaleMotion, 1, { duration: 0.2 }); animate(xMotion, 0, { duration: 0.2 }); animate(yMotion, 0, { duration: 0.2 }); setRotation(0); }; emblaApi.on('select', onSelect); onSelect(); return () => { emblaApi.off('select', onSelect); }; }, [emblaApi, scaleMotion, xMotion, yMotion]); // 禁用背景滚动 useEffect(() => { if (open) { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; } }, [open]); // 缩放功能 const handleZoomIn = useCallback(() => { const current = scaleMotion.get(); animate(scaleMotion, Math.min(current + 0.25, 5), { duration: 0.2 }); }, [scaleMotion]); const handleZoomOut = useCallback(() => { const current = scaleMotion.get(); animate(scaleMotion, Math.max(current - 0.25, 0.5), { duration: 0.2 }); }, [scaleMotion]); const handleRotate = useCallback(() => { setRotation((prev) => (prev + 90) % 360); }, []); const handleReset = useCallback(() => { animate(scaleMotion, 1, { duration: 0.2 }); animate(xMotion, 0, { duration: 0.2 }); animate(yMotion, 0, { duration: 0.2 }); setRotation(0); }, [scaleMotion, xMotion, yMotion]); // 键盘事件处理 useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { // 阻止事件冒泡,避免关闭父对话框 switch (e.key) { case 'Escape': e.preventDefault(); e.stopPropagation(); onClose(); break; case 'ArrowLeft': e.preventDefault(); e.stopPropagation(); emblaApi?.scrollPrev(); break; case 'ArrowRight': e.preventDefault(); e.stopPropagation(); emblaApi?.scrollNext(); break; case '+': case '=': e.preventDefault(); e.stopPropagation(); handleZoomIn(); break; case '-': e.preventDefault(); e.stopPropagation(); handleZoomOut(); break; case 'r': case 'R': e.preventDefault(); e.stopPropagation(); handleRotate(); break; case '0': e.preventDefault(); e.stopPropagation(); handleReset(); break; case '?': e.preventDefault(); e.stopPropagation(); setShowHelp((prev) => !prev); break; } }; // 使用 capture 阶段捕获事件,优先级更高 window.addEventListener('keydown', handleKeyDown, true); return () => window.removeEventListener('keydown', handleKeyDown, true); }, [open, emblaApi, onClose, handleZoomIn, handleZoomOut, handleRotate, handleReset]); // 使用 use-gesture 处理所有手势 // 注意:必须使用 target 选项才能正确使用 preventDefault useGesture( { // 拖拽手势 - 用于移动图片或切换图片 onDrag: ({ offset: [ox, oy], active }) => { const currentScale = scale.get(); // 如果图片放大了,拖拽用于移动图片 if (currentScale > 1) { x.set(ox); y.set(oy); } else if (!active) { // 重置位置,切换的逻辑embla-carousel-react处理,这里不用管 animate(x, 0, { duration: 0.3 }); animate(y, 0, { duration: 0.3 }); } }, // 滚轮手势 - 用于缩放 onWheel: ({ event, delta: [, dy], last }) => { // 避免在最后一个事件中访问 event(debounced) if (!last && event) { event.preventDefault(); } const currentScale = scaleMotion.get(); const scaleDelta = dy > 0 ? -0.1 : 0.1; const newScale = Math.max(0.5, Math.min(5, currentScale + scaleDelta)); if (!last) { scaleMotion.set(newScale); } }, // 双指缩放手势 - 移动端 onPinch: ({ offset: [s], origin: [ox, oy], first, memo, last }) => { if (first) { const currentScale = scaleMotion.get(); const currentX = xMotion.get(); const currentY = yMotion.get(); return [currentScale, currentX, currentY, ox, oy]; } const [initialScale, initialX, initialY, initialOx, initialOy] = memo; const newScale = Math.max(0.5, Math.min(5, s)); // 计算缩放中心偏移 if (imageRef.current) { const rect = imageRef.current.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; // 相对于图片中心的偏移 const offsetX = ox - centerX; const offsetY = oy - centerY; // 根据缩放调整位置,使缩放中心保持在手指位置 const scaleRatio = newScale / initialScale; xMotion.set(initialX + offsetX * (1 - scaleRatio)); yMotion.set(initialY + offsetY * (1 - scaleRatio)); } if (!last) { scaleMotion.set(newScale); } return memo; }, }, { target: imageRef, drag: { from: () => [x.get(), y.get()], bounds: (state) => { const currentScale = scale.get(); if (currentScale <= 1) { // 未放大时,允许左右拖拽切换 return { left: -200, right: 200, top: 0, bottom: 0 }; } // 放大时不限制边界 return { left: -Infinity, right: Infinity, top: -Infinity, bottom: Infinity }; }, }, pinch: { scaleBounds: { min: 0.5, max: 5 }, eventOptions: { passive: false }, }, wheel: { eventOptions: { passive: false }, }, } ); // 缩略图容器的拖动手势 useGesture( { onDrag: ({ movement: [mx], memo = thumbnailRef.current?.scrollLeft ?? 0 }) => { if (thumbnailRef.current) { thumbnailRef.current.scrollLeft = memo - mx; } return memo; }, }, { target: thumbnailRef, drag: { axis: 'x', filterTaps: true, }, } ); // 下载当前文件 const handleDownloadCurrent = useCallback(() => { const currentFile = files[currentIndex]; if (currentFile) { if (onDownload) { onDownload(currentFile.id, currentFile); } else if (currentFile.file) { downloadFromFile(currentFile.file); } } }, [files, currentIndex, onDownload]); if (!mounted || !open) return null; const currentFile = files[currentIndex]; const canScrollPrev = emblaApi?.canScrollPrev() ?? false; const canScrollNext = emblaApi?.canScrollNext() ?? false; return createPortal( { // 阻止键盘事件冒泡到父组件 e.stopPropagation(); }} role="dialog" aria-modal="true" aria-label="文件预览" > {/* 顶部工具栏 */} {currentIndex + 1} / {files.length} {currentFile && ( {currentFile.name} )} { setShowHelp((prev) => !prev); }} className="text-white hover:bg-white/20" title="快捷键帮助 (?)" > { onClose(); }} className="text-white hover:bg-white/20" title="关闭 (ESC)" > {/* 快捷键帮助面板 */} {showHelp && ( 键盘快捷键 ESC 关闭 切换图片 缩放 R 旋转 0 重置 ? 显示/隐藏帮助 )} {/* 左侧工具栏 */} { handleZoomIn(); }} disabled={currentScale >= 5} title="放大 (+)" className="bg-black/50 hover:bg-black/70 text-white" > { handleZoomOut(); }} disabled={currentScale <= 0.5} title="缩小 (-)" className="bg-black/50 hover:bg-black/70 text-white" > { handleRotate(); }} title="旋转 (R)" className="bg-black/50 hover:bg-black/70 text-white" > { handleReset(); }} title="重置 (0)" className="bg-black/50 hover:bg-black/70 text-white" > {(currentFile?.file || onDownload) && ( { handleDownloadCurrent(); }} title="下载" className="bg-black/50 hover:bg-black/70 text-white" > )} {/* 轮播容器 */} {files.map((file, index) => ( 1 ? 'grab' : 'default', }} > {file.type?.startsWith('image/') && file.preview ? ( ) : ( {getFileIcon(file.type)} {file.name} {formatBytes(file.size)} )} ))} {/* 底部导航区域:左右切换按钮 + 缩略图 */} {/* 左切换按钮 */} emblaApi?.scrollPrev()} disabled={!canScrollPrev} className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed" title="上一张 (←)" > {/* 缩略图导航 */} {files.map((file, index) => ( emblaApi?.scrollTo(index)} className={cn( 'relative size-16 flex-shrink-0 rounded overflow-hidden border-2 transition-all cursor-pointer select-none', index === currentIndex ? 'border-primary ring-2 ring-primary' : 'border-transparent hover:border-white/50' )} title={file.name} > {file.type?.startsWith('image/') && file.preview ? ( ) : ( {getFileIcon(file.type)} )} ))} {/* 右切换按钮 */} emblaApi?.scrollNext()} disabled={!canScrollNext} className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed" title="下一张 (→)" > , document.body ); }
{fileItem.name}
{formatBytes(fileItem.size)}
{file.name}
{formatBytes(file.size)}