dsh-mindmap 0.5.0 → 0.6.0

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/client.js +206 -4
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,9 +8,17 @@ All notable changes to this project are documented here. Release-specific notes
8
8
 
9
9
  - Step-by-step update convention for the AI (system prompt + `mindmap_update` tool description): when an edit has several parts, call `mindmap_update` as soon as each part is ready instead of one giant update at the end — each call still carries the FULL document, and the panel's growth animation makes the tree visibly grow while the AI works. The former "panel updates in one step" wording is removed as it pointed the other way.
10
10
  - Progressive growth animation for the mindmap panel: after every update, newly added/changed nodes fade in one by one (breadth-first from root to leaves, staggered up to 90ms apart) instead of the whole tree popping in at once. Diffing rides on the existing stable structural node IDs, so unchanged nodes never flicker or replay; the total duration is compressed to stay within ~2s for large documents; connector lines fade in together with their new children. The layout is computed in full up front, so the animation is compositor-only (opacity/transform) with no reflow cost. A new `growthAnimation` setting (settings panel「面板」→「生长动画」, default on) turns it off entirely, and `prefers-reduced-motion` is respected. The `mindmap_update` contract is unchanged (still full markdown).
11
+ - Block concept for mindmap nodes (per the 003 living spec): markdown content is now parsed into typed blocks — text, markdown (inline formatting), code (compact summary line + hover overlay for the full source), quote, and table (full grid, cells never split) — while block-level structure syntax (headings/lists/quotes) is split into child nodes and plain paragraphs are promoted to nodes instead of being stuffed into a parent's description. Inline formatting never triggers splitting.
12
+ - Unified clickable links: any URL inside any block is rendered complete (never abbreviated), clickable, and opens in a new browser tab; links inside the hover overlay are clickable too.
13
+ - Theme token system: node colors/corners/shadows/states are produced by the pure function `resolveNodeStyle` from theme tokens, so card style and color theme switch cleanly without touching layout code.
14
+ - Node right-click menu gains「复制全文」: copies a node's complete own content to the clipboard (code blocks take the full fenced source, table blocks re-emit a valid Markdown table with the separator row restored, everything else takes the raw text), alongside the existing 复制为图片 / 导出为图片. A divider line separates the node title from the actions.
15
+ - Hover overlay for truncated content: prose blocks cut by the line clamp and code blocks share one floating panel (fixed-positioned outside the zoom layer, scrollable full text); it always docks to the right of the node box and never flips left — clicking a node auto-focuses it into view, so hovering again always shows everything.
16
+ - Canvas panning (021): the mindmap canvas can now be dragged around with the middle mouse button (anywhere, even over a node), the left button on blank canvas space (this is the Mac trackpad「click and drag」path), or Space + left button (even over a node, when the drag has to start on a card). Content follows the pointer 1:1, blank space shows a grab hand, and the canvas no longer chains its scroll to the host page at the edges. A 4px threshold separates drag from click, so clicking blank space still clears the selection and clicking a node still focuses it — while a real drag no longer wipes the selection ring on release. Built on pointer events with pointer capture, so the pointer keeps dragging even outside the panel; touch input is left to native scrolling so momentum survives; and the gesture never re-renders the tree (cursor and text-selection lock go straight to the DOM, because a re-render would re-measure every `TreeRow`). Refinements from review (`_issues/001`): sub-threshold jitter no longer writes `scroll` or flashes the grab cursor (a 1–3px hand tremor on a plain click used to shift the canvas, and because the formula is absolute from the press anchor, skipping those early writes still tracks the pointer 1:1 — the full displacement lands in one jump once the threshold is crossed); the grab cursor and the text-selection lock are now applied on the first crossing rather than on press; a pan whose `setPointerCapture` failed no longer hangs when the pointer is released outside the scroller (a later button-less move ends the pan instead of dragging the canvas with nothing held); the click-suppression flag is cleared at the very top of every gesture rather than after the early returns, so a finished drag can no longer leak into a later tap that takes a non-panning path (touch tap, left-press on a node) and swallow it; and a second pointer can no longer drive an existing pan's anchor.
11
17
 
12
18
  ### Changed
13
19
 
20
+ - Length governance for node boxes (020 revision, replacing unbounded full-wrap): every node box is capped at 320px wide so long content wraps instead of stretching into horizontal bars. Prose blocks (text/md/list/quote-topic) that still exceed 6 wrapped lines are clamped with an ellipsis — full text stays reachable via the hover overlay. The clamp uses an integer 20px line-height plus a hard `maxHeight` guard so the cut line never leaks half a glyph. Structural blocks keep their shape: tables get their own wider 680px writing surface (overflow scrolls inside the box) with per-cell min/max width guards.
21
+ - GFM table parsing contract: a literal `|` inside a cell must be escaped as `\|` and the parser now honors it; column count is pinned to the separator row — short rows are padded with empty cells and extra cells are dropped, so the grid never turns ragged even when a row contains unescaped pipes.
14
22
  - Split the two largest client source fragments for maintainability (pure physical split, no behavior change): `src/client/ui/render.js` (572 lines) becomes `slot.js` (MindmapSlot) + `render.js` (NodeBox/TreeRow) + `canvas.js` (the 016 canvas/zoom region), and `src/client/ui/panel.js` (790 lines) becomes `panel.js` (component head) + `treetab.js` (the 013 directory-tree tab region) + `panelbody.js` (layout effect + overlay JSX). The split was done by line slicing only, and the rebuilt `client.js` is byte-for-byte identical to the previous build. `build-client.mjs` now decides fragment separators via an explicit `PHYSICAL_SPLITS` set instead of hard-coded list indices.
15
23
 
16
24
  ### Fixed
package/client.js CHANGED
@@ -1301,7 +1301,9 @@ window.__ModuleLoader__.load({
1301
1301
  inlineCode: { fontFamily: "Menlo, monospace", fontSize: "12px", background: "var(--dsw-alias-fill-tsp-secondary)", borderRadius: "4px", padding: "0 3px" },
1302
1302
  // 016 脑图画布:滚动区 + 居中层 + 右上角浮动缩放控制条。
1303
1303
  canvasWrap: { flex: "1 1 auto", minHeight: 0, minWidth: 0, position: "relative", display: "flex", flexDirection: "column" },
1304
- canvasScroll: { flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "auto" },
1304
+ // 021 平移:空白处抓手光标(节点盒自带 pointer 覆盖);overscroll
1305
+ // contain 让画布滚到边时不把滚动链传给宿主页面(聊天区不跟着动)。
1306
+ canvasScroll: { flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "auto", cursor: "grab", overscrollBehavior: "contain" },
1305
1307
  // 居中层:内容小则铺满视口(100%),大则撑到内容尺寸(max-content);
1306
1308
  // 子项用 margin:auto——空间充足双向居中,溢出时 margin 归零、从滚动
1307
1309
  // 原点排布(flexbox 溢出居中裁剪的标准解法,无左/上侧裁剪)。
@@ -1850,8 +1852,54 @@ window.__ModuleLoader__.load({
1850
1852
  return clampZoom(Math.min((viewW - ZOOM.padding) / treeW, (viewH - ZOOM.padding) / treeH, ZOOM.focusMax));
1851
1853
  }
1852
1854
 
1853
- /**
1854
- * 脑图画布:滚动区(canvasScroll)+ 居中层(canvasCenter,100%/max-content
1855
+ //#region 021 画布平移:拖拽手势(中键 / 空白处左键 / 空格+左键)
1856
+ // 平移契约(三条手指路径,覆盖鼠标与 Mac 触摸板):
1857
+ // 中键拖动 —— 画布惯例,任何位置都拖(压节点上也拖)
1858
+ // 左键空白处拖动 —— Mac 触摸板「按住拖」= 左键拖拽,走这条
1859
+ // 空格 + 左键拖动 —— 压在节点上也能拖(节点上留文字选区给左键)
1860
+ // 方向:内容跟手(按下点始终贴着指针),故 scroll = 按下时 scroll − 位移。
1861
+ // 阈值 4px 以内算「点击」:不写 scroll(手抖不挪画布)、不上抓手光标、
1862
+ // 不吞随后的 click——保留点空白取消选中 / 点节点聚焦的既有行为。
1863
+ // 触摸(触屏)不劫持——交给原生滚动,保住惯性。
1864
+ const PAN = { threshold: 4 };
1865
+
1866
+ /** 是否在该指针按下上启动平移。button: 0 左 / 1 中 / 2 右。 */
1867
+ function shouldStartPan(button, options) {
1868
+ const { onNode = false, spaceHeld = false, touch = false } = options || {};
1869
+ if (touch) return false;
1870
+ if (button === 1) return true;
1871
+ if (button !== 0) return false;
1872
+ if (spaceHeld) return true;
1873
+ return !onNode;
1874
+ }
1875
+
1876
+ /** 平移一步:目标 scroll = 按下时 scroll − 位移;moved 表示已越过点击阈值。 */
1877
+ function panScroll(start, dx, dy, threshold) {
1878
+ const limit = Number.isFinite(threshold) ? threshold : PAN.threshold;
1879
+ return {
1880
+ scrollLeft: start.scrollLeft - dx,
1881
+ scrollTop: start.scrollTop - dy,
1882
+ moved: Math.abs(dx) >= limit || Math.abs(dy) >= limit,
1883
+ };
1884
+ }
1885
+
1886
+ /** 空格键是否落在可输入元素里(聊天框/输入框与面板同 document,不能抢空格)。 */
1887
+ function isTextEntry(el) {
1888
+ if (!el || typeof el.tagName !== "string") return false;
1889
+ const tag = el.tagName.toLowerCase();
1890
+ return tag === "input" || tag === "textarea" || el.isContentEditable === true;
1891
+ }
1892
+
1893
+ /** 空格键是否落在「空格即激活」的控件上(按钮/链接/自定义控件)。
1894
+ * 这类元素上不能 preventDefault,否则挡掉空格激活。 */
1895
+ function isActivatable(el) {
1896
+ if (!el || typeof el.closest !== "function") return false;
1897
+ return Boolean(el.closest("button, a[href], [role='button'], input, textarea, select, [contenteditable='true']"));
1898
+ }
1899
+ //#endregion
1900
+
1901
+ /**
1902
+ * 脑图画布:滚动区(canvasScroll)+ 居中层(canvasCenter,100%/max-content
1855
1903
  * 双下限)+ 缩放内容(margin:auto + CSS zoom)。zoom 用 CSS zoom 而非
1856
1904
  * transform:scale——它影响布局,滚动范围随缩放自动正确;TreeRow 连线是
1857
1905
  * getBoundingClientRect 相对测量,父子同因子缩放,几何保持一致。
@@ -1909,6 +1957,19 @@ window.__ModuleLoader__.load({
1909
1957
  codePanelTimerRef.current = setTimeout(() => setCodePanel(null), 150);
1910
1958
  }
1911
1959
 
1960
+ // 021 画布平移锚点:本次拖拽按下时的指针与 scroll。刻意不用
1961
+ // state——平移起止若触发重渲染,每个 TreeRow 的 useLayoutEffect
1962
+ // 都要重测一遍(O(n) 强制重排),大树上拖一下会明显顿一下。
1963
+ // 光标与「禁用选中」直接改 DOM style,全程零重渲染。
1964
+ const panRef = react.useRef(null);
1965
+ // 021 空格键:按下时左键在节点上也能拖。同样用 ref(按空格不该重渲染)。
1966
+ const spaceRef = react.useRef(false);
1967
+ // 021 拖过就吞掉随后那次 click(保留单击空白取消选中 / 点节点聚焦)。
1968
+ const suppressClickRef = react.useRef(false);
1969
+ // 021 指针是否悬在画布上(空格键要不要拦默认行为的门控;纯
1970
+ // 读取,不参与渲染)。
1971
+ const hoverRef = react.useRef(false);
1972
+
1912
1973
  // 测量并适配:自然尺寸 = getBoundingClientRect ÷ 已提交 zoom(与
1913
1974
  // DOM 实际状态严格同步,无竞态)。值不变不动 state(bail-out),
1914
1975
  // 值变化才重置滚动到原点让树回到居中;同步记录自然尺寸供防抖。
@@ -1976,6 +2037,111 @@ window.__ModuleLoader__.load({
1976
2037
  return () => observer.disconnect();
1977
2038
  }, []);
1978
2039
 
2040
+ // 021 空格键跟踪:指针悬在画布上时按住空格 → 左键可拖画布(画布惯例)。
2041
+ // 输入框/聊天框里按空格不抢;窗口失焦即松开,防「卡在按住态」。
2042
+ // 悬停门控(hoverRef)用于决定要不要拦空格的默认行为——全局拦会把
2043
+ // 聊天区的「空格翻页」一起干掉,只拦「指针在画布上」这一刻才安全。
2044
+ react.useEffect(() => {
2045
+ const isSpace = (e) => e.code === "Space" || e.key === " ";
2046
+ const onKeyDown = (e) => {
2047
+ if (!isSpace(e) || isTextEntry(e.target)) return;
2048
+ spaceRef.current = true;
2049
+ // 悬在画布上、且不在按钮/链接上:掐掉空格滚动宿主页面。
2050
+ if (hoverRef.current && !isActivatable(e.target)) e.preventDefault();
2051
+ };
2052
+ const onKeyUp = (e) => {
2053
+ if (!isSpace(e)) return;
2054
+ spaceRef.current = false;
2055
+ };
2056
+ const onBlur = () => {
2057
+ spaceRef.current = false;
2058
+ };
2059
+ window.addEventListener("keydown", onKeyDown);
2060
+ window.addEventListener("keyup", onKeyUp);
2061
+ window.addEventListener("blur", onBlur);
2062
+ return () => {
2063
+ window.removeEventListener("keydown", onKeyDown);
2064
+ window.removeEventListener("keyup", onKeyUp);
2065
+ window.removeEventListener("blur", onBlur);
2066
+ };
2067
+ }, []);
2068
+
2069
+ // 021 平移三件套:按下记锚点 → 移动按差值写 scroll → 松手收尾。
2070
+ // 用 pointer 事件 + setPointerCapture:指针滑出画布(甚至滑出面板)
2071
+ // 仍跟手,不必在 window 上挂监听。
2072
+ function applyPanCursor(scroller, active) {
2073
+ scroller.style.cursor = active ? "grabbing" : "grab";
2074
+ scroller.style.userSelect = active ? "none" : "";
2075
+ }
2076
+
2077
+ function beginPan(e) {
2078
+ // 吞 click 的标记一律在本轮手势的最开头清零,放在所有早退之前:
2079
+ // 否则上轮手势留下的 true 会粘到下一次点击上——例:拖完画布后再
2080
+ // 触摸点按(touch 路径不启动平移就早退了),那次点击会被白吞一次。
2081
+ suppressClickRef.current = false;
2082
+ const scroller = scrollRef.current;
2083
+ if (!scroller || panRef.current) return;
2084
+ const target = e.target;
2085
+ const onNode = Boolean(target && typeof target.closest === "function" && target.closest("[data-mindmap-node]"));
2086
+ if (!shouldStartPan(e.button, { onNode, spaceHeld: spaceRef.current, touch: e.pointerType === "touch" })) return;
2087
+ // 中键:掐掉浏览器自动滚动;左键:掐掉拖选文本。
2088
+ e.preventDefault();
2089
+ panRef.current = {
2090
+ pointerId: e.pointerId,
2091
+ x: e.clientX,
2092
+ y: e.clientY,
2093
+ scrollLeft: scroller.scrollLeft,
2094
+ scrollTop: scroller.scrollTop,
2095
+ moved: false,
2096
+ };
2097
+ // 不在按下就上抓手光标:普通点击(位移 < 阈值)不该闪一下
2098
+ // grabbing、也不该提前锁掉文本选择——等真正越过阈值再上。
2099
+ if (e.pointerId != null && typeof scroller.setPointerCapture === "function") {
2100
+ try { scroller.setPointerCapture(e.pointerId); } catch { /* 捕获失败:退化为元素内拖拽,movePan 有兜底 */ }
2101
+ }
2102
+ }
2103
+
2104
+ function movePan(e) {
2105
+ const pan = panRef.current;
2106
+ const scroller = scrollRef.current;
2107
+ if (!pan || !scroller) return;
2108
+ // 多指针(触屏/笔)下第二根指头的移动不该驱动第一根指头的锚点。
2109
+ if (e.pointerId != null && pan.pointerId != null && e.pointerId !== pan.pointerId) return;
2110
+ // 兜底:捕获失败退化为元素内拖拽时,指针在滚动区外松手就收不到
2111
+ // pointerup,panRef 会悬挂 → 之后不按键移动也会拖画布。无按键
2112
+ // 还在动说明早已松手,直接收尾(触摸进不了平移,不会误伤)。
2113
+ if (e.buttons === 0) {
2114
+ endPan();
2115
+ return;
2116
+ }
2117
+ const step = panScroll(pan, e.clientX - pan.x, e.clientY - pan.y, PAN.threshold);
2118
+ // 阈值内不写 scroll:点击手抖 1~3px 不该把画布挪走。公式是从按下
2119
+ // 锚点算的绝对值(非增量),跳过早期写入仍保持 1:1 跟手。
2120
+ if (!pan.moved && !step.moved) return;
2121
+ // 首次越过阈值:这才算拖拽——上抓手光标 + 锁文本选择。
2122
+ if (!pan.moved) applyPanCursor(scroller, true);
2123
+ if (step.moved) pan.moved = true;
2124
+ // 越界交给浏览器钳制(不自己算 scrollWidth,省一次重排)。
2125
+ scroller.scrollLeft = step.scrollLeft;
2126
+ scroller.scrollTop = step.scrollTop;
2127
+ }
2128
+
2129
+ function endPan() {
2130
+ const pan = panRef.current;
2131
+ // 已收尾(pointerup 后浏览器会补发 lostpointercapture)→ 不再动手,
2132
+ // 否则会把 suppressClickRef 清掉、平移后误触发 click。
2133
+ if (!pan) return;
2134
+ panRef.current = null;
2135
+ const scroller = scrollRef.current;
2136
+ if (scroller) {
2137
+ applyPanCursor(scroller, false);
2138
+ if (pan.pointerId != null && typeof scroller.releasePointerCapture === "function") {
2139
+ try { scroller.releasePointerCapture(pan.pointerId); } catch { /* 已释放 */ }
2140
+ }
2141
+ }
2142
+ suppressClickRef.current = pan.moved;
2143
+ }
2144
+
1979
2145
  function setZoom(next, anchorViewport) {
1980
2146
  const value = clampZoom(next);
1981
2147
  anchorRef.current = anchorViewport && scrollRef.current
@@ -2025,6 +2191,12 @@ window.__ModuleLoader__.load({
2025
2191
  // 上限 focusMax)。事件委托:closest 找节点盒与所在子树 row,无需给
2026
2192
  // 递归 TreeRow 传回调。聚焦视为用户手动缩放(停自动再适配)。
2027
2193
  function onCanvasClick(e) {
2194
+ // 021:刚拖过画布(平移)的这次 click 不是点击,直接吞掉——
2195
+ // 否则每次平移松手都会顺手把选中环清掉。
2196
+ if (suppressClickRef.current) {
2197
+ suppressClickRef.current = false;
2198
+ return;
2199
+ }
2028
2200
  const target = e.target;
2029
2201
  if (!target || typeof target.closest !== "function") return;
2030
2202
  const boxEl = target.closest("[data-mindmap-node]");
@@ -2135,7 +2307,29 @@ window.__ModuleLoader__.load({
2135
2307
  // 017 空白处/缩放条右键只拦浏览器默认菜单(节点右键已 stopPropagation)。
2136
2308
  onContextMenu: (e) => e.preventDefault(),
2137
2309
  children: [
2138
- (0, react_jsx_runtime.jsx)("div", { ref: scrollRef, style: S.canvasScroll, onClick: onCanvasClick, children:
2310
+ (0, react_jsx_runtime.jsx)("div", {
2311
+ ref: scrollRef,
2312
+ style: S.canvasScroll,
2313
+ onClick: onCanvasClick,
2314
+ // 021 画布平移:pointer 三件套 + 捕获,指针滑出画布仍跟手。
2315
+ onPointerDown: beginPan,
2316
+ onPointerMove: movePan,
2317
+ onPointerUp: endPan,
2318
+ // 系统手势打断(如三指滑动)与捕获丢失都走同一收尾。
2319
+ onPointerCancel: endPan,
2320
+ onLostPointerCapture: endPan,
2321
+ // 中键松开的 auxclick:掐掉自动滚动 / 剪贴板粘贴等默认行为。
2322
+ onAuxClick: (e) => {
2323
+ if (e.button === 1) e.preventDefault();
2324
+ },
2325
+ // 021 悬停门控:空格键只在指针悬在画布上时才拦默认行为。
2326
+ onPointerEnter: () => { hoverRef.current = true; },
2327
+ onPointerLeave: () => { hoverRef.current = false; },
2328
+ // 平移中掐掉原生 HTML5 拖拽(拖文字/图片会抢走手势)。
2329
+ onDragStart: (e) => {
2330
+ if (panRef.current) e.preventDefault();
2331
+ },
2332
+ children:
2139
2333
  (0, react_jsx_runtime.jsx)("div", { style: S.canvasCenter, children:
2140
2334
  (0, react_jsx_runtime.jsx)("div", { ref: contentRef, style: { margin: "auto", zoom }, children:
2141
2335
  (0, react_jsx_runtime.jsx)(TreeRow, { node, theme, onNodeContextMenu, reveal, selectedId, onCodePanel: handleCodePanel })
@@ -3166,8 +3360,16 @@ window.__ModuleLoader__.load({
3166
3360
  stepZoom,
3167
3361
  fitZoom,
3168
3362
  focusZoom,
3363
+ // 021 画布平移手势判定(供测试)。
3364
+ PAN,
3365
+ shouldStartPan,
3366
+ panScroll,
3367
+ isTextEntry,
3368
+ isActivatable,
3169
3369
  TOOL_NAMES,
3170
3370
  OPENING_OPS,
3371
+ // 021 画布组件:仅供测试驱动平移手势(不参与运行时契约)。
3372
+ MindmapCanvas,
3171
3373
  });
3172
3374
  return module.exports;
3173
3375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mindmap",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",