From c6577b4fab3ad3ac2ac951019e512f73b92d0dfd Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 10 Sep 2026 17:29:03 +0800 Subject: [PATCH] fix(diagram): fit the canvas after switching notes Wait for a real canvas size, then refit once scroller auto-resize settles, so a newly opened diagram is not left in a corner of the previous viewport. --- .../src/components/DiagramEditorPane.test.mjs | 13 ++++++++ apps/web/src/components/DiagramEditorPane.tsx | 32 ++++++++++++++++--- .../web/src/lib/diagram-scroller-fit.test.mjs | 14 +++++++- apps/web/src/lib/diagram-scroller-fit.ts | 10 ++++++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index be88b6e2..3520ce57 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -33,6 +33,19 @@ describe("diagram editor keyboard workflow", () => { expect(source).not.toContain("visibleNodes.length !== graph.getNodes().length"); }); + test("fits a newly opened diagram after the canvas has size and scroller auto-resize settles", () => { + const init = source.slice(source.indexOf("graph = new Graph"), source.indexOf("const updateHistory")); + expect(init.indexOf("graphRef.current = graph")).toBeGreaterThan(-1); + expect(init.indexOf("graphRef.current = graph")).toBeLessThan(init.indexOf("const settleLoadedViewport")); + expect(init).toContain("diagramCanvasIsReady(canvasSurfaceRef.current)"); + expect(init).toContain("scroller?.disableAutoResize()"); + expect(init).toContain("new ResizeObserver"); + expect(init).toContain("scroller?.enableAutoResize()"); + expect(init).toContain("scroller?.updateScroller()"); + expect(init).toContain("SCROLLER_AUTORESIZE_SETTLE_MS"); + expect(init).toContain("loadFitObserver?.disconnect()"); + }); + test("does not let scroller auto-fit flash a detached node while inserting", () => { expect(source).toContain("disableAutoResize()"); expect(source).toContain("enableAutoResize()"); diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 0b790443..be3795a4 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -136,7 +136,7 @@ import { getDiagramLayoutViewport, type DiagramLayoutViewport, } from "@/lib/diagram-layout"; -import { applyDiagramScrollerFitOptions, isUsableDiagramBounds } from "@/lib/diagram-scroller-fit"; +import { applyDiagramScrollerFitOptions, diagramCanvasIsReady, isUsableDiagramBounds } from "@/lib/diagram-scroller-fit"; import { resolveDiagramPalette, type DiagramAppearance } from "@/lib/diagram-theme"; import { isLocalMemoId } from "@/lib/local-mirror"; import { isBrowserOffline } from "@/lib/network-status"; @@ -1611,6 +1611,7 @@ export const DiagramEditorPane = ({ className: "edgeever-diagram-scroller", })); bindDiagramScrollerFit(graph); + graphRef.current = graph; graph.use(new History({ enabled: !readOnly })); graph.use(new Export()); graph.use(new Keyboard({ @@ -1652,14 +1653,35 @@ export const DiagramEditorPane = ({ applyGraphPalette(graph, documentTheme, document.kind, appearance, documentStructure); graph.on("scale", () => setZoomPercent(Math.round(graph.scale().sx * 100))); graph.cleanHistory(); + const scroller = getDiagramScroller(graph); + scroller?.disableAutoResize(); const settleLoadedViewport = () => { - if (graphRef.current !== graph) return; + if (graphRef.current !== graph) return false; + if (!diagramCanvasIsReady(canvasSurfaceRef.current)) return false; ensureDiagramPaperContainsNodes(graph); fitDiagramContent(graph, document, containerRef.current); + return true; }; settleLoadedViewport(); graph.once("render:done", settleLoadedViewport); - requestAnimationFrame(settleLoadedViewport); + const loadFitFrame = window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => settleLoadedViewport()); + }); + const canvasSurface = canvasSurfaceRef.current; + let loadFitObserver: ResizeObserver | null = null; + if (canvasSurface) { + loadFitObserver = new ResizeObserver(() => { + if (settleLoadedViewport()) loadFitObserver?.disconnect(); + }); + loadFitObserver.observe(canvasSurface); + } + const loadFitTimer = window.setTimeout(() => { + if (graphRef.current !== graph) return; + loadFitObserver?.disconnect(); + scroller?.enableAutoResize(); + scroller?.updateScroller(); + settleLoadedViewport(); + }, SCROLLER_AUTORESIZE_SETTLE_MS); const updateHistory = () => setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() }); const markDirty = () => { @@ -2015,13 +2037,15 @@ export const DiagramEditorPane = ({ graphToDocument(graph, document.kind, themeRef.current, structureRef.current), )); }); - graphRef.current = graph; return () => { containerRef.current?.removeEventListener("pointerdown", handleFlowPointerDown, true); window.removeEventListener("pointerup", handleFlowPointerUp, true); flowPointerDragRef.current = null; openFlowQuickCreateRef.current = () => undefined; nodeEditorRef.current = null; + loadFitObserver?.disconnect(); + window.clearTimeout(loadFitTimer); + window.cancelAnimationFrame(loadFitFrame); if (scrollerResumeTimerRef.current !== null) { window.clearTimeout(scrollerResumeTimerRef.current); scrollerResumeTimerRef.current = null; diff --git a/apps/web/src/lib/diagram-scroller-fit.test.mjs b/apps/web/src/lib/diagram-scroller-fit.test.mjs index 921d3945..ee2b754c 100644 --- a/apps/web/src/lib/diagram-scroller-fit.test.mjs +++ b/apps/web/src/lib/diagram-scroller-fit.test.mjs @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { applyDiagramScrollerFitOptions, isUsableDiagramBounds } from "./diagram-scroller-fit.ts"; +import { + DIAGRAM_CANVAS_READY_MIN_SIZE, + applyDiagramScrollerFitOptions, + diagramCanvasIsReady, + isUsableDiagramBounds, +} from "./diagram-scroller-fit.ts"; describe("diagram scroller fit options", () => { test("uses every node box so left-of-origin mind-map branches stay on the paper", () => { @@ -24,4 +29,11 @@ describe("diagram scroller fit options", () => { expect(isUsableDiagramBounds({ x: Number.NaN, y: 0, width: 80, height: 36 })).toBe(false); expect(isUsableDiagramBounds({ x: -10, y: 0, width: 80, height: 36 })).toBe(true); }); + + test("waits for the canvas to have a real viewport before fitting a loaded diagram", () => { + expect(diagramCanvasIsReady(null)).toBe(false); + expect(diagramCanvasIsReady({ clientWidth: 0, clientHeight: 640 })).toBe(false); + expect(diagramCanvasIsReady({ clientWidth: DIAGRAM_CANVAS_READY_MIN_SIZE - 1, clientHeight: 640 })).toBe(false); + expect(diagramCanvasIsReady({ clientWidth: 960, clientHeight: 640 })).toBe(true); + }); }); diff --git a/apps/web/src/lib/diagram-scroller-fit.ts b/apps/web/src/lib/diagram-scroller-fit.ts index b9d01b46..50c4a099 100644 --- a/apps/web/src/lib/diagram-scroller-fit.ts +++ b/apps/web/src/lib/diagram-scroller-fit.ts @@ -5,6 +5,16 @@ export type DiagramFitBounds = { height: number; }; +export const DIAGRAM_CANVAS_READY_MIN_SIZE = 32; + +export const diagramCanvasIsReady = ( + element: { clientWidth: number; clientHeight: number } | null | undefined, +) => Boolean( + element + && element.clientWidth >= DIAGRAM_CANVAS_READY_MIN_SIZE + && element.clientHeight >= DIAGRAM_CANVAS_READY_MIN_SIZE, +); + const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);