perf: smooth canvas panning

This commit is contained in:
Your Name 2026-06-05 20:46:16 +08:00
parent 8943e2900b
commit e2111add87

View File

@ -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<HTMLDivElement>(null);
const contentLayerRef = useRef<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState<Position>({ 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<typeof viewport | null>(null);
const pendingViewportRafRef = useRef<number | null>(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 ? (
<svg className="absolute inset-0 z-[1] h-full w-full overflow-visible">
{renderedMergeTrunks.map(({ targetNodeId, path, edgeIds }) => {
const isSelected = Boolean(selectedEdgeId && edgeIds.includes(selectedEdgeId));
return (
<g key={`merge-trunk-${targetNodeId}`} style={{ pointerEvents: "none" }}>
{isSelected ? (
<div
ref={contentLayerRef}
className="absolute inset-0 z-[2]"
style={{
contain: "layout paint style",
overflow: "visible",
transform: "translate3d(0px, 0px, 0px)",
transformOrigin: "0 0",
}}
>
{isWorkflowCanvas ? (
<svg className="absolute inset-0 z-[1] h-full w-full overflow-visible">
{renderedMergeTrunks.map(({ targetNodeId, path, edgeIds }) => {
const isSelected = Boolean(selectedEdgeId && edgeIds.includes(selectedEdgeId));
return (
<g key={`merge-trunk-${targetNodeId}`} style={{ pointerEvents: "none" }}>
{isSelected ? (
<path
d={path}
fill="none"
stroke="rgba(59, 130, 246, 0.18)"
strokeLinecap="round"
strokeWidth={10}
/>
) : null}
<path
d={path}
fill="none"
stroke="rgba(59, 130, 246, 0.18)"
stroke={isSelected ? WORKFLOW_EDGE_ACTIVE_COLOR : WORKFLOW_EDGE_COLOR}
strokeLinecap="round"
strokeWidth={10}
strokeWidth={isSelected ? 4 : 3}
/>
) : null}
<path
d={path}
fill="none"
stroke={isSelected ? WORKFLOW_EDGE_ACTIVE_COLOR : WORKFLOW_EDGE_COLOR}
strokeLinecap="round"
strokeWidth={isSelected ? 4 : 3}
/>
</g>
);
})}
{renderedEdges.map(({ edge, path }) => {
const isSelected = selectedEdgeId === edge.id;
return (
<g key={edge.id}>
{isSelected ? (
</g>
);
})}
{renderedEdges.map(({ edge, path }) => {
const isSelected = selectedEdgeId === edge.id;
return (
<g key={edge.id}>
{isSelected ? (
<path
d={path}
fill="none"
stroke="rgba(59, 130, 246, 0.18)"
strokeLinecap="round"
strokeWidth={10}
style={{ pointerEvents: "none" }}
/>
) : null}
<path
d={path}
fill="none"
stroke="rgba(59, 130, 246, 0.18)"
stroke={isSelected ? WORKFLOW_EDGE_ACTIVE_COLOR : WORKFLOW_EDGE_COLOR}
strokeDasharray={edge.type === "reference" ? "6 6" : undefined}
strokeLinecap="round"
strokeWidth={10}
strokeWidth={isSelected ? 4 : 3}
className="cursor-pointer transition-all"
style={{ pointerEvents: "stroke" }}
onClick={(event) => {
event.stopPropagation();
setSelectedEdgeId(edge.id);
clearSelection();
}}
onDoubleClick={(event) => {
event.stopPropagation();
deleteEdge(edge.id);
setSelectedEdgeId(null);
}}
/>
</g>
);
})}
{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 (
<g>
<path
d={buildWorkflowPath(sourcePoint, targetPoint)}
fill="none"
stroke="rgba(59, 130, 246, 0.14)"
strokeLinecap="round"
strokeWidth={8}
style={{ pointerEvents: "none" }}
/>
) : null}
<path
d={path}
fill="none"
stroke={isSelected ? WORKFLOW_EDGE_ACTIVE_COLOR : WORKFLOW_EDGE_COLOR}
strokeDasharray={edge.type === "reference" ? "6 6" : undefined}
strokeLinecap="round"
strokeWidth={isSelected ? 4 : 3}
className="cursor-pointer transition-all"
style={{ pointerEvents: "stroke" }}
onClick={(event) => {
event.stopPropagation();
setSelectedEdgeId(edge.id);
clearSelection();
}}
onDoubleClick={(event) => {
event.stopPropagation();
deleteEdge(edge.id);
setSelectedEdgeId(null);
}}
/>
</g>
);
})}
{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 (
<g>
<path
d={buildWorkflowPath(sourcePoint, targetPoint)}
fill="none"
stroke="rgba(59, 130, 246, 0.14)"
strokeLinecap="round"
strokeWidth={8}
style={{ pointerEvents: "none" }}
/>
<path
d={buildWorkflowPath(sourcePoint, targetPoint)}
fill="none"
stroke={WORKFLOW_EDGE_COLOR}
strokeDasharray="8 6"
strokeLinecap="round"
strokeWidth={3}
style={{ pointerEvents: "none" }}
/>
<circle
cx={targetPoint.x}
cy={targetPoint.y}
r={5}
fill="white"
stroke={WORKFLOW_EDGE_ACTIVE_COLOR}
strokeWidth={2}
style={{ pointerEvents: "none" }}
/>
</g>
);
})() : null}
</svg>
) : null}
<path
d={buildWorkflowPath(sourcePoint, targetPoint)}
fill="none"
stroke={WORKFLOW_EDGE_COLOR}
strokeDasharray="8 6"
strokeLinecap="round"
strokeWidth={3}
style={{ pointerEvents: "none" }}
/>
<circle
cx={targetPoint.x}
cy={targetPoint.y}
r={5}
fill="white"
stroke={WORKFLOW_EDGE_ACTIVE_COLOR}
strokeWidth={2}
style={{ pointerEvents: "none" }}
/>
</g>
);
})() : null}
</svg>
) : null}
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
<div className="absolute inset-0 z-[2]" style={{ overflow: 'visible' }}>
{visibleNodes.map(renderNode)}
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
<div className="absolute inset-0 z-[2]" style={{ overflow: 'visible' }}>
{visibleNodes.map(renderNode)}
</div>
</div>
{/* 性能指示器(节点数量大于 50 时显示) */}