forked from 77media/video-flow
298 lines
9.1 KiB
TypeScript
298 lines
9.1 KiB
TypeScript
import React, { forwardRef, useRef, useState } from "react";
|
||
import { useDeepCompareEffect } from "@/hooks/useDeepCompareEffect";
|
||
import { Plus, X, UserRoundPlus, MessageCirclePlus, MessageCircleMore, ClipboardType } from "lucide-react";
|
||
import ShotEditor from "./ShotEditor";
|
||
import { toast } from "sonner";
|
||
import { TextToShotAdapter } from "@/app/service/adapter/textToShot";
|
||
|
||
|
||
interface Shot {
|
||
name: string;
|
||
shotDescContent: any[];
|
||
shotDialogsContent: any[];
|
||
}
|
||
|
||
interface CharacterToken {
|
||
type: 'characterToken';
|
||
attrs: {
|
||
name: string;
|
||
gender: string;
|
||
age: string;
|
||
avatar: string;
|
||
};
|
||
}
|
||
|
||
const createEmptyShot = (): Shot => ({
|
||
name: `shot${Date.now()}`,
|
||
shotDescContent: [{
|
||
type: 'paragraph',
|
||
content: []
|
||
}],
|
||
shotDialogsContent: [{
|
||
type: 'paragraph',
|
||
content: []
|
||
}]
|
||
});
|
||
|
||
interface ShotsEditorProps {
|
||
roles: any[];
|
||
shotInfo: any[];
|
||
style?: React.CSSProperties;
|
||
}
|
||
|
||
export const ShotsEditor = forwardRef<any, ShotsEditorProps>(function ShotsEditor({ roles, shotInfo, style }, ref) {
|
||
const [currentShotIndex, setCurrentShotIndex] = useState(0);
|
||
const [shots, setShots] = useState<Shot[]>([]);
|
||
const descEditorRef = useRef<any>(null);
|
||
const dialogEditorRef = useRef<any>(null);
|
||
|
||
useDeepCompareEffect(() => {
|
||
if (shotInfo) {
|
||
console.log('-==========shotInfo===========-', shotInfo);
|
||
const shots = shotInfo.map((shot) => {
|
||
return TextToShotAdapter.fromLensType(shot, roles);
|
||
});
|
||
console.log('-==========shots===========-', shots);
|
||
setShots(shots as Shot[]);
|
||
}
|
||
}, [shotInfo, roles]);
|
||
|
||
const handleDescContentChange = (content: any) => {
|
||
const shot = shots[currentShotIndex];
|
||
shot.shotDescContent = content;
|
||
setShots(prevShots =>
|
||
prevShots.map((item, index) =>
|
||
index === currentShotIndex ? shot : item
|
||
)
|
||
);
|
||
}
|
||
const handleDialogContentChange = (content: any) => {
|
||
const shot = shots[currentShotIndex];
|
||
shot.shotDialogsContent = content;
|
||
setShots(prevShots =>
|
||
prevShots.map((item, index) =>
|
||
index === currentShotIndex ? shot : item
|
||
)
|
||
);
|
||
}
|
||
|
||
const handleShotTabClick = (index: number) => {
|
||
setCurrentShotIndex(index);
|
||
}
|
||
|
||
const getShotInfo = () => {
|
||
console.log('-==========getShotInfo shots===========-', shots);
|
||
const shotInfo = shots.map((shot) => {
|
||
return TextToShotAdapter.toLensType(shot);
|
||
});
|
||
return shotInfo;
|
||
}
|
||
|
||
const addShot = () => {
|
||
if (shots.length > 3) {
|
||
toast.error('No more than 4 shots', {
|
||
duration: 3000,
|
||
position: 'top-center',
|
||
richColors: true,
|
||
});
|
||
return;
|
||
}
|
||
const newShot = createEmptyShot();
|
||
setShots([...shots, newShot]);
|
||
// 自动切换到新创建的分镜
|
||
setCurrentShotIndex(shots.length);
|
||
};
|
||
|
||
const handleDeleteShot = (index: number) => {
|
||
if (shots.length <= 1) return; // 保留最后一个分镜
|
||
|
||
const newShots = shots.filter((_, i) => i !== index);
|
||
setShots(newShots);
|
||
// onShotsChange(newShots);
|
||
|
||
// 如果删除的是当前选中的分镜,或者删除的是最后一个分镜
|
||
if (currentShotIndex === index || currentShotIndex >= newShots.length) {
|
||
setCurrentShotIndex(Math.max(0, newShots.length - 1));
|
||
} else if (currentShotIndex > index) {
|
||
// 如果删除的分镜在当前选中分镜之前,需要更新索引
|
||
setCurrentShotIndex(currentShotIndex - 1);
|
||
}
|
||
};
|
||
|
||
const handleAddCharacterToDesc = () => {
|
||
if (!descEditorRef.current) return;
|
||
|
||
// 创建一个默认角色Token
|
||
const defaultCharacter: CharacterToken = {
|
||
type: 'characterToken',
|
||
attrs: {
|
||
name: 'Select Role',
|
||
gender: '',
|
||
age: '',
|
||
avatar: ''
|
||
}
|
||
};
|
||
|
||
// 在当前位置插入角色Token
|
||
descEditorRef.current.insertCharacter(defaultCharacter);
|
||
};
|
||
|
||
const handleAddCharacterToDialog = () => {
|
||
if (!dialogEditorRef.current) return;
|
||
|
||
// 创建一个默认角色Token
|
||
const defaultCharacter: CharacterToken = {
|
||
type: 'characterToken',
|
||
attrs: {
|
||
name: 'Select Role',
|
||
gender: '',
|
||
age: '',
|
||
avatar: ''
|
||
}
|
||
};
|
||
|
||
// 在当前位置插入角色Token
|
||
dialogEditorRef.current.insertCharacter(defaultCharacter);
|
||
};
|
||
|
||
const handleAddNewDialog = () => {
|
||
if (!dialogEditorRef.current) return;
|
||
|
||
// 创建一个新的对话行
|
||
const newDialog = {
|
||
type: 'paragraph',
|
||
content: [
|
||
{
|
||
type: 'characterToken',
|
||
attrs: {
|
||
name: 'Select Role',
|
||
gender: '',
|
||
age: '',
|
||
avatar: ''
|
||
}
|
||
},
|
||
{ type: 'readonlyText', attrs: { text: ':' } }
|
||
]
|
||
};
|
||
|
||
// 在编辑器末尾添加新对话
|
||
dialogEditorRef.current.editor?.commands.focus('end');
|
||
dialogEditorRef.current.insertContent([
|
||
{ type: 'text', text: '\n' },
|
||
newDialog
|
||
]);
|
||
};
|
||
|
||
// 暴露方法给父组件
|
||
React.useImperativeHandle(ref, () => ({
|
||
addShot,
|
||
getShotInfo
|
||
}));
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2" style={style}>
|
||
{/* 分镜标签(可删除)、新增分镜标签 */}
|
||
<div data-alt="shots-tabs" className="flex items-center gap-2">
|
||
<div className="flex gap-2 flex-wrap">
|
||
{shots.map((shot, index) => (
|
||
<div
|
||
key={shot.name}
|
||
data-alt="shot-tab"
|
||
className={`group flex items-center gap-2 px-3 py-1.5 rounded-md cursor-pointer transition-all
|
||
${currentShotIndex === index
|
||
? 'bg-blue-500/10 text-blue-500'
|
||
: 'text-white/60 bg-white/5'
|
||
}`}
|
||
onClick={() => handleShotTabClick(index)}
|
||
>
|
||
<span className="text-sm font-medium">Shot {index + 1}</span>
|
||
{shots.length > 1 && (
|
||
<button
|
||
className="p-0.5 rounded hover:bg-white/10 transition-colors"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteShot(index);
|
||
}}
|
||
>
|
||
<X className="w-3.5 h-3.5 hover:text-red-400" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
data-alt="add-shot-button"
|
||
onClick={addShot}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white/60 hover:text-white hover:bg-white/5 rounded-md transition-colors"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Add
|
||
</button>
|
||
</div>
|
||
|
||
{/* 分镜内容 */}
|
||
{shots[currentShotIndex] && (
|
||
<div className="flex flex-col gap-3 border border-white/10 p-2 rounded-[0.5rem]" key={currentShotIndex} style={{minHeight: 'calc(100% - 2rem)'}}>
|
||
{/* 分镜描述 添加角色 */}
|
||
<div data-alt="shot-description-section" className="flex flex-col gap-1">
|
||
<div className="flex items-center gap-2">
|
||
<ClipboardType className="w-5 h-5 text-white/60" />
|
||
<span className="text-lg font-medium text-white/60">Shot Description</span>
|
||
<button
|
||
data-alt="add-character-desc"
|
||
className="p-1 rounded-md"
|
||
onClick={() => handleAddCharacterToDesc()}
|
||
>
|
||
<UserRoundPlus className="w-5 h-5 text-blue-600" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 分镜描述内容 可视化编辑 */}
|
||
<ShotEditor
|
||
ref={descEditorRef}
|
||
content={shots[currentShotIndex].shotDescContent}
|
||
onCharacterClick={() => {}}
|
||
roles={roles}
|
||
placeholder="Add shot description here..."
|
||
onChangeContent={(content) => {handleDescContentChange(content)}}
|
||
/>
|
||
</div>
|
||
|
||
{/* 分镜对话 添加角色 添加对话 */}
|
||
<div data-alt="shot-dialog-section" className="flex flex-col gap-1">
|
||
<div className="flex items-center gap-2">
|
||
<MessageCircleMore className="w-5 h-5 text-white/60" />
|
||
<span className="text-lg font-medium text-white/60">Shot Dialogue</span>
|
||
<button
|
||
data-alt="add-character-dialog"
|
||
className="p-1 rounded-md"
|
||
onClick={() => handleAddCharacterToDialog()}
|
||
>
|
||
<UserRoundPlus className="w-5 h-5 text-blue-600" />
|
||
</button>
|
||
<button
|
||
data-alt="add-new-dialog"
|
||
className="p-1 rounded-md"
|
||
onClick={() => handleAddNewDialog()}
|
||
>
|
||
<MessageCirclePlus className="w-5 h-5 text-blue-600" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 分镜对话内容 可视化编辑 */}
|
||
<ShotEditor
|
||
ref={dialogEditorRef}
|
||
content={shots[currentShotIndex].shotDialogsContent}
|
||
onCharacterClick={() => {}}
|
||
roles={roles}
|
||
placeholder="Add shot dialogue here..."
|
||
onChangeContent={(content) => {handleDialogContentChange(content)}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}); |