diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 45f820f53..bbff15a60 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -33,12 +33,9 @@ import { Circle as CircleIcon, Slash, Upload, - ChevronDown, - ChevronUp, Sparkles, Save, Trash2, - Download, Undo2, Redo2, Send, @@ -71,6 +68,7 @@ type RealtimeConfig = { type RealtimeStatus = "idle" | "connecting" | "live" | "error"; type RealtimeBrushMode = "select" | "brush" | "eraser" | "rect" | "circle" | "line"; +type LayerResizeHandle = "nw" | "ne" | "sw" | "se"; type ImageLayer = { id: string; @@ -122,6 +120,10 @@ function cloneImageLayers(layers: ImageLayer[]) { return layers.map((layer) => ({ ...layer })); } +function getResizeCursor(handle: LayerResizeHandle) { + return handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize"; +} + function readRealtimeProjectsFromStorage() { if (typeof window === "undefined") return [] as SavedRealtimeProject[]; try { @@ -643,12 +645,22 @@ export function RealtimeCanvasDialog({ const connectionRef = useRef(null); const pointerDownRef = useRef(false); const draggingLayerIdRef = useRef(null); + const resizingLayerIdRef = useRef(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(null); @@ -719,11 +731,6 @@ export function RealtimeCanvasDialog({ () => 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]); useEffect(() => { let cancelled = false; @@ -914,6 +921,31 @@ export function RealtimeCanvasDialog({ 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 @@ -923,39 +955,83 @@ export function RealtimeCanvasDialog({ 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 resizeLayerFromHandle = useCallback( + (layerId: string, point: { x: number; y: number }, origin: NonNullable) => { + const canvas = canvasRef.current; + if (!canvas) return; - const nextLayers = imageLayersRef.current.map((layer) => { - if (layer.id !== layerId) return layer; + 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; - 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); + let anchorX = origin.layerX; + let anchorY = origin.layerY; + let maxWidth = canvasWidth; + let maxHeight = canvasHeight; - 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)), - }; - }); + 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; + } - imageLayersRef.current = nextLayers; - setImageLayers(nextLayers); - return nextLayers; - }, []); + 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; @@ -1705,6 +1781,21 @@ export function RealtimeCanvasDialog({ pointerDownRef.current = true; lastPointRef.current = point; if (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 && @@ -1761,6 +1852,7 @@ export function RealtimeCanvasDialog({ drawStroke, findLayerAtPoint, getCanvasPoint, + getResizeHandleAtPoint, markCanvasDirtyStroke, selectedImageLayer, scheduleFlush, @@ -1772,12 +1864,14 @@ export function RealtimeCanvasDialog({ if (cursorRef.current) { cursorRef.current.style.opacity = '0'; } - if (draggingLayerIdRef.current) { + 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(); @@ -1799,20 +1893,36 @@ export function RealtimeCanvasDialog({ // Update cursor const point = getCanvasPoint(event); if (point && cursorRef.current) { - const showCursor = - !selectedImageLayer && (brushMode === "brush" || brushMode === "eraser"); - if (showCursor) { - cursorRef.current.style.opacity = "1"; - cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`; - cursorRef.current.style.width = `${brushSize}px`; - cursorRef.current.style.height = `${brushSize}px`; - } else { - cursorRef.current.style.opacity = "0"; - } + const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer); + event.currentTarget.style.cursor = resizeHandle + ? getResizeCursor(resizeHandle) + : selectedImageLayer + ? "move" + : brushMode === "brush" || brushMode === "eraser" + ? "none" + : brushMode === "select" + ? "default" + : "crosshair"; + const showCursor = + !selectedImageLayer && (brushMode === "brush" || brushMode === "eraser"); + if (showCursor) { + cursorRef.current.style.opacity = "1"; + cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`; + cursorRef.current.style.width = `${brushSize}px`; + cursorRef.current.style.height = `${brushSize}px`; + } else { + cursorRef.current.style.opacity = "0"; + } } if (!pointerDownRef.current) return; if (!point || !lastPointRef.current) return; + if (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 @@ -1860,8 +1970,10 @@ export function RealtimeCanvasDialog({ scheduleFlush, brushMode, brushSize, + getResizeHandleAtPoint, markCanvasDirty, markCanvasDirtyStroke, + resizeLayerFromHandle, selectedImageLayer, updateLayerPosition, ] @@ -1871,9 +1983,24 @@ export function RealtimeCanvasDialog({ (event: ReactPointerEvent) => { 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; @@ -1904,6 +2031,7 @@ export function RealtimeCanvasDialog({ lastPointRef.current = null; shapeStartPointRef.current = null; layerDragOriginRef.current = null; + layerResizeOriginRef.current = null; try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { @@ -1928,10 +2056,31 @@ export function RealtimeCanvasDialog({ 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(); @@ -1948,10 +2097,18 @@ export function RealtimeCanvasDialog({ 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); - }, [open, undo, redo]); + }, [deleteSelectedImageLayer, open, redo, selectedLayerId, undo]); useEffect(() => { if (!open) return; @@ -2110,45 +2267,6 @@ 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 files = Array.from(event.target.files || []); @@ -2214,26 +2332,6 @@ export function RealtimeCanvasDialog({ [importFilesAsLayers] ); - const handleDownloadCanvas = useCallback(async () => { - const compositeCanvas = await buildCompositeCanvas(); - if (!compositeCanvas) return; - try { - const exportedImage = await canvasToJpegDataUri( - compositeCanvas, - 2048, - 0.92, - canvasBackgroundRef.current - ); - const anchor = document.createElement("a"); - anchor.href = exportedImage.dataUri; - anchor.download = `realtime-canvas-${Date.now()}.jpg`; - anchor.click(); - toast.success("已保存当前画布"); - } catch (error) { - toast.error(error instanceof Error ? error.message : "保存失败"); - } - }, [buildCompositeCanvas]); - const handleApplyToCanvas = useCallback(() => { if (!previewUrl) { toast.error("请先生成预览结果"); @@ -2247,6 +2345,19 @@ export function RealtimeCanvasDialog({ onOpenChange(false); }, [onApplyResult, onOpenChange, previewUrl, prompt]); + 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 startBlankProject = useCallback(() => { setPrompt(FALLBACK_PROMPT); setPreviewUrl(""); @@ -2487,15 +2598,6 @@ export function RealtimeCanvasDialog({
- -
{standalone ? ( -
+
+
+ ) : null} {/* Custom Brush Cursor */}
-
-
+
+
- - {imageLayers.length} 张 -
{imageLayers.length === 0 ? ( @@ -2702,126 +2841,28 @@ export function RealtimeCanvasDialog({ 暂无图层
) : ( - [...imageLayers].reverse().map((layer, displayIndex) => { - const actualIndex = imageLayers.length - 1 - displayIndex; - const isTopMost = actualIndex === imageLayers.length - 1; - const isBottomMost = actualIndex === 0; - return ( -
- -
- -
- - -
- -
-
- ); - }) + [...imageLayers].reverse().map((layer) => ( + + )) )}
- {selectedImageLayer ? ( -
-
- {Math.round(selectedImageLayerScale * 100)}% -
-
- - -
-
- ) : null} +
+ ) : null}