diff --git a/components/ui/edit-modal.tsx b/components/ui/edit-modal.tsx index c420bc5..73ae68b 100644 --- a/components/ui/edit-modal.tsx +++ b/components/ui/edit-modal.tsx @@ -115,6 +115,7 @@ export function EditModal({ currentSketchIndex={currentIndex} onSketchSelect={hanldeChangeSelect} isPlaying={false} + roles={roles} /> ); case '4': diff --git a/components/ui/shot-editor/CharacterToken.tsx b/components/ui/shot-editor/CharacterToken.tsx index 7eaefff..946cd5b 100644 --- a/components/ui/shot-editor/CharacterToken.tsx +++ b/components/ui/shot-editor/CharacterToken.tsx @@ -2,6 +2,7 @@ import { Node, mergeAttributes } from '@tiptap/core' import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react' import { motion, AnimatePresence } from 'framer-motion' import { useState } from 'react' +import { Check } from 'lucide-react' interface CharacterAttributes { id: string | null; @@ -11,50 +12,87 @@ interface CharacterAttributes { age: string; } -// interface CharacterTokenProps extends ReactNodeViewProps { -// onClick?: (attrs: CharacterAttributes) => void -// } +interface Role { + name: string; + url: string; +} + +interface CharacterTokenOptions { + roles?: Role[]; +} export function CharacterToken(props: ReactNodeViewProps) { - const [showCard, setShowCard] = useState(false) - const { name, avatar, gender, age } = props.node.attrs as CharacterAttributes + const [showRoleList, setShowRoleList] = useState(false) + const { name, avatar } = props.node.attrs as CharacterAttributes + const extension = props.extension as Node + const roles = extension.options.roles || [] - const handleClick = () => { - console.log('点击角色:', name) + const handleRoleSelect = (role: Role) => { const { editor } = props; - editor?.emit('character-clicked', props.node.attrs); + const pos = props.getPos(); + + if (typeof pos === 'number') { + const { tr } = editor.state; + tr.setNodeMarkup(pos, undefined, { + ...props.node.attrs, + name: role.name, + avatar: role.url, + }); + editor.view.dispatch(tr); + } + + setShowRoleList(false); } return ( setShowCard(true)} - onMouseLeave={() => setShowCard(false)} - onClick={handleClick} + onMouseLeave={() => setShowRoleList(false)} + onMouseEnter={() => setShowRoleList(true)} > {name} - {showCard && ( + {showRoleList && ( -
- {name} -
-
{name}
-
{gender} / {age}岁
-
+
+ {roles.map((role) => { + const isSelected = role.name === name; + return ( +
handleRoleSelect(role)} + > +
+ {role.name} + {isSelected && ( +
+ +
+ )} +
+ {role.name} +
+ ); + })}
)} @@ -63,18 +101,25 @@ export function CharacterToken(props: ReactNodeViewProps) { ) } -export const CharacterTokenExtension = Node.create({ +export const CharacterTokenExtension = Node.create({ name: 'characterToken', group: 'inline', inline: true, atom: true, + addOptions() { + return { + roles: [], + } + }, + addAttributes() { return { - name: {}, - gender: {}, - age: {}, - avatar: {}, + id: { default: null }, + name: { default: '' }, + gender: { default: '' }, + age: { default: '' }, + avatar: { default: '' }, }; }, @@ -86,13 +131,7 @@ export const CharacterTokenExtension = Node.create({ return ['character-token', mergeAttributes(HTMLAttributes)]; }, - // addStorage() { - // return { - // onClickCharacter: null as null | ((character: CharacterAttributes) => void), - // } - // }, - addNodeView() { return ReactNodeViewRenderer(CharacterToken); }, -}); +}); \ No newline at end of file diff --git a/components/ui/shot-editor/ShotEditor.tsx b/components/ui/shot-editor/ShotEditor.tsx index 1cddd66..b3be347 100644 --- a/components/ui/shot-editor/ShotEditor.tsx +++ b/components/ui/shot-editor/ShotEditor.tsx @@ -18,8 +18,18 @@ const initialContent = { { type: 'paragraph', content: [ - { type: 'characterToken', attrs: { name: '张三', gender: '男', age: '28', avatar: 'https://i.pravatar.cc/40?u=z3' }}, - { type: 'text', text: ' 从门口走来,皱着眉头说:“你怎么还在这里?”' } + { 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: '掩护!趴下!' } ] }, { @@ -37,109 +47,122 @@ const initialContent = { }; interface ShotEditorProps { + roles?: any[]; onAddSegment?: () => void; onCharacterClick?: (attrs: any) => void; } -const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick: (attrs: any) => void }, ShotEditorProps>(function ShotEditor({ onAddSegment, onCharacterClick }, 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, - 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 }) - }, - }) - - useEffect(() => { - const handleCharacterClick = (attrs: any) => { - console.log('SceneEditor 收到角色点击事件:', attrs) - // 你可以这里 setState 打开一个弹窗 / 面板等 - onCharacterClick?.(attrs); - }; - - editor?.on('character-clicked', handleCharacterClick as any); - - return () => { - editor?.off('character-clicked', handleCharacterClick as any); - }; - }, [editor]); - - const addSegment = () => { - if (!editor) return; - - // 自动编号(获取已有 shotTitle 节点数量) - const doc = editor.state.doc; - let shotCount = 0; - doc.descendants((node) => { - if (node.type.name === 'paragraph') { - shotCount++; - } - }); - - // 不能超过4个分镜 - if (shotCount >= 4) { - toast.error('不能超过4个分镜', { - duration: 3000, - position: 'top-center', - richColors: true, - }); - return; +declare module '@tiptap/core' { + interface Commands { + characterToken: { + setCharacterToken: (attrs: any) => ReturnType; } - - editor.chain().focus('end').insertContent([ - { - type: 'shotTitle', - attrs: { title: `分镜${shotCount + 1}` }, - }, - { - type: 'paragraph', - content: [ - { type: 'text', text: '镜头描述' } - ] - } - ]) - .focus('end') // 聚焦到文档末尾 - .run(); - - // 调用外部传入的回调函数 - onAddSegment?.(); - }; - - // 暴露 addSegment 方法给父组件 - React.useImperativeHandle(ref, () => ({ - addSegment - })); - - if (!editor) { - return null } +} - return ( -
- - {/* 智能润色按钮 */} - 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-tab-content.tsx b/components/ui/shot-tab-content.tsx index a8d492f..3e7cc7c 100644 --- a/components/ui/shot-tab-content.tsx +++ b/components/ui/shot-tab-content.tsx @@ -20,13 +20,15 @@ interface ShotTabContentProps { currentSketchIndex: number; onSketchSelect: (index: number) => void; isPlaying?: boolean; + roles?: any[]; } export function ShotTabContent({ taskSketch = [], currentSketchIndex = 0, onSketchSelect, - isPlaying: externalIsPlaying = true + isPlaying: externalIsPlaying = true, + roles = [] }: ShotTabContentProps) { const editorRef = useRef(null); const videoPlayerRef = useRef(null); @@ -326,6 +328,7 @@ export function ShotTabContent({
{ // 可以在这里添加其他逻辑 console.log('分镜添加成功');