ai-chatbot/components/canvas/RealtimeCanvasDialog.tsx
2026-04-06 23:21:46 +08:00

3916 lines
131 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 {
Circle as CircleIcon,
Eraser,
FolderOpen,
Layers3,
Loader2,
MousePointer2,
Paintbrush,
Plus,
Redo2,
Save,
Send,
Slash,
Sparkles,
Square,
Undo2,
Upload,
Wand2,
X,
} from "lucide-react";
import {
type ChangeEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { EnhanceProWorkspace } from "@/components/canvas/EnhanceProWorkspace";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas";
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 RealtimeWorkspaceMode = "realtime" | "enhance-pro";
type RealtimeBrushMode =
| "select"
| "brush"
| "eraser"
| "rect"
| "circle"
| "line";
type LayerResizeHandle = "nw" | "ne" | "sw" | "se";
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;
coverImageUrl?: string;
previewUrl?: string;
};
type LayerSplitBounds = {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
};
type LayerSplitItem = {
originalUrl: string;
title?: string;
layerIndex: number;
totalLayers: number;
bounds?: LayerSplitBounds | null;
};
type RealtimeCanvasDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
initialImageUrl?: string | null;
initialMode?: RealtimeWorkspaceMode;
onApplyResult: (payload: {
imageUrl: string;
prompt: string;
title?: string;
model?: string;
width?: number;
height?: number;
}) => void;
standalone?: boolean;
};
const FALLBACK_PROMPT =
"严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。";
const CANVAS_BACKGROUND = "#ffffff";
const DEFAULT_BRUSH_COLOR = "#18181b";
const DRAW_LAYER_ID = "__draw_layer__";
const REALTIME_PROJECTS_STORAGE_KEY = "ai-chatbot:realtime-canvas-projects";
function cloneImageLayers(layers: ImageLayer[]) {
return layers.map((layer) => ({ ...layer }));
}
function stripFileExtension(filename: string) {
const trimmed = String(filename || "").trim();
return trimmed.replace(/\.[^.]+$/, "") || trimmed || "图层";
}
function isFullFrameSplitLayer(bounds?: LayerSplitBounds | null) {
if (!bounds) return false;
return (
bounds.coverage >= 0.92 &&
bounds.width >= bounds.originalWidth - 2 &&
bounds.height >= bounds.originalHeight - 2
);
}
function orderLayerSplitItems<T extends LayerSplitItem>(layers: T[]) {
const ordered = [...layers].sort(
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
);
const backgroundLayers = ordered.filter((layer) =>
isFullFrameSplitLayer(layer.bounds)
);
const subjectLayers = ordered.filter(
(layer) => !isFullFrameSplitLayer(layer.bounds)
);
return [...backgroundLayers, ...subjectLayers];
}
function getResizeCursor(handle: LayerResizeHandle) {
return handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize";
}
function buildSuggestedProjectName(prompt: string) {
const trimmed = prompt.trim();
if (trimmed) {
return trimmed.slice(0, 24);
}
return `实时项目 ${new Date().toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}`;
}
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: RealtimeBrushMode) {
return mode === "rect" || mode === "circle" || mode === "line";
}
function createSessionSeed() {
return Math.floor(Math.random() * 2_147_483_647);
}
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,
initialMode = "realtime",
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 resizingLayerIdRef = useRef<string | null>(null);
const layerDragOriginRef = useRef<{
pointerX: number;
pointerY: number;
layerX: number;
layerY: number;
} | null>(null);
const layerResizeOriginRef = useRef<{
pointerX: number;
pointerY: number;
layerX: number;
layerY: number;
layerWidth: number;
layerHeight: number;
handle: LayerResizeHandle;
} | 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 sidebarDraggingLayerIdRef = useRef<string | null>(null);
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 splitUploadInputRef = 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 [, setStatusMessage] = useState("等待连接");
const [prompt, setPrompt] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const [workspaceMode, setWorkspaceMode] =
useState<RealtimeWorkspaceMode>(initialMode);
const [brushMode, setBrushMode] = useState<RealtimeBrushMode>("select");
const [brushColor, setBrushColor] = useState<string>(DEFAULT_BRUSH_COLOR);
const [canvasBackground, setCanvasBackground] = useState<string>(
canvasBackgroundRef.current
);
const [imageLayers, setImageLayers] = useState<ImageLayer[]>([]);
const [isLayerSplitImporting, setIsLayerSplitImporting] = useState(false);
const [selectedLayerId, setSelectedLayerId] = useState<string>(DRAW_LAYER_ID);
const [brushSize, setBrushSize] = useState(6);
const [savedProjects, setSavedProjects] = useState<SavedRealtimeProject[]>(
[]
);
const [projectPanelOpen, setProjectPanelOpen] = useState(false);
const [saveProjectDialogOpen, setSaveProjectDialogOpen] = useState(false);
const [pendingProjectName, setPendingProjectName] = useState("");
const [brushPopoverOpen, setBrushPopoverOpen] = useState(false);
const [eraserPopoverOpen, setEraserPopoverOpen] = useState(false);
const [isDragImportActive, setIsDragImportActive] = useState(false);
const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState<number>(
defaultRealtimeConfig.transport.throttleInterval
);
const [refreshToken, setRefreshToken] = useState(0);
const [siteName, setSiteName] = useState("AI Chatbot");
const [siteLogo, setSiteLogo] = useState<string | null>(null);
const statusBadge = useMemo(() => {
if (status === "live")
return {
label: "实时中",
className: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
};
if (status === "connecting") {
return {
label: "连接中",
className: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
};
}
if (status === "error")
return {
label: "异常",
className: "bg-rose-500/15 text-rose-600 dark:text-rose-400",
};
return {
label: "待启动",
className: "bg-muted text-muted-foreground",
};
}, [status]);
const selectedImageLayer = useMemo(
() => imageLayers.find((layer) => layer.id === selectedLayerId) || null,
[imageLayers, selectedLayerId]
);
const enhanceSourceImageUrl = useMemo(() => {
return (
previewUrl ||
selectedImageLayer?.src ||
imageLayers[0]?.src ||
initialImageUrl ||
""
);
}, [imageLayers, initialImageUrl, previewUrl, selectedImageLayer?.src]);
useEffect(() => {
if (!open) return;
setWorkspaceMode(initialMode);
}, [initialMode, open]);
useEffect(() => {
let cancelled = false;
fetch(`/api/site-settings?t=${Date.now()}`, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!data || cancelled) return;
if (typeof data.siteName === "string" && data.siteName.trim()) {
setSiteName(data.siteName.trim());
}
if (typeof data.siteLogo === "string" && data.siteLogo.trim()) {
setSiteLogo(data.siteLogo.trim());
} else if (
typeof data.siteLogoDark === "string" &&
data.siteLogoDark.trim()
) {
setSiteLogo(data.siteLogoDark.trim());
}
})
.catch((error) => {
console.warn("[RealtimeCanvas] Failed to load site settings:", error);
});
return () => {
cancelled = true;
};
}, []);
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;
const cssWidth = Math.max(
1,
Number.parseFloat(layerCanvas.style.width) || layerCanvas.width
);
const cssHeight = Math.max(
1,
Number.parseFloat(layerCanvas.style.height) || layerCanvas.height
);
await drawImageLayersToContext(ctx, cssWidth, cssHeight, { renderSeq });
}, [drawImageLayersToContext]);
const buildCompositeCanvas = useCallback(async () => {
const drawingCanvas = canvasRef.current;
if (!drawingCanvas) return null;
const cssWidth = Math.max(
1,
Number.parseFloat(drawingCanvas.style.width) || drawingCanvas.width
);
const cssHeight = Math.max(
1,
Number.parseFloat(drawingCanvas.style.height) || drawingCanvas.height
);
const dpr = Math.max(1, drawingCanvas.width / cssWidth);
const compositeCanvas = document.createElement("canvas");
compositeCanvas.width = drawingCanvas.width;
compositeCanvas.height = drawingCanvas.height;
const compositeCtx = compositeCanvas.getContext("2d");
if (!compositeCtx) return null;
compositeCtx.setTransform(1, 0, 0, 1, 0, 0);
compositeCtx.scale(dpr, dpr);
const didRenderLayers = await drawImageLayersToContext(
compositeCtx,
cssWidth,
cssHeight
);
if (!didRenderLayers) {
return null;
}
compositeCtx.drawImage(drawingCanvas, 0, 0, cssWidth, cssHeight);
return compositeCanvas;
}, [drawImageLayersToContext]);
const createPlacedLayer = useCallback(
async (params: {
src: string;
name: string;
placement?: {
x: number;
y: number;
width: number;
height: number;
};
}) => {
const image = await loadRenderableImage(params.src);
const canvas = canvasRef.current;
if (!canvas) {
throw new Error("画布未初始化");
}
if (params.placement) {
return {
id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: params.name,
src: params.src,
x: params.placement.x,
y: params.placement.y,
width: params.placement.width,
height: params.placement.height,
baseWidth: params.placement.width,
baseHeight: params.placement.height,
} satisfies ImageLayer;
}
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 getResizeHandleAtPoint = useCallback(
(
point: { x: number; y: number },
layer: ImageLayer | null
): LayerResizeHandle | null => {
if (!layer) return null;
const handleSize = 14;
const half = handleSize / 2;
const handles: Array<{ key: LayerResizeHandle; x: number; y: number }> = [
{ key: "nw", x: layer.x, y: layer.y },
{ key: "ne", x: layer.x + layer.width, y: layer.y },
{ key: "sw", x: layer.x, y: layer.y + layer.height },
{ key: "se", x: layer.x + layer.width, y: layer.y + layer.height },
];
for (const handle of handles) {
const withinX =
point.x >= handle.x - half && point.x <= handle.x + half;
const withinY =
point.y >= handle.y - half && point.y <= handle.y + half;
if (withinX && withinY) {
return handle.key;
}
}
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 resizeLayerFromHandle = useCallback(
(
layerId: string,
point: { x: number; y: number },
origin: NonNullable<typeof layerResizeOriginRef.current>
) => {
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 aspectRatio = Math.max(
0.01,
origin.layerWidth / Math.max(1, origin.layerHeight)
);
const minSize = 24;
let anchorX = origin.layerX;
let anchorY = origin.layerY;
let maxWidth = canvasWidth;
let maxHeight = canvasHeight;
if (origin.handle === "nw") {
anchorX = origin.layerX + origin.layerWidth;
anchorY = origin.layerY + origin.layerHeight;
maxWidth = anchorX;
maxHeight = anchorY;
} else if (origin.handle === "ne") {
anchorX = origin.layerX;
anchorY = origin.layerY + origin.layerHeight;
maxWidth = canvasWidth - anchorX;
maxHeight = anchorY;
} else if (origin.handle === "sw") {
anchorX = origin.layerX + origin.layerWidth;
anchorY = origin.layerY;
maxWidth = anchorX;
maxHeight = canvasHeight - anchorY;
} else {
anchorX = origin.layerX;
anchorY = origin.layerY;
maxWidth = canvasWidth - anchorX;
maxHeight = canvasHeight - anchorY;
}
let targetWidth = Math.max(minSize, Math.abs(point.x - anchorX));
let targetHeight = targetWidth / aspectRatio;
if (targetHeight < minSize) {
targetHeight = minSize;
targetWidth = targetHeight * aspectRatio;
}
if (targetHeight > Math.abs(point.y - anchorY)) {
targetHeight = Math.max(minSize, Math.abs(point.y - anchorY));
targetWidth = targetHeight * aspectRatio;
}
const fitRatio = Math.min(
1,
maxWidth / targetWidth,
maxHeight / targetHeight
);
targetWidth = Math.max(minSize, targetWidth * fitRatio);
targetHeight = Math.max(minSize, targetHeight * fitRatio);
const nextX =
origin.handle === "ne" || origin.handle === "se"
? anchorX
: anchorX - targetWidth;
const nextY =
origin.handle === "sw" || origin.handle === "se"
? anchorY
: anchorY - targetHeight;
const nextLayers = imageLayersRef.current.map((layer) =>
layer.id === layerId
? {
...layer,
x: Math.max(0, nextX),
y: Math.max(0, nextY),
width: Math.min(canvasWidth, targetWidth),
height: Math.min(canvasHeight, targetHeight),
}
: layer
);
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 reorderLayersFromSidebar = useCallback(
(draggedLayerId: string, targetLayerId: string) => {
if (!draggedLayerId || !targetLayerId || draggedLayerId === targetLayerId)
return;
const displayLayers = [...imageLayersRef.current].reverse();
const sourceIndex = displayLayers.findIndex(
(layer) => layer.id === draggedLayerId
);
const targetIndex = displayLayers.findIndex(
(layer) => layer.id === targetLayerId
);
if (
sourceIndex === -1 ||
targetIndex === -1 ||
sourceIndex === targetIndex
)
return;
const nextDisplayLayers = [...displayLayers];
const [movedLayer] = nextDisplayLayers.splice(sourceIndex, 1);
nextDisplayLayers.splice(targetIndex, 0, movedLayer);
const nextLayers = nextDisplayLayers.reverse();
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
setSelectedLayerId(draggedLayerId);
saveHistory(nextLayers);
markCanvasDirty(null);
scheduleFlush(true, false, "refine");
},
[markCanvasDirty, saveHistory, scheduleFlush]
);
const appendLayerBatch = useCallback(
(nextLayerBatch: ImageLayer[]) => {
if (nextLayerBatch.length === 0) return 0;
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;
},
[markCanvasDirty, saveHistory, scheduleFlush]
);
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);
}
return appendLayerBatch(nextLayerBatch);
},
[appendLayerBatch, createPlacedLayer, fileToDataUrl]
);
const importFilesAsSplitLayers = 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 referenceLayer = await createPlacedLayer({
src: dataUrl,
name: file.name || "图层拆分参考图",
});
const splitResult =
await imageGenerationService.splitImageLayers(dataUrl);
const splitLayers = splitResult.data?.layers || [];
if (!splitResult.success || splitLayers.length === 0) {
throw new Error(
splitResult.error || `${file.name || "图片"} 图层拆分失败`
);
}
const orderedLayers = orderLayerSplitItems(splitLayers);
const baseName = stripFileExtension(file.name || "图层拆分");
for (const layer of orderedLayers) {
const placedLayer = await createPlacedLayer({
src: layer.originalUrl,
name: `${baseName}-${layer.title || `图层${layer.layerIndex}`}`.slice(
0,
48
),
placement: {
x: referenceLayer.x,
y: referenceLayer.y,
width: referenceLayer.width,
height: referenceLayer.height,
},
});
nextLayerBatch.push(placedLayer);
}
}
return appendLayerBatch(nextLayerBatch);
},
[appendLayerBatch, createPlacedLayer, fileToDataUrl]
);
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 && project.prompt !== FALLBACK_PROMPT
? project.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(() => {
if (!hasRenderableInput(true)) {
toast.error("空白画布无需保存");
return;
}
setPendingProjectName(buildSuggestedProjectName(prompt));
setSaveProjectDialogOpen(true);
}, [hasRenderableInput, prompt]);
const confirmSaveCurrentProject = useCallback(async () => {
const normalizedProjectName = pendingProjectName.trim();
if (!normalizedProjectName) {
toast.error("请输入项目名称");
return;
}
const existingProjects = readRealtimeProjectsFromStorage();
const projectId = `rtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const compositeCanvas = await buildCompositeCanvas();
const coverImageUrl =
imageLayersRef.current[0]?.src ||
previewUrl ||
compositeCanvas?.toDataURL("image/jpeg", 0.72) ||
"";
const project: SavedRealtimeProject = {
id: projectId,
name: normalizedProjectName,
updatedAt: new Date().toISOString(),
prompt: prompt.trim() || FALLBACK_PROMPT,
brushColor,
brushSize,
canvasBackground: canvasBackgroundRef.current,
imageLayers: cloneImageLayers(imageLayersRef.current),
drawingLayerDataUrl: exportDrawingLayerDataUrl(),
coverImageUrl,
previewUrl:
previewUrl || compositeCanvas?.toDataURL("image/jpeg", 0.72) || "",
};
const nextProjects = [project, ...existingProjects].slice(0, 30);
writeRealtimeProjectsToStorage(nextProjects);
setSavedProjects(nextProjects);
setSaveProjectDialogOpen(false);
setProjectPanelOpen(true);
toast.success("项目已保存");
}, [
brushColor,
brushSize,
buildCompositeCanvas,
exportDrawingLayerDataUrl,
pendingProjectName,
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 (brushMode === "select" && selectedImageLayer) {
const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer);
if (resizeHandle) {
resizingLayerIdRef.current = selectedImageLayer.id;
layerResizeOriginRef.current = {
pointerX: point.x,
pointerY: point.y,
layerX: selectedImageLayer.x,
layerY: selectedImageLayer.y,
layerWidth: selectedImageLayer.width,
layerHeight: selectedImageLayer.height,
handle: resizeHandle,
};
event.currentTarget.setPointerCapture(event.pointerId);
return;
}
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 = brushMode === "select" ? findLayerAtPoint(point) : null;
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 (brushMode === "select") {
setSelectedLayerId(DRAW_LAYER_ID);
lastPointRef.current = null;
shapeStartPointRef.current = null;
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,
getResizeHandleAtPoint,
markCanvasDirtyStroke,
selectedImageLayer,
scheduleFlush,
]
);
const handlePointerLeave = useCallback(() => {
const wasDrawing = pointerDownRef.current;
if (cursorRef.current) {
cursorRef.current.style.opacity = "0";
}
if (draggingLayerIdRef.current || resizingLayerIdRef.current) {
return;
}
pointerDownRef.current = false;
draggingLayerIdRef.current = null;
resizingLayerIdRef.current = null;
layerDragOriginRef.current = null;
layerResizeOriginRef.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 =
brushMode === "brush" || brushMode === "eraser" ? "1" : "0";
}
}, [brushMode]);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
// Update cursor
const point = getCanvasPoint(event);
if (point && cursorRef.current) {
const resizeHandle =
brushMode === "select"
? getResizeHandleAtPoint(point, selectedImageLayer)
: null;
event.currentTarget.style.cursor = resizeHandle
? getResizeCursor(resizeHandle)
: brushMode === "select" && selectedImageLayer
? "move"
: brushMode === "brush" || brushMode === "eraser"
? "none"
: brushMode === "select"
? "default"
: "crosshair";
const showCursor = 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 (resizingLayerIdRef.current && layerResizeOriginRef.current) {
resizeLayerFromHandle(
resizingLayerIdRef.current,
point,
layerResizeOriginRef.current
);
markCanvasDirty(null);
scheduleFlush(false, false, "preview");
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,
getResizeHandleAtPoint,
markCanvasDirty,
markCanvasDirtyStroke,
resizeLayerFromHandle,
selectedImageLayer,
updateLayerPosition,
]
);
const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
event.preventDefault();
pointerDownRef.current = false;
const wasResizingLayer = resizingLayerIdRef.current;
const wasDraggingLayer = draggingLayerIdRef.current;
const endPoint = getCanvasPoint(event);
const shapeStart = shapeStartPointRef.current;
if (wasResizingLayer) {
resizingLayerIdRef.current = null;
layerResizeOriginRef.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 (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;
layerResizeOriginRef.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]);
const deleteSelectedImageLayer = useCallback(() => {
if (selectedLayerId === DRAW_LAYER_ID) return;
const nextLayers = imageLayersRef.current.filter(
(layer) => layer.id !== selectedLayerId
);
if (nextLayers.length === imageLayersRef.current.length) return;
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
setSelectedLayerId(DRAW_LAYER_ID);
saveHistory(nextLayers);
markCanvasDirty(null);
scheduleFlush(true, false, "refine");
}, [markCanvasDirty, saveHistory, scheduleFlush, selectedLayerId]);
// Shortcuts
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | null;
const isTypingTarget =
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement instanceof HTMLSelectElement ||
activeElement?.isContentEditable;
// 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));
}
if (
!isTypingTarget &&
(e.key === "Delete" || e.key === "Backspace") &&
selectedLayerId !== DRAW_LAYER_ID
) {
e.preventDefault();
deleteSelectedImageLayer();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [deleteSelectedImageLayer, open, redo, selectedLayerId, undo]);
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 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 handleLayerSplitUpload = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
setIsLayerSplitImporting(true);
try {
const importedCount = await importFilesAsSplitLayers(files);
toast.success(
importedCount === 1
? "图层拆分导入完成"
: `图层拆分完成,已导入 ${importedCount} 个图层`
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "图层拆分导入失败"
);
} finally {
setIsLayerSplitImporting(false);
event.currentTarget.value = "";
}
},
[importFilesAsSplitLayers]
);
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 handleApplyToCanvas = useCallback(() => {
if (!previewUrl) {
toast.error("请先生成预览结果");
return;
}
onApplyResult({
imageUrl: previewUrl,
prompt: prompt.trim() || FALLBACK_PROMPT,
title: "实时画布结果",
model: "bfl/flux-2",
});
onOpenChange(false);
}, [onApplyResult, onOpenChange, previewUrl, prompt]);
const handleApplyEnhanceResult = useCallback(
(payload: {
imageUrl: string;
prompt: string;
title?: string;
model?: string;
width?: number;
height?: number;
}) => {
onApplyResult(payload);
onOpenChange(false);
},
[onApplyResult, onOpenChange]
);
const handleDownloadPreview = useCallback(() => {
if (!previewUrl) {
toast.error("请先生成预览结果");
return;
}
const anchor = document.createElement("a");
anchor.href = /^https?:\/\//i.test(previewUrl)
? buildImageProxyUrl(previewUrl)
: previewUrl;
anchor.download = `realtime-preview-${Date.now()}.png`;
anchor.rel = "noopener";
anchor.click();
}, [previewUrl]);
const handleCloseRealtime = useCallback(() => {
if (standalone && typeof window !== "undefined") {
if (window.history.length > 1) {
window.history.back();
return;
}
window.location.assign("/canvas");
return;
}
onOpenChange(false);
}, [onOpenChange, standalone]);
const handleGoHome = useCallback(() => {
if (typeof window === "undefined") return;
window.location.assign("/");
}, []);
const closeBrushSettingPopovers = useCallback(() => {
setBrushPopoverOpen(false);
setEraserPopoverOpen(false);
}, []);
const activateSelectMode = useCallback(() => {
setSelectedLayerId(DRAW_LAYER_ID);
setBrushMode("select");
closeBrushSettingPopovers();
}, [closeBrushSettingPopovers]);
const activateBrushMode = useCallback(() => {
setSelectedLayerId(DRAW_LAYER_ID);
setBrushMode("brush");
setEraserPopoverOpen(false);
setBrushPopoverOpen(true);
}, []);
const activateEraserMode = useCallback(() => {
setSelectedLayerId(DRAW_LAYER_ID);
setBrushMode("eraser");
setBrushPopoverOpen(false);
setEraserPopoverOpen(true);
}, []);
const activateShapeMode = useCallback(
(mode: Extract<RealtimeBrushMode, "rect" | "circle" | "line">) => {
setSelectedLayerId(DRAW_LAYER_ID);
setBrushMode(mode);
closeBrushSettingPopovers();
},
[closeBrushSettingPopovers]
);
const startBlankProject = useCallback(() => {
setPrompt("");
setPreviewUrl("");
setProjectPanelOpen(false);
closeBrushSettingPopovers();
setSelectedLayerId(DRAW_LAYER_ID);
drawingHasContentRef.current = false;
hasLoadedInitialImageRef.current = true;
resetCanvas();
clearPreviewCanvas();
dirtyRef.current = false;
dirtyRegionRef.current = null;
setStatus("idle");
setStatusMessage("新建空白项目");
}, [clearPreviewCanvas, closeBrushSettingPopovers, resetCanvas]);
const floatingSurfaceClass =
"border border-[#eadfcb] bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(255,247,236,0.94))] shadow-[0_0_0_1px_rgba(234,223,203,0.82),0_20px_42px_rgba(15,23,42,0.08)] backdrop-blur-xl";
const controlSurfaceClass =
"border border-[#eadfcb] bg-[#fffdfa] text-[#1c1c1e] shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_1px_2px_rgba(28,28,30,0.04)]";
const softActionButtonClass =
"border border-[#dbe2ff] bg-[linear-gradient(180deg,#f4f7ff_0%,#e9efff_100%)] text-[#5b76fe] shadow-[0_12px_24px_rgba(91,118,254,0.10)] transition hover:bg-[#e7ecff] hover:text-[#2a41b6]";
const primaryActionButtonClass =
"border border-[#5b76fe] bg-[linear-gradient(180deg,#6d87ff_0%,#516bf5_100%)] text-white shadow-[0_16px_30px_rgba(91,118,254,0.22)] transition hover:border-[#2a41b6] hover:bg-[linear-gradient(180deg,#5d78ff_0%,#2a41b6_100%)]";
const compactToolButtonClass =
"h-9 w-9 rounded-[16px] border border-transparent text-[#555a6a] transition hover:border-[#dbe2ff] hover:bg-[#eef2ff] hover:text-[#5b76fe]";
const compactToolButtonActiveClass =
"border-[#dbe2ff] bg-[#eef2ff] text-[#5b76fe] shadow-[0_10px_20px_rgba(91,118,254,0.14)] hover:bg-[#e7ecff] hover:text-[#5b76fe]";
const currentModeLabel =
brushMode === "select"
? "鼠标"
: brushMode === "brush"
? "画笔"
: brushMode === "eraser"
? "橡皮"
: brushMode === "rect"
? "矩形"
: brushMode === "circle"
? "圆形"
: "直线";
const currentModeDescription =
brushMode === "select"
? "选择图层并拖动位置"
: brushMode === "brush"
? "在画布上自由绘制"
: brushMode === "eraser"
? "擦除已有草图笔触"
: brushMode === "rect"
? "绘制矩形轮廓"
: brushMode === "circle"
? "绘制圆形轮廓"
: "绘制直线轮廓";
const content = (
<>
{standalone ? (
<div className="sr-only">
<h2>实时画布</h2>
<p>左侧绘制草图,右侧实时预览。</p>
</div>
) : (
<DialogHeader className="sr-only">
<DialogTitle>实时画布</DialogTitle>
<DialogDescription>左侧绘制草图,右侧实时预览。</DialogDescription>
</DialogHeader>
)}
<div className="pointer-events-none absolute top-6 left-6 right-6 z-50 flex flex-wrap items-start gap-3">
<div
className={cn(
"pointer-events-auto inline-flex max-w-full items-center gap-2 rounded-[30px] p-2",
floatingSurfaceClass
)}
>
<button
className="inline-flex h-12 shrink-0 items-center gap-3 rounded-[22px] border border-white/90 bg-white/80 px-4 text-left text-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white"
onClick={handleGoHome}
title="返回首页"
type="button"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#eef2ff] shadow-[0_8px_18px_rgba(91,118,254,0.14)]">
{siteLogo ? (
<img
alt={siteName}
className="h-6 w-6 rounded-full object-contain"
src={
/^https?:\/\//i.test(siteLogo)
? buildImageProxyUrl(siteLogo)
: siteLogo
}
/>
) : (
<Sparkles className="h-3.5 w-3.5 text-[#1d4ed8]" />
)}
</span>
<span className="font-semibold text-[14px] tracking-[-0.02em]">
{siteName}
</span>
</button>
<div className="inline-flex h-12 min-w-0 items-center gap-1 rounded-[22px] border border-white/90 bg-white/72 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
<button
className={cn(
"inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
workspaceMode === "realtime"
? softActionButtonClass
: "border border-transparent bg-transparent text-slate-600 hover:bg-white hover:text-foreground"
)}
onClick={() => setWorkspaceMode("realtime")}
type="button"
>
<Paintbrush className="h-4 w-4" />
实时画布模式
</button>
<button
className={cn(
"inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
workspaceMode === "enhance-pro"
? softActionButtonClass
: "border border-transparent bg-transparent text-slate-600 hover:bg-white hover:text-foreground"
)}
onClick={() => setWorkspaceMode("enhance-pro")}
type="button"
>
<Wand2 className="h-4 w-4" />
图像增强模式
</button>
</div>
</div>
</div>
{saveProjectDialogOpen ? (
<div
className={cn(
"-translate-x-1/2 absolute top-24 left-1/2 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] p-4",
floatingSurfaceClass
)}
>
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-foreground text-sm">
保存项目
</div>
<div className="text-muted-foreground text-xs">
请输入项目名称后保存当前画布
</div>
</div>
<Button
className="h-8 w-8 rounded-full"
onClick={() => setSaveProjectDialogOpen(false)}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<Input
autoFocus
className={cn("mt-4 h-11 rounded-2xl", controlSurfaceClass)}
onChange={(event) => setPendingProjectName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void confirmSaveCurrentProject();
}
}}
placeholder="请输入项目名称"
value={pendingProjectName}
/>
<div className="mt-4 flex gap-2">
<Button
className={cn("h-10 flex-1 rounded-2xl", controlSurfaceClass)}
onClick={() => setSaveProjectDialogOpen(false)}
type="button"
variant="outline"
>
取消
</Button>
<Button
className={cn("h-10 flex-1 rounded-2xl", primaryActionButtonClass)}
onClick={() => void confirmSaveCurrentProject()}
type="button"
>
保存
</Button>
</div>
</div>
) : null}
{projectPanelOpen ? (
<div
className={cn(
"absolute top-20 left-4 z-40 w-[22rem] max-w-[calc(100vw-2rem)] rounded-[24px] p-4",
floatingSurfaceClass
)}
>
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-foreground text-sm">
项目列表
</div>
<div className="text-muted-foreground text-xs">
本地保存,便于下次继续编辑
</div>
</div>
<Button
className="h-8 w-8 rounded-full"
onClick={() => setProjectPanelOpen(false)}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="mt-4 max-h-[55vh] min-h-0 space-y-2 overflow-y-auto overscroll-contain pr-1">
{savedProjects.length === 0 ? (
<div className="rounded-2xl border border-[#e7e1d4] border-dashed px-4 py-8 text-center text-muted-foreground text-sm">
暂无保存的项目
</div>
) : (
savedProjects.map((project) => (
<div
className="rounded-2xl border border-[#e7e1d4] bg-[#fffdfa] p-3 shadow-sm"
key={project.id}
>
<div className="flex items-center gap-3">
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-[#e7e1d4] bg-muted">
{project.coverImageUrl || project.previewUrl ? (
<img
alt={project.name}
className="h-full w-full object-contain"
src={
/^https?:\/\//i.test(
project.coverImageUrl || project.previewUrl || ""
)
? buildImageProxyUrl(
project.coverImageUrl ||
project.previewUrl ||
""
)
: project.coverImageUrl ||
project.previewUrl ||
""
}
/>
) : (
<div className="flex h-full items-center justify-center text-[11px] text-muted-foreground">
无预览
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground text-sm leading-5">
{project.name}
</div>
<div className="mt-2 flex items-center justify-end gap-2">
<Button
className={cn(
"h-8 rounded-xl px-4",
softActionButtonClass
)}
onClick={() => void openSavedProject(project.id)}
size="sm"
type="button"
>
打开
</Button>
<Button
className={cn("h-8 rounded-xl px-4", controlSurfaceClass)}
onClick={() => deleteSavedProject(project.id)}
size="sm"
type="button"
variant="outline"
>
删除
</Button>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
) : null}
{workspaceMode === "enhance-pro" ? (
<EnhanceProWorkspace
onApplyResult={handleApplyEnhanceResult}
sourceImageUrl={enhanceSourceImageUrl}
/>
) : (
<>
{/* Main Canvas Area - Split View */}
<div className="relative flex h-full w-full bg-muted/30">
{/* Left: Input Canvas */}
<div className="relative flex flex-1 flex-col items-center justify-center overflow-hidden border-[#e7e1d4] border-r">
<div
className="relative flex h-full w-full items-center justify-center bg-background"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDropUpload}
ref={canvasWrapRef}
>
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.03]" />
{isDragImportActive ? (
<div className="absolute inset-6 z-20 flex items-center justify-center rounded-[28px] border-2 border-sky-400 border-dashed bg-sky-50/90 font-medium text-sky-700 text-sm shadow-inner">
松开鼠标,上传图片到图层
</div>
) : null}
<div className="relative">
<canvas
className="pointer-events-none block shadow-sm"
ref={imageLayerCanvasRef}
/>
<canvas
className={cn(
"absolute inset-0 block touch-none shadow-sm",
brushMode === "select" && selectedImageLayer
? "cursor-move"
: brushMode === "brush" || brushMode === "eraser"
? "cursor-none"
: brushMode === "select"
? "cursor-default"
: "cursor-crosshair"
)}
onPointerCancel={handlePointerUp}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
ref={canvasRef}
/>
<canvas
className="pointer-events-none absolute inset-0 touch-none"
ref={previewCanvasRef}
/>
{selectedImageLayer && brushMode === "select" ? (
<div
className="pointer-events-none absolute border border-sky-500 border-dashed 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}
{selectedImageLayer && brushMode === "select"
? (
[
{
key: "nw",
left: selectedImageLayer.x,
top: selectedImageLayer.y,
},
{
key: "ne",
left:
selectedImageLayer.x + selectedImageLayer.width,
top: selectedImageLayer.y,
},
{
key: "sw",
left: selectedImageLayer.x,
top:
selectedImageLayer.y + selectedImageLayer.height,
},
{
key: "se",
left:
selectedImageLayer.x + selectedImageLayer.width,
top:
selectedImageLayer.y + selectedImageLayer.height,
},
] as const
).map((handle) => (
<div
className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute z-20 h-3.5 w-3.5 rounded-full border border-[#e7e1d4] bg-background shadow-[0_2px_8px_rgba(15,23,42,0.18)]"
key={handle.key}
style={{
left: handle.left,
top: handle.top,
}}
/>
))
: null}
{/* Custom Brush Cursor */}
<div
className="pointer-events-none absolute top-0 left-0 z-10 rounded-full border border-zinc-500/50 bg-zinc-500/10 backdrop-invert transition-opacity duration-75"
ref={cursorRef}
style={{
width: brushSize,
height: brushSize,
opacity: 0,
willChange: "transform, width, height",
}}
/>
</div>
</div>
<div
className={cn(
"absolute top-28 bottom-4 left-4 z-10 flex w-16 max-w-[18vw] flex-col rounded-[24px] p-2",
floatingSurfaceClass
)}
>
<div className="flex items-center justify-center border-[#e7e1d4] border-b pb-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-foreground">
<Layers3 className="h-4 w-4" />
</div>
</div>
<div className="mt-2 flex flex-1 flex-col items-center space-y-2 overflow-y-auto py-1">
{imageLayers.length === 0 ? (
<div className="rounded-2xl border border-[#e7e1d4] border-dashed px-1 py-4 text-center text-[10px] text-muted-foreground leading-4">
暂无图层
</div>
) : (
[...imageLayers].reverse().map((layer) => (
<button
className={cn(
"flex h-12 w-12 shrink-0 items-center justify-center rounded-[18px] p-1.5 transition hover:bg-muted",
selectedLayerId === layer.id
? "bg-muted shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]"
: ""
)}
draggable
key={layer.id}
onClick={() => setSelectedLayerId(layer.id)}
onDragEnd={() => {
sidebarDraggingLayerIdRef.current = null;
}}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDragStart={() => {
sidebarDraggingLayerIdRef.current = layer.id;
}}
onDrop={(event) => {
event.preventDefault();
const draggingId = sidebarDraggingLayerIdRef.current;
if (draggingId) {
reorderLayersFromSidebar(draggingId, layer.id);
}
sidebarDraggingLayerIdRef.current = null;
}}
title={layer.name}
type="button"
>
<img
alt={layer.name}
className="h-full w-full rounded-[14px] object-contain"
src={
/^https?:\/\//i.test(layer.src)
? buildImageProxyUrl(layer.src)
: layer.src
}
/>
</button>
))
)}
</div>
<div className="mt-2 flex flex-col gap-2">
<Button
className={cn(
"h-10 rounded-xl px-0 hover:bg-[#f7f3eb]",
controlSurfaceClass
)}
disabled={isLayerSplitImporting}
onClick={() => splitUploadInputRef.current?.click()}
title="图层拆分导入"
type="button"
variant="outline"
>
{isLayerSplitImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Layers3 className="h-4 w-4" />
)}
</Button>
<Button
className={cn(
"h-10 rounded-xl px-0 hover:bg-[#f7f3eb]",
controlSurfaceClass
)}
disabled={isLayerSplitImporting}
onClick={() => uploadInputRef.current?.click()}
title="添加图片图层"
type="button"
variant="outline"
>
<Upload className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Right: Output Preview */}
<div className="relative flex flex-1 flex-col items-center justify-center overflow-hidden bg-muted/10">
<div className="relative flex h-full w-full items-center justify-center">
{previewUrl ? (
<img
alt="实时预览"
className="max-h-full max-w-full object-contain shadow-sm"
src={previewUrl}
/>
) : (
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
{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" />
</>
)}
</div>
)}
{previewUrl ? (
<div className="absolute right-5 bottom-5">
<Button
className={cn(
"h-10 rounded-full px-4 font-medium text-sm",
softActionButtonClass
)}
onClick={handleDownloadPreview}
size="sm"
type="button"
>
下载图片
</Button>
</div>
) : null}
</div>
</div>
</div>
<input
accept="image/*"
className="hidden"
multiple
onChange={handleSketchUpload}
ref={uploadInputRef}
type="file"
/>
<input
accept="image/*"
className="hidden"
multiple
onChange={handleLayerSplitUpload}
ref={splitUploadInputRef}
type="file"
/>
<div className="-translate-x-1/2 pointer-events-none absolute bottom-4 left-1/2 z-30 flex w-[min(600px,calc(100vw-2rem))] flex-col items-center gap-2.5">
<TooltipProvider delayDuration={120}>
<div
className={cn(
"pointer-events-auto flex flex-wrap items-center justify-center gap-1 rounded-[24px] p-1.5",
floatingSurfaceClass
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
compactToolButtonClass,
brushMode === "select" && compactToolButtonActiveClass
)}
onClick={activateSelectMode}
size="icon"
type="button"
variant="ghost"
>
<MousePointer2 className="h-4.5 w-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">鼠标</div>
<div className="text-muted-foreground text-xs">
选择图层并拖动位置
</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<Popover
onOpenChange={setBrushPopoverOpen}
open={brushPopoverOpen}
>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
className={cn(
compactToolButtonClass,
brushMode === "brush" &&
compactToolButtonActiveClass
)}
onClick={activateBrushMode}
size="icon"
type="button"
variant="ghost"
>
<Paintbrush className="h-4.5 w-4.5" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent
className={cn(
"w-[250px] rounded-[18px] p-4 backdrop-blur-md",
floatingSurfaceClass
)}
>
<div className="space-y-4">
<div className="space-y-2">
<div className="font-medium text-muted-foreground text-xs">
颜色
</div>
<button
className={cn(
"flex h-10 w-full items-center justify-between rounded-xl px-3 font-medium text-sm",
controlSurfaceClass
)}
onClick={() => colorInputRef.current?.click()}
type="button"
>
<span>选择颜色</span>
<span
className="h-5 w-5 rounded-full border border-[#e7e1d4] shadow-inner"
style={{ backgroundColor: brushColor }}
/>
</button>
<input
className="sr-only"
onChange={(event) =>
setBrushColor(event.target.value)
}
ref={colorInputRef}
title="自定义颜色"
type="color"
value={brushColor}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between font-medium text-muted-foreground text-xs">
<span>粗细</span>
<span className="w-8 text-right text-foreground">
{brushSize}
</span>
</div>
<Slider
className="w-full"
max={50}
min={2}
onValueChange={([val]) => setBrushSize(val)}
rangeClassName="bg-[#7da2ff]"
step={1}
thumbClassName="h-5 w-5 border border-[#d8e4ff] bg-[#eef4ff] shadow-sm"
trackClassName="h-2.5 bg-muted shadow-inner"
value={[brushSize]}
/>
</div>
</div>
</PopoverContent>
</Popover>
<TooltipContent>
<div className="font-medium text-sm">画笔</div>
<div className="text-muted-foreground text-xs">
点击后直接调整颜色和粗细
</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<Popover
onOpenChange={setEraserPopoverOpen}
open={eraserPopoverOpen}
>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
className={cn(
compactToolButtonClass,
brushMode === "eraser" &&
compactToolButtonActiveClass
)}
onClick={activateEraserMode}
size="icon"
type="button"
variant="ghost"
>
<Eraser className="h-4.5 w-4.5" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent
className={cn(
"w-[220px] rounded-[18px] p-4 backdrop-blur-md",
floatingSurfaceClass
)}
>
<div className="space-y-2">
<div className="flex items-center justify-between font-medium text-muted-foreground text-xs">
<span>橡皮粗细</span>
<span className="w-8 text-right text-foreground">
{brushSize}
</span>
</div>
<Slider
className="w-full"
max={50}
min={2}
onValueChange={([val]) => setBrushSize(val)}
rangeClassName="bg-[#7da2ff]"
step={1}
thumbClassName="h-5 w-5 border border-[#d8e4ff] bg-[#eef4ff] shadow-sm"
trackClassName="h-2.5 bg-muted shadow-inner"
value={[brushSize]}
/>
</div>
</PopoverContent>
</Popover>
<TooltipContent>
<div className="font-medium text-sm">橡皮</div>
<div className="text-muted-foreground text-xs">
点击后直接调整橡皮粗细
</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<Popover>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
className={cn(
compactToolButtonClass,
isShapeMode(brushMode) &&
compactToolButtonActiveClass
)}
size="icon"
type="button"
variant="ghost"
>
<Square className="h-4.5 w-4.5" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent
className={cn(
"w-[220px] rounded-[18px] p-3 backdrop-blur-md",
floatingSurfaceClass
)}
>
<div className="mb-2 font-medium text-muted-foreground text-xs">
形状工具
</div>
<div className="grid grid-cols-3 gap-2">
<Button
className={cn(
"h-10 rounded-xl border",
brushMode === "rect"
? softActionButtonClass
: "border-transparent text-slate-600 hover:bg-[#f7f3eb] hover:text-foreground"
)}
onClick={() => activateShapeMode("rect")}
type="button"
variant="ghost"
>
<Square className="h-4.5 w-4.5" />
</Button>
<Button
className={cn(
"h-10 rounded-xl border",
brushMode === "circle"
? softActionButtonClass
: "border-transparent text-slate-600 hover:bg-[#f7f3eb] hover:text-foreground"
)}
onClick={() => activateShapeMode("circle")}
type="button"
variant="ghost"
>
<CircleIcon className="h-4.5 w-4.5" />
</Button>
<Button
className={cn(
"h-10 rounded-xl border",
brushMode === "line"
? softActionButtonClass
: "border-transparent text-slate-600 hover:bg-[#f7f3eb] hover:text-foreground"
)}
onClick={() => activateShapeMode("line")}
type="button"
variant="ghost"
>
<Slash className="h-4.5 w-4.5" />
</Button>
</div>
</PopoverContent>
</Popover>
<TooltipContent>
<div className="font-medium text-sm">形状</div>
<div className="text-muted-foreground text-xs">
矩形、圆形与直线
</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={compactToolButtonClass}
onClick={() => void saveCurrentProject()}
size="icon"
type="button"
variant="ghost"
>
<Save className="h-4.5 w-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">保存项目</div>
<div className="text-muted-foreground text-xs">
保存当前画布状态
</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={compactToolButtonClass}
onClick={() => {
refreshSavedProjects();
setProjectPanelOpen((openState) => !openState);
}}
size="icon"
type="button"
variant="ghost"
>
<FolderOpen className="h-4.5 w-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">项目列表</div>
<div className="text-muted-foreground text-xs">
打开已保存项目
</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={compactToolButtonClass}
onClick={startBlankProject}
size="icon"
type="button"
variant="ghost"
>
<Plus className="h-4.5 w-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">新建空白</div>
<div className="text-muted-foreground text-xs">
清空并开始新项目
</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={compactToolButtonClass}
disabled={!canUndo}
onClick={undo}
size="icon"
type="button"
variant="ghost"
>
<Undo2 className="h-4.5 w-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">撤销</div>
<div className="text-muted-foreground text-xs">
回退上一步编辑
</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={compactToolButtonClass}
disabled={!canRedo}
onClick={redo}
size="icon"
type="button"
variant="ghost"
>
<Redo2 className="h-4.5 w-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">重做</div>
<div className="text-muted-foreground text-xs">
恢复已撤销操作
</div>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<div
className={cn(
"pointer-events-auto w-full rounded-[24px] p-4",
floatingSurfaceClass
)}
>
<Textarea
className="min-h-[56px] resize-none border-0 bg-transparent px-1 py-0.5 text-[15px] leading-7 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onBlur={() => scheduleFlush(true, false)}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(event) => {
if (
(event.metaKey || event.ctrlKey) &&
event.key === "Enter"
) {
event.preventDefault();
triggerGenerate();
}
}}
placeholder={FALLBACK_PROMPT}
value={prompt}
/>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-6 items-center gap-1.5 rounded-full px-2.5 font-medium text-xs",
statusBadge.className
)}
>
<div
className={cn(
"h-1.5 w-1.5 rounded-full",
status === "live" || status === "connecting"
? "animate-pulse bg-current"
: "bg-current"
)}
/>
{statusBadge.label}
</div>
<div className="flex h-6 items-center rounded-full bg-muted px-2.5 font-medium text-muted-foreground text-xs">
延迟 {adaptiveThrottleMs}ms
</div>
</div>
<div className="flex items-center gap-2">
<Button
className={cn(
"h-9 shrink-0 rounded-full px-4 font-medium",
primaryActionButtonClass
)}
disabled={status === "connecting"}
onClick={triggerGenerate}
size="sm"
>
{status === "connecting" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"生成"
)}
</Button>
<Button
className={cn(
"h-9 shrink-0 rounded-full px-4 font-medium",
softActionButtonClass
)}
disabled={!previewUrl}
onClick={handleApplyToCanvas}
size="sm"
variant="outline"
>
<Send className="mr-1.5 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
</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 onOpenChange={onOpenChange} open={open}>
<DialogContent className="h-[97vh] 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>
);
}