dsh-mindmap 0.5.1 → 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.
- package/CHANGELOG.md +1 -0
- package/client.js +206 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,7 @@ All notable changes to this project are documented here. Release-specific notes
|
|
|
13
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
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
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.
|
|
16
17
|
|
|
17
18
|
### Changed
|
|
18
19
|
|
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
|
-
|
|
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
|
-
|
|
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", {
|
|
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.
|
|
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",
|