From 250fa8441efeb460af0e2b2865e6214f973104ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E6=9E=B3?= <7854742+wang_rumeng@user.noreply.gitee.com> Date: Sat, 9 Aug 2025 10:18:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=96=B0=E8=AE=BE=E8=AE=A1=E9=95=9C?= =?UTF-8?q?=E5=A4=B4=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ui/shot-editor/ShotEditor copy.tsx | 182 ++++++++++++ components/ui/shot-editor/ShotEditor.tsx | 109 +++---- components/ui/shot-editor/ShotsEditor.tsx | 271 ++++++++++++++++++ components/ui/shot-tab-content.tsx | 24 +- 4 files changed, 497 insertions(+), 89 deletions(-) create mode 100644 components/ui/shot-editor/ShotEditor copy.tsx create mode 100644 components/ui/shot-editor/ShotsEditor.tsx diff --git a/components/ui/shot-editor/ShotEditor copy.tsx b/components/ui/shot-editor/ShotEditor copy.tsx new file mode 100644 index 0000000..b3be347 --- /dev/null +++ b/components/ui/shot-editor/ShotEditor copy.tsx @@ -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 { + 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 ( +
+ + {/* 智能润色按钮 */} + + + {isOptimizing ? "优化中..." : "智能优化"} + +
+ ) + } +); + +export default ShotEditor; \ No newline at end of file diff --git a/components/ui/shot-editor/ShotEditor.tsx b/components/ui/shot-editor/ShotEditor.tsx index b3be347..05f39b6 100644 --- a/components/ui/shot-editor/ShotEditor.tsx +++ b/components/ui/shot-editor/ShotEditor.tsx @@ -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( + 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 ( -
- - {/* 智能润色按钮 */} - - - {isOptimizing ? "优化中..." : "智能优化"} - -
+ ) } ); diff --git a/components/ui/shot-editor/ShotsEditor.tsx b/components/ui/shot-editor/ShotsEditor.tsx new file mode 100644 index 0000000..c6ec83a --- /dev/null +++ b/components/ui/shot-editor/ShotsEditor.tsx @@ -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(({ roles }, ref) => { + const [currentShotIndex, setCurrentShotIndex] = useState(0); + const [shots, setShots] = useState(mockShotsData); + const descEditorRef = useRef(null); + const dialogEditorRef = useRef(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 ( +
+ {/* 分镜标签(可删除)、新增分镜标签 */} +
+
+ {shots.map((shot, index) => ( +
setCurrentShotIndex(index)} + > + 镜头{index + 1} + {shots.length > 1 && ( + + )} +
+ ))} +
+ + {/* */} +
+ + {/* 分镜内容 */} +
+ {/* 分镜描述 添加角色 */} +
+
+ + 分镜描述 + +
+ + {/* 分镜描述内容 可视化编辑 */} + {}} + roles={roles} + /> +
+ + {/* 分镜对话 添加角色 添加对话 */} +
+
+ + 分镜对话 + + +
+ + {/* 分镜对话内容 可视化编辑 */} + {}} + roles={roles} + /> +
+
+
+ ); +}); \ No newline at end of file diff --git a/components/ui/shot-tab-content.tsx b/components/ui/shot-tab-content.tsx index 3e7cc7c..ce16e1c 100644 --- a/components/ui/shot-tab-content.tsx +++ b/components/ui/shot-tab-content.tsx @@ -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([]); + const shotsEditorRef = useRef(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({ {/* 基础配置 */}
- { - // 可以在这里添加其他逻辑 - console.log('分镜添加成功'); - }} - onCharacterClick={(attrs) => { - console.log('attrs', attrs); - setIsReplaceLibraryOpen(true); - }} /> {/* 重新生成按钮、新增分镜按钮 */}
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 }}