forked from 77media/video-flow
182 lines
5.2 KiB
TypeScript
182 lines
5.2 KiB
TypeScript
import React, { useState, useCallback, useEffect } from 'react';
|
||
import { EditorContent, useEditor } from '@tiptap/react';
|
||
import StarterKit from '@tiptap/starter-kit';
|
||
import { motion } from "framer-motion";
|
||
import { CharacterTokenExtension } from './CharacterToken';
|
||
import { ShotTitle } from './ShotTitle';
|
||
import { ReadonlyText } from './ReadonlyText';
|
||
import { Sparkles } from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
|
||
const initialContent = {
|
||
type: 'doc',
|
||
content: [
|
||
{
|
||
type: 'shotTitle',
|
||
attrs: { title: `分镜1` },
|
||
},
|
||
{
|
||
type: 'paragraph',
|
||
content: [
|
||
{ 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: '掩护!趴下!' }
|
||
]
|
||
},
|
||
{
|
||
type: 'shotTitle',
|
||
attrs: { title: `分镜2` },
|
||
},
|
||
{
|
||
type: 'paragraph',
|
||
content: [
|
||
{ type: 'characterToken', attrs: { name: '李四', gender: '女', age: '26', avatar: 'https://i.pravatar.cc/40?u=l4' }},
|
||
{ type: 'text', text: ' 微微低头,没有说话。' }
|
||
]
|
||
}
|
||
]
|
||
};
|
||
|
||
interface ShotEditorProps {
|
||
roles?: any[];
|
||
onAddSegment?: () => void;
|
||
onCharacterClick?: (attrs: any) => void;
|
||
}
|
||
|
||
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 [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
|
||
bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 rounded-full
|
||
transition-colors text-xs disabled:opacity-50"
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
>
|
||
<Sparkles className="w-3.5 h-3.5" />
|
||
<span>{isOptimizing ? "优化中..." : "智能优化"}</span>
|
||
</motion.button>
|
||
</div>
|
||
)
|
||
}
|
||
);
|
||
|
||
export default ShotEditor; |