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.
This commit is contained in:
tianma-if committed 2026-09-10 17:29:03 +08:00
1 parent a4a10a32b9
commit c6577b4fab
4 files changed
+64 -5

No files matched your search

@@ -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()");
+28 -4
View File
@@ -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;
+13 -1
View File
@@ -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);
});
});
+10
View File
@@ -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);