forked from 77media/video-flow
角色编辑页面调整
This commit is contained in:
parent
250fa8441e
commit
f0844aaf77
@ -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<HTMLDivElement>) => {
|
||||
@ -368,11 +372,14 @@ export function ThumbnailGrid({
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={() => setIsDragging(false)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
>
|
||||
{Number(currentStep) > 2 && taskVideos.length > 0 && Number(currentStep) < 6
|
||||
? renderVideoThumbnails()
|
||||
|
||||
@ -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 }
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -42,6 +42,7 @@ const HorizontalScroller = forwardRef(
|
||||
const dragInstance = useRef<Draggable | null>(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 (
|
||||
<div
|
||||
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
|
||||
ref={wrapperRef}
|
||||
|
||||
@ -37,10 +37,8 @@ export default function ImageBlurTransition({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-xl ${className}`}
|
||||
className={`relative rounded-xl w-full h-fit ${className}`}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
perspective: enableAnimation ? 1000 : 'none', // 只在启用动画时提供 3D 深度
|
||||
}}
|
||||
>
|
||||
@ -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({
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="absolute w-full h-auto object-cover rounded-xl"
|
||||
className="w-full h-auto object-cover rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,125 +1,49 @@
|
||||
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<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 () => {
|
||||
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 (
|
||||
<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">
|
||||
<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="用自然语言描述角色,比如:一个身穿红袍的精灵女性..."
|
||||
/>
|
||||
<MainEditor content={mockContent} />
|
||||
|
||||
{/* 智能润色按钮 */}
|
||||
<motion.button
|
||||
@ -135,35 +59,5 @@ export default function CharacterEditor({
|
||||
<span>{isOptimizing ? "优化中..." : "智能优化"}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* 结构化属性标签 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attributesRef.current.map((attr) => (
|
||||
<motion.div
|
||||
key={attr.key}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full
|
||||
bg-white/5 hover:bg-white/10 transition-colors
|
||||
border border-white/20 group"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<span className="text-sm text-white/90">{attr.label}:</span>
|
||||
<ContentEditable
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<HTMLInputElement>(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<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) {
|
||||
return (
|
||||
@ -133,6 +173,14 @@ export function CharacterTabContent({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 隐藏的文件输入框 */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/* 上部分:角色缩略图 */}
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
@ -187,7 +235,7 @@ export function CharacterTabContent({
|
||||
src={currentRole.url}
|
||||
alt={currentRole.name}
|
||||
width='100%'
|
||||
height='auto'
|
||||
height='100%'
|
||||
enableAnimation={enableAnimation}
|
||||
/>
|
||||
{/* 应用角色按钮 */}
|
||||
@ -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}
|
||||
>
|
||||
<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" />
|
||||
</motion.button>
|
||||
@ -208,47 +265,7 @@ export function CharacterTabContent({
|
||||
{/* 右列:角色信息 */}
|
||||
<div className="space-y-4">
|
||||
<CharacterEditor
|
||||
initialDescription={localRole.roleDescription}
|
||||
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);
|
||||
}}
|
||||
className="min-h-[calc(100%-4rem)]"
|
||||
/>
|
||||
{/* 重新生成按钮、替换形象按钮 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@ -263,7 +280,7 @@ export function CharacterTabContent({
|
||||
<span>Replace</span>
|
||||
</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
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
@ -282,13 +299,14 @@ export function CharacterTabContent({
|
||||
|
||||
<FloatingGlassPanel
|
||||
open={isReplacePanelOpen}
|
||||
width='500px'
|
||||
width='66vw'
|
||||
r_key={replacePanelKey}
|
||||
onClose={() => handleCloseReplacePanel()}
|
||||
>
|
||||
<ReplaceCharacterPanel
|
||||
shots={mockShots}
|
||||
character={mockCharacter}
|
||||
showAddToLibrary={showAddToLibrary}
|
||||
onClose={() => handleCloseReplacePanel()}
|
||||
onConfirm={handleConfirmReplace}
|
||||
/>
|
||||
@ -300,6 +318,40 @@ export function CharacterTabContent({
|
||||
setIsReplaceLibraryOpen={setIsReplaceLibraryOpen}
|
||||
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>
|
||||
);
|
||||
}
|
||||
61
components/ui/main-editor/HighlightText.tsx
Normal file
61
components/ui/main-editor/HighlightText.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
34
components/ui/main-editor/MainEditor.tsx
Normal file
34
components/ui/main-editor/MainEditor.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
@ -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,7 +42,33 @@ export function ReplacePanel({
|
||||
);
|
||||
const [addToLibrary, setAddToLibrary] = useState(false);
|
||||
const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null);
|
||||
const [isAtStart, setIsAtStart] = useState(true);
|
||||
const [isAtEnd, setIsAtEnd] = useState(false);
|
||||
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) => {
|
||||
setSelectedShots(prev =>
|
||||
@ -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 (
|
||||
<div className="space-y-2 w-full max-w-5xl">
|
||||
{/* 标题 */}
|
||||
@ -98,9 +142,9 @@ export function ReplacePanel({
|
||||
</div>
|
||||
|
||||
{/* 分镜展示区 */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 relative">
|
||||
<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) => (
|
||||
<motion.div
|
||||
key={shot.id}
|
||||
@ -150,6 +194,26 @@ export function ReplacePanel({
|
||||
</motion.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>
|
||||
|
||||
{/* 预览信息 */}
|
||||
|
||||
@ -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;
|
||||
Loading…
x
Reference in New Issue
Block a user