dsh-mindmap 0.2.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 +8 -1
- package/README.md +8 -3
- package/README.zh-CN.md +8 -3
- package/client.js +473 -70
- package/index.js +1 -0
- package/package.json +16 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,17 @@
|
|
|
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
|
-
## [
|
|
5
|
+
## [0.4.0] - 2026-08-24
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- The browser client is now maintained as focused source fragments under `src/client/` and assembled into the single runtime `client.js` entry by `npm run build:client`.
|
|
10
|
+
- AI create/open/view intent now restores and switches the target mindmap panel smoothly, including repeated opens and open-then-update tool sequences.
|
|
11
|
+
- Added release metadata, a `prepack` verification hook, and an npm package dry-run check to the documented development flow.
|
|
6
12
|
|
|
7
13
|
### Added
|
|
8
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.
|
|
9
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.
|
|
10
17
|
|
|
11
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
|
|
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
|
|
|
@@ -17,11 +17,12 @@ A DeepSeek Harness plugin that turns a plain Markdown file into a live mindmap.
|
|
|
17
17
|
|
|
18
18
|
- **Four tools** (`mindmap_create` / `mindmap_open` / `mindmap_get` / `mindmap_update`) — plain Markdown files in the session working directory; the root node title is the filename and stays in sync both ways (`renameRoot` renames the file, collisions are rejected).
|
|
19
19
|
- **Live panel with zero extra channels** — the panel consumes the session snapshot (`mindmap_*` tool results), so every AI edit re-renders immediately.
|
|
20
|
-
- **Floating right panel** — toggled by the 思维脑图 button in the session header;
|
|
20
|
+
- **Floating right panel** — toggled by the 思维脑图 button in the session header; AI create/open/view intents also expand it and switch to the target document, including when the panel is currently closed or the same document is opened again. Opening the panel pushes the chat to the left (layout-push, the chat is never covered); width is drag-resizable (280px ~ 80% viewport) and persisted.
|
|
21
21
|
- **Directory tree tab** — a persistent tree of the session working directory (served by a plugin-owned read-only route), lazy-loaded per directory; right-click to create a mindmap (at the root or inside a directory); left-click a `.md` to open it instantly and hand it to the AI for editing.
|
|
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
|
|
|
@@ -58,9 +59,13 @@ dsh plugin --profile <profile> add <pkg>#v<version>
|
|
|
58
59
|
## Development
|
|
59
60
|
|
|
60
61
|
```bash
|
|
61
|
-
npm run
|
|
62
|
+
npm run build:client # assemble the runtime client.js from src/client fragments
|
|
63
|
+
npm run verify # rebuild + syntax check + node --test
|
|
64
|
+
npm pack --dry-run # inspect the files that will enter the npm package
|
|
62
65
|
```
|
|
63
66
|
|
|
67
|
+
The browser implementation is maintained under `src/client/` and assembled into the single `client.js` entry required by DeepSeek Harness. Edit the source fragments, then run `npm run build:client`; do not hand-edit the generated entry.
|
|
68
|
+
|
|
64
69
|
## License
|
|
65
70
|
|
|
66
71
|
Not yet chosen — contact the author before redistributing.
|
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
|
|
7
|
+
> 项目状态:pre-1.0。当前功能集(见 [CHANGELOG](./CHANGELOG.md))已实现并有单元测试覆盖,但开发环境之外的跨版本兼容性尚未认证。
|
|
8
8
|
|
|
9
9
|
## 核心思想
|
|
10
10
|
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
- **四个工具**(`mindmap_create` / `mindmap_open` / `mindmap_get` / `mindmap_update`)——会话工作目录里的普通 `.md` 文件;根节点标题 = 文件名,双向同步(`renameRoot` 触发文件重命名,撞名报错不覆盖)。
|
|
19
19
|
- **零通道实时面板**——面板直接消费会话快照里的 `mindmap_*` 工具结果,AI 每改一步面板即渲染一次。
|
|
20
|
-
- **右侧悬浮面板**——会话头部「思维脑图」按钮开合;AI
|
|
20
|
+
- **右侧悬浮面板**——会话头部「思维脑图」按钮开合;AI 创建、打开或查看脑图时会自动展开并切换到目标文档,即使面板当前收起或重复打开同一文档也能接续跟随。打开时**聊天区向左让位**(布局推挤,互不遮挡);宽度可拖(280px ~ 80% 视口)并持久化。
|
|
21
21
|
- **常驻目录树 tab**——工作目录结构懒加载树(插件自建只读路由);空白处/目录右键新建脑图;左键点 `.md` 秒开 tab 并自动交给 AI 打开,随后直接对话继续编辑。
|
|
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
|
|
|
@@ -58,9 +59,13 @@ dsh plugin --profile <profile> add <pkg>#v<version>
|
|
|
58
59
|
## 开发
|
|
59
60
|
|
|
60
61
|
```bash
|
|
61
|
-
npm run
|
|
62
|
+
npm run build:client # 从 src/client 片段组装运行时 client.js
|
|
63
|
+
npm run verify # 重建 + 语法检查 + node --test
|
|
64
|
+
npm pack --dry-run # 检查将进入 npm 包的文件
|
|
62
65
|
```
|
|
63
66
|
|
|
67
|
+
浏览器端实现维护在 `src/client/` 下,构建后仍输出 DeepSeek Harness 要求的单一 `client.js` 入口。请修改源码片段后运行 `npm run build:client`,不要直接手改生成入口。
|
|
68
|
+
|
|
64
69
|
## License
|
|
65
70
|
|
|
66
71
|
尚未确定——再分发前请先联系作者。
|
package/client.js
CHANGED
|
@@ -281,41 +281,97 @@ window.__ModuleLoader__.load({
|
|
|
281
281
|
/**
|
|
282
282
|
* 重放会话快照里的 mindmap_* 工具结果,得到每个脑图文档的最新状态。
|
|
283
283
|
* nodes: ConversationSnapshot.nodes(ToolResultNode 含 call.name 与渲染后的
|
|
284
|
-
* content 文本块——host 的工具结果 JSON 就写在其中)。
|
|
285
|
-
*
|
|
284
|
+
* content 文本块——host 的工具结果 JSON 就写在其中)。Code 等工具的结果
|
|
285
|
+
* 还可能把真实的 mindmap ToolResultNode 放在 subCalls 中,因此这里按事件
|
|
286
|
+
* 顺序递归重放整棵调用树。
|
|
287
|
+
* 返回文档集及最近一次 create/open 意图,用于驱动面板自动切换目标。
|
|
286
288
|
*/
|
|
287
289
|
function reduceDocuments(nodes) {
|
|
288
290
|
const byPath = Object.create(null);
|
|
289
291
|
let order = [];
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
292
|
+
let latestOpeningPath = null;
|
|
293
|
+
let latestOpeningEventKey = null;
|
|
294
|
+
const visited = new WeakSet();
|
|
295
|
+
const MAX_SUBCALL_DEPTH = 100;
|
|
296
|
+
|
|
297
|
+
function replayNode(node, eventPath, depth) {
|
|
298
|
+
if (!node || typeof node !== "object" || visited.has(node)) return;
|
|
299
|
+
visited.add(node);
|
|
300
|
+
|
|
301
|
+
if (node.kind === "tool-result" && !node.isError) {
|
|
302
|
+
const name = node.call?.name;
|
|
303
|
+
if (typeof name === "string" && TOOL_NAMES.has(name)) {
|
|
304
|
+
let parsed;
|
|
305
|
+
try {
|
|
306
|
+
parsed = JSON.parse(resultTextOfBlocks(node.content));
|
|
307
|
+
} catch {
|
|
308
|
+
parsed = null;
|
|
309
|
+
}
|
|
310
|
+
if (parsed && parsed.ok === true && typeof parsed.path === "string" && parsed.path) {
|
|
311
|
+
const op = typeof parsed.op === "string" ? parsed.op : name;
|
|
312
|
+
// callId 是实际调用的事件身份;eventPath 是无 callId 时按会话
|
|
313
|
+
// 遍历顺序生成的稳定兜底,避免重复 open 被合并成一个事件。
|
|
314
|
+
const callId = node.callId != null && String(node.callId)
|
|
315
|
+
? node.callId
|
|
316
|
+
: node.call?.callId;
|
|
317
|
+
const eventKey = callId != null && String(callId)
|
|
318
|
+
? `call:${String(callId)}`
|
|
319
|
+
: `node:${eventPath}`;
|
|
320
|
+
const renamedFrom = typeof parsed.renamedFrom === "string" ? parsed.renamedFrom : null;
|
|
321
|
+
const previous = byPath[parsed.path];
|
|
322
|
+
const renamedDocument = renamedFrom && renamedFrom !== parsed.path
|
|
323
|
+
? byPath[renamedFrom]
|
|
324
|
+
: null;
|
|
325
|
+
const inheritedOpeningEventKey = renamedDocument?.openingEventKey
|
|
326
|
+
?? previous?.openingEventKey
|
|
327
|
+
?? (renamedFrom && latestOpeningPath === renamedFrom ? latestOpeningEventKey : null);
|
|
328
|
+
|
|
329
|
+
// 根节点改名会删除旧路径键;若旧路径正是最近一次打开意图,
|
|
330
|
+
// 同时迁移路径并保留原 openingEventKey,确保自动切换仍生效。
|
|
331
|
+
if (renamedFrom && renamedFrom !== parsed.path && latestOpeningPath === renamedFrom) {
|
|
332
|
+
latestOpeningPath = parsed.path;
|
|
333
|
+
if (latestOpeningEventKey == null) latestOpeningEventKey = inheritedOpeningEventKey;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const openingEventKey = OPENING_OPS.has(op)
|
|
337
|
+
? eventKey
|
|
338
|
+
: inheritedOpeningEventKey;
|
|
339
|
+
if (OPENING_OPS.has(op)) {
|
|
340
|
+
latestOpeningPath = parsed.path;
|
|
341
|
+
latestOpeningEventKey = eventKey;
|
|
342
|
+
}
|
|
343
|
+
if (renamedFrom && renamedDocument && renamedFrom !== parsed.path) {
|
|
344
|
+
delete byPath[renamedFrom];
|
|
345
|
+
order = order.filter((p) => p !== renamedFrom);
|
|
346
|
+
}
|
|
347
|
+
if (!byPath[parsed.path]) order.push(parsed.path);
|
|
348
|
+
byPath[parsed.path] = {
|
|
349
|
+
path: parsed.path,
|
|
350
|
+
rootTitle: typeof parsed.rootTitle === "string" && parsed.rootTitle ? parsed.rootTitle : stemOf(parsed.path),
|
|
351
|
+
content: typeof parsed.content === "string" ? parsed.content : "",
|
|
352
|
+
op,
|
|
353
|
+
callId,
|
|
354
|
+
eventKey,
|
|
355
|
+
openingEventKey,
|
|
356
|
+
// 013:rename 迁移后保留旧路径,供本地直读 tab 清理(mergeDocuments)。
|
|
357
|
+
renamedFrom,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
299
361
|
}
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
362
|
+
|
|
363
|
+
// 父级非 mindmap 工具不参与文档解析,但其 subCalls 仍是会话事件,
|
|
364
|
+
// 必须继续深入;深度上限与 WeakSet 一起防止异常结构卡死。
|
|
365
|
+
if (depth >= MAX_SUBCALL_DEPTH || !Array.isArray(node.subCalls)) return;
|
|
366
|
+
for (const [subCallIndex, subCall] of node.subCalls.entries()) {
|
|
367
|
+
replayNode(subCall, `${eventPath}.${subCallIndex}`, depth + 1);
|
|
306
368
|
}
|
|
307
|
-
if (!byPath[parsed.path]) order.push(parsed.path);
|
|
308
|
-
byPath[parsed.path] = {
|
|
309
|
-
path: parsed.path,
|
|
310
|
-
rootTitle: typeof parsed.rootTitle === "string" && parsed.rootTitle ? parsed.rootTitle : stemOf(parsed.path),
|
|
311
|
-
content: typeof parsed.content === "string" ? parsed.content : "",
|
|
312
|
-
op: typeof parsed.op === "string" ? parsed.op : name,
|
|
313
|
-
callId: node.callId,
|
|
314
|
-
// 013:rename 迁移后保留旧路径,供本地直读 tab 清理(mergeDocuments)。
|
|
315
|
-
renamedFrom: typeof parsed.renamedFrom === "string" ? parsed.renamedFrom : null,
|
|
316
|
-
};
|
|
317
369
|
}
|
|
318
|
-
|
|
370
|
+
|
|
371
|
+
for (const [nodeIndex, node] of (Array.isArray(nodes) ? nodes : []).entries()) {
|
|
372
|
+
replayNode(node, String(nodeIndex), 0);
|
|
373
|
+
}
|
|
374
|
+
return { order, byPath, latestOpeningPath, latestOpeningEventKey };
|
|
319
375
|
}
|
|
320
376
|
|
|
321
377
|
/**
|
|
@@ -337,7 +393,36 @@ window.__ModuleLoader__.load({
|
|
|
337
393
|
for (const p of Object.keys(localDocs)) {
|
|
338
394
|
if (!snapshot.byPath[p] && !dropped.has(p)) order.push(p);
|
|
339
395
|
}
|
|
340
|
-
return {
|
|
396
|
+
return {
|
|
397
|
+
order,
|
|
398
|
+
byPath,
|
|
399
|
+
latestOpeningPath: snapshot.latestOpeningPath ?? null,
|
|
400
|
+
latestOpeningEventKey: snapshot.latestOpeningEventKey ?? null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 找到本次快照需要自动展示的脑图路径。
|
|
406
|
+
* seenKeys 为 null 表示首次挂载:恢复历史会话最近一次 create/open;
|
|
407
|
+
* 否则只响应尚未消费的打开事件。
|
|
408
|
+
*/
|
|
409
|
+
function autoOpenTarget(snapshot, seenKeys) {
|
|
410
|
+
if (!snapshot || !snapshot.latestOpeningPath || !snapshot.byPath?.[snapshot.latestOpeningPath]) return null;
|
|
411
|
+
if (seenKeys === null) return snapshot.latestOpeningPath;
|
|
412
|
+
const latest = snapshot.byPath[snapshot.latestOpeningPath];
|
|
413
|
+
if (latest.openingEventKey && !seenKeys.has(latest.openingEventKey)) return snapshot.latestOpeningPath;
|
|
414
|
+
let target = null;
|
|
415
|
+
for (const p of snapshot.order ?? []) {
|
|
416
|
+
const doc = snapshot.byPath[p];
|
|
417
|
+
if (doc?.openingEventKey && !seenKeys.has(doc.openingEventKey)) target = p;
|
|
418
|
+
}
|
|
419
|
+
return target;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function openingEventKeys(snapshot) {
|
|
423
|
+
return new Set((snapshot?.order ?? [])
|
|
424
|
+
.map((p) => snapshot.byPath[p]?.openingEventKey)
|
|
425
|
+
.filter((key) => key != null));
|
|
341
426
|
}
|
|
342
427
|
//#endregion
|
|
343
428
|
|
|
@@ -552,12 +637,30 @@ window.__ModuleLoader__.load({
|
|
|
552
637
|
childrenColumn: { display: "flex", flexDirection: "column", gap: "8px", marginLeft: "40px", minWidth: 0 },
|
|
553
638
|
// 面板树连线层:正交折线(MarkGrove 的 orthogonalPath 风格),
|
|
554
639
|
// 覆盖整行、点击穿透、置于节点盒之下。
|
|
555
|
-
|
|
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" },
|
|
556
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)" },
|
|
557
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)" },
|
|
558
646
|
headingBox: { fontWeight: 600 },
|
|
559
647
|
placeholderBox: { padding: "6px 12px", borderRadius: "10px", border: "1px dashed var(--dsw-alias-border-l2)", color: "var(--dsw-alias-label-tertiary)", background: "none" },
|
|
560
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" },
|
|
561
664
|
};
|
|
562
665
|
|
|
563
666
|
/** 015 分段选择控件(线型/卡片风格)。 */
|
|
@@ -788,8 +891,15 @@ window.__ModuleLoader__.load({
|
|
|
788
891
|
const rowRef = react.useRef(null);
|
|
789
892
|
const boxWrapRef = react.useRef(null);
|
|
790
893
|
const childRefs = react.useRef([]);
|
|
791
|
-
|
|
792
|
-
|
|
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);
|
|
793
903
|
|
|
794
904
|
// 测量父盒右缘与各子节点包裹块的几何位置,画连线
|
|
795
905
|
// (折线 = M x1 y1 H midX V y2 H x2;曲线 = 贝塞尔水平切出);
|
|
@@ -797,28 +907,35 @@ window.__ModuleLoader__.load({
|
|
|
797
907
|
react.useLayoutEffect(() => {
|
|
798
908
|
const rowEl = rowRef.current;
|
|
799
909
|
const boxEl = boxWrapRef.current;
|
|
800
|
-
|
|
910
|
+
const probeEl = probeRef.current;
|
|
911
|
+
if (!rowEl || !boxEl || !probeEl) return;
|
|
801
912
|
const curve = theme && theme.lineStyle === "curve";
|
|
802
913
|
const measure = () => {
|
|
914
|
+
// 探针实测缩放因子(视觉/本地)——与 DOM 当前状态同步,
|
|
915
|
+
// 对 CSS zoom 的新旧实现(渲染期缩放 / used 值缩放)都成立。
|
|
916
|
+
const scale = probeEl.getBoundingClientRect().width / 10 || 1;
|
|
803
917
|
const rowRect = rowEl.getBoundingClientRect();
|
|
804
918
|
const boxRect = boxEl.getBoundingClientRect();
|
|
805
919
|
const next = [];
|
|
806
920
|
for (const ref of childRefs.current) {
|
|
807
921
|
if (!ref) continue;
|
|
808
922
|
const c = ref.getBoundingClientRect();
|
|
809
|
-
|
|
810
|
-
const
|
|
811
|
-
const
|
|
812
|
-
const
|
|
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;
|
|
813
928
|
const midX = (x1 + x2) / 2;
|
|
814
929
|
next.push(curve
|
|
815
930
|
? `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`
|
|
816
931
|
: `M ${x1} ${y1} H ${midX} V ${y2} H ${x2}`);
|
|
817
932
|
}
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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 });
|
|
822
939
|
};
|
|
823
940
|
measure();
|
|
824
941
|
let observer = null;
|
|
@@ -834,20 +951,34 @@ window.__ModuleLoader__.load({
|
|
|
834
951
|
};
|
|
835
952
|
});
|
|
836
953
|
|
|
837
|
-
|
|
838
|
-
|
|
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
|
|
839
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",
|
|
840
967
|
style: S.edgeLayer,
|
|
841
|
-
children: edges.map((d, i) => (0, react_jsx_runtime.jsx)("path", {
|
|
968
|
+
children: layout.edges.map((d, i) => (0, react_jsx_runtime.jsx)("path", {
|
|
842
969
|
key: i,
|
|
843
970
|
d,
|
|
844
971
|
stroke: "var(--dsw-alias-border-l2)",
|
|
845
972
|
strokeWidth: 1.5,
|
|
846
973
|
fill: "none",
|
|
974
|
+
// 016:CSS zoom 缩放下 strokeWidth 会被一并缩放,缩小后线变
|
|
975
|
+
// 亚像素、模糊看不清;vectorEffect=non-scaling-stroke 让线宽
|
|
976
|
+
// 保持不变(任何缩放下都是 1.5px 视觉宽度)。
|
|
977
|
+
vectorEffect: "non-scaling-stroke",
|
|
847
978
|
}, i)),
|
|
848
979
|
})
|
|
849
980
|
: null,
|
|
850
|
-
(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 }) }),
|
|
851
982
|
node.children && node.children.length > 0
|
|
852
983
|
? (0, react_jsx_runtime.jsx)("div", { style: S.childrenColumn, children: node.children.map((child, idx) => (0, react_jsx_runtime.jsx)("div", {
|
|
853
984
|
key: child.id,
|
|
@@ -857,8 +988,281 @@ window.__ModuleLoader__.load({
|
|
|
857
988
|
children: (0, react_jsx_runtime.jsx)(TreeRow, { node: child, theme }),
|
|
858
989
|
}, child.id)) })
|
|
859
990
|
: null,
|
|
860
|
-
|
|
861
|
-
|
|
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
|
|
862
1266
|
|
|
863
1267
|
function MindmapDetailsPanel(props) {
|
|
864
1268
|
// 014:面板与 M 按钮同槽位(conversation.session.header.actions),
|
|
@@ -1028,26 +1432,17 @@ window.__ModuleLoader__.load({
|
|
|
1028
1432
|
[doc && doc.content, doc && doc.rootTitle],
|
|
1029
1433
|
);
|
|
1030
1434
|
|
|
1031
|
-
// AI
|
|
1032
|
-
//
|
|
1435
|
+
// AI 自动打开:create/open 代表用户明确的「创建 / 打开 / 查看」意图。
|
|
1436
|
+
// 无论面板当前是否收起,都展开并切到这次意图对应的文档;首次挂载的
|
|
1437
|
+
// 历史快照也照常显示最近一次打开的脑图,避免出现「AI 说已打开但面板没了」。
|
|
1033
1438
|
const seen = react.useRef(null);
|
|
1034
1439
|
react.useEffect(() => {
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
let shouldOpen = false;
|
|
1041
|
-
for (const p of merged.order) {
|
|
1042
|
-
const d = merged.byPath[p];
|
|
1043
|
-
if (OPENING_OPS.has(d.op) && !seen.current.has(d.callId)) shouldOpen = true;
|
|
1044
|
-
}
|
|
1045
|
-
seen.current = ids;
|
|
1046
|
-
if (shouldOpen) {
|
|
1440
|
+
const targetPath = autoOpenTarget(merged, seen.current);
|
|
1441
|
+
seen.current = openingEventKeys(merged);
|
|
1442
|
+
if (targetPath) {
|
|
1047
1443
|
onOpen();
|
|
1048
|
-
// 单脑图模式:新打开的脑图顶替旧视图(001 场景 1「一句开脑图」)。
|
|
1049
1444
|
setHiddenPath(null);
|
|
1050
|
-
setCurrentPath(
|
|
1445
|
+
setCurrentPath(targetPath);
|
|
1051
1446
|
setView("mindmap");
|
|
1052
1447
|
}
|
|
1053
1448
|
}, [merged]);
|
|
@@ -1530,13 +1925,15 @@ window.__ModuleLoader__.load({
|
|
|
1530
1925
|
}, shown) : null,
|
|
1531
1926
|
] }),
|
|
1532
1927
|
] }),
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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 }),
|
|
1540
1937
|
tabMenu ? (0, react_jsx_runtime.jsxs)("div", {
|
|
1541
1938
|
style: { ...S.treeMenu, left: tabMenu.x, top: tabMenu.y },
|
|
1542
1939
|
onContextMenu: (e) => e.preventDefault(),
|
|
@@ -1644,6 +2041,8 @@ window.__ModuleLoader__.load({
|
|
|
1644
2041
|
parseMarkdownToTree,
|
|
1645
2042
|
reduceDocuments,
|
|
1646
2043
|
mergeDocuments,
|
|
2044
|
+
autoOpenTarget,
|
|
2045
|
+
openingEventKeys,
|
|
1647
2046
|
resultTextOfBlocks,
|
|
1648
2047
|
stemOf,
|
|
1649
2048
|
buildExportSvg,
|
|
@@ -1651,6 +2050,10 @@ window.__ModuleLoader__.load({
|
|
|
1651
2050
|
relPathWithin,
|
|
1652
2051
|
visibleTreeRows,
|
|
1653
2052
|
colorThemeTokens,
|
|
2053
|
+
clampZoom,
|
|
2054
|
+
stepZoom,
|
|
2055
|
+
fitZoom,
|
|
2056
|
+
focusZoom,
|
|
1654
2057
|
TOOL_NAMES,
|
|
1655
2058
|
OPENING_OPS,
|
|
1656
2059
|
});
|
package/index.js
CHANGED
|
@@ -52,6 +52,7 @@ Tools:
|
|
|
52
52
|
Markdown mapping (the panel's parser): headings nest by level (H1 are root children, H2 under the previous H1, ...); list items are child nodes nested by 2-space indentation; an EMPTY list item ("- " followed by nothing) renders as a placeholder node — use placeholders for planned-but-unwritten nodes; a fenced code block becomes a leaf node titled "[lang] first line"; plain paragraphs become the note text of the nearest heading.
|
|
53
53
|
|
|
54
54
|
Behavior rules:
|
|
55
|
+
- When the user asks to create a mindmap, call mindmap_create. When the user asks to open, view, show, or switch to an existing mindmap, call mindmap_open (do not use mindmap_get alone). Both operations bring that document to the visible mindmap panel automatically.
|
|
55
56
|
- Always mindmap_get before editing, then send the complete updated document to mindmap_update. One tool call per step so the panel follows along live.
|
|
56
57
|
- Never delete the whole document or restructure it without an explicit user request. Make the smallest change that answers the request.
|
|
57
58
|
- When the user steps away or pauses (e.g. "我去买咖啡"), stop all mindmap edits immediately and wait — never continue autonomously.
|
package/package.json
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mindmap",
|
|
3
|
-
"version": "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
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/guhanfei-ai/dsh-mindmap.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/guhanfei-ai/dsh-mindmap#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/guhanfei-ai/dsh-mindmap/issues"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
5
16
|
"type": "module",
|
|
6
17
|
"main": "index.js",
|
|
7
18
|
"scripts": {
|
|
8
|
-
"
|
|
19
|
+
"build:client": "node scripts/build-client.mjs",
|
|
20
|
+
"check": "npm run build:client && node --check index.js && node --check client.js && node --check scripts/build-client.mjs && node --check test/index.test.js && node --check test/client.test.js",
|
|
9
21
|
"test": "node --test",
|
|
10
|
-
"verify": "npm run check && npm test"
|
|
22
|
+
"verify": "npm run check && npm test",
|
|
23
|
+
"prepack": "npm run verify"
|
|
11
24
|
},
|
|
12
25
|
"files": [
|
|
13
26
|
"index.js",
|