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

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}
onSketchSelect={hanldeChangeSelect}
isPlaying={false}
roles={roles}
/>
);
case '4':

View File

@ -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<CharacterTokenOptions>
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 (
<NodeViewWrapper
as="span"
data-alt="character-token"
contentEditable={false}
className="relative inline text-blue-400 cursor-pointer hover:text-blue-300 transition-colors duration-200"
onMouseEnter={() => setShowCard(true)}
onMouseLeave={() => setShowCard(false)}
onClick={handleClick}
onMouseLeave={() => setShowRoleList(false)}
onMouseEnter={() => setShowRoleList(true)}
>
{name}
<AnimatePresence>
{showCard && (
{showRoleList && (
<motion.div
data-alt="role-list"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
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">
<img
src={avatar || 'https://placekitten.com/64/64'}
alt={name}
className="w-12 h-12 rounded-full border"
/>
<div>
<div className="font-medium text-base text-gray-200">{name}</div>
<div className="text-sm text-gray-400">{gender} / {age}</div>
</div>
<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
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>
</motion.div>
)}
@ -63,18 +101,25 @@ export function CharacterToken(props: ReactNodeViewProps) {
)
}
export const CharacterTokenExtension = Node.create({
export const CharacterTokenExtension = Node.create<CharacterTokenOptions>({
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);
},
});
});

View File

@ -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<ReturnType> {
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 (
<div className="w-full relative p-[0.5rem] pb-[2.5rem] border border-white/10 rounded-[0.5rem]">
<EditorContent editor={editor} />
{/* 智能润色按钮 */}
<motion.button
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
@ -151,23 +174,9 @@ const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick:
<Sparkles className="w-3.5 h-3.5" />
<span>{isOptimizing ? "优化中..." : "智能优化"}</span>
</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;

View File

@ -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<any>(null);
const videoPlayerRef = useRef<HTMLVideoElement>(null);
@ -326,6 +328,7 @@ export function ShotTabContent({
<div className='space-y-4 col-span-1'>
<ShotEditor
ref={editorRef}
roles={roles}
onAddSegment={() => {
// 可以在这里添加其他逻辑
console.log('分镜添加成功');