5b87594e36
- 剧本评测/分辨率提升/数字人/角色迁移/图片工作台/去水印/电商:新增外部拖拽文件上传 - 电商:爆款图复刻上传框支持拖拽+大滚动条,短视频/模特图/详情图滚动条精简回退 - 图片工作台:右侧输出面板移至左侧提示词上方,删除局部重绘遮罩/结果框 - 数字人:生成按钮改为「开始生成」 - 局部重绘:编辑遮罩→编辑页面 - 对话框生成器:新增对话/视频模式、模型/速度/深度选择按钮 - 视频时长默认改为5秒 - 工具箱页面空状态logo统一绿底亮色图标 - 多处CSS滚动条和布局优化
480 lines
19 KiB
TypeScript
480 lines
19 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
|
||
import { ApartmentOutlined, DownOutlined, RobotOutlined, ThunderboltOutlined } from "@ant-design/icons";
|
||
|
||
type DialogStyle = "style1" | "style2" | "style3" | "style4";
|
||
type GenerationMode = "dialog" | "video";
|
||
|
||
interface DialogItem {
|
||
id: number;
|
||
style: DialogStyle;
|
||
x: number;
|
||
y: number;
|
||
text: string;
|
||
color: string;
|
||
confirmed: boolean;
|
||
}
|
||
|
||
interface DragState {
|
||
id: number;
|
||
offsetX: number;
|
||
offsetY: number;
|
||
}
|
||
|
||
const dialogStyles: Array<{
|
||
key: DialogStyle;
|
||
label: string;
|
||
description: string;
|
||
swatchClass: string;
|
||
}> = [
|
||
{ key: "style1", label: "白色圆角对话框", description: "适合浅色说明与标注", swatchClass: "is-white" },
|
||
{ key: "style2", label: "蓝色气泡对话框", description: "适合角色台词与重点提示", swatchClass: "is-blue" },
|
||
{ key: "style3", label: "黄色提示对话框", description: "适合醒目提醒与强调", swatchClass: "is-amber" },
|
||
{ key: "style4", label: "灰色简约对话框", description: "适合信息备注与辅助说明", swatchClass: "is-gray" },
|
||
];
|
||
|
||
const textColorOptions = [
|
||
{ value: "#ffffff", label: "白色" },
|
||
{ value: "#111827", label: "黑色" },
|
||
{ value: "#ef4444", label: "红色" },
|
||
{ value: "#f59e0b", label: "黄色" },
|
||
{ value: "#165dff", label: "蓝色" },
|
||
{ value: "#00ff88", label: "绿色" },
|
||
];
|
||
|
||
const dialogModelOptions = [
|
||
{ id: "gemini", label: "Gemini" },
|
||
{ id: "wanxian", label: "万相" },
|
||
{ id: "deepseek", label: "DeepSeek" },
|
||
];
|
||
|
||
const thinkingSpeedOptions = [
|
||
{ id: "default", label: "默认" },
|
||
{ id: "high", label: "高" },
|
||
{ id: "ultra", label: "急速" },
|
||
];
|
||
|
||
const thinkingDepthOptions = [
|
||
{ id: "default", label: "默认" },
|
||
{ id: "strong", label: "强" },
|
||
{ id: "extreme", label: "极限" },
|
||
];
|
||
|
||
const videoDurationOptions = [
|
||
{ value: "5", label: "5s" },
|
||
{ value: "6", label: "6s" },
|
||
{ value: "7", label: "7s" },
|
||
{ value: "8", label: "8s" },
|
||
{ value: "9", label: "9s" },
|
||
{ value: "10", label: "10s" },
|
||
{ value: "11", label: "11s" },
|
||
{ value: "12", label: "12s" },
|
||
{ value: "13", label: "13s" },
|
||
{ value: "14", label: "14s" },
|
||
{ value: "15", label: "15s" },
|
||
];
|
||
|
||
function DialogGeneratorPage() {
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||
const dragRef = useRef<DragState | null>(null);
|
||
const nextIdRef = useRef(0);
|
||
const controlsRef = useRef<HTMLDivElement>(null);
|
||
const [backgroundUrl, setBackgroundUrl] = useState("");
|
||
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
|
||
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
|
||
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
||
|
||
// ── Generation state ──
|
||
const [generationMode, setGenerationMode] = useState<GenerationMode>("dialog");
|
||
const [dialogModel, setDialogModel] = useState(dialogModelOptions[0].id);
|
||
const [thinkingSpeed, setThinkingSpeed] = useState(thinkingSpeedOptions[0].id);
|
||
const [thinkingDepth, setThinkingDepth] = useState(thinkingDepthOptions[0].id);
|
||
const [videoDuration, setVideoDuration] = useState(videoDurationOptions[0].value);
|
||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) {
|
||
setActiveDropdown(null);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, []);
|
||
|
||
const handleFile = useCallback((file?: File | null) => {
|
||
if (!file || !file.type.startsWith("image/")) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
if (typeof reader.result === "string") {
|
||
setBackgroundUrl(reader.result);
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}, []);
|
||
|
||
const addDialog = useCallback((style: DialogStyle) => {
|
||
nextIdRef.current += 1;
|
||
const id = nextIdRef.current;
|
||
setDialogs((current) => [
|
||
...current,
|
||
{
|
||
id,
|
||
style,
|
||
x: 30 + (id * 25) % 200,
|
||
y: 30 + (id * 20) % 150,
|
||
text: "",
|
||
color: selectedTextColor,
|
||
confirmed: false,
|
||
},
|
||
]);
|
||
}, [selectedTextColor]);
|
||
|
||
const updateDialog = useCallback((id: number, patch: Partial<DialogItem>) => {
|
||
setDialogs((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||
}, []);
|
||
|
||
const deleteDialog = useCallback((id: number) => {
|
||
setDialogs((current) => current.filter((item) => item.id !== id));
|
||
}, []);
|
||
|
||
const startDrag = useCallback((id: number, clientX: number, clientY: number) => {
|
||
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${id}"]`);
|
||
if (!dialogEl) return;
|
||
const rect = dialogEl.getBoundingClientRect();
|
||
dragRef.current = {
|
||
id,
|
||
offsetX: clientX - rect.left,
|
||
offsetY: clientY - rect.top,
|
||
};
|
||
setActiveDragId(id);
|
||
}, []);
|
||
|
||
const moveDrag = useCallback((clientX: number, clientY: number) => {
|
||
const drag = dragRef.current;
|
||
const preview = previewRef.current;
|
||
if (!drag || !preview) return;
|
||
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${drag.id}"]`);
|
||
if (!dialogEl) return;
|
||
|
||
const bounds = preview.getBoundingClientRect();
|
||
const nextX = Math.max(0, Math.min(clientX - drag.offsetX - bounds.left, bounds.width - dialogEl.offsetWidth));
|
||
const nextY = Math.max(0, Math.min(clientY - drag.offsetY - bounds.top, bounds.height - dialogEl.offsetHeight));
|
||
updateDialog(drag.id, { x: nextX, y: nextY });
|
||
}, [updateDialog]);
|
||
|
||
const endDrag = useCallback(() => {
|
||
dragRef.current = null;
|
||
setActiveDragId(null);
|
||
}, []);
|
||
|
||
const handleCanvasMouseMove = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||
moveDrag(event.clientX, event.clientY);
|
||
}, [moveDrag]);
|
||
|
||
const handleCanvasTouchMove = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
|
||
const touch = event.touches[0];
|
||
if (!touch) return;
|
||
moveDrag(touch.clientX, touch.clientY);
|
||
}, [moveDrag]);
|
||
|
||
return (
|
||
<section className="dialog-generator-page page-motion">
|
||
<div className="dialog-generator-shell">
|
||
<aside className="dialog-generator-panel">
|
||
<div className="dialog-generator-heading">
|
||
<span className="dialog-generator-kicker">Interactive Dialog</span>
|
||
<h1>交互式对话框生成器</h1>
|
||
<p>上传背景图,在画面上添加可拖拽、可编辑的文字图层,用于图片标注、剧情分镜和互动内容设计。</p>
|
||
</div>
|
||
|
||
<div className="dialog-generator-section">
|
||
<h2>上传背景图片</h2>
|
||
<button
|
||
type="button"
|
||
className="dialog-generator-drop"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onDrop={(event) => {
|
||
event.preventDefault();
|
||
handleFile(event.dataTransfer.files[0]);
|
||
}}
|
||
>
|
||
<span className="dialog-generator-drop-icon">🖼</span>
|
||
<strong>点击或拖拽图片到此处</strong>
|
||
<small>支持 JPG、PNG、WEBP 格式</small>
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
hidden
|
||
onChange={(event) => handleFile(event.target.files?.[0])}
|
||
/>
|
||
</div>
|
||
|
||
<div className="dialog-generator-section">
|
||
<h2>点击添加文字</h2>
|
||
<p className="dialog-generator-hint">每点一次即在预览区新增一个可编辑文字图层。</p>
|
||
<div className="dialog-generator-color-picker" role="radiogroup" aria-label="文字颜色">
|
||
{textColorOptions.map((item) => (
|
||
<button
|
||
key={item.value}
|
||
type="button"
|
||
className={`dialog-generator-color${selectedTextColor === item.value ? " is-active" : ""}`}
|
||
style={{ "--text-color": item.value } as CSSProperties}
|
||
aria-checked={selectedTextColor === item.value}
|
||
role="radio"
|
||
onClick={() => setSelectedTextColor(item.value)}
|
||
>
|
||
<span />
|
||
<strong>{item.label}</strong>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="dialog-generator-style-list">
|
||
{dialogStyles.map((item) => (
|
||
<button key={item.key} type="button" className="dialog-generator-style" onClick={() => addDialog(item.key)}>
|
||
<span className={`dialog-generator-swatch ${item.swatchClass}`} />
|
||
<span>
|
||
<strong>{item.label}</strong>
|
||
<small>{item.description}</small>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="dialog-generator-section">
|
||
<h2>生成设置</h2>
|
||
<div className="dialog-generator-mode-switch" role="radiogroup" aria-label="生成模式">
|
||
<button
|
||
type="button"
|
||
className={`dialog-generator-mode${generationMode === "dialog" ? " is-active" : ""}`}
|
||
role="radio"
|
||
aria-checked={generationMode === "dialog"}
|
||
onClick={() => setGenerationMode("dialog")}
|
||
>
|
||
对话模式
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`dialog-generator-mode${generationMode === "video" ? " is-active" : ""}`}
|
||
role="radio"
|
||
aria-checked={generationMode === "video"}
|
||
onClick={() => setGenerationMode("video")}
|
||
>
|
||
视频模式
|
||
</button>
|
||
</div>
|
||
|
||
<div className="dialog-generator-controls" ref={controlsRef}>
|
||
{generationMode === "dialog" ? (
|
||
<>
|
||
<div className="dialog-generator-pills">
|
||
<button
|
||
type="button"
|
||
className={`dialog-generator-pill${activeDropdown === "model" ? " is-open" : ""}`}
|
||
onClick={() => setActiveDropdown(activeDropdown === "model" ? null : "model")}
|
||
>
|
||
<RobotOutlined />
|
||
{dialogModelOptions.find((m) => m.id === dialogModel)?.label ?? "模型选择"}
|
||
<DownOutlined />
|
||
</button>
|
||
{activeDropdown === "model" && (
|
||
<div className="dialog-generator-dropdown">
|
||
{dialogModelOptions.map((m) => (
|
||
<button
|
||
key={m.id}
|
||
type="button"
|
||
className={`dialog-generator-dropdown__item${dialogModel === m.id ? " is-active" : ""}`}
|
||
onClick={() => { setDialogModel(m.id); setActiveDropdown(null); }}
|
||
>
|
||
{m.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="dialog-generator-pills">
|
||
<button
|
||
type="button"
|
||
className={`dialog-generator-pill${activeDropdown === "speed" ? " is-open" : ""}`}
|
||
onClick={() => setActiveDropdown(activeDropdown === "speed" ? null : "speed")}
|
||
>
|
||
<ThunderboltOutlined />
|
||
{thinkingSpeedOptions.find((s) => s.id === thinkingSpeed)?.label ?? "思考速度"}
|
||
<DownOutlined />
|
||
</button>
|
||
{activeDropdown === "speed" && (
|
||
<div className="dialog-generator-dropdown">
|
||
{thinkingSpeedOptions.map((s) => (
|
||
<button
|
||
key={s.id}
|
||
type="button"
|
||
className={`dialog-generator-dropdown__item${thinkingSpeed === s.id ? " is-active" : ""}`}
|
||
onClick={() => { setThinkingSpeed(s.id); setActiveDropdown(null); }}
|
||
>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="dialog-generator-pills">
|
||
<button
|
||
type="button"
|
||
className={`dialog-generator-pill${activeDropdown === "depth" ? " is-open" : ""}`}
|
||
onClick={() => setActiveDropdown(activeDropdown === "depth" ? null : "depth")}
|
||
>
|
||
<ApartmentOutlined />
|
||
{thinkingDepthOptions.find((d) => d.id === thinkingDepth)?.label ?? "思考深度"}
|
||
<DownOutlined />
|
||
</button>
|
||
{activeDropdown === "depth" && (
|
||
<div className="dialog-generator-dropdown">
|
||
{thinkingDepthOptions.map((d) => (
|
||
<button
|
||
key={d.id}
|
||
type="button"
|
||
className={`dialog-generator-dropdown__item${thinkingDepth === d.id ? " is-active" : ""}`}
|
||
onClick={() => { setThinkingDepth(d.id); setActiveDropdown(null); }}
|
||
>
|
||
{d.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="dialog-generator-duration">
|
||
<span className="dialog-generator-duration__label">视频时长</span>
|
||
<div className="dialog-generator-duration__options">
|
||
{videoDurationOptions.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
className={`dialog-generator-duration__btn${videoDuration === opt.value ? " is-active" : ""}`}
|
||
onClick={() => setVideoDuration(opt.value)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="dialog-generator-run"
|
||
disabled={isGenerating}
|
||
onClick={() => {
|
||
setIsGenerating(true);
|
||
// TODO: wire to actual generation API
|
||
setTimeout(() => setIsGenerating(false), 2000);
|
||
}}
|
||
>
|
||
{isGenerating ? "生成中..." : "生成"}
|
||
</button>
|
||
</div>
|
||
|
||
<button type="button" className="dialog-generator-clear" onClick={() => setDialogs([])}>
|
||
清空全部文字
|
||
</button>
|
||
</aside>
|
||
|
||
<main className="dialog-generator-preview-card">
|
||
<div className="dialog-generator-preview-head">
|
||
<div>
|
||
<span>Preview</span>
|
||
<h2>预览区域</h2>
|
||
</div>
|
||
<p>拖动文字定位,输入文字后点击确认,确认后只保留文字图层,双击可重新编辑。</p>
|
||
</div>
|
||
|
||
<div
|
||
ref={previewRef}
|
||
className="dialog-generator-preview"
|
||
onMouseMove={handleCanvasMouseMove}
|
||
onMouseUp={endDrag}
|
||
onMouseLeave={endDrag}
|
||
onTouchMove={handleCanvasTouchMove}
|
||
onTouchEnd={endDrag}
|
||
>
|
||
{backgroundUrl ? <div className="dialog-generator-image" style={{ backgroundImage: `url(${backgroundUrl})` }} /> : null}
|
||
{!backgroundUrl ? (
|
||
<div className="dialog-generator-empty">
|
||
<span>🖼</span>
|
||
<p>上传图片后开始编辑</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{dialogs.map((dialog) => (
|
||
<div
|
||
key={dialog.id}
|
||
data-dialog-id={dialog.id}
|
||
className={`dialog-generator-bubble ${dialog.style}${dialog.confirmed ? " is-confirmed" : ""}${activeDragId === dialog.id ? " is-dragging" : ""}`}
|
||
style={{ left: dialog.x, top: dialog.y, "--dialog-text-color": dialog.color } as CSSProperties}
|
||
onMouseDown={(event) => {
|
||
const target = event.target as HTMLElement;
|
||
if (target.closest("textarea,button")) return;
|
||
startDrag(dialog.id, event.clientX, event.clientY);
|
||
event.preventDefault();
|
||
}}
|
||
onTouchStart={(event) => {
|
||
const target = event.target as HTMLElement;
|
||
if (target.closest("textarea,button")) return;
|
||
const touch = event.touches[0];
|
||
if (touch) startDrag(dialog.id, touch.clientX, touch.clientY);
|
||
}}
|
||
onDoubleClick={() => {
|
||
if (dialog.confirmed) updateDialog(dialog.id, { confirmed: false });
|
||
}}
|
||
>
|
||
{!dialog.confirmed ? (
|
||
<button type="button" className="dialog-generator-delete" onClick={() => deleteDialog(dialog.id)} aria-label="删除文字">
|
||
×
|
||
</button>
|
||
) : null}
|
||
{dialog.confirmed ? (
|
||
<div className="dialog-generator-text-display">{dialog.text}</div>
|
||
) : (
|
||
<textarea
|
||
className="dialog-generator-text"
|
||
rows={2}
|
||
placeholder="输入文本..."
|
||
value={dialog.text}
|
||
onChange={(event) => updateDialog(dialog.id, { text: event.target.value })}
|
||
/>
|
||
)}
|
||
{!dialog.confirmed ? (
|
||
<div className="dialog-generator-bubble-bottom">
|
||
<button
|
||
type="button"
|
||
className="dialog-generator-confirm"
|
||
onClick={() => {
|
||
if (dialog.text.trim()) {
|
||
updateDialog(dialog.id, { text: dialog.text.trim(), confirmed: true });
|
||
}
|
||
}}
|
||
>
|
||
✓ 确认
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export default DialogGeneratorPage;
|