From bb460bfb80dac396c8d0462501497d3e544f0f64 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 8 Sep 2026 19:58:34 +0800 Subject: [PATCH] fix(diagram): zoom and center through the scroller Route zoom, fit, and reset through X6 Scroller so the paper stays aligned with the viewport instead of the graph transforming underneath the scroll container. --- .../src/components/DiagramEditorPane.test.mjs | 9 ++- apps/web/src/components/DiagramEditorPane.tsx | 79 +++++++++++++++---- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index 6e115576..dc856eb9 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -171,7 +171,8 @@ describe("diagram editor canvas surface", () => { expect(toolbarSource).toContain("showTheme = true"); expect(source).toContain("maxScale: policy.maxScale"); expect(source).not.toContain("minScale: policy.minScale"); - expect(source).toContain("graph.centerContent()"); + expect(source).toContain("centerDiagramContent(graph)"); + expect(source).toContain("scroller.centerContent()"); expect(source).not.toContain("desiredLeft - contentLeft"); expect(source).not.toContain("visibleNodes.length !== graph.getNodes().length"); expect(source).toContain("bindDiagramScrollerFit(graph)"); @@ -179,9 +180,9 @@ describe("diagram editor canvas surface", () => { expect(source).toContain("diagramNodeBounds(graph)"); expect(source).toContain("graph.scale().sx < policy.minScale"); expect(source).toContain("readFlowchart(graph, document, container)"); - expect(source).toContain("scroller?.container.clientHeight"); - expect(source).toContain("graph.centerPoint(box.x + box.width / 2, box.y + viewportHeight / 2 - 48)"); - expect(source).toContain("graph.zoomToRect(bounds, { padding, maxScale: policy.maxScale })"); + expect(source).toContain("scroller.positionPoint({ x: box.x + box.width / 2, y: box.y }, \"50%\", 48)"); + expect(source).toContain("fitDiagramRect(graph, bounds, { padding, maxScale: policy.maxScale })"); + expect(source).toContain("scroller.zoomToRect(bounds, options)"); expect(source).toContain("getDiagramLayoutViewport(document.kind)"); expect(source).toContain("fitDiagramContent(graph, document, containerRef.current);"); }); diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index d8d8c8a5..95343f18 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -645,13 +645,43 @@ const ensureDiagramPaperContainsNodes = (graph: Graph) => { if (!bounds) return; graph.transform.fitToContent({ allowNewOrigin: "any", - padding: 32, + padding: 48, gridWidth: 1, gridHeight: 1, contentArea: bounds, }); }; +const zoomDiagram = (graph: Graph, factor: number, absolute = false) => { + const scroller = getDiagramScroller(graph); + if (scroller) { + if (absolute) scroller.zoomTo(factor); + else scroller.zoom(factor); + return; + } + if (absolute) graph.zoomTo(factor); + else graph.zoom(factor); +}; + +const fitDiagramRect = ( + graph: Graph, + bounds: { x: number; y: number; width: number; height: number }, + options: { padding: number; maxScale: number }, +) => { + const scroller = getDiagramScroller(graph); + if (scroller) scroller.zoomToRect(bounds, options); + else graph.zoomToRect(bounds, options); +}; + +const centerDiagramContent = (graph: Graph) => { + const scroller = getDiagramScroller(graph); + if (scroller) { + scroller.centerContent(); + return; + } + graph.centerContent(); +}; + const diagramClientToLocalPoint = (graph: Graph, point: { x: number; y: number }) => { const anchorNode = graph.getNodes().find((node) => node.isVisible()); const anchorView = anchorNode ? graph.findViewByCell(anchorNode) : null; @@ -1103,14 +1133,17 @@ const readFlowchart = (graph: Graph, document: DiagramDocument, container: HTMLE const start = document.nodes.find((node) => !incoming.has(node.id)) ?? document.nodes[0]; const cell = graph.getCellById(start.id); if (!cell?.isNode()) return; - graph.zoomTo(1); + ensureDiagramPaperContainsNodes(graph); + zoomDiagram(graph, 1, true); + ensureDiagramPaperContainsNodes(graph); const box = cell.getBBox(); const scroller = getDiagramScroller(graph); - const viewportHeight = scroller?.container.clientHeight - || container?.clientHeight - || graph.container.clientHeight - || 640; - graph.centerPoint(box.x + box.width / 2, box.y + viewportHeight / 2 - 48); + if (scroller) { + scroller.positionPoint({ x: box.x + box.width / 2, y: box.y }, "50%", 48); + } else { + graph.centerPoint(box.x + box.width / 2, box.y); + } + ensureDiagramPaperContainsNodes(graph); }; const fitDiagramContent = ( @@ -1123,9 +1156,11 @@ const fitDiagramContent = ( const policy = viewport ?? getDiagramLayoutViewport(document.kind); const bounds = diagramNodeBounds(graph); if (!bounds) return; + ensureDiagramPaperContainsNodes(graph); // Fit every node, including mind-map branches left of the root. Zooming to a // visible subset or to edge paths lets Scroller shrink the paper and clip. - graph.zoomToRect(bounds, { padding, maxScale: policy.maxScale }); + fitDiagramRect(graph, bounds, { padding, maxScale: policy.maxScale }); + ensureDiagramPaperContainsNodes(graph); if ( document.kind === "flowchart" && policy.minScale != null @@ -1929,8 +1964,10 @@ export const DiagramEditorPane = ({ }); graph.bindKey("1", (event) => { event.preventDefault(); - graph.zoomTo(1); - graph.centerContent(); + ensureDiagramPaperContainsNodes(graph); + zoomDiagram(graph, 1, true); + ensureDiagramPaperContainsNodes(graph); + centerDiagramContent(graph); }); graph.bindKey("esc", (event) => { if (!flowQuickCreateRef.current) return; @@ -2821,12 +2858,26 @@ export const DiagramEditorPane = ({ onResetZoom={() => { const graph = graphRef.current; if (!graph) return; - graph.zoomTo(1); + ensureDiagramPaperContainsNodes(graph); + zoomDiagram(graph, 1, true); + ensureDiagramPaperContainsNodes(graph); const bounds = diagramNodeBounds(graph); - if (bounds) graph.centerPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + const scroller = getDiagramScroller(graph); + if (bounds && scroller) scroller.centerPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + else if (bounds) graph.centerPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + }} + onZoomIn={() => { + const graph = graphRef.current; + if (!graph) return; + zoomDiagram(graph, 0.1); + ensureDiagramPaperContainsNodes(graph); + }} + onZoomOut={() => { + const graph = graphRef.current; + if (!graph) return; + zoomDiagram(graph, -0.1); + ensureDiagramPaperContainsNodes(graph); }} - onZoomIn={() => graphRef.current?.zoom(0.1)} - onZoomOut={() => graphRef.current?.zoom(-0.1)} readOnly={readOnly} selectionEditor={( <>