forked from 77media/video-flow
删除文件
This commit is contained in:
parent
8decd2893a
commit
2708f14378
@ -1,167 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVertical, Trash2, Edit2, ChevronDown } from 'lucide-react';
|
||||
import { DialogueItem as DialogueItemType, CharacterOption } from '@/app/model/enums';
|
||||
import KeywordText from './keyword-text';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { mockCharacterOptions } from '@/app/model/enums';
|
||||
import './style/dialogue-item.css';
|
||||
|
||||
interface DialogueItemProps {
|
||||
dialogue: DialogueItemType;
|
||||
onUpdate: (updates: Partial<DialogueItemType>) => void;
|
||||
onDelete: () => void;
|
||||
onCharacterChange?: (character: CharacterOption) => void;
|
||||
}
|
||||
|
||||
const DialogueItem: React.FC<DialogueItemProps> = ({
|
||||
dialogue,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onCharacterChange,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(dialogue.text);
|
||||
const [isCharacterSelectOpen, setIsCharacterSelectOpen] = useState(false);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: dialogue.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate({ text: editText });
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (character: CharacterOption) => {
|
||||
onUpdate({ speaker: character.name });
|
||||
onCharacterChange?.(character);
|
||||
setIsCharacterSelectOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={`
|
||||
group relative flex items-start gap-3 p-3 rounded-lg
|
||||
${isDragging ? 'bg-white/10' : 'hover:bg-white/5'}
|
||||
transition-colors duration-200 dialogue-item
|
||||
`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mt-1 cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-grow">
|
||||
{/* Character Selector */}
|
||||
<Popover.Root open={isCharacterSelectOpen} onOpenChange={setIsCharacterSelectOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className="mb-1 px-2 py-0.5 text-sm font-medium text-gray-400 rounded
|
||||
hover:bg-white/5 flex items-center gap-1 transition-colors group/select"
|
||||
>
|
||||
{dialogue.speaker}
|
||||
<ChevronDown className="w-3 h-3 opacity-50 group-hover/select:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="w-[200px] bg-black/90 backdrop-blur-xl rounded-lg border border-white/10
|
||||
shadow-xl animate-in fade-in-80 z-50"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="p-1">
|
||||
{mockCharacterOptions.map((char) => (
|
||||
<button
|
||||
key={char.characterId}
|
||||
onClick={() => handleCharacterSelect(char)}
|
||||
className="w-full px-3 py-2 text-sm text-left text-gray-200 rounded-md
|
||||
hover:bg-white/5 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<img
|
||||
src={char.image}
|
||||
alt={char.name}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
/>
|
||||
{char.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="h-[1px] bg-white/10 my-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpdate({ speaker: '旁白' });
|
||||
setIsCharacterSelectOpen(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-gray-400 rounded-md
|
||||
hover:bg-white/5 transition-colors"
|
||||
>
|
||||
旁白
|
||||
</button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
className="w-full bg-black/20 rounded-lg p-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
||||
rows={3}
|
||||
onBlur={() => {
|
||||
handleSave();
|
||||
setIsEditing(false);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-200" onClick={() => setIsEditing(true)}>
|
||||
<KeywordText text={dialogue.text} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 opacity-0 transition-opacity opt-btn-group">
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="p-1 rounded-md hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 rounded-md hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogueItem;
|
||||
@ -1,284 +0,0 @@
|
||||
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;
|
||||
Loading…
x
Reference in New Issue
Block a user