From 87dfa9345b97dc32b5d028fe69219e1110ee4cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 30 May 2026 16:27:22 -0400 Subject: [PATCH] Add zoom controls and Ctrl/Cmd-wheel zoom to the org chart --- .../renderer/src/pages/team/OrgChartTree.tsx | 103 +++++++++++++----- AgentCompany-New/src/renderer/src/styles.css | 42 ++++++- 2 files changed, 119 insertions(+), 26 deletions(-) diff --git a/AgentCompany-New/src/renderer/src/pages/team/OrgChartTree.tsx b/AgentCompany-New/src/renderer/src/pages/team/OrgChartTree.tsx index d72a58d1..1fc81968 100644 --- a/AgentCompany-New/src/renderer/src/pages/team/OrgChartTree.tsx +++ b/AgentCompany-New/src/renderer/src/pages/team/OrgChartTree.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Avatar } from "../../shared/ui"; import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain"; @@ -7,6 +7,9 @@ const NODE_H = 64; const H_GAP = 24; const V_GAP = 44; const LEVEL_H = NODE_H + V_GAP; +const ZOOM_MIN = 0.4; +const ZOOM_MAX = 1.6; +const ZOOM_STEP = 0.15; interface LayoutNode { id: string; @@ -44,12 +47,53 @@ export function OrgChartTree({ }) { const layout = useMemo(() => buildLayout(boardChair, agents), [boardChair, agents]); const scrollRef = useRef(null); + const scaleRef = useRef(1); + const pendingCenter = useRef<{ cx: number; cy: number } | null>(null); + const [scale, setScale] = useState(1); const rootX = layout?.root.x ?? 0; + // Zoom while keeping the current viewport center fixed. + const zoomTo = useCallback((next: number) => { + const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(next * 100) / 100)); + const el = scrollRef.current; + if (el) { + pendingCenter.current = { + cx: (el.scrollLeft + el.clientWidth / 2) / scaleRef.current, + cy: (el.scrollTop + el.clientHeight / 2) / scaleRef.current, + }; + } + scaleRef.current = clamped; + setScale(clamped); + }, []); + + // Ctrl/Cmd + wheel zooms (non-passive so we can prevent the browser zoom). + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onWheel = (event: WheelEvent) => { + if (!(event.ctrlKey || event.metaKey)) return; + event.preventDefault(); + zoomTo(scaleRef.current - Math.sign(event.deltaY) * ZOOM_STEP); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [zoomTo]); + + // After a zoom re-render, restore the captured center point. + useEffect(() => { + const el = scrollRef.current; + const center = pendingCenter.current; + if (el && center) { + el.scrollLeft = center.cx * scale - el.clientWidth / 2; + el.scrollTop = center.cy * scale - el.clientHeight / 2; + pendingCenter.current = null; + } + }, [scale]); + // Open the chart looking at the top of the company, not a wall of whitespace. useEffect(() => { const el = scrollRef.current; - if (el) el.scrollLeft = Math.max(0, rootX - el.clientWidth / 2); + if (el) el.scrollLeft = Math.max(0, rootX * scaleRef.current - el.clientWidth / 2); }, [rootX]); if (!layout) return null; @@ -58,29 +102,38 @@ export function OrgChartTree({ const edges = collectEdges(root); return ( -
-
- - {nodes.map((node) => ( - - ))} +
+
+ + + +
+
+
+
+ + {nodes.map((node) => ( + + ))} +
+
); diff --git a/AgentCompany-New/src/renderer/src/styles.css b/AgentCompany-New/src/renderer/src/styles.css index 21b6d928..7cc778d0 100644 --- a/AgentCompany-New/src/renderer/src/styles.css +++ b/AgentCompany-New/src/renderer/src/styles.css @@ -388,7 +388,47 @@ a { color: inherit; } /* Hierarchical org chart: nodes are absolutely positioned by a layout pass, connectors are drawn in an SVG underlay (see OrgChartTree). */ -.org-scroll { overflow-x: auto; overflow-y: hidden; } +.org-chart { position: relative; } +.org-zoom { + position: absolute; + top: -6px; + right: 0; + z-index: 3; + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--panel); + box-shadow: var(--shadow-sm); +} +.org-zoom-btn { + width: 26px; + height: 24px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-2); + font-size: 15px; + line-height: 1; +} +.org-zoom-btn:hover:not(:disabled) { background: var(--hover); color: var(--text-1); } +.org-zoom-btn:disabled { color: var(--text-4); cursor: default; } +.org-zoom-label { + min-width: 46px; + height: 24px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.org-zoom-label:hover { background: var(--hover); color: var(--text-1); } +.org-scroll { overflow: auto; max-height: 62vh; } +.org-sizer { position: relative; margin: 20px auto 4px; } +.org-canvas { position: absolute; top: 0; left: 0; } .org-node-card { position: absolute; display: flex;