video-flow-b/components/ui/character-editor.tsx
2025-07-29 21:22:51 +08:00

184 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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;
}
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" },
];
};
export default function CharacterEditor({
initialDescription = "一个银白短发的精灵女性大约20岁肤色白皙身材高挑身着白色连衣裙",
onDescriptionChange,
onAttributesChange,
onReplaceCharacter,
}: 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 pb-12 rounded-[0.5rem]">
{/* 自由输入区域 */}
<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="用自然语言描述角色,比如:一个身穿红袍的精灵女性..."
/>
{/* 智能润色按钮 */}
<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">
{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>
{/* 重新生成按钮 */}
<motion.button
onClick={handleRegenerate}
disabled={isRegenerating}
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
bg-blue-500/10 hover:bg-blue-500/20 text-blue-500 rounded-full
transition-colors text-xs disabled:opacity-50"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<RefreshCw className="w-3.5 h-3.5" />
<span>{isRegenerating ? "生成中..." : "重新生成"}</span>
</motion.button>
</div>
);
}