ai-chatbot/components/canvas/RealtimeCanvasDialog.tsx
Your Name d4ff127fb3
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
polish realtime canvas toolbar layout
2026-03-08 00:37:44 +08:00

2924 lines
104 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.

"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
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 { buildImageProxyUrl } from "@/lib/image-delivery";
import {
FolderOpen,
Eraser,
Layers3,
Loader2,
Paintbrush,
Plus,
Square,
Circle as CircleIcon,
Slash,
Upload,
ChevronDown,
ChevronUp,
RefreshCw,
Sparkles,
Save,
Trash2,
Download,
Undo2,
Redo2,
Send,
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;
cfgScale: number;
strength: number;
imageSize: "square" | "square_hd";
};
};
behavior?: {
strictSketch?: boolean;
};
};
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
type ImageLayer = {
id: string;
name: string;
src: string;
x: number;
y: number;
width: number;
height: number;
baseWidth: number;
baseHeight: number;
};
type HistorySnapshot = {
imageData: ImageData;
imageLayers: ImageLayer[];
canvasBackground: string;
};
type SavedRealtimeProject = {
id: string;
name: string;
updatedAt: string;
prompt: string;
brushColor: string;
brushSize: number;
canvasBackground: string;
imageLayers: ImageLayer[];
drawingLayerDataUrl: string;
previewUrl?: string;
};
type RealtimeCanvasDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
initialImageUrl?: string | null;
onApplyResult: (payload: { imageUrl: string; prompt: string }) => void;
standalone?: boolean;
};
const FALLBACK_PROMPT =
"严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。";
const CANVAS_BACKGROUND = "#ffffff";
const DEFAULT_BRUSH_COLOR = "#18181b";
const DRAW_LAYER_ID = "__draw_layer__";
const CANVAS_BACKGROUND_PRESETS = ["#ffffff", "#f4f4f5", "#fdf2f8", "#ecfeff", "#f0fdf4"] as const;
const REALTIME_PROJECTS_STORAGE_KEY = "ai-chatbot:realtime-canvas-projects";
function cloneImageLayers(layers: ImageLayer[]) {
return layers.map((layer) => ({ ...layer }));
}
function readRealtimeProjectsFromStorage() {
if (typeof window === "undefined") return [] as SavedRealtimeProject[];
try {
const raw = window.localStorage.getItem(REALTIME_PROJECTS_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as SavedRealtimeProject[]) : [];
} catch {
return [];
}
}
function writeRealtimeProjectsToStorage(projects: SavedRealtimeProject[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(REALTIME_PROJECTS_STORAGE_KEY, JSON.stringify(projects));
}
const defaultRealtimeConfig: RealtimeConfig = {
transport: {
throttleInterval: 100,
maxBuffering: 2,
},
image: {
maxInputResolution: 1024,
jpegQuality: 0.55,
},
model: {
id: "flux-2-klein-4b",
defaults: {
steps: 3,
cfgScale: 7,
strength: 0.32,
imageSize: "square",
},
},
behavior: {
strictSketch: true,
},
};
function isShapeMode(mode: "brush" | "eraser" | "rect" | "circle" | "line") {
return mode === "rect" || mode === "circle" || mode === "line";
}
function createSessionSeed() {
return Math.floor(Math.random() * 2147483647);
}
function imageDataHasVisiblePixels(imageData: ImageData) {
const pixels = imageData.data;
for (let index = 3; index < pixels.length; index += 4) {
if (pixels[index] > 0) return true;
}
return false;
}
async function canvasToJpegDataUri(
canvas: HTMLCanvasElement,
maxInputResolution: number,
jpegQuality: number,
backgroundColor: string,
options?: {
autoCrop?: boolean;
}
): Promise<{ dataUri: string; width: number; height: number }> {
type CropBox = {
x: number;
y: number;
width: number;
height: number;
foregroundRatio: number;
};
const parseColor = (rawColor: string) => {
const tempCanvas = document.createElement("canvas");
tempCanvas.width = 1;
tempCanvas.height = 1;
const tempCtx = tempCanvas.getContext("2d");
if (!tempCtx) {
return { r: 255, g: 255, b: 255 };
}
tempCtx.fillStyle = rawColor;
const normalized = String(tempCtx.fillStyle || "").trim().toLowerCase();
if (normalized.startsWith("#")) {
const hex = normalized.slice(1);
if (hex.length === 3) {
return {
r: Number.parseInt(hex[0] + hex[0], 16),
g: Number.parseInt(hex[1] + hex[1], 16),
b: Number.parseInt(hex[2] + hex[2], 16),
};
}
if (hex.length >= 6) {
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
};
}
}
const rgbMatch = normalized.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
return {
r: Number(rgbMatch[1]),
g: Number(rgbMatch[2]),
b: Number(rgbMatch[3]),
};
}
return { r: 255, g: 255, b: 255 };
};
const findForegroundBox = (): CropBox | null => {
const sourceWidth = canvas.width;
const sourceHeight = canvas.height;
const longestSide = Math.max(sourceWidth, sourceHeight);
const analysisScale = longestSide > 320 ? 320 / longestSide : 1;
const analysisWidth = Math.max(48, Math.round(sourceWidth * analysisScale));
const analysisHeight = Math.max(48, Math.round(sourceHeight * analysisScale));
const analysisCanvas = document.createElement("canvas");
analysisCanvas.width = analysisWidth;
analysisCanvas.height = analysisHeight;
const analysisCtx = analysisCanvas.getContext("2d", { willReadFrequently: true });
if (!analysisCtx) return null;
analysisCtx.fillStyle = backgroundColor;
analysisCtx.fillRect(0, 0, analysisWidth, analysisHeight);
analysisCtx.drawImage(canvas, 0, 0, analysisWidth, analysisHeight);
const imageData = analysisCtx.getImageData(0, 0, analysisWidth, analysisHeight);
const data = imageData.data;
const totalPixels = analysisWidth * analysisHeight;
const mask = new Uint8Array(totalPixels);
const bg = parseColor(backgroundColor);
const tolerance = 18;
let minX = analysisWidth;
let minY = analysisHeight;
let maxX = -1;
let maxY = -1;
let foregroundPixels = 0;
for (let y = 0; y < analysisHeight; y += 1) {
for (let x = 0; x < analysisWidth; x += 1) {
const pixelIndex = y * analysisWidth + x;
const offset = pixelIndex * 4;
const alpha = data[offset + 3];
if (alpha < 16) continue;
const dr = Math.abs(data[offset] - bg.r);
const dg = Math.abs(data[offset + 1] - bg.g);
const db = Math.abs(data[offset + 2] - bg.b);
const isForeground = dr > tolerance || dg > tolerance || db > tolerance;
if (!isForeground) continue;
mask[pixelIndex] = 1;
foregroundPixels += 1;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (foregroundPixels <= 0 || maxX < minX || maxY < minY) {
return null;
}
const visited = new Uint8Array(totalPixels);
const queue = new Uint32Array(totalPixels);
const minComponentArea = Math.max(9, Math.floor(foregroundPixels * 0.0015));
let bestArea = 0;
let bestMinX = minX;
let bestMinY = minY;
let bestMaxX = maxX;
let bestMaxY = maxY;
for (let idx = 0; idx < totalPixels; idx += 1) {
if (!mask[idx] || visited[idx]) continue;
let head = 0;
let tail = 0;
queue[tail] = idx;
tail += 1;
visited[idx] = 1;
let area = 0;
let componentMinX = analysisWidth;
let componentMinY = analysisHeight;
let componentMaxX = -1;
let componentMaxY = -1;
while (head < tail) {
const current = queue[head];
head += 1;
const x = current % analysisWidth;
const y = Math.floor(current / analysisWidth);
area += 1;
if (x < componentMinX) componentMinX = x;
if (x > componentMaxX) componentMaxX = x;
if (y < componentMinY) componentMinY = y;
if (y > componentMaxY) componentMaxY = y;
const startY = Math.max(0, y - 1);
const endY = Math.min(analysisHeight - 1, y + 1);
const startX = Math.max(0, x - 1);
const endX = Math.min(analysisWidth - 1, x + 1);
for (let ny = startY; ny <= endY; ny += 1) {
for (let nx = startX; nx <= endX; nx += 1) {
const neighbor = ny * analysisWidth + nx;
if (!mask[neighbor] || visited[neighbor]) continue;
visited[neighbor] = 1;
queue[tail] = neighbor;
tail += 1;
}
}
}
if (area >= minComponentArea && area > bestArea) {
bestArea = area;
bestMinX = componentMinX;
bestMinY = componentMinY;
bestMaxX = componentMaxX;
bestMaxY = componentMaxY;
}
}
if (bestArea > 0) {
minX = bestMinX;
minY = bestMinY;
maxX = bestMaxX;
maxY = bestMaxY;
} else {
bestArea = foregroundPixels;
}
const rowCounts = new Uint32Array(analysisHeight);
const colCounts = new Uint32Array(analysisWidth);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const idx = y * analysisWidth + x;
if (!mask[idx]) continue;
rowCounts[y] += 1;
colCounts[x] += 1;
}
}
const trimPixels = Math.max(0, Math.floor(bestArea * 0.008));
if (trimPixels > 0) {
let cumulative = 0;
for (let x = minX; x <= maxX; x += 1) {
cumulative += colCounts[x];
if (cumulative > trimPixels) {
minX = x;
break;
}
}
cumulative = 0;
for (let x = maxX; x >= minX; x -= 1) {
cumulative += colCounts[x];
if (cumulative > trimPixels) {
maxX = x;
break;
}
}
cumulative = 0;
for (let y = minY; y <= maxY; y += 1) {
cumulative += rowCounts[y];
if (cumulative > trimPixels) {
minY = y;
break;
}
}
cumulative = 0;
for (let y = maxY; y >= minY; y -= 1) {
cumulative += rowCounts[y];
if (cumulative > trimPixels) {
maxY = y;
break;
}
}
}
const scaleX = sourceWidth / analysisWidth;
const scaleY = sourceHeight / analysisHeight;
const boxX = Math.max(0, Math.floor(minX * scaleX));
const boxY = Math.max(0, Math.floor(minY * scaleY));
const boxRight = Math.min(sourceWidth, Math.ceil((maxX + 1) * scaleX));
const boxBottom = Math.min(sourceHeight, Math.ceil((maxY + 1) * scaleY));
const boxWidth = Math.max(1, boxRight - boxX);
const boxHeight = Math.max(1, boxBottom - boxY);
const foregroundRatio = Math.min(1, bestArea / totalPixels);
const paddingRatio = foregroundRatio < 0.08 ? 0.34 : foregroundRatio < 0.2 ? 0.28 : 0.22;
const padding = Math.max(16, Math.floor(Math.max(boxWidth, boxHeight) * paddingRatio));
const x = Math.max(0, boxX - padding);
const y = Math.max(0, boxY - padding);
const right = Math.min(sourceWidth, boxRight + padding);
const bottom = Math.min(sourceHeight, boxBottom + padding);
return {
x,
y,
width: Math.max(1, right - x),
height: Math.max(1, bottom - y),
foregroundRatio,
};
};
const sourceWidth = canvas.width;
const sourceHeight = canvas.height;
const cropBox = options?.autoCrop ? findForegroundBox() : null;
const drawSourceX = cropBox?.x || 0;
const drawSourceY = cropBox?.y || 0;
const drawSourceWidth = cropBox?.width || sourceWidth;
const drawSourceHeight = cropBox?.height || sourceHeight;
const autoCropEnabled = Boolean(options?.autoCrop && cropBox);
const longestSide = Math.max(drawSourceWidth, drawSourceHeight);
const scale = longestSide > maxInputResolution ? maxInputResolution / longestSide : 1;
const exportWidth = autoCropEnabled
? Math.max(512, maxInputResolution)
: Math.max(1, Math.round(drawSourceWidth * scale));
const exportHeight = autoCropEnabled
? Math.max(512, maxInputResolution)
: Math.max(1, Math.round(drawSourceHeight * scale));
const exportCanvas = document.createElement("canvas");
exportCanvas.width = exportWidth;
exportCanvas.height = exportHeight;
const exportCtx = exportCanvas.getContext("2d");
if (!exportCtx) {
throw new Error("无法初始化导出画布");
}
exportCtx.fillStyle = backgroundColor;
exportCtx.fillRect(0, 0, exportWidth, exportHeight);
if (autoCropEnabled) {
const fitRatio = (cropBox?.foregroundRatio || 0) < 0.12 ? 0.9 : 0.94;
const fitScale = Math.min((exportWidth * fitRatio) / drawSourceWidth, (exportHeight * fitRatio) / drawSourceHeight);
const targetWidth = Math.max(1, Math.round(drawSourceWidth * fitScale));
const targetHeight = Math.max(1, Math.round(drawSourceHeight * fitScale));
const targetX = Math.round((exportWidth - targetWidth) / 2);
const targetY = Math.round((exportHeight - targetHeight) / 2);
exportCtx.imageSmoothingEnabled = true;
exportCtx.imageSmoothingQuality = "high";
exportCtx.drawImage(
canvas,
drawSourceX,
drawSourceY,
drawSourceWidth,
drawSourceHeight,
targetX,
targetY,
targetWidth,
targetHeight
);
if ((cropBox?.foregroundRatio || 0) < 0.28) {
exportCtx.save();
exportCtx.globalAlpha = 0.22;
exportCtx.drawImage(
canvas,
drawSourceX,
drawSourceY,
drawSourceWidth,
drawSourceHeight,
targetX,
targetY,
targetWidth,
targetHeight
);
exportCtx.restore();
}
} else {
exportCtx.drawImage(
canvas,
drawSourceX,
drawSourceY,
drawSourceWidth,
drawSourceHeight,
0,
0,
exportWidth,
exportHeight
);
}
const blob = await new Promise<Blob | null>((resolve) => {
exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality);
});
if (!blob) {
throw new Error("导出图片失败");
}
const dataUri = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取导出图片失败"));
reader.readAsDataURL(blob);
});
return {
dataUri,
width: exportWidth,
height: exportHeight,
};
}
type FlushMode = "preview" | "refine";
type NormalizedRegion = {
x: number;
y: number;
width: number;
height: number;
};
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}
function mergeRegion(
current: NormalizedRegion | null,
next: NormalizedRegion
): NormalizedRegion {
if (!current) return next;
const x1 = Math.min(current.x, next.x);
const y1 = Math.min(current.y, next.y);
const x2 = Math.max(current.x + current.width, next.x + next.width);
const y2 = Math.max(current.y + current.height, next.y + next.height);
return {
x: clamp01(x1),
y: clamp01(y1),
width: clamp01(x2 - x1),
height: clamp01(y2 - y1),
};
}
function toNormalizedRegion(
canvas: HTMLCanvasElement,
rect: {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
): NormalizedRegion | null {
const bounds = canvas.getBoundingClientRect();
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const x = clamp01(rect.minX / width);
const y = clamp01(rect.minY / height);
const right = clamp01(rect.maxX / width);
const bottom = clamp01(rect.maxY / height);
const regionWidth = right - x;
const regionHeight = bottom - y;
if (regionWidth <= 0 || regionHeight <= 0) return null;
return { x, y, width: regionWidth, height: regionHeight };
}
async function createMaskDataUri(
width: number,
height: number,
region: NormalizedRegion
) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, width);
canvas.height = Math.max(1, height);
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("无法初始化遮罩画布");
}
const basePad = Math.max(12, Math.floor(Math.max(width, height) * 0.02));
let x = Math.floor(region.x * width) - basePad;
let y = Math.floor(region.y * height) - basePad;
let regionWidth = Math.ceil(region.width * width) + basePad * 2;
let regionHeight = Math.ceil(region.height * height) + basePad * 2;
const minPatch = Math.max(40, Math.floor(Math.max(width, height) * 0.1));
if (regionWidth < minPatch) {
const delta = Math.ceil((minPatch - regionWidth) / 2);
x -= delta;
regionWidth += delta * 2;
}
if (regionHeight < minPatch) {
const delta = Math.ceil((minPatch - regionHeight) / 2);
y -= delta;
regionHeight += delta * 2;
}
x = Math.max(0, x);
y = Math.max(0, y);
const right = Math.min(width, x + regionWidth);
const bottom = Math.min(height, y + regionHeight);
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(x, y, Math.max(1, right - x), Math.max(1, bottom - y));
return canvas.toDataURL("image/png");
}
export function RealtimeCanvasDialog({
open,
onOpenChange,
initialImageUrl,
onApplyResult,
standalone = false,
}: RealtimeCanvasDialogProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const imageLayerCanvasRef = useRef<HTMLCanvasElement | null>(null);
const previewCanvasRef = useRef<HTMLCanvasElement | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const connectionRef = useRef<any>(null);
const pointerDownRef = useRef(false);
const draggingLayerIdRef = useRef<string | null>(null);
const layerDragOriginRef = useRef<{
pointerX: number;
pointerY: number;
layerX: number;
layerY: number;
} | null>(null);
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
const shapeStartPointRef = useRef<{ x: number; y: number } | null>(null);
const pendingFlushTimerRef = useRef<number | null>(null);
const flushInFlightRef = useRef(false);
const dirtyRef = useRef(false);
const dirtyRegionRef = useRef<NormalizedRegion | null>(null);
const draftVersionRef = useRef(0);
const pendingFlushModeRef = useRef<FlushMode | null>(null);
const pendingFlushForceRef = useRef(false);
const latencyEmaRef = useRef<number | null>(null);
const baseThrottleMsRef = useRef<number>(defaultRealtimeConfig.transport.throttleInterval);
const pumpFlushRef = useRef<(() => void) | null>(null);
const warningMessageRef = useRef("");
const seqRef = useRef(0);
const requestStartMapRef = useRef<Map<string, number>>(new Map());
const lastRenderedSeqRef = useRef(0);
const layerRenderSeqRef = useRef(0);
const metricsQueueRef = useRef<any[]>([]);
const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const seedRef = useRef<number>(createSessionSeed());
const hasLoadedInitialImageRef = useRef(false);
const canvasBackgroundRef = useRef(CANVAS_BACKGROUND);
const imageLayersRef = useRef<ImageLayer[]>([]);
const colorInputRef = useRef<HTMLInputElement | null>(null);
const layerObjectUrlsRef = useRef<Set<string>>(new Set());
const drawingHasContentRef = useRef(false);
const dragDepthRef = useRef(0);
// History Management
const historyRef = useRef<HistorySnapshot[]>([]);
const historyIndexRef = useRef(-1);
const cursorRef = useRef<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(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 [brushMode, setBrushMode] = useState<"brush" | "eraser" | "rect" | "circle" | "line">("brush");
const [brushColor, setBrushColor] = useState<string>(DEFAULT_BRUSH_COLOR);
const [canvasBackground, setCanvasBackground] = useState<string>(canvasBackgroundRef.current);
const [imageLayers, setImageLayers] = useState<ImageLayer[]>([]);
const [selectedLayerId, setSelectedLayerId] = useState<string>(DRAW_LAYER_ID);
const [brushSize, setBrushSize] = useState(6);
const [savedProjects, setSavedProjects] = useState<SavedRealtimeProject[]>([]);
const [projectPanelOpen, setProjectPanelOpen] = useState(false);
const [isDragImportActive, setIsDragImportActive] = useState(false);
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 selectedImageLayer = useMemo(
() => imageLayers.find((layer) => layer.id === selectedLayerId) || null,
[imageLayers, selectedLayerId]
);
const selectedImageLayerScale = useMemo(() => {
if (!selectedImageLayer) return 1;
const baseWidth = selectedImageLayer.baseWidth || selectedImageLayer.width || 1;
return selectedImageLayer.width / baseWidth;
}, [selectedImageLayer]);
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]);
useEffect(() => {
imageLayersRef.current = imageLayers;
}, [imageLayers]);
const revokeAllLayerObjectUrls = useCallback(() => {
for (const url of layerObjectUrlsRef.current) {
URL.revokeObjectURL(url);
}
layerObjectUrlsRef.current.clear();
}, []);
const clearDrawingCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawingHasContentRef.current = false;
}, []);
const loadRenderableImage = useCallback((src: string) => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
const resolvedSrc = /^https?:\/\//i.test(src) ? buildImageProxyUrl(src) : src;
image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("图层图片加载失败"));
image.src = resolvedSrc;
});
}, []);
const drawImageLayersToContext = useCallback(
async (
ctx: CanvasRenderingContext2D,
width: number,
height: number,
options?: { renderSeq?: number }
) => {
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = canvasBackgroundRef.current;
ctx.fillRect(0, 0, width, height);
for (const layer of imageLayersRef.current) {
try {
const image = await loadRenderableImage(layer.src);
if (options?.renderSeq && options.renderSeq !== layerRenderSeqRef.current) {
ctx.restore();
return false;
}
ctx.drawImage(image, layer.x, layer.y, layer.width, layer.height);
} catch (error) {
console.error("[RealtimeCanvas] layer render failed:", error);
}
}
ctx.restore();
return true;
},
[loadRenderableImage]
);
const renderImageLayers = useCallback(async () => {
const layerCanvas = imageLayerCanvasRef.current;
if (!layerCanvas) return;
const ctx = layerCanvas.getContext("2d");
if (!ctx) return;
const renderSeq = layerRenderSeqRef.current + 1;
layerRenderSeqRef.current = renderSeq;
await drawImageLayersToContext(ctx, layerCanvas.width, layerCanvas.height, { renderSeq });
}, [drawImageLayersToContext]);
const buildCompositeCanvas = useCallback(async () => {
const drawingCanvas = canvasRef.current;
if (!drawingCanvas) return null;
const compositeCanvas = document.createElement("canvas");
compositeCanvas.width = drawingCanvas.width;
compositeCanvas.height = drawingCanvas.height;
const compositeCtx = compositeCanvas.getContext("2d");
if (!compositeCtx) return null;
const didRenderLayers = await drawImageLayersToContext(
compositeCtx,
compositeCanvas.width,
compositeCanvas.height
);
if (!didRenderLayers) {
return null;
}
compositeCtx.drawImage(drawingCanvas, 0, 0);
return compositeCanvas;
}, [drawImageLayersToContext]);
const createPlacedLayer = useCallback(
async (params: { src: string; name: string }) => {
const image = await loadRenderableImage(params.src);
const canvas = canvasRef.current;
if (!canvas) {
throw new Error("画布未初始化");
}
const rect = canvas.getBoundingClientRect();
const canvasWidth = Math.max(1, rect.width);
const canvasHeight = Math.max(1, rect.height);
const maxWidth = canvasWidth * 0.82;
const maxHeight = canvasHeight * 0.82;
const scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1);
const drawWidth = Math.max(24, image.width * scale);
const drawHeight = Math.max(24, image.height * scale);
const offsetX = (canvasWidth - drawWidth) / 2;
const offsetY = (canvasHeight - drawHeight) / 2;
return {
id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: params.name,
src: params.src,
x: offsetX,
y: offsetY,
width: drawWidth,
height: drawHeight,
baseWidth: drawWidth,
baseHeight: drawHeight,
} satisfies ImageLayer;
},
[loadRenderableImage]
);
const findLayerAtPoint = useCallback((point: { x: number; y: number }) => {
for (let index = imageLayersRef.current.length - 1; index >= 0; index -= 1) {
const layer = imageLayersRef.current[index];
const withinX = point.x >= layer.x && point.x <= layer.x + layer.width;
const withinY = point.y >= layer.y && point.y <= layer.y + layer.height;
if (withinX && withinY) {
return layer;
}
}
return null;
}, []);
const updateLayerPosition = useCallback((layerId: string, x: number, y: number) => {
const nextLayers = imageLayersRef.current.map((layer) =>
layer.id === layerId ? { ...layer, x, y } : layer
);
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
return nextLayers;
}, []);
const scaleImageLayer = useCallback((layerId: string, nextScale: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const canvasWidth = Math.max(1, rect.width);
const canvasHeight = Math.max(1, rect.height);
const nextLayers = imageLayersRef.current.map((layer) => {
if (layer.id !== layerId) return layer;
const baseWidth = layer.baseWidth || layer.width;
const baseHeight = layer.baseHeight || layer.height;
const normalizedScale = Math.min(3, Math.max(0.2, nextScale));
const targetWidth = Math.min(canvasWidth, Math.max(24, baseWidth * normalizedScale));
const targetHeight = Math.min(canvasHeight, Math.max(24, baseHeight * normalizedScale));
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const maxX = Math.max(0, canvasWidth - targetWidth);
const maxY = Math.max(0, canvasHeight - targetHeight);
return {
...layer,
width: targetWidth,
height: targetHeight,
x: Math.min(maxX, Math.max(0, centerX - targetWidth / 2)),
y: Math.min(maxY, Math.max(0, centerY - targetHeight / 2)),
};
});
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
return nextLayers;
}, []);
const exportDrawingLayerDataUrl = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return "";
return canvas.toDataURL("image/png");
}, []);
const drawingCanvasHasContent = useCallback((accurate = false) => {
if (!accurate) {
return drawingHasContentRef.current;
}
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d", { willReadFrequently: true });
if (!canvas || !ctx) return false;
try {
const hasContent = imageDataHasVisiblePixels(
ctx.getImageData(0, 0, canvas.width, canvas.height)
);
drawingHasContentRef.current = hasContent;
return hasContent;
} catch {
return drawingHasContentRef.current;
}
}, []);
const hasRenderableInput = useCallback(
(accurate = false) =>
imageLayersRef.current.length > 0 || drawingCanvasHasContent(accurate),
[drawingCanvasHasContent]
);
const fileToDataUrl = useCallback((file: File) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(file);
});
}, []);
const saveHistory = useCallback((layersOverride?: ImageLayer[], backgroundOverride?: string) => {
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);
}
try {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
drawingHasContentRef.current = imageDataHasVisiblePixels(imageData);
historyRef.current.push({
imageData,
imageLayers: cloneImageLayers(layersOverride || imageLayersRef.current),
canvasBackground: backgroundOverride || canvasBackgroundRef.current,
});
historyIndexRef.current = historyRef.current.length - 1;
} catch (error) {
console.warn("[RealtimeCanvas] saveHistory skipped:", error);
return;
}
// Limit history size
if (historyRef.current.length > 50) {
historyRef.current.shift();
historyIndexRef.current--;
}
setCanUndo(historyIndexRef.current > 0);
setCanRedo(false);
}, []);
const markCanvasDirty = useCallback((region?: NormalizedRegion | null) => {
dirtyRef.current = true;
draftVersionRef.current += 1;
if (!region) {
dirtyRegionRef.current = { x: 0, y: 0, width: 1, height: 1 };
return;
}
dirtyRegionRef.current = mergeRegion(dirtyRegionRef.current, region);
}, []);
const markCanvasDirtyRect = useCallback(
(rect: { minX: number; minY: number; maxX: number; maxY: number }) => {
const canvas = canvasRef.current;
if (!canvas) return;
const region = toNormalizedRegion(canvas, rect);
if (!region) return;
markCanvasDirty(region);
},
[markCanvasDirty]
);
const markCanvasDirtyStroke = useCallback(
(from: { x: number; y: number }, to: { x: number; y: number }, lineWidth: number) => {
const pad = Math.max(6, Math.ceil(lineWidth * 1.2));
markCanvasDirtyRect({
minX: Math.min(from.x, to.x) - pad,
minY: Math.min(from.y, to.y) - pad,
maxX: Math.max(from.x, to.x) + pad,
maxY: Math.max(from.y, to.y) + pad,
});
},
[markCanvasDirtyRect]
);
useEffect(() => {
if (!open) return;
void renderImageLayers();
}, [canvasBackground, imageLayers, open, renderImageLayers]);
useEffect(() => {
return () => {
revokeAllLayerObjectUrls();
};
}, [revokeAllLayerObjectUrls]);
const flushFrame = useCallback(
async (mode: FlushMode, force = false) => {
if (!open) return;
if (!connectionRef.current) return;
if (flushInFlightRef.current) return;
if (!dirtyRef.current && !force) return;
if (!hasRenderableInput(true)) {
dirtyRef.current = false;
dirtyRegionRef.current = null;
setStatus("idle");
setStatusMessage("画布为空,先上传图片或绘制内容");
return;
}
const compositeCanvas = await buildCompositeCanvas();
if (!compositeCanvas) return;
flushInFlightRef.current = true;
const seq = seqRef.current + 1;
seqRef.current = seq;
const requestId = `${sessionIdRef.current}-${seq}`;
requestStartMapRef.current.set(requestId, Date.now());
const draftVersionAtSend = draftVersionRef.current;
const dirtyRegionAtSend = dirtyRegionRef.current
? { ...dirtyRegionRef.current }
: null;
try {
const exportedImage = await canvasToJpegDataUri(
compositeCanvas,
config.image.maxInputResolution,
config.image.jpegQuality,
canvasBackgroundRef.current,
{ autoCrop: false }
);
const imageDataUri = exportedImage.dataUri;
const shouldUseMask =
Boolean(dirtyRegionAtSend) &&
mode !== "refine" &&
(dirtyRegionAtSend?.width || 0) * (dirtyRegionAtSend?.height || 0) < 0.92;
const maskImageUrl =
shouldUseMask && dirtyRegionAtSend
? await createMaskDataUri(
exportedImage.width,
exportedImage.height,
dirtyRegionAtSend
)
: undefined;
const activeConnection = connectionRef.current;
if (!activeConnection) {
requestStartMapRef.current.delete(requestId);
setStatus("connecting");
setStatusMessage("连接重建中,正在重试...");
return;
}
setStatus("connecting");
setStatusMessage("正在请求实时生成...");
const response = await fetch("/api/realtime/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
requestId,
imageUrl: imageDataUri,
prompt: prompt.trim() || FALLBACK_PROMPT,
seed: seedRef.current,
strictSketch: config.behavior?.strictSketch !== false,
imageSize: config.model.defaults.imageSize,
steps: config.model.defaults.steps,
cfgScale: config.model.defaults.cfgScale,
strength: config.model.defaults.strength,
qualityProfile: mode,
maskImageUrl,
roi: dirtyRegionAtSend || undefined,
}),
});
const payload = (await response.json().catch(() => ({}))) as {
imageUrl?: string;
imageDataURI?: string;
cost?: number;
transport?: string;
warning?: string;
message?: string;
error?: string;
};
if (!response.ok) {
throw new Error(payload.message || payload.error || "实时生成失败");
}
const nextPreviewUrl = String(payload.imageDataURI || payload.imageUrl || "");
if (!nextPreviewUrl) {
throw new Error("实时生成未返回图像");
}
const startedAt = requestStartMapRef.current.get(requestId) || Date.now();
const latencyMs = Date.now() - startedAt;
requestStartMapRef.current.delete(requestId);
if (seq > 0 && seq < lastRenderedSeqRef.current) {
return;
}
lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq);
setPreviewUrl(nextPreviewUrl);
setStatus("live");
setStatusMessage(
payload.transport === "ws" ? "实时渲染中(WS)" : "实时渲染中(HTTP)"
);
const upstreamWarning = String(payload.warning || "").trim();
if (upstreamWarning && upstreamWarning !== warningMessageRef.current) {
warningMessageRef.current = upstreamWarning;
toast.warning(upstreamWarning);
}
if (draftVersionAtSend === draftVersionRef.current) {
dirtyRef.current = false;
dirtyRegionRef.current = null;
}
const previousLatency = latencyEmaRef.current;
const nextLatency =
previousLatency === null ? latencyMs : previousLatency * 0.72 + latencyMs * 0.28;
latencyEmaRef.current = nextLatency;
const computedThrottle = Math.round(
Math.max(baseThrottleMsRef.current, Math.min(520, nextLatency * 0.45))
);
setAdaptiveThrottleMs((prev) =>
Math.abs(prev - computedThrottle) >= 8 ? computedThrottle : prev
);
pushMetric({
name: "frame_send",
ts: Date.now(),
seq,
});
pushMetric({
name: "frame_result",
ts: Date.now(),
seq,
latencyMs,
queueDepth: Number(payload.cost || 0),
qualityProfile: mode,
hasMask: Boolean(maskImageUrl),
transport: payload.transport || "unknown",
});
} catch (error) {
requestStartMapRef.current.delete(requestId);
console.error("[RealtimeCanvas] flush frame failed:", error);
setStatus("error");
setStatusMessage(error instanceof Error ? error.message : "发送失败");
} finally {
flushInFlightRef.current = false;
if (pendingFlushModeRef.current) {
if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current);
pendingFlushTimerRef.current = null;
}
const shouldDelay = pendingFlushModeRef.current === "preview";
if (shouldDelay) {
pendingFlushTimerRef.current = window.setTimeout(() => {
pendingFlushTimerRef.current = null;
pumpFlushRef.current?.();
}, adaptiveThrottleMs);
} else {
pumpFlushRef.current?.();
}
}
}
},
[adaptiveThrottleMs, buildCompositeCanvas, config, hasRenderableInput, open, prompt, pushMetric]
);
const pumpFlush = useCallback(() => {
if (flushInFlightRef.current) return;
const mode = pendingFlushModeRef.current;
const force = pendingFlushForceRef.current;
if (!mode) return;
pendingFlushModeRef.current = null;
pendingFlushForceRef.current = false;
void flushFrame(mode, force);
}, [flushFrame]);
const scheduleFlush = useCallback(
(force = false, markDirty = true, mode?: FlushMode) => {
if (markDirty) {
markCanvasDirty(null);
}
const nextMode: FlushMode = mode || (force ? "refine" : "preview");
if (pendingFlushModeRef.current === "refine" || nextMode === "refine") {
pendingFlushModeRef.current = "refine";
} else {
pendingFlushModeRef.current = "preview";
}
pendingFlushForceRef.current = pendingFlushForceRef.current || force;
if (!dirtyRef.current && !pendingFlushForceRef.current) {
pendingFlushModeRef.current = null;
pendingFlushForceRef.current = false;
return;
}
if (force || pendingFlushModeRef.current === "refine") {
if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current);
pendingFlushTimerRef.current = null;
}
pumpFlush();
return;
}
if (pendingFlushTimerRef.current) return;
pendingFlushTimerRef.current = window.setTimeout(() => {
pendingFlushTimerRef.current = null;
pumpFlush();
}, adaptiveThrottleMs);
},
[adaptiveThrottleMs, markCanvasDirty, pumpFlush]
);
const importFilesAsLayers = useCallback(
async (files: File[]) => {
if (files.length === 0) return 0;
const invalidFile = files.find((file) => !file.type.startsWith("image/"));
if (invalidFile) {
throw new Error("请上传图片文件");
}
const nextLayerBatch: ImageLayer[] = [];
for (const file of files) {
const dataUrl = await fileToDataUrl(file);
const layer = await createPlacedLayer({
src: dataUrl,
name: file.name || "粘贴图片",
});
nextLayerBatch.push(layer);
}
const nextLayers = [...imageLayersRef.current, ...nextLayerBatch];
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
const newestLayer = nextLayerBatch[nextLayerBatch.length - 1];
if (newestLayer) {
setSelectedLayerId(newestLayer.id);
}
saveHistory(nextLayers);
markCanvasDirty(null);
scheduleFlush(true);
return nextLayerBatch.length;
},
[createPlacedLayer, fileToDataUrl, markCanvasDirty, saveHistory, scheduleFlush]
);
const triggerGenerate = useCallback(() => {
if (!hasRenderableInput(true)) {
setStatus("idle");
setStatusMessage("画布为空,先上传图片或绘制内容");
toast.error("请先上传图片或绘制内容");
return;
}
scheduleFlush(true);
}, [hasRenderableInput, scheduleFlush]);
const refreshSavedProjects = useCallback(() => {
const projects = readRealtimeProjectsFromStorage().sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
setSavedProjects(projects);
return projects;
}, []);
const loadDrawingLayerFromDataUrl = useCallback(async (dataUrl: string) => {
if (!dataUrl) {
clearDrawingCanvas();
drawingHasContentRef.current = false;
return;
}
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
clearDrawingCanvas();
const image = await loadRenderableImage(dataUrl);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
drawingHasContentRef.current = true;
}, [clearDrawingCanvas, loadRenderableImage]);
const openSavedProject = useCallback(
async (projectId: string) => {
const project = readRealtimeProjectsFromStorage().find((item) => item.id === projectId);
if (!project) {
toast.error("未找到项目");
return;
}
canvasBackgroundRef.current = project.canvasBackground || CANVAS_BACKGROUND;
setCanvasBackground(project.canvasBackground || CANVAS_BACKGROUND);
const nextLayers = cloneImageLayers(project.imageLayers || []);
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
setSelectedLayerId(nextLayers[0]?.id || DRAW_LAYER_ID);
setBrushColor(project.brushColor || DEFAULT_BRUSH_COLOR);
setBrushSize(project.brushSize || 6);
setPrompt(project.prompt || FALLBACK_PROMPT);
setPreviewUrl(project.previewUrl || "");
await loadDrawingLayerFromDataUrl(project.drawingLayerDataUrl || "");
saveHistory(nextLayers, project.canvasBackground || CANVAS_BACKGROUND);
setProjectPanelOpen(false);
markCanvasDirty(null);
scheduleFlush(true);
toast.success("项目已打开");
},
[loadDrawingLayerFromDataUrl, markCanvasDirty, saveHistory, scheduleFlush]
);
const deleteSavedProject = useCallback((projectId: string) => {
const nextProjects = readRealtimeProjectsFromStorage().filter((item) => item.id !== projectId);
writeRealtimeProjectsToStorage(nextProjects);
setSavedProjects(nextProjects);
toast.success("项目已删除");
}, []);
const saveCurrentProject = useCallback(async () => {
if (!hasRenderableInput(true)) {
toast.error("空白画布无需保存");
return;
}
const existingProjects = readRealtimeProjectsFromStorage();
const projectId = `rtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const compositeCanvas = await buildCompositeCanvas();
const project: SavedRealtimeProject = {
id: projectId,
name:
prompt.trim().slice(0, 24) ||
`实时项目 ${new Date().toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}`,
updatedAt: new Date().toISOString(),
prompt: prompt.trim() || FALLBACK_PROMPT,
brushColor,
brushSize,
canvasBackground: canvasBackgroundRef.current,
imageLayers: cloneImageLayers(imageLayersRef.current),
drawingLayerDataUrl: exportDrawingLayerDataUrl(),
previewUrl: previewUrl || compositeCanvas?.toDataURL("image/jpeg", 0.72) || "",
};
const nextProjects = [project, ...existingProjects].slice(0, 30);
writeRealtimeProjectsToStorage(nextProjects);
setSavedProjects(nextProjects);
setProjectPanelOpen(true);
toast.success("项目已保存");
}, [
brushColor,
brushSize,
buildCompositeCanvas,
exportDrawingLayerDataUrl,
hasRenderableInput,
previewUrl,
prompt,
]);
const scheduleFlushRef = useRef(scheduleFlush);
const flushMetricsRef = useRef(flushMetrics);
useEffect(() => {
scheduleFlushRef.current = scheduleFlush;
}, [scheduleFlush]);
useEffect(() => {
pumpFlushRef.current = pumpFlush;
}, [pumpFlush]);
useEffect(() => {
flushMetricsRef.current = flushMetrics;
}, [flushMetrics]);
useEffect(() => {
if (!open) return;
refreshSavedProjects();
}, [open, refreshSavedProjects]);
useEffect(() => {
if (!open) return;
const handlePaste = async (event: ClipboardEvent) => {
const imageFiles = Array.from(event.clipboardData?.files || []).filter((file) =>
file.type.startsWith("image/")
);
if (imageFiles.length === 0) return;
event.preventDefault();
try {
const importedCount = await importFilesAsLayers(imageFiles);
if (importedCount > 0) {
toast.success(`已粘贴 ${importedCount} 张图片`);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "粘贴图片失败");
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [importFilesAsLayers, open]);
const clearPreviewCanvas = useCallback(() => {
const previewCanvas = previewCanvasRef.current;
if (!previewCanvas) return;
const previewCtx = previewCanvas.getContext("2d");
if (!previewCtx) return;
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
}, []);
const undo = useCallback(() => {
if (historyIndexRef.current <= 0) return;
historyIndexRef.current--;
const snapshot = historyRef.current[historyIndexRef.current];
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx && snapshot) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(snapshot.imageData, 0, 0);
drawingHasContentRef.current = imageDataHasVisiblePixels(snapshot.imageData);
canvasBackgroundRef.current = snapshot.canvasBackground;
setCanvasBackground(snapshot.canvasBackground);
setImageLayers(cloneImageLayers(snapshot.imageLayers));
setSelectedLayerId((current) =>
current !== DRAW_LAYER_ID && snapshot.imageLayers.some((layer) => layer.id === current)
? current
: DRAW_LAYER_ID
);
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 snapshot = historyRef.current[historyIndexRef.current];
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx && snapshot) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(snapshot.imageData, 0, 0);
drawingHasContentRef.current = imageDataHasVisiblePixels(snapshot.imageData);
canvasBackgroundRef.current = snapshot.canvasBackground;
setCanvasBackground(snapshot.canvasBackground);
setImageLayers(cloneImageLayers(snapshot.imageLayers));
setSelectedLayerId((current) =>
current !== DRAW_LAYER_ID && snapshot.imageLayers.some((layer) => layer.id === current)
? current
: DRAW_LAYER_ID
);
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
scheduleFlush(true);
}
}, [scheduleFlush]);
const resetCanvas = useCallback(() => {
clearDrawingCanvas();
revokeAllLayerObjectUrls();
setImageLayers([]);
imageLayersRef.current = [];
setSelectedLayerId(DRAW_LAYER_ID);
void renderImageLayers();
// Clear history and save initial state
historyRef.current = [];
historyIndexRef.current = -1;
saveHistory();
}, [clearDrawingCanvas, renderImageLayers, revokeAllLayerObjectUrls, saveHistory]);
const drawInitialImage = useCallback(async () => {
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return false;
const nextLayer = await createPlacedLayer({
src: initialImageUrl,
name: "参考图",
});
const nextLayers = [nextLayer];
setImageLayers(nextLayers);
imageLayersRef.current = nextLayers;
setSelectedLayerId(nextLayer.id);
hasLoadedInitialImageRef.current = true;
markCanvasDirty(null);
saveHistory(nextLayers); // Save initial image state
return true;
}, [createPlacedLayer, initialImageUrl, markCanvasDirty, open, saveHistory]);
const initializeCanvas = useCallback(() => {
const canvas = canvasRef.current;
const imageLayerCanvas = imageLayerCanvasRef.current;
const previewCanvas = previewCanvasRef.current;
const wrap = canvasWrapRef.current;
if (!canvas || !imageLayerCanvas || !previewCanvas || !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`;
imageLayerCanvas.width = canvas.width;
imageLayerCanvas.height = canvas.height;
imageLayerCanvas.style.width = canvas.style.width;
imageLayerCanvas.style.height = canvas.style.height;
previewCanvas.width = canvas.width;
previewCanvas.height = canvas.height;
previewCanvas.style.width = canvas.style.width;
previewCanvas.style.height = canvas.style.height;
const ctx = canvas.getContext("2d");
const imageLayerCtx = imageLayerCanvas.getContext("2d");
const previewCtx = previewCanvas.getContext("2d");
if (!ctx) return;
if (!imageLayerCtx) return;
if (!previewCtx) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
imageLayerCtx.setTransform(1, 0, 0, 1, 0, 0);
imageLayerCtx.scale(dpr, dpr);
previewCtx.setTransform(1, 0, 0, 1, 0, 0);
previewCtx.scale(dpr, dpr);
ctx.clearRect(0, 0, canvas.width, canvas.height);
imageLayerCtx.clearRect(0, 0, imageLayerCanvas.width, imageLayerCanvas.height);
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
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 = brushColor;
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, brushColor, brushSize]
);
const drawShape = useCallback(
(
from: { x: number; y: number },
to: { x: number; y: number },
targetCtx?: CanvasRenderingContext2D | null
) => {
const defaultCtx = canvasRef.current?.getContext("2d");
const ctx = targetCtx || defaultCtx;
if (!ctx) return;
const minX = Math.min(from.x, to.x);
const minY = Math.min(from.y, to.y);
const width = Math.abs(to.x - from.x);
const height = Math.abs(to.y - from.y);
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = brushColor;
ctx.lineWidth = Math.max(1, brushSize * 0.9);
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (brushMode === "rect") {
ctx.strokeRect(minX, minY, width, height);
} else if (brushMode === "circle") {
const centerX = minX + width / 2;
const centerY = minY + height / 2;
const radiusX = width / 2;
const radiusY = height / 2;
ctx.beginPath();
ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
ctx.stroke();
} else if (brushMode === "line") {
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
ctx.restore();
},
[brushMode, brushColor, brushSize]
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
const point = getCanvasPoint(event);
if (!point) return;
pointerDownRef.current = true;
lastPointRef.current = point;
if (selectedImageLayer) {
const hitSelectedLayer =
point.x >= selectedImageLayer.x &&
point.x <= selectedImageLayer.x + selectedImageLayer.width &&
point.y >= selectedImageLayer.y &&
point.y <= selectedImageLayer.y + selectedImageLayer.height;
if (hitSelectedLayer) {
draggingLayerIdRef.current = selectedImageLayer.id;
layerDragOriginRef.current = {
pointerX: point.x,
pointerY: point.y,
layerX: selectedImageLayer.x,
layerY: selectedImageLayer.y,
};
event.currentTarget.setPointerCapture(event.pointerId);
return;
}
}
const hitLayer = findLayerAtPoint(point);
if (hitLayer) {
setSelectedLayerId(hitLayer.id);
draggingLayerIdRef.current = hitLayer.id;
layerDragOriginRef.current = {
pointerX: point.x,
pointerY: point.y,
layerX: hitLayer.x,
layerY: hitLayer.y,
};
event.currentTarget.setPointerCapture(event.pointerId);
return;
}
if (isShapeMode(brushMode)) {
shapeStartPointRef.current = point;
clearPreviewCanvas();
} else {
drawStroke(point, point);
drawingHasContentRef.current = true;
const lineWidth = brushMode === "eraser" ? brushSize * 1.45 : brushSize;
markCanvasDirtyStroke(point, point, lineWidth);
scheduleFlush(false, false, "preview");
}
event.currentTarget.setPointerCapture(event.pointerId);
},
[
brushMode,
brushSize,
clearPreviewCanvas,
drawStroke,
findLayerAtPoint,
getCanvasPoint,
markCanvasDirtyStroke,
selectedImageLayer,
scheduleFlush,
]
);
const handlePointerLeave = useCallback(() => {
const wasDrawing = pointerDownRef.current;
if (cursorRef.current) {
cursorRef.current.style.opacity = '0';
}
if (draggingLayerIdRef.current) {
return;
}
pointerDownRef.current = false;
draggingLayerIdRef.current = null;
layerDragOriginRef.current = null;
lastPointRef.current = null;
shapeStartPointRef.current = null;
clearPreviewCanvas();
if (wasDrawing) {
scheduleFlush(true, false, "refine");
}
}, [clearPreviewCanvas, scheduleFlush]);
const handlePointerEnter = useCallback(() => {
if (cursorRef.current) {
cursorRef.current.style.opacity =
!selectedImageLayer && (brushMode === "brush" || brushMode === "eraser") ? "1" : "0";
}
}, [brushMode, selectedImageLayer]);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
// Update cursor
const point = getCanvasPoint(event);
if (point && cursorRef.current) {
const showCursor =
!selectedImageLayer && (brushMode === "brush" || brushMode === "eraser");
if (showCursor) {
cursorRef.current.style.opacity = "1";
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`;
} else {
cursorRef.current.style.opacity = "0";
}
}
if (!pointerDownRef.current) return;
if (!point || !lastPointRef.current) return;
if (draggingLayerIdRef.current && layerDragOriginRef.current) {
const activeLayer = imageLayersRef.current.find(
(layer) => layer.id === draggingLayerIdRef.current
);
const canvas = canvasRef.current;
if (!activeLayer || !canvas) return;
const rect = canvas.getBoundingClientRect();
const nextX =
layerDragOriginRef.current.layerX + (point.x - layerDragOriginRef.current.pointerX);
const nextY =
layerDragOriginRef.current.layerY + (point.y - layerDragOriginRef.current.pointerY);
const maxX = Math.max(0, rect.width - activeLayer.width);
const maxY = Math.max(0, rect.height - activeLayer.height);
updateLayerPosition(
activeLayer.id,
Math.min(maxX, Math.max(0, nextX)),
Math.min(maxY, Math.max(0, nextY))
);
markCanvasDirty(null);
scheduleFlush(false, false, "preview");
return;
}
if (isShapeMode(brushMode) && shapeStartPointRef.current) {
const previewCanvas = previewCanvasRef.current;
const previewCtx = previewCanvas?.getContext("2d");
if (previewCanvas && previewCtx) {
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
drawShape(shapeStartPointRef.current, point, previewCtx);
}
} else {
drawStroke(lastPointRef.current, point);
drawingHasContentRef.current = true;
const lineWidth = brushMode === "eraser" ? brushSize * 1.45 : brushSize;
markCanvasDirtyStroke(lastPointRef.current, point, lineWidth);
}
lastPointRef.current = point;
scheduleFlush(false, false, "preview");
},
[
drawShape,
drawStroke,
getCanvasPoint,
scheduleFlush,
brushMode,
brushSize,
markCanvasDirty,
markCanvasDirtyStroke,
selectedImageLayer,
updateLayerPosition,
]
);
const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
pointerDownRef.current = false;
const wasDraggingLayer = draggingLayerIdRef.current;
const endPoint = getCanvasPoint(event);
const shapeStart = shapeStartPointRef.current;
if (wasDraggingLayer) {
draggingLayerIdRef.current = null;
layerDragOriginRef.current = null;
lastPointRef.current = null;
shapeStartPointRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
saveHistory(imageLayersRef.current);
scheduleFlush(true, false, "refine");
return;
}
if (isShapeMode(brushMode) && endPoint && shapeStart) {
drawShape(shapeStart, endPoint);
drawingHasContentRef.current = true;
const lineWidth = Math.max(1, brushSize * 0.9);
const pad = Math.max(6, Math.ceil(lineWidth * 1.2));
markCanvasDirtyRect({
minX: Math.min(shapeStart.x, endPoint.x) - pad,
minY: Math.min(shapeStart.y, endPoint.y) - pad,
maxX: Math.max(shapeStart.x, endPoint.x) + pad,
maxY: Math.max(shapeStart.y, endPoint.y) + pad,
});
clearPreviewCanvas();
}
lastPointRef.current = null;
shapeStartPointRef.current = null;
layerDragOriginRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
saveHistory(); // Save state after stroke
scheduleFlush(true, false, "refine");
},
[
brushMode,
brushSize,
clearPreviewCanvas,
drawShape,
getCanvasPoint,
markCanvasDirtyRect,
saveHistory,
scheduleFlush,
]
);
useEffect(() => {
clearPreviewCanvas();
}, [brushMode, clearPreviewCanvas]);
// 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]);
useEffect(() => {
if (!open) return;
seedRef.current = createSessionSeed();
sessionIdRef.current = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
seqRef.current = 0;
lastRenderedSeqRef.current = 0;
requestStartMapRef.current.clear();
draftVersionRef.current = 0;
dirtyRef.current = false;
dirtyRegionRef.current = null;
pendingFlushModeRef.current = null;
pendingFlushForceRef.current = false;
latencyEmaRef.current = null;
warningMessageRef.current = "";
metricsQueueRef.current = [];
}, [open]);
useEffect(() => {
if (!open) return;
initializeCanvas();
void drawInitialImage().then((hasInitialImage) => {
if (hasInitialImage) {
scheduleFlushRef.current(true, false);
}
});
const onResize = () => initializeCanvas();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, [drawInitialImage, initializeCanvas, open]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setStatus("connecting");
setStatusMessage("正在初始化实时连接...");
const start = async () => {
try {
let configResponse: Response | null = null;
let configJson: Partial<RealtimeConfig> & { message?: string; retryAfter?: number } = {};
for (let attempt = 0; attempt < 3; attempt += 1) {
configResponse = await fetch("/api/realtime/config", { cache: "no-store" });
configJson = (await configResponse.json().catch(() => ({}))) as Partial<RealtimeConfig> & {
message?: string;
retryAfter?: number;
};
if (configResponse.ok) break;
const retryAfterSeconds = Number(configJson.retryAfter || 0);
const shouldRetry429 = configResponse.status === 429 && attempt < 2;
if (!shouldRetry429) {
throw new Error(configJson.message || "无法加载实时配置");
}
const retryDelay = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : 700 * (attempt + 1);
await new Promise((resolve) => window.setTimeout(resolve, retryDelay));
}
if (!configResponse?.ok) {
throw new Error(configJson.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,
cfgScale:
configJson.model?.defaults?.cfgScale ||
defaultRealtimeConfig.model.defaults.cfgScale,
strength:
configJson.model?.defaults?.strength ||
defaultRealtimeConfig.model.defaults.strength,
imageSize:
configJson.model?.defaults?.imageSize ||
defaultRealtimeConfig.model.defaults.imageSize,
},
},
behavior: {
strictSketch: configJson.behavior?.strictSketch !== false,
},
};
setConfig(resolvedConfig);
baseThrottleMsRef.current = resolvedConfig.transport.throttleInterval;
setAdaptiveThrottleMs(resolvedConfig.transport.throttleInterval);
if (cancelled) return;
connectionRef.current = {
provider: "bfl",
modelId: resolvedConfig.model.id,
reconnectToken: refreshToken,
};
setStatus("live");
setStatusMessage("实时服务已就绪");
} catch (error) {
if (cancelled) return;
console.error("[RealtimeCanvas] init failed:", error);
setStatus("error");
setStatusMessage(error instanceof Error ? error.message : "实时初始化失败");
}
};
void start();
const metricTimer = window.setInterval(() => {
void flushMetricsRef.current();
}, 5000);
return () => {
cancelled = true;
if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current);
pendingFlushTimerRef.current = null;
}
window.clearInterval(metricTimer);
void flushMetricsRef.current();
connectionRef.current?.close?.();
connectionRef.current = null;
setStatus("idle");
setStatusMessage("等待连接");
pointerDownRef.current = false;
lastPointRef.current = null;
pendingFlushModeRef.current = null;
pendingFlushForceRef.current = false;
};
}, [open, pushMetric, refreshToken]);
const forceReconnect = useCallback(() => {
connectionRef.current = null;
setStatus("connecting");
setStatusMessage("正在重连...");
setRefreshToken((prev) => prev + 1);
}, []);
const clearSketch = useCallback(() => {
resetCanvas();
clearPreviewCanvas();
scheduleFlush(true, true, "refine");
}, [clearPreviewCanvas, resetCanvas, scheduleFlush]);
const moveImageLayer = useCallback(
(layerId: string, direction: "up" | "down") => {
const currentLayers = imageLayersRef.current;
const index = currentLayers.findIndex((layer) => layer.id === layerId);
if (index === -1) return;
const targetIndex = direction === "up" ? index + 1 : index - 1;
if (targetIndex < 0 || targetIndex >= currentLayers.length) return;
const nextLayers = cloneImageLayers(currentLayers);
const [layer] = nextLayers.splice(index, 1);
nextLayers.splice(targetIndex, 0, layer);
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
setSelectedLayerId(layerId);
saveHistory(nextLayers);
markCanvasDirty(null);
scheduleFlush(true, false, "refine");
},
[markCanvasDirty, saveHistory, scheduleFlush]
);
const removeImageLayer = useCallback(
(layerId: string) => {
const currentLayers = imageLayersRef.current;
const nextLayers = currentLayers.filter((layer) => layer.id !== layerId);
if (nextLayers.length === currentLayers.length) return;
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
setSelectedLayerId((current) => (current === layerId ? DRAW_LAYER_ID : current));
saveHistory(nextLayers);
markCanvasDirty(null);
scheduleFlush(true, false, "refine");
},
[markCanvasDirty, saveHistory, scheduleFlush]
);
const handleSketchUpload = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
try {
const importedCount = await importFilesAsLayers(files);
toast.success(
importedCount === 1
? "图片已导入图层"
: `已导入 ${importedCount} 张图片到图层`
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "导入图片失败");
} finally {
event.currentTarget.value = "";
}
},
[importFilesAsLayers]
);
const handleDragEnter = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return;
event.preventDefault();
dragDepthRef.current += 1;
setIsDragImportActive(true);
}, []);
const handleDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setIsDragImportActive(true);
}, []);
const handleDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return;
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsDragImportActive(false);
}
}, []);
const handleDropUpload = useCallback(
async (event: React.DragEvent<HTMLDivElement>) => {
if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return;
event.preventDefault();
dragDepthRef.current = 0;
setIsDragImportActive(false);
const files = Array.from(event.dataTransfer.files || []);
if (files.length === 0) return;
try {
const importedCount = await importFilesAsLayers(files);
if (importedCount > 0) {
toast.success(`已拖入 ${importedCount} 张图片`);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "拖拽上传失败");
}
},
[importFilesAsLayers]
);
const handleCanvasBackgroundChange = useCallback(
(nextColor: string) => {
canvasBackgroundRef.current = nextColor;
setCanvasBackground(nextColor);
saveHistory(undefined, nextColor);
markCanvasDirty(null);
scheduleFlush(true);
},
[markCanvasDirty, saveHistory, scheduleFlush]
);
const handleDownloadCanvas = useCallback(async () => {
const compositeCanvas = await buildCompositeCanvas();
if (!compositeCanvas) return;
try {
const exportedImage = await canvasToJpegDataUri(
compositeCanvas,
2048,
0.92,
canvasBackgroundRef.current
);
const anchor = document.createElement("a");
anchor.href = exportedImage.dataUri;
anchor.download = `realtime-canvas-${Date.now()}.jpg`;
anchor.click();
toast.success("已保存当前画布");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
}, [buildCompositeCanvas]);
const handleApplyToCanvas = useCallback(() => {
if (!previewUrl) {
toast.error("请先生成预览结果");
return;
}
onApplyResult({
imageUrl: previewUrl,
prompt: prompt.trim() || FALLBACK_PROMPT,
});
onOpenChange(false);
}, [onApplyResult, onOpenChange, previewUrl, prompt]);
const startBlankProject = useCallback(() => {
setPrompt(FALLBACK_PROMPT);
setPreviewUrl("");
setProjectPanelOpen(false);
setSelectedLayerId(DRAW_LAYER_ID);
drawingHasContentRef.current = false;
hasLoadedInitialImageRef.current = true;
resetCanvas();
clearPreviewCanvas();
dirtyRef.current = false;
dirtyRegionRef.current = null;
setStatus("idle");
setStatusMessage("新建空白项目");
}, [clearPreviewCanvas, resetCanvas]);
const content = (
<>
{standalone ? (
<div className="sr-only">
<h2>实时画布</h2>
<p>左侧绘制草图,右侧实时预览。</p>
</div>
) : (
<DialogHeader className="sr-only">
<DialogTitle>实时画布</DialogTitle>
<DialogDescription>左侧绘制草图,右侧实时预览。</DialogDescription>
</DialogHeader>
)}
<div className="absolute left-4 right-4 top-4 z-50 pointer-events-none">
<div className="pointer-events-auto rounded-[24px] border border-black/10 bg-white/98 shadow-[0_12px_36px_rgba(15,23,42,0.1)]">
<div className="flex items-center gap-2 overflow-x-auto px-2 py-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex h-10 shrink-0 items-center gap-2 rounded-full border border-black/10 bg-zinc-50 px-3">
<span className="text-sm font-semibold text-zinc-900">Realtime Canvas</span>
<span
className={cn(
"flex items-center gap-1.5 rounded-full px-2 py-1 text-xs font-medium",
statusBadge.className
)}
>
<span
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>
<span className="hidden text-xs text-zinc-500 sm:inline">
延迟 {adaptiveThrottleMs}ms
</span>
</div>
<div className="flex h-10 shrink-0 items-center gap-0.5 rounded-full border border-black/10 bg-white px-1">
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 rounded-full px-2.5 text-xs"
onClick={() => void saveCurrentProject()}
>
<Save className="mr-1.5 h-3.5 w-3.5" />
保存项目
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 rounded-full px-2.5 text-xs"
onClick={() => {
refreshSavedProjects();
setProjectPanelOpen((openState) => !openState);
}}
>
<FolderOpen className="mr-1.5 h-3.5 w-3.5" />
项目列表
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 rounded-full px-2.5 text-xs"
onClick={startBlankProject}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新建空白
</Button>
{standalone ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
title="返回画布"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
<div className="flex h-10 shrink-0 items-center gap-0.5 rounded-full border border-black/10 bg-white px-1">
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "brush" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("brush")}
title="画笔"
>
<Paintbrush className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "eraser" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("eraser")}
title="橡皮"
>
<Eraser className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "rect" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("rect")}
title="矩形"
>
<Square className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "circle" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("circle")}
title="圆形"
>
<CircleIcon className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100",
brushMode === "line" && "bg-black text-white hover:bg-black"
)}
onClick={() => setBrushMode("line")}
title="直线"
>
<Slash className="h-5 w-5" />
</Button>
</div>
<div className="flex h-10 shrink-0 items-center gap-2 rounded-full border border-black/10 bg-zinc-50 px-3">
<button
type="button"
className="flex h-8 items-center gap-2 rounded-full border border-black/10 bg-white px-3 text-xs font-medium text-zinc-700 transition hover:bg-zinc-50"
onClick={() => colorInputRef.current?.click()}
title="选择画笔颜色"
>
<span
className="h-5 w-5 rounded-full border border-black/15 shadow-inner"
style={{ backgroundColor: brushColor }}
/>
颜色
</button>
<input
ref={colorInputRef}
type="color"
value={brushColor}
onChange={(event) => setBrushColor(event.target.value)}
className="sr-only"
title="自定义颜色"
/>
<span className="min-w-[64px] text-[11px] uppercase tracking-wide text-zinc-500">
{brushColor}
</span>
<div className="flex w-[210px] items-center gap-3 rounded-full border border-black/10 bg-white px-3 py-1.5">
<span className="whitespace-nowrap text-xs font-medium text-zinc-600">
粗细 {brushSize}
</span>
<Slider
value={[brushSize]}
min={2}
max={50}
step={1}
onValueChange={([val]) => setBrushSize(val)}
className="min-w-[140px] flex-1 [&>span:first-child]:h-2.5 [&>span:first-child]:bg-zinc-200 [&>span:first-child]:shadow-inner [&>span:first-child>span]:bg-sky-500 [&>span:last-child]:h-5 [&>span:last-child]:w-5 [&>span:last-child]:border-2 [&>span:last-child]:border-sky-500 [&>span:last-child]:bg-white"
/>
</div>
<div className="flex h-8 items-center gap-1 rounded-full border border-black/10 bg-white px-2">
{CANVAS_BACKGROUND_PRESETS.map((color) => (
<button
key={color}
type="button"
className={cn(
"h-5 w-5 rounded-full border border-black/15 transition-all",
canvasBackground === color && "scale-110 ring-2 ring-black/20"
)}
style={{ backgroundColor: color }}
onClick={() => handleCanvasBackgroundChange(color)}
title="画布底色"
/>
))}
</div>
</div>
<div className="flex h-10 shrink-0 items-center gap-0.5 rounded-full border border-black/10 bg-white px-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={() => uploadInputRef.current?.click()}
title="上传图片"
>
<Upload className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={clearSketch}
title="清空"
>
<Trash2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={undo}
disabled={!canUndo}
title="撤销"
>
<Undo2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={redo}
disabled={!canRedo}
title="重做"
>
<Redo2 className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={handleDownloadCanvas}
title="保存图片"
>
<Download className="h-5 w-5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100"
onClick={forceReconnect}
title="重连"
>
<RefreshCw className="h-5 w-5" />
</Button>
</div>
</div>
</div>
</div>
{projectPanelOpen ? (
<div className="absolute left-4 top-20 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] border border-black/10 bg-white/95 p-4 shadow-2xl backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-zinc-900">项目列表</div>
<div className="text-xs text-zinc-400">本地保存,便于下次继续编辑</div>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full"
onClick={() => setProjectPanelOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="mt-4 max-h-[55vh] space-y-2 overflow-y-auto pr-1">
{savedProjects.length === 0 ? (
<div className="rounded-2xl border border-dashed border-black/10 px-4 py-8 text-center text-sm text-zinc-400">
暂无保存的项目
</div>
) : (
savedProjects.map((project) => (
<div key={project.id} className="rounded-2xl border border-black/10 bg-white p-2.5 shadow-sm">
<div className="flex gap-3">
<div className="h-16 w-16 overflow-hidden rounded-xl border border-black/10 bg-zinc-100">
{project.previewUrl ? (
<img src={project.previewUrl} alt={project.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-[11px] text-zinc-400">
无预览
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-zinc-900">{project.name}</div>
<div className="mt-1 line-clamp-2 text-[11px] text-zinc-400">
{project.prompt || "未填写提示词"}
</div>
<div className="mt-2 text-[11px] text-zinc-400">
{new Date(project.updatedAt).toLocaleString("zh-CN")}
</div>
</div>
</div>
<div className="mt-3 flex gap-2">
<Button
type="button"
size="sm"
className="h-8 flex-1 rounded-xl"
onClick={() => void openSavedProject(project.id)}
>
打开
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-xl"
onClick={() => deleteSavedProject(project.id)}
>
删除
</Button>
</div>
</div>
))
)}
</div>
</div>
) : null}
{/* 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]"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDropUpload}
>
<div className="absolute inset-0 pattern-grid-lg opacity-[0.03] pointer-events-none" />
{isDragImportActive ? (
<div className="absolute inset-6 z-20 flex items-center justify-center rounded-[28px] border-2 border-dashed border-sky-400 bg-sky-50/90 text-sm font-medium text-sky-700 shadow-inner">
松开鼠标,上传图片到图层
</div>
) : null}
<div className="relative">
<canvas
ref={imageLayerCanvasRef}
className="pointer-events-none shadow-sm block"
/>
<canvas
ref={canvasRef}
className={cn(
"absolute inset-0 touch-none shadow-sm block",
selectedImageLayer
? "cursor-move"
: brushMode === "brush" || brushMode === "eraser"
? "cursor-none"
: "cursor-crosshair"
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
<canvas
ref={previewCanvasRef}
className="pointer-events-none absolute inset-0 touch-none"
/>
{selectedImageLayer ? (
<div
className="pointer-events-none absolute border border-dashed border-sky-500 shadow-[0_0_0_1px_rgba(14,165,233,0.25)]"
style={{
left: selectedImageLayer.x,
top: selectedImageLayer.y,
width: selectedImageLayer.width,
height: selectedImageLayer.height,
}}
/>
) : null}
{/* 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 left-3 top-28 bottom-4 z-10 flex w-[11.75rem] max-w-[38vw] flex-col rounded-[20px] border border-black/10 bg-white/96 p-3 shadow-xl">
<div className="flex items-center justify-between border-b border-black/10 pb-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-900">
<Layers3 className="h-4 w-4" />
图层
</div>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] text-zinc-500">
{imageLayers.length} 张
</span>
</div>
<div className="mt-3 flex-1 space-y-2 overflow-y-auto pr-1">
<button
type="button"
className={cn(
"w-full rounded-2xl border px-3 py-2 text-left transition",
selectedLayerId === DRAW_LAYER_ID
? "border-black/20 bg-zinc-900 text-white shadow-sm"
: "border-dashed border-black/10 bg-zinc-50 text-zinc-900 hover:bg-zinc-100"
)}
onClick={() => setSelectedLayerId(DRAW_LAYER_ID)}
>
<div className="text-[11px] font-medium text-zinc-600">画笔图层</div>
<div
className={cn(
"mt-1 text-[11px]",
selectedLayerId === DRAW_LAYER_ID ? "text-zinc-300" : "text-zinc-400"
)}
>
固定在最上层,适合标注和结构草绘
</div>
</button>
{imageLayers.length === 0 ? (
<div className="rounded-2xl border border-dashed border-black/10 px-3 py-4 text-center text-xs text-zinc-400">
暂无图片图层
</div>
) : (
[...imageLayers].reverse().map((layer, displayIndex) => {
const actualIndex = imageLayers.length - 1 - displayIndex;
const isTopMost = actualIndex === imageLayers.length - 1;
const isBottomMost = actualIndex === 0;
return (
<div
key={layer.id}
className={cn(
"rounded-2xl border bg-white px-2.5 py-2 shadow-sm transition",
selectedLayerId === layer.id
? "border-black/20 ring-2 ring-black/5"
: "border-black/10"
)}
>
<div className="flex items-center gap-2">
<img
src={/^https?:\/\//i.test(layer.src) ? buildImageProxyUrl(layer.src) : layer.src}
alt={layer.name}
className="h-12 w-12 rounded-xl border border-black/10 object-cover"
/>
<div className="min-w-0 flex-1">
<button
type="button"
className="w-full text-left"
onClick={() => setSelectedLayerId(layer.id)}
>
<div className="truncate text-xs font-medium text-zinc-900">{layer.name}</div>
</button>
<div className="mt-1 text-[11px] text-zinc-400">
{isTopMost ? "当前最上层" : isBottomMost ? "当前底层" : "中间图层"}
</div>
</div>
<div className="flex flex-col gap-1">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-lg text-zinc-500 hover:bg-zinc-100"
onClick={() => moveImageLayer(layer.id, "up")}
disabled={isTopMost}
title="上移图层"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-lg text-zinc-500 hover:bg-zinc-100"
onClick={() => moveImageLayer(layer.id, "down")}
disabled={isBottomMost}
title="下移图层"
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-lg text-zinc-500 hover:bg-rose-50 hover:text-rose-600"
onClick={() => removeImageLayer(layer.id)}
title="删除图层"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})
)}
</div>
{selectedImageLayer ? (
<div className="mt-3 rounded-2xl border border-black/10 bg-zinc-50 p-3">
<div className="flex items-center justify-between text-xs font-medium text-zinc-700">
<span>图层缩放</span>
<span>{Math.round(selectedImageLayerScale * 100)}%</span>
</div>
<div className="mt-3 flex items-center gap-2">
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full"
onClick={() => {
const nextLayers = scaleImageLayer(
selectedImageLayer.id,
selectedImageLayerScale - 0.1
);
if (nextLayers) {
saveHistory(nextLayers);
markCanvasDirty(null);
scheduleFlush(true);
}
}}
>
<span className="text-base leading-none">-</span>
</Button>
<Slider
value={[Math.round(selectedImageLayerScale * 100)]}
min={20}
max={300}
step={5}
onValueChange={([value]) => {
scaleImageLayer(selectedImageLayer.id, value / 100);
}}
onValueCommit={([value]) => {
const nextLayers = scaleImageLayer(selectedImageLayer.id, value / 100);
if (nextLayers) {
saveHistory(nextLayers);
markCanvasDirty(null);
scheduleFlush(true);
}
}}
className="flex-1 [&>span:first-child]:h-2.5 [&>span:first-child]:bg-zinc-200 [&>span:first-child]:shadow-inner [&>span:first-child>span]:bg-sky-500 [&>span:last-child]:h-5 [&>span:last-child]:w-5 [&>span:last-child]:border-2 [&>span:last-child]:border-sky-500 [&>span:last-child]:bg-white"
/>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 rounded-full"
onClick={() => {
const nextLayers = scaleImageLayer(
selectedImageLayer.id,
selectedImageLayerScale + 0.1
);
if (nextLayers) {
saveHistory(nextLayers);
markCanvasDirty(null);
scheduleFlush(true);
}
}}
>
<span className="text-base leading-none">+</span>
</Button>
</div>
</div>
) : null}
<Button
type="button"
variant="outline"
className="mt-3 h-10 rounded-xl bg-white"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="mr-2 h-4 w-4" />
添加图片图层
</Button>
</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>
</div>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleSketchUpload}
/>
<div className="absolute bottom-5 left-1/2 z-20 w-[min(760px,calc(100vw-2rem))] -translate-x-1/2 pointer-events-none">
<div className="pointer-events-auto flex items-center gap-2 rounded-[22px] border border-black/10 bg-white/96 px-2 py-2 shadow-[0_12px_40px_rgba(15,23,42,0.12)]">
<div className="flex h-9 w-9 shrink-0 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, false)}
placeholder="输入指令,基于当前画布实时生成"
className="h-9 flex-1 border-0 bg-transparent px-1 text-sm shadow-none focus-visible:ring-0"
/>
<span className="hidden whitespace-nowrap text-xs text-zinc-500 xl:inline">
{statusMessage}
</span>
<Button
size="sm"
className="h-9 shrink-0 rounded-full px-4 font-medium"
onClick={triggerGenerate}
disabled={status === "connecting"}
>
{status === "connecting" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"生成"
)}
</Button>
<Button
size="sm"
variant="outline"
className="h-9 shrink-0 rounded-full px-4 font-medium"
onClick={handleApplyToCanvas}
disabled={!previewUrl}
>
<Send className="mr-1.5 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
</>
);
if (standalone) {
return (
<div className="h-dvh w-full overflow-hidden bg-background">
<div className="relative h-full w-full overflow-hidden bg-background">{content}</div>
</div>
);
}
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]">
{content}
</DialogContent>
</Dialog>
);
}