perf: smooth canvas panning
This commit is contained in:
parent
8943e2900b
commit
e2111add87
@ -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 时显示) */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user