forked from 77media/video-flow
更新编辑模态和分镜编辑器以支持角色选择
This commit is contained in:
parent
4d2c0b661b
commit
003eff57f5
@ -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':
|
||||||
|
|||||||
@ -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,13 +131,7 @@ 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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -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 = () => {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
const ShotEditor = React.forwardRef<{ addSegment: () => void, onCharacterClick: (attrs: any) => void }, ShotEditorProps>(
|
||||||
<div className="w-full relative p-[0.5rem] pb-[2.5rem] border border-white/10 rounded-[0.5rem]">
|
function ShotEditor({ onAddSegment, onCharacterClick, roles }, ref) {
|
||||||
<EditorContent editor={editor} />
|
const [segments, setSegments] = useState(initialContent.content);
|
||||||
{/* 智能润色按钮 */}
|
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||||
<motion.button
|
|
||||||
|
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}
|
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;
|
||||||
@ -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('分镜添加成功');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user