forked from 77media/video-flow
224 lines
7.6 KiB
TypeScript
224 lines
7.6 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { motion, useAnimation, useMotionValue, useTransform, useSpring } from 'framer-motion';
|
|
import { ChevronLeft, ChevronRight, Upload, BookOpen, Brain, Users, Film, Flag } from 'lucide-react';
|
|
|
|
// 定义步骤数据结构
|
|
interface Step {
|
|
id: string;
|
|
icon: JSX.Element;
|
|
title: string;
|
|
subtitle: string;
|
|
description: string;
|
|
}
|
|
|
|
// 步骤配置
|
|
const STEPS: Step[] = [
|
|
{
|
|
id: 'overview',
|
|
icon: <BookOpen className="w-6 h-6" />,
|
|
title: '剧本大纲',
|
|
subtitle: 'Script Overview',
|
|
description: '提取剧本结构和关键要素'
|
|
},
|
|
{
|
|
id: 'storyboard',
|
|
icon: <Brain className="w-6 h-6" />,
|
|
title: '分镜草图',
|
|
subtitle: 'Storyboard',
|
|
description: '可视化场景设计和转场'
|
|
},
|
|
{
|
|
id: 'character',
|
|
icon: <Users className="w-6 h-6" />,
|
|
title: '演员角色',
|
|
subtitle: 'Character Design',
|
|
description: '定制角色形象和个性'
|
|
},
|
|
{
|
|
id: 'post',
|
|
icon: <Film className="w-6 h-6" />,
|
|
title: '后期制作',
|
|
subtitle: 'Post Production',
|
|
description: '音效配乐和特效处理'
|
|
},
|
|
{
|
|
id: 'output',
|
|
icon: <Flag className="w-6 h-6" />,
|
|
title: '最终成品',
|
|
subtitle: 'Final Output',
|
|
description: '预览和导出作品'
|
|
}
|
|
];
|
|
|
|
interface FilmstripStepperProps {
|
|
currentStep: string;
|
|
onStepChange: (stepId: string) => void;
|
|
}
|
|
|
|
export function FilmstripStepper({ currentStep, onStepChange }: FilmstripStepperProps) {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const controls = useAnimation();
|
|
|
|
// 滚动位置状态
|
|
const x = useMotionValue(0);
|
|
const springX = useSpring(x, { stiffness: 300, damping: 30 });
|
|
|
|
// 处理滚动边界
|
|
useEffect(() => {
|
|
if (!containerRef.current || !scrollRef.current) return;
|
|
|
|
const container = containerRef.current;
|
|
const scroll = scrollRef.current;
|
|
const maxScroll = -(scroll.scrollWidth - container.clientWidth);
|
|
|
|
x.set(Math.max(Math.min(x.get(), 0), maxScroll));
|
|
}, [x]);
|
|
|
|
// 处理拖拽结束
|
|
const handleDragEnd = () => {
|
|
setIsDragging(false);
|
|
if (!containerRef.current || !scrollRef.current) return;
|
|
|
|
const container = containerRef.current;
|
|
const scroll = scrollRef.current;
|
|
const maxScroll = -(scroll.scrollWidth - container.clientWidth);
|
|
|
|
// 确保不会过度滚动
|
|
if (x.get() > 0) {
|
|
controls.start({ x: 0 });
|
|
} else if (x.get() < maxScroll) {
|
|
controls.start({ x: maxScroll });
|
|
}
|
|
};
|
|
|
|
// 滚动到指定步骤
|
|
const scrollToStep = (stepId: string) => {
|
|
if (!containerRef.current || !scrollRef.current) return;
|
|
|
|
const stepElement = document.getElementById(`step-${stepId}`);
|
|
if (!stepElement) return;
|
|
|
|
const container = containerRef.current;
|
|
const stepLeft = stepElement.offsetLeft;
|
|
const stepWidth = stepElement.offsetWidth;
|
|
const containerWidth = container.clientWidth;
|
|
const targetX = -(stepLeft - (containerWidth - stepWidth) / 2);
|
|
|
|
controls.start({ x: targetX });
|
|
};
|
|
|
|
return (
|
|
<div className="relative w-full">
|
|
{/* 滚动箭头 */}
|
|
<button
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 backdrop-blur-sm flex items-center justify-center transition-colors"
|
|
onClick={() => controls.start({ x: x.get() + 300 })}
|
|
>
|
|
<ChevronLeft className="w-6 h-6" />
|
|
</button>
|
|
<button
|
|
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 backdrop-blur-sm flex items-center justify-center transition-colors"
|
|
onClick={() => controls.start({ x: x.get() - 300 })}
|
|
>
|
|
<ChevronRight className="w-6 h-6" />
|
|
</button>
|
|
|
|
{/* 胶片容器 */}
|
|
<div
|
|
ref={containerRef}
|
|
className="w-full overflow-hidden px-20"
|
|
style={{ perspective: '1000px' }}
|
|
>
|
|
<motion.div
|
|
ref={scrollRef}
|
|
drag="x"
|
|
dragConstraints={containerRef}
|
|
dragElastic={0.1}
|
|
onDragStart={() => setIsDragging(true)}
|
|
onDragEnd={handleDragEnd}
|
|
animate={controls}
|
|
style={{ x: springX }}
|
|
className="flex gap-6 px-4 py-8"
|
|
>
|
|
{STEPS.map((step, index) => {
|
|
const isActive = currentStep === step.id;
|
|
|
|
return (
|
|
<motion.div
|
|
key={step.id}
|
|
id={`step-${step.id}`}
|
|
className={`
|
|
relative flex-shrink-0 w-64 h-40 rounded-lg cursor-pointer
|
|
${isActive ? 'z-10' : 'opacity-70'}
|
|
`}
|
|
whileHover={{ scale: 1.05, y: -5 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
animate={isActive ? {
|
|
scale: 1.1,
|
|
y: -10,
|
|
transition: { type: 'spring', stiffness: 300, damping: 25 }
|
|
} : {
|
|
scale: 1,
|
|
y: 0
|
|
}}
|
|
onClick={() => {
|
|
if (!isDragging) {
|
|
onStepChange(step.id);
|
|
scrollToStep(step.id);
|
|
}
|
|
}}
|
|
>
|
|
{/* 胶片打孔效果 */}
|
|
<div className="absolute -left-2 top-1/2 -translate-y-1/2 space-y-4">
|
|
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
|
|
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
|
|
</div>
|
|
<div className="absolute -right-2 top-1/2 -translate-y-1/2 space-y-4">
|
|
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
|
|
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
|
|
</div>
|
|
|
|
{/* 卡片内容 */}
|
|
<div
|
|
className={`
|
|
relative w-full h-full rounded-lg p-4 overflow-hidden
|
|
bg-gradient-to-br from-white/10 to-white/5
|
|
${isActive ? 'ring-2 ring-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.5)]' : ''}
|
|
`}
|
|
>
|
|
<div className="absolute top-0 left-0 w-full h-full bg-black/20 backdrop-blur-[2px]" />
|
|
<div className="relative z-10 flex flex-col h-full">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className={`
|
|
p-2 rounded-lg
|
|
${isActive ? 'bg-blue-500/20 text-blue-400' : 'bg-white/10 text-white/60'}
|
|
`}>
|
|
{step.icon}
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-medium">{step.title}</h3>
|
|
<p className="text-xs text-white/50">{step.subtitle}</p>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-white/70">{step.description}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 步骤序号 */}
|
|
<div className={`
|
|
absolute -top-3 -right-3 w-8 h-8 rounded-full
|
|
flex items-center justify-center text-sm font-medium
|
|
${isActive ? 'bg-blue-500 text-white' : 'bg-white/10 text-white/60'}
|
|
`}>
|
|
{index + 1}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|