Files
stu-ai-demo/src/components/common/file-preview.tsx

968 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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-100undefined 表示未上传或已完成 */
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 }) => {
// 避免在最后一个事件中访问 eventdebounced
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
);
}