更新编辑模态和分镜编辑器以支持角色选择

This commit is contained in:
北枳 2025-08-06 15:52:29 +08:00
parent 4d2c0b661b
commit 003eff57f5
4 changed files with 204 additions and 152 deletions

View File

@ -115,6 +115,7 @@ export function EditModal({
currentSketchIndex={currentIndex} currentSketchIndex={currentIndex}
onSketchSelect={hanldeChangeSelect} onSketchSelect={hanldeChangeSelect}
isPlaying={false} isPlaying={false}
roles={roles}
/> />
); );
case '4': case '4':

View File

@ -2,6 +2,7 @@ import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useState } from 'react' import { useState } from 'react'
import { Check } from 'lucide-react'
interface CharacterAttributes { interface CharacterAttributes {
id: string | null; id: string | null;
@ -11,50 +12,87 @@ interface CharacterAttributes {
age: string; age: string;
} }
// interface CharacterTokenProps extends ReactNodeViewProps { interface Role {
// onClick?: (attrs: CharacterAttributes) => void name: string;
// } url: string;
}
interface CharacterTokenOptions {
roles?: Role[];
}
export function CharacterToken(props: ReactNodeViewProps) { export function CharacterToken(props: ReactNodeViewProps) {
const [showCard, setShowCard] = useState(false) const [showRoleList, setShowRoleList] = useState(false)
const { name, avatar, gender, age } = props.node.attrs as CharacterAttributes const { name, avatar } = props.node.attrs as CharacterAttributes
const extension = props.extension as Node<CharacterTokenOptions>
const roles = extension.options.roles || []
const handleClick = () => { const handleRoleSelect = (role: Role) => {
console.log('点击角色:', name)
const { editor } = props; 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 ( return (
<NodeViewWrapper <NodeViewWrapper
as="span" as="span"
data-alt="character-token"
contentEditable={false} contentEditable={false}
className="relative inline text-blue-400 cursor-pointer hover:text-blue-300 transition-colors duration-200" className="relative inline text-blue-400 cursor-pointer hover:text-blue-300 transition-colors duration-200"
onMouseEnter={() => setShowCard(true)} onMouseLeave={() => setShowRoleList(false)}
onMouseLeave={() => setShowCard(false)} onMouseEnter={() => setShowRoleList(true)}
onClick={handleClick}
> >
{name} {name}
<AnimatePresence> <AnimatePresence>
{showCard && ( {showRoleList && (
<motion.div <motion.div
data-alt="role-list"
initial={{ opacity: 0, y: 4 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }} exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="absolute top-full left-0 mt-2 w-64 rounded-lg backdrop-blur-md bg-white/10 border border-white/20 p-4 z-50" className="absolute top-full left-0 mt-2 w-64 rounded-lg backdrop-blur-md bg-white/10 border border-white/20 p-2 z-50"
> >
<div className="flex items-center gap-3"> <div className="space-y-1">
{roles.map((role) => {
const isSelected = role.name === name;
return (
<div
key={role.name}
data-alt="role-item"
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors duration-200
${isSelected ? 'bg-blue-500/20 text-blue-400' : 'hover:bg-white/5 text-gray-200'}`}
onClick={() => handleRoleSelect(role)}
>
<div className="relative">
<img <img
src={avatar || 'https://placekitten.com/64/64'} src={role.url}
alt={name} alt={role.name}
className="w-12 h-12 rounded-full border" className={`w-10 h-10 rounded-full border transition-all duration-200
${isSelected ? 'border-blue-400 border-2' : 'border-white/20'}`}
/> />
<div> {isSelected && (
<div className="font-medium text-base text-gray-200">{name}</div> <div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
<div className="text-sm text-gray-400">{gender} / {age}</div> <Check className="w-3 h-3 text-white" />
</div> </div>
)}
</div>
<span className="flex-1">{role.name}</span>
</div>
);
})}
</div> </div>
</motion.div> </motion.div>
)} )}
@ -63,18 +101,25 @@ export function CharacterToken(props: ReactNodeViewProps) {
) )
} }
export const CharacterTokenExtension = Node.create({ export const CharacterTokenExtension = Node.create<CharacterTokenOptions>({
name: 'characterToken', name: 'characterToken',
group: 'inline', group: 'inline',
inline: true, inline: true,
atom: true, atom: true,
addOptions() {
return {
roles: [],
}
},
addAttributes() { addAttributes() {
return { return {
name: {}, id: { default: null },
gender: {}, name: { default: '' },
age: {}, gender: { default: '' },
avatar: {}, age: { default: '' },
avatar: { default: '' },
}; };
}, },
@ -86,12 +131,6 @@ export const CharacterTokenExtension = Node.create({
return ['character-token', mergeAttributes(HTMLAttributes)]; return ['character-token', mergeAttributes(HTMLAttributes)];
}, },
// addStorage() {
// return {
// onClickCharacter: null as null | ((character: CharacterAttributes) => void),
// }
// },
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(CharacterToken); return ReactNodeViewRenderer(CharacterToken);
}, },

View File

@ -18,8 +18,18 @@ const initialContent = {
{ {
type: 'paragraph', type: 'paragraph',
content: [ 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,11 +47,21 @@ const initialContent = {
}; };
interface ShotEditorProps { interface ShotEditorProps {
roles?: any[];
onAddSegment?: () => void; onAddSegment?: () => void;
onCharacterClick?: (attrs: any) => void; onCharacterClick?: (attrs: any) => void;
} }
const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick: (attrs: any) => void }, ShotEditorProps>(function ShotEditor({ onAddSegment, onCharacterClick }, ref) { 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 [segments, setSegments] = useState(initialContent.content);
const [isOptimizing, setIsOptimizing] = useState(false); const [isOptimizing, setIsOptimizing] = useState(false);
@ -55,7 +75,9 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
CharacterTokenExtension, CharacterTokenExtension.configure({
roles
}),
ShotTitle, ShotTitle,
ReadonlyText, ReadonlyText,
], ],
@ -71,20 +93,6 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
}, },
}) })
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 = () => { const addSegment = () => {
if (!editor) return; if (!editor) return;
@ -92,7 +100,7 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
const doc = editor.state.doc; const doc = editor.state.doc;
let shotCount = 0; let shotCount = 0;
doc.descendants((node) => { doc.descendants((node) => {
if (node.type.name === 'paragraph') { if (node.type.name === 'shotTitle' && node.attrs.title.includes('分镜')) {
shotCount++; shotCount++;
} }
}); });
@ -117,6 +125,18 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
content: [ content: [
{ type: 'text', text: '镜头描述' } { 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') // 聚焦到文档末尾 .focus('end') // 聚焦到文档末尾
@ -126,9 +146,12 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
onAddSegment?.(); onAddSegment?.();
}; };
// 暴露 addSegment 方法给父组件 // 暴露方法给父组件
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
addSegment addSegment,
onCharacterClick: (attrs: any) => {
onCharacterClick?.(attrs);
}
})); }));
if (!editor) { if (!editor) {
@ -151,23 +174,9 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-3.5 h-3.5" />
<span>{isOptimizing ? "优化中..." : "智能优化"}</span> <span>{isOptimizing ? "优化中..." : "智能优化"}</span>
</motion.button> </motion.button>
{/* <motion.button
onClick={addSegment}
className="group absolute bottom-[0.5rem] h-8 rounded-full bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 flex items-center justify-center overflow-hidden"
initial={{ width: "2rem" }}
whileHover={{
width: "8rem",
transition: { duration: 0.3, ease: "easeInOut" }
}}
>
<motion.div
className="flex items-center justify-center space-x-1 px-2 h-full">
<span className="text-lg">+</span>
<span className="text-sm group-hover:opacity-100 opacity-0 transition-all duration-500 w-0 group-hover:w-auto"></span>
</motion.div>
</motion.button> */}
</div> </div>
) )
}); }
);
export default ShotEditor; export default ShotEditor;

View File

@ -20,13 +20,15 @@ interface ShotTabContentProps {
currentSketchIndex: number; currentSketchIndex: number;
onSketchSelect: (index: number) => void; onSketchSelect: (index: number) => void;
isPlaying?: boolean; isPlaying?: boolean;
roles?: any[];
} }
export function ShotTabContent({ export function ShotTabContent({
taskSketch = [], taskSketch = [],
currentSketchIndex = 0, currentSketchIndex = 0,
onSketchSelect, onSketchSelect,
isPlaying: externalIsPlaying = true isPlaying: externalIsPlaying = true,
roles = []
}: ShotTabContentProps) { }: ShotTabContentProps) {
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const videoPlayerRef = useRef<HTMLVideoElement>(null); const videoPlayerRef = useRef<HTMLVideoElement>(null);
@ -326,6 +328,7 @@ export function ShotTabContent({
<div className='space-y-4 col-span-1'> <div className='space-y-4 col-span-1'>
<ShotEditor <ShotEditor
ref={editorRef} ref={editorRef}
roles={roles}
onAddSegment={() => { onAddSegment={() => {
// 可以在这里添加其他逻辑 // 可以在这里添加其他逻辑
console.log('分镜添加成功'); console.log('分镜添加成功');