mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Fit topology canvas after the pane has a real size.
Avoid the corner-only first paint on slow layouts by deferring fitView until the container is ready and re-fitting on resize. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6642f4b8b5
commit
5821d08d63
1 changed files with 79 additions and 12 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -122,9 +122,16 @@ const SNAP_GRID: [number, number] = [16, 16];
|
||||||
*/
|
*/
|
||||||
const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
|
const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
|
||||||
const FIT_VIEW_OPTS_FS = { padding: 0.15, includeHiddenNodes: true } as const;
|
const FIT_VIEW_OPTS_FS = { padding: 0.15, includeHiddenNodes: true } as const;
|
||||||
|
type FitViewOpts = typeof FIT_VIEW_OPTS | typeof FIT_VIEW_OPTS_FS;
|
||||||
const UNDO_MAX = 40;
|
const UNDO_MAX = 40;
|
||||||
const PALETTE_DND = "application/x-netx-topo-palette";
|
const PALETTE_DND = "application/x-netx-topo-palette";
|
||||||
|
|
||||||
|
/** Pane must be laid out before fitView; zero-size panes produce a corner-zoom viewport. */
|
||||||
|
function canvasPaneReady(el: HTMLElement | null | undefined): boolean {
|
||||||
|
if (!el) return false;
|
||||||
|
return el.clientWidth >= 40 && el.clientHeight >= 40;
|
||||||
|
}
|
||||||
|
|
||||||
function FullscreenIcon({ exit }: { exit?: boolean }) {
|
function FullscreenIcon({ exit }: { exit?: boolean }) {
|
||||||
if (exit) {
|
if (exit) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -878,6 +885,9 @@ export function TopologyPage() {
|
||||||
const appliedMapIdRef = useRef("");
|
const appliedMapIdRef = useRef("");
|
||||||
const appliedGraphFpRef = useRef("");
|
const appliedGraphFpRef = useRef("");
|
||||||
const nodesRef = useRef<Node<NeNodeData>[]>([]);
|
const nodesRef = useRef<Node<NeNodeData>[]>([]);
|
||||||
|
/** True until the first successful fitView after opening / switching a canvas. */
|
||||||
|
const needsInitialFitRef = useRef(false);
|
||||||
|
const fitGenRef = useRef(0);
|
||||||
const historyRef = useRef<HistorySnap[]>([]);
|
const historyRef = useRef<HistorySnap[]>([]);
|
||||||
const redoRef = useRef<HistorySnap[]>([]);
|
const redoRef = useRef<HistorySnap[]>([]);
|
||||||
const historyLockRef = useRef(false);
|
const historyLockRef = useRef(false);
|
||||||
|
|
@ -887,6 +897,39 @@ export function TopologyPage() {
|
||||||
|
|
||||||
nodesRef.current = nodes;
|
nodesRef.current = nodes;
|
||||||
|
|
||||||
|
const scheduleFitView = useCallback((opts: FitViewOpts = FIT_VIEW_OPTS) => {
|
||||||
|
const gen = ++fitGenRef.current;
|
||||||
|
const attempt = (n: number) => {
|
||||||
|
if (gen !== fitGenRef.current) return;
|
||||||
|
const inst = rfRef.current;
|
||||||
|
const paneOk = canvasPaneReady(canvasRef.current);
|
||||||
|
if (!inst || !paneOk || nodesRef.current.length === 0) {
|
||||||
|
if (n < 80) window.setTimeout(() => attempt(n + 1), 40);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Double rAF: wait for flex layout + React Flow pane measurement.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (gen !== fitGenRef.current) return;
|
||||||
|
if (!canvasPaneReady(canvasRef.current) || !rfRef.current) {
|
||||||
|
if (n < 80) window.setTimeout(() => attempt(n + 1), 40);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rfRef.current.fitView({ ...opts });
|
||||||
|
// Late layout (fonts / sidebar) on slower hosts — one deferred refit.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (gen !== fitGenRef.current) return;
|
||||||
|
if (canvasPaneReady(canvasRef.current) && rfRef.current && nodesRef.current.length > 0) {
|
||||||
|
rfRef.current.fitView({ ...opts });
|
||||||
|
needsInitialFitRef.current = false;
|
||||||
|
}
|
||||||
|
}, 160);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
attempt(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const markDirty = useCallback(() => {
|
const markDirty = useCallback(() => {
|
||||||
dirtyRef.current = true;
|
dirtyRef.current = true;
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
|
|
@ -1041,6 +1084,28 @@ export function TopologyPage() {
|
||||||
}, [mapId, selectedFolderId, treeRoot, regions]);
|
}, [mapId, selectedFolderId, treeRoot, regions]);
|
||||||
|
|
||||||
const canvasMode = Boolean(mapId);
|
const canvasMode = Boolean(mapId);
|
||||||
|
|
||||||
|
// If the pane was 0×0 when nodes arrived (common on remote Linux), fit once it grows.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasMode) return;
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el || typeof ResizeObserver === "undefined") return;
|
||||||
|
let prevW = el.clientWidth;
|
||||||
|
let prevH = el.clientHeight;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const w = el.clientWidth;
|
||||||
|
const h = el.clientHeight;
|
||||||
|
const becameReady = (prevW < 40 || prevH < 40) && w >= 40 && h >= 40;
|
||||||
|
prevW = w;
|
||||||
|
prevH = h;
|
||||||
|
if (becameReady && needsInitialFitRef.current && nodesRef.current.length > 0) {
|
||||||
|
scheduleFitView(FIT_VIEW_OPTS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [canvasMode, mapId, scheduleFitView]);
|
||||||
|
|
||||||
const activeRegion = useMemo(() => {
|
const activeRegion = useMemo(() => {
|
||||||
if (!selectedFolderId) return null;
|
if (!selectedFolderId) return null;
|
||||||
return regions.find((c) => c.id === selectedFolderId) || null;
|
return regions.find((c) => c.id === selectedFolderId) || null;
|
||||||
|
|
@ -1148,10 +1213,11 @@ export function TopologyPage() {
|
||||||
redoRef.current = [];
|
redoRef.current = [];
|
||||||
clearDirty();
|
clearDirty();
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
window.setTimeout(() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS }), 50);
|
needsInitialFitRef.current = true;
|
||||||
|
scheduleFitView(FIT_VIEW_OPTS);
|
||||||
}
|
}
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
}, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]);
|
}, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory, scheduleFitView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults)));
|
setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults)));
|
||||||
|
|
@ -1235,7 +1301,7 @@ export function TopologyPage() {
|
||||||
const next = layoutGraph(nodes, edges, kind, { onlyIds });
|
const next = layoutGraph(nodes, edges, kind, { onlyIds });
|
||||||
setNodes(next);
|
setNodes(next);
|
||||||
markDirty();
|
markDirty();
|
||||||
window.setTimeout(() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS }), 40);
|
window.setTimeout(() => scheduleFitView(FIT_VIEW_OPTS), 40);
|
||||||
if (opts?.persist && mapId) {
|
if (opts?.persist && mapId) {
|
||||||
try {
|
try {
|
||||||
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
|
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
|
||||||
|
|
@ -1246,7 +1312,7 @@ export function TopologyPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t, markDirty, clearDirty],
|
[nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t, markDirty, clearDirty, scheduleFitView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyAlign = useCallback(
|
const applyAlign = useCallback(
|
||||||
|
|
@ -1501,7 +1567,8 @@ export function TopologyPage() {
|
||||||
setEdges(rfEdges);
|
setEdges(rfEdges);
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
if (didAutoLayout) {
|
if (didAutoLayout) {
|
||||||
window.setTimeout(() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS }), 50);
|
needsInitialFitRef.current = true;
|
||||||
|
scheduleFitView(FIT_VIEW_OPTS);
|
||||||
}
|
}
|
||||||
const out: TopologyDiscoverOut = {
|
const out: TopologyDiscoverOut = {
|
||||||
map_id: mapId,
|
map_id: mapId,
|
||||||
|
|
@ -1814,12 +1881,13 @@ export function TopologyPage() {
|
||||||
const active = Boolean(el && document.fullscreenElement === el);
|
const active = Boolean(el && document.fullscreenElement === el);
|
||||||
setFullscreen(active);
|
setFullscreen(active);
|
||||||
if (active) {
|
if (active) {
|
||||||
window.setTimeout(() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS_FS }), 80);
|
needsInitialFitRef.current = true;
|
||||||
|
scheduleFitView(FIT_VIEW_OPTS_FS);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("fullscreenchange", syncFs);
|
document.addEventListener("fullscreenchange", syncFs);
|
||||||
return () => document.removeEventListener("fullscreenchange", syncFs);
|
return () => document.removeEventListener("fullscreenchange", syncFs);
|
||||||
}, []);
|
}, [scheduleFitView]);
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(async () => {
|
const toggleFullscreen = useCallback(async () => {
|
||||||
const el = canvasRef.current;
|
const el = canvasRef.current;
|
||||||
|
|
@ -3671,12 +3739,11 @@ export function TopologyPage() {
|
||||||
onMoveStart={closeCtxMenu}
|
onMoveStart={closeCtxMenu}
|
||||||
onInit={(inst) => {
|
onInit={(inst) => {
|
||||||
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
|
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
|
||||||
if (nodesRef.current.length > 0) {
|
if (needsInitialFitRef.current || nodesRef.current.length > 0) {
|
||||||
window.setTimeout(() => inst.fitView({ ...FIT_VIEW_OPTS }), 0);
|
scheduleFitView(FIT_VIEW_OPTS);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
fitView
|
fitView={false}
|
||||||
fitViewOptions={{ ...FIT_VIEW_OPTS }}
|
|
||||||
deleteKeyCode={null}
|
deleteKeyCode={null}
|
||||||
edgesFocusable
|
edgesFocusable
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue