forked from 77media/video-flow
更新编辑模态和分镜编辑器以支持角色选择
This commit is contained in:
parent
4d2c0b661b
commit
003eff57f5
@ -115,6 +115,7 @@ export function EditModal({
|
||||
currentSketchIndex={currentIndex}
|
||||
onSketchSelect={hanldeChangeSelect}
|
||||
isPlaying={false}
|
||||
roles={roles}
|
||||
/>
|
||||
);
|
||||
case '4':
|
||||
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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('分镜添加成功');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user