dsh-mindmap 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  All notable changes to this project are documented here. Release-specific notes are also published on GitHub Releases.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.4.0] - 2026-08-24
6
6
 
7
7
  ### Changed
8
8
 
@@ -12,6 +12,7 @@ All notable changes to this project are documented here. Release-specific notes
12
12
 
13
13
  ### Added
14
14
 
15
+ - Centered mindmap canvas with a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit): the tree opens centered in the panel (scrollable, no edge clipping when larger), auto fit-to-view on open (no enlargement beyond 100%, 25% floor for giant trees), 25%–300% step zoom with a stable viewport-center anchor, and smart re-fit on AI edits or panel resize until the user zooms manually. Connector lines use `vector-effect: non-scaling-stroke` so they stay crisp at any zoom level, and natural-size debouncing keeps "fit" from oscillating with scrollbar appearance. Clicking any node focuses it: the node scrolls to the left-center of the canvas and the view zooms until the node and its whole subtree are visible (capped at 100%). Zoom math is exposed as pure functions (`clampZoom` / `stepZoom` / `fitZoom` / `focusZoom`) with unit tests.
15
16
  - Settings page section「思维脑图」in the left nav (`settings.section`), backed by a host settings namespace (`mindmap`): node theme (line style curve/elbow, card corners rounded/square, three color themes ocean/sunset/forest), default panel width (20-80%). The `requireApproval` switch stays functional (read at tool pre-execute time) but is hidden from the UI by design. Introduces a `@deepseek-ai/schemastery` dependency for the settings schema.
16
17
 
17
18
  ## [0.1.0] - 2026-08-23
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  A DeepSeek Harness plugin that turns a plain Markdown file into a live mindmap. The working directory is the document: the chat is the editor, the AI edits the `.md` step by step, and the right-side floating panel re-renders the mindmap in real time.
6
6
 
7
- > Project status: pre-1.0. The 0.1.0 feature set (see [CHANGELOG](./CHANGELOG.md)) is implemented and covered by unit tests, but cross-version compatibility beyond the development environment is not yet certified.
7
+ > Project status: pre-1.0. The current feature set (see [CHANGELOG](./CHANGELOG.md)) is implemented and covered by unit tests, but cross-version compatibility beyond the development environment is not yet certified.
8
8
 
9
9
  ## The core idea
10
10
 
@@ -22,6 +22,7 @@ A DeepSeek Harness plugin that turns a plain Markdown file into a live mindmap.
22
22
  - **Single-mindmap mode** — two tabs only: 目录 (tree) and 脑图 (the current mindmap); opening another `.md` replaces the previous one.
23
23
  - **"What you see is what the AI edits"** — when the visible mindmap differs from the AI's working document, the panel automatically asks the AI to open it, keeping the chat focus in sync.
24
24
  - **MarkGrove-style mapping** — heading hierarchy, nested lists (empty items become placeholder nodes), code blocks as leaf nodes, paragraphs as node notes, stable structural IDs, and orthogonal connector lines between nodes.
25
+ - **Centered canvas with zoom** — the mindmap opens centered in the canvas (scrollable without edge clipping when larger); a floating zoom bar at the canvas top-right (zoom out / percent / zoom in / fit) applies auto fit-to-view on open (small maps stay at 100%), steps through 25%–300% with a stable view center, and keeps re-fitting as the AI edits — until you zoom manually. Click any node to zoom in on it and its whole subtree, with the node pinned at the left-center of the canvas.
25
26
  - **PNG export** — one click on 导出图片 exports the current mindmap.
26
27
  - **Safety** — `mindmap_update` is approval-free by default (files are git-managed) with a `requireApproval` switch as an escape hatch; the client has **no write path** to the filesystem — every edit goes through the AI tools.
27
28
 
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  一个 DeepSeek Harness 插件:把工作目录里的普通 Markdown 文件变成一颗**实时脑图**。聊天框是编辑它的助手——你聊一句、AI 改一步 `.md`、右侧悬浮面板实时跟着变。
6
6
 
7
- > 项目状态:pre-1.0。0.1.0 功能集(见 [CHANGELOG](./CHANGELOG.md))已实现并有单元测试覆盖,但开发环境之外的跨版本兼容性尚未认证。
7
+ > 项目状态:pre-1.0。当前功能集(见 [CHANGELOG](./CHANGELOG.md))已实现并有单元测试覆盖,但开发环境之外的跨版本兼容性尚未认证。
8
8
 
9
9
  ## 核心思想
10
10
 
@@ -22,6 +22,7 @@
22
22
  - **单脑图模式**——面板只有「目录」和「脑图」两个 tab,打开新脑图替换旧的那颗。
23
23
  - **「所见即所编」焦点同步**——可见脑图与 AI 工作文档不一致时,面板自动让 AI 打开它,聊天焦点始终跟随你的眼睛。
24
24
  - **MarkGrove 同款映射与连线**——标题层级挂树、列表缩进(空项 = 占位节点)、代码块叶节点、段落挂节点说明、稳定结构 ID、节点间直角折线。
25
+ - **画布居中与缩放**——脑图打开后居中呈现(超出画布时可滚动、无边缘裁剪);画布右上角浮动缩放条(缩小 / 比例 / 放大 / 适配),打开时自动适配合适比例(小图保持 100%),25%–300% 逐级缩放且视图中心不跳变,AI 编辑后持续自动再适配,直到你手动缩放。点击任意节点即可聚焦:节点滚到画布左侧居中,视图放大到它和整棵子树完整可见(上限 100%)。
25
26
  - **PNG 导出**——面板右上角「导出图片」一键导出当前脑图。
26
27
  - **安全**——`mindmap_update` 默认免原生审批(文件受 git 管控),留 `requireApproval` 开关作后悔药;客户端**没有任何写文件通道**,一切编辑都经 AI 工具。
27
28
 
package/client.js CHANGED
@@ -637,12 +637,30 @@ window.__ModuleLoader__.load({
637
637
  childrenColumn: { display: "flex", flexDirection: "column", gap: "8px", marginLeft: "40px", minWidth: 0 },
638
638
  // 面板树连线层:正交折线(MarkGrove 的 orthogonalPath 风格),
639
639
  // 覆盖整行、点击穿透、置于节点盒之下。
640
- edgeLayer: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" },
640
+ // 016:去掉 CSS width/height 百分比——在 auto-height flex 行内,
641
+ // 百分比高度解析为 auto 会让 SVG 坐标系塌缩,连线与节点像素错位。
642
+ // 改为由 TreeRow 在 measure 里同步记下实际像素,作 SVG 属性直传。
643
+ edgeLayer: { position: "absolute", top: 0, left: 0, display: "block", pointerEvents: "none", overflow: "visible" },
641
644
  box: { padding: "6px 12px", borderRadius: "10px", border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", maxWidth: "240px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: "0 0 auto", boxShadow: "0 1px 2px rgba(16,24,40,0.04)" },
642
645
  rootBox: { fontWeight: 700, fontSize: "14px", border: "1px solid var(--dsw-alias-border-l2-darkmode-thin, #b9c0cc)", background: "var(--dsw-alias-bg-module-platform, #eef2ff)" },
643
646
  headingBox: { fontWeight: 600 },
644
647
  placeholderBox: { padding: "6px 12px", borderRadius: "10px", border: "1px dashed var(--dsw-alias-border-l2)", color: "var(--dsw-alias-label-tertiary)", background: "none" },
645
648
  codeBox: { fontFamily: "Menlo, monospace", fontSize: "12px" },
649
+ // 016 脑图画布:滚动区 + 居中层 + 右上角浮动缩放控制条。
650
+ canvasWrap: { flex: "1 1 auto", minHeight: 0, minWidth: 0, position: "relative", display: "flex", flexDirection: "column" },
651
+ canvasScroll: { flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "auto" },
652
+ // 居中层:内容小则铺满视口(100%),大则撑到内容尺寸(max-content);
653
+ // 子项用 margin:auto——空间充足双向居中,溢出时 margin 归零、从滚动
654
+ // 原点排布(flexbox 溢出居中裁剪的标准解法,无左/上侧裁剪)。
655
+ canvasCenter: { display: "flex", width: "100%", height: "100%", minWidth: "max-content", minHeight: "max-content", boxSizing: "border-box", padding: "16px" },
656
+ // 缩放控制条:绝对定位于 canvasWrap(滚动区外,不随内容滚动)。
657
+ zoomBar: { position: "absolute", top: "10px", right: "12px", zIndex: 5, display: "inline-flex", alignItems: "center", gap: "2px", padding: "3px", borderRadius: "8px", background: "var(--dsw-alias-bg-layer-3)", border: "1px solid var(--dsw-alias-border-l2)", boxShadow: "var(--dsw-shadow-lv2)" },
658
+ zoomBtn: { border: "none", background: "none", cursor: "pointer", font: "inherit", fontSize: "13px", lineHeight: "20px", height: "22px", minWidth: "22px", padding: "0 4px", borderRadius: "6px", color: "var(--dsw-alias-label-secondary)", display: "inline-flex", alignItems: "center", justifyContent: "center", flex: "none" },
659
+ zoomBtnHover: { background: "var(--dsw-alias-interactive-bg-hover)", color: "var(--dsw-alias-label-primary)" },
660
+ zoomBtnDisabled: { opacity: 0.4, cursor: "default" },
661
+ // 百分比标签:tabular-nums 防数字抖动。
662
+ zoomLabel: { flex: "none", minWidth: "38px", textAlign: "center", fontSize: "11px", lineHeight: "20px", color: "var(--dsw-alias-label-secondary)", fontVariantNumeric: "tabular-nums", userSelect: "none" },
663
+ zoomFitBtn: { border: "none", background: "none", cursor: "pointer", font: "inherit", fontSize: "12px", lineHeight: "20px", height: "22px", padding: "0 8px", borderRadius: "6px", color: "var(--dsw-alias-label-secondary)", flex: "none" },
646
664
  };
647
665
 
648
666
  /** 015 分段选择控件(线型/卡片风格)。 */
@@ -873,8 +891,15 @@ window.__ModuleLoader__.load({
873
891
  const rowRef = react.useRef(null);
874
892
  const boxWrapRef = react.useRef(null);
875
893
  const childRefs = react.useRef([]);
876
- const [edges, setEdges] = react.useState([]);
877
- const prevEdgesRef = react.useRef("");
894
+ // 016:一次测量 = 连线 + 行本地尺寸,单键防抖。坐标全部换算到行
895
+ // 「本地空间」(视觉像素 ÷ 缩放因子,由隐形探针实测),SVG 用
896
+ // viewBox 把用户空间钉在本地空间——CSS zoom 新旧实现都精确对齐,
897
+ // 且 SVG 盒子恒等于行的视觉尺寸(绝不撑出滚动区)。
898
+ const [layout, setLayout] = react.useState({ edges: [], w: 0, h: 0 });
899
+ const prevKeyRef = react.useRef("");
900
+ // 缩放探针:本地 10×10(小于最小节点盒,永不溢出行),visibility
901
+ // 隐藏。getBoundingClientRect 返回 10×zoom → 实测缩放因子。
902
+ const probeRef = react.useRef(null);
878
903
 
879
904
  // 测量父盒右缘与各子节点包裹块的几何位置,画连线
880
905
  // (折线 = M x1 y1 H midX V y2 H x2;曲线 = 贝塞尔水平切出);
@@ -882,28 +907,35 @@ window.__ModuleLoader__.load({
882
907
  react.useLayoutEffect(() => {
883
908
  const rowEl = rowRef.current;
884
909
  const boxEl = boxWrapRef.current;
885
- if (!rowEl || !boxEl) return;
910
+ const probeEl = probeRef.current;
911
+ if (!rowEl || !boxEl || !probeEl) return;
886
912
  const curve = theme && theme.lineStyle === "curve";
887
913
  const measure = () => {
914
+ // 探针实测缩放因子(视觉/本地)——与 DOM 当前状态同步,
915
+ // 对 CSS zoom 的新旧实现(渲染期缩放 / used 值缩放)都成立。
916
+ const scale = probeEl.getBoundingClientRect().width / 10 || 1;
888
917
  const rowRect = rowEl.getBoundingClientRect();
889
918
  const boxRect = boxEl.getBoundingClientRect();
890
919
  const next = [];
891
920
  for (const ref of childRefs.current) {
892
921
  if (!ref) continue;
893
922
  const c = ref.getBoundingClientRect();
894
- const x1 = boxRect.right - rowRect.left;
895
- const y1 = boxRect.top - rowRect.top + boxRect.height / 2;
896
- const x2 = c.left - rowRect.left;
897
- const y2 = c.top - rowRect.top + c.height / 2;
923
+ // 视觉像素 行本地坐标(SVG 用户空间 = 本地空间)。
924
+ const x1 = (boxRect.right - rowRect.left) / scale;
925
+ const y1 = (boxRect.top - rowRect.top + boxRect.height / 2) / scale;
926
+ const x2 = (c.left - rowRect.left) / scale;
927
+ const y2 = (c.top - rowRect.top + c.height / 2) / scale;
898
928
  const midX = (x1 + x2) / 2;
899
929
  next.push(curve
900
930
  ? `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`
901
931
  : `M ${x1} ${y1} H ${midX} V ${y2} H ${x2}`);
902
932
  }
903
- const key = next.join("|");
904
- if (prevEdgesRef.current === key) return;
905
- prevEdgesRef.current = key;
906
- setEdges(next);
933
+ const w = rowRect.width / scale;
934
+ const h = rowRect.height / scale;
935
+ const key = `${w.toFixed(2)}x${h.toFixed(2)}|${next.join("|")}`;
936
+ if (prevKeyRef.current === key) return;
937
+ prevKeyRef.current = key;
938
+ setLayout({ edges: next, w, h });
907
939
  };
908
940
  measure();
909
941
  let observer = null;
@@ -919,20 +951,34 @@ window.__ModuleLoader__.load({
919
951
  };
920
952
  });
921
953
 
922
- return (0, react_jsx_runtime.jsxs)("div", { ref: rowRef, style: { ...S.row, position: "relative" }, children: [
923
- edges.length > 0
954
+ // 016 点击聚焦标记:row = 该节点的整棵子树边界(盒+子列),node = 节点盒本身;
955
+ // 画布层用事件委托 closest 定位(递归树不逐层传回调)。
956
+ return (0, react_jsx_runtime.jsxs)("div", { ref: rowRef, "data-mindmap-row": "", style: { ...S.row, position: "relative" }, children: [
957
+ // 缩放探针(本地 10×10,隐形,点击穿透)。
958
+ (0, react_jsx_runtime.jsx)("div", { ref: probeRef, style: { position: "absolute", top: 0, left: 0, width: 10, height: 10, visibility: "hidden", pointerEvents: "none" } }),
959
+ layout.edges.length > 0 && layout.w > 0 && layout.h > 0
924
960
  ? (0, react_jsx_runtime.jsx)("svg", {
961
+ // width/height = 本地尺寸 → 视觉 = 本地×zoom,恒等于行尺寸;
962
+ // viewBox 把用户空间钉在本地空间,路径坐标(本地)精确落位。
963
+ width: layout.w,
964
+ height: layout.h,
965
+ viewBox: `0 0 ${layout.w} ${layout.h}`,
966
+ preserveAspectRatio: "none",
925
967
  style: S.edgeLayer,
926
- children: edges.map((d, i) => (0, react_jsx_runtime.jsx)("path", {
968
+ children: layout.edges.map((d, i) => (0, react_jsx_runtime.jsx)("path", {
927
969
  key: i,
928
970
  d,
929
971
  stroke: "var(--dsw-alias-border-l2)",
930
972
  strokeWidth: 1.5,
931
973
  fill: "none",
974
+ // 016:CSS zoom 缩放下 strokeWidth 会被一并缩放,缩小后线变
975
+ // 亚像素、模糊看不清;vectorEffect=non-scaling-stroke 让线宽
976
+ // 保持不变(任何缩放下都是 1.5px 视觉宽度)。
977
+ vectorEffect: "non-scaling-stroke",
932
978
  }, i)),
933
979
  })
934
980
  : null,
935
- (0, react_jsx_runtime.jsx)("div", { ref: boxWrapRef, style: { flex: "0 0 auto" }, children: (0, react_jsx_runtime.jsx)(NodeBox, { node, theme }) }),
981
+ (0, react_jsx_runtime.jsx)("div", { ref: boxWrapRef, "data-mindmap-node": "", style: { flex: "0 0 auto", cursor: "pointer" }, children: (0, react_jsx_runtime.jsx)(NodeBox, { node, theme }) }),
936
982
  node.children && node.children.length > 0
937
983
  ? (0, react_jsx_runtime.jsx)("div", { style: S.childrenColumn, children: node.children.map((child, idx) => (0, react_jsx_runtime.jsx)("div", {
938
984
  key: child.id,
@@ -942,8 +988,281 @@ window.__ModuleLoader__.load({
942
988
  children: (0, react_jsx_runtime.jsx)(TreeRow, { node: child, theme }),
943
989
  }, child.id)) })
944
990
  : null,
945
- ] });
946
- }
991
+ ] });
992
+ }
993
+
994
+ //#region 016 脑图画布:居中呈现 + 缩放控制(右上角)
995
+ // 缩放契约:范围 [0.25, 3],每级 ×1.2;适配计算四周留 48px 余量
996
+ //(16px 视觉内距 + 经典滚动条占位,避免「适配→滚动条出现→视口变
997
+ // 窄→再适配」的抖动循环)。
998
+ const ZOOM = { min: 0.25, max: 3, step: 1.2, padding: 48, focusMax: 1 };
999
+
1000
+ /** 缩放夹取:非有限值/≤0 回退 1,否则夹到 [min, max]。 */
1001
+ function clampZoom(value) {
1002
+ if (!Number.isFinite(value) || value <= 0) return 1;
1003
+ return Math.min(ZOOM.max, Math.max(ZOOM.min, value));
1004
+ }
1005
+
1006
+ /** 步进缩放:direction>0 放大(×step),否则缩小(÷step),结果夹取。 */
1007
+ function stepZoom(value, direction) {
1008
+ const base = clampZoom(value);
1009
+ return clampZoom(direction > 0 ? base * ZOOM.step : base / ZOOM.step);
1010
+ }
1011
+
1012
+ /** 适配比例:min((view-padding)/tree, 1) 再夹取——小图不放大、巨图夹下限;零/非法尺寸返回 1。 */
1013
+ function fitZoom(treeW, treeH, viewW, viewH) {
1014
+ if (!(treeW > 0) || !(treeH > 0) || !(viewW > 0) || !(viewH > 0)) return 1;
1015
+ return clampZoom(Math.min((viewW - ZOOM.padding) / treeW, (viewH - ZOOM.padding) / treeH, 1));
1016
+ }
1017
+
1018
+ /**
1019
+ * 子树聚焦比例:适配整棵子树(区别于全局适配,允许放大到 focusMax),
1020
+ * 叶子/小子树不会怼脸、巨子树夹下限;零/非法尺寸返回 1。
1021
+ */
1022
+ function focusZoom(treeW, treeH, viewW, viewH) {
1023
+ if (!(treeW > 0) || !(treeH > 0) || !(viewW > 0) || !(viewH > 0)) return 1;
1024
+ return clampZoom(Math.min((viewW - ZOOM.padding) / treeW, (viewH - ZOOM.padding) / treeH, ZOOM.focusMax));
1025
+ }
1026
+
1027
+ /**
1028
+ * 脑图画布:滚动区(canvasScroll)+ 居中层(canvasCenter,100%/max-content
1029
+ * 双下限)+ 缩放内容(margin:auto + CSS zoom)。zoom 用 CSS zoom 而非
1030
+ * transform:scale——它影响布局,滚动范围随缩放自动正确;TreeRow 连线是
1031
+ * getBoundingClientRect 相对测量,父子同因子缩放,几何保持一致。
1032
+ * 打开(fitKey=文档路径)时自动测量并应用适配比例;用户未手动缩放前
1033
+ * ResizeObserver 持续再适配(AI 编辑改树尺寸、面板拖宽);手动缩放后以
1034
+ * 用户为准,点「适配」或切换文档恢复自动。缩放时记录视口中心并按比例
1035
+ * 修正 scroll,视图不跳变(内容回到视口内时浏览器会自动钳制回 0)。
1036
+ */
1037
+ function MindmapCanvas(props) {
1038
+ const { node, theme, fitKey } = props;
1039
+ const scrollRef = react.useRef(null);
1040
+ const contentRef = react.useRef(null);
1041
+ const zoomRef = react.useRef(1);
1042
+ const userZoomedRef = react.useRef(false);
1043
+ const anchorRef = react.useRef(null);
1044
+ // 016:上次适配时的脑图自然尺寸(getBoundingClientRect / 当前 zoom)。
1045
+ // ResizeObserver 回调里对比当前自然尺寸,差异 ≤ 2px 视为「滚动条抖动」
1046
+ // 引发的同树再测,跳过避免「适配→横向滚动条出现→视口变窄→再适配」
1047
+ // 的 33%/34%/30% 不停跳变。
1048
+ const lastNaturalRef = react.useRef(null);
1049
+ // 016 点击聚焦:记录待定位的节点盒元素。zoom 生效后的 [zoom]
1050
+ // layout effect 里量新布局,把节点滚到「水平 25% / 垂直居中」。
1051
+ const focusRef = react.useRef(null);
1052
+ // 016 已提交 zoom:DOM 真正渲染到的缩放值([zoom] layout effect
1053
+ // 里同步)。zoomRef 可能领先于渲染提交(setState 异步),测量
1054
+ // 一律除以 committed——否则「新 zoom ÷ 旧尺寸」得到错误自然
1055
+ // 尺寸,适配值来回跳、停不下来(跳闪根源)。
1056
+ const committedZoomRef = react.useRef(1);
1057
+ // 016 熔断器:观察器触发的适配时间戳。1.5s 内第 5 次 → 判定
1058
+ // 反馈循环,自动停手(保险丝,任何未知循环都最多闪几下)。
1059
+ const fitStampRef = react.useRef([]);
1060
+ const [zoom, setZoomState] = react.useState(1);
1061
+ const [hover, setHover] = react.useState(null);
1062
+
1063
+ // 测量并适配:自然尺寸 = getBoundingClientRect ÷ 已提交 zoom(与
1064
+ // DOM 实际状态严格同步,无竞态)。值不变不动 state(bail-out),
1065
+ // 值变化才重置滚动到原点让树回到居中;同步记录自然尺寸供防抖。
1066
+ function applyFit() {
1067
+ const scroller = scrollRef.current;
1068
+ const content = contentRef.current;
1069
+ if (!scroller || !content) return;
1070
+ const rect = content.getBoundingClientRect();
1071
+ if (!(rect.width > 0) || !(rect.height > 0)) return;
1072
+ const committed = committedZoomRef.current;
1073
+ const naturalW = rect.width / committed;
1074
+ const naturalH = rect.height / committed;
1075
+ lastNaturalRef.current = { w: naturalW, h: naturalH };
1076
+ const fit = fitZoom(naturalW, naturalH, scroller.clientWidth, scroller.clientHeight);
1077
+ zoomRef.current = fit;
1078
+ if (fit !== committed) {
1079
+ scroller.scrollLeft = 0;
1080
+ scroller.scrollTop = 0;
1081
+ setZoomState(fit);
1082
+ }
1083
+ }
1084
+
1085
+ // 挂载 / 文档切换:清除「用户已手动缩放」标记与熔断计数,
1086
+ // 布局稳定后(rAF)适配一次。
1087
+ react.useLayoutEffect(() => {
1088
+ userZoomedRef.current = false;
1089
+ lastNaturalRef.current = null;
1090
+ fitStampRef.current = [];
1091
+ const id = requestAnimationFrame(applyFit);
1092
+ return () => cancelAnimationFrame(id);
1093
+ }, [fitKey]);
1094
+
1095
+ // 内容 / 画布尺寸变化(AI 编辑、面板拖宽)→ 未手动缩放则再适配。
1096
+ // 区分触发源:仅内容变化(多为 zoom 引发的重排)时对比自然尺寸
1097
+ // (÷ 已提交 zoom),差异 ≤ 2px 视为「滚动条抖动/亚像素重测」跳过;
1098
+ // 画布变化(拖宽、滚动条出现)正常再适配。熔断器兜底:1.5s 内
1099
+ // 第 5 次观察器适配 → 判定循环,自动停手(点适配/切文档可恢复)。
1100
+ react.useEffect(() => {
1101
+ const scroller = scrollRef.current;
1102
+ const content = contentRef.current;
1103
+ if (!scroller || !content || typeof ResizeObserver === "undefined") return;
1104
+ const observer = new ResizeObserver((entries) => {
1105
+ if (userZoomedRef.current) return;
1106
+ const scrollerChanged = entries.some((e) => e.target === scroller);
1107
+ if (!scrollerChanged) {
1108
+ const last = lastNaturalRef.current;
1109
+ if (last) {
1110
+ const rect = content.getBoundingClientRect();
1111
+ const w = rect.width / committedZoomRef.current;
1112
+ const h = rect.height / committedZoomRef.current;
1113
+ if (Math.abs(w - last.w) <= 2 && Math.abs(h - last.h) <= 2) return;
1114
+ }
1115
+ }
1116
+ const now = Date.now();
1117
+ const stamps = fitStampRef.current = fitStampRef.current.filter((t) => now - t < 1500);
1118
+ if (stamps.length >= 5) {
1119
+ userZoomedRef.current = true;
1120
+ return;
1121
+ }
1122
+ stamps.push(now);
1123
+ applyFit();
1124
+ });
1125
+ observer.observe(content);
1126
+ observer.observe(scroller);
1127
+ return () => observer.disconnect();
1128
+ }, []);
1129
+
1130
+ function setZoom(next, anchorViewport) {
1131
+ const value = clampZoom(next);
1132
+ anchorRef.current = anchorViewport && scrollRef.current
1133
+ ? { prevZoom: zoomRef.current, scrollLeft: scrollRef.current.scrollLeft, scrollTop: scrollRef.current.scrollTop }
1134
+ : null;
1135
+ zoomRef.current = value;
1136
+ setZoomState(value);
1137
+ }
1138
+
1139
+ // 用户缩放后保持视口中心稳定:内容坐标按 new/old 比例缩放,scroll 同步修正。
1140
+ // fit 路径 anchorRef 为 null,不锚定。
1141
+ react.useLayoutEffect(() => {
1142
+ // DOM 已提交到该 zoom,测量换算基准同步(applyFit 依赖)。
1143
+ committedZoomRef.current = zoom;
1144
+ if (focusRef.current) {
1145
+ positionFocus();
1146
+ return;
1147
+ }
1148
+ const scroller = scrollRef.current;
1149
+ const anchor = anchorRef.current;
1150
+ if (!scroller || !anchor) return;
1151
+ anchorRef.current = null;
1152
+ const ratio = anchor.prevZoom > 0 ? zoom / anchor.prevZoom : 1;
1153
+ if (!(ratio > 0) || ratio === 1) return;
1154
+ scroller.scrollLeft = (anchor.scrollLeft + scroller.clientWidth / 2) * ratio - scroller.clientWidth / 2;
1155
+ scroller.scrollTop = (anchor.scrollTop + scroller.clientHeight / 2) * ratio - scroller.clientHeight / 2;
1156
+ }, [zoom]);
1157
+
1158
+ function zoomIn() {
1159
+ userZoomedRef.current = true;
1160
+ setZoom(stepZoom(zoomRef.current, 1), true);
1161
+ }
1162
+
1163
+ function zoomOut() {
1164
+ userZoomedRef.current = true;
1165
+ setZoom(stepZoom(zoomRef.current, -1), true);
1166
+ }
1167
+
1168
+ function refit() {
1169
+ userZoomedRef.current = false;
1170
+ fitStampRef.current = [];
1171
+ applyFit();
1172
+ }
1173
+
1174
+ // 016 点击节点聚焦:节点滚到「垂直居中、水平约 25%」(树向右生长,
1175
+ // 左侧锚点让子级铺满右侧视野),缩放比例取 focusZoom(整棵子树适配、
1176
+ // 上限 focusMax)。事件委托:closest 找节点盒与所在子树 row,无需给
1177
+ // 递归 TreeRow 传回调。聚焦视为用户手动缩放(停自动再适配)。
1178
+ function onCanvasClick(e) {
1179
+ const target = e.target;
1180
+ if (!target || typeof target.closest !== "function") return;
1181
+ const boxEl = target.closest("[data-mindmap-node]");
1182
+ if (!boxEl || !boxEl.isConnected) return;
1183
+ const rowEl = boxEl.closest("[data-mindmap-row]");
1184
+ if (!rowEl) return;
1185
+ const scroller = scrollRef.current;
1186
+ if (!scroller) return;
1187
+ const current = zoomRef.current;
1188
+ const rowRect = rowEl.getBoundingClientRect();
1189
+ const focus = focusZoom(rowRect.width / current, rowRect.height / current, scroller.clientWidth, scroller.clientHeight);
1190
+ userZoomedRef.current = true;
1191
+ focusRef.current = { boxEl };
1192
+ if (focus !== current) {
1193
+ anchorRef.current = null;
1194
+ zoomRef.current = focus;
1195
+ setZoomState(focus);
1196
+ } else {
1197
+ positionFocus();
1198
+ }
1199
+ }
1200
+
1201
+ // 聚焦定位:DOM 更新后量节点盒当前位置,滚动增量 = 当前位置 − 期望位置
1202
+ //(scroll 增量与视口位移 1:1,浏览器自动钳制滚动范围;比例不变时同步调用)。
1203
+ function positionFocus() {
1204
+ const focus = focusRef.current;
1205
+ focusRef.current = null;
1206
+ const scroller = scrollRef.current;
1207
+ if (!scroller || !focus || !focus.boxEl || !focus.boxEl.isConnected) return;
1208
+ const boxRect = focus.boxEl.getBoundingClientRect();
1209
+ const scrollerRect = scroller.getBoundingClientRect();
1210
+ const curX = boxRect.left + boxRect.width / 2 - scrollerRect.left;
1211
+ const curY = boxRect.top + boxRect.height / 2 - scrollerRect.top;
1212
+ scroller.scrollLeft += curX - scroller.clientWidth * 0.25;
1213
+ scroller.scrollTop += curY - scroller.clientHeight / 2;
1214
+ }
1215
+
1216
+ // 边界反馈:到达上下限时对应按钮置灰(fit 值与边界精确相等时也命中)。
1217
+ const atMin = zoom <= ZOOM.min;
1218
+ const atMax = zoom >= ZOOM.max;
1219
+ const zoomBtnStyle = (key, disabled) => (disabled
1220
+ ? { ...S.zoomBtn, ...S.zoomBtnDisabled }
1221
+ : (hover === key ? { ...S.zoomBtn, ...S.zoomBtnHover } : S.zoomBtn));
1222
+
1223
+ return (0, react_jsx_runtime.jsxs)("div", { style: S.canvasWrap, children: [
1224
+ (0, react_jsx_runtime.jsx)("div", { ref: scrollRef, style: S.canvasScroll, onClick: onCanvasClick, children:
1225
+ (0, react_jsx_runtime.jsx)("div", { style: S.canvasCenter, children:
1226
+ (0, react_jsx_runtime.jsx)("div", { ref: contentRef, style: { margin: "auto", zoom }, children:
1227
+ (0, react_jsx_runtime.jsx)(TreeRow, { node, theme })
1228
+ })
1229
+ })
1230
+ }),
1231
+ (0, react_jsx_runtime.jsxs)("div", { style: S.zoomBar, children: [
1232
+ (0, react_jsx_runtime.jsx)("button", {
1233
+ type: "button",
1234
+ title: "缩小",
1235
+ style: zoomBtnStyle("out", atMin),
1236
+ disabled: atMin,
1237
+ onClick: zoomOut,
1238
+ onMouseEnter: () => setHover("out"),
1239
+ onMouseLeave: () => setHover((h) => (h === "out" ? null : h)),
1240
+ children: "−",
1241
+ }),
1242
+ (0, react_jsx_runtime.jsx)("span", { style: S.zoomLabel, children: `${Math.round(zoom * 100)}%` }),
1243
+ (0, react_jsx_runtime.jsx)("button", {
1244
+ type: "button",
1245
+ title: "放大",
1246
+ style: zoomBtnStyle("in", atMax),
1247
+ disabled: atMax,
1248
+ onClick: zoomIn,
1249
+ onMouseEnter: () => setHover("in"),
1250
+ onMouseLeave: () => setHover((h) => (h === "in" ? null : h)),
1251
+ children: "+",
1252
+ }),
1253
+ (0, react_jsx_runtime.jsx)("button", {
1254
+ type: "button",
1255
+ title: "适配画布(重新计算合适比例)",
1256
+ style: hover === "fit" ? { ...S.zoomFitBtn, ...S.zoomBtnHover } : S.zoomFitBtn,
1257
+ onClick: refit,
1258
+ onMouseEnter: () => setHover("fit"),
1259
+ onMouseLeave: () => setHover((h) => (h === "fit" ? null : h)),
1260
+ children: "适配",
1261
+ }),
1262
+ ] }),
1263
+ ] });
1264
+ }
1265
+ //#endregion
947
1266
 
948
1267
  function MindmapDetailsPanel(props) {
949
1268
  // 014:面板与 M 按钮同槽位(conversation.session.header.actions),
@@ -1606,13 +1925,15 @@ window.__ModuleLoader__.load({
1606
1925
  }, shown) : null,
1607
1926
  ] }),
1608
1927
  ] }),
1609
- (0, react_jsx_runtime.jsx)("div", { style: S.body, children: active === TREE_TAB
1610
- ? renderTree()
1611
- : (doc && doc.op === "local")
1612
- ? renderLoading()
1613
- : tree
1614
- ? (0, react_jsx_runtime.jsx)(TreeRow, { node: tree, theme })
1615
- : renderTree() }),
1928
+ // 016:脑图视图走 MindmapCanvas(自带滚动 + 居中 + 右上角缩放控制条),
1929
+ // 不再套 S.body(避免嵌套滚动容器与双重 padding);目录/加载/空态保持原样。
1930
+ active === TREE_TAB || (doc && doc.op === "local") || !tree
1931
+ ? (0, react_jsx_runtime.jsx)("div", { style: S.body, children: active === TREE_TAB
1932
+ ? renderTree()
1933
+ : (doc && doc.op === "local")
1934
+ ? renderLoading()
1935
+ : renderTree() })
1936
+ : (0, react_jsx_runtime.jsx)(MindmapCanvas, { node: tree, theme, fitKey: doc && doc.path }),
1616
1937
  tabMenu ? (0, react_jsx_runtime.jsxs)("div", {
1617
1938
  style: { ...S.treeMenu, left: tabMenu.x, top: tabMenu.y },
1618
1939
  onContextMenu: (e) => e.preventDefault(),
@@ -1729,6 +2050,10 @@ window.__ModuleLoader__.load({
1729
2050
  relPathWithin,
1730
2051
  visibleTreeRows,
1731
2052
  colorThemeTokens,
2053
+ clampZoom,
2054
+ stepZoom,
2055
+ fitZoom,
2056
+ focusZoom,
1732
2057
  TOOL_NAMES,
1733
2058
  OPENING_OPS,
1734
2059
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mindmap",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Mindmap plugin for DeepSeek Harness: a plain markdown file in the working directory IS the mindmap; the chat edits it step by step and the right-side panel follows live.",
5
5
  "repository": {
6
6
  "type": "git",