3916 lines
131 KiB
TypeScript
3916 lines
131 KiB
TypeScript
"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>
|
||
);
|
||
}
|