forked from admin/hair-keeper
968 lines
31 KiB
TypeScript
968 lines
31 KiB
TypeScript
'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 <ImageIcon className="size-6" />;
|
||
if (type.startsWith('video/')) return <Video className="size-6" />;
|
||
if (type.startsWith('audio/')) return <Music className="size-6" />;
|
||
if (type.includes('pdf')) return <FileText className="size-6" />;
|
||
if (type.includes('word') || type.includes('doc')) return <FileText className="size-6" />;
|
||
if (type.includes('excel') || type.includes('sheet')) return <FileSpreadsheet className="size-6" />;
|
||
if (type.includes('zip') || type.includes('rar') || type.includes('7z')) return <FileArchive className="size-6" />;
|
||
}
|
||
return <File className="size-6" />;
|
||
};
|
||
|
||
// ==================== 类型定义 ====================
|
||
|
||
/**
|
||
* 文件预览项的基础接口
|
||
*/
|
||
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
|
||
* <FileCardPreview
|
||
* files={files}
|
||
* showDownload
|
||
* showRemove
|
||
* onRemove={(id) => 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<string | null>(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 (
|
||
<div className={cn('w-full', className)}>
|
||
<div
|
||
className={cn(
|
||
'grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3',
|
||
gridClassName
|
||
)}
|
||
>
|
||
{files.map((fileItem) => (
|
||
<div
|
||
key={fileItem.id}
|
||
className="group relative aspect-square cursor-pointer p-1"
|
||
onClick={() => handleFileClick(fileItem.id)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'relative h-full overflow-hidden rounded-lg border bg-card transition-all',
|
||
activeFileId === fileItem.id
|
||
? 'border-primary ring-2 ring-primary ring-offset-2 shadow-lg'
|
||
: 'md:group-hover:border-primary/50 md:group-hover:shadow-md'
|
||
)}
|
||
>
|
||
{/* 文件预览区域 */}
|
||
{fileItem.type?.startsWith('image/') && fileItem.preview ? (
|
||
<img
|
||
src={fileItem.preview}
|
||
alt={fileItem.name}
|
||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground">
|
||
{getFileIcon(fileItem.type)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 上传进度指示器 */}
|
||
{fileItem.progress !== undefined && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||
<div className="relative size-16">
|
||
<svg className="size-full -rotate-90" viewBox="0 0 100 100">
|
||
{/* 背景圆环 */}
|
||
<circle
|
||
cx="50"
|
||
cy="50"
|
||
r="45"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="8"
|
||
className="text-white/20"
|
||
/>
|
||
{/* 进度圆环 */}
|
||
<circle
|
||
cx="50"
|
||
cy="50"
|
||
r="45"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="8"
|
||
strokeLinecap="round"
|
||
strokeDasharray={`${2 * Math.PI * 45}`}
|
||
strokeDashoffset={`${2 * Math.PI * 45 * (1 - fileItem.progress / 100)}`}
|
||
className="text-primary transition-all duration-300"
|
||
/>
|
||
</svg>
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<span className="text-sm font-semibold text-white">
|
||
{Math.round(fileItem.progress)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* PC端悬停或移动端点击后显示的操作按钮 */}
|
||
{(showDownload || showRemove || fileItem.type?.startsWith('image/')) && (
|
||
<div
|
||
className={cn(
|
||
'absolute inset-0 flex items-center justify-center gap-2 rounded-lg bg-black/50 transition-opacity',
|
||
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100 pointer-events-none md:group-hover:pointer-events-auto'
|
||
)}
|
||
>
|
||
{fileItem.type?.startsWith('image/') && (
|
||
<Button
|
||
onClick={(e) => handlePreview(fileItem.id, e)}
|
||
type="button"
|
||
variant="secondary"
|
||
size="icon"
|
||
disabled={disabled}
|
||
className={cn(
|
||
'size-7',
|
||
activeFileId === fileItem.id && 'pointer-events-auto'
|
||
)}
|
||
title="预览"
|
||
>
|
||
<ZoomIn className="size-4" />
|
||
</Button>
|
||
)}
|
||
{showDownload && (fileItem.file || onDownload) && (
|
||
<Button
|
||
onClick={(e) => handleDownload(fileItem.id, e)}
|
||
type="button"
|
||
variant="secondary"
|
||
size="icon"
|
||
disabled={disabled}
|
||
className={cn(
|
||
'size-7',
|
||
activeFileId === fileItem.id && 'pointer-events-auto'
|
||
)}
|
||
title="下载"
|
||
>
|
||
<Download className="size-4" />
|
||
</Button>
|
||
)}
|
||
{showRemove && onRemove && (
|
||
<Button
|
||
onClick={(e) => handleRemove(fileItem.id, e)}
|
||
type="button"
|
||
variant="secondary"
|
||
size="icon"
|
||
disabled={disabled}
|
||
className={cn(
|
||
'size-7',
|
||
activeFileId === fileItem.id && 'pointer-events-auto'
|
||
)}
|
||
title="删除"
|
||
>
|
||
<X className="size-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* PC端悬停或移动端点击后显示的文件信息 */}
|
||
{showFileInfo && (
|
||
<div
|
||
className={cn(
|
||
'absolute bottom-0 left-0 right-0 rounded-b-lg bg-gradient-to-t from-black/80 via-black/60 to-transparent p-2 pt-8 text-white transition-opacity',
|
||
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100'
|
||
)}
|
||
>
|
||
<p className="truncate text-xs font-medium" title={fileItem.name}>
|
||
{fileItem.name}
|
||
</p>
|
||
<p className="text-xs text-gray-300">
|
||
{formatBytes(fileItem.size)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 轮播预览 */}
|
||
<FileCarouselPreview
|
||
files={files}
|
||
initialIndex={carouselIndex}
|
||
open={carouselOpen}
|
||
onClose={() => setCarouselOpen(false)}
|
||
onDownload={onDownload}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// ==================== 轮播预览组件 ====================
|
||
|
||
/**
|
||
* 文件轮播预览组件的属性
|
||
*/
|
||
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);
|
||
*
|
||
* <FileCarouselPreview
|
||
* files={files}
|
||
* initialIndex={index}
|
||
* open={open}
|
||
* onClose={() => 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<HTMLDivElement>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const thumbnailRef = useRef<HTMLDivElement>(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(
|
||
<div
|
||
ref={containerRef}
|
||
className="fixed inset-0 z-70 flex items-center justify-center bg-black/95 pointer-events-auto"
|
||
onKeyDown={(e) => {
|
||
// 阻止键盘事件冒泡到父组件
|
||
e.stopPropagation();
|
||
}}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="文件预览"
|
||
>
|
||
{/* 顶部工具栏 */}
|
||
<div
|
||
className="absolute top-0 left-0 right-0 z-10 flex items-start gap-2 p-4 bg-gradient-to-b from-black/50 to-transparent pointer-events-none"
|
||
>
|
||
<span className="text-sm font-medium text-white shrink-0">
|
||
{currentIndex + 1} / {files.length}
|
||
</span>
|
||
|
||
{currentFile && (
|
||
<span className="text-sm text-gray-300 break-words flex-1 min-w-0">
|
||
{currentFile.name}
|
||
</span>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 shrink-0 pointer-events-auto">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
setShowHelp((prev) => !prev);
|
||
}}
|
||
className="text-white hover:bg-white/20"
|
||
title="快捷键帮助 (?)"
|
||
>
|
||
<HelpCircle className='size-5'/>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
onClose();
|
||
}}
|
||
className="text-white hover:bg-white/20"
|
||
title="关闭 (ESC)"
|
||
>
|
||
<X className="size-5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 快捷键帮助面板 */}
|
||
{showHelp && (
|
||
<div
|
||
className="absolute top-20 right-4 z-20 bg-black/90 text-white p-4 rounded-lg text-sm space-y-3 max-w-xs pointer-events-none"
|
||
>
|
||
<h3 className="font-semibold mb-2">键盘快捷键</h3>
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between items-center gap-4">
|
||
<Kbd className="bg-white/10 text-white border border-white/20">ESC</Kbd>
|
||
<span className="text-gray-300">关闭</span>
|
||
</div>
|
||
<div className="flex justify-between items-center gap-4">
|
||
<div className="flex gap-1">
|
||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||
<ArrowLeft className="size-3" />
|
||
</Kbd>
|
||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||
<ArrowRight className="size-3" />
|
||
</Kbd>
|
||
</div>
|
||
<span className="text-gray-300">切换图片</span>
|
||
</div>
|
||
<div className="flex justify-between items-center gap-4">
|
||
<div className="flex gap-1">
|
||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||
<Plus className="size-3" />
|
||
</Kbd>
|
||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||
<Minus className="size-3" />
|
||
</Kbd>
|
||
</div>
|
||
<span className="text-gray-300">缩放</span>
|
||
</div>
|
||
<div className="flex justify-between items-center gap-4">
|
||
<Kbd className="bg-white/10 text-white border border-white/20">R</Kbd>
|
||
<span className="text-gray-300">旋转</span>
|
||
</div>
|
||
<div className="flex justify-between items-center gap-4">
|
||
<Kbd className="bg-white/10 text-white border border-white/20">0</Kbd>
|
||
<span className="text-gray-300">重置</span>
|
||
</div>
|
||
<div className="flex justify-between items-center gap-4">
|
||
<Kbd className="bg-white/10 text-white border border-white/20">?</Kbd>
|
||
<span className="text-gray-300">显示/隐藏帮助</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 左侧工具栏 */}
|
||
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-10 flex flex-col gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
handleZoomIn();
|
||
}}
|
||
disabled={currentScale >= 5}
|
||
title="放大 (+)"
|
||
className="bg-black/50 hover:bg-black/70 text-white"
|
||
>
|
||
<ZoomIn className="size-5" />
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
handleZoomOut();
|
||
}}
|
||
disabled={currentScale <= 0.5}
|
||
title="缩小 (-)"
|
||
className="bg-black/50 hover:bg-black/70 text-white"
|
||
>
|
||
<ZoomOut className="size-5" />
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
handleRotate();
|
||
}}
|
||
title="旋转 (R)"
|
||
className="bg-black/50 hover:bg-black/70 text-white"
|
||
>
|
||
<RotateCw className="size-5" />
|
||
</Button>
|
||
<Button
|
||
variant="secondary"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
handleReset();
|
||
}}
|
||
title="重置 (0)"
|
||
className="bg-black/50 hover:bg-black/70 text-white"
|
||
>
|
||
<Maximize2 className="size-5" />
|
||
</Button>
|
||
{(currentFile?.file || onDownload) && (
|
||
<Button
|
||
variant="secondary"
|
||
size="icon"
|
||
onClick={(e) => {
|
||
handleDownloadCurrent();
|
||
}}
|
||
title="下载"
|
||
className="bg-black/50 hover:bg-black/70 text-white"
|
||
>
|
||
<Download className="size-5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 轮播容器 */}
|
||
<div className="relative w-full h-full flex items-center justify-center">
|
||
<div ref={emblaRef} className="overflow-hidden w-full h-full">
|
||
<div className="flex h-full gap-8">
|
||
{files.map((file, index) => (
|
||
<div
|
||
key={file.id}
|
||
className="flex-[0_0_100%] min-w-0 flex items-center justify-center"
|
||
>
|
||
<div
|
||
ref={index === currentIndex ? imageRef : null}
|
||
className="relative flex items-center justify-center w-full h-full touch-none"
|
||
style={{
|
||
cursor: index === currentIndex && scale.get() > 1 ? 'grab' : 'default',
|
||
}}
|
||
>
|
||
{file.type?.startsWith('image/') && file.preview ? (
|
||
<motion.img
|
||
src={file.preview}
|
||
alt={file.name}
|
||
className="max-w-full max-h-full object-contain select-none"
|
||
style={
|
||
index === currentIndex
|
||
? {
|
||
scale,
|
||
x,
|
||
y,
|
||
rotate: rotation,
|
||
}
|
||
: {}
|
||
}
|
||
draggable={false}
|
||
/>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
||
{getFileIcon(file.type)}
|
||
<div className="text-center">
|
||
<p className="text-lg font-medium">{file.name}</p>
|
||
<p className="text-sm text-gray-400">{formatBytes(file.size)}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{/* 底部导航区域:左右切换按钮 + 缩略图 */}
|
||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
|
||
{/* 左切换按钮 */}
|
||
<Button
|
||
variant="secondary"
|
||
size="icon"
|
||
onClick={() => 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="上一张 (←)"
|
||
>
|
||
<ChevronLeft className="size-5" />
|
||
</Button>
|
||
|
||
{/* 缩略图导航 */}
|
||
<div
|
||
ref={thumbnailRef}
|
||
className="flex gap-2 max-w-[70vw] overflow-x-auto scrollbar-muted p-2 bg-black/50 rounded-lg cursor-grab active:cursor-grabbing touch-pan-x"
|
||
>
|
||
{files.map((file, index) => (
|
||
<button
|
||
key={file.id}
|
||
onClick={() => 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 ? (
|
||
<img
|
||
src={file.preview}
|
||
alt={file.name}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
|
||
{getFileIcon(file.type)}
|
||
</div>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 右切换按钮 */}
|
||
<Button
|
||
variant="secondary"
|
||
size="icon"
|
||
onClick={() => 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="下一张 (→)"
|
||
>
|
||
<ChevronRight className="size-5" />
|
||
</Button>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
} |