From e2111add876ab4dcea370403d8f9ce9696e14cd7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 5 Jun 2026 20:46:16 +0800 Subject: [PATCH] perf: smooth canvas panning --- components/canvas/CanvasWorkspace.tsx | 372 ++++++++++++++++++-------- 1 file changed, 258 insertions(+), 114 deletions(-) diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index b481d06cb..6775b05e3 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -227,8 +227,8 @@ function isNodeInViewport( const nodeScreenWidth = node.size.width * viewport.zoom; const nodeScreenHeight = node.size.height * viewport.zoom; - // 添加缓冲区(200px),使即将进入视口的节点也被渲染 - const buffer = 200; + // 拖动画布时会先走 GPU 位移预览,保留更大的热区避免快速平移时露白。 + const buffer = 800; return ( nodeScreenX + nodeScreenWidth > -buffer && @@ -299,6 +299,7 @@ export function CanvasWorkspace({ horizontal?: { y: number; left: number; right: number }; }; const containerRef = useRef(null); + const contentLayerRef = useRef(null); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false); @@ -331,8 +332,101 @@ export function CanvasWorkspace({ const viewport = useCanvasStore(canvasSelectors.viewport); const tool = useCanvasStore(canvasSelectors.tool); const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId); + const setViewport = useCanvasStore((state) => state.setViewport); + const viewportRef = useRef(viewport); + const pendingViewportRef = useRef(null); + const pendingViewportRafRef = useRef(null); + const panPreviewRef = useRef<{ + startClientX: number; + startClientY: number; + startViewport: typeof viewport; + currentViewport: typeof viewport; + offsetX: number; + offsetY: number; + rafId: number | null; + } | null>(null); // 性能优化:监听容器大小变化 + React.useEffect(() => { + viewportRef.current = viewport; + }, [viewport]); + + React.useEffect(() => { + return () => { + if (pendingViewportRafRef.current !== null) { + window.cancelAnimationFrame(pendingViewportRafRef.current); + } + const panPreview = panPreviewRef.current; + if (panPreview && panPreview.rafId !== null) { + window.cancelAnimationFrame(panPreview.rafId); + } + }; + }, []); + + const resetContentLayerTransform = useCallback(() => { + const layer = contentLayerRef.current; + if (!layer) return; + layer.style.transform = "translate3d(0px, 0px, 0px)"; + layer.style.willChange = ""; + }, []); + + const schedulePanPreviewTransform = useCallback((offsetX: number, offsetY: number) => { + const session = panPreviewRef.current; + if (!session) return; + + session.offsetX = offsetX; + session.offsetY = offsetY; + if (session.rafId !== null) return; + + session.rafId = window.requestAnimationFrame(() => { + const activeSession = panPreviewRef.current; + if (!activeSession) return; + activeSession.rafId = null; + const layer = contentLayerRef.current; + if (!layer) return; + layer.style.transform = `translate3d(${activeSession.offsetX}px, ${activeSession.offsetY}px, 0px)`; + layer.style.willChange = "transform"; + }); + }, []); + + const finishPanPreview = useCallback(() => { + const session = panPreviewRef.current; + if (!session) return null; + if (session.rafId !== null) { + window.cancelAnimationFrame(session.rafId); + } + const finalViewport = session.currentViewport; + panPreviewRef.current = null; + return finalViewport; + }, []); + + const cancelPanPreview = useCallback(() => { + const session = panPreviewRef.current; + if (session && session.rafId !== null) { + window.cancelAnimationFrame(session.rafId); + } + panPreviewRef.current = null; + resetContentLayerTransform(); + }, [resetContentLayerTransform]); + + const scheduleViewport = useCallback( + (nextViewport: typeof viewport) => { + pendingViewportRef.current = nextViewport; + viewportRef.current = nextViewport; + + if (pendingViewportRafRef.current !== null) return; + pendingViewportRafRef.current = window.requestAnimationFrame(() => { + pendingViewportRafRef.current = null; + const queuedViewport = pendingViewportRef.current; + pendingViewportRef.current = null; + if (queuedViewport) { + setViewport(queuedViewport); + } + }); + }, + [setViewport] + ); + React.useEffect(() => { const updateSize = () => { if (containerRef.current) { @@ -444,7 +538,6 @@ export function CanvasWorkspace({ const selectedImageLocalEditEnabled = true; // Store actions - const setViewport = useCanvasStore((state) => state.setViewport); const selectNode = useCanvasStore((state) => state.selectNode); const selectNodes = useCanvasStore((state) => state.selectNodes); const clearSelection = useCanvasStore((state) => state.clearSelection); @@ -865,8 +958,19 @@ export function CanvasWorkspace({ (e.button === 0 && e.altKey) || shouldPanBlankWorkflowCanvas ) { + cancelPanPreview(); + const startViewport = viewportRef.current; setIsPanning(true); - setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y }); + setPanStart({ x: e.clientX - startViewport.x, y: e.clientY - startViewport.y }); + panPreviewRef.current = { + startClientX: e.clientX, + startClientY: e.clientY, + startViewport, + currentViewport: startViewport, + offsetX: 0, + offsetY: 0, + rafId: null, + }; e.preventDefault(); return; } @@ -879,12 +983,15 @@ export function CanvasWorkspace({ setMarqueeStart(start); setMarqueeEnd(start); } - }, [clearBrowserTextSelection, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool, viewport]); + }, [cancelPanPreview, clearBrowserTextSelection, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, isWorkflowCanvas, toCanvasPosition, tool]); // 处理画布平移 const handleMouseMove = useCallback((e: React.MouseEvent) => { if (hasBlockingDialogOpen()) { - if (isPanning) setIsPanning(false); + if (isPanning) { + setIsPanning(false); + cancelPanPreview(); + } return; } if (!isEventFromCanvasContainer(e.target)) return; @@ -908,10 +1015,29 @@ export function CanvasWorkspace({ } if (isPanning) { e.preventDefault(); - setViewport({ - x: e.clientX - panStart.x, - y: e.clientY - panStart.y, - }); + const session = panPreviewRef.current; + const currentViewport = viewportRef.current; + const nextViewport = session + ? { + ...session.startViewport, + x: session.startViewport.x + e.clientX - session.startClientX, + y: session.startViewport.y + e.clientY - session.startClientY, + } + : { + ...currentViewport, + x: e.clientX - panStart.x, + y: e.clientY - panStart.y, + }; + if (session) { + session.currentViewport = nextViewport; + viewportRef.current = nextViewport; + schedulePanPreviewTransform( + nextViewport.x - session.startViewport.x, + nextViewport.y - session.startViewport.y, + ); + } else { + scheduleViewport(nextViewport); + } return; } @@ -919,11 +1045,11 @@ export function CanvasWorkspace({ e.preventDefault(); setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY)); } - }, [connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition, viewport.x, viewport.y, viewport.zoom]); + }, [cancelPanPreview, connectionDraft, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart.x, panStart.y, schedulePanPreviewTransform, scheduleViewport, toCanvasPosition, viewport.x, viewport.y, viewport.zoom]); // 处理画布平移结束 const handleMouseUp = useCallback((e?: React.MouseEvent) => { - if (e && !isEventFromCanvasContainer(e.target)) return; + if (e && !isEventFromCanvasContainer(e.target) && !isPanning) return; if (connectionDraft) { if (e) { const releaseTarget = document.elementFromPoint(e.clientX, e.clientY); @@ -945,6 +1071,11 @@ export function CanvasWorkspace({ setConnectionDraft(null); return; } + const finalPanViewport = isPanning ? finishPanPreview() : null; + if (finalPanViewport) { + setViewport(finalPanViewport); + resetContentLayerTransform(); + } setIsPanning(false); setSnapGuides(null); clearBrowserTextSelection(); @@ -982,7 +1113,7 @@ export function CanvasWorkspace({ setIsMarqueeSelecting(false); setMarqueeStart(null); setMarqueeEnd(null); - }, [addEdge, clearBrowserTextSelection, clearSelection, connectionDraft, isEventFromCanvasContainer, isMarqueeSelecting, marqueeEnd, marqueeStart, nodes, selectNodes, viewport.x, viewport.y, viewport.zoom]); + }, [addEdge, clearBrowserTextSelection, clearSelection, connectionDraft, finishPanPreview, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, marqueeEnd, marqueeStart, nodes, resetContentLayerTransform, selectNodes, setViewport, viewport.x, viewport.y, viewport.zoom]); // 新增图片后自动居中展示 const centerNodeInView = useCallback((nodeId: string) => { @@ -1029,25 +1160,27 @@ export function CanvasWorkspace({ // mac: 双指滚动默认平移,Cmd/Ctrl+滚轮才缩放(保留触控板体验) // windows/linux: 鼠标滚轮默认缩放画布,不触发整页缩放 const shouldZoom = isMacPlatform ? (e.ctrlKey || e.metaKey) : !e.shiftKey; + const currentViewport = viewportRef.current; if (!shouldZoom) { - setViewport({ - x: viewport.x - e.deltaX, - y: viewport.y - e.deltaY, + scheduleViewport({ + ...currentViewport, + x: currentViewport.x - e.deltaX, + y: currentViewport.y - e.deltaY, }); return; } const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05; - const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * zoomFactor)); + const newZoom = Math.max(0.1, Math.min(4, currentViewport.zoom * zoomFactor)); const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; - const scaleChange = newZoom / viewport.zoom; - const newX = mouseX - (mouseX - viewport.x) * scaleChange; - const newY = mouseY - (mouseY - viewport.y) * scaleChange; - setViewport({ x: newX, y: newY, zoom: newZoom }); - }, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMacPlatform, setViewport, viewport.x, viewport.y, viewport.zoom]); + const scaleChange = newZoom / currentViewport.zoom; + const newX = mouseX - (mouseX - currentViewport.x) * scaleChange; + const newY = mouseY - (mouseY - currentViewport.y) * scaleChange; + scheduleViewport({ ...currentViewport, x: newX, y: newY, zoom: newZoom }); + }, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMacPlatform, scheduleViewport]); const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; @@ -1856,110 +1989,121 @@ export function CanvasWorkspace({ )} - {isWorkflowCanvas ? ( - - {renderedMergeTrunks.map(({ targetNodeId, path, edgeIds }) => { - const isSelected = Boolean(selectedEdgeId && edgeIds.includes(selectedEdgeId)); - return ( - - {isSelected ? ( +
+ {isWorkflowCanvas ? ( + + {renderedMergeTrunks.map(({ targetNodeId, path, edgeIds }) => { + const isSelected = Boolean(selectedEdgeId && edgeIds.includes(selectedEdgeId)); + return ( + + {isSelected ? ( + + ) : null} - ) : null} - - - ); - })} - {renderedEdges.map(({ edge, path }) => { - const isSelected = selectedEdgeId === edge.id; - return ( - - {isSelected ? ( + + ); + })} + {renderedEdges.map(({ edge, path }) => { + const isSelected = selectedEdgeId === edge.id; + return ( + + {isSelected ? ( + + ) : null} { + event.stopPropagation(); + setSelectedEdgeId(edge.id); + clearSelection(); + }} + onDoubleClick={(event) => { + event.stopPropagation(); + deleteEdge(edge.id); + setSelectedEdgeId(null); + }} + /> + + ); + })} + {connectionDraft ? (() => { + const sourceNode = nodes.find((node) => node.id === connectionDraft.sourceId); + if (!sourceNode) return null; + const sourcePoint = toScreenPosition(getNodeOutputAnchor(sourceNode)); + const targetPoint = toScreenPosition(connectionDraft.current); + return ( + + - ) : null} - { - event.stopPropagation(); - setSelectedEdgeId(edge.id); - clearSelection(); - }} - onDoubleClick={(event) => { - event.stopPropagation(); - deleteEdge(edge.id); - setSelectedEdgeId(null); - }} - /> - - ); - })} - {connectionDraft ? (() => { - const sourceNode = nodes.find((node) => node.id === connectionDraft.sourceId); - if (!sourceNode) return null; - const sourcePoint = toScreenPosition(getNodeOutputAnchor(sourceNode)); - const targetPoint = toScreenPosition(connectionDraft.current); - return ( - - - - - - ); - })() : null} - - ) : null} + + + + ); + })() : null} + + ) : null} - {/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */} -
- {visibleNodes.map(renderNode)} + {/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */} +
+ {visibleNodes.map(renderNode)} +
{/* 性能指示器(节点数量大于 50 时显示) */}