From f0844aaf779f1c0c30ad6d37176457943e7e1593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E6=9E=B3?= <7854742+wang_rumeng@user.noreply.gitee.com> Date: Sat, 9 Aug 2025 16:41:05 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=92=E8=89=B2=E7=BC=96=E8=BE=91=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/pages/work-flow/thumbnail-grid.tsx | 11 +- components/ui/FloatingGlassPanel.tsx | 5 +- components/ui/HorizontalScroller.tsx | 14 +- components/ui/ImageBlurTransition.tsx | 8 +- components/ui/character-editor.tsx | 184 ++++-------------- components/ui/character-tab-content.tsx | 158 ++++++++++----- components/ui/main-editor/HighlightText.tsx | 61 ++++++ components/ui/main-editor/MainEditor.tsx | 34 ++++ components/ui/replace-panel.tsx | 70 ++++++- components/ui/shot-editor/ShotEditor copy.tsx | 182 ----------------- 10 files changed, 332 insertions(+), 395 deletions(-) create mode 100644 components/ui/main-editor/HighlightText.tsx create mode 100644 components/ui/main-editor/MainEditor.tsx delete mode 100644 components/ui/shot-editor/ShotEditor copy.tsx diff --git a/components/pages/work-flow/thumbnail-grid.tsx b/components/pages/work-flow/thumbnail-grid.tsx index f9035bb..925129f 100644 --- a/components/pages/work-flow/thumbnail-grid.tsx +++ b/components/pages/work-flow/thumbnail-grid.tsx @@ -36,6 +36,7 @@ export function ThumbnailGrid({ const [isDragging, setIsDragging] = useState(false); const [startX, setStartX] = useState(0); const [scrollLeft, setScrollLeft] = useState(0); + const [isFocused, setIsFocused] = useState(false); // 监听当前选中索引变化,自动滚动到对应位置 useEffect(() => { @@ -54,6 +55,9 @@ export function ThumbnailGrid({ // 处理键盘左右键事件 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // 只在元素被聚焦时处理键盘事件 + if (!isFocused) return; + const isVideoPhase = Number(currentStep) > 2 && taskVideos.length > 0 && Number(currentStep) < 6; const maxIndex = isVideoPhase ? taskVideos.length - 1 : taskSketch.length - 1; @@ -75,7 +79,7 @@ export function ThumbnailGrid({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [currentStep, currentSketchIndex, taskSketch.length, taskVideos.length, onSketchSelect]); + }, [currentStep, currentSketchIndex, taskSketch.length, taskVideos.length, onSketchSelect, isFocused]); // 处理鼠标/触摸拖动事件 const handleMouseDown = (e: React.MouseEvent) => { @@ -368,11 +372,14 @@ export function ThumbnailGrid({ return (
setIsDragging(false)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} > {Number(currentStep) > 2 && taskVideos.length > 0 && Number(currentStep) < 6 ? renderVideoThumbnails() diff --git a/components/ui/FloatingGlassPanel.tsx b/components/ui/FloatingGlassPanel.tsx index 0564a75..588efcf 100644 --- a/components/ui/FloatingGlassPanel.tsx +++ b/components/ui/FloatingGlassPanel.tsx @@ -5,6 +5,7 @@ import { ReactNode } from 'react'; type FloatingGlassPanelProps = { open: boolean; + clickMaskClose?: boolean; onClose?: () => void; children: ReactNode; width?: string; @@ -12,7 +13,7 @@ type FloatingGlassPanelProps = { panel_style?: React.CSSProperties; }; -export default function FloatingGlassPanel({ open, onClose, children, width = '320px', r_key, panel_style }: FloatingGlassPanelProps) { +export default function FloatingGlassPanel({ open, onClose, children, width = '320px', r_key, panel_style, clickMaskClose = true }: FloatingGlassPanelProps) { // 定义弹出动画 const bounceAnimation = { scale: [0.95, 1.02, 0.98, 1], @@ -61,7 +62,7 @@ export default function FloatingGlassPanel({ open, onClose, children, width = '3 initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - onClick={onClose} + onClick={clickMaskClose ? onClose : undefined } />
)} diff --git a/components/ui/HorizontalScroller.tsx b/components/ui/HorizontalScroller.tsx index 3fbdf4f..7ba9f4a 100644 --- a/components/ui/HorizontalScroller.tsx +++ b/components/ui/HorizontalScroller.tsx @@ -42,6 +42,7 @@ const HorizontalScroller = forwardRef( const dragInstance = useRef(null) const itemRefs = useRef<(HTMLDivElement | null)[]>([]) const [currentIndex, setCurrentIndex] = useState(selectedIndex || 0) + const [isFocused, setIsFocused] = useState(false) const scrollToIndex = useCallback( (index: number) => { @@ -58,7 +59,8 @@ const HorizontalScroller = forwardRef( const wrapperRect = wrapperRef.current.getBoundingClientRect() targetX = -(itemElement.offsetLeft - (containerWidth - itemRect.width) / 2) } else { - targetX = -(index * (itemWidth + gap) - (containerWidth - itemWidth) / 2) + const numericWidth = typeof itemWidth === 'number' ? itemWidth : parseFloat(itemWidth) + targetX = -(index * (numericWidth + gap) - (containerWidth - numericWidth) / 2) } const maxScroll = wrapperRef.current.scrollWidth - containerWidth @@ -112,6 +114,9 @@ const HorizontalScroller = forwardRef( useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // 只在容器被聚焦时处理键盘事件 + if (!isFocused) return; + if (e.key === 'ArrowLeft') { e.preventDefault() let newIndex = Math.max(0, currentIndex - 1) @@ -131,7 +136,7 @@ const HorizontalScroller = forwardRef( window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [currentIndex, children.length, scrollToIndex, onItemClick]) + }, [currentIndex, children.length, scrollToIndex, onItemClick, isFocused]) useImperativeHandle(ref, () => ({ scrollToIndex, @@ -140,7 +145,10 @@ const HorizontalScroller = forwardRef( return (
setIsFocused(true)} + onBlur={() => setIsFocused(false)} >
@@ -50,7 +48,7 @@ export default function ImageBlurTransition({ key={current} src={current} alt={alt} - className="absolute w-full h-auto object-cover rounded-xl" + className="w-full h-auto object-cover rounded-xl" initial={{ opacity: 0, filter: 'blur(8px)', @@ -76,7 +74,7 @@ export default function ImageBlurTransition({ {alt} )}
diff --git a/components/ui/character-editor.tsx b/components/ui/character-editor.tsx index 387d57c..b0af4c8 100644 --- a/components/ui/character-editor.tsx +++ b/components/ui/character-editor.tsx @@ -1,169 +1,63 @@ import { useState, useRef } from "react"; import { motion } from "framer-motion"; -import { Button } from "@/components/ui/button"; import { Sparkles, X, Plus, RefreshCw } from 'lucide-react'; +import MainEditor from "./main-editor/MainEditor"; import { cn } from "@/public/lib/utils"; -import ContentEditable from 'react-contenteditable'; - -interface CharacterAttribute { - key: string; - label: string; - value: string; - type: 'text' | 'number' | 'select'; - options?: string[]; -} interface CharacterEditorProps { - initialDescription?: string; - onDescriptionChange?: (description: string) => void; - onAttributesChange?: (attributes: CharacterAttribute[]) => void; - onReplaceCharacter?: (url: string) => void; + className?: string; } -const mockParse = (text: string): CharacterAttribute[] => { - // 模拟结构化解析结果 - return [ - { key: "age", label: "年龄", value: "20", type: "number" }, - { key: "gender", label: "性别", value: "女性", type: "select", options: ["男性", "女性", "其他"] }, - { key: "hair", label: "发型", value: "银白短发", type: "text" }, - { key: "race", label: "种族", value: "精灵", type: "text" }, - { key: "skin", label: "肤色", value: "白皙", type: "text" }, - { key: "build", label: "体型", value: "高挑", type: "text" }, - { key: "costume", label: "服装", value: "白色连衣裙", type: "text" }, - ]; -}; +const mockContent = [ + { + type: 'paragraph', + content: [ + { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'blue' } }, + { type: 'text', text: 'Hello, world!' }, + { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'red' } }, + { type: 'text', text: 'Hello, world!' }, + { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'green' } }, + { type: 'text', text: 'Hello, world!' }, + { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'yellow' } }, + { type: 'text', text: 'Hello, world!' }, + { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'purple' } }, + { type: 'text', text: 'Hello, world!' }, + { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'orange' } }, + { type: 'text', text: 'Hello, world!' }, + { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'pink' } }, + { type: 'text', text: 'Hello, world!' }, + ], + }, +]; export default function CharacterEditor({ - initialDescription = "一个银白短发的精灵女性,大约20岁,肤色白皙,身材高挑,身着白色连衣裙", - onDescriptionChange, - onAttributesChange, - onReplaceCharacter, + className, }: CharacterEditorProps) { - const [inputText, setInputText] = useState(initialDescription); const [isOptimizing, setIsOptimizing] = useState(false); - const [isRegenerating, setIsRegenerating] = useState(false); - const [customTags, setCustomTags] = useState([]); - const [newTag, setNewTag] = useState(""); - const attributesRef = useRef(mockParse(initialDescription)); - const contentEditableRef = useRef(null); - - const handleTextChange = (e: { target: { value: string } }) => { - // 移除 HTML 标签,保留换行 - const value = e.target.value; - setInputText(value); - onDescriptionChange?.(value); - }; - - // 格式化文本为 HTML - const formatTextToHtml = (text: string) => { - return text - .split('\n') - .map(line => line || '
') - .join('
'); - }; const handleSmartPolish = async () => { - setIsOptimizing(true); - try { - const polishedText = "一位拥有银白短发、白皙肌肤的高挑精灵少女,年龄约二十岁,气质神秘优雅。举手投足间散发着独特的精灵族气质,眼神中透露出智慧与沧桑。"; - setInputText(polishedText); - attributesRef.current = mockParse(polishedText); - onDescriptionChange?.(polishedText); - onAttributesChange?.(attributesRef.current); - } finally { - setIsOptimizing(false); - } - }; - - const handleAttributeChange = (attr: CharacterAttribute, newValue: string) => { - // 移除 HTML 标签 - newValue = newValue.replace(/<[^>]*>/g, ''); - // 更新描述文本 - let newText = inputText; - if (attr.type === "number" && attr.key === "age") { - newText = newText.replace(/\d+岁/, `${newValue}岁`); - } else { - newText = newText.replace(new RegExp(attr.value, 'g'), newValue); - } - - // 更新属性值 - const newAttr = { ...attr, value: newValue }; - attributesRef.current = attributesRef.current.map(a => - a.key === attr.key ? newAttr : a - ); - - setInputText(newText); - onDescriptionChange?.(newText); - onAttributesChange?.(attributesRef.current); - }; - - const handleRegenerate = () => { - setIsRegenerating(true); - setTimeout(() => { - onReplaceCharacter?.("https://c.huiying.video/images/0411ac7b-ab7e-4a17-ab4f-6880a28f8915.jpg"); - setIsRegenerating(false); - }, 3000); }; return ( -
+
{/* 自由输入区域 */} -
- - - {/* 智能润色按钮 */} - - - {isOptimizing ? "优化中..." : "智能优化"} - -
+ - {/* 结构化属性标签 */} -
- {attributesRef.current.map((attr) => ( - - {attr.label}: - handleAttributeChange(attr, e.target.value)} - className="text-sm text-white/90 min-w-[1em] focus:outline-none - border-b border-transparent focus:border-white/30 - hover:border-white/20" - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLElement).blur(); - } - }} - /> - - ))} -
+ {/* 智能润色按钮 */} + + + {isOptimizing ? "优化中..." : "智能优化"} +
); } diff --git a/components/ui/character-tab-content.tsx b/components/ui/character-tab-content.tsx index cf6ac7c..4f1cfdd 100644 --- a/components/ui/character-tab-content.tsx +++ b/components/ui/character-tab-content.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Upload, Library, Play, Pause, RefreshCw, Wand2, Users, Check, ReplaceAll, X } from 'lucide-react'; +import { ImageUp, Library, Play, Pause, RefreshCw, Wand2, Users, Check, ReplaceAll, X, TriangleAlert } from 'lucide-react'; import { cn } from '@/public/lib/utils'; import CharacterEditor from './character-editor'; import ImageBlurTransition from './ImageBlurTransition'; @@ -68,10 +68,21 @@ export function CharacterTabContent({ const [replacePanelKey, setReplacePanelKey] = useState(0); const [ignoreReplace, setIgnoreReplace] = useState(false); const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false); - + const [isRemindReplacePanelOpen, setIsRemindReplacePanelOpen] = useState(false); const [selectRoleIndex, setSelectRoleIndex] = useState(0); + const fileInputRef = useRef(null); const [enableAnimation, setEnableAnimation] = useState(true); + const [showAddToLibrary, setShowAddToLibrary] = useState(true); + const handleConfirmGotoReplace = () => { + setIsRemindReplacePanelOpen(false); + setIsReplacePanelOpen(true); + }; + + const handleCloseRemindReplacePanel = () => { + setIsRemindReplacePanelOpen(false); + setIgnoreReplace(true); + }; const handleReplaceCharacter = (url: string) => { setEnableAnimation(true); @@ -93,17 +104,12 @@ export function CharacterTabContent({ // 取消替换 const handleCloseReplacePanel = () => { setIsReplacePanelOpen(false); - setIgnoreReplace(true); }; const handleChangeRole = (index: number) => { if (currentRole.url !== roles[selectRoleIndex].url && !ignoreReplace) { // 提示 角色已修改,弹出替换角色面板 - if (isReplacePanelOpen) { - setReplacePanelKey(replacePanelKey + 1); - } else { - setIsReplacePanelOpen(true); - } + setIsRemindReplacePanelOpen(true); return; } // 重置替换规则 @@ -118,9 +124,43 @@ export function CharacterTabContent({ const handleSelectCharacter = (index: number) => { console.log('index', index); setIsReplaceLibraryOpen(false); + setShowAddToLibrary(false); handleReplaceCharacter('https://c.huiying.video/images/5740cb7c-6e08-478f-9e7c-bca7f78a2bf6.jpg'); }; + const handleOpenReplaceLibrary = () => { + setIsReplaceLibraryOpen(true); + setShowAddToLibrary(true); + }; + + const handleRegenerate = () => { + console.log('Regenerate'); + setShowAddToLibrary(true); + }; + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // 检查文件类型 + if (!file.type.startsWith('image/')) { + alert('请选择图片文件'); + return; + } + + // 创建本地预览URL + const imageUrl = URL.createObjectURL(file); + setShowAddToLibrary(false); + handleReplaceCharacter(imageUrl); + + // 清空input的值,这样同一个文件可以重复选择 + event.target.value = ''; + }; + // 如果没有角色数据,显示占位内容 if (!roles || roles.length === 0) { return ( @@ -133,6 +173,14 @@ export function CharacterTabContent({ return (
+ {/* 隐藏的文件输入框 */} + {/* 上部分:角色缩略图 */} {/* 应用角色按钮 */} @@ -197,7 +245,16 @@ export function CharacterTabContent({ text-white rounded-full backdrop-blur-sm transition-colors z-10" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} - onClick={() => setIsReplaceLibraryOpen(true)} + onClick={handleUploadClick} + > + + + handleOpenReplaceLibrary()} > @@ -208,47 +265,7 @@ export function CharacterTabContent({ {/* 右列:角色信息 */}
{ - setLocalRole({ - ...localRole, - roleDescription: description - }); - }} - onAttributesChange={(attributes) => { - const newRole = { ...localRole }; - - attributes.forEach(attr => { - switch (attr.key) { - case 'age': - newRole.age = parseInt(attr.value); - break; - case 'gender': - if (attr.value === '男性') { - newRole.gender = 'male'; - } else if (attr.value === '女性') { - newRole.gender = 'female'; - } else { - newRole.gender = 'other'; - } - break; - case 'hair': - newRole.appearance.hairStyle = attr.value; - break; - case 'skin': - newRole.appearance.skinTone = attr.value; - break; - case 'build': - newRole.appearance.bodyType = attr.value; - break; - } - }); - - setLocalRole(newRole); - }} - onReplaceCharacter={(url) => { - handleReplaceCharacter(url); - }} + className="min-h-[calc(100%-4rem)]" /> {/* 重新生成按钮、替换形象按钮 */}
@@ -263,7 +280,7 @@ export function CharacterTabContent({ Replace console.log('Regenerate')} + onClick={() => handleRegenerate()} className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20 text-blue-500 rounded-lg transition-colors" whileHover={{ scale: 1.02 }} @@ -282,13 +299,14 @@ export function CharacterTabContent({ handleCloseReplacePanel()} > handleCloseReplacePanel()} onConfirm={handleConfirmReplace} /> @@ -300,6 +318,40 @@ export function CharacterTabContent({ setIsReplaceLibraryOpen={setIsReplaceLibraryOpen} onSelect={handleSelectCharacter} /> + + {/* 提醒用户角色已修改 是否需要替换 */} + +
+
+ +

角色已修改,是否需要替换?

+
+ +
+ + + +
+
+
); } \ No newline at end of file diff --git a/components/ui/main-editor/HighlightText.tsx b/components/ui/main-editor/HighlightText.tsx new file mode 100644 index 0000000..f763f18 --- /dev/null +++ b/components/ui/main-editor/HighlightText.tsx @@ -0,0 +1,61 @@ +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 HighlightTextAttributes { + type: string; + text: string; + color: string; +} + +interface HighlightTextOptions { + type?: string; + text: string; + color: string; +} + +export function HighlightText(props: ReactNodeViewProps) { + const { text, color } = props.node.attrs as HighlightTextAttributes + + return ( + + {text} + + {/* 暂时空着 为后续可视化文本预留 */} + + ) +} + +export const HighlightTextExtension = Node.create({ + name: 'highlightText', + group: 'inline', + inline: true, + atom: false, + + addAttributes() { + return { + type: { default: null }, + text: { default: '' }, + color: { default: 'blue' }, + }; + }, + + parseHTML() { + return [{ tag: 'highlight-text' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['highlight-text', mergeAttributes(HTMLAttributes)]; + }, + + addNodeView() { + return ReactNodeViewRenderer(HighlightText); + }, +}); \ No newline at end of file diff --git a/components/ui/main-editor/MainEditor.tsx b/components/ui/main-editor/MainEditor.tsx new file mode 100644 index 0000000..35a96a2 --- /dev/null +++ b/components/ui/main-editor/MainEditor.tsx @@ -0,0 +1,34 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { EditorContent, useEditor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { HighlightTextExtension } from './HighlightText'; + +interface MainEditorProps { + content: any[]; +} + +export default function MainEditor({ content }: MainEditorProps) { + const editor = useEditor({ + extensions: [ + StarterKit, + HighlightTextExtension, + ], + content: { type: 'doc', content: content }, + editorProps: { + attributes: { + class: 'prose prose-invert max-w-none focus:outline-none' + } + }, + immediatelyRender: false, + onCreate: ({ editor }) => { + editor.setOptions({ editable: true }) + }, + }); + + if (!editor) { + return null + } + return ( + + ); +} \ No newline at end of file diff --git a/components/ui/replace-panel.tsx b/components/ui/replace-panel.tsx index 38d4fe9..8d42538 100644 --- a/components/ui/replace-panel.tsx +++ b/components/ui/replace-panel.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef } from 'react'; import { motion } from 'framer-motion'; -import { Check, X, CircleAlert } from 'lucide-react'; +import { Check, X, CircleAlert, ArrowLeft, ArrowRight } from 'lucide-react'; import { cn } from '@/public/lib/utils'; // 定义类型 @@ -42,8 +42,34 @@ export function ReplacePanel({ ); const [addToLibrary, setAddToLibrary] = useState(false); const [hoveredVideoId, setHoveredVideoId] = useState(null); + const [isAtStart, setIsAtStart] = useState(true); + const [isAtEnd, setIsAtEnd] = useState(false); const videoRefs = useRef<{ [key: string]: HTMLVideoElement }>({}); + const shotsRef = useRef(null); + // 检查滚动位置 + const checkScrollPosition = () => { + if (!shotsRef.current) return; + + const { scrollLeft, scrollWidth, clientWidth } = shotsRef.current; + setIsAtStart(scrollLeft <= 0); + setIsAtEnd(Math.ceil(scrollLeft + clientWidth) >= scrollWidth); + }; + + // 添加滚动事件监听 + React.useEffect(() => { + const shotsElement = shotsRef.current; + if (!shotsElement) return; + + shotsElement.addEventListener('scroll', checkScrollPosition); + // 初始检查 + checkScrollPosition(); + + return () => { + shotsElement.removeEventListener('scroll', checkScrollPosition); + }; + }, []); + const handleShotToggle = (shotId: string) => { setSelectedShots(prev => prev.includes(shotId) @@ -75,6 +101,24 @@ export function ReplacePanel({ onConfirm(selectedShots, addToLibrary); }; + const handleLeftArrowClick = () => { + if (!shotsRef.current) return; + + shotsRef.current.scrollBy({ + left: -300, // 每次滚动的距离 + behavior: 'smooth' // 平滑滚动 + }); + }; + + const handleRightArrowClick = () => { + if (!shotsRef.current) return; + + shotsRef.current.scrollBy({ + left: 300, // 每次滚动的距离 + behavior: 'smooth' // 平滑滚动 + }); + }; + return (
{/* 标题 */} @@ -98,9 +142,9 @@ export function ReplacePanel({
{/* 分镜展示区 */} -
+
选择需要替换的分镜:
-
+
{shots.map((shot) => ( ))}
+ + {/* 左右箭头 */} +
!isAtStart && handleLeftArrowClick()} + > + +
+
!isAtEnd && handleRightArrowClick()} + > + +
{/* 预览信息 */} diff --git a/components/ui/shot-editor/ShotEditor copy.tsx b/components/ui/shot-editor/ShotEditor copy.tsx deleted file mode 100644 index b3be347..0000000 --- a/components/ui/shot-editor/ShotEditor copy.tsx +++ /dev/null @@ -1,182 +0,0 @@ -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 { - 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 ( -
- - {/* 智能润色按钮 */} - - - {isOptimizing ? "优化中..." : "智能优化"} - -
- ) - } -); - -export default ShotEditor; \ No newline at end of file