forked from 77media/video-flow
214 lines
7.9 KiB
TypeScript
214 lines
7.9 KiB
TypeScript
import { useState, useRef } from "react";
|
||
import { motion } from "framer-motion";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Sparkles, X, Plus } 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;
|
||
}
|
||
|
||
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,
|
||
}: CharacterEditorProps) {
|
||
const [inputText, setInputText] = useState(initialDescription);
|
||
const [isOptimizing, setIsOptimizing] = 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);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{/* 自由输入区域 */}
|
||
<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 border-white/10 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||
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>
|
||
|
||
{/* 自定义标签区域 */}
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap gap-2">
|
||
{customTags.map((tag) => (
|
||
<motion.div
|
||
key={tag}
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
className="group flex items-center gap-1 px-3 py-1.5 bg-white/5
|
||
rounded-full hover:bg-white/10 transition-colors"
|
||
>
|
||
<span className="text-sm text-white/90">{tag}</span>
|
||
<button
|
||
onClick={() => {
|
||
setCustomTags(tags => tags.filter(t => t !== tag));
|
||
setInputText((text: string) => text.replace(new RegExp(`[,。]?${tag}[,。]?`), ""));
|
||
}}
|
||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<X className="w-3 h-3 text-white/70" />
|
||
</button>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={newTag}
|
||
onChange={(e) => setNewTag(e.target.value)}
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter' && newTag.trim()) {
|
||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||
setNewTag("");
|
||
}
|
||
}}
|
||
placeholder="添加自定义标签..."
|
||
className="flex-1 px-3 py-2 bg-white/5 border-none rounded-lg text-sm
|
||
focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-white/30"
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
if (newTag.trim()) {
|
||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||
setNewTag("");
|
||
}
|
||
}}
|
||
className="p-2 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||
>
|
||
<Plus className="w-5 h-5 text-white/70" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|