From b2d53444ae2ae386cbd1023c899fb0ee04261e5c Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 8 Mar 2026 00:04:58 +0800 Subject: [PATCH] feat: upgrade realtime canvas project workflow --- components/canvas/RealtimeCanvasDialog.tsx | 1274 +++++++++++++++++--- 1 file changed, 1127 insertions(+), 147 deletions(-) diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 7321b33e0..dc3c1eb28 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -22,15 +22,21 @@ import { Slider } from "@/components/ui/slider"; import { cn } from "@/lib/utils"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { + FolderOpen, Eraser, + Layers3, Loader2, Paintbrush, + Plus, Square, Circle as CircleIcon, Slash, Upload, + ChevronDown, + ChevronUp, RefreshCw, Sparkles, + Save, Trash2, Download, Undo2, @@ -65,6 +71,37 @@ type RealtimeConfig = { type RealtimeStatus = "idle" | "connecting" | "live" | "error"; +type ImageLayer = { + id: string; + name: string; + src: string; + x: number; + y: number; + width: number; + height: number; + baseWidth: number; + baseHeight: number; +}; + +type HistorySnapshot = { + imageData: ImageData; + imageLayers: ImageLayer[]; + canvasBackground: string; +}; + +type SavedRealtimeProject = { + id: string; + name: string; + updatedAt: string; + prompt: string; + brushColor: string; + brushSize: number; + canvasBackground: string; + imageLayers: ImageLayer[]; + drawingLayerDataUrl: string; + previewUrl?: string; +}; + type RealtimeCanvasDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -76,15 +113,31 @@ type RealtimeCanvasDialogProps = { const FALLBACK_PROMPT = "严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。"; const CANVAS_BACKGROUND = "#ffffff"; -const BRUSH_COLOR_PRESETS = [ - "#18181b", - "#ef4444", - "#f59e0b", - "#22c55e", - "#3b82f6", - "#a855f7", -] as const; +const DEFAULT_BRUSH_COLOR = "#18181b"; +const DRAW_LAYER_ID = "__draw_layer__"; const CANVAS_BACKGROUND_PRESETS = ["#ffffff", "#f4f4f5", "#fdf2f8", "#ecfeff", "#f0fdf4"] as const; +const REALTIME_PROJECTS_STORAGE_KEY = "ai-chatbot:realtime-canvas-projects"; + +function cloneImageLayers(layers: ImageLayer[]) { + return layers.map((layer) => ({ ...layer })); +} + +function readRealtimeProjectsFromStorage() { + if (typeof window === "undefined") return [] as SavedRealtimeProject[]; + try { + const raw = window.localStorage.getItem(REALTIME_PROJECTS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as SavedRealtimeProject[]) : []; + } catch { + return []; + } +} + +function writeRealtimeProjectsToStorage(projects: SavedRealtimeProject[]) { + if (typeof window === "undefined") return; + window.localStorage.setItem(REALTIME_PROJECTS_STORAGE_KEY, JSON.stringify(projects)); +} const defaultRealtimeConfig: RealtimeConfig = { transport: { @@ -117,6 +170,14 @@ function createSessionSeed() { return Math.floor(Math.random() * 2147483647); } +function imageDataHasVisiblePixels(imageData: ImageData) { + const pixels = imageData.data; + for (let index = 3; index < pixels.length; index += 4) { + if (pixels[index] > 0) return true; + } + return false; +} + async function canvasToJpegDataUri( canvas: HTMLCanvasElement, maxInputResolution: number, @@ -576,10 +637,18 @@ export function RealtimeCanvasDialog({ standalone = false, }: RealtimeCanvasDialogProps) { const canvasRef = useRef(null); + const imageLayerCanvasRef = useRef(null); const previewCanvasRef = useRef(null); const canvasWrapRef = useRef(null); const connectionRef = useRef(null); const pointerDownRef = useRef(false); + const draggingLayerIdRef = useRef(null); + const layerDragOriginRef = useRef<{ + pointerX: number; + pointerY: number; + layerX: number; + layerY: number; + } | null>(null); const lastPointRef = useRef<{ x: number; y: number } | null>(null); const shapeStartPointRef = useRef<{ x: number; y: number } | null>(null); const pendingFlushTimerRef = useRef(null); @@ -596,14 +665,20 @@ export function RealtimeCanvasDialog({ const seqRef = useRef(0); const requestStartMapRef = useRef>(new Map()); const lastRenderedSeqRef = useRef(0); + const layerRenderSeqRef = useRef(0); const metricsQueueRef = useRef([]); const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); const seedRef = useRef(createSessionSeed()); const hasLoadedInitialImageRef = useRef(false); const canvasBackgroundRef = useRef(CANVAS_BACKGROUND); + const imageLayersRef = useRef([]); + const colorInputRef = useRef(null); + const layerObjectUrlsRef = useRef>(new Set()); + const drawingHasContentRef = useRef(false); + const dragDepthRef = useRef(0); // History Management - const historyRef = useRef([]); + const historyRef = useRef([]); const historyIndexRef = useRef(-1); const cursorRef = useRef(null); const uploadInputRef = useRef(null); @@ -616,9 +691,14 @@ export function RealtimeCanvasDialog({ const [prompt, setPrompt] = useState(FALLBACK_PROMPT); const [previewUrl, setPreviewUrl] = useState(""); const [brushMode, setBrushMode] = useState<"brush" | "eraser" | "rect" | "circle" | "line">("brush"); - const [brushColor, setBrushColor] = useState(BRUSH_COLOR_PRESETS[0]); + const [brushColor, setBrushColor] = useState(DEFAULT_BRUSH_COLOR); const [canvasBackground, setCanvasBackground] = useState(canvasBackgroundRef.current); + const [imageLayers, setImageLayers] = useState([]); + const [selectedLayerId, setSelectedLayerId] = useState(DRAW_LAYER_ID); const [brushSize, setBrushSize] = useState(6); + const [savedProjects, setSavedProjects] = useState([]); + const [projectPanelOpen, setProjectPanelOpen] = useState(false); + const [isDragImportActive, setIsDragImportActive] = useState(false); const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState( defaultRealtimeConfig.transport.throttleInterval ); @@ -633,6 +713,16 @@ export function RealtimeCanvasDialog({ return { label: "待启动", className: "bg-zinc-100 text-zinc-600" }; }, [status]); + const selectedImageLayer = useMemo( + () => imageLayers.find((layer) => layer.id === selectedLayerId) || null, + [imageLayers, selectedLayerId] + ); + const selectedImageLayerScale = useMemo(() => { + if (!selectedImageLayer) return 1; + const baseWidth = selectedImageLayer.baseWidth || selectedImageLayer.width || 1; + return selectedImageLayer.width / baseWidth; + }, [selectedImageLayer]); + const pushMetric = useCallback((event: any) => { metricsQueueRef.current.push(event); if (metricsQueueRef.current.length > 120) { @@ -657,7 +747,229 @@ export function RealtimeCanvasDialog({ } }, [open]); - const saveHistory = useCallback(() => { + 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((resolve, reject) => { + const image = new Image(); + const resolvedSrc = /^https?:\/\//i.test(src) ? buildImageProxyUrl(src) : src; + image.crossOrigin = "anonymous"; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("图层图片加载失败")); + image.src = resolvedSrc; + }); + }, []); + + const drawImageLayersToContext = useCallback( + async ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + options?: { renderSeq?: number } + ) => { + ctx.clearRect(0, 0, width, height); + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = canvasBackgroundRef.current; + ctx.fillRect(0, 0, width, height); + + for (const layer of imageLayersRef.current) { + try { + const image = await loadRenderableImage(layer.src); + if (options?.renderSeq && options.renderSeq !== layerRenderSeqRef.current) { + ctx.restore(); + return false; + } + ctx.drawImage(image, layer.x, layer.y, layer.width, layer.height); + } catch (error) { + console.error("[RealtimeCanvas] layer render failed:", error); + } + } + ctx.restore(); + return true; + }, + [loadRenderableImage] + ); + + const renderImageLayers = useCallback(async () => { + const layerCanvas = imageLayerCanvasRef.current; + if (!layerCanvas) return; + const ctx = layerCanvas.getContext("2d"); + if (!ctx) return; + const renderSeq = layerRenderSeqRef.current + 1; + layerRenderSeqRef.current = renderSeq; + await drawImageLayersToContext(ctx, layerCanvas.width, layerCanvas.height, { renderSeq }); + }, [drawImageLayersToContext]); + + const buildCompositeCanvas = useCallback(async () => { + const drawingCanvas = canvasRef.current; + if (!drawingCanvas) return null; + + const compositeCanvas = document.createElement("canvas"); + compositeCanvas.width = drawingCanvas.width; + compositeCanvas.height = drawingCanvas.height; + const compositeCtx = compositeCanvas.getContext("2d"); + if (!compositeCtx) return null; + + const didRenderLayers = await drawImageLayersToContext( + compositeCtx, + compositeCanvas.width, + compositeCanvas.height + ); + if (!didRenderLayers) { + return null; + } + compositeCtx.drawImage(drawingCanvas, 0, 0); + return compositeCanvas; + }, [drawImageLayersToContext]); + + const createPlacedLayer = useCallback( + async (params: { src: string; name: string }) => { + const image = await loadRenderableImage(params.src); + const canvas = canvasRef.current; + if (!canvas) { + throw new Error("画布未初始化"); + } + + const rect = canvas.getBoundingClientRect(); + const canvasWidth = Math.max(1, rect.width); + const canvasHeight = Math.max(1, rect.height); + const maxWidth = canvasWidth * 0.82; + const maxHeight = canvasHeight * 0.82; + const scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1); + const drawWidth = Math.max(24, image.width * scale); + const drawHeight = Math.max(24, image.height * scale); + const offsetX = (canvasWidth - drawWidth) / 2; + const offsetY = (canvasHeight - drawHeight) / 2; + + return { + id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: params.name, + src: params.src, + x: offsetX, + y: offsetY, + width: drawWidth, + height: drawHeight, + baseWidth: drawWidth, + baseHeight: drawHeight, + } satisfies ImageLayer; + }, + [loadRenderableImage] + ); + + const findLayerAtPoint = useCallback((point: { x: number; y: number }) => { + for (let index = imageLayersRef.current.length - 1; index >= 0; index -= 1) { + const layer = imageLayersRef.current[index]; + const withinX = point.x >= layer.x && point.x <= layer.x + layer.width; + const withinY = point.y >= layer.y && point.y <= layer.y + layer.height; + if (withinX && withinY) { + return layer; + } + } + return null; + }, []); + + const updateLayerPosition = useCallback((layerId: string, x: number, y: number) => { + const nextLayers = imageLayersRef.current.map((layer) => + layer.id === layerId ? { ...layer, x, y } : layer + ); + imageLayersRef.current = nextLayers; + setImageLayers(nextLayers); + return nextLayers; + }, []); + + const scaleImageLayer = useCallback((layerId: string, nextScale: number) => { + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const canvasWidth = Math.max(1, rect.width); + const canvasHeight = Math.max(1, rect.height); + + const nextLayers = imageLayersRef.current.map((layer) => { + if (layer.id !== layerId) return layer; + + const baseWidth = layer.baseWidth || layer.width; + const baseHeight = layer.baseHeight || layer.height; + const normalizedScale = Math.min(3, Math.max(0.2, nextScale)); + const targetWidth = Math.min(canvasWidth, Math.max(24, baseWidth * normalizedScale)); + const targetHeight = Math.min(canvasHeight, Math.max(24, baseHeight * normalizedScale)); + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + const maxX = Math.max(0, canvasWidth - targetWidth); + const maxY = Math.max(0, canvasHeight - targetHeight); + + return { + ...layer, + width: targetWidth, + height: targetHeight, + x: Math.min(maxX, Math.max(0, centerX - targetWidth / 2)), + y: Math.min(maxY, Math.max(0, centerY - targetHeight / 2)), + }; + }); + + imageLayersRef.current = nextLayers; + setImageLayers(nextLayers); + return nextLayers; + }, []); + + const exportDrawingLayerDataUrl = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return ""; + return canvas.toDataURL("image/png"); + }, []); + + const drawingCanvasHasContent = useCallback((accurate = false) => { + if (!accurate) { + return drawingHasContentRef.current; + } + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d", { willReadFrequently: true }); + if (!canvas || !ctx) return false; + try { + const hasContent = imageDataHasVisiblePixels( + ctx.getImageData(0, 0, canvas.width, canvas.height) + ); + drawingHasContentRef.current = hasContent; + return hasContent; + } catch { + return drawingHasContentRef.current; + } + }, []); + + const hasRenderableInput = useCallback( + (accurate = false) => + imageLayersRef.current.length > 0 || drawingCanvasHasContent(accurate), + [drawingCanvasHasContent] + ); + + const fileToDataUrl = useCallback((file: File) => { + return new Promise((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 }); @@ -670,7 +982,12 @@ export function RealtimeCanvasDialog({ try { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - historyRef.current.push(imageData); + 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); @@ -687,6 +1004,7 @@ export function RealtimeCanvasDialog({ setCanRedo(false); }, []); + const markCanvasDirty = useCallback((region?: NormalizedRegion | null) => { dirtyRef.current = true; draftVersionRef.current += 1; @@ -721,15 +1039,33 @@ export function RealtimeCanvasDialog({ [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 canvas = canvasRef.current; - if (!canvas) return; + const compositeCanvas = await buildCompositeCanvas(); + if (!compositeCanvas) return; flushInFlightRef.current = true; const seq = seqRef.current + 1; @@ -743,7 +1079,7 @@ export function RealtimeCanvasDialog({ try { const exportedImage = await canvasToJpegDataUri( - canvas, + compositeCanvas, config.image.maxInputResolution, config.image.jpegQuality, canvasBackgroundRef.current, @@ -886,7 +1222,7 @@ export function RealtimeCanvasDialog({ } } }, - [adaptiveThrottleMs, config, open, prompt, pushMetric] + [adaptiveThrottleMs, buildCompositeCanvas, config, hasRenderableInput, open, prompt, pushMetric] ); const pumpFlush = useCallback(() => { @@ -935,6 +1271,152 @@ export function RealtimeCanvasDialog({ [adaptiveThrottleMs, markCanvasDirty, pumpFlush] ); + const importFilesAsLayers = useCallback( + async (files: File[]) => { + if (files.length === 0) return 0; + const invalidFile = files.find((file) => !file.type.startsWith("image/")); + if (invalidFile) { + throw new Error("请上传图片文件"); + } + + const nextLayerBatch: ImageLayer[] = []; + for (const file of files) { + const dataUrl = await fileToDataUrl(file); + const layer = await createPlacedLayer({ + src: dataUrl, + name: file.name || "粘贴图片", + }); + nextLayerBatch.push(layer); + } + + const nextLayers = [...imageLayersRef.current, ...nextLayerBatch]; + imageLayersRef.current = nextLayers; + setImageLayers(nextLayers); + const newestLayer = nextLayerBatch[nextLayerBatch.length - 1]; + if (newestLayer) { + setSelectedLayerId(newestLayer.id); + } + saveHistory(nextLayers); + markCanvasDirty(null); + scheduleFlush(true); + return nextLayerBatch.length; + }, + [createPlacedLayer, fileToDataUrl, markCanvasDirty, saveHistory, scheduleFlush] + ); + + const triggerGenerate = useCallback(() => { + if (!hasRenderableInput(true)) { + setStatus("idle"); + setStatusMessage("画布为空,先上传图片或绘制内容"); + toast.error("请先上传图片或绘制内容"); + return; + } + scheduleFlush(true); + }, [hasRenderableInput, scheduleFlush]); + + const refreshSavedProjects = useCallback(() => { + const projects = readRealtimeProjectsFromStorage().sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + setSavedProjects(projects); + return projects; + }, []); + + const loadDrawingLayerFromDataUrl = useCallback(async (dataUrl: string) => { + if (!dataUrl) { + clearDrawingCanvas(); + drawingHasContentRef.current = false; + return; + } + + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + + clearDrawingCanvas(); + const image = await loadRenderableImage(dataUrl); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + drawingHasContentRef.current = true; + }, [clearDrawingCanvas, loadRenderableImage]); + + const openSavedProject = useCallback( + async (projectId: string) => { + const project = readRealtimeProjectsFromStorage().find((item) => item.id === projectId); + if (!project) { + toast.error("未找到项目"); + return; + } + + canvasBackgroundRef.current = project.canvasBackground || CANVAS_BACKGROUND; + setCanvasBackground(project.canvasBackground || CANVAS_BACKGROUND); + const nextLayers = cloneImageLayers(project.imageLayers || []); + imageLayersRef.current = nextLayers; + setImageLayers(nextLayers); + setSelectedLayerId(nextLayers[0]?.id || DRAW_LAYER_ID); + setBrushColor(project.brushColor || DEFAULT_BRUSH_COLOR); + setBrushSize(project.brushSize || 6); + setPrompt(project.prompt || FALLBACK_PROMPT); + setPreviewUrl(project.previewUrl || ""); + await loadDrawingLayerFromDataUrl(project.drawingLayerDataUrl || ""); + saveHistory(nextLayers, project.canvasBackground || CANVAS_BACKGROUND); + setProjectPanelOpen(false); + markCanvasDirty(null); + scheduleFlush(true); + toast.success("项目已打开"); + }, + [loadDrawingLayerFromDataUrl, markCanvasDirty, saveHistory, scheduleFlush] + ); + + const deleteSavedProject = useCallback((projectId: string) => { + const nextProjects = readRealtimeProjectsFromStorage().filter((item) => item.id !== projectId); + writeRealtimeProjectsToStorage(nextProjects); + setSavedProjects(nextProjects); + toast.success("项目已删除"); + }, []); + + const saveCurrentProject = useCallback(async () => { + if (!hasRenderableInput(true)) { + toast.error("空白画布无需保存"); + return; + } + + const existingProjects = readRealtimeProjectsFromStorage(); + const projectId = `rtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const compositeCanvas = await buildCompositeCanvas(); + const project: SavedRealtimeProject = { + id: projectId, + name: + prompt.trim().slice(0, 24) || + `实时项目 ${new Date().toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })}`, + updatedAt: new Date().toISOString(), + prompt: prompt.trim() || FALLBACK_PROMPT, + brushColor, + brushSize, + canvasBackground: canvasBackgroundRef.current, + imageLayers: cloneImageLayers(imageLayersRef.current), + drawingLayerDataUrl: exportDrawingLayerDataUrl(), + previewUrl: previewUrl || compositeCanvas?.toDataURL("image/jpeg", 0.72) || "", + }; + const nextProjects = [project, ...existingProjects].slice(0, 30); + writeRealtimeProjectsToStorage(nextProjects); + setSavedProjects(nextProjects); + setProjectPanelOpen(true); + toast.success("项目已保存"); + }, [ + brushColor, + brushSize, + buildCompositeCanvas, + exportDrawingLayerDataUrl, + hasRenderableInput, + previewUrl, + prompt, + ]); + const scheduleFlushRef = useRef(scheduleFlush); const flushMetricsRef = useRef(flushMetrics); @@ -950,6 +1432,34 @@ export function RealtimeCanvasDialog({ 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; @@ -962,12 +1472,22 @@ export function RealtimeCanvasDialog({ if (historyIndexRef.current <= 0) return; historyIndexRef.current--; - const imageData = historyRef.current[historyIndexRef.current]; + const snapshot = historyRef.current[historyIndexRef.current]; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); - if (canvas && ctx && imageData) { - ctx.putImageData(imageData, 0, 0); + 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); @@ -978,12 +1498,22 @@ export function RealtimeCanvasDialog({ if (historyIndexRef.current >= historyRef.current.length - 1) return; historyIndexRef.current++; - const imageData = historyRef.current[historyIndexRef.current]; + const snapshot = historyRef.current[historyIndexRef.current]; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); - - if (canvas && ctx && imageData) { - ctx.putImageData(imageData, 0, 0); + + 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); @@ -991,74 +1521,69 @@ export function RealtimeCanvasDialog({ }, [scheduleFlush]); const resetCanvas = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.save(); - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = canvasBackgroundRef.current; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.restore(); - + clearDrawingCanvas(); + revokeAllLayerObjectUrls(); + setImageLayers([]); + imageLayersRef.current = []; + setSelectedLayerId(DRAW_LAYER_ID); + void renderImageLayers(); + // Clear history and save initial state historyRef.current = []; historyIndexRef.current = -1; saveHistory(); - }, [saveHistory]); + }, [clearDrawingCanvas, renderImageLayers, revokeAllLayerObjectUrls, saveHistory]); const drawInitialImage = useCallback(async () => { if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return false; - const canvas = canvasRef.current; - if (!canvas) return false; - const ctx = canvas.getContext("2d"); - if (!ctx) return false; - - const image = new Image(); - image.crossOrigin = "anonymous"; - const loadUrl = buildImageProxyUrl(initialImageUrl); - await new Promise((resolve, reject) => { - image.onload = () => resolve(); - image.onerror = () => reject(new Error("加载初始图片失败")); - image.src = loadUrl; + const nextLayer = await createPlacedLayer({ + src: initialImageUrl, + name: "参考图", }); - - const scale = Math.min(canvas.width / image.width, canvas.height / image.height); - const drawWidth = image.width * scale; - const drawHeight = image.height * scale; - const offsetX = (canvas.width - drawWidth) / 2; - const offsetY = (canvas.height - drawHeight) / 2; - - ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); + const nextLayers = [nextLayer]; + setImageLayers(nextLayers); + imageLayersRef.current = nextLayers; + setSelectedLayerId(nextLayer.id); hasLoadedInitialImageRef.current = true; markCanvasDirty(null); - saveHistory(); // Save initial image state + saveHistory(nextLayers); // Save initial image state return true; - }, [initialImageUrl, markCanvasDirty, open, saveHistory]); + }, [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 || !previewCanvas || !wrap) return; + 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; @@ -1152,11 +1677,44 @@ export function RealtimeCanvasDialog({ if (!point) return; pointerDownRef.current = true; lastPointRef.current = point; + if (selectedImageLayer) { + const hitSelectedLayer = + point.x >= selectedImageLayer.x && + point.x <= selectedImageLayer.x + selectedImageLayer.width && + point.y >= selectedImageLayer.y && + point.y <= selectedImageLayer.y + selectedImageLayer.height; + + if (hitSelectedLayer) { + draggingLayerIdRef.current = selectedImageLayer.id; + layerDragOriginRef.current = { + pointerX: point.x, + pointerY: point.y, + layerX: selectedImageLayer.x, + layerY: selectedImageLayer.y, + }; + event.currentTarget.setPointerCapture(event.pointerId); + return; + } + } + const hitLayer = findLayerAtPoint(point); + if (hitLayer) { + setSelectedLayerId(hitLayer.id); + draggingLayerIdRef.current = hitLayer.id; + layerDragOriginRef.current = { + pointerX: point.x, + pointerY: point.y, + layerX: hitLayer.x, + layerY: hitLayer.y, + }; + event.currentTarget.setPointerCapture(event.pointerId); + return; + } if (isShapeMode(brushMode)) { shapeStartPointRef.current = point; clearPreviewCanvas(); } else { drawStroke(point, point); + drawingHasContentRef.current = true; const lineWidth = brushMode === "eraser" ? brushSize * 1.45 : brushSize; markCanvasDirtyStroke(point, point, lineWidth); scheduleFlush(false, false, "preview"); @@ -1168,8 +1726,10 @@ export function RealtimeCanvasDialog({ brushSize, clearPreviewCanvas, drawStroke, + findLayerAtPoint, getCanvasPoint, markCanvasDirtyStroke, + selectedImageLayer, scheduleFlush, ] ); @@ -1179,7 +1739,12 @@ export function RealtimeCanvasDialog({ if (cursorRef.current) { cursorRef.current.style.opacity = '0'; } + if (draggingLayerIdRef.current) { + return; + } pointerDownRef.current = false; + draggingLayerIdRef.current = null; + layerDragOriginRef.current = null; lastPointRef.current = null; shapeStartPointRef.current = null; clearPreviewCanvas(); @@ -1190,9 +1755,10 @@ export function RealtimeCanvasDialog({ const handlePointerEnter = useCallback(() => { if (cursorRef.current) { - cursorRef.current.style.opacity = brushMode === "brush" || brushMode === "eraser" ? "1" : "0"; + cursorRef.current.style.opacity = + !selectedImageLayer && (brushMode === "brush" || brushMode === "eraser") ? "1" : "0"; } - }, [brushMode]); + }, [brushMode, selectedImageLayer]); const handlePointerMove = useCallback( (event: ReactPointerEvent) => { @@ -1200,7 +1766,8 @@ export function RealtimeCanvasDialog({ // Update cursor const point = getCanvasPoint(event); if (point && cursorRef.current) { - const showCursor = brushMode === "brush" || brushMode === "eraser"; + const showCursor = + !selectedImageLayer && (brushMode === "brush" || brushMode === "eraser"); if (showCursor) { cursorRef.current.style.opacity = "1"; cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`; @@ -1213,6 +1780,30 @@ export function RealtimeCanvasDialog({ if (!pointerDownRef.current) return; if (!point || !lastPointRef.current) return; + if (draggingLayerIdRef.current && layerDragOriginRef.current) { + const activeLayer = imageLayersRef.current.find( + (layer) => layer.id === draggingLayerIdRef.current + ); + const canvas = canvasRef.current; + if (!activeLayer || !canvas) return; + + const rect = canvas.getBoundingClientRect(); + const nextX = + layerDragOriginRef.current.layerX + (point.x - layerDragOriginRef.current.pointerX); + const nextY = + layerDragOriginRef.current.layerY + (point.y - layerDragOriginRef.current.pointerY); + const maxX = Math.max(0, rect.width - activeLayer.width); + const maxY = Math.max(0, rect.height - activeLayer.height); + + updateLayerPosition( + activeLayer.id, + Math.min(maxX, Math.max(0, nextX)), + Math.min(maxY, Math.max(0, nextY)) + ); + markCanvasDirty(null); + scheduleFlush(false, false, "preview"); + return; + } if (isShapeMode(brushMode) && shapeStartPointRef.current) { const previewCanvas = previewCanvasRef.current; const previewCtx = previewCanvas?.getContext("2d"); @@ -1222,6 +1813,7 @@ export function RealtimeCanvasDialog({ } } else { drawStroke(lastPointRef.current, point); + drawingHasContentRef.current = true; const lineWidth = brushMode === "eraser" ? brushSize * 1.45 : brushSize; markCanvasDirtyStroke(lastPointRef.current, point, lineWidth); } @@ -1235,7 +1827,10 @@ export function RealtimeCanvasDialog({ scheduleFlush, brushMode, brushSize, + markCanvasDirty, markCanvasDirtyStroke, + selectedImageLayer, + updateLayerPosition, ] ); @@ -1243,10 +1838,26 @@ export function RealtimeCanvasDialog({ (event: ReactPointerEvent) => { event.preventDefault(); pointerDownRef.current = false; + const wasDraggingLayer = draggingLayerIdRef.current; const endPoint = getCanvasPoint(event); const shapeStart = shapeStartPointRef.current; + if (wasDraggingLayer) { + draggingLayerIdRef.current = null; + layerDragOriginRef.current = null; + lastPointRef.current = null; + shapeStartPointRef.current = null; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // ignore + } + saveHistory(imageLayersRef.current); + scheduleFlush(true, false, "refine"); + return; + } if (isShapeMode(brushMode) && endPoint && shapeStart) { drawShape(shapeStart, endPoint); + drawingHasContentRef.current = true; const lineWidth = Math.max(1, brushSize * 0.9); const pad = Math.max(6, Math.ceil(lineWidth * 1.2)); markCanvasDirtyRect({ @@ -1259,6 +1870,7 @@ export function RealtimeCanvasDialog({ } lastPointRef.current = null; shapeStartPointRef.current = null; + layerDragOriginRef.current = null; try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { @@ -1465,90 +2077,127 @@ export function RealtimeCanvasDialog({ scheduleFlush(true, true, "refine"); }, [clearPreviewCanvas, resetCanvas, scheduleFlush]); + const moveImageLayer = useCallback( + (layerId: string, direction: "up" | "down") => { + const currentLayers = imageLayersRef.current; + const index = currentLayers.findIndex((layer) => layer.id === layerId); + if (index === -1) return; + + const targetIndex = direction === "up" ? index + 1 : index - 1; + if (targetIndex < 0 || targetIndex >= currentLayers.length) return; + + const nextLayers = cloneImageLayers(currentLayers); + const [layer] = nextLayers.splice(index, 1); + nextLayers.splice(targetIndex, 0, layer); + + imageLayersRef.current = nextLayers; + setImageLayers(nextLayers); + setSelectedLayerId(layerId); + saveHistory(nextLayers); + markCanvasDirty(null); + scheduleFlush(true, false, "refine"); + }, + [markCanvasDirty, saveHistory, scheduleFlush] + ); + + const removeImageLayer = useCallback( + (layerId: string) => { + const currentLayers = imageLayersRef.current; + const nextLayers = currentLayers.filter((layer) => layer.id !== layerId); + if (nextLayers.length === currentLayers.length) return; + + imageLayersRef.current = nextLayers; + setImageLayers(nextLayers); + setSelectedLayerId((current) => (current === layerId ? DRAW_LAYER_ID : current)); + saveHistory(nextLayers); + markCanvasDirty(null); + scheduleFlush(true, false, "refine"); + }, + [markCanvasDirty, saveHistory, scheduleFlush] + ); + const handleSketchUpload = useCallback( async (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - if (!file.type.startsWith("image/")) { - toast.error("请上传图片文件"); - event.currentTarget.value = ""; - return; - } + const files = Array.from(event.target.files || []); + if (files.length === 0) return; try { - const objectUrl = URL.createObjectURL(file); - const image = new Image(); - await new Promise((resolve, reject) => { - image.onload = () => resolve(); - image.onerror = () => reject(new Error("图片加载失败")); - image.src = objectUrl; - }); - - const canvas = canvasRef.current; - const ctx = canvas?.getContext("2d"); - if (!canvas || !ctx) { - URL.revokeObjectURL(objectUrl); - throw new Error("画布未初始化"); - } - - const rect = canvas.getBoundingClientRect(); - const canvasWidth = Math.max(1, rect.width); - const canvasHeight = Math.max(1, rect.height); - const scale = Math.min(canvasWidth / image.width, canvasHeight / image.height); - const drawWidth = image.width * scale; - const drawHeight = image.height * scale; - const offsetX = (canvasWidth - drawWidth) / 2; - const offsetY = (canvasHeight - drawHeight) / 2; - - ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); - URL.revokeObjectURL(objectUrl); - saveHistory(); - scheduleFlush(true); - toast.success("图片已导入绘画区"); + const importedCount = await importFilesAsLayers(files); + toast.success( + importedCount === 1 + ? "图片已导入图层" + : `已导入 ${importedCount} 张图片到图层` + ); } catch (error) { toast.error(error instanceof Error ? error.message : "导入图片失败"); } finally { event.currentTarget.value = ""; } }, - [saveHistory, scheduleFlush] + [importFilesAsLayers] + ); + + const handleDragEnter = useCallback((event: React.DragEvent) => { + if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return; + event.preventDefault(); + dragDepthRef.current += 1; + setIsDragImportActive(true); + }, []); + + const handleDragOver = useCallback((event: React.DragEvent) => { + if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setIsDragImportActive(true); + }, []); + + const handleDragLeave = useCallback((event: React.DragEvent) => { + 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) => { + if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return; + event.preventDefault(); + dragDepthRef.current = 0; + setIsDragImportActive(false); + const files = Array.from(event.dataTransfer.files || []); + if (files.length === 0) return; + + try { + const importedCount = await importFilesAsLayers(files); + if (importedCount > 0) { + toast.success(`已拖入 ${importedCount} 张图片`); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "拖拽上传失败"); + } + }, + [importFilesAsLayers] ); const handleCanvasBackgroundChange = useCallback( (nextColor: string) => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const snapshot = document.createElement("canvas"); - snapshot.width = canvas.width; - snapshot.height = canvas.height; - const snapshotCtx = snapshot.getContext("2d"); - if (!snapshotCtx) return; - snapshotCtx.drawImage(canvas, 0, 0); - - ctx.save(); - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = nextColor; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(snapshot, 0, 0); - ctx.restore(); - canvasBackgroundRef.current = nextColor; setCanvasBackground(nextColor); - saveHistory(); + saveHistory(undefined, nextColor); + markCanvasDirty(null); scheduleFlush(true); }, - [saveHistory, scheduleFlush] + [markCanvasDirty, saveHistory, scheduleFlush] ); const handleDownloadCanvas = useCallback(async () => { - const canvas = canvasRef.current; - if (!canvas) return; + const compositeCanvas = await buildCompositeCanvas(); + if (!compositeCanvas) return; try { const exportedImage = await canvasToJpegDataUri( - canvas, + compositeCanvas, 2048, 0.92, canvasBackgroundRef.current @@ -1561,7 +2210,7 @@ export function RealtimeCanvasDialog({ } catch (error) { toast.error(error instanceof Error ? error.message : "保存失败"); } - }, []); + }, [buildCompositeCanvas]); const handleApplyToCanvas = useCallback(() => { if (!previewUrl) { @@ -1576,6 +2225,21 @@ export function RealtimeCanvasDialog({ onOpenChange(false); }, [onApplyResult, onOpenChange, previewUrl, prompt]); + const startBlankProject = useCallback(() => { + setPrompt(FALLBACK_PROMPT); + setPreviewUrl(""); + setProjectPanelOpen(false); + setSelectedLayerId(DRAW_LAYER_ID); + drawingHasContentRef.current = false; + hasLoadedInitialImageRef.current = true; + resetCanvas(); + clearPreviewCanvas(); + dirtyRef.current = false; + dirtyRegionRef.current = null; + setStatus("idle"); + setStatusMessage("新建空白项目"); + }, [clearPreviewCanvas, resetCanvas]); + const content = ( <> {standalone ? ( @@ -1610,22 +2274,146 @@ export function RealtimeCanvasDialog({
{statusBadge.label} +
+ + +
+ {projectPanelOpen ? ( +
+
+
+
项目列表
+
本地保存,便于下次继续编辑
+
+ +
+
+ {savedProjects.length === 0 ? ( +
+ 暂无保存的项目 +
+ ) : ( + savedProjects.map((project) => ( +
+
+
+ {project.previewUrl ? ( + {project.name} + ) : ( +
+ 无预览 +
+ )} +
+
+
{project.name}
+
+ {project.prompt || "未填写提示词"} +
+
+ {new Date(project.updatedAt).toLocaleString("zh-CN")} +
+
+
+
+ + +
+
+ )) + )} +
+
+ ) : null} {/* Main Canvas Area - Split View */}
{/* Left: Input Canvas */}
-
+
+ {isDragImportActive ? ( +
+ 松开鼠标,上传图片到图层 +
+ ) : null}
+ + {selectedImageLayer ? ( +
+ ) : null} {/* Custom Brush Cursor */}
-
- INPUT +
+
+
+ + 图层 +
+ + {imageLayers.length} 张 + +
+
+ + {imageLayers.length === 0 ? ( +
+ 暂无图片图层 +
+ ) : ( + [...imageLayers].reverse().map((layer, displayIndex) => { + const actualIndex = imageLayers.length - 1 - displayIndex; + const isTopMost = actualIndex === imageLayers.length - 1; + const isBottomMost = actualIndex === 0; + return ( +
+
+ {layer.name} +
+ +
+ {isTopMost ? "当前最上层" : isBottomMost ? "当前底层" : "中间图层"} +
+
+
+ + +
+ +
+
+ ); + }) + )} +
+ {selectedImageLayer ? ( +
+
+ 图层缩放 + {Math.round(selectedImageLayerScale * 100)}% +
+
+ + { + scaleImageLayer(selectedImageLayer.id, value / 100); + }} + onValueCommit={([value]) => { + const nextLayers = scaleImageLayer(selectedImageLayer.id, value / 100); + if (nextLayers) { + saveHistory(nextLayers); + markCanvasDirty(null); + scheduleFlush(true); + } + }} + /> + +
+
+ ) : null} +
@@ -1677,9 +2653,6 @@ export function RealtimeCanvasDialog({
)}
-
- OUTPUT -
@@ -1749,32 +2722,38 @@ export function RealtimeCanvasDialog({
-
- {BRUSH_COLOR_PRESETS.map((color) => ( - setBrushColor(event.target.value)} - className="h-5 w-5 cursor-pointer rounded-full border border-black/15 bg-transparent p-0" + className="sr-only" title="自定义颜色" /> + + {brushColor} +
-
+
+ + 粗细 {brushSize} + scheduleFlush(true)} + onClick={triggerGenerate} disabled={status === "connecting"} title="生成" > @@ -1879,6 +2858,7 @@ export function RealtimeCanvasDialog({ ref={uploadInputRef} type="file" accept="image/*" + multiple className="hidden" onChange={handleSketchUpload} /> @@ -1901,7 +2881,7 @@ export function RealtimeCanvasDialog({