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

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">
<img {roles.map((role) => {
src={avatar || 'https://placekitten.com/64/64'} const isSelected = role.name === name;
alt={name} return (
className="w-12 h-12 rounded-full border" <div
/> key={role.name}
<div> data-alt="role-item"
<div className="font-medium text-base text-gray-200">{name}</div> className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors duration-200
<div className="text-sm text-gray-400">{gender} / {age}</div> ${isSelected ? 'bg-blue-500/20 text-blue-400' : 'hover:bg-white/5 text-gray-200'}`}
</div> onClick={() => handleRoleSelect(role)}
>
<div className="relative">
<img
src={role.url}
alt={role.name}
className={`w-10 h-10 rounded-full border transition-all duration-200
${isSelected ? 'border-blue-400 border-2' : 'border-white/20'}`}
/>
{isSelected && (
<div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
<Check className="w-3 h-3 text-white" />
</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,109 +47,122 @@ 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' {
const [segments, setSegments] = useState(initialContent.content); interface Commands<ReturnType> {
const [isOptimizing, setIsOptimizing] = useState(false); characterToken: {
setCharacterToken: (attrs: any) => ReturnType;
}
}
}
const handleSmartPolish = () => { const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick: (attrs: any) => void }, ShotEditorProps>(
setIsOptimizing(true); function ShotEditor({ onAddSegment, onCharacterClick, roles }, ref) {
setTimeout(() => { const [segments, setSegments] = useState(initialContent.content);
setIsOptimizing(false); const [isOptimizing, setIsOptimizing] = useState(false);
}, 3000);
};
const editor = useEditor({ const handleSmartPolish = () => {
extensions: [ setIsOptimizing(true);
StarterKit, setTimeout(() => {
CharacterTokenExtension, setIsOptimizing(false);
ShotTitle, }, 3000);
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); 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 })
},
})
return () => { const addSegment = () => {
editor?.off('character-clicked', handleCharacterClick as any); if (!editor) return;
};
}, [editor]);
const addSegment = () => { // 自动编号(获取已有 shotTitle 节点数量)
if (!editor) return; const doc = editor.state.doc;
let shotCount = 0;
// 自动编号(获取已有 shotTitle 节点数量) doc.descendants((node) => {
const doc = editor.state.doc; if (node.type.name === 'shotTitle' && node.attrs.title.includes('分镜')) {
let shotCount = 0; shotCount++;
doc.descendants((node) => { }
if (node.type.name === 'paragraph') {
shotCount++;
}
});
// 不能超过4个分镜
if (shotCount >= 4) {
toast.error('不能超过4个分镜', {
duration: 3000,
position: 'top-center',
richColors: true,
}); });
return;
// 不能超过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
} }
editor.chain().focus('end').insertContent([ return (
{ <div className="w-full relative p-[0.5rem] pb-[2.5rem] border border-white/10 rounded-[0.5rem]">
type: 'shotTitle', <EditorContent editor={editor} />
attrs: { title: `分镜${shotCount + 1}` }, {/* 智能润色按钮 */}
}, <motion.button
{
type: 'paragraph',
content: [
{ type: 'text', text: '镜头描述' }
]
}
])
.focus('end') // 聚焦到文档末尾
.run();
// 调用外部传入的回调函数
onAddSegment?.();
};
// 暴露 addSegment 方法给父组件
React.useImperativeHandle(ref, () => ({
addSegment
}));
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} onClick={handleSmartPolish}
disabled={isOptimizing} disabled={isOptimizing}
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5 className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
@ -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 </div>
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>
)
});
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('分镜添加成功');