diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 22d0cc274..7603ad1ea 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -1,14 +1,36 @@ "use client"; import { + Circle as CircleIcon, + Eraser, + FolderOpen, + Layers3, + Loader2, + MousePointer2, + Paintbrush, + Plus, + Redo2, + Save, + Send, + Slash, + SlidersHorizontal, + Sparkles, + Square, + Undo2, + Upload, + X, +} from "lucide-react"; +import { + type ChangeEvent, + type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, - type ChangeEvent, - type PointerEvent as ReactPointerEvent, } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -16,35 +38,22 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +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 { cn } from "@/lib/utils"; -import { buildImageProxyUrl } from "@/lib/image-delivery"; import { - FolderOpen, - Eraser, - Layers3, - Loader2, - MousePointer2, - Paintbrush, - Plus, - Square, - Circle as CircleIcon, - Slash, - Upload, - Sparkles, - Save, - SlidersHorizontal, - Undo2, - Redo2, - Send, - X, -} from "lucide-react"; -import { toast } from "sonner"; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; +import { cn } from "@/lib/utils"; type RealtimeConfig = { transport: { @@ -70,7 +79,13 @@ type RealtimeConfig = { }; type RealtimeStatus = "idle" | "connecting" | "live" | "error"; -type RealtimeBrushMode = "select" | "brush" | "eraser" | "rect" | "circle" | "line"; +type RealtimeBrushMode = + | "select" + | "brush" + | "eraser" + | "rect" + | "circle" + | "line"; type LayerResizeHandle = "nw" | "ne" | "sw" | "se"; type ImageLayer = { @@ -155,7 +170,10 @@ function readRealtimeProjectsFromStorage() { function writeRealtimeProjectsToStorage(projects: SavedRealtimeProject[]) { if (typeof window === "undefined") return; - window.localStorage.setItem(REALTIME_PROJECTS_STORAGE_KEY, JSON.stringify(projects)); + window.localStorage.setItem( + REALTIME_PROJECTS_STORAGE_KEY, + JSON.stringify(projects) + ); } const defaultRealtimeConfig: RealtimeConfig = { @@ -186,7 +204,7 @@ function isShapeMode(mode: RealtimeBrushMode) { } function createSessionSeed() { - return Math.floor(Math.random() * 2147483647); + return Math.floor(Math.random() * 2_147_483_647); } function imageDataHasVisiblePixels(imageData: ImageData) { @@ -224,7 +242,9 @@ async function canvasToJpegDataUri( } tempCtx.fillStyle = rawColor; - const normalized = String(tempCtx.fillStyle || "").trim().toLowerCase(); + const normalized = String(tempCtx.fillStyle || "") + .trim() + .toLowerCase(); if (normalized.startsWith("#")) { const hex = normalized.slice(1); if (hex.length === 3) { @@ -261,17 +281,27 @@ async function canvasToJpegDataUri( 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 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 }); + 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 imageData = analysisCtx.getImageData( + 0, + 0, + analysisWidth, + analysisHeight + ); const data = imageData.data; const totalPixels = analysisWidth * analysisHeight; const mask = new Uint8Array(totalPixels); @@ -436,8 +466,12 @@ async function canvasToJpegDataUri( 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 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); @@ -462,7 +496,8 @@ async function canvasToJpegDataUri( const autoCropEnabled = Boolean(options?.autoCrop && cropBox); const longestSide = Math.max(drawSourceWidth, drawSourceHeight); - const scale = longestSide > maxInputResolution ? maxInputResolution / longestSide : 1; + const scale = + longestSide > maxInputResolution ? maxInputResolution / longestSide : 1; const exportWidth = autoCropEnabled ? Math.max(512, maxInputResolution) : Math.max(1, Math.round(drawSourceWidth * scale)); @@ -483,7 +518,10 @@ async function canvasToJpegDataUri( if (autoCropEnabled) { const fitRatio = (cropBox?.foregroundRatio || 0) < 0.12 ? 0.9 : 0.94; - const fitScale = Math.min((exportWidth * fitRatio) / drawSourceWidth, (exportHeight * fitRatio) / drawSourceHeight); + 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); @@ -688,7 +726,9 @@ export function RealtimeCanvasDialog({ const pendingFlushModeRef = useRef(null); const pendingFlushForceRef = useRef(false); const latencyEmaRef = useRef(null); - const baseThrottleMsRef = useRef(defaultRealtimeConfig.transport.throttleInterval); + const baseThrottleMsRef = useRef( + defaultRealtimeConfig.transport.throttleInterval + ); const pumpFlushRef = useRef<(() => void) | null>(null); const warningMessageRef = useRef(""); const seqRef = useRef(0); @@ -696,7 +736,9 @@ export function RealtimeCanvasDialog({ 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 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); @@ -706,7 +748,7 @@ export function RealtimeCanvasDialog({ const sidebarDraggingLayerIdRef = useRef(null); const drawingHasContentRef = useRef(false); const dragDepthRef = useRef(0); - + // History Management const historyRef = useRef([]); const historyIndexRef = useRef(-1); @@ -722,11 +764,15 @@ export function RealtimeCanvasDialog({ const [previewUrl, setPreviewUrl] = useState(""); const [brushMode, setBrushMode] = useState("select"); const [brushColor, setBrushColor] = useState(DEFAULT_BRUSH_COLOR); - const [canvasBackground, setCanvasBackground] = useState(canvasBackgroundRef.current); + 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 [savedProjects, setSavedProjects] = useState( + [] + ); const [projectPanelOpen, setProjectPanelOpen] = useState(false); const [saveProjectDialogOpen, setSaveProjectDialogOpen] = useState(false); const [pendingProjectName, setPendingProjectName] = useState(""); @@ -739,11 +785,13 @@ export function RealtimeCanvasDialog({ const [siteLogo, setSiteLogo] = useState(null); const statusBadge = useMemo(() => { - if (status === "live") return { label: "实时中", className: "bg-emerald-50 text-emerald-700" }; + if (status === "live") + return { label: "实时中", className: "bg-emerald-50 text-emerald-700" }; if (status === "connecting") { return { label: "连接中", className: "bg-blue-50 text-blue-700" }; } - if (status === "error") return { label: "异常", className: "bg-rose-50 text-rose-700" }; + if (status === "error") + return { label: "异常", className: "bg-rose-50 text-rose-700" }; return { label: "待启动", className: "bg-zinc-100 text-zinc-600" }; }, [status]); @@ -764,7 +812,10 @@ export function RealtimeCanvasDialog({ } if (typeof data.siteLogo === "string" && data.siteLogo.trim()) { setSiteLogo(data.siteLogo.trim()); - } else if (typeof data.siteLogoDark === "string" && data.siteLogoDark.trim()) { + } else if ( + typeof data.siteLogoDark === "string" && + data.siteLogoDark.trim() + ) { setSiteLogo(data.siteLogoDark.trim()); } }) @@ -786,7 +837,10 @@ export function RealtimeCanvasDialog({ const flushMetrics = useCallback(async () => { if (!open || metricsQueueRef.current.length === 0) return; - const events = metricsQueueRef.current.splice(0, metricsQueueRef.current.length); + const events = metricsQueueRef.current.splice( + 0, + metricsQueueRef.current.length + ); try { await fetch("/api/realtime/metrics", { method: "POST", @@ -824,7 +878,9 @@ export function RealtimeCanvasDialog({ const loadRenderableImage = useCallback((src: string) => { return new Promise((resolve, reject) => { const image = new Image(); - const resolvedSrc = /^https?:\/\//i.test(src) ? buildImageProxyUrl(src) : src; + const resolvedSrc = /^https?:\/\//i.test(src) + ? buildImageProxyUrl(src) + : src; image.crossOrigin = "anonymous"; image.onload = () => resolve(image); image.onerror = () => reject(new Error("图层图片加载失败")); @@ -848,7 +904,10 @@ export function RealtimeCanvasDialog({ for (const layer of imageLayersRef.current) { try { const image = await loadRenderableImage(layer.src); - if (options?.renderSeq && options.renderSeq !== layerRenderSeqRef.current) { + if ( + options?.renderSeq && + options.renderSeq !== layerRenderSeqRef.current + ) { ctx.restore(); return false; } @@ -870,16 +929,28 @@ export function RealtimeCanvasDialog({ if (!ctx) return; const renderSeq = layerRenderSeqRef.current + 1; layerRenderSeqRef.current = renderSeq; - const cssWidth = Math.max(1, parseFloat(layerCanvas.style.width) || layerCanvas.width); - const cssHeight = Math.max(1, parseFloat(layerCanvas.style.height) || layerCanvas.height); + 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, parseFloat(drawingCanvas.style.width) || drawingCanvas.width); - const cssHeight = Math.max(1, parseFloat(drawingCanvas.style.height) || drawingCanvas.height); + 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"); @@ -915,7 +986,11 @@ export function RealtimeCanvasDialog({ 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 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; @@ -937,7 +1012,11 @@ export function RealtimeCanvasDialog({ ); const findLayerAtPoint = useCallback((point: { x: number; y: number }) => { - for (let index = imageLayersRef.current.length - 1; index >= 0; index -= 1) { + 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; @@ -949,7 +1028,10 @@ export function RealtimeCanvasDialog({ }, []); const getResizeHandleAtPoint = useCallback( - (point: { x: number; y: number }, layer: ImageLayer | null): LayerResizeHandle | null => { + ( + point: { x: number; y: number }, + layer: ImageLayer | null + ): LayerResizeHandle | null => { if (!layer) return null; const handleSize = 14; const half = handleSize / 2; @@ -961,8 +1043,10 @@ export function RealtimeCanvasDialog({ ]; 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; + 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; } @@ -973,24 +1057,34 @@ export function RealtimeCanvasDialog({ [] ); - 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 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) => { + ( + layerId: string, + point: { x: number; y: number }, + origin: NonNullable + ) => { 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 aspectRatio = Math.max( + 0.01, + origin.layerWidth / Math.max(1, origin.layerHeight) + ); const minSize = 24; let anchorX = origin.layerX; @@ -1032,14 +1126,22 @@ export function RealtimeCanvasDialog({ targetWidth = targetHeight * aspectRatio; } - const fitRatio = Math.min(1, maxWidth / targetWidth, maxHeight / targetHeight); + 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; + origin.handle === "ne" || origin.handle === "se" + ? anchorX + : anchorX - targetWidth; const nextY = - origin.handle === "sw" || origin.handle === "se" ? anchorY : anchorY - targetHeight; + origin.handle === "sw" || origin.handle === "se" + ? anchorY + : anchorY - targetHeight; const nextLayers = imageLayersRef.current.map((layer) => layer.id === layerId @@ -1099,41 +1201,48 @@ export function RealtimeCanvasDialog({ }); }, []); - const saveHistory = useCallback((layersOverride?: ImageLayer[], backgroundOverride?: string) => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d", { willReadFrequently: true }); - if (!ctx) return; + 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); - } + // 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; - } + 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); - }, []); + // 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; @@ -1157,7 +1266,11 @@ export function RealtimeCanvasDialog({ ); const markCanvasDirtyStroke = useCallback( - (from: { x: number; y: number }, to: { x: number; y: number }, lineWidth: number) => { + ( + 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, @@ -1219,7 +1332,8 @@ export function RealtimeCanvasDialog({ const shouldUseMask = Boolean(dirtyRegionAtSend) && mode !== "refine" && - (dirtyRegionAtSend?.width || 0) * (dirtyRegionAtSend?.height || 0) < 0.92; + (dirtyRegionAtSend?.width || 0) * (dirtyRegionAtSend?.height || 0) < + 0.92; const maskImageUrl = shouldUseMask && dirtyRegionAtSend ? await createMaskDataUri( @@ -1273,12 +1387,15 @@ export function RealtimeCanvasDialog({ throw new Error(payload.message || payload.error || "实时生成失败"); } - const nextPreviewUrl = String(payload.imageDataURI || payload.imageUrl || ""); + const nextPreviewUrl = String( + payload.imageDataURI || payload.imageUrl || "" + ); if (!nextPreviewUrl) { throw new Error("实时生成未返回图像"); } - const startedAt = requestStartMapRef.current.get(requestId) || Date.now(); + const startedAt = + requestStartMapRef.current.get(requestId) || Date.now(); const latencyMs = Date.now() - startedAt; requestStartMapRef.current.delete(requestId); @@ -1304,7 +1421,9 @@ export function RealtimeCanvasDialog({ const previousLatency = latencyEmaRef.current; const nextLatency = - previousLatency === null ? latencyMs : previousLatency * 0.72 + latencyMs * 0.28; + 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)) @@ -1352,7 +1471,15 @@ export function RealtimeCanvasDialog({ } } }, - [adaptiveThrottleMs, buildCompositeCanvas, config, hasRenderableInput, open, prompt, pushMetric] + [ + adaptiveThrottleMs, + buildCompositeCanvas, + config, + hasRenderableInput, + open, + prompt, + pushMetric, + ] ); const pumpFlush = useCallback(() => { @@ -1403,12 +1530,22 @@ export function RealtimeCanvasDialog({ const reorderLayersFromSidebar = useCallback( (draggedLayerId: string, targetLayerId: string) => { - if (!draggedLayerId || !targetLayerId || draggedLayerId === targetLayerId) return; + 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 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); @@ -1455,7 +1592,13 @@ export function RealtimeCanvasDialog({ scheduleFlush(true); return nextLayerBatch.length; }, - [createPlacedLayer, fileToDataUrl, markCanvasDirty, saveHistory, scheduleFlush] + [ + createPlacedLayer, + fileToDataUrl, + markCanvasDirty, + saveHistory, + scheduleFlush, + ] ); const triggerGenerate = useCallback(() => { @@ -1470,38 +1613,45 @@ export function RealtimeCanvasDialog({ const refreshSavedProjects = useCallback(() => { const projects = readRealtimeProjectsFromStorage().sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); setSavedProjects(projects); return projects; }, []); - const loadDrawingLayerFromDataUrl = useCallback(async (dataUrl: string) => { - if (!dataUrl) { + 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(); - 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 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); + const project = readRealtimeProjectsFromStorage().find( + (item) => item.id === projectId + ); if (!project) { toast.error("未找到项目"); return; } - canvasBackgroundRef.current = project.canvasBackground || CANVAS_BACKGROUND; + canvasBackgroundRef.current = + project.canvasBackground || CANVAS_BACKGROUND; setCanvasBackground(project.canvasBackground || CANVAS_BACKGROUND); const nextLayers = cloneImageLayers(project.imageLayers || []); imageLayersRef.current = nextLayers; @@ -1522,7 +1672,9 @@ export function RealtimeCanvasDialog({ ); const deleteSavedProject = useCallback((projectId: string) => { - const nextProjects = readRealtimeProjectsFromStorage().filter((item) => item.id !== projectId); + const nextProjects = readRealtimeProjectsFromStorage().filter( + (item) => item.id !== projectId + ); writeRealtimeProjectsToStorage(nextProjects); setSavedProjects(nextProjects); toast.success("项目已删除"); @@ -1564,7 +1716,8 @@ export function RealtimeCanvasDialog({ imageLayers: cloneImageLayers(imageLayersRef.current), drawingLayerDataUrl: exportDrawingLayerDataUrl(), coverImageUrl, - previewUrl: previewUrl || compositeCanvas?.toDataURL("image/jpeg", 0.72) || "", + previewUrl: + previewUrl || compositeCanvas?.toDataURL("image/jpeg", 0.72) || "", }; const nextProjects = [project, ...existingProjects].slice(0, 30); writeRealtimeProjectsToStorage(nextProjects); @@ -1606,8 +1759,8 @@ export function RealtimeCanvasDialog({ if (!open) return; const handlePaste = async (event: ClipboardEvent) => { - const imageFiles = Array.from(event.clipboardData?.files || []).filter((file) => - file.type.startsWith("image/") + const imageFiles = Array.from(event.clipboardData?.files || []).filter( + (file) => file.type.startsWith("image/") ); if (imageFiles.length === 0) return; event.preventDefault(); @@ -1635,21 +1788,24 @@ export function RealtimeCanvasDialog({ 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); + 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 && + snapshot.imageLayers.some((layer) => layer.id === current) ? current : DRAW_LAYER_ID ); @@ -1666,16 +1822,19 @@ export function RealtimeCanvasDialog({ 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); + 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 && + snapshot.imageLayers.some((layer) => layer.id === current) ? current : DRAW_LAYER_ID ); @@ -1697,10 +1856,16 @@ export function RealtimeCanvasDialog({ historyRef.current = []; historyIndexRef.current = -1; saveHistory(); - }, [clearDrawingCanvas, renderImageLayers, revokeAllLayerObjectUrls, saveHistory]); + }, [ + clearDrawingCanvas, + renderImageLayers, + revokeAllLayerObjectUrls, + saveHistory, + ]); const drawInitialImage = useCallback(async () => { - if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return false; + if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) + return false; const nextLayer = await createPlacedLayer({ src: initialImageUrl, name: "参考图", @@ -1748,21 +1913,29 @@ export function RealtimeCanvasDialog({ 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); + 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) => { - const canvas = canvasRef.current; - if (!canvas) return null; - const rect = canvas.getBoundingClientRect(); - return { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }; - }, []); + const getCanvasPoint = useCallback( + (event: ReactPointerEvent) => { + 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 }) => { @@ -1924,7 +2097,7 @@ export function RealtimeCanvasDialog({ const handlePointerLeave = useCallback(() => { const wasDrawing = pointerDownRef.current; if (cursorRef.current) { - cursorRef.current.style.opacity = '0'; + cursorRef.current.style.opacity = "0"; } if (draggingLayerIdRef.current || resizingLayerIdRef.current) { return; @@ -1956,7 +2129,9 @@ export function RealtimeCanvasDialog({ const point = getCanvasPoint(event); if (point && cursorRef.current) { const resizeHandle = - brushMode === "select" ? getResizeHandleAtPoint(point, selectedImageLayer) : null; + brushMode === "select" + ? getResizeHandleAtPoint(point, selectedImageLayer) + : null; event.currentTarget.style.cursor = resizeHandle ? getResizeCursor(resizeHandle) : brushMode === "select" && selectedImageLayer @@ -1980,7 +2155,11 @@ export function RealtimeCanvasDialog({ if (!pointerDownRef.current) return; if (!point || !lastPointRef.current) return; if (resizingLayerIdRef.current && layerResizeOriginRef.current) { - resizeLayerFromHandle(resizingLayerIdRef.current, point, layerResizeOriginRef.current); + resizeLayerFromHandle( + resizingLayerIdRef.current, + point, + layerResizeOriginRef.current + ); markCanvasDirty(null); scheduleFlush(false, false, "preview"); return; @@ -1994,9 +2173,11 @@ export function RealtimeCanvasDialog({ const rect = canvas.getBoundingClientRect(); const nextX = - layerDragOriginRef.current.layerX + (point.x - layerDragOriginRef.current.pointerX); + layerDragOriginRef.current.layerX + + (point.x - layerDragOriginRef.current.pointerX); const nextY = - layerDragOriginRef.current.layerY + (point.y - layerDragOriginRef.current.pointerY); + 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); @@ -2121,7 +2302,9 @@ export function RealtimeCanvasDialog({ const deleteSelectedImageLayer = useCallback(() => { if (selectedLayerId === DRAW_LAYER_ID) return; - const nextLayers = imageLayersRef.current.filter((layer) => layer.id !== selectedLayerId); + const nextLayers = imageLayersRef.current.filter( + (layer) => layer.id !== selectedLayerId + ); if (nextLayers.length === imageLayersRef.current.length) return; imageLayersRef.current = nextLayers; @@ -2144,20 +2327,20 @@ export function RealtimeCanvasDialog({ activeElement?.isContentEditable; // Undo/Redo - if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + if ((e.metaKey || e.ctrlKey) && e.key === "z") { e.preventDefault(); if (e.shiftKey) { - redo(); + redo(); } else { - undo(); + undo(); } } // Brush Size - if (e.key === '[') { - setBrushSize(prev => Math.max(2, prev - 2)); + if (e.key === "[") { + setBrushSize((prev) => Math.max(2, prev - 2)); } - if (e.key === ']') { - setBrushSize(prev => Math.min(50, prev + 2)); + if (e.key === "]") { + setBrushSize((prev) => Math.min(50, prev + 2)); } if ( !isTypingTarget && @@ -2168,8 +2351,8 @@ export function RealtimeCanvasDialog({ deleteSelectedImageLayer(); } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [deleteSelectedImageLayer, open, redo, selectedLayerId, undo]); useEffect(() => { @@ -2214,11 +2397,18 @@ export function RealtimeCanvasDialog({ const start = async () => { try { let configResponse: Response | null = null; - let configJson: Partial & { message?: string; retryAfter?: number } = {}; + let configJson: Partial & { + 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 & { + configResponse = await fetch("/api/realtime/config", { + cache: "no-store", + }); + configJson = (await configResponse + .json() + .catch(() => ({}))) as Partial & { message?: string; retryAfter?: number; }; @@ -2230,8 +2420,13 @@ export function RealtimeCanvasDialog({ throw new Error(configJson.message || "无法加载实时配置"); } - const retryDelay = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : 700 * (attempt + 1); - await new Promise((resolve) => window.setTimeout(resolve, retryDelay)); + const retryDelay = + retryAfterSeconds > 0 + ? retryAfterSeconds * 1000 + : 700 * (attempt + 1); + await new Promise((resolve) => + window.setTimeout(resolve, retryDelay) + ); } if (!configResponse?.ok) { @@ -2245,17 +2440,23 @@ export function RealtimeCanvasDialog({ configJson.transport?.throttleInterval || defaultRealtimeConfig.transport.throttleInterval, maxBuffering: - configJson.transport?.maxBuffering || defaultRealtimeConfig.transport.maxBuffering, + configJson.transport?.maxBuffering || + defaultRealtimeConfig.transport.maxBuffering, }, image: { maxInputResolution: - configJson.image?.maxInputResolution || defaultRealtimeConfig.image.maxInputResolution, - jpegQuality: configJson.image?.jpegQuality || defaultRealtimeConfig.image.jpegQuality, + 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, + steps: + configJson.model?.defaults?.steps || + defaultRealtimeConfig.model.defaults.steps, cfgScale: configJson.model?.defaults?.cfgScale || defaultRealtimeConfig.model.defaults.cfgScale, @@ -2288,7 +2489,9 @@ export function RealtimeCanvasDialog({ if (cancelled) return; console.error("[RealtimeCanvas] init failed:", error); setStatus("error"); - setStatusMessage(error instanceof Error ? error.message : "实时初始化失败"); + setStatusMessage( + error instanceof Error ? error.message : "实时初始化失败" + ); } }; @@ -2350,32 +2553,45 @@ export function RealtimeCanvasDialog({ [importFilesAsLayers] ); - const handleDragEnter = useCallback((event: React.DragEvent) => { - if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return; - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragImportActive(true); - }, []); + 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 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 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; + if (!Array.from(event.dataTransfer?.types || []).includes("Files")) + return; event.preventDefault(); dragDepthRef.current = 0; setIsDragImportActive(false); @@ -2414,7 +2630,9 @@ export function RealtimeCanvasDialog({ } const anchor = document.createElement("a"); - anchor.href = /^https?:\/\//i.test(previewUrl) ? buildImageProxyUrl(previewUrl) : previewUrl; + anchor.href = /^https?:\/\//i.test(previewUrl) + ? buildImageProxyUrl(previewUrl) + : previewUrl; anchor.download = `realtime-preview-${Date.now()}.png`; anchor.rel = "noopener"; anchor.click(); @@ -2437,7 +2655,8 @@ export function RealtimeCanvasDialog({ const compactToolButtonClass = "h-9 w-9 rounded-[16px] text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-900"; - const compactToolButtonActiveClass = "bg-black text-white hover:bg-black hover:text-white"; + const compactToolButtonActiveClass = + "bg-black text-white hover:bg-black hover:text-white"; const currentModeLabel = brushMode === "select" ? "鼠标" @@ -2465,1008 +2684,1082 @@ export function RealtimeCanvasDialog({ const content = ( <> - {standalone ? ( -
-

实时画布

-

左侧绘制草图,右侧实时预览。

-
- ) : ( - - 实时画布 - 左侧绘制草图,右侧实时预览。 - - )} -
-
-
-
-
- {siteLogo ? ( - {siteName} - ) : ( -
- -
- )} - - {siteName} - -
+ {standalone ? ( +
+

实时画布

+

左侧绘制草图,右侧实时预览。

+
+ ) : ( + + 实时画布 + 左侧绘制草图,右侧实时预览。 + + )} +
+
+
+
+
+ {siteLogo ? ( + {siteName} + ) : ( +
+ +
+ )} + + {siteName} + +
+ - - {statusBadge.label} - - - 延迟 {adaptiveThrottleMs}ms - -
- -
- - - -
- -
- - - - - - -
- -
- - setBrushColor(event.target.value)} - className="sr-only" - title="自定义颜色" /> -
- - 粗细 {brushSize} - - setBrushSize(val)} - className="min-w-[140px] flex-1" - trackClassName="h-2.5 bg-zinc-200 shadow-inner" - rangeClassName="bg-sky-500" - thumbClassName="h-5 w-5 border border-zinc-300 bg-white shadow-sm" - /> -
-
- -
- - -
- - {standalone ? ( -
- -
- ) : null} - + {statusBadge.label} + + + 延迟 {adaptiveThrottleMs}ms +
+ +
+ + + +
+ +
+ + + + + + +
+ +
+ + setBrushColor(event.target.value)} + ref={colorInputRef} + title="自定义颜色" + type="color" + value={brushColor} + /> +
+ + 粗细 {brushSize} + + setBrushSize(val)} + rangeClassName="bg-sky-500" + step={1} + thumbClassName="h-5 w-5 border border-zinc-300 bg-white shadow-sm" + trackClassName="h-2.5 bg-zinc-200 shadow-inner" + value={[brushSize]} + /> +
+
+ +
+ + +
+ + {standalone ? ( +
+ +
+ ) : null}
- {saveProjectDialogOpen ? ( -
-
-
-
保存项目
-
请输入项目名称后保存当前画布
+
+ {saveProjectDialogOpen ? ( +
+
+
+
+ 保存项目 +
+
+ 请输入项目名称后保存当前画布
- -
- setPendingProjectName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void confirmSaveCurrentProject(); - } - }} - placeholder="请输入项目名称" - className="mt-4 h-11 rounded-2xl border-black/10" - autoFocus - /> -
- -
+
- ) : null} - {projectPanelOpen ? ( -
-
-
-
项目列表
-
本地保存,便于下次继续编辑
+ setPendingProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void confirmSaveCurrentProject(); + } + }} + placeholder="请输入项目名称" + value={pendingProjectName} + /> +
+ + +
+
+ ) : null} + {projectPanelOpen ? ( +
+
+
+
+ 项目列表 +
+
+ 本地保存,便于下次继续编辑
-
-
- {savedProjects.length === 0 ? ( -
- 暂无保存的项目 -
- ) : ( - savedProjects.map((project) => ( -
-
-
- {project.coverImageUrl || project.previewUrl ? ( - {project.name} - ) : ( -
- 无预览 -
- )} -
-
-
{project.name}
-
+ +
+
+ {savedProjects.length === 0 ? ( +
+ 暂无保存的项目 +
+ ) : ( + savedProjects.map((project) => ( +
+
+
+ {project.coverImageUrl || project.previewUrl ? ( + {project.name} + ) : ( +
+ 无预览 +
+ )}
-
- - +
+
+ {project.name} +
+
+ + +
+
+ )) + )} +
+
+ ) : null} + + {/* Main Canvas Area - Split View */} +
+ {/* Left: Input Canvas */} +
+
+
+ {isDragImportActive ? ( +
+ 松开鼠标,上传图片到图层 +
+ ) : null} + +
+ + + + {selectedImageLayer && brushMode === "select" ? ( +
+ ) : 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) => ( +
+ )) + : null} + {/* Custom Brush Cursor */} +
+
+
+
+
+
+ +
+
+
+ {imageLayers.length === 0 ? ( +
+ 暂无图层 +
+ ) : ( + [...imageLayers].reverse().map((layer) => ( + )) )}
+
- ) : null} +
- {/* Main Canvas Area - Split View */} -
- {/* Left: Input Canvas */} -
-
-
- {isDragImportActive ? ( -
- 松开鼠标,上传图片到图层 -
- ) : null} - -
- - - - {selectedImageLayer && brushMode === "select" ? ( -
- ) : 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) => ( -
- )) - : null} - {/* Custom Brush Cursor */} -
-
-
-
-
-
- -
-
-
- {imageLayers.length === 0 ? ( -
- 暂无图层 -
- ) : ( - [...imageLayers].reverse().map((layer) => ( - - )) - )} -
+ {/* Right: Output Preview */} +
+
+ {previewUrl ? ( + 实时预览 + ) : ( +
+ {status === "connecting" ? ( + <> + + Connecting... + + ) : ( + <> + + Start drawing to generate + + )} +
+ )} + {previewUrl ? ( +
-
+
+ ) : null}
+
+
- {/* Right: Output Preview */} -
-
- {previewUrl ? ( - 实时预览 - ) : ( -
- {status === "connecting" ? ( - <> - - Connecting... - - ) : ( - <> - - Start drawing to generate - - )} -
- )} - {previewUrl ? ( -
+ +
+ +
+
+ + -
+ + +
鼠标
+
+ 选择图层并拖动位置 +
+
+ + + + + + +
画笔
+
在画布上自由绘制
+
+
+ + + + + +
橡皮
+
擦除当前草图笔触
+
+
+ + + + + + + + +
+ 形状工具 +
+
+ + + +
+
+
+ +
形状
+
矩形、圆形与直线
+
+
+
+ +
+ + + + + + + + +
+
+
+ 颜色 +
+ + + setBrushColor(event.target.value) + } + ref={colorInputRef} + title="自定义颜色" + type="color" + value={brushColor} + /> +
+
+
+ 粗细 + + {brushSize} + +
+ setBrushSize(val)} + rangeClassName="bg-black" + step={1} + thumbClassName="h-5 w-5 border border-zinc-300 bg-white shadow-sm" + trackClassName="h-2.5 bg-zinc-200 shadow-inner" + value={[brushSize]} + /> +
+
+
+
+ +
画笔设置
+
调整颜色和粗细
+
+
+
+ +
+ + + + + +
上传图片
+
添加新的图片图层
+
+
+ + + + + +
保存项目
+
保存当前画布状态
+
+
+ + + + + +
项目列表
+
打开已保存项目
+
+
+ + + + + +
新建空白
+
清空并开始新项目
+
+
+
+ +
+ + + + + +
撤销
+
回退上一步编辑
+
+
+ + + + + +
重做
+
恢复已撤销操作
+
+
+ {standalone ? ( + + + + + +
关闭页面
+
返回上一层画布
+
+
) : null}
-
+ - -
- -
-
- - - - - -
鼠标
-
选择图层并拖动位置
-
-
- - - - - -
画笔
-
在画布上自由绘制
-
-
- - - - - -
橡皮
-
擦除当前草图笔触
-
-
- - - - - - - - -
形状工具
-
- - - -
-
-
- -
形状
-
矩形、圆形与直线
-
-
-
- -
- - - - - - - - -
-
-
颜色
- - setBrushColor(event.target.value)} - className="sr-only" - title="自定义颜色" - /> -
-
-
- 粗细 - {brushSize} -
- setBrushSize(val)} - className="w-full" - trackClassName="h-2.5 bg-zinc-200 shadow-inner" - rangeClassName="bg-black" - thumbClassName="h-5 w-5 border border-zinc-300 bg-white shadow-sm" - /> -
-
-
-
- -
画笔设置
-
调整颜色和粗细
-
-
-
- -
- - - - - -
上传图片
-
添加新的图片图层
-
-
- - - - - -
保存项目
-
保存当前画布状态
-
-
- - - - - -
项目列表
-
打开已保存项目
-
-
- - - - - -
新建空白
-
清空并开始新项目
-
-
-
- -
- - - - - -
撤销
-
回退上一步编辑
-
-
- - - - - -
重做
-
恢复已撤销操作
-
-
- {standalone ? ( - - - - - -
关闭页面
-
返回上一层画布
-
-
- ) : null} -
-
-
- -
-