角色编辑页面调整

This commit is contained in:
北枳 2025-08-09 16:41:05 +08:00
parent 250fa8441e
commit f0844aaf77
10 changed files with 332 additions and 395 deletions

View File

@ -36,6 +36,7 @@ export function ThumbnailGrid({
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0); const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0); const [scrollLeft, setScrollLeft] = useState(0);
const [isFocused, setIsFocused] = useState(false);
// 监听当前选中索引变化,自动滚动到对应位置 // 监听当前选中索引变化,自动滚动到对应位置
useEffect(() => { useEffect(() => {
@ -54,6 +55,9 @@ export function ThumbnailGrid({
// 处理键盘左右键事件 // 处理键盘左右键事件
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// 只在元素被聚焦时处理键盘事件
if (!isFocused) return;
const isVideoPhase = Number(currentStep) > 2 && taskVideos.length > 0 && Number(currentStep) < 6; const isVideoPhase = Number(currentStep) > 2 && taskVideos.length > 0 && Number(currentStep) < 6;
const maxIndex = isVideoPhase ? taskVideos.length - 1 : taskSketch.length - 1; const maxIndex = isVideoPhase ? taskVideos.length - 1 : taskSketch.length - 1;
@ -75,7 +79,7 @@ export function ThumbnailGrid({
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('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<HTMLDivElement>) => { const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
@ -368,11 +372,14 @@ export function ThumbnailGrid({
return ( return (
<div <div
ref={thumbnailsRef} ref={thumbnailsRef}
className="w-full grid grid-flow-col auto-cols-[20%] gap-4 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing" tabIndex={0}
className="w-full grid grid-flow-col auto-cols-[20%] gap-4 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={() => setIsDragging(false)} onMouseLeave={() => setIsDragging(false)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
> >
{Number(currentStep) > 2 && taskVideos.length > 0 && Number(currentStep) < 6 {Number(currentStep) > 2 && taskVideos.length > 0 && Number(currentStep) < 6
? renderVideoThumbnails() ? renderVideoThumbnails()

View File

@ -5,6 +5,7 @@ import { ReactNode } from 'react';
type FloatingGlassPanelProps = { type FloatingGlassPanelProps = {
open: boolean; open: boolean;
clickMaskClose?: boolean;
onClose?: () => void; onClose?: () => void;
children: ReactNode; children: ReactNode;
width?: string; width?: string;
@ -12,7 +13,7 @@ type FloatingGlassPanelProps = {
panel_style?: React.CSSProperties; 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 = { const bounceAnimation = {
scale: [0.95, 1.02, 0.98, 1], scale: [0.95, 1.02, 0.98, 1],
@ -61,7 +62,7 @@ export default function FloatingGlassPanel({ open, onClose, children, width = '3
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
onClick={onClose} onClick={clickMaskClose ? onClose : undefined }
/> />
</div> </div>
)} )}

View File

@ -42,6 +42,7 @@ const HorizontalScroller = forwardRef(
const dragInstance = useRef<Draggable | null>(null) const dragInstance = useRef<Draggable | null>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([]) const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const [currentIndex, setCurrentIndex] = useState(selectedIndex || 0) const [currentIndex, setCurrentIndex] = useState(selectedIndex || 0)
const [isFocused, setIsFocused] = useState(false)
const scrollToIndex = useCallback( const scrollToIndex = useCallback(
(index: number) => { (index: number) => {
@ -58,7 +59,8 @@ const HorizontalScroller = forwardRef(
const wrapperRect = wrapperRef.current.getBoundingClientRect() const wrapperRect = wrapperRef.current.getBoundingClientRect()
targetX = -(itemElement.offsetLeft - (containerWidth - itemRect.width) / 2) targetX = -(itemElement.offsetLeft - (containerWidth - itemRect.width) / 2)
} else { } 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 const maxScroll = wrapperRef.current.scrollWidth - containerWidth
@ -112,6 +114,9 @@ const HorizontalScroller = forwardRef(
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// 只在容器被聚焦时处理键盘事件
if (!isFocused) return;
if (e.key === 'ArrowLeft') { if (e.key === 'ArrowLeft') {
e.preventDefault() e.preventDefault()
let newIndex = Math.max(0, currentIndex - 1) let newIndex = Math.max(0, currentIndex - 1)
@ -131,7 +136,7 @@ const HorizontalScroller = forwardRef(
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentIndex, children.length, scrollToIndex, onItemClick]) }, [currentIndex, children.length, scrollToIndex, onItemClick, isFocused])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
scrollToIndex, scrollToIndex,
@ -140,7 +145,10 @@ const HorizontalScroller = forwardRef(
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="relative w-full overflow-auto touch-none cursor-grab active:cursor-grabbing" tabIndex={0}
className="relative w-full overflow-auto touch-none cursor-grab active:cursor-grabbing focus:outline-none"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
> >
<div <div
ref={wrapperRef} ref={wrapperRef}

View File

@ -37,10 +37,8 @@ export default function ImageBlurTransition({
return ( return (
<div <div
className={`relative rounded-xl ${className}`} className={`relative rounded-xl w-full h-fit ${className}`}
style={{ style={{
width,
height,
perspective: enableAnimation ? 1000 : 'none', // 只在启用动画时提供 3D 深度 perspective: enableAnimation ? 1000 : 'none', // 只在启用动画时提供 3D 深度
}} }}
> >
@ -50,7 +48,7 @@ export default function ImageBlurTransition({
key={current} key={current}
src={current} src={current}
alt={alt} alt={alt}
className="absolute w-full h-auto object-cover rounded-xl" className="w-full h-auto object-cover rounded-xl"
initial={{ initial={{
opacity: 0, opacity: 0,
filter: 'blur(8px)', filter: 'blur(8px)',
@ -76,7 +74,7 @@ export default function ImageBlurTransition({
<img <img
src={src} src={src}
alt={alt} alt={alt}
className="absolute w-full h-auto object-cover rounded-xl" className="w-full h-auto object-cover rounded-xl"
/> />
)} )}
</div> </div>

View File

@ -1,169 +1,63 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Sparkles, X, Plus, RefreshCw } from 'lucide-react'; import { Sparkles, X, Plus, RefreshCw } from 'lucide-react';
import MainEditor from "./main-editor/MainEditor";
import { cn } from "@/public/lib/utils"; 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 { interface CharacterEditorProps {
initialDescription?: string; className?: string;
onDescriptionChange?: (description: string) => void;
onAttributesChange?: (attributes: CharacterAttribute[]) => void;
onReplaceCharacter?: (url: string) => void;
} }
const mockParse = (text: string): CharacterAttribute[] => { const mockContent = [
// 模拟结构化解析结果 {
return [ type: 'paragraph',
{ key: "age", label: "年龄", value: "20", type: "number" }, content: [
{ key: "gender", label: "性别", value: "女性", type: "select", options: ["男性", "女性", "其他"] }, { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'blue' } },
{ key: "hair", label: "发型", value: "银白短发", type: "text" }, { type: 'text', text: 'Hello, world!' },
{ key: "race", label: "种族", value: "精灵", type: "text" }, { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'red' } },
{ key: "skin", label: "肤色", value: "白皙", type: "text" }, { type: 'text', text: 'Hello, world!' },
{ key: "build", label: "体型", value: "高挑", type: "text" }, { type: 'highlightText', attrs: { text: 'Hello, world!', color: 'green' } },
{ key: "costume", label: "服装", value: "白色连衣裙", type: "text" }, { 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({ export default function CharacterEditor({
initialDescription = "一个银白短发的精灵女性大约20岁肤色白皙身材高挑身着白色连衣裙", className,
onDescriptionChange,
onAttributesChange,
onReplaceCharacter,
}: CharacterEditorProps) { }: CharacterEditorProps) {
const [inputText, setInputText] = useState(initialDescription);
const [isOptimizing, setIsOptimizing] = useState(false); const [isOptimizing, setIsOptimizing] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const [customTags, setCustomTags] = useState<string[]>([]);
const [newTag, setNewTag] = useState("");
const attributesRef = useRef<CharacterAttribute[]>(mockParse(initialDescription));
const contentEditableRef = useRef<HTMLElement>(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 || '<br>')
.join('<div>');
};
const handleSmartPolish = async () => { 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 ( return (
<div className="space-y-2 border border-white/10 relative p-2 rounded-[0.5rem]"> <div className={cn("space-y-2 border border-white/10 relative p-2 rounded-[0.5rem] pb-12", className)}>
{/* 自由输入区域 */} {/* 自由输入区域 */}
<div className="relative"> <MainEditor content={mockContent} />
<ContentEditable
innerRef={contentEditableRef}
html={formatTextToHtml(inputText)}
onChange={handleTextChange}
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
rounded-lg border-unset outline-none pb-12
whitespace-pre-wrap break-words"
placeholder="用自然语言描述角色,比如:一个身穿红袍的精灵女性..."
/>
{/* 智能润色按钮 */}
<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>
{/* 结构化属性标签 */} {/* 智能润色按钮 */}
<div className="flex flex-wrap gap-2"> <motion.button
{attributesRef.current.map((attr) => ( onClick={handleSmartPolish}
<motion.div disabled={isOptimizing}
key={attr.key} className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 rounded-full
bg-white/5 hover:bg-white/10 transition-colors transition-colors text-xs disabled:opacity-50"
border border-white/20 group" whileHover={{ scale: 1.05 }}
whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}
whileTap={{ scale: 0.95 }} >
> <Sparkles className="w-3.5 h-3.5" />
<span className="text-sm text-white/90">{attr.label}</span> <span>{isOptimizing ? "优化中..." : "智能优化"}</span>
<ContentEditable </motion.button>
html={attr.value}
onChange={(e) => 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();
}
}}
/>
</motion.div>
))}
</div>
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; 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 { cn } from '@/public/lib/utils';
import CharacterEditor from './character-editor'; import CharacterEditor from './character-editor';
import ImageBlurTransition from './ImageBlurTransition'; import ImageBlurTransition from './ImageBlurTransition';
@ -68,10 +68,21 @@ export function CharacterTabContent({
const [replacePanelKey, setReplacePanelKey] = useState(0); const [replacePanelKey, setReplacePanelKey] = useState(0);
const [ignoreReplace, setIgnoreReplace] = useState(false); const [ignoreReplace, setIgnoreReplace] = useState(false);
const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false); const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false);
const [isRemindReplacePanelOpen, setIsRemindReplacePanelOpen] = useState(false);
const [selectRoleIndex, setSelectRoleIndex] = useState(0); const [selectRoleIndex, setSelectRoleIndex] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const [enableAnimation, setEnableAnimation] = useState(true); 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) => { const handleReplaceCharacter = (url: string) => {
setEnableAnimation(true); setEnableAnimation(true);
@ -93,17 +104,12 @@ export function CharacterTabContent({
// 取消替换 // 取消替换
const handleCloseReplacePanel = () => { const handleCloseReplacePanel = () => {
setIsReplacePanelOpen(false); setIsReplacePanelOpen(false);
setIgnoreReplace(true);
}; };
const handleChangeRole = (index: number) => { const handleChangeRole = (index: number) => {
if (currentRole.url !== roles[selectRoleIndex].url && !ignoreReplace) { if (currentRole.url !== roles[selectRoleIndex].url && !ignoreReplace) {
// 提示 角色已修改,弹出替换角色面板 // 提示 角色已修改,弹出替换角色面板
if (isReplacePanelOpen) { setIsRemindReplacePanelOpen(true);
setReplacePanelKey(replacePanelKey + 1);
} else {
setIsReplacePanelOpen(true);
}
return; return;
} }
// 重置替换规则 // 重置替换规则
@ -118,9 +124,43 @@ export function CharacterTabContent({
const handleSelectCharacter = (index: number) => { const handleSelectCharacter = (index: number) => {
console.log('index', index); console.log('index', index);
setIsReplaceLibraryOpen(false); setIsReplaceLibraryOpen(false);
setShowAddToLibrary(false);
handleReplaceCharacter('https://c.huiying.video/images/5740cb7c-6e08-478f-9e7c-bca7f78a2bf6.jpg'); 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<HTMLInputElement>) => {
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) { if (!roles || roles.length === 0) {
return ( return (
@ -133,6 +173,14 @@ export function CharacterTabContent({
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* 隐藏的文件输入框 */}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileChange}
/>
{/* 上部分:角色缩略图 */} {/* 上部分:角色缩略图 */}
<motion.div <motion.div
className="space-y-6" className="space-y-6"
@ -187,7 +235,7 @@ export function CharacterTabContent({
src={currentRole.url} src={currentRole.url}
alt={currentRole.name} alt={currentRole.name}
width='100%' width='100%'
height='auto' height='100%'
enableAnimation={enableAnimation} enableAnimation={enableAnimation}
/> />
{/* 应用角色按钮 */} {/* 应用角色按钮 */}
@ -197,7 +245,16 @@ export function CharacterTabContent({
text-white rounded-full backdrop-blur-sm transition-colors z-10" text-white rounded-full backdrop-blur-sm transition-colors z-10"
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={() => setIsReplaceLibraryOpen(true)} onClick={handleUploadClick}
>
<ImageUp className="w-4 h-4" />
</motion.button>
<motion.button
className="p-2 bg-black/50 hover:bg-black/70
text-white rounded-full backdrop-blur-sm transition-colors z-10"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleOpenReplaceLibrary()}
> >
<Library className="w-4 h-4" /> <Library className="w-4 h-4" />
</motion.button> </motion.button>
@ -208,47 +265,7 @@ export function CharacterTabContent({
{/* 右列:角色信息 */} {/* 右列:角色信息 */}
<div className="space-y-4"> <div className="space-y-4">
<CharacterEditor <CharacterEditor
initialDescription={localRole.roleDescription} className="min-h-[calc(100%-4rem)]"
onDescriptionChange={(description) => {
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);
}}
/> />
{/* 重新生成按钮、替换形象按钮 */} {/* 重新生成按钮、替换形象按钮 */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@ -263,7 +280,7 @@ export function CharacterTabContent({
<span>Replace</span> <span>Replace</span>
</motion.button> </motion.button>
<motion.button <motion.button
onClick={() => 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 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" text-blue-500 rounded-lg transition-colors"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
@ -282,13 +299,14 @@ export function CharacterTabContent({
<FloatingGlassPanel <FloatingGlassPanel
open={isReplacePanelOpen} open={isReplacePanelOpen}
width='500px' width='66vw'
r_key={replacePanelKey} r_key={replacePanelKey}
onClose={() => handleCloseReplacePanel()} onClose={() => handleCloseReplacePanel()}
> >
<ReplaceCharacterPanel <ReplaceCharacterPanel
shots={mockShots} shots={mockShots}
character={mockCharacter} character={mockCharacter}
showAddToLibrary={showAddToLibrary}
onClose={() => handleCloseReplacePanel()} onClose={() => handleCloseReplacePanel()}
onConfirm={handleConfirmReplace} onConfirm={handleConfirmReplace}
/> />
@ -300,6 +318,40 @@ export function CharacterTabContent({
setIsReplaceLibraryOpen={setIsReplaceLibraryOpen} setIsReplaceLibraryOpen={setIsReplaceLibraryOpen}
onSelect={handleSelectCharacter} onSelect={handleSelectCharacter}
/> />
{/* 提醒用户角色已修改 是否需要替换 */}
<FloatingGlassPanel
open={isRemindReplacePanelOpen}
width='500px'
clickMaskClose={false}
>
<div className="flex flex-col items-center gap-4 text-white py-4">
<div className="flex items-center gap-3">
<TriangleAlert className="w-6 h-6 text-yellow-400" />
<p className="text-lg font-medium"></p>
</div>
<div className="flex gap-3 mt-2">
<button
onClick={() => handleConfirmGotoReplace()}
data-alt="confirm-replace-button"
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-md transition-colors duration-200 flex items-center gap-2"
>
<ReplaceAll className="w-4 h-4" />
</button>
<button
onClick={() => handleCloseRemindReplacePanel()}
data-alt="ignore-button"
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md transition-colors duration-200 flex items-center gap-2"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</FloatingGlassPanel>
</div> </div>
); );
} }

View File

@ -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 (
<NodeViewWrapper
as="span"
data-alt="highlight-text"
contentEditable={false}
className={`relative inline text-${color}-400 hover:text-${color}-300 transition-colors duration-200`}
>
{text}
{/* 暂时空着 为后续可视化文本预留 */}
</NodeViewWrapper>
)
}
export const HighlightTextExtension = Node.create<HighlightTextOptions>({
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);
},
});

View File

@ -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 (
<EditorContent editor={editor} />
);
}

View File

@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { motion } from 'framer-motion'; 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'; import { cn } from '@/public/lib/utils';
// 定义类型 // 定义类型
@ -42,8 +42,34 @@ export function ReplacePanel({
); );
const [addToLibrary, setAddToLibrary] = useState(false); const [addToLibrary, setAddToLibrary] = useState(false);
const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null); const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null);
const [isAtStart, setIsAtStart] = useState(true);
const [isAtEnd, setIsAtEnd] = useState(false);
const videoRefs = useRef<{ [key: string]: HTMLVideoElement }>({}); const videoRefs = useRef<{ [key: string]: HTMLVideoElement }>({});
const shotsRef = useRef<HTMLDivElement>(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) => { const handleShotToggle = (shotId: string) => {
setSelectedShots(prev => setSelectedShots(prev =>
prev.includes(shotId) prev.includes(shotId)
@ -75,6 +101,24 @@ export function ReplacePanel({
onConfirm(selectedShots, addToLibrary); 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 ( return (
<div className="space-y-2 w-full max-w-5xl"> <div className="space-y-2 w-full max-w-5xl">
{/* 标题 */} {/* 标题 */}
@ -98,9 +142,9 @@ export function ReplacePanel({
</div> </div>
{/* 分镜展示区 */} {/* 分镜展示区 */}
<div className="space-y-2"> <div className="space-y-2 relative">
<div className="text-white/80 text-sm"></div> <div className="text-white/80 text-sm"></div>
<div className="flex gap-4 overflow-x-auto pb-4 hide-scrollbar h-64"> <div className="relative flex gap-4 overflow-x-auto pb-4 hide-scrollbar h-64" ref={shotsRef}>
{shots.map((shot) => ( {shots.map((shot) => (
<motion.div <motion.div
key={shot.id} key={shot.id}
@ -150,6 +194,26 @@ export function ReplacePanel({
</motion.div> </motion.div>
))} ))}
</div> </div>
{/* 左右箭头 */}
<div
className={cn(
"absolute top-1/2 -translate-y-1/2 left-0 w-10 h-10 rounded-full bg-black/50 flex items-center justify-center transition-opacity duration-200",
isAtStart ? "opacity-30 cursor-not-allowed" : "opacity-100 cursor-pointer hover:bg-black/70"
)}
onClick={() => !isAtStart && handleLeftArrowClick()}
>
<ArrowLeft className="w-4 h-4 text-white" />
</div>
<div
className={cn(
"absolute top-1/2 -translate-y-1/2 right-0 w-10 h-10 rounded-full bg-black/50 flex items-center justify-center transition-opacity duration-200",
isAtEnd ? "opacity-30 cursor-not-allowed" : "opacity-100 cursor-pointer hover:bg-black/70"
)}
onClick={() => !isAtEnd && handleRightArrowClick()}
>
<ArrowRight className="w-4 h-4 text-white" />
</div>
</div> </div>
{/* 预览信息 */} {/* 预览信息 */}

View File

@ -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<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;