ai-chatbot/components/canvas/RealtimeCanvasDialog.tsx
Your Name 0fa4a14ac6
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: refine detail generation copy strategy and canvas layout
2026-02-22 04:08:59 +08:00

956 lines
34 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import * as fal from "@fal-ai/serverless-client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import {
Eraser,
Loader2,
Paintbrush,
RefreshCw,
Send,
Sparkles,
Trash2,
Wifi,
WifiOff,
Minimize2,
Maximize2,
Download,
Image as ImageIcon,
MoreHorizontal,
Undo2,
Redo2,
X,
} from "lucide-react";
import { toast } from "sonner";
type RealtimeConfig = {
transport: {
throttleInterval: number;
maxBuffering: number;
};
image: {
maxInputResolution: number;
jpegQuality: number;
};
model: {
id: string;
defaults: {
steps: number;
scheduleMu: number;
feedback: number;
imageSize: "square" | "square_hd";
};
};
};
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
type RealtimeCanvasDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
initialImageUrl?: string | null;
onApplyResult: (payload: { imageUrl: string; prompt: string }) => void;
};
const FALLBACK_PROMPT = "将草图渲染为高质感产品设计图,保持结构和主轮廓一致。";
const defaultRealtimeConfig: RealtimeConfig = {
transport: {
throttleInterval: 100,
maxBuffering: 2,
},
image: {
maxInputResolution: 1024,
jpegQuality: 0.55,
},
model: {
id: "fal-ai/flux-2/klein/realtime",
defaults: {
steps: 3,
scheduleMu: 2.3,
feedback: 0.95,
imageSize: "square",
},
},
};
function buildImageDataUrl(image: any) {
if (!image) return "";
if (typeof image.url === "string" && image.url.trim()) {
return image.url;
}
if (typeof image.content === "string" && image.content.trim()) {
if (image.content.startsWith("data:")) {
return image.content;
}
const contentType = image.content_type || "image/jpeg";
return `data:${contentType};base64,${image.content}`;
}
return "";
}
async function canvasToJpegDataUri(
canvas: HTMLCanvasElement,
maxInputResolution: number,
jpegQuality: number
) {
const sourceWidth = canvas.width;
const sourceHeight = canvas.height;
const longestSide = Math.max(sourceWidth, sourceHeight);
const scale = longestSide > maxInputResolution ? maxInputResolution / longestSide : 1;
const exportWidth = Math.max(1, Math.round(sourceWidth * scale));
const exportHeight = Math.max(1, Math.round(sourceHeight * scale));
const exportCanvas = document.createElement("canvas");
exportCanvas.width = exportWidth;
exportCanvas.height = exportHeight;
const exportCtx = exportCanvas.getContext("2d");
if (!exportCtx) {
throw new Error("无法初始化导出画布");
}
exportCtx.fillStyle = "#d8d8db";
exportCtx.fillRect(0, 0, exportWidth, exportHeight);
exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight);
const blob = await new Promise<Blob | null>((resolve) => {
exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality);
});
if (!blob) {
throw new Error("导出图片失败");
}
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取导出图片失败"));
reader.readAsDataURL(blob);
});
}
export function RealtimeCanvasDialog({
open,
onOpenChange,
initialImageUrl,
onApplyResult,
}: RealtimeCanvasDialogProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const connectionRef = useRef<any>(null);
const pointerDownRef = useRef(false);
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
const pendingFlushTimerRef = useRef<number | null>(null);
const flushInFlightRef = useRef(false);
const dirtyRef = useRef(false);
const seqRef = useRef(0);
const requestSeqMapRef = useRef<Map<string, number>>(new Map());
const requestStartMapRef = useRef<Map<string, number>>(new Map());
const lastRenderedSeqRef = useRef(0);
const metricsQueueRef = useRef<any[]>([]);
const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const hasLoadedInitialImageRef = useRef(false);
// History Management
const historyRef = useRef<ImageData[]>([]);
const historyIndexRef = useRef(-1);
const cursorRef = useRef<HTMLDivElement>(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [config, setConfig] = useState<RealtimeConfig>(defaultRealtimeConfig);
const [status, setStatus] = useState<RealtimeStatus>("idle");
const [statusMessage, setStatusMessage] = useState("等待连接");
const [prompt, setPrompt] = useState(FALLBACK_PROMPT);
const [previewUrl, setPreviewUrl] = useState("");
const [isApplying, setIsApplying] = useState(false);
const [brushMode, setBrushMode] = useState<"brush" | "eraser">("brush");
const [brushSize, setBrushSize] = useState(6);
const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState<number>(
defaultRealtimeConfig.transport.throttleInterval
);
const [refreshToken, setRefreshToken] = useState(0);
const statusBadge = useMemo(() => {
if (status === "live") return { label: "实时中", className: "bg-emerald-50 text-emerald-700" };
if (status === "connecting") {
return { label: "连接中", className: "bg-blue-50 text-blue-700" };
}
if (status === "error") return { label: "异常", className: "bg-rose-50 text-rose-700" };
return { label: "待启动", className: "bg-zinc-100 text-zinc-600" };
}, [status]);
const pushMetric = useCallback((event: any) => {
metricsQueueRef.current.push(event);
if (metricsQueueRef.current.length > 120) {
metricsQueueRef.current = metricsQueueRef.current.slice(-80);
}
}, []);
const flushMetrics = useCallback(async () => {
if (!open || metricsQueueRef.current.length === 0) return;
const events = metricsQueueRef.current.splice(0, metricsQueueRef.current.length);
try {
await fetch("/api/realtime/metrics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: sessionIdRef.current,
events,
}),
});
} catch {
metricsQueueRef.current.unshift(...events.slice(-20));
}
}, [open]);
const saveHistory = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
// If we are not at the end of history, truncate future history
if (historyIndexRef.current < historyRef.current.length - 1) {
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
historyRef.current.push(imageData);
historyIndexRef.current = historyRef.current.length - 1;
// Limit history size
if (historyRef.current.length > 50) {
historyRef.current.shift();
historyIndexRef.current--;
}
setCanUndo(historyIndexRef.current > 0);
setCanRedo(false);
}, []);
const flushFrame = useCallback(
async (force = false) => {
if (!open) return;
if (!connectionRef.current) return;
if (flushInFlightRef.current) return;
if (!dirtyRef.current && !force) return;
const canvas = canvasRef.current;
if (!canvas) return;
flushInFlightRef.current = true;
const seq = seqRef.current + 1;
seqRef.current = seq;
const requestId = `${sessionIdRef.current}-${seq}`;
requestSeqMapRef.current.set(requestId, seq);
requestStartMapRef.current.set(requestId, Date.now());
try {
const imageDataUri = await canvasToJpegDataUri(
canvas,
config.image.maxInputResolution,
config.image.jpegQuality
);
dirtyRef.current = false;
setStatus("connecting");
setStatusMessage("正在请求实时生成...");
connectionRef.current.send({
request_id: requestId,
image_url: imageDataUri,
prompt: prompt.trim() || FALLBACK_PROMPT,
image_size: config.model.defaults.imageSize,
num_inference_steps: config.model.defaults.steps,
schedule_mu: config.model.defaults.scheduleMu,
output_feedback_strength: config.model.defaults.feedback,
});
pushMetric({
name: "frame_send",
ts: Date.now(),
seq,
});
} catch (error) {
console.error("[RealtimeCanvas] flush frame failed:", error);
setStatus("error");
setStatusMessage(error instanceof Error ? error.message : "发送失败");
} finally {
flushInFlightRef.current = false;
}
},
[config, open, prompt, pushMetric]
);
const scheduleFlush = useCallback(
(force = false) => {
dirtyRef.current = true;
if (force) {
if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current);
pendingFlushTimerRef.current = null;
}
void flushFrame(true);
return;
}
if (pendingFlushTimerRef.current) return;
pendingFlushTimerRef.current = window.setTimeout(() => {
pendingFlushTimerRef.current = null;
void flushFrame();
}, adaptiveThrottleMs);
},
[adaptiveThrottleMs, flushFrame]
);
const undo = useCallback(() => {
if (historyIndexRef.current <= 0) return;
historyIndexRef.current--;
const imageData = historyRef.current[historyIndexRef.current];
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx && imageData) {
ctx.putImageData(imageData, 0, 0);
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
scheduleFlush(true);
}
}, [scheduleFlush]);
const redo = useCallback(() => {
if (historyIndexRef.current >= historyRef.current.length - 1) return;
historyIndexRef.current++;
const imageData = historyRef.current[historyIndexRef.current];
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx && imageData) {
ctx.putImageData(imageData, 0, 0);
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
scheduleFlush(true);
}
}, [scheduleFlush]);
const resetCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "#d8d8db";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
// Clear history and save initial state
historyRef.current = [];
historyIndexRef.current = -1;
saveHistory();
}, [saveHistory]);
const drawInitialImage = useCallback(async () => {
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const image = new Image();
image.crossOrigin = "anonymous";
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("加载初始图片失败"));
image.src = initialImageUrl;
});
const scale = Math.min(canvas.width / image.width, canvas.height / image.height);
const drawWidth = image.width * scale;
const drawHeight = image.height * scale;
const offsetX = (canvas.width - drawWidth) / 2;
const offsetY = (canvas.height - drawHeight) / 2;
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
hasLoadedInitialImageRef.current = true;
dirtyRef.current = true;
saveHistory(); // Save initial image state
}, [initialImageUrl, open, saveHistory]);
const initializeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const wrap = canvasWrapRef.current;
if (!canvas || !wrap) return;
const rect = wrap.getBoundingClientRect();
const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.max(640, Math.floor(rect.width * dpr));
canvas.height = Math.max(480, Math.floor(rect.height * dpr));
canvas.style.width = `${Math.floor(rect.width)}px`;
canvas.style.height = `${Math.floor(rect.height)}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
resetCanvas();
hasLoadedInitialImageRef.current = false;
}, [resetCanvas]);
const getCanvasPoint = useCallback((event: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}, []);
const drawStroke = useCallback(
(from: { x: number; y: number }, to: { x: number; y: number }) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
if (brushMode === "eraser") {
ctx.globalCompositeOperation = "destination-out";
ctx.strokeStyle = "rgba(0,0,0,1)";
ctx.lineWidth = brushSize * 1.45;
} else {
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = "#18181b";
ctx.lineWidth = brushSize;
}
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
ctx.restore();
},
[brushMode, brushSize]
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
const point = getCanvasPoint(event);
if (!point) return;
pointerDownRef.current = true;
lastPointRef.current = point;
event.currentTarget.setPointerCapture(event.pointerId);
},
[getCanvasPoint]
);
const handlePointerLeave = useCallback(() => {
if (cursorRef.current) {
cursorRef.current.style.opacity = '0';
}
pointerDownRef.current = false;
lastPointRef.current = null;
scheduleFlush(true);
}, [scheduleFlush]);
const handlePointerEnter = useCallback(() => {
if (cursorRef.current) {
cursorRef.current.style.opacity = '1';
}
}, []);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
// Update cursor
const point = getCanvasPoint(event);
if (point && cursorRef.current) {
cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`;
cursorRef.current.style.width = `${brushSize}px`;
cursorRef.current.style.height = `${brushSize}px`;
}
if (!pointerDownRef.current) return;
if (!point || !lastPointRef.current) return;
drawStroke(lastPointRef.current, point);
lastPointRef.current = point;
scheduleFlush();
},
[drawStroke, getCanvasPoint, scheduleFlush, brushSize]
);
const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
pointerDownRef.current = false;
lastPointRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
saveHistory(); // Save state after stroke
scheduleFlush(true);
},
[scheduleFlush, saveHistory]
);
// Shortcuts
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Undo/Redo
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
}
// Brush Size
if (e.key === '[') {
setBrushSize(prev => Math.max(2, prev - 2));
}
if (e.key === ']') {
setBrushSize(prev => Math.min(50, prev + 2));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, undo, redo]);
const uploadPreviewToCanvas = useCallback(async () => {
if (!previewUrl) {
toast.error("请先完成一帧实时预览");
return;
}
setIsApplying(true);
try {
const blob = await fetch(previewUrl).then((res) => res.blob());
const formData = new FormData();
const filename = `realtime-canvas-${Date.now()}.jpg`;
formData.append("file", new File([blob], filename, { type: blob.type || "image/jpeg" }));
const response = await fetch("/api/files/upload", {
method: "POST",
body: formData,
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传到画布失败");
}
onApplyResult({
imageUrl: String(result.url),
prompt: prompt.trim() || FALLBACK_PROMPT,
});
toast.success("已发送到画布");
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "发送到画布失败");
} finally {
setIsApplying(false);
}
}, [onApplyResult, onOpenChange, previewUrl, prompt]);
useEffect(() => {
if (!open) return;
initializeCanvas();
void drawInitialImage().then(() => {
scheduleFlush(true);
});
const onResize = () => initializeCanvas();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, [drawInitialImage, initializeCanvas, open, scheduleFlush]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setStatus("connecting");
setStatusMessage("正在初始化实时连接...");
const start = async () => {
try {
const configResponse = await fetch("/api/realtime/config", { cache: "no-store" });
const configJson = (await configResponse.json().catch(() => ({}))) as Partial<RealtimeConfig>;
if (!configResponse.ok) {
throw new Error((configJson as any)?.message || "无法加载实时配置");
}
if (cancelled) return;
const resolvedConfig: RealtimeConfig = {
transport: {
throttleInterval:
configJson.transport?.throttleInterval ||
defaultRealtimeConfig.transport.throttleInterval,
maxBuffering:
configJson.transport?.maxBuffering || defaultRealtimeConfig.transport.maxBuffering,
},
image: {
maxInputResolution:
configJson.image?.maxInputResolution || defaultRealtimeConfig.image.maxInputResolution,
jpegQuality: configJson.image?.jpegQuality || defaultRealtimeConfig.image.jpegQuality,
},
model: {
id: configJson.model?.id || defaultRealtimeConfig.model.id,
defaults: {
steps: configJson.model?.defaults?.steps || defaultRealtimeConfig.model.defaults.steps,
scheduleMu:
configJson.model?.defaults?.scheduleMu ||
defaultRealtimeConfig.model.defaults.scheduleMu,
feedback:
configJson.model?.defaults?.feedback ||
defaultRealtimeConfig.model.defaults.feedback,
imageSize:
configJson.model?.defaults?.imageSize ||
defaultRealtimeConfig.model.defaults.imageSize,
},
},
};
setConfig(resolvedConfig);
setAdaptiveThrottleMs(resolvedConfig.transport.throttleInterval);
fal.config({
proxyUrl: "/api/fal/proxy",
});
const connection = fal.realtime.connect(resolvedConfig.model.id, {
connectionKey: `canvas-realtime-${sessionIdRef.current}-${refreshToken}`,
throttleInterval: resolvedConfig.transport.throttleInterval,
maxBuffering: resolvedConfig.transport.maxBuffering,
onResult: (result: any) => {
const requestId = String(result?.request_id || "");
const seqFromRequest = requestSeqMapRef.current.get(requestId) || 0;
const seq = Math.max(seqFromRequest, Number(result?.seq || 0));
if (seq > 0 && seq < lastRenderedSeqRef.current) {
return;
}
const latestImage = Array.isArray(result?.images)
? result.images[result.images.length - 1]
: null;
const nextPreviewUrl = buildImageDataUrl(latestImage);
if (!nextPreviewUrl) return;
lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq);
setPreviewUrl(nextPreviewUrl);
setStatus("live");
setStatusMessage("实时渲染中");
if (requestId) {
const startedAt = requestStartMapRef.current.get(requestId);
if (startedAt) {
pushMetric({
name: "frame_result",
ts: Date.now(),
seq,
latencyMs: Date.now() - startedAt,
});
}
requestStartMapRef.current.delete(requestId);
requestSeqMapRef.current.delete(requestId);
}
},
onError: (error: any) => {
const errorMessage =
error?.body?.message || error?.message || "实时连接异常,请稍后重试";
setStatus("error");
setStatusMessage(String(errorMessage));
if (Number(error?.status) === 429) {
setAdaptiveThrottleMs((prev) => Math.min(240, prev + 40));
}
pushMetric({
name: "frame_error",
ts: Date.now(),
message: String(errorMessage),
});
},
});
if (cancelled) {
connection.close();
return;
}
connectionRef.current = connection;
setStatus("connecting");
setStatusMessage("连接成功,等待首帧...");
scheduleFlush(true);
} catch (error) {
console.error("[RealtimeCanvas] init failed:", error);
setStatus("error");
setStatusMessage(error instanceof Error ? error.message : "实时初始化失败");
}
};
void start();
const metricTimer = window.setInterval(() => {
void flushMetrics();
}, 5000);
return () => {
cancelled = true;
if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current);
pendingFlushTimerRef.current = null;
}
window.clearInterval(metricTimer);
void flushMetrics();
connectionRef.current?.close?.();
connectionRef.current = null;
setStatus("idle");
setStatusMessage("等待连接");
pointerDownRef.current = false;
lastPointRef.current = null;
};
}, [flushMetrics, open, pushMetric, refreshToken, scheduleFlush]);
const forceReconnect = useCallback(() => {
connectionRef.current?.close?.();
connectionRef.current = null;
setStatus("connecting");
setStatusMessage("正在重连...");
setRefreshToken((prev) => prev + 1);
}, []);
const clearSketch = useCallback(() => {
resetCanvas();
dirtyRef.current = true;
scheduleFlush(true);
}, [resetCanvas, scheduleFlush]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-background p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]">
{/* Top Header - Minimal */}
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-between px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/80 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm">
<span className="font-semibold text-sm">Realtime Canvas</span>
<div className="h-4 w-[1px] bg-border" />
<span className={cn("flex items-center gap-1.5 text-xs font-medium", statusBadge.className, "bg-transparent p-0")}>
<div className={cn("h-2 w-2 rounded-full", status === "live" ? "bg-emerald-500 animate-pulse" : status === "error" ? "bg-rose-500" : "bg-zinc-400")} />
{statusBadge.label}
</span>
</div>
<div className="pointer-events-auto flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full bg-background/50 hover:bg-background/80 backdrop-blur-md text-foreground/80 hover:text-foreground transition-colors"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Main Canvas Area - Split View */}
<div className="relative flex h-full w-full bg-[#f4f4f5] dark:bg-[#09090b]">
{/* Left: Input Canvas */}
<div className="relative flex-1 flex flex-col items-center justify-center overflow-hidden border-r border-border/10">
<div ref={canvasWrapRef} className="relative h-full w-full flex items-center justify-center bg-white dark:bg-[#18181b]">
<div className="absolute inset-0 pattern-grid-lg opacity-[0.03] pointer-events-none" />
<div className="relative">
<canvas
ref={canvasRef}
className="touch-none shadow-sm block cursor-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
{/* Custom Brush Cursor */}
<div
ref={cursorRef}
className="pointer-events-none absolute top-0 left-0 rounded-full border border-zinc-500/50 bg-zinc-500/10 backdrop-invert transition-opacity duration-75 z-10"
style={{
width: brushSize,
height: brushSize,
opacity: 0,
willChange: 'transform, width, height'
}}
/>
</div>
</div>
<div className="absolute top-4 left-4 pointer-events-none z-10">
<span className="text-xs font-medium text-zinc-400 bg-black/5 px-2 py-1 rounded-md backdrop-blur-sm">INPUT</span>
</div>
</div>
{/* Right: Output Preview */}
<div className="relative flex-1 flex flex-col items-center justify-center overflow-hidden bg-zinc-50 dark:bg-[#0c0c0e]">
<div className="relative h-full w-full flex items-center justify-center">
{previewUrl ? (
<img src={previewUrl} alt="实时预览" className="max-h-full max-w-full object-contain shadow-sm" />
) : (
<div className="flex flex-col items-center justify-center text-zinc-400 gap-3">
{status === "connecting" ? (
<>
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
<span className="text-sm">Connecting...</span>
</>
) : (
<>
<Sparkles className="h-8 w-8 opacity-20" />
<span className="text-sm">Start drawing to generate</span>
</>
)}
</div>
)}
</div>
<div className="absolute top-4 right-4 pointer-events-none">
<span className="text-xs font-medium text-zinc-400 bg-black/5 px-2 py-1 rounded-md backdrop-blur-sm">OUTPUT</span>
</div>
</div>
</div>
{/* Floating Toolbar - Left (Tools) */}
<div className="absolute left-6 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-20 pointer-events-none">
<div className="pointer-events-auto flex flex-col gap-2 p-2 bg-background/90 backdrop-blur-xl border border-border/50 shadow-xl rounded-2xl">
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={undo}
disabled={!canUndo}
title="Undo (Cmd+Z)"
>
<Undo2 className="h-5 w-5 opacity-70" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={redo}
disabled={!canRedo}
title="Redo (Cmd+Shift+Z)"
>
<Redo2 className="h-5 w-5 opacity-70" />
</Button>
<div className="h-[1px] bg-border/50 mx-2 my-1" />
<Button
size="icon"
variant={brushMode === "brush" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "brush" && "bg-primary text-primary-foreground shadow-sm")}
onClick={() => setBrushMode("brush")}
title="Brush"
>
<Paintbrush className="h-5 w-5" />
</Button>
<Button
size="icon"
variant={brushMode === "eraser" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "eraser" && "bg-primary text-primary-foreground shadow-sm")}
onClick={() => setBrushMode("eraser")}
>
<Eraser className="h-5 w-5" />
</Button>
<div className="h-[1px] bg-border/50 mx-2 my-1" />
<div className="flex flex-col items-center gap-2 py-2">
<span className="text-[10px] font-medium text-muted-foreground">{brushSize}px</span>
<Slider
orientation="vertical"
value={[brushSize]}
min={2}
max={50}
step={1}
onValueChange={([val]) => setBrushSize(val)}
className="h-24 w-4"
/>
</div>
<div className="h-[1px] bg-border/50 mx-2 my-1" />
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-destructive/10 hover:text-destructive"
onClick={clearSketch}
>
<Trash2 className="h-5 w-5" />
</Button>
</div>
</div>
{/* Floating Toolbar - Bottom (Prompt) */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20 pointer-events-none">
<div className="pointer-events-auto flex flex-col gap-2 p-1.5 bg-background/80 backdrop-blur-xl border border-border/50 shadow-2xl rounded-[24px]">
<div className="flex items-center gap-2 pl-2 pr-1.5 py-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="h-4 w-4" />
</div>
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onBlur={() => scheduleFlush(true)}
placeholder="Describe what you want to create..."
className="flex-1 h-10 border-0 bg-transparent focus-visible:ring-0 px-2 text-base shadow-none"
/>
<Button
size="sm"
className="h-9 rounded-xl px-4 font-medium"
onClick={() => scheduleFlush(true)}
disabled={status === 'connecting'}
>
{status === 'connecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate'}
</Button>
</div>
</div>
<div className="text-center mt-2">
<span className="text-[10px] text-white/50 bg-black/40 px-3 py-1 rounded-full backdrop-blur-sm">
{statusMessage} • Latency: {adaptiveThrottleMs}ms
</span>
</div>
</div>
{/* Floating Toolbar - Right (Actions) */}
<div className="absolute right-6 top-6 flex flex-col gap-3 z-20 pointer-events-none">
<div className="pointer-events-auto flex flex-col gap-2">
<Button
className="h-10 px-4 rounded-full shadow-lg bg-primary text-primary-foreground hover:bg-primary/90"
onClick={uploadPreviewToCanvas}
disabled={!previewUrl || isApplying}
>
{isApplying ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
Send to Canvas
</Button>
<Button
variant="secondary"
className="h-10 px-4 rounded-full shadow-md bg-background/80 backdrop-blur-md hover:bg-background"
onClick={forceReconnect}
>
<RefreshCw className="mr-2 h-4 w-4" />
Reconnect
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}