diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 5ca879a00..05ad0f5da 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -37,6 +37,9 @@ function isNodeInViewport( } export function CanvasWorkspace() { + const SNAP_THRESHOLD = 10; + const SNAP_GAP = 12; + const SELECTION_COLOR = "#3182ed"; const containerRef = useRef(null); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); @@ -172,6 +175,89 @@ export function CanvasWorkspace() { return /Mac|iPhone|iPad|iPod/i.test(navigator.platform); }, []); + const hasBlockingDialogOpen = useCallback(() => { + if (typeof document === "undefined") return false; + return Boolean(document.querySelector('[role="dialog"][data-state="open"]')); + }, []); + + const getSnappedCanvasPosition = useCallback( + ( + node: CanvasNode, + target: Position, + ignoreNodeIds: Set = new Set(), + ): Position => { + if (node.type !== "image") { + return target; + } + + const candidates = nodes.filter( + (item) => + item.type === "image" && + item.id !== node.id && + !ignoreNodeIds.has(item.id), + ); + + if (candidates.length === 0) { + return target; + } + + let bestOffsetX = 0; + let bestDeltaX = Number.POSITIVE_INFINITY; + let bestOffsetY = 0; + let bestDeltaY = Number.POSITIVE_INFINITY; + + const selfLeft = target.x; + const selfCenterX = target.x + node.size.width / 2; + const selfRight = target.x + node.size.width; + const selfTop = target.y; + const selfCenterY = target.y + node.size.height / 2; + const selfBottom = target.y + node.size.height; + + const pickBestX = (delta: number) => { + const abs = Math.abs(delta); + if (abs <= SNAP_THRESHOLD && abs < bestDeltaX) { + bestDeltaX = abs; + bestOffsetX = delta; + } + }; + + const pickBestY = (delta: number) => { + const abs = Math.abs(delta); + if (abs <= SNAP_THRESHOLD && abs < bestDeltaY) { + bestDeltaY = abs; + bestOffsetY = delta; + } + }; + + for (const candidate of candidates) { + const targetLeft = candidate.position.x; + const targetCenterX = candidate.position.x + candidate.size.width / 2; + const targetRight = candidate.position.x + candidate.size.width; + const targetTop = candidate.position.y; + const targetCenterY = candidate.position.y + candidate.size.height / 2; + const targetBottom = candidate.position.y + candidate.size.height; + + pickBestX(targetLeft - selfLeft); + pickBestX(targetCenterX - selfCenterX); + pickBestX(targetRight - selfRight); + pickBestX(targetRight + SNAP_GAP - selfLeft); + pickBestX(targetLeft - SNAP_GAP - selfRight); + + pickBestY(targetTop - selfTop); + pickBestY(targetCenterY - selfCenterY); + pickBestY(targetBottom - selfBottom); + pickBestY(targetBottom + SNAP_GAP - selfTop); + pickBestY(targetTop - SNAP_GAP - selfBottom); + } + + return { + x: target.x + (Number.isFinite(bestDeltaX) ? bestOffsetX : 0), + y: target.y + (Number.isFinite(bestDeltaY) ? bestOffsetY : 0), + }; + }, + [nodes, SNAP_GAP], + ); + const toCanvasPosition = useCallback((clientX: number, clientY: number): Position => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; @@ -193,6 +279,7 @@ export function CanvasWorkspace() { // 处理画布点击(取消选择) const handleCanvasClick = useCallback((e: React.MouseEvent) => { + if (hasBlockingDialogOpen()) return; if (!isEventFromCanvasContainer(e.target)) return; if (suppressCanvasClickRef.current) { suppressCanvasClickRef.current = false; @@ -201,10 +288,11 @@ export function CanvasWorkspace() { if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) { clearSelection(); } - }, [clearSelection, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]); + }, [clearSelection, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]); // 处理画布平移开始 const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (hasBlockingDialogOpen()) return; if (!isEventFromCanvasContainer(e.target)) return; if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) { setIsPanning(true); @@ -219,10 +307,14 @@ export function CanvasWorkspace() { setMarqueeStart(start); setMarqueeEnd(start); } - }, [isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]); + }, [hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]); // 处理画布平移 const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (hasBlockingDialogOpen()) { + if (isPanning) setIsPanning(false); + return; + } if (!isEventFromCanvasContainer(e.target)) return; dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); if (isPanning) { @@ -236,7 +328,7 @@ export function CanvasWorkspace() { if (isMarqueeSelecting) { setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY)); } - }, [isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition]); + }, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition]); // 处理画布平移结束 const handleMouseUp = useCallback((e?: React.MouseEvent) => { @@ -313,6 +405,7 @@ export function CanvasWorkspace() { // 处理滚轮缩放 const handleWheel = useCallback((e: React.WheelEvent) => { + if (hasBlockingDialogOpen()) return; if (!isEventFromCanvasContainer(e.target)) return; if (e.cancelable) { e.preventDefault(); @@ -340,7 +433,7 @@ export function CanvasWorkspace() { const newX = mouseX - (mouseX - viewport.x) * scaleChange; const newY = mouseY - (mouseY - viewport.y) * scaleChange; setViewport({ x: newX, y: newY, zoom: newZoom }); - }, [isEventFromCanvasContainer, isMacPlatform, setViewport, viewport.x, viewport.y, viewport.zoom]); + }, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMacPlatform, setViewport, viewport.x, viewport.y, viewport.zoom]); const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; @@ -587,6 +680,61 @@ export function CanvasWorkspace() { // 渲染节点 const renderNode = (node: CanvasNode) => { const isSelected = selectedNodeIds.includes(node.id); + const isImageNode = node.type === "image"; + const enableImageResizeHandles = + isSelected && tool === "select" && isImageNode + ? { + top: false, + right: false, + bottom: false, + left: false, + topRight: true, + bottomRight: true, + bottomLeft: true, + topLeft: true, + } + : isSelected && tool === "select"; + + const imageResizeHandleStyles = isImageNode + ? { + topRight: { + width: 12, + height: 12, + right: -6, + top: -6, + borderRadius: 999, + border: `2px solid ${SELECTION_COLOR}`, + backgroundColor: "#ffffff", + }, + bottomRight: { + width: 12, + height: 12, + right: -6, + bottom: -6, + borderRadius: 999, + border: `2px solid ${SELECTION_COLOR}`, + backgroundColor: "#ffffff", + }, + bottomLeft: { + width: 12, + height: 12, + left: -6, + bottom: -6, + borderRadius: 999, + border: `2px solid ${SELECTION_COLOR}`, + backgroundColor: "#ffffff", + }, + topLeft: { + width: 12, + height: 12, + left: -6, + top: -6, + borderRadius: 999, + border: `2px solid ${SELECTION_COLOR}`, + backgroundColor: "#ffffff", + }, + } + : undefined; return ( 1) { const deltaCanvasX = (d.x - group.startX) / viewport.zoom; const deltaCanvasY = (d.y - group.startY) / viewport.zoom; + const origin = group.originPositions[node.id]; + const rawAnchor = origin + ? { + x: origin.x + deltaCanvasX, + y: origin.y + deltaCanvasY, + } + : { + x: node.position.x + deltaCanvasX, + y: node.position.y + deltaCanvasY, + }; + const snappedAnchor = getSnappedCanvasPosition( + node, + rawAnchor, + new Set(selectedNodeIds), + ); + const offsetX = origin ? snappedAnchor.x - origin.x : deltaCanvasX; + const offsetY = origin ? snappedAnchor.y - origin.y : deltaCanvasY; selectedNodeIds.forEach((id) => { - const origin = group.originPositions[id]; - if (!origin) return; + const itemOrigin = group.originPositions[id]; + if (!itemOrigin) return; moveNode(id, { - x: origin.x + deltaCanvasX, - y: origin.y + deltaCanvasY, + x: itemOrigin.x + offsetX, + y: itemOrigin.y + offsetY, }); }); groupDragRef.current = null; @@ -659,7 +824,8 @@ export function CanvasWorkspace() { const newX = (d.x - viewport.x) / viewport.zoom; const newY = (d.y - viewport.y) / viewport.zoom; - moveNode(node.id, { x: newX, y: newY }); + const snapped = getSnappedCanvasPosition(node, { x: newX, y: newY }); + moveNode(node.id, snapped); groupDragRef.current = null; pushHistory(); }} @@ -674,8 +840,10 @@ export function CanvasWorkspace() { pushHistory(); }} bounds="parent" - enableResizing={isSelected && tool === 'select'} + enableResizing={enableImageResizeHandles} + resizeHandleStyles={imageResizeHandleStyles} disableDragging={tool !== 'select'} + cancel={'input, textarea, button, a, [contenteditable="true"], [data-canvas-no-drag="true"]'} className={cn( "absolute", tool === "select" && "cursor-pointer" diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 89de3e5e1..3d7077a0f 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1437,8 +1437,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { setDraftTitle(e.target.value)} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} onBlur={handleSaveTitle} onKeyDown={(e) => { if (e.key === "Enter") { @@ -1471,9 +1475,9 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
setIsHovered(true)}