重新设计镜头页面

This commit is contained in:
北枳 2025-08-09 10:18:14 +08:00
parent 46bda04605
commit 250fa8441e
4 changed files with 497 additions and 89 deletions

View File

@ -0,0 +1,182 @@
import React, { useState, useCallback, useEffect } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { motion } from "framer-motion";
import { CharacterTokenExtension } from './CharacterToken';
import { ShotTitle } from './ShotTitle';
import { ReadonlyText } from './ReadonlyText';
import { Sparkles } from 'lucide-react';
import { toast } from 'sonner';
const initialContent = {
type: 'doc',
content: [
{
type: 'shotTitle',
attrs: { title: `分镜1` },
},
{
type: 'paragraph',
content: [
{ type: 'text', text: '镜头聚焦在' },
{ type: 'characterToken', attrs: { name: '"沙利"·沙利文中士', gender: '男', age: '28', avatar: 'https://i.pravatar.cc/40?u=z3' }},
{ type: 'text', text: ' 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。' },
]
},
{
type: 'paragraph',
content: [
{ type: 'shotTitle', attrs: { title: `对话` } },
{ type: 'characterToken', attrs: { name: '"沙利"·沙利文中士', gender: '男', age: '28', avatar: 'https://i.pravatar.cc/40?u=z3' }},
{ type: 'text', text: '' },
{ type: 'text', text: '掩护!趴下!' }
]
},
{
type: 'shotTitle',
attrs: { title: `分镜2` },
},
{
type: 'paragraph',
content: [
{ type: 'characterToken', attrs: { name: '李四', gender: '女', age: '26', avatar: 'https://i.pravatar.cc/40?u=l4' }},
{ type: 'text', text: ' 微微低头,没有说话。' }
]
}
]
};
interface ShotEditorProps {
roles?: any[];
onAddSegment?: () => void;
onCharacterClick?: (attrs: any) => void;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
characterToken: {
setCharacterToken: (attrs: any) => ReturnType;
}
}
}
const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick: (attrs: any) => void }, ShotEditorProps>(
function ShotEditor({ onAddSegment, onCharacterClick, roles }, ref) {
const [segments, setSegments] = useState(initialContent.content);
const [isOptimizing, setIsOptimizing] = useState(false);
const handleSmartPolish = () => {
setIsOptimizing(true);
setTimeout(() => {
setIsOptimizing(false);
}, 3000);
};
const editor = useEditor({
extensions: [
StarterKit,
CharacterTokenExtension.configure({
roles
}),
ShotTitle,
ReadonlyText,
],
content: { type: 'doc', content: segments },
editorProps: {
attributes: {
class: 'prose prose-invert max-w-none min-h-[150px] focus:outline-none'
}
},
immediatelyRender: false,
onCreate: ({ editor }) => {
editor.setOptions({ editable: true })
},
})
const addSegment = () => {
if (!editor) return;
// 自动编号(获取已有 shotTitle 节点数量)
const doc = editor.state.doc;
let shotCount = 0;
doc.descendants((node) => {
if (node.type.name === 'shotTitle' && node.attrs.title.includes('分镜')) {
shotCount++;
}
});
// 不能超过4个分镜
if (shotCount >= 4) {
toast.error('不能超过4个分镜', {
duration: 3000,
position: 'top-center',
richColors: true,
});
return;
}
editor.chain().focus('end').insertContent([
{
type: 'shotTitle',
attrs: { title: `分镜${shotCount + 1}` },
},
{
type: 'paragraph',
content: [
{ type: 'text', text: '镜头描述' }
]
},
{
type: 'shotTitle',
attrs: { title: `对话` },
},
{
type: 'paragraph',
content: [
{ type: 'characterToken', attrs: { name: '讲话人', gender: '女', age: '26', avatar: 'https://i.pravatar.cc/40?u=l4' }},
{ type: 'text', text: '' },
{ type: 'text', text: '讲话内容' }
]
}
])
.focus('end') // 聚焦到文档末尾
.run();
// 调用外部传入的回调函数
onAddSegment?.();
};
// 暴露方法给父组件
React.useImperativeHandle(ref, () => ({
addSegment,
onCharacterClick: (attrs: any) => {
onCharacterClick?.(attrs);
}
}));
if (!editor) {
return null
}
return (
<div className="w-full relative p-[0.5rem] pb-[2.5rem] border border-white/10 rounded-[0.5rem]">
<EditorContent editor={editor} />
{/* 智能润色按钮 */}
<motion.button
onClick={handleSmartPolish}
disabled={isOptimizing}
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 rounded-full
transition-colors text-xs disabled:opacity-50"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Sparkles className="w-3.5 h-3.5" />
<span>{isOptimizing ? "优化中..." : "智能优化"}</span>
</motion.button>
</div>
)
}
);
export default ShotEditor;

View File

@ -47,8 +47,8 @@ const initialContent = {
};
interface ShotEditorProps {
content: any[];
roles?: any[];
onAddSegment?: () => void;
onCharacterClick?: (attrs: any) => void;
}
@ -60,9 +60,25 @@ declare module '@tiptap/core' {
}
}
const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick: (attrs: any) => void }, ShotEditorProps>(
function ShotEditor({ onAddSegment, onCharacterClick, roles }, ref) {
const [segments, setSegments] = useState(initialContent.content);
interface CharacterToken {
type: 'characterToken';
attrs: {
name: string;
gender: string;
age: string;
avatar: string;
};
}
interface EditorRef {
editor: any;
insertCharacter: (character: CharacterToken) => void;
insertContent: (content: any) => void;
}
const ShotEditor = React.forwardRef<EditorRef, ShotEditorProps>(
function ShotEditor({ content, onCharacterClick, roles }, ref) {
const [segments, setSegments] = useState(content);
const [isOptimizing, setIsOptimizing] = useState(false);
const handleSmartPolish = () => {
@ -84,7 +100,7 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
content: { type: 'doc', content: segments },
editorProps: {
attributes: {
class: 'prose prose-invert max-w-none min-h-[150px] focus:outline-none'
class: 'prose prose-invert max-w-none focus:outline-none'
}
},
immediatelyRender: false,
@ -93,64 +109,18 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
},
})
const addSegment = () => {
if (!editor) return;
// 自动编号(获取已有 shotTitle 节点数量)
const doc = editor.state.doc;
let shotCount = 0;
doc.descendants((node) => {
if (node.type.name === 'shotTitle' && node.attrs.title.includes('分镜')) {
shotCount++;
}
});
// 不能超过4个分镜
if (shotCount >= 4) {
toast.error('不能超过4个分镜', {
duration: 3000,
position: 'top-center',
richColors: true,
});
return;
}
editor.chain().focus('end').insertContent([
{
type: 'shotTitle',
attrs: { title: `分镜${shotCount + 1}` },
},
{
type: 'paragraph',
content: [
{ type: 'text', text: '镜头描述' }
]
},
{
type: 'shotTitle',
attrs: { title: `对话` },
},
{
type: 'paragraph',
content: [
{ type: 'characterToken', attrs: { name: '讲话人', gender: '女', age: '26', avatar: 'https://i.pravatar.cc/40?u=l4' }},
{ type: 'text', text: '' },
{ type: 'text', text: '讲话内容' }
]
}
])
.focus('end') // 聚焦到文档末尾
.run();
// 调用外部传入的回调函数
onAddSegment?.();
};
// 暴露方法给父组件
React.useImperativeHandle(ref, () => ({
addSegment,
onCharacterClick: (attrs: any) => {
onCharacterClick?.(attrs);
editor,
insertCharacter: (character: CharacterToken) => {
editor?.commands.insertContent([
{ type: 'text', text: ' ' },
character,
{ type: 'text', text: ' ' }
]);
},
insertContent: (content: any) => {
editor?.commands.insertContent(content);
}
}));
@ -159,22 +129,7 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
}
return (
<div className="w-full relative p-[0.5rem] pb-[2.5rem] border border-white/10 rounded-[0.5rem]">
<EditorContent editor={editor} />
{/* 智能润色按钮 */}
<motion.button
onClick={handleSmartPolish}
disabled={isOptimizing}
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 rounded-full
transition-colors text-xs disabled:opacity-50"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Sparkles className="w-3.5 h-3.5" />
<span>{isOptimizing ? "优化中..." : "智能优化"}</span>
</motion.button>
</div>
<EditorContent editor={editor} />
)
}
);

View File

@ -0,0 +1,271 @@
import React, { forwardRef, useRef, useState } from "react";
import { Plus, X, UserRoundPlus, MessageCirclePlus, MessageCircleMore, ClipboardType } from "lucide-react";
import ShotEditor from "./ShotEditor";
import { toast } from "sonner";
interface Shot {
id: string;
shotDescContent: any[];
shotDialogsContent: any[];
}
interface CharacterToken {
type: 'characterToken';
attrs: {
name: string;
gender: string;
age: string;
avatar: string;
};
}
const mockShotsData = [
{
id: 'shot1',
shotDescContent: [{
type: 'paragraph',
content: [
{ type: 'text', text: '镜头聚焦在' },
{ type: 'characterToken', attrs: { name: '"沙利"·沙利文中士', gender: '男', age: '28', avatar: 'https://i.pravatar.cc/40?u=z3' }},
{ type: 'text', text: ' 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。' },
]
}],
shotDialogsContent: [
{
type: 'paragraph',
content: [
{ type: 'characterToken', attrs: { name: '李四', gender: '女', age: '26', avatar: 'https://i.pravatar.cc/40?u=l4' }},
{ type: 'text', text: ' 微微低头,没有说话。' }
]
}
]
}
]
const createEmptyShot = (): Shot => ({
id: `shot${Date.now()}`,
shotDescContent: [{
type: 'paragraph',
content: [
{ type: 'text', text: '在这里添加分镜描述...' }
]
}],
shotDialogsContent: [{
type: 'paragraph',
content: [
{ type: 'text', text: '在这里添加分镜对话...' }
]
}]
});
interface ShotsEditorProps {
roles: any[];
}
export const ShotsEditor = forwardRef<any, ShotsEditorProps>(({ roles }, ref) => {
const [currentShotIndex, setCurrentShotIndex] = useState(0);
const [shots, setShots] = useState<Shot[]>(mockShotsData);
const descEditorRef = useRef<any>(null);
const dialogEditorRef = useRef<any>(null);
const addShot = () => {
if (shots.length > 3) {
toast.error('不能超过4个分镜', {
duration: 3000,
position: 'top-center',
richColors: true,
});
return;
}
const newShot = createEmptyShot();
setShots([...shots, newShot]);
// onShotsChange([...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: '新角色',
gender: '男',
age: '25',
avatar: 'https://i.pravatar.cc/40'
}
};
// 在当前位置插入角色Token
descEditorRef.current.insertCharacter(defaultCharacter);
};
const handleAddCharacterToDialog = () => {
if (!dialogEditorRef.current) return;
// 创建一个默认角色Token
const defaultCharacter: CharacterToken = {
type: 'characterToken',
attrs: {
name: '新角色',
gender: '男',
age: '25',
avatar: 'https://i.pravatar.cc/40'
}
};
// 在当前位置插入角色Token
dialogEditorRef.current.insertCharacter(defaultCharacter);
};
const handleAddNewDialog = () => {
if (!dialogEditorRef.current) return;
// 创建一个新的对话行
const newDialog = {
type: 'paragraph',
content: [
{
type: 'characterToken',
attrs: {
name: '新角色',
gender: '男',
age: '25',
avatar: 'https://i.pravatar.cc/40'
}
},
{ type: 'text', text: ' 说道:' }
]
};
// 在编辑器末尾添加新对话
dialogEditorRef.current.editor?.commands.focus('end');
dialogEditorRef.current.insertContent([
{ type: 'text', text: '\n' },
newDialog
]);
};
// 暴露方法给父组件
React.useImperativeHandle(ref, () => ({
addShot,
}));
return (
<div className="flex flex-col gap-4">
{/* 分镜标签(可删除)、新增分镜标签 */}
<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.id}
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={() => setCurrentShotIndex(index)}
>
<span className="text-sm font-medium">{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 text-red-400 hidden group-hover:block transition-all" />
</button>
)}
</div>
))}
</div>
{/* <button
data-alt="add-shot-button"
onClick={handleAddShot}
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" />
</button> */}
</div>
{/* 分镜内容 */}
<div className="flex flex-col gap-3 border border-white/10 p-2 rounded-[0.5rem]" key={currentShotIndex}>
{/* 分镜描述 添加角色 */}
<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"></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}
/>
</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"></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}
/>
</div>
</div>
</div>
);
});

View File

@ -9,7 +9,7 @@ import { ReplaceVideoModal } from './replace-video-modal';
import { MediaPropertiesModal } from './media-properties-modal';
import { DramaLineChart } from './drama-line-chart';
import { PersonDetection, PersonDetectionScene } from './person-detection';
import ShotEditor from './shot-editor/ShotEditor';
import { ShotsEditor } from './shot-editor/ShotsEditor';
import { CharacterLibrarySelector } from './character-library-selector';
import FloatingGlassPanel from './FloatingGlassPanel';
import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel';
@ -43,6 +43,8 @@ export function ShotTabContent({
const [shots, setShots] = useState<any[]>([]);
const shotsEditorRef = useRef<any>(null);
// 监听外部播放状态变化
useEffect(() => {
@ -123,6 +125,12 @@ export function ShotTabContent({
};
// 新增分镜
const handleAddShot = () => {
console.log('add shot');
shotsEditorRef.current.addShot();
};
// 切换选择分镜
const handleSelectShot = (index: number) => {
// 切换前 判断数据是否发生变化
@ -326,23 +334,15 @@ export function ShotTabContent({
{/* 基础配置 */}
<div className='space-y-4 col-span-1'>
<ShotEditor
ref={editorRef}
<ShotsEditor
ref={shotsEditorRef}
roles={roles}
onAddSegment={() => {
// 可以在这里添加其他逻辑
console.log('分镜添加成功');
}}
onCharacterClick={(attrs) => {
console.log('attrs', attrs);
setIsReplaceLibraryOpen(true);
}}
/>
{/* 重新生成按钮、新增分镜按钮 */}
<div className="grid grid-cols-2 gap-2">
<motion.button
onClick={() => editorRef.current?.addSegment()}
onClick={() => handleAddShot()}
className="flex items-center justify-center gap-2 px-4 py-3 bg-pink-500/10 hover:bg-pink-500/20
text-pink-500 rounded-lg transition-colors"
whileHover={{ scale: 1.02 }}