10b8379965
新增: - 交互式对话框生成器模块(路由、页面、样式、MorePage入口) - 电商模块取消生成功能(任务追踪/取消按钮/中止逻辑) - 视频服务图片上传支持 Blob/dataURL/远程URL 多种来源 优化: - 电商图片上传修复本地 blob 预览图缺少原始文件的问题 - 视频规划管线错误信息改进 - 生成流程中多处增加中止检查点
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
|
||
|
||
type DialogStyle = "style1" | "style2" | "style3" | "style4";
|
||
|
||
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: "绿色" },
|
||
];
|
||
|
||
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 [backgroundUrl, setBackgroundUrl] = useState("");
|
||
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
|
||
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
|
||
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
||
|
||
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>
|
||
|
||
<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;
|