forked from 77media/video-flow
284 lines
9.5 KiB
TypeScript
284 lines
9.5 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { useSortable } from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { GripVertical, Trash2, Copy, Plus } from 'lucide-react';
|
|
import { StoryboardCard as StoryboardCardType, DialogueItem as DialogueItemType, CharacterOption, mockCharacterOptions } from '@/app/model/enums';
|
|
import DialogueItem from './dialogue-item';
|
|
import KeywordText from './keyword-text';
|
|
import { DndContext, closestCenter } from '@dnd-kit/core';
|
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
|
|
interface StoryboardCardProps {
|
|
card: StoryboardCardType;
|
|
onUpdate: (updates: Partial<StoryboardCardType>) => void;
|
|
onDelete: () => void;
|
|
onDuplicate: () => void;
|
|
}
|
|
|
|
const EditableField: React.FC<{
|
|
label: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
className?: string;
|
|
}> = ({ label, value, onChange, className = '' }) => {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editValue, setEditValue] = useState(value);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const handleSave = () => {
|
|
onChange(editValue);
|
|
setIsEditing(false);
|
|
};
|
|
|
|
// 自动调整文本框高度
|
|
useEffect(() => {
|
|
if (isEditing && textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
|
}
|
|
}, [isEditing, editValue]);
|
|
|
|
return (
|
|
<div className={className}>
|
|
<div className="text-sm text-gray-400 mb-1">{label}</div>
|
|
{isEditing ? (
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={editValue}
|
|
onChange={(e) => {
|
|
setEditValue(e.target.value);
|
|
// 调整高度
|
|
e.target.style.height = 'auto';
|
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
}}
|
|
onBlur={handleSave}
|
|
autoFocus
|
|
className="w-full bg-black/20 rounded-lg p-2 text-sm
|
|
focus:outline-none focus:ring-1 focus:ring-blue-500/50
|
|
resize-none overflow-hidden min-h-[2.5rem]"
|
|
style={{
|
|
maxHeight: '20rem', // 防止内容过多时超出视口
|
|
}}
|
|
/>
|
|
) : (
|
|
<div
|
|
onClick={() => {
|
|
setIsEditing(true);
|
|
setEditValue(value); // 重置编辑值
|
|
}}
|
|
className="text-sm text-gray-200 cursor-text
|
|
hover:bg-white/5 rounded-lg p-2 transition-colors
|
|
line-clamp-3"
|
|
>
|
|
{value || `点击添加${label}...`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const StoryboardCard: React.FC<StoryboardCardProps> = ({
|
|
card,
|
|
onUpdate,
|
|
onDelete,
|
|
onDuplicate,
|
|
}) => {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editNotes, setEditNotes] = useState(card.notes || '');
|
|
console.log('card', card);
|
|
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: card.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
};
|
|
|
|
const handleDialogueReorder = (event: any) => {
|
|
const { active, over } = event;
|
|
if (active.id !== over?.id) {
|
|
const oldIndex = card.dialogues.findIndex(d => d.id === active.id);
|
|
const newIndex = card.dialogues.findIndex(d => d.id === over?.id);
|
|
const newDialogues = [...card.dialogues];
|
|
const [removed] = newDialogues.splice(oldIndex, 1);
|
|
newDialogues.splice(newIndex, 0, removed);
|
|
onUpdate({ dialogues: newDialogues });
|
|
}
|
|
};
|
|
|
|
const handleDialogueUpdate = (dialogueId: string, updates: Partial<DialogueItemType>) => {
|
|
const newDialogues = card.dialogues.map(d =>
|
|
d.id === dialogueId ? { ...d, ...updates } : d
|
|
);
|
|
onUpdate({ dialogues: newDialogues });
|
|
};
|
|
|
|
const handleCharacterChange = (dialogueId: string, character: CharacterOption) => {
|
|
// 更新对话中的角色
|
|
handleDialogueUpdate(dialogueId, { speaker: character.name });
|
|
|
|
// 更新卡片的角色列表
|
|
const allCharacters = new Map(card.characters.map(char => [char.characterId, char]));
|
|
allCharacters.set(character.characterId, character);
|
|
|
|
// 获取所有对话中出现的角色
|
|
card.dialogues.forEach(dialogue => {
|
|
const char = mockCharacterOptions.find(c => c.name === dialogue.speaker);
|
|
if (char) {
|
|
allCharacters.set(char.characterId, char);
|
|
}
|
|
});
|
|
|
|
onUpdate({ characters: Array.from(allCharacters.values()) });
|
|
};
|
|
|
|
const handleAddDialogue = () => {
|
|
const newDialogue: DialogueItemType = {
|
|
id: `d${Date.now()}`,
|
|
speaker: '新角色',
|
|
text: '在此输入对话内容',
|
|
};
|
|
onUpdate({ dialogues: [...card.dialogues, newDialogue] });
|
|
};
|
|
|
|
const handleSaveNotes = () => {
|
|
onUpdate({ notes: editNotes });
|
|
setIsEditing(false);
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{
|
|
opacity: 1,
|
|
scale: 1,
|
|
x: isDragging ? 5 : 0,
|
|
y: isDragging ? 5 : 0,
|
|
rotate: isDragging ? 2 : 0
|
|
}}
|
|
exit={{ opacity: 0, scale: 0.8 }}
|
|
className={`
|
|
group relative w-full bg-black/30 backdrop-blur-lg rounded-xl overflow-hidden
|
|
border border-white/10 shadow-xl h-[480px] flex flex-col
|
|
${isDragging ? 'z-50 shadow-2xl ring-2 ring-blue-500/50' : 'hover:ring-1 hover:ring-white/20'}
|
|
transition-all duration-200
|
|
`}
|
|
>
|
|
{/* Drag Handle */}
|
|
<div
|
|
{...attributes}
|
|
{...listeners}
|
|
className="absolute top-3 right-3 p-2 rounded-lg bg-black/20 opacity-0 group-hover:opacity-100
|
|
transition-opacity cursor-grab active:cursor-grabbing"
|
|
>
|
|
<GripVertical className="w-4 h-4 text-white/60" />
|
|
</div>
|
|
|
|
{/* Card Header */}
|
|
<div className="p-4 border-b border-white/10">
|
|
<div className="text-lg font-medium text-white mb-2">
|
|
{card.shotId}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|
{card.scene && <KeywordText text={`[${card.scene.name}]`} id={card.scene.sceneId} />}
|
|
{card.characters.map((char) => (
|
|
<span key={char.characterId} className="text-sm">
|
|
<KeywordText text={`#${char.name}#`} id={char.characterId} />
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dialogues */}
|
|
<div className="flex-1 overflow-hidden flex flex-col bg-black/10">
|
|
<div className="p-4 pb-2 flex-shrink-0 flex justify-between items-center">
|
|
<div className="text-sm text-gray-400">对话列表</div>
|
|
<button
|
|
onClick={handleAddDialogue}
|
|
className="px-2 py-1 rounded-md border border-dashed border-white/20
|
|
text-xs text-gray-400 hover:bg-white/5 transition-colors
|
|
flex items-center gap-1"
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
添加对话
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar px-4 pb-4">
|
|
<DndContext
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDialogueReorder}
|
|
>
|
|
<SortableContext
|
|
items={card.dialogues.map(d => d.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<AnimatePresence>
|
|
{card.dialogues.map((dialogue) => (
|
|
<DialogueItem
|
|
key={dialogue.id}
|
|
dialogue={dialogue}
|
|
onUpdate={(updates) => handleDialogueUpdate(dialogue.id, updates)}
|
|
onDelete={() => {
|
|
const newDialogues = card.dialogues.filter(d => d.id !== dialogue.id);
|
|
onUpdate({ dialogues: newDialogues });
|
|
|
|
const remainingCharacters = new Map<string, CharacterOption>();
|
|
newDialogues.forEach(d => {
|
|
const char = mockCharacterOptions.find(c => c.name === d.speaker);
|
|
if (char) {
|
|
remainingCharacters.set(char.characterId, char);
|
|
}
|
|
});
|
|
onUpdate({ characters: Array.from(remainingCharacters.values()) });
|
|
}}
|
|
onCharacterChange={(character) => handleCharacterChange(dialogue.id, character)}
|
|
/>
|
|
))}
|
|
</AnimatePresence>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Shot Description */}
|
|
<div className="p-4 border-t border-white/10 bg-black/5">
|
|
<EditableField
|
|
label="分镜描述"
|
|
value={card.description}
|
|
onChange={(value) => onUpdate({ description: value })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Card Actions */}
|
|
<div className="absolute top-3 right-[4rem] flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={onDuplicate}
|
|
className="p-2 rounded-lg bg-black/20 hover:bg-black/30 transition-colors"
|
|
title="复制"
|
|
>
|
|
<Copy className="w-4 h-4 text-blue-400" />
|
|
</button>
|
|
<button
|
|
onClick={onDelete}
|
|
className="p-2 rounded-lg bg-black/20 hover:bg-black/30 transition-colors"
|
|
title="删除"
|
|
>
|
|
<Trash2 className="w-4 h-4 text-red-400" />
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
export default StoryboardCard;
|