Keep node positions after discover and close display menu on outside click.

Default auto-layout-after-discover to off (persisted), skip fitView unless layout ran, and dismiss the display details panel on outside click or Esc.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 14:11:04 +08:00
parent 6ea0a09274
commit 5079343b2a
2 changed files with 44 additions and 7 deletions

View file

@ -122,7 +122,7 @@ src/
- 渲染:`@xyflow/react`;布局:`dagre`(层次)+ 内置力导向/网格/环形 - 渲染:`@xyflow/react`;布局:`dagre`(层次)+ 内置力导向/网格/环形
- 工具模式:选择(框选多选)/ 平移 / 拖动 / 连线;快捷键 `V` `H` `A` `C` - 工具模式:选择(框选多选)/ 平移 / 拖动 / 连线;快捷键 `V` `H` `A` `C`
- 侧栏网元可点击或拖放到画布;发现链路后可选自动层次布局并写回坐标 - 侧栏网元可点击或拖放到画布;发现默认保留节点坐标(「显示 → 发现后自动布局」可选开启并本机记住)
- 支持对齐、网格吸附、轻量撤销/重做、链路图例 - 支持对齐、网格吸附、轻量撤销/重做、链路图例
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线) - 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名 - 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名

View file

@ -289,6 +289,25 @@ type EdgeDefaultStyle = {
type EdgeDefaults = Record<EdgeSourceKind, EdgeDefaultStyle>; type EdgeDefaults = Record<EdgeSourceKind, EdgeDefaultStyle>;
const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults"; const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
function loadAutoLayoutAfterDiscover(): boolean {
try {
const raw = localStorage.getItem(AUTO_LAYOUT_DISCOVER_KEY);
if (raw === null) return false;
return raw === "1" || raw === "true";
} catch {
return false;
}
}
function persistAutoLayoutAfterDiscover(value: boolean) {
try {
localStorage.setItem(AUTO_LAYOUT_DISCOVER_KEY, value ? "1" : "0");
} catch {
/* ignore */
}
}
const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = { const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" }, manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
@ -468,7 +487,7 @@ export function TopologyPage() {
const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults()); const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
const [toolMode, setToolMode] = useState<ToolMode>("select"); const [toolMode, setToolMode] = useState<ToolMode>("select");
const [snapToGrid, setSnapToGrid] = useState(true); const [snapToGrid, setSnapToGrid] = useState(true);
const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(true); const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [hideAddedNes, setHideAddedNes] = useState(true); const [hideAddedNes, setHideAddedNes] = useState(true);
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed"); const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
@ -498,6 +517,7 @@ export function TopologyPage() {
const discoverAbortRef = useRef<AbortController | null>(null); const discoverAbortRef = useRef<AbortController | null>(null);
const searchHitTimerRef = useRef<number | null>(null); const searchHitTimerRef = useRef<number | null>(null);
const findBoxRef = useRef<HTMLDivElement | null>(null); const findBoxRef = useRef<HTMLDivElement | null>(null);
const displayMenuRef = useRef<HTMLDetailsElement | null>(null);
const findJustLocatedRef = useRef(false); const findJustLocatedRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]); const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@ -867,8 +887,10 @@ export function TopologyPage() {
if (out.graph) { if (out.graph) {
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph); queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
let { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges, edgeDefaults); let { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges, edgeDefaults);
let didAutoLayout = false;
if (autoLayoutAfterDiscover && rfNodes.length > 1) { if (autoLayoutAfterDiscover && rfNodes.length > 1) {
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb"); rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
didAutoLayout = true;
try { try {
const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges)); const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges));
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
@ -883,7 +905,10 @@ export function TopologyPage() {
setNodes(rfNodes); setNodes(rfNodes);
setEdges(rfEdges); setEdges(rfEdges);
historyLockRef.current = false; historyLockRef.current = false;
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50); // Only refit when layout changed; otherwise keep the user's viewport.
if (didAutoLayout) {
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
}
} }
setDiscoverReport(out); setDiscoverReport(out);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
@ -1251,6 +1276,8 @@ export function TopologyPage() {
clearSelection(); clearSelection();
connectClickRef.current = null; connectClickRef.current = null;
setToolMode((m) => (m === "connect" ? "select" : m)); setToolMode((m) => (m === "connect" ? "select" : m));
if (displayMenuRef.current?.open) displayMenuRef.current.open = false;
setFindOpen(false);
return; return;
} }
const mode = toolModeFromKey(e.key); const mode = toolModeFromKey(e.key);
@ -1507,8 +1534,14 @@ export function TopologyPage() {
useEffect(() => { useEffect(() => {
const onDoc = (e: MouseEvent) => { const onDoc = (e: MouseEvent) => {
if (!findBoxRef.current) return; const target = e.target as HTMLElement | null;
if (!findBoxRef.current.contains(e.target as HTMLElement)) setFindOpen(false); if (findBoxRef.current && target && !findBoxRef.current.contains(target)) {
setFindOpen(false);
}
const details = displayMenuRef.current;
if (details?.open && target && !details.contains(target)) {
details.open = false;
}
}; };
document.addEventListener("mousedown", onDoc); document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc);
@ -1915,7 +1948,7 @@ export function TopologyPage() {
</button> </button>
))} ))}
</div> </div>
<details className="topo-toolbar__display"> <details className="topo-toolbar__display" ref={displayMenuRef}>
<summary>{t("topology.display")}</summary> <summary>{t("topology.display")}</summary>
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}> <div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
<label className="topo-display-toggles__item"> <label className="topo-display-toggles__item">
@ -1958,7 +1991,11 @@ export function TopologyPage() {
<input <input
type="checkbox" type="checkbox"
checked={autoLayoutAfterDiscover} checked={autoLayoutAfterDiscover}
onChange={(e) => setAutoLayoutAfterDiscover(e.target.checked)} onChange={(e) => {
const next = e.target.checked;
setAutoLayoutAfterDiscover(next);
persistAutoLayoutAfterDiscover(next);
}}
/> />
{t("topology.autoLayoutDiscover")} {t("topology.autoLayoutDiscover")}
</label> </label>