From e5828ff292ecc3663966025117f7045e48619a90 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 11:11:07 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E4=B8=8E=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=EF=BC=8C?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E6=8F=8F=E8=BF=B0=E7=B3=BB=E7=BB=9F=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E3=80=81=E5=8A=9F=E8=83=BD=E4=B8=8E=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E5=8E=9F=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- README.md | 271 +++++++++++++++++++++++-------------------- docs/architecture.md | 136 ++++++++++++++++++++++ docs/design.md | 124 ++++++++++++++++++++ 3 files changed, 406 insertions(+), 125 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/design.md diff --git a/README.md b/README.md index a4a0af5..1041a72 100644 --- a/README.md +++ b/README.md @@ -8,171 +8,177 @@ [![build](https://github.com/kooksee/markview/actions/workflows/ci.yml/badge.svg)](https://github.com/kooksee/markview/actions/workflows/ci.yml) ![Coverage](https://raw.githubusercontent.com/k1LoW/octocovs/main/badges/kooksee/markview/coverage.svg) ![Code to Test Ratio](https://raw.githubusercontent.com/k1LoW/octocovs/main/badges/kooksee/markview/ratio.svg) ![Test Execution Time](https://raw.githubusercontent.com/k1LoW/octocovs/main/badges/kooksee/markview/time.svg) -`markview` is a Markdown viewer that opens `.md` files in a browser. - -> For AI coding assistant/project guidance, use `.github/copilot-instructions.md` as the single source of truth. - -## Features - -- GitHub-flavored Markdown (tables, task lists, footnotes, etc.) -- Syntax highlighting ([Shiki](https://shiki.style/)) -- [Mermaid](https://mermaid.js.org/) diagram rendering -- LaTeX math rendering ([KaTeX](https://katex.org/)) -- dark theme Dark / light theme light theme -- group File grouping -- toc Table of contents panel -- flat view Flat / tree view tree sidebar view with drag-and-drop reorder and file search -- YAML frontmatter display (collapsible metadata block) -- MDX file support (renders as Markdown, strips `import`/`export`, escapes JSX tags) -- wide view Wide / narrow view narrow content width toggle -- raw Raw markdown view -- copy Copy content (Markdown / Text / HTML) -- restart Server restart with session preservation -- Auto session backup and restore -- Drag-and-drop file addition from the OS file manager (content is loaded in-memory; live-reload is not supported for dropped files) -- Live-reload on save (for files opened via CLI) - -## Install - -**homebrew tap:** +`markview` 是一个在浏览器中打开 `.md` 文件并支持实时刷新的 Markdown 浏览工具。 + +> 如果你在使用 AI 编码助手或需要项目约束说明,请以 `.github/copilot-instructions.md` 为准。 + +## 功能特性 + +- 支持 GitHub 风格 Markdown(表格、任务列表、脚注等) +- 代码高亮([Shiki](https://shiki.style/)) +- [Mermaid](https://mermaid.js.org/) 图表渲染 +- LaTeX 数学公式([KaTeX](https://katex.org/)) +- 浅色主题 深色 / 深色主题 浅色主题切换 +- 分组 文件分组管理 +- 目录 文档目录面板 +- 平铺 平铺 / 树形 树形侧边栏(支持拖拽排序与文件搜索) +- YAML Frontmatter 展示(可折叠元数据区域) +- MDX 支持(渲染 Markdown,去除 `import`/`export`,转义 JSX 标签) +- 宽布局 宽版 / 窄布局 窄版阅读宽度切换 +- 原文 原始 Markdown 视图 +- 复制 内容复制(Markdown / 文本 / HTML) +- 重启 服务重启并保留会话 +- 自动会话备份与恢复 +- 支持从操作系统文件管理器拖拽添加文件(内容以内存形式加载,拖拽文件不支持 live-reload) +- 对通过 CLI 打开的文件支持保存后实时刷新 + +## 安装 + +**Homebrew:** ```console $ brew install kooksee/tap/markview ``` -**manually:** +**手动安装:** -Download binary from [releases page](https://github.com/kooksee/markview/releases) +从 [发布页](https://github.com/kooksee/markview/releases) 下载对应平台二进制文件。 -## Usage +## 使用方式 -``` console -$ markview README.md # Open a single file -$ markview README.md CHANGELOG.md docs/*.md # Open multiple files -$ markview spec.md --target design # Open in a named group +```console +$ markview README.md # 打开单个文件 +$ markview README.md CHANGELOG.md docs/*.md # 打开多个文件 +$ markview spec.md --target design # 打开到指定分组 ``` -`markview` opens Markdown files in a browser with live-reload. When you save a file, the browser automatically reflects the changes. +`markview` 会在浏览器中展示 Markdown 内容,并在文件保存后自动刷新页面。 + +### 单服务、多文件 -### Single server, multiple files +默认情况下,`markview` 在 `6275` 端口启动单个服务。 -By default, `markview` runs a single server on port `6275`. If a server is already running on the same port, subsequent `markview` invocations add files to the existing session instead of starting a new one. +当同端口已存在 `markview` 服务时,后续命令不会重复启动进程,而是把新文件追加到已有会话中。 -``` console -$ markview README.md # Starts a markview server in the background -$ markview CHANGELOG.md # Adds the file to the running markview server +```console +$ markview README.md # 后台启动 markview +$ markview CHANGELOG.md # 将文件追加到已运行服务 ``` -To run a completely separate session, use a different port: +如果你需要完全独立的会话,换一个端口即可: -``` console +```console $ markview draft.md -p 6276 ``` -![Multiple files with sidebar](images/multiple-files.png) +![多文件侧边栏](images/multiple-files.png) -### Groups +### 分组 -Files can be organized into named groups using the `--target` (`-t`) flag. Each group gets its own URL path and sidebar. +可通过 `--target`(`-t`)把文件归入命名分组。每个分组对应一个 URL 路径与独立侧边栏。 -``` console -$ markview spec.md --target design # Opens at http://localhost:6275/design -$ markview api.md --target design # Adds to the "design" group -$ markview notes.md --target notes # Opens at http://localhost:6275/notes +```console +$ markview spec.md --target design # 打开到 http://localhost:6275/design +$ markview api.md --target design # 追加到 design 分组 +$ markview notes.md --target notes # 打开到 http://localhost:6275/notes ``` -![Group view](images/groups.png) +![分组视图](images/groups.png) -### Glob pattern watching +### 通配模式监听 -Use `--watch` (`-w`) to specify glob patterns. Matching files are opened automatically, and watched directories are monitored for new files. +使用 `--watch`(`-w`)注册通配模式。匹配到的文件会自动打开,匹配目录也会被持续监听以发现新文件。 -``` console -$ markview --watch '**/*.md' # Watch and open all .md files recursively -$ markview --watch 'docs/**/*.md' --target docs # Watch docs/ tree in "docs" group -$ markview --watch '*.md' --watch 'docs/**/*.md' # Multiple patterns +```console +$ markview --watch '**/*.md' # 递归监听并打开所有 .md +$ markview --watch 'docs/**/*.md' --target docs # 监听 docs 目录并放入 docs 分组 +$ markview --watch '*.md' --watch 'docs/**/*.md' # 同时注册多个模式 ``` -`--watch` cannot be combined with file arguments. The `**` pattern matches directories recursively. +`--watch` 不能与文件参数同时使用。`**` 表示递归目录匹配。 -#### Removing watch patterns +#### 移除监听模式 -Use `--unwatch` to stop watching a previously registered pattern. Files already added remain in the sidebar. +使用 `--unwatch` 取消已注册模式。已添加进侧边栏的文件不会被自动移除。 -``` console -$ markview --unwatch '**/*.md' # Stop watching a pattern (default group) -$ markview --unwatch 'docs/**/*.md' --target docs # Stop watching in a specific group -$ markview --unwatch '/Users/you/project/**/*.md' # Stop watching by absolute path +```console +$ markview --unwatch '**/*.md' # 取消默认分组中的模式 +$ markview --unwatch 'docs/**/*.md' --target docs # 取消 docs 分组中的模式 +$ markview --unwatch '/Users/you/project/**/*.md' # 按绝对路径取消 ``` -Patterns are resolved to absolute paths before matching, so you can specify either a relative glob or the full path shown by `--status`. +模式会先解析为绝对路径再匹配,因此你可以使用相对路径,也可以使用 `--status` 输出中的绝对路径。 -### Sidebar view modes +### 侧边栏视图模式 -The sidebar supports flat and tree view modes. Flat view shows file names only, while tree view displays the directory hierarchy. +侧边栏支持平铺和树形两种视图: -| Flat | Tree | +| 平铺 | 树形 | | ------------------------------------------------------- | ------------------------------------------------------- | -| ![Flat view](images/sidebar-flat.png) | ![Tree view](images/sidebar-tree.png) | +| ![平铺视图](images/sidebar-flat.png) | ![树形视图](images/sidebar-tree.png) | -### Starting and stopping +### 启动与停止 -`markview` runs in the background by default — the command returns immediately, leaving the shell free for other work. This makes it easy to incorporate into scripts, tool chains, or LLM-driven workflows. +`markview` 默认后台运行,命令会立即返回,不阻塞当前终端。 -``` console +```console $ markview README.md markview: serving at http://localhost:6275 (pid 12345) -$ # shell is available immediately +$ # 终端可继续使用 ``` -Use `--status` to check all running markview servers, and `--shutdown` to stop one: +使用 `--status` 查看服务状态,使用 `--shutdown` 停止服务: -``` console -$ markview --status # Show all running markview servers +```console +$ markview --status # 查看所有 markview 服务 http://localhost:6275 (pid 12345, v0.12.0) default: 5 file(s) watching: /Users/you/project/src/**/*.md, /Users/you/project/*.md docs: 2 file(s) watching: /Users/you/project/docs/**/*.md -$ markview --shutdown # Shut down the markview server on the default port -$ markview --shutdown -p 6276 # Shut down the markview server on a specific port -$ markview --restart # Restart the markview server on the default port +$ markview --shutdown # 关闭默认端口服务 +$ markview --shutdown -p 6276 # 关闭指定端口服务 +$ markview --restart # 重启默认端口服务 ``` -If you need the markview server to run in the foreground (e.g. for debugging), use `--foreground`: +如果需要前台运行(例如调试时),可使用 `--foreground`: -``` console +```console $ markview --foreground README.md ``` -### Server restart +### 服务重启 + +你可以点击页面右下角的 重启 重启按钮,或执行 `markview --restart`。 -Click the restart restart button (bottom-right corner) or run `markview --restart` to restart the `markview` server process. The current session — all open files and groups — is preserved across the restart. This is useful when you have updated the `markview` binary and want to pick up the new version without re-opening your files. +重启后会保留当前会话(分组与文件列表),适合在升级二进制后无缝切换到新版本。 -### Session backup and restore +### 会话备份与恢复 -`markview` automatically saves session state (open files and watch patterns per group) when files are added or removed. When starting a new server, the previous session is automatically restored and merged with any files specified on the command line. Restored session entries appear first, followed by newly specified files. +`markview` 会在文件或模式发生变更时自动保存会话状态(按分组保存文件与监听模式)。 -``` console -$ markview README.md CHANGELOG.md # Start with two files -$ markview --shutdown # Shut down the server -$ markview # Restores README.md and CHANGELOG.md -$ markview TODO.md # Restores previous session + adds TODO.md +当服务再次启动时,会自动恢复上次会话,并与本次命令行参数合并(恢复项优先,新参数追加,去重处理)。 + +```console +$ markview README.md CHANGELOG.md # 启动并打开两个文件 +$ markview --shutdown # 关闭服务 +$ markview # 恢复 README.md 与 CHANGELOG.md +$ markview TODO.md # 恢复会话并追加 TODO.md ``` -Use `--clear` to remove a saved session: +使用 `--clear` 可以清理某个端口的会话备份: -``` console -$ markview --clear # Clear saved session for the default port -$ markview --clear -p 6276 # Clear saved session for a specific port +```console +$ markview --clear # 清理默认端口备份 +$ markview --clear -p 6276 # 清理指定端口备份 ``` -### JSON output +### JSON 输出 -Use `--json` to get structured JSON output on stdout, useful for scripting and integration with other tools. +使用 `--json` 可以将结果输出为结构化 JSON,便于脚本或自动化工具调用。 -``` console +```console $ markview --json README.md { "url": "http://localhost:6275", @@ -186,9 +192,9 @@ $ markview --json README.md } ``` -`--status` also supports `--json`: +`--status` 也支持 `--json`: -``` console +```console $ markview --status --json [ { @@ -208,42 +214,57 @@ $ markview --status --json ] ``` -### Flags - -| Flag | Short | Default | Description | -| -------------- | ----- | --------- | ----------------------------------------------------- | -| `--target` | `-t` | `default` | Group name | -| `--port` | `-p` | `6275` | Server port | -| `--bind` | `-b` | `0.0.0.0` | Bind address (e.g. `localhost`) | -| `--open` | | | Always open browser | -| `--no-open` | | | Never open browser | -| `--status` | | | Show all running markview servers | -| `--watch` | `-w` | | Glob pattern to watch for matching files (repeatable) | -| `--unwatch` | | | Remove a watched glob pattern (repeatable) | -| `--shutdown` | | | Shut down the running markview server | -| `--restart` | | | Restart the running markview server | -| `--clear` | | | Clear saved session for the specified port | -| `--foreground` | | | Run markview server in foreground | -| `--json` | | | Output structured data as JSON to stdout | +### 参数说明 + +| 参数 | 简写 | 默认值 | 说明 | +| -------------- | ---- | --------- | -------------------------- | +| `--target` | `-t` | `default` | 分组名称 | +| `--port` | `-p` | `6275` | 服务端口 | +| `--bind` | `-b` | `0.0.0.0` | 绑定地址(如 `localhost`) | +| `--open` | | | 总是打开浏览器 | +| `--no-open` | | | 不自动打开浏览器 | +| `--status` | | | 查看运行中的 markview 服务 | +| `--watch` | `-w` | | 监听通配模式(可重复) | +| `--unwatch` | | | 移除已监听的模式(可重复) | +| `--shutdown` | | | 关闭运行中的 markview 服务 | +| `--restart` | | | 重启运行中的 markview 服务 | +| `--clear` | | | 清理指定端口的会话备份 | +| `--foreground` | | | 前台运行服务 | +| `--json` | | | 以 JSON 输出结构化结果 | > [!WARNING] -> Binding to a non-loopback address exposes markview to the network **without any authentication**. Remote clients can read any file accessible by the user, browse the filesystem via glob patterns, and shut down the server. A confirmation prompt is shown when `--bind` is set to a non-loopback address. +> 当 `--bind` 设置为非回环地址时,markview 会暴露到网络,且默认没有鉴权机制。远程客户端可能读取当前用户可访问文件、浏览通配目录并控制服务启停。该场景仅建议用于可信网络。 -## Build +## 构建 -Requires Go and [pnpm](https://pnpm.io/). +需要 Go 与 [pnpm](https://pnpm.io/) 环境。 -``` console +```console $ make build ``` -## References +## 中文文档 + +为了便于本地阅读和二次开发,仓库提供了以下中文文档: + +- [设计文档](docs/design.md) +- [架构文档](docs/architecture.md) +- [全局搜索功能说明](docs/global-search.md) + +### 中文速览 + +- 架构形态:Go 后端 + React 前端,最终以单二进制分发 +- 运行机制:CLI 单实例复用 + HTTP API + SSE 实时刷新 +- 状态策略:服务端状态中心(groups/files/patterns)+ XDG 会话备份恢复 +- 工程化:Makefile、CI(lint/test/coverage)、GoReleaser 多平台发布 + +## 参考项目 -- [yusukebe/gh-markdown-preview](https://github.com/yusukebe/gh-markdown-preview): GitHub CLI extension to preview Markdown looks like GitHub. +- [yusukebe/gh-markdown-preview](https://github.com/yusukebe/gh-markdown-preview):在 GitHub 风格下预览 Markdown 的 GitHub CLI 扩展。 -## License +## 许可证 - [MIT License](LICENSE) - - Include logo as well as source code. - - Only logo license can be selected [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). - - Also, if there is no alteration to the logo and it is used for technical information about markview, I would not say anything if the copyright notice is omitted. + - 包含源码与 logo 在内的项目整体以 MIT 许可发布。 + - logo 也可单独按 [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) 使用。 + - 在不修改 logo 且用于 markview 技术说明场景时,通常可不强制要求额外版权标注。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..27930d0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,136 @@ +# markview 架构文档 + +## 1. 架构总览 + +`markview` 采用 **Go 后端 + React 前端 + 单二进制分发** 的架构: + +```mermaid +flowchart LR + CLI[命令行入口 / cmd/root.go] -->|启动/复用| HTTP[Go HTTP 服务] + HTTP --> STATE[状态中心\n(groups/files/patterns)] + HTTP --> WATCH[fsnotify] + WATCH --> SSE[SSE /_/events] + SSE --> SPA[React SPA] + SPA --> API[/_/api/*] + API --> STATE + STATE --> BACKUP[会话备份 JSON\nXDG_STATE_HOME] + SPA --> STATIC[前端静态资源] + STATIC --> HTTP +``` + +核心特征: + +- **单实例复用**:同端口优先复用已有进程。 +- **服务端状态中心**:分组、文件、watch 模式统一由后端维护。 +- **前后端松耦合**:通过 HTTP 接口 + SSE 通信。 +- **内嵌前端静态资源**:最终交付为单可执行文件。 + +## 2. 目录与模块职责 + +| 模块 | 路径 | 职责 | +| --------------- | -------------------------------------------- | ------------------------------------------ | +| CLI 入口 | `cmd/root.go` | 参数解析、单实例探测、前后台启动、状态命令 | +| 服务状态与路由 | `internal/server/server.go` | HTTP API、SSE、文件监听、状态管理 | +| 链接/大纲图构建 | `internal/server/graph.go` | 从 Markdown 内容提取关系并输出图数据 | +| 分组名规范 | `internal/server/group.go` | 分组名归一化与安全校验 | +| 备份存储 | `internal/backup/backup.go` | 会话快照读写(原子写) | +| 静态资源嵌入 | `internal/static/static.go` | 触发前端构建并 `go:embed` 嵌入 | +| 前端主流程 | `frontend/src/App.tsx` | 路由状态、分组/文件选择、SSE 刷新 | +| 前端渲染核心 | `frontend/src/components/MarkdownViewer.tsx` | Markdown 渲染与图表增强 | +| API 封装 | `frontend/src/hooks/useApi.ts` | 前端到后端接口调用 | +| SSE 订阅 | `frontend/src/hooks/useSSE.ts` | 实时事件与断线重连 | + +## 3. 关键运行时序 + +## 3.1 启动与复用 + +```mermaid +sequenceDiagram + participant U as 用户 + participant C as 命令行 + participant S as 已运行服务 + + U->>C: markview README.md + C->>S: GET /_/api/status + alt Server exists + C->>S: POST /_/api/files + C-->>U: 输出 URL 并退出 + else Server not found + C->>C: 启动新服务进程 + C-->>U: 输出 URL + end +``` + +## 3.2 文件变更到页面刷新 + +```mermaid +sequenceDiagram + participant FS as 文件系统 + participant W as fsnotify + participant ST as 状态中心 + participant FE as 前端(SSE) + + FS->>W: 文件写入/重命名 + W->>ST: 变更事件 + ST->>FE: SSE(file-changed) + FE->>FE: 拉取 /_/api/files/{id}/content + FE-->>FE: 重新渲染 Markdown +``` + +## 4. 数据与状态模型 + +## 4.1 服务端状态 + +- `groups`: 当前分组及文件列表 +- `patterns`: 当前 watch 的 glob 模式集合 +- `watchedDirs`: 被监听目录的引用计数 +- `subscribers`: SSE 订阅者集合 + +通过 `RWMutex` 保护并发读写。 + +## 4.2 持久化模型 + +`RestoreData` 结构包含: + +- `Groups`: 分组到文件路径列表 +- `Patterns`: 分组到 watch 模式列表 +- `UploadedFiles`: 内存上传文件快照 + +状态在变更后触发去抖保存;启动时读取并合并 CLI 参数。 + +## 5. API 边界 + +内部 API 统一使用 `/_/api/*` 前缀,避免与用户分组路由冲突: + +- 文件管理:`POST /_/api/files`、`DELETE /_/api/files/{id}` +- 内容读取:`GET /_/api/files/{id}/content` +- 分组与排序:`PUT /_/api/files/{id}/group`、`PUT /_/api/reorder` +- 模式管理:`POST/DELETE /_/api/patterns` +- 运行控制:`POST /_/api/restart`、`POST /_/api/shutdown` +- 状态检查:`GET /_/api/status`、`GET /_/api/version` +- 实时事件:`GET /_/events` + +## 6. 构建与发布架构 + +## 6.1 本地构建 + +- `go generate ./internal/static/`:构建前端产物到 `internal/static/dist` +- `go build`:打包 Go 服务 + 内嵌静态资源 + +## 6.2 CI/CD + +- CI:前端 lint/format、Go lint、测试覆盖率 +- Release:`tagpr` 管理版本,`goreleaser` 产出多平台二进制 +- License:Trivy 做许可证扫描 + +## 7. 安全与边界 + +1. 默认本地网络使用;非回环地址绑定需显式确认。 +2. 内部 API 无认证,面向本机可信环境。 +3. 文件原始资源访问路径带目录边界校验,防止目录穿越。 + +## 8. 可扩展建议 + +1. 鉴权与访问控制:为远程协作模式增加 token/ACL。 +2. 文档规模扩展:搜索与解析任务可进一步 Worker 化。 +3. 模块拆分:继续降低 `MarkdownViewer` 复杂度,提升前端可维护性。 diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..8598bd0 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,124 @@ +# markview 设计文档 + +## 1. 背景与目标 + +`markview` 是一个本地运行的 Markdown 工作台,目标是把「编辑器中的多个 Markdown 文件」快速投射到浏览器中,以更高可读性和更强交互能力进行阅读、导航和组织。 + +核心目标: + +1. **零心智负担启动**:一条命令即可打开预览。 +2. **多文件协同阅读**:支持分组、重排、树视图、全文搜索、链接跳转。 +3. **实时反馈**:文件保存后自动刷新内容。 +4. **会话可恢复**:关闭后可恢复上次状态。 + +## 2. 设计原则 + +1. **本地优先** + - 服务默认运行在本机端口,数据不依赖云端存储。 +2. **单实例优先** + - 同端口已有服务时复用现有服务,而非重复拉起进程。 +3. **渐进增强** + - 基础 Markdown 浏览可用;图表、搜索、图谱等能力按需增强。 +4. **状态可追溯** + - 重要状态持久化,重启与故障恢复后可继续工作。 + +## 3. 用户与使用场景 + +### 3.1 目标用户 + +- 文档驱动开发者(README、ADR、设计文档维护) +- 技术写作者(教程、知识库、研究笔记) +- AI 辅助编码工作流用户(需要稳定的本地文档视图) + +### 3.2 典型场景 + +1. 打开多个文档并分组查看(例如 `design` / `notes`) +2. 监听目录内新增 Markdown 文件并自动加入 +3. 在文档中点击相对链接自动打开目标文件 +4. 关闭服务后再次启动,恢复上次文件与分组 + +## 4. 功能设计 + +## 4.1 文件与分组模型 + +- 文件以 `FileEntry` 表示,核心字段:`id`、`name`、`path`。 +- 分组以 `Group` 表示,映射到 URL 路径(如 `/design`)。 +- 默认分组名为 `default`。 + +文件 ID 设计: + +- 常规文件:基于绝对路径的 SHA-256 前 8 位,稳定且可深链。 +- 上传文件:基于内容哈希生成上传 ID(`uxxxxxxx`)。 + +## 4.2 单实例复用策略 + +CLI 启动时先探测 `/_/api/status`: + +- 若服务存在:通过 API 将文件或模式加入现有会话。 +- 若服务不存在:启动新服务并打开浏览器。 + +这可避免同端口多进程竞争,并让命令行调用天然支持“追加文件”。 + +## 4.3 实时刷新设计 + +- 后端使用 `fsnotify` 监听文件变化。 +- 前端通过 SSE (`/_/events`) 订阅事件: + - `update`:文件集或分组变化 + - `file-changed`:文件内容变化 + - `started`:服务进程身份(PID) + +当进程 PID 变化时,前端自动刷新页面,保证重启后的会话一致性。 + +## 4.4 会话持久化设计 + +- 存储位置:`$XDG_STATE_HOME/markview/backup/markview-.json` +- 保存内容:分组、文件路径、watch 模式、上传文件内容 +- 写入策略:原子写入(临时文件 + rename) +- 触发策略:状态变更后去抖保存 + +## 4.5 前端交互设计 + +主要交互能力: + +- 侧边栏平铺/树形视图切换 +- 分组切换与文件拖拽重排 +- 目录树折叠状态记忆 +- ToC 面板、Raw 视图、宽窄阅读模式 +- 全局全文搜索与命中跳转 +- 图谱与大纲视图(链接关系可视化) + +持久化策略: + +- 前端 UI 偏好写入 `localStorage`(统一 `markview-` 前缀) + +## 5. 非功能设计 + +## 5.1 性能 + +- 单实例减少资源浪费 +- 前端按需加载/分包(Shiki/Mermaid/D3/PDF 等) +- 搜索与图谱构建以当前打开文件范围为边界 + +## 5.2 可维护性 + +- Go 后端与 React 前端边界清晰 +- API 路由统一 `/_/api/` 前缀,避免与 SPA 路径冲突 +- 测试覆盖 Go 单测与前端 Vitest + +## 5.3 安全 + +- 默认本地使用;非回环地址绑定会给出风险确认 +- 对路径访问进行基本约束(如原始文件访问防目录穿越) + +## 6. 已知边界 + +1. 上传文件为内存态,不支持文件系统 live-reload。 +2. 监听与搜索范围是“已加入会话的文件集”,不是全盘索引。 +3. 远程可访问模式(非 loopback 绑定)需用户明确确认,适合可信网络。 + +## 7. 后续演进方向 + +1. 搜索键盘导航和防抖优化 +2. 大文件/大规模文档场景下的 Worker 化搜索 +3. `MarkdownViewer` 继续拆分,降低单文件复杂度 +4. 远程访问模式的鉴权与更细粒度权限控制 From 2ea308444f0927068650b20904715a1870493940 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 11:14:27 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=B8=AD=E7=9A=84=E6=9C=AF=E8=AF=AD=E4=B8=8E=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=EF=BC=8C=E6=8F=90=E5=8D=87=E5=8F=AF=E8=AF=BB=E6=80=A7?= =?UTF-8?q?=E4=B8=8E=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- README.md | 20 ++++++++++++++++---- docs/architecture.md | 36 ++++++++++++++++++------------------ docs/design.md | 16 ++++++++-------- docs/global-search.md | 40 ++++++++++++++++++++-------------------- 4 files changed, 62 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 1041a72..a7b9754 100644 --- a/README.md +++ b/README.md @@ -22,15 +22,15 @@ - 分组 文件分组管理 - 目录 文档目录面板 - 平铺 平铺 / 树形 树形侧边栏(支持拖拽排序与文件搜索) -- YAML Frontmatter 展示(可折叠元数据区域) +- YAML 前置元数据展示(可折叠元数据区域) - MDX 支持(渲染 Markdown,去除 `import`/`export`,转义 JSX 标签) - 宽布局 宽版 / 窄布局 窄版阅读宽度切换 - 原文 原始 Markdown 视图 - 复制 内容复制(Markdown / 文本 / HTML) - 重启 服务重启并保留会话 - 自动会话备份与恢复 -- 支持从操作系统文件管理器拖拽添加文件(内容以内存形式加载,拖拽文件不支持 live-reload) -- 对通过 CLI 打开的文件支持保存后实时刷新 +- 支持从操作系统文件管理器拖拽添加文件(内容以内存形式加载,拖拽文件不支持实时刷新) +- 对通过命令行打开的文件支持保存后实时刷新 ## 安装 @@ -254,10 +254,22 @@ $ make build ### 中文速览 - 架构形态:Go 后端 + React 前端,最终以单二进制分发 -- 运行机制:CLI 单实例复用 + HTTP API + SSE 实时刷新 +- 运行机制:命令行单实例复用 + HTTP 接口 + 服务端事件流实时刷新 - 状态策略:服务端状态中心(groups/files/patterns)+ XDG 会话备份恢复 - 工程化:Makefile、CI(lint/test/coverage)、GoReleaser 多平台发布 +## 术语说明 + +| 中文称呼 | 代码中的常见写法 | 说明 | +| ------------ | ---------------- | --------------------------------- | +| 命令行入口 | `CLI` | 指命令行程序入口与参数处理逻辑 | +| 接口 | `API` | 指前后端通过 HTTP 调用的内部接口 | +| 服务端事件流 | `SSE` | 用于将服务端变更实时推送到前端 | +| 监听模式 | `watch pattern` | 通过 `--watch` 注册的通配路径规则 | +| 前置元数据 | `Frontmatter` | Markdown 文件顶部的 YAML 元数据块 | +| 目录面板 | `ToC` | 文档标题目录导航面板 | +| 原文视图 | `Raw` | 直接查看 Markdown 原始文本 | + ## 参考项目 - [yusukebe/gh-markdown-preview](https://github.com/yusukebe/gh-markdown-preview):在 GitHub 风格下预览 Markdown 的 GitHub CLI 扩展。 diff --git a/docs/architecture.md b/docs/architecture.md index 27930d0..93164f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,11 +6,11 @@ ```mermaid flowchart LR - CLI[命令行入口 / cmd/root.go] -->|启动/复用| HTTP[Go HTTP 服务] + CLI[命令行入口 / cmd/root.go] -->|启动/复用| HTTP[Go 网络服务] HTTP --> STATE[状态中心\n(groups/files/patterns)] HTTP --> WATCH[fsnotify] - WATCH --> SSE[SSE /_/events] - SSE --> SPA[React SPA] + WATCH --> SSE[服务端事件流 /_/events] + SSE --> SPA[React 前端界面] SPA --> API[/_/api/*] API --> STATE STATE --> BACKUP[会话备份 JSON\nXDG_STATE_HOME] @@ -22,23 +22,23 @@ flowchart LR - **单实例复用**:同端口优先复用已有进程。 - **服务端状态中心**:分组、文件、watch 模式统一由后端维护。 -- **前后端松耦合**:通过 HTTP 接口 + SSE 通信。 +- **前后端松耦合**:通过 HTTP 接口 + 服务端事件流通信。 - **内嵌前端静态资源**:最终交付为单可执行文件。 ## 2. 目录与模块职责 | 模块 | 路径 | 职责 | | --------------- | -------------------------------------------- | ------------------------------------------ | -| CLI 入口 | `cmd/root.go` | 参数解析、单实例探测、前后台启动、状态命令 | -| 服务状态与路由 | `internal/server/server.go` | HTTP API、SSE、文件监听、状态管理 | +| 命令行入口 | `cmd/root.go` | 参数解析、单实例探测、前后台启动、状态命令 | +| 服务状态与路由 | `internal/server/server.go` | HTTP 接口、事件流、文件监听、状态管理 | | 链接/大纲图构建 | `internal/server/graph.go` | 从 Markdown 内容提取关系并输出图数据 | | 分组名规范 | `internal/server/group.go` | 分组名归一化与安全校验 | | 备份存储 | `internal/backup/backup.go` | 会话快照读写(原子写) | | 静态资源嵌入 | `internal/static/static.go` | 触发前端构建并 `go:embed` 嵌入 | | 前端主流程 | `frontend/src/App.tsx` | 路由状态、分组/文件选择、SSE 刷新 | | 前端渲染核心 | `frontend/src/components/MarkdownViewer.tsx` | Markdown 渲染与图表增强 | -| API 封装 | `frontend/src/hooks/useApi.ts` | 前端到后端接口调用 | -| SSE 订阅 | `frontend/src/hooks/useSSE.ts` | 实时事件与断线重连 | +| 接口封装 | `frontend/src/hooks/useApi.ts` | 前端到后端接口调用 | +| 事件流订阅 | `frontend/src/hooks/useSSE.ts` | 实时事件与断线重连 | ## 3. 关键运行时序 @@ -52,10 +52,10 @@ sequenceDiagram U->>C: markview README.md C->>S: GET /_/api/status - alt Server exists + alt 服务已存在 C->>S: POST /_/api/files C-->>U: 输出 URL 并退出 - else Server not found + else 服务未找到 C->>C: 启动新服务进程 C-->>U: 输出 URL end @@ -68,11 +68,11 @@ sequenceDiagram participant FS as 文件系统 participant W as fsnotify participant ST as 状态中心 - participant FE as 前端(SSE) + participant FE as 前端(服务端事件流) FS->>W: 文件写入/重命名 W->>ST: 变更事件 - ST->>FE: SSE(file-changed) + ST->>FE: 事件流推送(file-changed) FE->>FE: 拉取 /_/api/files/{id}/content FE-->>FE: 重新渲染 Markdown ``` @@ -82,7 +82,7 @@ sequenceDiagram ## 4.1 服务端状态 - `groups`: 当前分组及文件列表 -- `patterns`: 当前 watch 的 glob 模式集合 +- `patterns`: 当前监听通配模式集合 - `watchedDirs`: 被监听目录的引用计数 - `subscribers`: SSE 订阅者集合 @@ -100,7 +100,7 @@ sequenceDiagram ## 5. API 边界 -内部 API 统一使用 `/_/api/*` 前缀,避免与用户分组路由冲突: +内部接口统一使用 `/_/api/*` 前缀,避免与用户分组路由冲突: - 文件管理:`POST /_/api/files`、`DELETE /_/api/files/{id}` - 内容读取:`GET /_/api/files/{id}/content` @@ -117,11 +117,11 @@ sequenceDiagram - `go generate ./internal/static/`:构建前端产物到 `internal/static/dist` - `go build`:打包 Go 服务 + 内嵌静态资源 -## 6.2 CI/CD +## 6.2 持续集成与发布 -- CI:前端 lint/format、Go lint、测试覆盖率 -- Release:`tagpr` 管理版本,`goreleaser` 产出多平台二进制 -- License:Trivy 做许可证扫描 +- 持续集成:前端 lint/format、Go lint、测试覆盖率 +- 发布:`tagpr` 管理版本,`goreleaser` 产出多平台二进制 +- 许可证检查:Trivy 扫描许可证风险 ## 7. 安全与边界 diff --git a/docs/design.md b/docs/design.md index 8598bd0..cffe7e0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -52,9 +52,9 @@ ## 4.2 单实例复用策略 -CLI 启动时先探测 `/_/api/status`: +命令行启动时先探测 `/_/api/status`: -- 若服务存在:通过 API 将文件或模式加入现有会话。 +- 若服务存在:通过接口将文件或模式加入现有会话。 - 若服务不存在:启动新服务并打开浏览器。 这可避免同端口多进程竞争,并让命令行调用天然支持“追加文件”。 @@ -62,7 +62,7 @@ CLI 启动时先探测 `/_/api/status`: ## 4.3 实时刷新设计 - 后端使用 `fsnotify` 监听文件变化。 -- 前端通过 SSE (`/_/events`) 订阅事件: +- 前端通过服务端事件流 (`/_/events`) 订阅事件: - `update`:文件集或分组变化 - `file-changed`:文件内容变化 - `started`:服务进程身份(PID) @@ -72,7 +72,7 @@ CLI 启动时先探测 `/_/api/status`: ## 4.4 会话持久化设计 - 存储位置:`$XDG_STATE_HOME/markview/backup/markview-.json` -- 保存内容:分组、文件路径、watch 模式、上传文件内容 +- 保存内容:分组、文件路径、监听模式、上传文件内容 - 写入策略:原子写入(临时文件 + rename) - 触发策略:状态变更后去抖保存 @@ -83,7 +83,7 @@ CLI 启动时先探测 `/_/api/status`: - 侧边栏平铺/树形视图切换 - 分组切换与文件拖拽重排 - 目录树折叠状态记忆 -- ToC 面板、Raw 视图、宽窄阅读模式 +- 目录面板(ToC)、原文视图(Raw)、宽窄阅读模式 - 全局全文搜索与命中跳转 - 图谱与大纲视图(链接关系可视化) @@ -112,13 +112,13 @@ CLI 启动时先探测 `/_/api/status`: ## 6. 已知边界 -1. 上传文件为内存态,不支持文件系统 live-reload。 -2. 监听与搜索范围是“已加入会话的文件集”,不是全盘索引。 +1. 上传文件为内存态,不支持文件系统实时刷新。 +2. 监听与搜索范围是“已加入会话的文件集合”,不是全盘索引。 3. 远程可访问模式(非 loopback 绑定)需用户明确确认,适合可信网络。 ## 7. 后续演进方向 1. 搜索键盘导航和防抖优化 -2. 大文件/大规模文档场景下的 Worker 化搜索 +2. 大文件/大规模文档场景下的工作线程(Worker)化搜索 3. `MarkdownViewer` 继续拆分,降低单文件复杂度 4. 远程访问模式的鉴权与更细粒度权限控制 diff --git a/docs/global-search.md b/docs/global-search.md index b570413..c38f419 100644 --- a/docs/global-search.md +++ b/docs/global-search.md @@ -15,7 +15,7 @@ ## 搜索行为 -- 搜索范围:所有分组(group)下的所有文件内容 +- 搜索范围:所有分组下的所有文件内容 - 匹配方式:大小写不敏感的子串匹配 - 结果上限:默认最多 200 条命中 - 结果信息:文件名、所属分组、行号、命中片段(关键词高亮) @@ -24,23 +24,23 @@ ```mermaid sequenceDiagram - participant User - participant App - participant GlobalSearchModal - participant fullTextSearch - participant API - - User->>App: ⌘+Shift+F - App->>GlobalSearchModal: isOpen=true - GlobalSearchModal->>API: fetchFileContent (并发拉取所有文件) - API-->>GlobalSearchModal: 文件内容 (带缓存) - User->>GlobalSearchModal: 输入关键词 - GlobalSearchModal->>fullTextSearch: searchInFiles(files, query) - fullTextSearch-->>GlobalSearchModal: FullTextSearchHit[] - GlobalSearchModal-->>User: 展示匹配结果列表 - User->>GlobalSearchModal: 点击某条结果 - GlobalSearchModal->>App: onSelect(groupName, fileId) - App->>App: 切换分组 + 激活文件 + participant 用户 + participant 应用 + participant 搜索弹窗 + participant 检索模块 + participant 接口层 + + 用户->>应用: ⌘+Shift+F + 应用->>搜索弹窗: isOpen=true + 搜索弹窗->>接口层: fetchFileContent(并发拉取所有文件) + 接口层-->>搜索弹窗: 文件内容(带缓存) + 用户->>搜索弹窗: 输入关键词 + 搜索弹窗->>检索模块: searchInFiles(files, query) + 检索模块-->>搜索弹窗: FullTextSearchHit[] + 搜索弹窗-->>用户: 展示匹配结果列表 + 用户->>搜索弹窗: 点击某条结果 + 搜索弹窗->>应用: onSelect(groupName, fileId) + 应用->>应用: 切换分组 + 激活文件 ``` ## 涉及文件 @@ -57,13 +57,13 @@ sequenceDiagram ### 交互体验 1. **键盘导航** — 上下方向键选择结果、Enter 打开选中项,减少鼠标依赖 -2. **防抖输入** — 文件较多时加 ~150ms debounce,避免每次按键都触发搜索 +2. **防抖输入** — 文件较多时增加约 150ms 输入防抖,避免每次按键都触发搜索 3. **每行多处匹配** — 当前每行只取第一个命中,长行中后续匹配会被遗漏 ### 性能 4. **增量缓存** — 当前每次打开弹窗都重新拉取所有文件内容;可改为只拉新增/变更文件(结合 SSE `file-changed` 事件清除对应缓存) -5. **Web Worker 搜索** — 文件数量很大时搜索会阻塞主线程,可移至 Worker 执行 +5. **工作线程搜索** — 文件数量很大时搜索会阻塞主线程,可移至 Worker 执行 ### 功能增强 From d4cbf288b1ae13570f9766848e123369518c3823 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 11:41:31 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Markdown=20?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=B8=85=E5=8D=95=E6=96=87=E6=A1=A3=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20MarkdownViewer=20=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E4=BB=A5=E5=A4=84=E7=90=86=E9=93=BE=E6=8E=A5=E6=89=93=E5=BC=80?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- README.md | 1 + docs/markdown-capabilities.md | 105 +++++++++++++++++ .../src/components/MarkdownViewer.test.tsx | 107 ++++++++++++++++++ frontend/src/components/MarkdownViewer.tsx | 28 ++++- frontend/src/components/MermaidBlock.test.tsx | 6 +- .../src/components/PlantUmlBlock.test.tsx | 1 + frontend/src/components/SvgBobBlock.test.tsx | 1 + 7 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 docs/markdown-capabilities.md create mode 100644 frontend/src/components/MarkdownViewer.test.tsx diff --git a/README.md b/README.md index a7b9754..a8773bd 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ $ make build - [设计文档](docs/design.md) - [架构文档](docs/architecture.md) +- [Markdown 能力清单](docs/markdown-capabilities.md) - [全局搜索功能说明](docs/global-search.md) ### 中文速览 diff --git a/docs/markdown-capabilities.md b/docs/markdown-capabilities.md new file mode 100644 index 0000000..52380a4 --- /dev/null +++ b/docs/markdown-capabilities.md @@ -0,0 +1,105 @@ +# Markdown 功能能力清单 + +本文档用于盘点 `markview` 当前已实现的 Markdown 相关能力,便于后续增强规划与回归验证。 + +## 1. 渲染能力 + +当前渲染主链路位于 `frontend/src/components/MarkdownViewer.tsx`。 + +### 1.1 基础与扩展语法 + +- GFM(表格、任务列表、删除线等) +- 数学公式(KaTeX) +- 换行增强 +- Emoji 短代码 +- 原始 HTML 渲染 +- 标题锚点自动生成 +- GitHub Alerts 风格提示块 + +### 1.2 代码与图表块 + +- 代码高亮(Shiki,语言不支持时降级到纯文本) +- Mermaid 渲染 + - 支持主题/布局参数 + - 支持全屏、缩放、平移、重置 + - 支持复制图像(PNG) +- PlantUML 渲染(通过 Kroki 服务) + - 支持明暗主题预设注入 + - 支持全屏、缩放、平移 +- SVG Bob 渲染(`bob-wasm`) + - 支持主题适配 + - 支持全屏、缩放、平移 + +### 1.3 元数据与 MDX 兼容 + +- YAML 前置元数据(Frontmatter)解析与可折叠展示 +- `.mdx` 兼容阅读模式: + - 去除 `import` / `export` + - 转义 JSX 标签,避免被当作组件执行 + +### 1.4 自定义 Markdown 预处理 + +来自 `frontend/src/utils/markdownEnhance.ts`: + +- Wiki Link(`[[...]]`)转换为标准链接 +- `!!! note/warning/...` 提示块语法转换为 GitHub Alerts + +## 2. 链接与资源能力 + +- 外链(`http` / `mailto` / `tel`)支持 +- 站内锚点(`#...`)支持 +- `.md` / `.mdx` 相对链接:点击后自动打开目标文件并加入会话 +- 相对链接打开失败时提供可见错误提示(避免静默失败) +- 非 Markdown 资源:通过后端 `raw` 接口读取 +- 图片相对路径自动改写为后端可访问地址 +- 服务端对 `raw` 路径做目录边界限制,防止越界访问 + +## 3. 导航与阅读辅助 + +- ToC 面板(按 H1-H6 标题导航) +- 当前标题高亮与平滑滚动定位 +- 原文视图(Raw)与渲染视图切换 +- 全局全文搜索后可跳转到目标文档并定位命中内容 + +## 4. 结构化关系能力 + +来自 `internal/server/graph.go`: + +- 文件间链接图(Graph) +- 按 H1/H2 抽取的大纲结构图(Outline) +- 反向链接(Backlinks)面板 + +## 5. 导出与复制 + +- 单文档 PDF 导出 +- 分组合并 PDF 导出 +- 复制内容(Markdown / 文本 / HTML) +- 代码块复制 +- Mermaid 图像复制 + +## 6. 当前边界 + +1. 拖拽上传文件为内存态,不支持文件系统实时刷新。 +2. PlantUML 依赖外部渲染服务(Kroki),离线场景受限。 +3. `.mdx` 为兼容阅读模式,不执行组件逻辑。 +4. 图谱能力当前聚焦 Markdown 链接关系,不是全格式知识图谱。 + +## 7. 后续增强建议(建议优先级) + +### P0(优先) + +1. 图表渲染失败时提供明确提示与回退说明 +2. 前置元数据支持更多格式(如 `...` 结束符、BOM 容错) +3. 相对链接失败时增加可见反馈(而不仅静默失败) + +### P1(建议) + +1. 图片懒加载与错误占位样式增强 +2. 图表导出能力统一(Mermaid/PlantUML/SVG Bob) +3. 搜索结果键盘导航与高亮体验提升 + +### P2(可选) + +1. 增加可配置 Markdown 扩展开关 +2. 增加本地 PlantUML 渲染兜底方案 +3. 增强图谱维度(标签、引用类型、跨分组统计) diff --git a/frontend/src/components/MarkdownViewer.test.tsx b/frontend/src/components/MarkdownViewer.test.tsx new file mode 100644 index 0000000..7360287 --- /dev/null +++ b/frontend/src/components/MarkdownViewer.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MarkdownViewer } from "./MarkdownViewer"; +import { fetchFileContent, openRelativeFile } from "../hooks/useApi"; + +vi.mock("../hooks/useApi", () => ({ + fetchFileContent: vi.fn(), + openRelativeFile: vi.fn(), +})); + +vi.mock("./TocToggle", () => ({ + TocToggle: () => null, +})); + +vi.mock("./RawToggle", () => ({ + RawToggle: () => null, +})); + +vi.mock("./CopyButton", () => ({ + CopyButton: () => null, +})); + +vi.mock("./PdfExportButton", () => ({ + PdfExportButton: () => null, +})); + +vi.mock("./RemoveButton", () => ({ + RemoveButton: () => null, +})); + +vi.mock("./BacklinksPanel", () => ({ + BacklinksPanel: () => null, +})); + +describe("MarkdownViewer link opening", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows visible error when opening relative markdown link fails", async () => { + vi.mocked(fetchFileContent).mockResolvedValue({ + content: "[坏链接](./missing.md)", + baseDir: "/tmp", + }); + vi.mocked(openRelativeFile).mockRejectedValue(new Error("not found")); + + const onFileOpened = vi.fn(); + + render( + { }} + isTocOpen={false} + onTocToggle={() => { }} + onRemoveFile={() => { }} + isWide={false} + />, + ); + + const link = await screen.findByRole("link", { name: "坏链接" }); + fireEvent.click(link); + + await waitFor(() => { + expect(screen.getByText("无法打开链接:./missing.md")).toBeInTheDocument(); + }); + expect(onFileOpened).not.toHaveBeenCalled(); + }); + + it("opens relative markdown link and does not show error when request succeeds", async () => { + vi.mocked(fetchFileContent).mockResolvedValue({ + content: "[打开文档](./ok.md)", + baseDir: "/tmp", + }); + vi.mocked(openRelativeFile).mockResolvedValue({ + id: "file-2", + name: "ok.md", + path: "/tmp/ok.md", + }); + + const onFileOpened = vi.fn(); + + render( + { }} + isTocOpen={false} + onTocToggle={() => { }} + onRemoveFile={() => { }} + isWide={false} + />, + ); + + const link = await screen.findByRole("link", { name: "打开文档" }); + fireEvent.click(link); + + await waitFor(() => { + expect(onFileOpened).toHaveBeenCalledWith("file-2"); + }); + expect(screen.queryByText(/^无法打开链接:/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/MarkdownViewer.tsx b/frontend/src/components/MarkdownViewer.tsx index 6d37d54..e10958c 100644 --- a/frontend/src/components/MarkdownViewer.tsx +++ b/frontend/src/components/MarkdownViewer.tsx @@ -872,6 +872,11 @@ export function MermaidBlock({ code }: { code: string }) { } return (
+ {renderStatus === "failed" && ( +
+ 图表渲染失败,已回退为代码块显示。 +
+ )}
         {code}
       
@@ -1068,6 +1073,11 @@ export function SvgBobBlock({ code }: { code: string }) { return (
+ {renderStatus === "failed" && ( +
+ 图表渲染失败,已回退为代码块显示。 +
+ )}
         {code}
       
@@ -1260,6 +1270,11 @@ export function PlantUmlBlock({ code }: { code: string }) { return (
+ {renderStatus === "failed" && ( +
+ 图表渲染失败,已回退为代码块显示。 +
+ )}
         {code}
       
@@ -1655,6 +1670,7 @@ export function MarkdownViewer({ const [content, setContent] = useState(""); const [loading, setLoading] = useState(true); const [isRawView, setIsRawView] = useState(false); + const [linkOpenError, setLinkOpenError] = useState(null); const articleRef = useRef(null); const [prevFetchKey, setPrevFetchKey] = useState({ fileId, revision }); @@ -1665,6 +1681,7 @@ export function MarkdownViewer({ useEffect(() => { let cancelled = false; + setLinkOpenError(null); fetchFileContent(fileId) .then((data) => { if (!cancelled) { @@ -1686,6 +1703,7 @@ export function MarkdownViewer({ const handleLinkClick = useCallback( async (e: React.MouseEvent, href: string, anchor: string | null) => { e.preventDefault(); + setLinkOpenError(null); try { const entry = await openRelativeFile(fileId, href); onFileOpened(entry.id); @@ -1699,7 +1717,7 @@ export function MarkdownViewer({ }); } } catch { - // fallback: do nothing + setLinkOpenError(`无法打开链接:${href}`); } }, [fileId, onFileOpened], @@ -1894,6 +1912,14 @@ export function MarkdownViewer({ data-file-id={fileId} className={`markdown-body min-w-0 flex-1${isWide ? " markdown-body--wide" : ""}`} > + {linkOpenError && ( +
+ {linkOpenError} +
+ )} {renderedContent} diff --git a/frontend/src/components/MermaidBlock.test.tsx b/frontend/src/components/MermaidBlock.test.tsx index abca0a4..8a8da15 100644 --- a/frontend/src/components/MermaidBlock.test.tsx +++ b/frontend/src/components/MermaidBlock.test.tsx @@ -41,12 +41,13 @@ describe("MermaidBlock", () => { render(); await waitFor(() => { - expect(screen.getByTitle("Copy code")).toBeInTheDocument(); + const beautifulCalls = renderMermaidSVGMock.mock.calls.length; + const mermaidCalls = vi.mocked(mermaid.render).mock.calls.length; + expect(beautifulCalls + mermaidCalls).toBeGreaterThan(0); }); const beautifulCalls = renderMermaidSVGMock.mock.calls.length; const mermaidCalls = vi.mocked(mermaid.render).mock.calls.length; - expect(beautifulCalls + mermaidCalls).toBeGreaterThan(0); if (beautifulCalls > 0) { expect(mermaidCalls).toBe(0); } @@ -92,6 +93,7 @@ describe("MermaidBlock", () => { render(); await waitFor(() => { + expect(screen.getByText("图表渲染失败,已回退为代码块显示。")).toBeInTheDocument(); expect(screen.getByTitle("Copy code")).toBeInTheDocument(); }); expect(screen.getByText("invalid mermaid")).toBeInTheDocument(); diff --git a/frontend/src/components/PlantUmlBlock.test.tsx b/frontend/src/components/PlantUmlBlock.test.tsx index 44014b0..fd2c8de 100644 --- a/frontend/src/components/PlantUmlBlock.test.tsx +++ b/frontend/src/components/PlantUmlBlock.test.tsx @@ -62,6 +62,7 @@ describe("PlantUmlBlock", () => { render(); await waitFor(() => { + expect(screen.getByText("图表渲染失败,已回退为代码块显示。")).toBeInTheDocument(); expect(screen.getByText(/@startuml/)).toBeInTheDocument(); expect(screen.getByText(/@enduml/)).toBeInTheDocument(); expect(screen.queryByRole("img", { name: "PlantUML diagram" })).not.toBeInTheDocument(); diff --git a/frontend/src/components/SvgBobBlock.test.tsx b/frontend/src/components/SvgBobBlock.test.tsx index 6f5517b..d80464e 100644 --- a/frontend/src/components/SvgBobBlock.test.tsx +++ b/frontend/src/components/SvgBobBlock.test.tsx @@ -61,6 +61,7 @@ describe("SvgBobBlock", () => { render(); await waitFor(() => { + expect(screen.getByText("图表渲染失败,已回退为代码块显示。")).toBeInTheDocument(); expect(screen.getByText("+---+")).toBeInTheDocument(); expect(screen.queryByRole("img", { name: "SVG Bob diagram" })).not.toBeInTheDocument(); expect(screen.getByTitle("Copy code")).toBeInTheDocument(); From 7859dc39cf10fbf038d47a3461e03a51c499579b Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 11:57:38 +0800 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E6=B8=B2=E6=9F=93=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E4=BE=9B=E8=AF=A6=E7=BB=86=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E4=B8=8E=E5=9B=9E=E9=80=80=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/markdown-capabilities.md | 9 ++- frontend/src/components/MarkdownViewer.tsx | 69 +++++++++++++++++-- frontend/src/components/MermaidBlock.test.tsx | 14 +++- .../src/components/PlantUmlBlock.test.tsx | 2 +- frontend/src/components/SvgBobBlock.test.tsx | 2 +- 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/markdown-capabilities.md b/docs/markdown-capabilities.md index 52380a4..9f2237b 100644 --- a/docs/markdown-capabilities.md +++ b/docs/markdown-capabilities.md @@ -23,12 +23,15 @@ - 支持主题/布局参数 - 支持全屏、缩放、平移、重置 - 支持复制图像(PNG) + - 渲染失败时按错误类型给出提示(语法/网络/超时/服务异常),并回退为代码块 - PlantUML 渲染(通过 Kroki 服务) - 支持明暗主题预设注入 - 支持全屏、缩放、平移 + - 渲染失败时按错误类型给出提示(语法/网络/超时/服务异常),并回退为代码块 - SVG Bob 渲染(`bob-wasm`) - 支持主题适配 - 支持全屏、缩放、平移 + - 渲染失败时按错误类型给出提示(语法/网络/超时/服务异常),并回退为代码块 ### 1.3 元数据与 MDX 兼容 @@ -88,9 +91,9 @@ ### P0(优先) -1. 图表渲染失败时提供明确提示与回退说明 -2. 前置元数据支持更多格式(如 `...` 结束符、BOM 容错) -3. 相对链接失败时增加可见反馈(而不仅静默失败) +1. 前置元数据支持更多格式(如 `...` 结束符、BOM 容错) +2. 图片远程加载失败的占位与重试提示 +3. 外部图表服务(如 PlantUML)失败时增加重试入口 ### P1(建议) diff --git a/frontend/src/components/MarkdownViewer.tsx b/frontend/src/components/MarkdownViewer.tsx index e10958c..c677ccb 100644 --- a/frontend/src/components/MarkdownViewer.tsx +++ b/frontend/src/components/MarkdownViewer.tsx @@ -149,6 +149,54 @@ function normalizeSvgBobSvg(svg: string, isDark: boolean): string { } } +const DIAGRAM_ERROR_MESSAGE_MAX_LEN = 180; + +function normalizeDiagramErrorMessage(err: unknown, fallback: string): string { + let message = fallback; + + if (err instanceof Error && err.message.trim().length > 0) { + message = err.message.trim(); + } else if (typeof err === "string" && err.trim().length > 0) { + message = err.trim(); + } + + const compact = message.replace(/\s+/g, " "); + if (compact.length <= DIAGRAM_ERROR_MESSAGE_MAX_LEN) { + return formatDiagramErrorMessageByType(compact, fallback); + } + return formatDiagramErrorMessageByType(`${compact.slice(0, DIAGRAM_ERROR_MESSAGE_MAX_LEN)}…`, fallback); +} + +function formatDiagramErrorMessageByType(message: string, fallback: string): string { + const lower = message.toLowerCase(); + + const isTimeout = /(timeout|timed out|etimedout|abort(ed)?)/.test(lower); + if (isTimeout) { + return `渲染超时,请稍后重试(${message})`; + } + + const isNetwork = /(failed to fetch|network\s*error|econnrefused|enotfound|eai_again|connection\s*reset|cors)/.test(lower); + if (isNetwork) { + return `网络请求失败,请检查网络或服务可用性(${message})`; + } + + const isService = /(status\s*[45]\d\d|http\s*[45]\d\d|service unavailable|bad gateway|gateway timeout|internal server error)/.test(lower); + if (isService) { + return `渲染服务异常,请稍后重试(${message})`; + } + + const isSyntax = /(parse|syntax|unexpected token|lex(ical)? error|invalid|unterminated)/.test(lower); + if (isSyntax) { + return `语法可能有误,请检查图表代码(${message})`; + } + + if (message === fallback) { + return fallback; + } + + return message; +} + interface MarkdownViewerProps { fileId: string; fileName: string; @@ -593,6 +641,7 @@ export function MermaidBlock({ code }: { code: string }) { const settingsRevision = useMermaidSettingsRevision(); const [svg, setSvg] = useState(""); const [renderStatus, setRenderStatus] = useState<"pending" | "rendered" | "failed">("pending"); + const [renderError, setRenderError] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); @@ -731,6 +780,7 @@ export function MermaidBlock({ code }: { code: string }) { const doRender = async () => { const width = resolveRenderWidth(); setRenderStatus("pending"); + setRenderError(null); try { let renderedSvg = ""; const canUseBeautiful = supportsBeautifulMermaid(normalizedCode); @@ -777,9 +827,10 @@ export function MermaidBlock({ code }: { code: string }) { setSvg(normalizeMermaidSvg(renderedSvg, nextLayout, width)); setRenderStatus("rendered"); } - } catch { + } catch (err) { if (!cancelled) { setSvg(""); + setRenderError(normalizeDiagramErrorMessage(err, "Mermaid 渲染失败")); setRenderStatus("failed"); } } @@ -874,7 +925,7 @@ export function MermaidBlock({ code }: { code: string }) {
{renderStatus === "failed" && (
- 图表渲染失败,已回退为代码块显示。 + 图表渲染失败:{renderError ?? "未知错误"}。已回退为代码块显示。
)}
@@ -888,6 +939,7 @@ export function MermaidBlock({ code }: { code: string }) {
 export function SvgBobBlock({ code }: { code: string }) {
   const [svgUrl, setSvgUrl] = useState(null);
   const [renderStatus, setRenderStatus] = useState<"pending" | "rendered" | "failed">("pending");
+  const [renderError, setRenderError] = useState(null);
   const [themeVersion, setThemeVersion] = useState(0);
   const [isFullscreen, setIsFullscreen] = useState(false);
   const [zoom, setZoom] = useState(1);
@@ -990,6 +1042,7 @@ export function SvgBobBlock({ code }: { code: string }) {
 
     const doRender = async () => {
       setRenderStatus("pending");
+      setRenderError(null);
       try {
         const renderedSvg = await renderSvgBob(code);
         const normalizedSvg = normalizeSvgBobSvg(renderedSvg, getMermaidTheme() === "dark");
@@ -1005,13 +1058,14 @@ export function SvgBobBlock({ code }: { code: string }) {
         } else {
           URL.revokeObjectURL(nextUrl);
         }
-      } catch {
+      } catch (err) {
         if (!cancelled) {
           if (objectUrlRef.current) {
             URL.revokeObjectURL(objectUrlRef.current);
             objectUrlRef.current = null;
           }
           setSvgUrl(null);
+          setRenderError(normalizeDiagramErrorMessage(err, "SVG Bob 渲染失败"));
           setRenderStatus("failed");
         }
       }
@@ -1075,7 +1129,7 @@ export function SvgBobBlock({ code }: { code: string }) {
     
{renderStatus === "failed" && (
- 图表渲染失败,已回退为代码块显示。 + 图表渲染失败:{renderError ?? "未知错误"}。已回退为代码块显示。
)}
@@ -1089,6 +1143,7 @@ export function SvgBobBlock({ code }: { code: string }) {
 export function PlantUmlBlock({ code }: { code: string }) {
   const [svgUrl, setSvgUrl] = useState(null);
   const [renderStatus, setRenderStatus] = useState<"pending" | "rendered" | "failed">("pending");
+  const [renderError, setRenderError] = useState(null);
   const [themeVersion, setThemeVersion] = useState(0);
   const [isFullscreen, setIsFullscreen] = useState(false);
   const [zoom, setZoom] = useState(1);
@@ -1191,6 +1246,7 @@ export function PlantUmlBlock({ code }: { code: string }) {
 
     const doRender = async () => {
       setRenderStatus("pending");
+      setRenderError(null);
       try {
         const nextCode = injectPlantUmlThemePreset(code, getMermaidTheme() === "dark");
         const svg = await renderPlantUml(nextCode);
@@ -1206,13 +1262,14 @@ export function PlantUmlBlock({ code }: { code: string }) {
         } else {
           URL.revokeObjectURL(nextUrl);
         }
-      } catch {
+      } catch (err) {
         if (!cancelled) {
           if (objectUrlRef.current) {
             URL.revokeObjectURL(objectUrlRef.current);
             objectUrlRef.current = null;
           }
           setSvgUrl(null);
+          setRenderError(normalizeDiagramErrorMessage(err, "PlantUML 渲染失败"));
           setRenderStatus("failed");
         }
       }
@@ -1272,7 +1329,7 @@ export function PlantUmlBlock({ code }: { code: string }) {
     
{renderStatus === "failed" && (
- 图表渲染失败,已回退为代码块显示。 + 图表渲染失败:{renderError ?? "未知错误"}。已回退为代码块显示。
)}
diff --git a/frontend/src/components/MermaidBlock.test.tsx b/frontend/src/components/MermaidBlock.test.tsx
index 8a8da15..9480e0d 100644
--- a/frontend/src/components/MermaidBlock.test.tsx
+++ b/frontend/src/components/MermaidBlock.test.tsx
@@ -93,12 +93,24 @@ describe("MermaidBlock", () => {
     render();
 
     await waitFor(() => {
-      expect(screen.getByText("图表渲染失败,已回退为代码块显示。")).toBeInTheDocument();
+      expect(screen.getByText("图表渲染失败:语法可能有误,请检查图表代码(parse error)。已回退为代码块显示。")).toBeInTheDocument();
       expect(screen.getByTitle("Copy code")).toBeInTheDocument();
     });
     expect(screen.getByText("invalid mermaid")).toBeInTheDocument();
   });
 
+  it("shows timeout-oriented hint when rendering times out", async () => {
+    vi.mocked(mermaid.render).mockRejectedValue(new Error("render timeout after 3000ms"));
+
+    render();
+
+    await waitFor(() => {
+      expect(
+        screen.getByText("图表渲染失败:渲染超时,请稍后重试(render timeout after 3000ms)。已回退为代码块显示。"),
+      ).toBeInTheDocument();
+    });
+  });
+
   it("copies original mermaid code to clipboard on click", async () => {
     vi.mocked(mermaid.render).mockResolvedValue({
       svg: "diagram",
diff --git a/frontend/src/components/PlantUmlBlock.test.tsx b/frontend/src/components/PlantUmlBlock.test.tsx
index fd2c8de..bb1e675 100644
--- a/frontend/src/components/PlantUmlBlock.test.tsx
+++ b/frontend/src/components/PlantUmlBlock.test.tsx
@@ -62,7 +62,7 @@ describe("PlantUmlBlock", () => {
         render();
 
         await waitFor(() => {
-            expect(screen.getByText("图表渲染失败,已回退为代码块显示。")).toBeInTheDocument();
+            expect(screen.getByText("图表渲染失败:渲染服务异常,请稍后重试(PlantUML render failed with status 500)。已回退为代码块显示。")).toBeInTheDocument();
             expect(screen.getByText(/@startuml/)).toBeInTheDocument();
             expect(screen.getByText(/@enduml/)).toBeInTheDocument();
             expect(screen.queryByRole("img", { name: "PlantUML diagram" })).not.toBeInTheDocument();
diff --git a/frontend/src/components/SvgBobBlock.test.tsx b/frontend/src/components/SvgBobBlock.test.tsx
index d80464e..7836a8f 100644
--- a/frontend/src/components/SvgBobBlock.test.tsx
+++ b/frontend/src/components/SvgBobBlock.test.tsx
@@ -61,7 +61,7 @@ describe("SvgBobBlock", () => {
         render();
 
         await waitFor(() => {
-            expect(screen.getByText("图表渲染失败,已回退为代码块显示。")).toBeInTheDocument();
+            expect(screen.getByText("图表渲染失败:语法可能有误,请检查图表代码(svgbob parse error)。已回退为代码块显示。")).toBeInTheDocument();
             expect(screen.getByText("+---+")).toBeInTheDocument();
             expect(screen.queryByRole("img", { name: "SVG Bob diagram" })).not.toBeInTheDocument();
             expect(screen.getByTitle("Copy code")).toBeInTheDocument();

From 88eb3ff3493491f2d4c1bbc70424b51554f3dae3 Mon Sep 17 00:00:00 2001
From: barry 
Date: Thu, 30 Apr 2026 14:14:23 +0800
Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20PlantUML=20?=
 =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=9A=84=E9=87=8D?=
 =?UTF-8?q?=E8=AF=95=E5=8A=9F=E8=83=BD=E4=B8=8E=E6=B5=8B=E8=AF=95=E7=94=A8?=
 =?UTF-8?q?=E4=BE=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 docs/markdown-capabilities.md                 |  3 +-
 frontend/src/components/MarkdownViewer.tsx    | 18 +++++++++--
 .../src/components/PlantUmlBlock.test.tsx     | 30 +++++++++++++++++++
 3 files changed, 48 insertions(+), 3 deletions(-)

diff --git a/docs/markdown-capabilities.md b/docs/markdown-capabilities.md
index 9f2237b..aef3513 100644
--- a/docs/markdown-capabilities.md
+++ b/docs/markdown-capabilities.md
@@ -28,6 +28,7 @@
   - 支持明暗主题预设注入
   - 支持全屏、缩放、平移
   - 渲染失败时按错误类型给出提示(语法/网络/超时/服务异常),并回退为代码块
+  - 失败时提供一键重试入口
 - SVG Bob 渲染(`bob-wasm`)
   - 支持主题适配
   - 支持全屏、缩放、平移
@@ -93,7 +94,7 @@
 
 1. 前置元数据支持更多格式(如 `...` 结束符、BOM 容错)
 2. 图片远程加载失败的占位与重试提示
-3. 外部图表服务(如 PlantUML)失败时增加重试入口
+3. 外部图表服务多次失败时提供更明确的排障建议
 
 ### P1(建议)
 
diff --git a/frontend/src/components/MarkdownViewer.tsx b/frontend/src/components/MarkdownViewer.tsx
index c677ccb..1786198 100644
--- a/frontend/src/components/MarkdownViewer.tsx
+++ b/frontend/src/components/MarkdownViewer.tsx
@@ -1145,6 +1145,7 @@ export function PlantUmlBlock({ code }: { code: string }) {
   const [renderStatus, setRenderStatus] = useState<"pending" | "rendered" | "failed">("pending");
   const [renderError, setRenderError] = useState(null);
   const [themeVersion, setThemeVersion] = useState(0);
+  const [retryVersion, setRetryVersion] = useState(0);
   const [isFullscreen, setIsFullscreen] = useState(false);
   const [zoom, setZoom] = useState(1);
   const [pan, setPan] = useState({ x: 0, y: 0 });
@@ -1228,6 +1229,12 @@ export function PlantUmlBlock({ code }: { code: string }) {
     panStartRef.current = null;
   }, []);
 
+  const handleRetryRender = useCallback(() => {
+    setRenderStatus("pending");
+    setRenderError(null);
+    setRetryVersion((prev) => prev + 1);
+  }, []);
+
   useEffect(() => {
     const observer = new MutationObserver(() => {
       setThemeVersion((v) => v + 1);
@@ -1284,7 +1291,7 @@ export function PlantUmlBlock({ code }: { code: string }) {
         objectUrlRef.current = null;
       }
     };
-  }, [code, themeVersion]);
+  }, [code, themeVersion, retryVersion]);
 
   if (svgUrl) {
     const canvasStyle = isFullscreen
@@ -1329,7 +1336,14 @@ export function PlantUmlBlock({ code }: { code: string }) {
     
{renderStatus === "failed" && (
- 图表渲染失败:{renderError ?? "未知错误"}。已回退为代码块显示。 +

图表渲染失败:{renderError ?? "未知错误"}。已回退为代码块显示。

+
)}
diff --git a/frontend/src/components/PlantUmlBlock.test.tsx b/frontend/src/components/PlantUmlBlock.test.tsx
index bb1e675..9ca8ed4 100644
--- a/frontend/src/components/PlantUmlBlock.test.tsx
+++ b/frontend/src/components/PlantUmlBlock.test.tsx
@@ -70,6 +70,36 @@ describe("PlantUmlBlock", () => {
         });
     });
 
+    it("retries rendering when clicking retry button after failure", async () => {
+        const fetchMock = vi
+            .fn()
+            .mockResolvedValueOnce({
+                ok: false,
+                status: 503,
+                text: vi.fn().mockResolvedValue("service unavailable"),
+            })
+            .mockResolvedValueOnce({
+                ok: true,
+                status: 200,
+                text: vi.fn().mockResolvedValue('ok'),
+            });
+        vi.stubGlobal("fetch", fetchMock);
+
+        render();
+
+        await waitFor(() => {
+            expect(screen.getByText("图表渲染失败:渲染服务异常,请稍后重试(PlantUML render failed with status 503)。已回退为代码块显示。")).toBeInTheDocument();
+            expect(screen.getByRole("button", { name: "重试渲染" })).toBeInTheDocument();
+        });
+
+        fireEvent.click(screen.getByRole("button", { name: "重试渲染" }));
+
+        await waitFor(() => {
+            expect(screen.getByRole("img", { name: "PlantUML diagram" })).toBeInTheDocument();
+        });
+        expect(fetchMock).toHaveBeenCalledTimes(2);
+    });
+
     it("calls requestFullscreen on fullscreen button click", async () => {
         vi.stubGlobal(
             "fetch",

From ccfbec0765964f986da605be1531052f29fee8f7 Mon Sep 17 00:00:00 2001
From: barry 
Date: Thu, 30 Apr 2026 14:34:59 +0800
Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=A7?=
 =?UTF-8?q?=E7=BA=B2=E7=AC=94=E8=AE=B0=E6=A8=A1=E5=BC=8F=EF=BC=8C=E6=94=AF?=
 =?UTF-8?q?=E6=8C=81=E5=B7=A6=E4=BE=A7=E6=80=9D=E7=BB=B4=E5=AF=BC=E5=9B=BE?=
 =?UTF-8?q?=E4=B8=8E=E5=8F=B3=E4=BE=A7=E6=96=87=E6=A1=A3=E8=81=94=E5=8A=A8?=
 =?UTF-8?q?=E6=B5=8F=E8=A7=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Copilot 
---
 README.md                                   |  1 +
 docs/markdown-capabilities.md               |  1 +
 frontend/src/App.tsx                        | 76 +++++++++++++++++++-
 frontend/src/components/OutlineTreeView.tsx | 80 +++++++++++++--------
 4 files changed, 126 insertions(+), 32 deletions(-)

diff --git a/README.md b/README.md
index a8773bd..b97f894 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@
 - 分组 文件分组管理
 - 目录 文档目录面板
 - 平铺 平铺 / 树形 树形侧边栏(支持拖拽排序与文件搜索)
+- 大纲笔记模式(类幕布):左侧思维导图大纲,右侧文档联动阅读
 - YAML 前置元数据展示(可折叠元数据区域)
 - MDX 支持(渲染 Markdown,去除 `import`/`export`,转义 JSX 标签)
 - 宽布局 宽版 / 窄布局 窄版阅读宽度切换
diff --git a/docs/markdown-capabilities.md b/docs/markdown-capabilities.md
index aef3513..c750b72 100644
--- a/docs/markdown-capabilities.md
+++ b/docs/markdown-capabilities.md
@@ -64,6 +64,7 @@
 - 当前标题高亮与平滑滚动定位
 - 原文视图(Raw)与渲染视图切换
 - 全局全文搜索后可跳转到目标文档并定位命中内容
+- 大纲笔记模式(类幕布):左侧大纲思维导图、右侧文档联动浏览
 
 ## 4. 结构化关系能力
 
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6e908ed..0da600a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -84,7 +84,7 @@ export function App() {
     }
   });
   const [showGraph, setShowGraph] = useState(false);
-  const [graphViewMode, setGraphViewMode] = useState<"link" | "outline" | "gravity" | "tree">("link");
+  const [graphViewMode, setGraphViewMode] = useState<"link" | "outline" | "gravity" | "tree" | "notebook">("link");
   const [isExportingAllPdf, setIsExportingAllPdf] = useState(false);
   const [status, setStatus] = useState(null);
   const knownFileIds = useRef>(new Set());
@@ -353,6 +353,12 @@ export function App() {
     setActiveFileId(fileId);
   }, []);
 
+  const handleNotebookFileSelect = useCallback((fileId: string, group: string) => {
+    setPendingSearchJump(null);
+    setActiveGroup(group);
+    setActiveFileId(fileId);
+  }, []);
+
   const handleRemoveFile = useCallback(() => {
     if (activeFileId != null) {
       removeFile(activeFileId);
@@ -604,6 +610,28 @@ export function App() {
               
             
           
+          
            setIsWide((v) => !v)} />
           
+                    
+
+ setShowGraph(false)} + onSelectFile={handleNotebookFileSelect} + embedded + /> +
+ +
+ {activeFileId != null ? ( + setTocOpen((v) => !v)} + onRemoveFile={handleRemoveFile} + isWide={isWide} + searchJumpRequest={ + pendingSearchJump?.fileId === activeFileId ? pendingSearchJump : null + } + onSearchJumpHandled={() => setPendingSearchJump(null)} + /> + ) : ( +
+ 请在左侧选择一个文档开始大纲笔记浏览 +
+ )} +
+
) : ( setShowGraph(false)} /> ) diff --git a/frontend/src/components/OutlineTreeView.tsx b/frontend/src/components/OutlineTreeView.tsx index d5d551c..0b73722 100644 --- a/frontend/src/components/OutlineTreeView.tsx +++ b/frontend/src/components/OutlineTreeView.tsx @@ -286,11 +286,13 @@ function getNodeSide( interface OutlineTreeViewProps { onClose: () => void; + onSelectFile?: (fileId: string, group: string) => void; + embedded?: boolean; } const LAYOUT_DIRECTION_KEY = "markview-outline-layout-direction"; -export function OutlineTreeView({ onClose }: OutlineTreeViewProps) { +export function OutlineTreeView({ onClose, onSelectFile, embedded = false }: OutlineTreeViewProps) { const [outline, setOutline] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -352,14 +354,22 @@ export function OutlineTreeView({ onClose }: OutlineTreeViewProps) { const handleNodeClick = useCallback( (_nodeId: string, d: TreeDataNode["data"]) => { if (d.isLink && d.targetFileId && d.targetGroup) { + if (onSelectFile) { + onSelectFile(d.targetFileId, d.targetGroup); + return; + } const path = buildFileUrl(d.targetGroup, d.targetFileId); window.open(`${window.location.origin}${path}`, "_blank", "noopener,noreferrer"); } else if ((d.isFile || d.isH1 || (d.isH2 && !d.hasLinks)) && d.fileId && d.group) { + if (onSelectFile) { + onSelectFile(d.fileId, d.group); + return; + } const path = buildFileUrl(d.group, d.fileId); window.open(`${window.location.origin}${path}`, "_blank", "noopener,noreferrer"); } }, - [], + [onSelectFile], ); useEffect(() => { @@ -527,13 +537,15 @@ export function OutlineTreeView({ onClose }: OutlineTreeViewProps) { return (
- + {!embedded && ( + + )}
加载中… @@ -546,13 +558,15 @@ export function OutlineTreeView({ onClose }: OutlineTreeViewProps) { return (
- + {!embedded && ( + + )}
{error}
@@ -563,13 +577,15 @@ export function OutlineTreeView({ onClose }: OutlineTreeViewProps) { return (
- + {!embedded && ( + + )}
暂无文档或标题 @@ -581,13 +597,15 @@ export function OutlineTreeView({ onClose }: OutlineTreeViewProps) { return (
- + {!embedded && ( + + )}
From 26892338b4122f4e4ae1583c10ef18d3b796898d Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 14:54:43 +0800 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=9D=A5=E6=BA=90=E4=B8=8E=E7=89=88=E6=9D=83=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=EF=BC=8C=E6=9B=B4=E6=96=B0=20NOTICE=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BB=A5=E5=8C=85=E5=90=AB=E4=B8=8A=E6=B8=B8=E4=BF=A1?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- NOTICE | 14 ++++++++++++++ README.md | 12 ++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 NOTICE diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..7d34323 --- /dev/null +++ b/NOTICE @@ -0,0 +1,14 @@ +markview NOTICE +=============== + +This project includes work derived from: + +- Project: k1LoW/mo +- Repository: https://github.com/k1LoW/mo +- License: MIT License +- Upstream copyright: + Copyright © 2026 Ken'ichiro Oyama + +markview is maintained as a derivative project with additional modifications. +Please refer to LICENSE and CREDITS/CREDITS_FRONTEND for detailed license texts +of this repository and third-party dependencies. diff --git a/README.md b/README.md index b97f894..3e6812c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ > 如果你在使用 AI 编码助手或需要项目约束说明,请以 `.github/copilot-instructions.md` 为准。 +> [!NOTE] +> **项目来源与版权说明** +> +> 本项目基于 [k1LoW/mo](https://github.com/k1LoW/mo) 演进开发,遵循其开源许可(MIT)。 +> 为尊重原作者与版权,本仓库保留来源标注与许可说明;详见根目录 `NOTICE` 与 `LICENSE`。 + ## 功能特性 - 支持 GitHub 风格 Markdown(表格、任务列表、脚注等) @@ -282,3 +288,9 @@ $ make build - 包含源码与 logo 在内的项目整体以 MIT 许可发布。 - logo 也可单独按 [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) 使用。 - 在不修改 logo 且用于 markview 技术说明场景时,通常可不强制要求额外版权标注。 + +### 上游来源致谢 + +- Upstream: [k1LoW/mo](https://github.com/k1LoW/mo) +- 上游许可:MIT(Copyright © 2026 Ken'ichiro Oyama) +- 归属与说明:见本仓库 `NOTICE` From b56ca2d4f72dff6a884a16617e0e4a874742bac7 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 15:02:06 +0800 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=A7?= =?UTF-8?q?=E7=BA=B2=E7=AC=94=E8=AE=B0=E9=9D=A2=E6=9D=BF=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=AF=E7=BC=96=E8=BE=91=E6=9D=A1=E7=9B=AE=E4=B8=8E?= =?UTF-8?q?=E4=BB=8E=E6=96=87=E6=A1=A3=E5=AF=BC=E5=85=A5=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- README.md | 2 +- docs/markdown-capabilities.md | 2 + frontend/src/App.tsx | 53 ++++- .../src/components/OutlineNotebookPanel.tsx | 207 ++++++++++++++++++ frontend/src/utils/notebookOutline.test.ts | 82 +++++++ frontend/src/utils/notebookOutline.ts | 142 ++++++++++++ 6 files changed, 481 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/OutlineNotebookPanel.tsx create mode 100644 frontend/src/utils/notebookOutline.test.ts create mode 100644 frontend/src/utils/notebookOutline.ts diff --git a/README.md b/README.md index 3e6812c..34d08f9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - 分组 文件分组管理 - 目录 文档目录面板 - 平铺 平铺 / 树形 树形侧边栏(支持拖拽排序与文件搜索) -- 大纲笔记模式(类幕布):左侧思维导图大纲,右侧文档联动阅读 +- 大纲笔记模式(类幕布):左侧思维导图/可编辑大纲切换,右侧文档联动阅读(支持 Tab 缩进) - YAML 前置元数据展示(可折叠元数据区域) - MDX 支持(渲染 Markdown,去除 `import`/`export`,转义 JSX 标签) - 宽布局 宽版 / 窄布局 窄版阅读宽度切换 diff --git a/docs/markdown-capabilities.md b/docs/markdown-capabilities.md index c750b72..35c1e31 100644 --- a/docs/markdown-capabilities.md +++ b/docs/markdown-capabilities.md @@ -65,6 +65,8 @@ - 原文视图(Raw)与渲染视图切换 - 全局全文搜索后可跳转到目标文档并定位命中内容 - 大纲笔记模式(类幕布):左侧大纲思维导图、右侧文档联动浏览 + - 支持可编辑条目(Enter 新建、Tab/Shift+Tab 调整层级) + - 支持从当前文档导入标题并一键定位到右侧内容 ## 4. 结构化关系能力 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0da600a..e69e5e6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,12 +26,14 @@ import { captureArticleForMergedPdf, exportMergedPdfFromSnapshots } from "./util import { OutlineGraphView } from "./components/OutlineGraphView"; import { OutlineGravityView } from "./components/OutlineGravityView"; import { OutlineTreeView } from "./components/OutlineTreeView"; +import { OutlineNotebookPanel } from "./components/OutlineNotebookPanel"; import { isStaticMode } from "./utils/staticData"; const WIDTH_STORAGE_KEY = "markview-layout-width"; const VIEWMODE_STORAGE_KEY = "markview-sidebar-viewmode"; const PDF_OPEN_FILE_PARAM = "markview_open"; const PDF_OPEN_FROM_PARAM = "markview_from"; +const NOTEBOOK_LEFT_TAB_KEY = "markview-notebook-left-tab"; interface PendingPdfOpenRequest { fromFileId: string; @@ -86,6 +88,14 @@ export function App() { const [showGraph, setShowGraph] = useState(false); const [graphViewMode, setGraphViewMode] = useState<"link" | "outline" | "gravity" | "tree" | "notebook">("link"); const [isExportingAllPdf, setIsExportingAllPdf] = useState(false); + const [notebookLeftTab, setNotebookLeftTab] = useState<"mindmap" | "outline">(() => { + try { + const raw = localStorage.getItem(NOTEBOOK_LEFT_TAB_KEY); + return raw === "outline" ? "outline" : "mindmap"; + } catch { + return "mindmap"; + } + }); const [status, setStatus] = useState(null); const knownFileIds = useRef>(new Set()); const [initialFileId, setInitialFileId] = useState(() => { @@ -310,6 +320,14 @@ export function App() { } }, [isWide]); + useEffect(() => { + try { + localStorage.setItem(NOTEBOOK_LEFT_TAB_KEY, notebookLeftTab); + } catch { + /* ignore */ + } + }, [notebookLeftTab]); + const handleViewModeToggle = useCallback(() => { setViewModes((prev) => { const current = prev[activeGroup] ?? "flat"; @@ -678,7 +696,22 @@ export function App() {
-

大纲笔记

+
+ + +
- setShowGraph(false)} - onSelectFile={handleNotebookFileSelect} - embedded - /> + {notebookLeftTab === "mindmap" ? ( + setShowGraph(false)} + onSelectFile={handleNotebookFileSelect} + embedded + /> + ) : ( + + )}
diff --git a/frontend/src/components/OutlineNotebookPanel.tsx b/frontend/src/components/OutlineNotebookPanel.tsx new file mode 100644 index 0000000..31a29cc --- /dev/null +++ b/frontend/src/components/OutlineNotebookPanel.tsx @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { NotebookHeadingLike, NotebookOutlineItem } from "../utils/notebookOutline"; +import { + createNotebookOutlineItem, + importHeadingsAsNotebookItems, + indentNotebookItem, + insertNotebookItemAfter, + outdentNotebookItem, + removeNotebookItemAt, + sanitizeNotebookItems, + updateNotebookItemText, +} from "../utils/notebookOutline"; + +const STORAGE_PREFIX = "markview-notebook-outline:"; + +interface OutlineNotebookPanelProps { + groupName: string; + headings: NotebookHeadingLike[]; + onNavigateHeading?: (headingId: string) => void; +} + +export function OutlineNotebookPanel({ + groupName, + headings, + onNavigateHeading, +}: OutlineNotebookPanelProps) { + const storageKey = useMemo(() => `${STORAGE_PREFIX}${groupName}`, [groupName]); + const [items, setItems] = useState(() => [createNotebookOutlineItem()]); + const [activeIndex, setActiveIndex] = useState(0); + const [focusIndex, setFocusIndex] = useState(null); + const inputRefs = useRef>([]); + + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey); + if (!raw) { + setItems([createNotebookOutlineItem()]); + setActiveIndex(0); + return; + } + const parsed = JSON.parse(raw); + const restored = sanitizeNotebookItems(parsed); + setItems(restored); + setActiveIndex(0); + } catch { + setItems([createNotebookOutlineItem()]); + setActiveIndex(0); + } + }, [storageKey]); + + useEffect(() => { + localStorage.setItem(storageKey, JSON.stringify(items)); + }, [items, storageKey]); + + useEffect(() => { + if (focusIndex == null) return; + const target = inputRefs.current[focusIndex]; + if (!target) return; + target.focus(); + const length = target.value.length; + target.setSelectionRange(length, length); + setFocusIndex(null); + }, [focusIndex, items]); + + const moveFocus = useCallback((index: number) => { + if (index < 0 || index >= items.length) return; + setActiveIndex(index); + setFocusIndex(index); + }, [items.length]); + + const handleAddItem = useCallback(() => { + setItems((prev) => { + const inserted = insertNotebookItemAfter(prev, activeIndex); + setActiveIndex(inserted.focusIndex); + setFocusIndex(inserted.focusIndex); + return inserted.items; + }); + }, [activeIndex]); + + const handleImportHeadings = useCallback(() => { + const imported = importHeadingsAsNotebookItems(headings); + if (imported.length === 0) { + setItems([createNotebookOutlineItem()]); + setActiveIndex(0); + setFocusIndex(0); + return; + } + setItems(imported); + setActiveIndex(0); + setFocusIndex(0); + }, [headings]); + + const handleClear = useCallback(() => { + setItems([createNotebookOutlineItem()]); + setActiveIndex(0); + setFocusIndex(0); + }, []); + + const handleKeyDown = useCallback((index: number, event: React.KeyboardEvent) => { + if (event.key === "Tab") { + event.preventDefault(); + setItems((prev) => (event.shiftKey ? outdentNotebookItem(prev, index) : indentNotebookItem(prev, index))); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + setItems((prev) => { + const inserted = insertNotebookItemAfter(prev, index); + setActiveIndex(inserted.focusIndex); + setFocusIndex(inserted.focusIndex); + return inserted.items; + }); + return; + } + + if (event.key === "Backspace" && items[index]?.text === "" && items.length > 1) { + event.preventDefault(); + setItems((prev) => { + const removed = removeNotebookItemAt(prev, index); + setActiveIndex(removed.focusIndex); + setFocusIndex(removed.focusIndex); + return removed.items; + }); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + moveFocus(index - 1); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + moveFocus(index + 1); + } + }, [items, moveFocus]); + + return ( +
+
+ + + +
+ +
+ {items.map((item, index) => ( +
+ {item.level === 0 ? "•" : "◦"} + { + inputRefs.current[index] = el; + }} + className="flex-1 rounded-md border border-gh-border bg-gh-bg px-2 py-1 text-sm text-gh-text-primary outline-none focus:border-gh-text-secondary" + value={item.text} + onChange={(e) => { + const value = e.target.value; + setItems((prev) => updateNotebookItemText(prev, index, value)); + }} + onFocus={() => setActiveIndex(index)} + onKeyDown={(e) => handleKeyDown(index, e)} + placeholder="输入条目,Enter 新建,Tab 缩进" + /> + {item.headingId && ( + + )} +
+ ))} +
+ +
+ 快捷键:Enter 新建、Tab/Shift+Tab 调整层级、↑/↓ 切换、空条目 Backspace 删除 +
+
+ ); +} diff --git a/frontend/src/utils/notebookOutline.test.ts b/frontend/src/utils/notebookOutline.test.ts new file mode 100644 index 0000000..f14418d --- /dev/null +++ b/frontend/src/utils/notebookOutline.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + createNotebookOutlineItem, + importHeadingsAsNotebookItems, + indentNotebookItem, + insertNotebookItemAfter, + outdentNotebookItem, + removeNotebookItemAt, + sanitizeNotebookItems, + updateNotebookItemText, +} from "./notebookOutline"; + +describe("notebookOutline utils", () => { + it("imports headings with mapped outline levels", () => { + const imported = importHeadingsAsNotebookItems([ + { id: "h1", text: "Title", level: 1 }, + { id: "h2", text: "Section", level: 2 }, + { id: "h4", text: "Deep", level: 4 }, + ]); + + expect(imported).toHaveLength(3); + expect(imported[0].level).toBe(0); + expect(imported[1].level).toBe(1); + expect(imported[2].level).toBe(3); + expect(imported[2].headingId).toBe("h4"); + }); + + it("inserts sibling item after current index", () => { + const base = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 1 }), + ]; + + const inserted = insertNotebookItemAfter(base, 1); + expect(inserted.items).toHaveLength(3); + expect(inserted.focusIndex).toBe(2); + expect(inserted.items[2].level).toBe(1); + }); + + it("supports indent and outdent with boundaries", () => { + const base = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 0 }), + ]; + + const indented = indentNotebookItem(base, 1); + expect(indented[1].level).toBe(1); + + const outdented = outdentNotebookItem(indented, 1); + expect(outdented[1].level).toBe(0); + }); + + it("removes item and keeps at least one empty item", () => { + const single = [createNotebookOutlineItem({ id: "only", text: "", level: 0 })]; + const removedSingle = removeNotebookItemAt(single, 0); + expect(removedSingle.items).toHaveLength(1); + expect(removedSingle.focusIndex).toBe(0); + + const multi = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 1 }), + ]; + const removedMulti = removeNotebookItemAt(multi, 1); + expect(removedMulti.items).toHaveLength(1); + expect(removedMulti.items[0].id).toBe("a"); + expect(removedMulti.focusIndex).toBe(0); + }); + + it("updates item text and sanitizes malformed storage payload", () => { + const base = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 1 }), + ]; + const updated = updateNotebookItemText(base, 1, "B2"); + expect(updated[1].text).toBe("B2"); + + const sanitized = sanitizeNotebookItems([{ id: 1, text: 2, level: -4 }, null, "x"]); + expect(sanitized).toHaveLength(1); + expect(sanitized[0].level).toBe(0); + expect(sanitized[0].text).toBe(""); + }); +}); diff --git a/frontend/src/utils/notebookOutline.ts b/frontend/src/utils/notebookOutline.ts new file mode 100644 index 0000000..c735104 --- /dev/null +++ b/frontend/src/utils/notebookOutline.ts @@ -0,0 +1,142 @@ +export interface NotebookOutlineItem { + id: string; + text: string; + level: number; + headingId?: string; +} + +export interface NotebookHeadingLike { + id: string; + text: string; + level: number; +} + +const MAX_LEVEL = 6; + +function clampLevel(level: number): number { + if (!Number.isFinite(level)) return 0; + return Math.max(0, Math.min(MAX_LEVEL, Math.floor(level))); +} + +function createId(): string { + return `note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function createNotebookOutlineItem( + partial: Partial = {}, +): NotebookOutlineItem { + return { + id: partial.id ?? createId(), + text: partial.text ?? "", + level: clampLevel(partial.level ?? 0), + ...(partial.headingId ? { headingId: partial.headingId } : {}), + }; +} + +export function sanitizeNotebookItems(input: unknown): NotebookOutlineItem[] { + if (!Array.isArray(input)) { + return [createNotebookOutlineItem()]; + } + + const items = input + .map((item) => { + if (!item || typeof item !== "object") return null; + const candidate = item as Partial; + return createNotebookOutlineItem({ + id: typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : undefined, + text: typeof candidate.text === "string" ? candidate.text : "", + level: typeof candidate.level === "number" ? candidate.level : 0, + headingId: typeof candidate.headingId === "string" && candidate.headingId.length > 0 + ? candidate.headingId + : undefined, + }); + }) + .filter((item): item is NotebookOutlineItem => item != null); + + return items.length > 0 ? items : [createNotebookOutlineItem()]; +} + +export function importHeadingsAsNotebookItems( + headings: NotebookHeadingLike[], +): NotebookOutlineItem[] { + return headings.map((heading) => + createNotebookOutlineItem({ + text: heading.text, + level: clampLevel(heading.level - 1), + headingId: heading.id, + }), + ); +} + +export function updateNotebookItemText( + items: NotebookOutlineItem[], + index: number, + text: string, +): NotebookOutlineItem[] { + if (index < 0 || index >= items.length) return items; + const next = [...items]; + next[index] = { ...next[index], text }; + return next; +} + +export function insertNotebookItemAfter( + items: NotebookOutlineItem[], + index: number, +): { items: NotebookOutlineItem[]; focusIndex: number } { + if (items.length === 0) { + return { items: [createNotebookOutlineItem()], focusIndex: 0 }; + } + + const safeIndex = Math.max(-1, Math.min(index, items.length - 1)); + const baseLevel = safeIndex >= 0 ? items[safeIndex].level : 0; + const nextItem = createNotebookOutlineItem({ level: baseLevel }); + const insertPos = safeIndex + 1; + + return { + items: [...items.slice(0, insertPos), nextItem, ...items.slice(insertPos)], + focusIndex: insertPos, + }; +} + +export function indentNotebookItem( + items: NotebookOutlineItem[], + index: number, +): NotebookOutlineItem[] { + if (index <= 0 || index >= items.length) return items; + const next = [...items]; + const target = next[index]; + const limit = Math.min(MAX_LEVEL, next[index - 1].level + 1); + const level = Math.min(limit, target.level + 1); + if (level === target.level) return items; + next[index] = { ...target, level }; + return next; +} + +export function outdentNotebookItem( + items: NotebookOutlineItem[], + index: number, +): NotebookOutlineItem[] { + if (index < 0 || index >= items.length) return items; + const next = [...items]; + const target = next[index]; + const level = Math.max(0, target.level - 1); + if (level === target.level) return items; + next[index] = { ...target, level }; + return next; +} + +export function removeNotebookItemAt( + items: NotebookOutlineItem[], + index: number, +): { items: NotebookOutlineItem[]; focusIndex: number } { + if (index < 0 || index >= items.length) { + return { items, focusIndex: Math.max(0, Math.min(items.length - 1, index)) }; + } + + const next = [...items.slice(0, index), ...items.slice(index + 1)]; + if (next.length === 0) { + return { items: [createNotebookOutlineItem()], focusIndex: 0 }; + } + + return { items: next, focusIndex: Math.max(0, index - 1) }; +} From 9f141dd2accf8c7c7d8ba172dc95e53e5490a7c4 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 15:15:12 +0800 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=A4=A7?= =?UTF-8?q?=E7=BA=B2=E7=AC=94=E8=AE=B0=E6=A8=A1=E5=BC=8F=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=AD=90=E9=A1=B9=E6=8A=98=E5=8F=A0/=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E3=80=81=E4=B8=8A=E4=B8=8B=E7=A7=BB=E5=8A=A8=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- README.md | 2 +- docs/markdown-capabilities.md | 3 + .../src/components/OutlineNotebookPanel.tsx | 326 ++++++++++++++++-- frontend/src/utils/notebookOutline.test.ts | 64 ++++ frontend/src/utils/notebookOutline.ts | 202 +++++++++++ 5 files changed, 561 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 34d08f9..eb4b7c1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - 分组 文件分组管理 - 目录 文档目录面板 - 平铺 平铺 / 树形 树形侧边栏(支持拖拽排序与文件搜索) -- 大纲笔记模式(类幕布):左侧思维导图/可编辑大纲切换,右侧文档联动阅读(支持 Tab 缩进) +- 大纲笔记模式(类幕布):左侧思维导图/可编辑大纲切换,右侧文档联动阅读(支持折叠子项、Tab 缩进、Alt+↑/↓ 调整顺序、多选批量编辑) - YAML 前置元数据展示(可折叠元数据区域) - MDX 支持(渲染 Markdown,去除 `import`/`export`,转义 JSX 标签) - 宽布局 宽版 / 窄布局 窄版阅读宽度切换 diff --git a/docs/markdown-capabilities.md b/docs/markdown-capabilities.md index 35c1e31..61f2eb2 100644 --- a/docs/markdown-capabilities.md +++ b/docs/markdown-capabilities.md @@ -66,6 +66,9 @@ - 全局全文搜索后可跳转到目标文档并定位命中内容 - 大纲笔记模式(类幕布):左侧大纲思维导图、右侧文档联动浏览 - 支持可编辑条目(Enter 新建、Tab/Shift+Tab 调整层级) + - 支持折叠/展开子项与一键全部展开 + - 支持 Alt+↑/↓ 及按钮上下移动条目(含子项) + - 支持复选多条目并批量缩进/反缩进/删除 - 支持从当前文档导入标题并一键定位到右侧内容 ## 4. 结构化关系能力 diff --git a/frontend/src/components/OutlineNotebookPanel.tsx b/frontend/src/components/OutlineNotebookPanel.tsx index 31a29cc..e774ade 100644 --- a/frontend/src/components/OutlineNotebookPanel.tsx +++ b/frontend/src/components/OutlineNotebookPanel.tsx @@ -2,16 +2,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { NotebookHeadingLike, NotebookOutlineItem } from "../utils/notebookOutline"; import { createNotebookOutlineItem, + getHiddenNotebookIndices, + hasNotebookChildren, + indentNotebookItemsByIds, importHeadingsAsNotebookItems, indentNotebookItem, insertNotebookItemAfter, + moveNotebookItem, + outdentNotebookItemsByIds, outdentNotebookItem, removeNotebookItemAt, + removeNotebookItemsByIds, sanitizeNotebookItems, updateNotebookItemText, } from "../utils/notebookOutline"; const STORAGE_PREFIX = "markview-notebook-outline:"; +const COLLAPSED_STORAGE_PREFIX = "markview-notebook-outline-collapsed:"; interface OutlineNotebookPanelProps { groupName: string; @@ -25,33 +32,82 @@ export function OutlineNotebookPanel({ onNavigateHeading, }: OutlineNotebookPanelProps) { const storageKey = useMemo(() => `${STORAGE_PREFIX}${groupName}`, [groupName]); + const collapsedStorageKey = useMemo(() => `${COLLAPSED_STORAGE_PREFIX}${groupName}`, [groupName]); const [items, setItems] = useState(() => [createNotebookOutlineItem()]); const [activeIndex, setActiveIndex] = useState(0); const [focusIndex, setFocusIndex] = useState(null); + const [collapsedIds, setCollapsedIds] = useState>(() => new Set()); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); const inputRefs = useRef>([]); + const hiddenIndices = useMemo( + () => getHiddenNotebookIndices(items, collapsedIds), + [items, collapsedIds], + ); + + const visibleItems = useMemo( + () => items.filter((_, index) => !hiddenIndices.has(index)), + [items, hiddenIndices], + ); + useEffect(() => { try { const raw = localStorage.getItem(storageKey); if (!raw) { setItems([createNotebookOutlineItem()]); setActiveIndex(0); - return; + setSelectedIds(new Set()); + } else { + const parsed = JSON.parse(raw); + const restored = sanitizeNotebookItems(parsed); + setItems(restored); + setActiveIndex(0); + setSelectedIds(new Set()); } - const parsed = JSON.parse(raw); - const restored = sanitizeNotebookItems(parsed); - setItems(restored); - setActiveIndex(0); } catch { setItems([createNotebookOutlineItem()]); setActiveIndex(0); + setSelectedIds(new Set()); } }, [storageKey]); + useEffect(() => { + try { + const raw = localStorage.getItem(collapsedStorageKey); + if (!raw) { + setCollapsedIds(new Set()); + return; + } + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const ids = parsed.filter((id): id is string => typeof id === "string" && id.length > 0); + setCollapsedIds(new Set(ids)); + return; + } + } catch { + // ignore + } + setCollapsedIds(new Set()); + }, [collapsedStorageKey]); + useEffect(() => { localStorage.setItem(storageKey, JSON.stringify(items)); }, [items, storageKey]); + useEffect(() => { + localStorage.setItem(collapsedStorageKey, JSON.stringify([...collapsedIds])); + }, [collapsedIds, collapsedStorageKey]); + + useEffect(() => { + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const existing = new Set(items.map((item) => item.id)); + const next = new Set([...prev].filter((id) => existing.has(id))); + if (next.size === prev.size) return prev; + return next; + }); + }, [items]); + useEffect(() => { if (focusIndex == null) return; const target = inputRefs.current[focusIndex]; @@ -83,20 +139,105 @@ export function OutlineNotebookPanel({ setItems([createNotebookOutlineItem()]); setActiveIndex(0); setFocusIndex(0); + setSelectedIds(new Set()); + setCollapsedIds(new Set()); return; } setItems(imported); + setCollapsedIds(new Set()); + setSelectedIds(new Set()); setActiveIndex(0); setFocusIndex(0); }, [headings]); const handleClear = useCallback(() => { setItems([createNotebookOutlineItem()]); + setCollapsedIds(new Set()); + setSelectedIds(new Set()); setActiveIndex(0); setFocusIndex(0); }, []); + const handleToggleCollapse = useCallback((id: string) => { + setCollapsedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const handleExpandAll = useCallback(() => { + setCollapsedIds(new Set()); + }, []); + + const handleToggleSelectItem = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const handleSelectAllVisible = useCallback(() => { + setSelectedIds(new Set(visibleItems.map((item) => item.id))); + }, [visibleItems]); + + const handleClearSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const handleBatchIndent = useCallback(() => { + if (selectedIds.size === 0) return; + setItems((prev) => indentNotebookItemsByIds(prev, selectedIds)); + }, [selectedIds]); + + const handleBatchOutdent = useCallback(() => { + if (selectedIds.size === 0) return; + setItems((prev) => outdentNotebookItemsByIds(prev, selectedIds)); + }, [selectedIds]); + + const handleBatchRemove = useCallback(() => { + if (selectedIds.size === 0) return; + setItems((prev) => { + const removed = removeNotebookItemsByIds(prev, selectedIds); + setActiveIndex(removed.focusIndex); + setFocusIndex(removed.focusIndex); + return removed.items; + }); + setSelectedIds(new Set()); + }, [selectedIds]); + const handleKeyDown = useCallback((index: number, event: React.KeyboardEvent) => { + if (event.altKey && event.key === "ArrowUp") { + event.preventDefault(); + setItems((prev) => { + const moved = moveNotebookItem(prev, index, "up"); + setActiveIndex(moved.focusIndex); + setFocusIndex(moved.focusIndex); + return moved.items; + }); + return; + } + + if (event.altKey && event.key === "ArrowDown") { + event.preventDefault(); + setItems((prev) => { + const moved = moveNotebookItem(prev, index, "down"); + setActiveIndex(moved.focusIndex); + setFocusIndex(moved.focusIndex); + return moved.items; + }); + return; + } + if (event.key === "Tab") { event.preventDefault(); setItems((prev) => (event.shiftKey ? outdentNotebookItem(prev, index) : indentNotebookItem(prev, index))); @@ -161,46 +302,161 @@ export function OutlineNotebookPanel({ > 清空 + + + + + + + 已选 {selectedIds.size} 项
- {items.map((item, index) => ( -
- {item.level === 0 ? "•" : "◦"} - { - inputRefs.current[index] = el; - }} - className="flex-1 rounded-md border border-gh-border bg-gh-bg px-2 py-1 text-sm text-gh-text-primary outline-none focus:border-gh-text-secondary" - value={item.text} - onChange={(e) => { - const value = e.target.value; - setItems((prev) => updateNotebookItemText(prev, index, value)); - }} - onFocus={() => setActiveIndex(index)} - onKeyDown={(e) => handleKeyDown(index, e)} - placeholder="输入条目,Enter 新建,Tab 缩进" - /> - {item.headingId && ( + {items.map((item, index) => { + if (hiddenIndices.has(index)) return null; + + const hasChildren = hasNotebookChildren(items, index); + const isCollapsed = collapsedIds.has(item.id); + + return ( +
+ handleToggleSelectItem(item.id)} + aria-label="选择条目" + /> + + { + inputRefs.current[index] = el; + }} + className="flex-1 rounded-md border border-gh-border bg-gh-bg px-2 py-1 text-sm text-gh-text-primary outline-none focus:border-gh-text-secondary" + value={item.text} + onChange={(e) => { + const value = e.target.value; + setItems((prev) => updateNotebookItemText(prev, index, value)); + }} + onFocus={() => setActiveIndex(index)} + onKeyDown={(e) => handleKeyDown(index, e)} + placeholder="输入条目,Enter 新建,Tab 缩进" + /> + - )} -
- ))} + {item.headingId && ( + + )} +
+ ); + })}
- 快捷键:Enter 新建、Tab/Shift+Tab 调整层级、↑/↓ 切换、空条目 Backspace 删除 + 快捷键:Enter 新建、Tab/Shift+Tab 调整层级、↑/↓ 切换、Alt+↑/↓ 上下移动、空条目 Backspace 删除;支持多选批量缩进/删除
); diff --git a/frontend/src/utils/notebookOutline.test.ts b/frontend/src/utils/notebookOutline.test.ts index f14418d..e0d4a9f 100644 --- a/frontend/src/utils/notebookOutline.test.ts +++ b/frontend/src/utils/notebookOutline.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it } from "vitest"; import { createNotebookOutlineItem, + getHiddenNotebookIndices, + indentNotebookItemsByIds, importHeadingsAsNotebookItems, indentNotebookItem, insertNotebookItemAfter, + moveNotebookItem, + outdentNotebookItemsByIds, outdentNotebookItem, removeNotebookItemAt, + removeNotebookItemsByIds, sanitizeNotebookItems, updateNotebookItemText, } from "./notebookOutline"; @@ -79,4 +84,63 @@ describe("notebookOutline utils", () => { expect(sanitized[0].level).toBe(0); expect(sanitized[0].text).toBe(""); }); + + it("moves an item with its subtree up and down among siblings", () => { + const base = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "a1", text: "A.1", level: 1 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 0 }), + createNotebookOutlineItem({ id: "c", text: "C", level: 0 }), + ]; + + const movedUp = moveNotebookItem(base, 2, "up"); + expect(movedUp.focusIndex).toBe(0); + expect(movedUp.items.map((item) => item.id)).toEqual(["b", "a", "a1", "c"]); + + const movedDown = moveNotebookItem(base, 2, "down"); + expect(movedDown.focusIndex).toBe(3); + expect(movedDown.items.map((item) => item.id)).toEqual(["a", "a1", "c", "b"]); + }); + + it("computes hidden descendants from collapsed parent items", () => { + const base = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "a1", text: "A.1", level: 1 }), + createNotebookOutlineItem({ id: "a2", text: "A.2", level: 1 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 0 }), + ]; + + const hidden = getHiddenNotebookIndices(base, new Set(["a"])); + expect([...hidden]).toEqual([1, 2]); + + const noneHidden = getHiddenNotebookIndices(base, new Set(["a1"])); + expect([...noneHidden]).toEqual([]); + }); + + it("supports batch indent and outdent by selected ids", () => { + const base = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 0 }), + createNotebookOutlineItem({ id: "c", text: "C", level: 0 }), + ]; + + const indented = indentNotebookItemsByIds(base, new Set(["b", "c"])); + expect(indented.map((item) => item.level)).toEqual([0, 1, 1]); + + const outdented = outdentNotebookItemsByIds(indented, ["b", "c"]); + expect(outdented.map((item) => item.level)).toEqual([0, 0, 0]); + }); + + it("batch remove deletes selected parent subtree and keeps valid focus", () => { + const base = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "a1", text: "A.1", level: 1 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 0 }), + createNotebookOutlineItem({ id: "c", text: "C", level: 0 }), + ]; + + const removed = removeNotebookItemsByIds(base, new Set(["a", "c"])); + expect(removed.items.map((item) => item.id)).toEqual(["b"]); + expect(removed.focusIndex).toBe(0); + }); }); diff --git a/frontend/src/utils/notebookOutline.ts b/frontend/src/utils/notebookOutline.ts index c735104..54221e4 100644 --- a/frontend/src/utils/notebookOutline.ts +++ b/frontend/src/utils/notebookOutline.ts @@ -11,6 +11,9 @@ export interface NotebookHeadingLike { level: number; } +export type NotebookMoveDirection = "up" | "down"; +export type NotebookSelectionInput = Set | string[]; + const MAX_LEVEL = 6; function clampLevel(level: number): number { @@ -125,6 +128,205 @@ export function outdentNotebookItem( return next; } +function getSubtreeEnd(items: NotebookOutlineItem[], index: number): number { + if (index < 0 || index >= items.length) return index; + const baseLevel = items[index].level; + let end = index + 1; + while (end < items.length && items[end].level > baseLevel) { + end += 1; + } + return end; +} + +export function hasNotebookChildren(items: NotebookOutlineItem[], index: number): boolean { + if (index < 0 || index >= items.length - 1) return false; + const baseLevel = items[index].level; + for (let i = index + 1; i < items.length; i += 1) { + if (items[i].level <= baseLevel) return false; + return true; + } + return false; +} + +export function getHiddenNotebookIndices( + items: NotebookOutlineItem[], + collapsedIds: Set, +): Set { + const hidden = new Set(); + const collapseLevels: number[] = []; + + for (let i = 0; i < items.length; i += 1) { + const current = items[i]; + + while (collapseLevels.length > 0 && current.level <= collapseLevels[collapseLevels.length - 1]) { + collapseLevels.pop(); + } + + if (collapseLevels.length > 0) { + hidden.add(i); + } + + if (collapsedIds.has(current.id) && hasNotebookChildren(items, i)) { + collapseLevels.push(current.level); + } + } + + return hidden; +} + +export function moveNotebookItem( + items: NotebookOutlineItem[], + index: number, + direction: NotebookMoveDirection, +): { items: NotebookOutlineItem[]; focusIndex: number } { + if (index < 0 || index >= items.length) { + return { items, focusIndex: Math.max(0, Math.min(items.length - 1, index)) }; + } + + const currentLevel = items[index].level; + const currentEnd = getSubtreeEnd(items, index); + const currentBlock = items.slice(index, currentEnd); + + if (direction === "up") { + let prevStart = -1; + for (let i = index - 1; i >= 0; i -= 1) { + if (items[i].level < currentLevel) break; + if (items[i].level === currentLevel) { + prevStart = i; + break; + } + } + + if (prevStart === -1) return { items, focusIndex: index }; + + const prevEnd = getSubtreeEnd(items, prevStart); + const prevBlock = items.slice(prevStart, prevEnd); + const between = items.slice(prevEnd, index); + + return { + items: [ + ...items.slice(0, prevStart), + ...currentBlock, + ...between, + ...prevBlock, + ...items.slice(currentEnd), + ], + focusIndex: prevStart, + }; + } + + let nextStart = -1; + for (let i = currentEnd; i < items.length; i += 1) { + if (items[i].level < currentLevel) break; + if (items[i].level === currentLevel) { + nextStart = i; + break; + } + } + + if (nextStart === -1) return { items, focusIndex: index }; + + const nextEnd = getSubtreeEnd(items, nextStart); + const nextBlock = items.slice(nextStart, nextEnd); + const middle = items.slice(currentEnd, nextStart); + + const nextItems = [ + ...items.slice(0, index), + ...middle, + ...nextBlock, + ...currentBlock, + ...items.slice(nextEnd), + ]; + + return { + items: nextItems, + focusIndex: nextEnd - currentBlock.length, + }; +} + +function normalizeSelectionIds(selected: NotebookSelectionInput): Set { + if (selected instanceof Set) return new Set(selected); + return new Set(selected.filter((id) => typeof id === "string" && id.length > 0)); +} + +export function indentNotebookItemsByIds( + items: NotebookOutlineItem[], + selected: NotebookSelectionInput, +): NotebookOutlineItem[] { + const selectedIds = normalizeSelectionIds(selected); + if (selectedIds.size === 0) return items; + + let next = items; + for (let i = 0; i < next.length; i += 1) { + if (selectedIds.has(next[i].id)) { + next = indentNotebookItem(next, i); + } + } + return next; +} + +export function outdentNotebookItemsByIds( + items: NotebookOutlineItem[], + selected: NotebookSelectionInput, +): NotebookOutlineItem[] { + const selectedIds = normalizeSelectionIds(selected); + if (selectedIds.size === 0) return items; + + let next = items; + for (let i = 0; i < next.length; i += 1) { + if (selectedIds.has(next[i].id)) { + next = outdentNotebookItem(next, i); + } + } + return next; +} + +export function removeNotebookItemsByIds( + items: NotebookOutlineItem[], + selected: NotebookSelectionInput, +): { items: NotebookOutlineItem[]; focusIndex: number } { + const selectedIds = normalizeSelectionIds(selected); + if (selectedIds.size === 0) { + return { + items, + focusIndex: Math.max(0, Math.min(items.length - 1, 0)), + }; + } + + const toRemove = new Set(); + let firstRemovedIndex = -1; + + for (let i = 0; i < items.length; i += 1) { + const current = items[i]; + if (!selectedIds.has(current.id)) continue; + + if (firstRemovedIndex === -1) { + firstRemovedIndex = i; + } + + const end = getSubtreeEnd(items, i); + for (let j = i; j < end; j += 1) { + toRemove.add(items[j].id); + } + i = end - 1; + } + + if (toRemove.size === 0) { + return { + items, + focusIndex: Math.max(0, Math.min(items.length - 1, 0)), + }; + } + + const nextItems = items.filter((item) => !toRemove.has(item.id)); + if (nextItems.length === 0) { + return { items: [createNotebookOutlineItem()], focusIndex: 0 }; + } + + const focusIndex = Math.max(0, Math.min(nextItems.length - 1, firstRemovedIndex - 1)); + return { items: nextItems, focusIndex }; +} + export function removeNotebookItemAt( items: NotebookOutlineItem[], index: number, From dc22ad814ef078aaeef5faf966c3b4bb80700d58 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 16:07:52 +0800 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=A4=A7?= =?UTF-8?q?=E7=BA=B2=E7=AC=94=E8=AE=B0=E6=A8=A1=E5=BC=8F=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=9F=BA=E4=BA=8E=E6=96=87=E6=A1=A3=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E7=9A=84=E6=80=9D=E7=BB=B4=E5=AF=BC=E5=9B=BE?= =?UTF-8?q?=E4=B8=8E=E5=A4=A7=E7=BA=B2=E7=AC=94=E8=AE=B0=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- README.md | 3 +- docs/markdown-capabilities.md | 9 +- docs/quick-start-visual.md | 98 ++++ frontend/src/App.tsx | 14 +- frontend/src/components/BacklinksPanel.tsx | 5 +- .../src/components/DocumentMindmapPanel.tsx | 99 ++++ .../src/components/OutlineNotebookPanel.tsx | 442 +++--------------- frontend/src/utils/notebookOutline.test.ts | 27 ++ frontend/src/utils/notebookOutline.ts | 41 ++ 9 files changed, 343 insertions(+), 395 deletions(-) create mode 100644 docs/quick-start-visual.md create mode 100644 frontend/src/components/DocumentMindmapPanel.tsx diff --git a/README.md b/README.md index eb4b7c1..09c59e3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - 分组 文件分组管理 - 目录 文档目录面板 - 平铺 平铺 / 树形 树形侧边栏(支持拖拽排序与文件搜索) -- 大纲笔记模式(类幕布):左侧思维导图/可编辑大纲切换,右侧文档联动阅读(支持折叠子项、Tab 缩进、Alt+↑/↓ 调整顺序、多选批量编辑) +- 大纲笔记模式(类幕布):左侧思维导图/大纲笔记均基于当前文档标题解析,右侧文档联动阅读(支持标题定位) - YAML 前置元数据展示(可折叠元数据区域) - MDX 支持(渲染 Markdown,去除 `import`/`export`,转义 JSX 标签) - 宽布局 宽版 / 窄布局 窄版阅读宽度切换 @@ -254,6 +254,7 @@ $ make build 为了便于本地阅读和二次开发,仓库提供了以下中文文档: +- [可视化快速上手](docs/quick-start-visual.md) - [设计文档](docs/design.md) - [架构文档](docs/architecture.md) - [Markdown 能力清单](docs/markdown-capabilities.md) diff --git a/docs/markdown-capabilities.md b/docs/markdown-capabilities.md index 61f2eb2..9eebe15 100644 --- a/docs/markdown-capabilities.md +++ b/docs/markdown-capabilities.md @@ -64,12 +64,9 @@ - 当前标题高亮与平滑滚动定位 - 原文视图(Raw)与渲染视图切换 - 全局全文搜索后可跳转到目标文档并定位命中内容 -- 大纲笔记模式(类幕布):左侧大纲思维导图、右侧文档联动浏览 - - 支持可编辑条目(Enter 新建、Tab/Shift+Tab 调整层级) - - 支持折叠/展开子项与一键全部展开 - - 支持 Alt+↑/↓ 及按钮上下移动条目(含子项) - - 支持复选多条目并批量缩进/反缩进/删除 - - 支持从当前文档导入标题并一键定位到右侧内容 +- 大纲笔记模式(类幕布):左侧思维导图与大纲笔记均基于当前文档标题解析 + - 支持标题层级折叠/展开与一键全部展开 + - 支持点击标题定位到右侧文档对应位置 ## 4. 结构化关系能力 diff --git a/docs/quick-start-visual.md b/docs/quick-start-visual.md new file mode 100644 index 0000000..9ed1491 --- /dev/null +++ b/docs/quick-start-visual.md @@ -0,0 +1,98 @@ +# 可视化快速上手(3 分钟看到效果) + +如果你刚接触 `markview`,可按本文一步步体验真实效果(含图表、公式、类幕布模式)。 + +## 1) 一键启动示例会话 + +在仓库根目录执行: + +```console +$ markview -p 16275 -b localhost --no-open testdata/basic.md testdata/mermaid-flowchart.md testdata/plantuml.md testdata/svgbob-complex.md testdata/math.md +``` + +然后打开: + +- `http://localhost:16275` + +> 说明: +> +> - `-p 16275`:使用独立端口,避免和默认 `6275` 会话互相影响。 +> - `-b localhost`:仅本机可访问,避免出现远程访问安全确认提示。 + +## 2) 先看基础渲染能力 + +打开左侧文件列表,依次点击: + +1. `basic.md`:基础 Markdown(标题、列表、引用、链接) +2. `math.md`:LaTeX 数学公式(KaTeX) +3. `mermaid-flowchart.md`:Mermaid 图表 +4. `plantuml.md`:PlantUML 图表 +5. `svgbob-complex.md`:SVG Bob 图表 + +你会看到: + +- 页面右侧立即切换渲染内容 +- 图表块支持缩放/全屏等交互(不同图表能力略有差异) +- 目录、原文、复制、导出等按钮在文档右侧工具列 + +## 3) 体验“类幕布”模式 + +在页面顶部工具栏点击: + +- `Outline notebook`(大纲笔记)按钮 + +进入后会看到左右双栏: + +- 左栏:`思维导图` / `大纲笔记` 可切换 +- 右栏:当前文档渲染区(联动展示) + +### 3.1 思维导图视图 + +- 点击导图节点可在右侧打开对应文档 +- 适合做“文件关系总览” + +### 3.2 大纲笔记视图 + +该视图会实时解析“当前文档”的标题并展示: + +- 支持折叠/展开子项与 `全部展开` +- 点击任意标题可定位到右侧文档对应位置 +- 与顶部 `思维导图` 标签一致,均基于当前文档标题,而非全局文件关系 + +## 4) 常见问题(看不到效果时) + +### Q1:页面里看不到 “Outline notebook” 按钮 + +通常是连接到了旧进程或旧端口。 + +建议: + +1. 用 `markview --status --json` 看当前有哪些服务。 +2. 优先访问 `http://localhost:16275`(本文示例端口)。 +3. 必要时先停旧服务再重启: + +```console +$ markview --shutdown -p 6275 +$ markview --shutdown -p 16275 +``` + +### Q2:前台启动后窗口关闭,服务就没了 + +这是正常行为。`--foreground` 会跟随终端生命周期。若你希望后台常驻,去掉 `--foreground` 即可。 + +### Q3:如何确认是最新构建版本 + +页面右下角会显示版本与修订号,例如: + +- `markview 0.18.1 (HEAD)` + +如果显示的 revision 与当前仓库 `git rev-parse --short HEAD` 不一致,说明你正在访问的不是这次构建出的服务实例。 + +## 5) 下一步建议 + +体验完成后,建议再看两份文档: + +- `docs/markdown-capabilities.md`(功能清单) +- `docs/design.md`(设计意图与演进思路) + +这样能更快理解“功能有什么”与“为什么这样设计”。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e69e5e6..5112387 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { OutlineGraphView } from "./components/OutlineGraphView"; import { OutlineGravityView } from "./components/OutlineGravityView"; import { OutlineTreeView } from "./components/OutlineTreeView"; import { OutlineNotebookPanel } from "./components/OutlineNotebookPanel"; +import { DocumentMindmapPanel } from "./components/DocumentMindmapPanel"; import { isStaticMode } from "./utils/staticData"; const WIDTH_STORAGE_KEY = "markview-layout-width"; @@ -371,12 +372,6 @@ export function App() { setActiveFileId(fileId); }, []); - const handleNotebookFileSelect = useCallback((fileId: string, group: string) => { - setPendingSearchJump(null); - setActiveGroup(group); - setActiveFileId(fileId); - }, []); - const handleRemoveFile = useCallback(() => { if (activeFileId != null) { removeFile(activeFileId); @@ -722,10 +717,9 @@ export function App() {
{notebookLeftTab === "mindmap" ? ( - setShowGraph(false)} - onSelectFile={handleNotebookFileSelect} - embedded + ) : ( { const url = buildFileUrl(entry.group, entry.fileId); if (entry.group === activeGroup) { - // Same group: update URL in-place - window.history.pushState(null, "", `${url}`); - window.dispatchEvent(new PopStateEvent("popstate")); + // Same group: navigate in current tab to ensure file switch always works + window.location.assign(`${window.location.origin}${url}`); } else { window.open(`${window.location.origin}${url}`, "_blank", "noopener,noreferrer"); } diff --git a/frontend/src/components/DocumentMindmapPanel.tsx b/frontend/src/components/DocumentMindmapPanel.tsx new file mode 100644 index 0000000..aef0982 --- /dev/null +++ b/frontend/src/components/DocumentMindmapPanel.tsx @@ -0,0 +1,99 @@ +import { useMemo } from "react"; +import type { NotebookHeadingLike } from "../utils/notebookOutline"; + +interface MindmapNode { + id: string; + text: string; + level: number; + children: MindmapNode[]; +} + +interface DocumentMindmapPanelProps { + headings: NotebookHeadingLike[]; + onNavigateHeading?: (headingId: string) => void; +} + +function buildMindmapTree(headings: NotebookHeadingLike[]): MindmapNode[] { + const roots: MindmapNode[] = []; + const stack: MindmapNode[] = []; + + for (const heading of headings) { + const level = Math.max(1, Math.min(6, heading.level)); + const node: MindmapNode = { + id: heading.id, + text: heading.text, + level, + children: [], + }; + + while (stack.length > 0 && stack[stack.length - 1].level >= level) { + stack.pop(); + } + + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1].children.push(node); + } + + stack.push(node); + } + + return roots; +} + +function MindmapTree({ + nodes, + onNavigateHeading, +}: { + nodes: MindmapNode[]; + onNavigateHeading?: (headingId: string) => void; +}) { + if (nodes.length === 0) return null; + + return ( +
    + {nodes.map((node) => ( +
  • + + {node.children.length > 0 && ( +
    + +
    + )} +
  • + ))} +
+ ); +} + +export function DocumentMindmapPanel({ headings, onNavigateHeading }: DocumentMindmapPanelProps) { + const trees = useMemo(() => buildMindmapTree(headings), [headings]); + + if (trees.length === 0) { + return ( +
+ 当前文档暂无可解析标题 +
+ ); + } + + return ( +
+
+ 当前文档思维导图(基于标题解析) +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/OutlineNotebookPanel.tsx b/frontend/src/components/OutlineNotebookPanel.tsx index e774ade..b0712c7 100644 --- a/frontend/src/components/OutlineNotebookPanel.tsx +++ b/frontend/src/components/OutlineNotebookPanel.tsx @@ -1,24 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { NotebookHeadingLike, NotebookOutlineItem } from "../utils/notebookOutline"; -import { - createNotebookOutlineItem, - getHiddenNotebookIndices, - hasNotebookChildren, - indentNotebookItemsByIds, - importHeadingsAsNotebookItems, - indentNotebookItem, - insertNotebookItemAfter, - moveNotebookItem, - outdentNotebookItemsByIds, - outdentNotebookItem, - removeNotebookItemAt, - removeNotebookItemsByIds, - sanitizeNotebookItems, - updateNotebookItemText, -} from "../utils/notebookOutline"; - -const STORAGE_PREFIX = "markview-notebook-outline:"; -const COLLAPSED_STORAGE_PREFIX = "markview-notebook-outline-collapsed:"; +import { useMemo, useState } from "react"; +import type { NotebookHeadingLike } from "../utils/notebookOutline"; interface OutlineNotebookPanelProps { groupName: string; @@ -26,139 +7,62 @@ interface OutlineNotebookPanelProps { onNavigateHeading?: (headingId: string) => void; } -export function OutlineNotebookPanel({ - groupName, - headings, - onNavigateHeading, -}: OutlineNotebookPanelProps) { - const storageKey = useMemo(() => `${STORAGE_PREFIX}${groupName}`, [groupName]); - const collapsedStorageKey = useMemo(() => `${COLLAPSED_STORAGE_PREFIX}${groupName}`, [groupName]); - const [items, setItems] = useState(() => [createNotebookOutlineItem()]); - const [activeIndex, setActiveIndex] = useState(0); - const [focusIndex, setFocusIndex] = useState(null); - const [collapsedIds, setCollapsedIds] = useState>(() => new Set()); - const [selectedIds, setSelectedIds] = useState>(() => new Set()); - const inputRefs = useRef>([]); +interface ParsedHeadingItem { + id: string; + text: string; + level: number; +} - const hiddenIndices = useMemo( - () => getHiddenNotebookIndices(items, collapsedIds), - [items, collapsedIds], - ); +function hasChildren(items: ParsedHeadingItem[], index: number): boolean { + if (index < 0 || index >= items.length - 1) return false; + const baseLevel = items[index].level; + for (let i = index + 1; i < items.length; i += 1) { + if (items[i].level <= baseLevel) return false; + return true; + } + return false; +} - const visibleItems = useMemo( - () => items.filter((_, index) => !hiddenIndices.has(index)), - [items, hiddenIndices], - ); +function getHiddenIndices(items: ParsedHeadingItem[], collapsedIds: Set): Set { + const hidden = new Set(); + const collapseLevels: number[] = []; - useEffect(() => { - try { - const raw = localStorage.getItem(storageKey); - if (!raw) { - setItems([createNotebookOutlineItem()]); - setActiveIndex(0); - setSelectedIds(new Set()); - } else { - const parsed = JSON.parse(raw); - const restored = sanitizeNotebookItems(parsed); - setItems(restored); - setActiveIndex(0); - setSelectedIds(new Set()); - } - } catch { - setItems([createNotebookOutlineItem()]); - setActiveIndex(0); - setSelectedIds(new Set()); - } - }, [storageKey]); + for (let i = 0; i < items.length; i += 1) { + const current = items[i]; - useEffect(() => { - try { - const raw = localStorage.getItem(collapsedStorageKey); - if (!raw) { - setCollapsedIds(new Set()); - return; - } - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - const ids = parsed.filter((id): id is string => typeof id === "string" && id.length > 0); - setCollapsedIds(new Set(ids)); - return; - } - } catch { - // ignore + while (collapseLevels.length > 0 && current.level <= collapseLevels[collapseLevels.length - 1]) { + collapseLevels.pop(); } - setCollapsedIds(new Set()); - }, [collapsedStorageKey]); - - useEffect(() => { - localStorage.setItem(storageKey, JSON.stringify(items)); - }, [items, storageKey]); - - useEffect(() => { - localStorage.setItem(collapsedStorageKey, JSON.stringify([...collapsedIds])); - }, [collapsedIds, collapsedStorageKey]); - useEffect(() => { - setSelectedIds((prev) => { - if (prev.size === 0) return prev; - const existing = new Set(items.map((item) => item.id)); - const next = new Set([...prev].filter((id) => existing.has(id))); - if (next.size === prev.size) return prev; - return next; - }); - }, [items]); + if (collapseLevels.length > 0) { + hidden.add(i); + } - useEffect(() => { - if (focusIndex == null) return; - const target = inputRefs.current[focusIndex]; - if (!target) return; - target.focus(); - const length = target.value.length; - target.setSelectionRange(length, length); - setFocusIndex(null); - }, [focusIndex, items]); + if (collapsedIds.has(current.id) && hasChildren(items, i)) { + collapseLevels.push(current.level); + } + } - const moveFocus = useCallback((index: number) => { - if (index < 0 || index >= items.length) return; - setActiveIndex(index); - setFocusIndex(index); - }, [items.length]); + return hidden; +} - const handleAddItem = useCallback(() => { - setItems((prev) => { - const inserted = insertNotebookItemAfter(prev, activeIndex); - setActiveIndex(inserted.focusIndex); - setFocusIndex(inserted.focusIndex); - return inserted.items; - }); - }, [activeIndex]); +export function OutlineNotebookPanel({ + headings, + onNavigateHeading, +}: OutlineNotebookPanelProps) { + const [collapsedIds, setCollapsedIds] = useState>(() => new Set()); - const handleImportHeadings = useCallback(() => { - const imported = importHeadingsAsNotebookItems(headings); - if (imported.length === 0) { - setItems([createNotebookOutlineItem()]); - setActiveIndex(0); - setFocusIndex(0); - setSelectedIds(new Set()); - setCollapsedIds(new Set()); - return; - } - setItems(imported); - setCollapsedIds(new Set()); - setSelectedIds(new Set()); - setActiveIndex(0); - setFocusIndex(0); + const items = useMemo(() => { + return headings.map((heading) => ({ + id: heading.id, + text: heading.text, + level: Math.max(1, Math.min(6, heading.level)), + })); }, [headings]); - const handleClear = useCallback(() => { - setItems([createNotebookOutlineItem()]); - setCollapsedIds(new Set()); - setSelectedIds(new Set()); - setActiveIndex(0); - setFocusIndex(0); - }, []); + const hiddenIndices = useMemo(() => getHiddenIndices(items, collapsedIds), [items, collapsedIds]); - const handleToggleCollapse = useCallback((id: string) => { + const handleToggleCollapse = (id: string) => { setCollapsedIds((prev) => { const next = new Set(prev); if (next.has(id)) { @@ -168,140 +72,15 @@ export function OutlineNotebookPanel({ } return next; }); - }, []); + }; - const handleExpandAll = useCallback(() => { + const handleExpandAll = () => { setCollapsedIds(new Set()); - }, []); - - const handleToggleSelectItem = useCallback((id: string) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - const handleSelectAllVisible = useCallback(() => { - setSelectedIds(new Set(visibleItems.map((item) => item.id))); - }, [visibleItems]); - - const handleClearSelection = useCallback(() => { - setSelectedIds(new Set()); - }, []); - - const handleBatchIndent = useCallback(() => { - if (selectedIds.size === 0) return; - setItems((prev) => indentNotebookItemsByIds(prev, selectedIds)); - }, [selectedIds]); - - const handleBatchOutdent = useCallback(() => { - if (selectedIds.size === 0) return; - setItems((prev) => outdentNotebookItemsByIds(prev, selectedIds)); - }, [selectedIds]); - - const handleBatchRemove = useCallback(() => { - if (selectedIds.size === 0) return; - setItems((prev) => { - const removed = removeNotebookItemsByIds(prev, selectedIds); - setActiveIndex(removed.focusIndex); - setFocusIndex(removed.focusIndex); - return removed.items; - }); - setSelectedIds(new Set()); - }, [selectedIds]); - - const handleKeyDown = useCallback((index: number, event: React.KeyboardEvent) => { - if (event.altKey && event.key === "ArrowUp") { - event.preventDefault(); - setItems((prev) => { - const moved = moveNotebookItem(prev, index, "up"); - setActiveIndex(moved.focusIndex); - setFocusIndex(moved.focusIndex); - return moved.items; - }); - return; - } - - if (event.altKey && event.key === "ArrowDown") { - event.preventDefault(); - setItems((prev) => { - const moved = moveNotebookItem(prev, index, "down"); - setActiveIndex(moved.focusIndex); - setFocusIndex(moved.focusIndex); - return moved.items; - }); - return; - } - - if (event.key === "Tab") { - event.preventDefault(); - setItems((prev) => (event.shiftKey ? outdentNotebookItem(prev, index) : indentNotebookItem(prev, index))); - return; - } - - if (event.key === "Enter") { - event.preventDefault(); - setItems((prev) => { - const inserted = insertNotebookItemAfter(prev, index); - setActiveIndex(inserted.focusIndex); - setFocusIndex(inserted.focusIndex); - return inserted.items; - }); - return; - } - - if (event.key === "Backspace" && items[index]?.text === "" && items.length > 1) { - event.preventDefault(); - setItems((prev) => { - const removed = removeNotebookItemAt(prev, index); - setActiveIndex(removed.focusIndex); - setFocusIndex(removed.focusIndex); - return removed.items; - }); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - moveFocus(index - 1); - return; - } - - if (event.key === "ArrowDown") { - event.preventDefault(); - moveFocus(index + 1); - } - }, [items, moveFocus]); + }; return (
- - - - - - - - - 已选 {selectedIds.size} 项 + 当前文档解析到 {items.length} 个标题
+ {items.length === 0 && ( +
+ 当前文档暂无标题,请先在右侧文档中使用 `#`/`##` 等标题语法。 +
+ )} {items.map((item, index) => { if (hiddenIndices.has(index)) return null; - const hasChildren = hasNotebookChildren(items, index); + const hasChild = hasChildren(items, index); const isCollapsed = collapsedIds.has(item.id); return (
- handleToggleSelectItem(item.id)} - aria-label="选择条目" - /> - { - inputRefs.current[index] = el; - }} - className="flex-1 rounded-md border border-gh-border bg-gh-bg px-2 py-1 text-sm text-gh-text-primary outline-none focus:border-gh-text-secondary" - value={item.text} - onChange={(e) => { - const value = e.target.value; - setItems((prev) => updateNotebookItemText(prev, index, value)); - }} - onFocus={() => setActiveIndex(index)} - onKeyDown={(e) => handleKeyDown(index, e)} - placeholder="输入条目,Enter 新建,Tab 缩进" - /> - - {item.headingId && ( - - )}
); })}
- 快捷键:Enter 新建、Tab/Shift+Tab 调整层级、↑/↓ 切换、Alt+↑/↓ 上下移动、空条目 Backspace 删除;支持多选批量缩进/删除 + 大纲笔记已切换为当前文档标题解析视图(点击任一标题可定位到右侧内容)。
); diff --git a/frontend/src/utils/notebookOutline.test.ts b/frontend/src/utils/notebookOutline.test.ts index e0d4a9f..5cdf72d 100644 --- a/frontend/src/utils/notebookOutline.test.ts +++ b/frontend/src/utils/notebookOutline.test.ts @@ -6,6 +6,7 @@ import { importHeadingsAsNotebookItems, indentNotebookItem, insertNotebookItemAfter, + moveNotebookItemBeforeById, moveNotebookItem, outdentNotebookItemsByIds, outdentNotebookItem, @@ -143,4 +144,30 @@ describe("notebookOutline utils", () => { expect(removed.items.map((item) => item.id)).toEqual(["b"]); expect(removed.focusIndex).toBe(0); }); + + it("moves subtree before another item by id", () => { + const base = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "a1", text: "A.1", level: 1 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 0 }), + createNotebookOutlineItem({ id: "c", text: "C", level: 0 }), + ]; + + const moved = moveNotebookItemBeforeById(base, "c", "a"); + expect(moved.items.map((item) => item.id)).toEqual(["c", "a", "a1", "b"]); + expect(moved.focusIndex).toBe(0); + }); + + it("does not move item into its own subtree", () => { + const base = [ + createNotebookOutlineItem({ id: "a", text: "A", level: 0 }), + createNotebookOutlineItem({ id: "a1", text: "A.1", level: 1 }), + createNotebookOutlineItem({ id: "a2", text: "A.2", level: 2 }), + createNotebookOutlineItem({ id: "b", text: "B", level: 0 }), + ]; + + const moved = moveNotebookItemBeforeById(base, "a", "a2"); + expect(moved.items.map((item) => item.id)).toEqual(["a", "a1", "a2", "b"]); + expect(moved.focusIndex).toBe(0); + }); }); diff --git a/frontend/src/utils/notebookOutline.ts b/frontend/src/utils/notebookOutline.ts index 54221e4..8b2d232 100644 --- a/frontend/src/utils/notebookOutline.ts +++ b/frontend/src/utils/notebookOutline.ts @@ -244,6 +244,47 @@ export function moveNotebookItem( }; } +export function moveNotebookItemBeforeById( + items: NotebookOutlineItem[], + draggedId: string, + targetId: string, +): { items: NotebookOutlineItem[]; focusIndex: number } { + if (!draggedId || !targetId || draggedId === targetId) { + return { items, focusIndex: 0 }; + } + + const draggedIndex = items.findIndex((item) => item.id === draggedId); + const targetIndex = items.findIndex((item) => item.id === targetId); + + if (draggedIndex === -1 || targetIndex === -1) { + return { items, focusIndex: Math.max(0, draggedIndex) }; + } + + const draggedEnd = getSubtreeEnd(items, draggedIndex); + // Prevent moving into itself/its descendants + if (targetIndex >= draggedIndex && targetIndex < draggedEnd) { + return { items, focusIndex: draggedIndex }; + } + + const draggedBlock = items.slice(draggedIndex, draggedEnd); + const remaining = [...items.slice(0, draggedIndex), ...items.slice(draggedEnd)]; + + const adjustedTargetIndex = targetIndex > draggedIndex + ? targetIndex - draggedBlock.length + : targetIndex; + + const nextItems = [ + ...remaining.slice(0, adjustedTargetIndex), + ...draggedBlock, + ...remaining.slice(adjustedTargetIndex), + ]; + + return { + items: nextItems, + focusIndex: Math.max(0, adjustedTargetIndex), + }; +} + function normalizeSelectionIds(selected: NotebookSelectionInput): Set { if (selected instanceof Set) return new Set(selected); return new Set(selected.filter((id) => typeof id === "string" && id.length > 0)); From 0eb3a60e849a2453b87117913bd3e34cae98635c Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 16:53:43 +0800 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E6=A0=87=E9=A2=98=E7=BB=84=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=9C=A8=20Markdown=20=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E4=B8=AD=E6=8A=98=E5=8F=A0/=E5=B1=95=E5=BC=80=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 51 +----- .../src/components/MarkdownViewer.test.tsx | 51 ++++++ frontend/src/components/MarkdownViewer.tsx | 172 +++++++++++++++++- 3 files changed, 227 insertions(+), 47 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5112387..593c47a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,7 +26,6 @@ import { captureArticleForMergedPdf, exportMergedPdfFromSnapshots } from "./util import { OutlineGraphView } from "./components/OutlineGraphView"; import { OutlineGravityView } from "./components/OutlineGravityView"; import { OutlineTreeView } from "./components/OutlineTreeView"; -import { OutlineNotebookPanel } from "./components/OutlineNotebookPanel"; import { DocumentMindmapPanel } from "./components/DocumentMindmapPanel"; import { isStaticMode } from "./utils/staticData"; @@ -34,7 +33,6 @@ const WIDTH_STORAGE_KEY = "markview-layout-width"; const VIEWMODE_STORAGE_KEY = "markview-sidebar-viewmode"; const PDF_OPEN_FILE_PARAM = "markview_open"; const PDF_OPEN_FROM_PARAM = "markview_from"; -const NOTEBOOK_LEFT_TAB_KEY = "markview-notebook-left-tab"; interface PendingPdfOpenRequest { fromFileId: string; @@ -89,14 +87,6 @@ export function App() { const [showGraph, setShowGraph] = useState(false); const [graphViewMode, setGraphViewMode] = useState<"link" | "outline" | "gravity" | "tree" | "notebook">("link"); const [isExportingAllPdf, setIsExportingAllPdf] = useState(false); - const [notebookLeftTab, setNotebookLeftTab] = useState<"mindmap" | "outline">(() => { - try { - const raw = localStorage.getItem(NOTEBOOK_LEFT_TAB_KEY); - return raw === "outline" ? "outline" : "mindmap"; - } catch { - return "mindmap"; - } - }); const [status, setStatus] = useState(null); const knownFileIds = useRef>(new Set()); const [initialFileId, setInitialFileId] = useState(() => { @@ -321,14 +311,6 @@ export function App() { } }, [isWide]); - useEffect(() => { - try { - localStorage.setItem(NOTEBOOK_LEFT_TAB_KEY, notebookLeftTab); - } catch { - /* ignore */ - } - }, [notebookLeftTab]); - const handleViewModeToggle = useCallback(() => { setViewModes((prev) => { const current = prev[activeGroup] ?? "flat"; @@ -691,22 +673,7 @@ export function App() {
-
- - -
+
当前文档思维导图
- {notebookLeftTab === "mindmap" ? ( - - ) : ( - - )} +
diff --git a/frontend/src/components/MarkdownViewer.test.tsx b/frontend/src/components/MarkdownViewer.test.tsx index 7360287..34a3a20 100644 --- a/frontend/src/components/MarkdownViewer.test.tsx +++ b/frontend/src/components/MarkdownViewer.test.tsx @@ -104,4 +104,55 @@ describe("MarkdownViewer link opening", () => { }); expect(screen.queryByText(/^无法打开链接:/)).not.toBeInTheDocument(); }); + + it("toggles section visibility when clicking headings", async () => { + vi.mocked(fetchFileContent).mockResolvedValue({ + content: `# 第一章 + +段落一 + +## 子节 + +段落二 + +# 第二章 + +段落三`, + baseDir: "/tmp", + }); + + render( + { }} + onHeadingsChange={() => { }} + isTocOpen={false} + onTocToggle={() => { }} + onRemoveFile={() => { }} + isWide={false} + />, + ); + + const heading = await screen.findByRole("heading", { name: /第一章/ }); + expect(await screen.findByText("段落一")).toBeVisible(); + expect(await screen.findByText("段落二")).toBeVisible(); + expect(await screen.findByRole("heading", { name: /第二章/ })).toBeVisible(); + + fireEvent.click(heading); + + await waitFor(() => { + expect(screen.getByText("段落一")).not.toBeVisible(); + expect(screen.getByText("段落二")).not.toBeVisible(); + expect(screen.getByRole("heading", { name: /第二章/ })).toBeVisible(); + }); + + fireEvent.click(screen.getByRole("heading", { name: /第一章/ })); + + await waitFor(() => { + expect(screen.getByText("段落一")).toBeVisible(); + expect(screen.getByText("段落二")).toBeVisible(); + }); + }); }); diff --git a/frontend/src/components/MarkdownViewer.tsx b/frontend/src/components/MarkdownViewer.tsx index 1786198..53923e1 100644 --- a/frontend/src/components/MarkdownViewer.tsx +++ b/frontend/src/components/MarkdownViewer.tsx @@ -1724,6 +1724,47 @@ function RawView({ content }: { content: string }) { ); } +interface CollapsibleHeadingProps extends React.HTMLAttributes { + as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; + collapsed: boolean; + onToggleCollapse?: () => void; +} + +function CollapsibleHeading({ + as, + collapsed, + onToggleCollapse, + className, + children, + onClick, + id, + ...props +}: CollapsibleHeadingProps) { + const Tag = as; + const canToggle = typeof id === "string" && id.length > 0; + + return ( + { + onClick?.(e); + if (!canToggle) return; + const target = e.target as HTMLElement; + if (target.closest("a")) return; + onToggleCollapse?.(); + }} + title={canToggle ? (collapsed ? "点击展开该标题内容" : "点击折叠该标题内容") : undefined} + {...props} + > + + {canToggle ? (collapsed ? "▶" : "▼") : "•"} + + {children} + + ); +} + export function MarkdownViewer({ fileId, fileName, @@ -1741,6 +1782,7 @@ export function MarkdownViewer({ const [content, setContent] = useState(""); const [loading, setLoading] = useState(true); const [isRawView, setIsRawView] = useState(false); + const [collapsedHeadingIds, setCollapsedHeadingIds] = useState>(() => new Set()); const [linkOpenError, setLinkOpenError] = useState(null); const articleRef = useRef(null); const [prevFetchKey, setPrevFetchKey] = useState({ fileId, revision }); @@ -1771,6 +1813,22 @@ export function MarkdownViewer({ }; }, [fileId, revision]); + useEffect(() => { + setCollapsedHeadingIds(new Set()); + }, [fileId, revision]); + + const toggleHeadingCollapse = useCallback((headingId: string) => { + setCollapsedHeadingIds((prev) => { + const next = new Set(prev); + if (next.has(headingId)) { + next.delete(headingId); + } else { + next.add(headingId); + } + return next; + }); + }, []); + const handleLinkClick = useCallback( async (e: React.MouseEvent, href: string, anchor: string | null) => { e.preventDefault(); @@ -1797,6 +1855,78 @@ export function MarkdownViewer({ const components: Components = useMemo( () => ({ pre: ({ children }) => <>{children}, + h1: ({ node: _node, children, id, className, ...props }) => ( + toggleHeadingCollapse(id) : undefined} + {...props} + > + {children} + + ), + h2: ({ node: _node, children, id, className, ...props }) => ( + toggleHeadingCollapse(id) : undefined} + {...props} + > + {children} + + ), + h3: ({ node: _node, children, id, className, ...props }) => ( + toggleHeadingCollapse(id) : undefined} + {...props} + > + {children} + + ), + h4: ({ node: _node, children, id, className, ...props }) => ( + toggleHeadingCollapse(id) : undefined} + {...props} + > + {children} + + ), + h5: ({ node: _node, children, id, className, ...props }) => ( + toggleHeadingCollapse(id) : undefined} + {...props} + > + {children} + + ), + h6: ({ node: _node, children, id, className, ...props }) => ( + toggleHeadingCollapse(id) : undefined} + {...props} + > + {children} + + ), code: ({ className, children, ...props }) => { const language = extractLanguage(className); const code = String(children).replace(/\n$/, ""); @@ -1861,7 +1991,7 @@ export function MarkdownViewer({ } }, }), - [fileId, handleLinkClick], + [collapsedHeadingIds, fileId, handleLinkClick, toggleHeadingCollapse], ); const parsed = useMemo( @@ -1890,6 +2020,46 @@ export function MarkdownViewer({ ); }, [content, isRawView, parsed, components, fileName]); + useEffect(() => { + const article = articleRef.current; + if (!article || loading || isRawView) return; + + const resetHidden = () => { + const hiddenEls = article.querySelectorAll("[data-heading-collapsed-hidden='1']"); + hiddenEls.forEach((el) => { + el.style.removeProperty("display"); + el.removeAttribute("data-heading-collapsed-hidden"); + }); + }; + + resetHidden(); + if (collapsedHeadingIds.size === 0) return; + + const headingEls = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6")); + + for (const headingEl of headingEls) { + if (!headingEl.id || !collapsedHeadingIds.has(headingEl.id)) continue; + + const level = parseInt(headingEl.tagName.slice(1), 10); + let cursor = headingEl.nextElementSibling as HTMLElement | null; + + while (cursor) { + if (/^H[1-6]$/.test(cursor.tagName)) { + const nextLevel = parseInt(cursor.tagName.slice(1), 10); + if (nextLevel <= level) break; + } + + cursor.style.display = "none"; + cursor.setAttribute("data-heading-collapsed-hidden", "1"); + cursor = cursor.nextElementSibling as HTMLElement | null; + } + } + + return () => { + resetHidden(); + }; + }, [collapsedHeadingIds, isRawView, loading, renderedContent]); + const prevHeadingsKey = useRef(""); useEffect(() => { const newHeadings: TocHeading[] = []; From 6d76201f9de37026c3f324fd0a9f7909a4e2b473 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 18:02:28 +0800 Subject: [PATCH 12/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=80=9D=E7=BB=B4=E5=AF=BC=E5=9B=BE=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BB=8E=20Markdown=20=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E7=94=9F=E6=88=90=E6=80=9D=E7=BB=B4=E5=AF=BC=E5=9B=BE?= =?UTF-8?q?=E5=B9=B6=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 1 + .../src/components/DocumentMindmapPanel.tsx | 181 +++++++++++------- frontend/src/utils/documentMindmap.test.ts | 44 +++++ frontend/src/utils/documentMindmap.ts | 74 +++++++ 4 files changed, 233 insertions(+), 67 deletions(-) create mode 100644 frontend/src/utils/documentMindmap.test.ts create mode 100644 frontend/src/utils/documentMindmap.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 593c47a..463969d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -685,6 +685,7 @@ export function App() {
diff --git a/frontend/src/components/DocumentMindmapPanel.tsx b/frontend/src/components/DocumentMindmapPanel.tsx index aef0982..8988aef 100644 --- a/frontend/src/components/DocumentMindmapPanel.tsx +++ b/frontend/src/components/DocumentMindmapPanel.tsx @@ -1,84 +1,97 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import mermaid from "mermaid"; import type { NotebookHeadingLike } from "../utils/notebookOutline"; - -interface MindmapNode { - id: string; - text: string; - level: number; - children: MindmapNode[]; -} +import { buildDocumentMindmapMermaid } from "../utils/documentMindmap"; +import { ZoomPanView } from "./ZoomPanView"; interface DocumentMindmapPanelProps { headings: NotebookHeadingLike[]; + fileName?: string; onNavigateHeading?: (headingId: string) => void; } -function buildMindmapTree(headings: NotebookHeadingLike[]): MindmapNode[] { - const roots: MindmapNode[] = []; - const stack: MindmapNode[] = []; +function getMermaidTheme(): "dark" | "default" { + return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "default"; +} - for (const heading of headings) { - const level = Math.max(1, Math.min(6, heading.level)); - const node: MindmapNode = { - id: heading.id, - text: heading.text, - level, - children: [], - }; +export function DocumentMindmapPanel({ + headings, + fileName, + onNavigateHeading, +}: DocumentMindmapPanelProps) { + const [svg, setSvg] = useState(""); + const [isRendering, setIsRendering] = useState(false); + const [renderError, setRenderError] = useState(null); + const [themeVersion, setThemeVersion] = useState(0); + const svgContainerRef = useRef(null); + const mermaidCode = useMemo( + () => buildDocumentMindmapMermaid(headings, fileName || "当前文档"), + [headings, fileName], + ); - while (stack.length > 0 && stack[stack.length - 1].level >= level) { - stack.pop(); - } + useEffect(() => { + const observer = new MutationObserver(() => setThemeVersion((value) => value + 1)); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, []); - if (stack.length === 0) { - roots.push(node); - } else { - stack[stack.length - 1].children.push(node); + useEffect(() => { + if (headings.length === 0) { + setSvg(""); + setRenderError(null); + setIsRendering(false); + return; } - stack.push(node); - } + let cancelled = false; + setIsRendering(true); + setRenderError(null); - return roots; -} + const hiddenContainer = document.createElement("div"); + hiddenContainer.style.position = "absolute"; + hiddenContainer.style.left = "-9999px"; + hiddenContainer.style.top = "-9999px"; + hiddenContainer.style.width = "1200px"; + document.body.appendChild(hiddenContainer); -function MindmapTree({ - nodes, - onNavigateHeading, -}: { - nodes: MindmapNode[]; - onNavigateHeading?: (headingId: string) => void; -}) { - if (nodes.length === 0) return null; + mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() }); + const id = `document-mindmap-${Date.now()}-${Math.random().toString(16).slice(2)}`; - return ( -
    - {nodes.map((node) => ( -
  • - - {node.children.length > 0 && ( -
    - -
    - )} -
  • - ))} -
- ); -} + mermaid + .render(id, mermaidCode, hiddenContainer) + .then(({ svg: rendered }) => { + if (!cancelled) { + setSvg(rendered); + } + }) + .catch((err) => { + if (!cancelled) { + setSvg(""); + setRenderError(err instanceof Error ? err.message : "思维导图渲染失败"); + } + }) + .finally(() => { + hiddenContainer.remove(); + if (!cancelled) { + setIsRendering(false); + } + }); + + return () => { + cancelled = true; + hiddenContainer.remove(); + }; + }, [headings, mermaidCode, themeVersion]); -export function DocumentMindmapPanel({ headings, onNavigateHeading }: DocumentMindmapPanelProps) { - const trees = useMemo(() => buildMindmapTree(headings), [headings]); + useEffect(() => { + if (!svgContainerRef.current) return; + svgContainerRef.current.innerHTML = svg; + }, [svg]); - if (trees.length === 0) { + if (headings.length === 0) { return (
当前文档暂无可解析标题 @@ -89,10 +102,44 @@ export function DocumentMindmapPanel({ headings, onNavigateHeading }: DocumentMi return (
- 当前文档思维导图(基于标题解析) + Markdown 标题已自动转换为思维导图(滚轮缩放、拖拽平移) +
+ {renderError && ( +
+ 思维导图渲染失败:{renderError} +
+ )} +
+ {svg.length > 0 ? ( + +
+ + ) : ( +
+ {isRendering ? "正在生成思维导图..." : "暂无可渲染的思维导图"} +
+ )}
-
- +
+
标题导航
+
+
+ {headings.map((heading) => ( + + ))} +
+
); diff --git a/frontend/src/utils/documentMindmap.test.ts b/frontend/src/utils/documentMindmap.test.ts new file mode 100644 index 0000000..0f08ea8 --- /dev/null +++ b/frontend/src/utils/documentMindmap.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + buildDocumentMindmapMermaid, + buildDocumentMindmapTree, + sanitizeMermaidMindmapText, +} from "./documentMindmap"; + +describe("documentMindmap utils", () => { + it("builds hierarchical tree from heading levels", () => { + const tree = buildDocumentMindmapTree([ + { id: "h1", text: "Overview", level: 1 }, + { id: "h2", text: "Install", level: 2 }, + { id: "h3", text: "CLI", level: 2 }, + { id: "h4", text: "Flags", level: 3 }, + ]); + + expect(tree).toHaveLength(1); + expect(tree[0].text).toBe("Overview"); + expect(tree[0].children.map((item) => item.text)).toEqual(["Install", "CLI"]); + expect(tree[0].children[1].children[0].text).toBe("Flags"); + }); + + it("converts markdown headings into Mermaid mindmap code", () => { + const code = buildDocumentMindmapMermaid( + [ + { id: "h1", text: "Overview", level: 1 }, + { id: "h2", text: "Install", level: 2 }, + { id: "h3", text: "Usage", level: 2 }, + ], + "README.md", + ); + + expect(code).toContain("mindmap"); + expect(code).toContain("root((README.md))"); + expect(code).toContain(" Overview"); + expect(code).toContain(" Install"); + expect(code).toContain(" Usage"); + }); + + it("sanitizes unsupported characters for Mermaid mindmap labels", () => { + const text = sanitizeMermaidMindmapText('Title (A) [x] {y}\n"quoted"'); + expect(text).toBe("Title A x y 'quoted'"); + }); +}); diff --git a/frontend/src/utils/documentMindmap.ts b/frontend/src/utils/documentMindmap.ts new file mode 100644 index 0000000..828f7ae --- /dev/null +++ b/frontend/src/utils/documentMindmap.ts @@ -0,0 +1,74 @@ +import type { NotebookHeadingLike } from "./notebookOutline"; + +export interface DocumentMindmapNode { + id: string; + text: string; + level: number; + children: DocumentMindmapNode[]; +} + +function clampHeadingLevel(level: number): number { + return Math.max(1, Math.min(6, Math.floor(level))); +} + +export function sanitizeMermaidMindmapText(input: string): string { + const cleaned = input + .replace(/[\n\r\t]+/g, " ") + .replace(/[()\[\]{}]/g, " ") + .replace(/"/g, "'") + .replace(/\s+/g, " ") + .trim(); + + return cleaned.length > 0 ? cleaned : "未命名标题"; +} + +export function buildDocumentMindmapTree(headings: NotebookHeadingLike[]): DocumentMindmapNode[] { + const roots: DocumentMindmapNode[] = []; + const stack: DocumentMindmapNode[] = []; + + for (const heading of headings) { + const level = clampHeadingLevel(heading.level); + const node: DocumentMindmapNode = { + id: heading.id, + text: sanitizeMermaidMindmapText(heading.text), + level, + children: [], + }; + + while (stack.length > 0 && stack[stack.length - 1].level >= level) { + stack.pop(); + } + + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1].children.push(node); + } + + stack.push(node); + } + + return roots; +} + +function appendMindmapNodeLines(lines: string[], node: DocumentMindmapNode, indent: number): void { + lines.push(`${" ".repeat(indent)}${node.text}`); + for (const child of node.children) { + appendMindmapNodeLines(lines, child, indent + 2); + } +} + +export function buildDocumentMindmapMermaid( + headings: NotebookHeadingLike[], + rootTitle = "当前文档", +): string { + const root = sanitizeMermaidMindmapText(rootTitle); + const trees = buildDocumentMindmapTree(headings); + const lines = ["mindmap", ` root((${root}))`]; + + for (const node of trees) { + appendMindmapNodeLines(lines, node, 4); + } + + return lines.join("\n"); +} From 91d7c76dd212820ec89f02f81805b7834cd03845 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 18:19:16 +0800 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=BC=8F=E6=80=9D=E7=BB=B4=E5=AF=BC=E5=9B=BE=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=9F=BA=E4=BA=8E=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=A0=87=E9=A2=98=E7=94=9F=E6=88=90=E5=9B=BE=E5=BD=A2?= =?UTF-8?q?=E6=A0=91=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/DocumentMindmapPanel.tsx | 304 ++++++++++++------ frontend/src/utils/documentMindmap.test.ts | 17 + frontend/src/utils/documentMindmap.ts | 60 ++++ 3 files changed, 288 insertions(+), 93 deletions(-) diff --git a/frontend/src/components/DocumentMindmapPanel.tsx b/frontend/src/components/DocumentMindmapPanel.tsx index 8988aef..d866ed1 100644 --- a/frontend/src/components/DocumentMindmapPanel.tsx +++ b/frontend/src/components/DocumentMindmapPanel.tsx @@ -1,8 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import mermaid from "mermaid"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Graph, idOf, treeToGraphData } from "@antv/g6"; import type { NotebookHeadingLike } from "../utils/notebookOutline"; -import { buildDocumentMindmapMermaid } from "../utils/documentMindmap"; -import { ZoomPanView } from "./ZoomPanView"; +import { + buildDocumentMindmapGraphTree, + type DocumentMindmapGraphNode, +} from "../utils/documentMindmap"; interface DocumentMindmapPanelProps { headings: NotebookHeadingLike[]; @@ -10,8 +12,25 @@ interface DocumentMindmapPanelProps { onNavigateHeading?: (headingId: string) => void; } -function getMermaidTheme(): "dark" | "default" { - return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "default"; +const DIRECTION_STORAGE_KEY = "markview-document-mindmap-direction"; + +const BRANCH_PALETTE = [ + { stroke: "#5B8FF9", fillLight: "#EEF4FF", fillDark: "#1B2B4D" }, + { stroke: "#5AD8A6", fillLight: "#ECFFF7", fillDark: "#173B33" }, + { stroke: "#F6BD16", fillLight: "#FFF8E8", fillDark: "#493B14" }, + { stroke: "#945FB9", fillLight: "#F6EEFF", fillDark: "#332043" }, + { stroke: "#E8684A", fillLight: "#FFF1ED", fillDark: "#4B241C" }, + { stroke: "#6DC8EC", fillLight: "#EBFAFF", fillDark: "#193745" }, +]; + +function getNodeWidth(text: string, isRoot: boolean): number { + const width = text.length * 9 + (isRoot ? 48 : 30); + return Math.max(isRoot ? 120 : 80, Math.min(width, isRoot ? 340 : 280)); +} + +function getBranchColor(branchIndex: number) { + const palette = BRANCH_PALETTE[Math.abs(branchIndex) % BRANCH_PALETTE.length]; + return palette ?? BRANCH_PALETTE[0]; } export function DocumentMindmapPanel({ @@ -19,15 +38,58 @@ export function DocumentMindmapPanel({ fileName, onNavigateHeading, }: DocumentMindmapPanelProps) { - const [svg, setSvg] = useState(""); - const [isRendering, setIsRendering] = useState(false); - const [renderError, setRenderError] = useState(null); + const [depthMode, setDepthMode] = useState<"summary" | "full">("summary"); + const [layoutDirection, setLayoutDirection] = useState<"H" | "V">(() => { + try { + const raw = localStorage.getItem(DIRECTION_STORAGE_KEY); + return raw === "V" ? "V" : "H"; + } catch { + return "H"; + } + }); const [themeVersion, setThemeVersion] = useState(0); - const svgContainerRef = useRef(null); - const mermaidCode = useMemo( - () => buildDocumentMindmapMermaid(headings, fileName || "当前文档"), + const containerRef = useRef(null); + const graphRef = useRef | null>(null); + const graphTree = useMemo( + () => buildDocumentMindmapGraphTree(headings, fileName || "当前文档"), [headings, fileName], ); + const visibleTree = useMemo(() => { + if (depthMode === "full") return graphTree; + + const cloneNode = (node: DocumentMindmapGraphNode): DocumentMindmapGraphNode => { + const level = node.data.level ?? 0; + if (!node.children || node.children.length === 0) { + return { ...node }; + } + if (level >= 2) { + return { ...node, children: undefined }; + } + return { + ...node, + children: node.children.map(cloneNode), + }; + }; + + return cloneNode(graphTree); + }, [depthMode, graphTree]); + const graphData = useMemo(() => { + return treeToGraphData(visibleTree, { + getNodeData: (datum, depth) => { + const node = datum as DocumentMindmapGraphNode; + const base = { + id: node.id, + data: node.data, + value: node.data.value, + depth, + }; + return node.children + ? { ...base, children: node.children.map((item) => item.id) } + : base; + }, + getChildren: (datum) => (datum as DocumentMindmapGraphNode).children ?? [], + }); + }, [visibleTree]); useEffect(() => { const observer = new MutationObserver(() => setThemeVersion((value) => value + 1)); @@ -39,57 +101,127 @@ export function DocumentMindmapPanel({ }, []); useEffect(() => { - if (headings.length === 0) { - setSvg(""); - setRenderError(null); - setIsRendering(false); + if (!containerRef.current || headings.length === 0) { return; } - let cancelled = false; - setIsRendering(true); - setRenderError(null); - - const hiddenContainer = document.createElement("div"); - hiddenContainer.style.position = "absolute"; - hiddenContainer.style.left = "-9999px"; - hiddenContainer.style.top = "-9999px"; - hiddenContainer.style.width = "1200px"; - document.body.appendChild(hiddenContainer); - - mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() }); - const id = `document-mindmap-${Date.now()}-${Math.random().toString(16).slice(2)}`; - - mermaid - .render(id, mermaidCode, hiddenContainer) - .then(({ svg: rendered }) => { - if (!cancelled) { - setSvg(rendered); - } - }) - .catch((err) => { - if (!cancelled) { - setSvg(""); - setRenderError(err instanceof Error ? err.message : "思维导图渲染失败"); - } - }) - .finally(() => { - hiddenContainer.remove(); - if (!cancelled) { - setIsRendering(false); - } - }); + const isDark = document.documentElement.getAttribute("data-theme") === "dark"; + let graph: InstanceType | null = null; + + graph = new Graph({ + container: containerRef.current, + width: containerRef.current.offsetWidth, + height: containerRef.current.offsetHeight, + data: graphData, + autoFit: "view", + layout: { + type: "mindmap", + direction: layoutDirection, + getHeight: () => 42, + getWidth: (node: { id: string; value?: string; data?: { value?: string } }) => + getNodeWidth((node.value ?? node.data?.value ?? "") as string, node.id === "document-root"), + getVGap: () => 20, + getHGap: () => 72, + animation: false, + }, + node: { + style: (datum) => { + const nodeId = idOf(datum); + const data = datum.data as DocumentMindmapGraphNode["data"] | undefined; + const isRoot = Boolean(data?.isRoot) || nodeId === "document-root"; + const branch = getBranchColor(data?.branchIndex ?? 0); + const labelText = data?.value ?? ((datum.value as string) || ""); + + return { + size: [getNodeWidth(labelText, isRoot), isRoot ? 44 : 36] as [number, number], + radius: isRoot ? 14 : 10, + fill: isRoot + ? isDark + ? "#30363d" + : "#EFF0F0" + : isDark + ? branch.fillDark + : branch.fillLight, + stroke: isRoot ? (isDark ? "#8b949e" : "#9ca3af") : branch.stroke, + lineWidth: isRoot ? 1.5 : 2, + shadowBlur: isRoot ? 0 : 8, + shadowColor: isDark ? "rgba(0,0,0,0.22)" : "rgba(15,23,42,0.08)", + shadowOffsetY: isRoot ? 0 : 1, + labelText, + labelFill: isDark ? "#e6edf3" : "#1f2328", + labelFontSize: isRoot ? 17 : 14, + labelFontWeight: isRoot ? 600 : 500, + labelPlacement: "center", + labelMaxWidth: isRoot ? 300 : 240, + labelTextOverflow: "ellipsis", + cursor: !isRoot ? "pointer" : "default", + ports: [{ placement: "left" }, { placement: "right" }], + }; + }, + }, + edge: { + type: "cubic-horizontal", + style: (edgeDatum: { target: string }) => { + const targetData = graph?.getNodeData(edgeDatum.target); + const data = targetData?.data as DocumentMindmapGraphNode["data"] | undefined; + const branch = getBranchColor(data?.branchIndex ?? 0); + return { + stroke: branch.stroke, + lineWidth: data?.level != null && data.level <= 2 ? 2.2 : 1.6, + opacity: 0.95, + }; + }, + }, + behaviors: [ + { type: "scroll-canvas", key: "scroll-canvas" }, + { type: "drag-canvas", key: "drag-canvas" }, + { type: "zoom-canvas", key: "zoom-canvas" }, + ], + }); + + graph.render(); + + const handleClick = (evt: unknown) => { + const e = evt as { target?: { id?: string } }; + const nodeId = e.target?.id; + if (!nodeId || !graph) return; + const nodeData = graph.getNodeData(nodeId); + const data = nodeData?.data as DocumentMindmapGraphNode["data"] | undefined; + if (data?.headingId) { + onNavigateHeading?.(data.headingId); + } + }; + graph.on("node:click", handleClick as (evt: unknown) => void); + graphRef.current = graph; + + const resizeObserver = new ResizeObserver(() => { + if (containerRef.current && graphRef.current && !graphRef.current.destroyed) { + graphRef.current.setSize(containerRef.current.offsetWidth, containerRef.current.offsetHeight); + } + }); + resizeObserver.observe(containerRef.current); return () => { - cancelled = true; - hiddenContainer.remove(); + resizeObserver.disconnect(); + if (graph && !graph.destroyed) { + graph.off("node:click", handleClick as (evt: unknown) => void); + graph.destroy(); + } + graphRef.current = null; }; - }, [headings, mermaidCode, themeVersion]); + }, [graphData, headings.length, layoutDirection, onNavigateHeading, themeVersion]); - useEffect(() => { - if (!svgContainerRef.current) return; - svgContainerRef.current.innerHTML = svg; - }, [svg]); + const toggleLayoutDirection = useCallback(() => { + setLayoutDirection((prev) => { + const next = prev === "H" ? "V" : "H"; + try { + localStorage.setItem(DIRECTION_STORAGE_KEY, next); + } catch { + /* ignore */ + } + return next; + }); + }, []); if (headings.length === 0) { return ( @@ -101,46 +233,32 @@ export function DocumentMindmapPanel({ return (
-
- Markdown 标题已自动转换为思维导图(滚轮缩放、拖拽平移) -
- {renderError && ( -
- 思维导图渲染失败:{renderError} +
+
+ 标题已直接转为脑图(接近 XMind/幕布交互:缩放、拖拽、点击节点定位)
- )} -
- {svg.length > 0 ? ( - -
- - ) : ( -
- {isRendering ? "正在生成思维导图..." : "暂无可渲染的思维导图"} -
- )} -
-
-
标题导航
-
-
- {headings.map((heading) => ( - - ))} -
+
+ +
+
+
+
); } diff --git a/frontend/src/utils/documentMindmap.test.ts b/frontend/src/utils/documentMindmap.test.ts index 0f08ea8..2b19203 100644 --- a/frontend/src/utils/documentMindmap.test.ts +++ b/frontend/src/utils/documentMindmap.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildDocumentMindmapGraphTree, buildDocumentMindmapMermaid, buildDocumentMindmapTree, sanitizeMermaidMindmapText, @@ -41,4 +42,20 @@ describe("documentMindmap utils", () => { const text = sanitizeMermaidMindmapText('Title (A) [x] {y}\n"quoted"'); expect(text).toBe("Title A x y 'quoted'"); }); + + it("builds graph tree data for interactive mindmap rendering", () => { + const graph = buildDocumentMindmapGraphTree( + [ + { id: "h1", text: "Overview", level: 1 }, + { id: "h2", text: "Install", level: 2 }, + { id: "h3", text: "Usage", level: 2 }, + ], + "README.md", + ); + + expect(graph.id).toBe("document-root"); + expect(graph.data.isRoot).toBe(true); + expect(graph.data.value).toBe("Overview"); + expect(graph.children?.map((item) => item.id)).toEqual(["h2", "h3"]); + }); }); diff --git a/frontend/src/utils/documentMindmap.ts b/frontend/src/utils/documentMindmap.ts index 828f7ae..bfbcf42 100644 --- a/frontend/src/utils/documentMindmap.ts +++ b/frontend/src/utils/documentMindmap.ts @@ -7,6 +7,18 @@ export interface DocumentMindmapNode { children: DocumentMindmapNode[]; } +export interface DocumentMindmapGraphNode { + id: string; + data: { + value: string; + level: number; + branchIndex: number; + headingId?: string; + isRoot?: boolean; + }; + children?: DocumentMindmapGraphNode[]; +} + function clampHeadingLevel(level: number): number { return Math.max(1, Math.min(6, Math.floor(level))); } @@ -72,3 +84,51 @@ export function buildDocumentMindmapMermaid( return lines.join("\n"); } + +function mapGraphChildren( + nodes: DocumentMindmapNode[], + branchIndex: number, +): DocumentMindmapGraphNode[] { + return nodes.map((node) => ({ + id: node.id, + data: { + value: node.text, + level: node.level, + branchIndex, + headingId: node.id, + }, + ...(node.children.length > 0 + ? { children: mapGraphChildren(node.children, branchIndex) } + : {}), + })); +} + +export function buildDocumentMindmapGraphTree( + headings: NotebookHeadingLike[], + rootTitle = "当前文档", +): DocumentMindmapGraphNode { + const trees = buildDocumentMindmapTree(headings); + + let rootLabel = sanitizeMermaidMindmapText(rootTitle); + let topLevel = trees; + + // If the document has a single H1 wrapping all sections, + // use that H1 as root and promote its children as main branches. + if (trees.length === 1 && trees[0].level === 1 && trees[0].children.length > 0) { + rootLabel = trees[0].text; + topLevel = trees[0].children; + } + + const children = topLevel.flatMap((node, index) => mapGraphChildren([node], index)); + + return { + id: "document-root", + data: { + value: rootLabel, + level: 0, + branchIndex: -1, + isRoot: true, + }, + ...(children.length > 0 ? { children } : {}), + }; +} From e03256bd90070be1bf2f9c4e24e4470f79f77c37 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 18:40:39 +0800 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=80=9D?= =?UTF-8?q?=E7=BB=B4=E5=AF=BC=E5=9B=BE=E5=88=87=E6=8D=A2=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=9C=A8=E7=95=8C=E9=9D=A2=E4=B8=AD?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=92=8C=E9=9A=90=E8=97=8F=E6=80=9D=E7=BB=B4?= =?UTF-8?q?=E5=AF=BC=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 32 +++++++++++++++---- .../src/components/MarkdownViewer.test.tsx | 10 ++++++ frontend/src/components/MarkdownViewer.tsx | 6 ++++ .../src/components/MindmapToggle.test.tsx | 31 ++++++++++++++++++ frontend/src/components/MindmapToggle.tsx | 30 +++++++++++++++++ 5 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/MindmapToggle.test.tsx create mode 100644 frontend/src/components/MindmapToggle.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 463969d..93b2257 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -63,6 +63,7 @@ export function App() { const [activeFileId, setActiveFileId] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(false); const [tocOpen, setTocOpen] = useState(false); + const [mindmapOpen, setMindmapOpen] = useState(false); const [headings, setHeadings] = useState([]); const [contentRevision, setContentRevision] = useState(0); const [searchQuery, setSearchQuery] = useState(null); @@ -701,6 +702,8 @@ export function App() { onContentRendered={onContentRendered} isTocOpen={tocOpen} onTocToggle={() => setTocOpen((v) => !v)} + isMindmapOpen={mindmapOpen} + onMindmapToggle={() => setMindmapOpen((v) => !v)} onRemoveFile={handleRemoveFile} isWide={isWide} searchJumpRequest={ @@ -728,6 +731,8 @@ export function App() { onContentRendered={onContentRendered} isTocOpen={tocOpen} onTocToggle={() => setTocOpen((v) => !v)} + isMindmapOpen={mindmapOpen} + onMindmapToggle={() => setMindmapOpen((v) => !v)} onRemoveFile={handleRemoveFile} isWide={isWide} searchJumpRequest={ @@ -742,12 +747,27 @@ export function App() { )}
- {tocOpen && ( - + {(mindmapOpen || tocOpen) && ( +
+ {mindmapOpen && ( + + )} + {tocOpen && ( + + )} +
)}
({ TocToggle: () => null, })); +vi.mock("./MindmapToggle", () => ({ + MindmapToggle: () => null, +})); + vi.mock("./RawToggle", () => ({ RawToggle: () => null, })); @@ -55,6 +59,8 @@ describe("MarkdownViewer link opening", () => { onHeadingsChange={() => { }} isTocOpen={false} onTocToggle={() => { }} + isMindmapOpen={false} + onMindmapToggle={() => { }} onRemoveFile={() => { }} isWide={false} />, @@ -91,6 +97,8 @@ describe("MarkdownViewer link opening", () => { onHeadingsChange={() => { }} isTocOpen={false} onTocToggle={() => { }} + isMindmapOpen={false} + onMindmapToggle={() => { }} onRemoveFile={() => { }} isWide={false} />, @@ -130,6 +138,8 @@ describe("MarkdownViewer link opening", () => { onHeadingsChange={() => { }} isTocOpen={false} onTocToggle={() => { }} + isMindmapOpen={false} + onMindmapToggle={() => { }} onRemoveFile={() => { }} isWide={false} />, diff --git a/frontend/src/components/MarkdownViewer.tsx b/frontend/src/components/MarkdownViewer.tsx index 53923e1..da43644 100644 --- a/frontend/src/components/MarkdownViewer.tsx +++ b/frontend/src/components/MarkdownViewer.tsx @@ -15,6 +15,7 @@ import { fetchFileContent, openRelativeFile } from "../hooks/useApi"; import { getMermaidSettings, useMermaidSettingsRevision, type MermaidSettings } from "../hooks/useMermaidSettings"; import { RawToggle } from "./RawToggle"; import { TocToggle } from "./TocToggle"; +import { MindmapToggle } from "./MindmapToggle"; import { CopyButton } from "./CopyButton"; import { PdfExportButton } from "./PdfExportButton"; import { RemoveButton } from "./RemoveButton"; @@ -206,6 +207,8 @@ interface MarkdownViewerProps { onContentRendered?: () => void; isTocOpen: boolean; onTocToggle: () => void; + isMindmapOpen: boolean; + onMindmapToggle: () => void; onRemoveFile: () => void; isWide: boolean; searchJumpRequest?: { @@ -1774,6 +1777,8 @@ export function MarkdownViewer({ onContentRendered, isTocOpen, onTocToggle, + isMindmapOpen, + onMindmapToggle, onRemoveFile, isWide, searchJumpRequest, @@ -2166,6 +2171,7 @@ export function MarkdownViewer({
+ setIsRawView((v) => !v)} /> diff --git a/frontend/src/components/MindmapToggle.test.tsx b/frontend/src/components/MindmapToggle.test.tsx new file mode 100644 index 0000000..ee46d1c --- /dev/null +++ b/frontend/src/components/MindmapToggle.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MindmapToggle } from "./MindmapToggle"; + +describe("MindmapToggle", () => { + it("shows 'Show mindmap' title when closed", () => { + render( { }} />); + expect(screen.getByTitle("Show mindmap")).toBeInTheDocument(); + }); + + it("shows 'Hide mindmap' title when open", () => { + render( { }} />); + expect(screen.getByTitle("Hide mindmap")).toBeInTheDocument(); + }); + + it("has correct aria attributes", () => { + render( { }} />); + const button = screen.getByRole("button", { name: "Mindmap" }); + expect(button).toHaveAttribute("aria-expanded", "true"); + }); + + it("calls onToggle when clicked", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Mindmap" })); + expect(onToggle).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/MindmapToggle.tsx b/frontend/src/components/MindmapToggle.tsx new file mode 100644 index 0000000..2c3a178 --- /dev/null +++ b/frontend/src/components/MindmapToggle.tsx @@ -0,0 +1,30 @@ +interface MindmapToggleProps { + isMindmapOpen: boolean; + onToggle: () => void; +} + +export function MindmapToggle({ isMindmapOpen, onToggle }: MindmapToggleProps) { + return ( + + ); +} From 9defcb56a89f842ee551011185a016102cd1c604 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 30 Apr 2026 18:46:43 +0800 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=BC=A9?= =?UTF-8?q?=E6=94=BE=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E6=8C=89=E9=92=AE=E5=92=8C=E6=BB=9A=E8=BD=AE=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=80=9D=E7=BB=B4=E5=AF=BC=E5=9B=BE=E7=9A=84=E7=BC=A9?= =?UTF-8?q?=E6=94=BE=E7=BA=A7=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/DocumentMindmapPanel.tsx | 148 ++++++++++++++++-- 1 file changed, 139 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/DocumentMindmapPanel.tsx b/frontend/src/components/DocumentMindmapPanel.tsx index d866ed1..cbda9af 100644 --- a/frontend/src/components/DocumentMindmapPanel.tsx +++ b/frontend/src/components/DocumentMindmapPanel.tsx @@ -13,6 +13,11 @@ interface DocumentMindmapPanelProps { } const DIRECTION_STORAGE_KEY = "markview-document-mindmap-direction"; +const DEPTH_STORAGE_KEY = "markview-document-mindmap-depth"; + +const MINDMAP_MIN_ZOOM = 0.45; +const MINDMAP_MAX_ZOOM = 2.8; +const MINDMAP_ZOOM_STEP = 0.15; const BRANCH_PALETTE = [ { stroke: "#5B8FF9", fillLight: "#EEF4FF", fillDark: "#1B2B4D" }, @@ -38,7 +43,14 @@ export function DocumentMindmapPanel({ fileName, onNavigateHeading, }: DocumentMindmapPanelProps) { - const [depthMode, setDepthMode] = useState<"summary" | "full">("summary"); + const [depthMode, setDepthMode] = useState<"summary" | "full">(() => { + try { + const raw = localStorage.getItem(DEPTH_STORAGE_KEY); + return raw === "summary" ? "summary" : "full"; + } catch { + return "full"; + } + }); const [layoutDirection, setLayoutDirection] = useState<"H" | "V">(() => { try { const raw = localStorage.getItem(DIRECTION_STORAGE_KEY); @@ -47,9 +59,18 @@ export function DocumentMindmapPanel({ return "H"; } }); + const [zoomPercent, setZoomPercent] = useState(100); const [themeVersion, setThemeVersion] = useState(0); const containerRef = useRef(null); const graphRef = useRef | null>(null); + const updateZoomIndicator = useCallback(() => { + const graph = graphRef.current; + if (!graph || graph.destroyed) return; + const zoom = graph.getZoom(); + if (Number.isFinite(zoom) && zoom > 0) { + setZoomPercent(Math.round(zoom * 100)); + } + }, []); const graphTree = useMemo( () => buildDocumentMindmapGraphTree(headings, fileName || "当前文档"), [headings, fileName], @@ -100,6 +121,14 @@ export function DocumentMindmapPanel({ return () => observer.disconnect(); }, []); + useEffect(() => { + try { + localStorage.setItem(DEPTH_STORAGE_KEY, depthMode); + } catch { + /* ignore */ + } + }, [depthMode]); + useEffect(() => { if (!containerRef.current || headings.length === 0) { return; @@ -120,8 +149,8 @@ export function DocumentMindmapPanel({ getHeight: () => 42, getWidth: (node: { id: string; value?: string; data?: { value?: string } }) => getNodeWidth((node.value ?? node.data?.value ?? "") as string, node.id === "document-root"), - getVGap: () => 20, - getHGap: () => 72, + getVGap: () => (depthMode === "full" ? 22 : 26), + getHGap: () => (depthMode === "full" ? 86 : 96), animation: false, }, node: { @@ -160,7 +189,7 @@ export function DocumentMindmapPanel({ }, }, edge: { - type: "cubic-horizontal", + type: layoutDirection === "H" ? "cubic-horizontal" : "cubic-vertical", style: (edgeDatum: { target: string }) => { const targetData = graph?.getNodeData(edgeDatum.target); const data = targetData?.data as DocumentMindmapGraphNode["data"] | undefined; @@ -180,6 +209,10 @@ export function DocumentMindmapPanel({ }); graph.render(); + void graph + .fitView() + .then(() => updateZoomIndicator()) + .catch(() => updateZoomIndicator()); const handleClick = (evt: unknown) => { const e = evt as { target?: { id?: string } }; @@ -194,22 +227,49 @@ export function DocumentMindmapPanel({ graph.on("node:click", handleClick as (evt: unknown) => void); graphRef.current = graph; + const syncZoomFromWheel = () => { + requestAnimationFrame(() => updateZoomIndicator()); + }; + containerRef.current.addEventListener("wheel", syncZoomFromWheel, { passive: true }); + + let resizeFrame = 0; const resizeObserver = new ResizeObserver(() => { - if (containerRef.current && graphRef.current && !graphRef.current.destroyed) { - graphRef.current.setSize(containerRef.current.offsetWidth, containerRef.current.offsetHeight); + if (resizeFrame !== 0) { + cancelAnimationFrame(resizeFrame); } + resizeFrame = requestAnimationFrame(() => { + if (containerRef.current && graphRef.current && !graphRef.current.destroyed) { + graphRef.current.setSize(containerRef.current.offsetWidth, containerRef.current.offsetHeight); + void graphRef.current + .fitView() + .then(() => updateZoomIndicator()) + .catch(() => updateZoomIndicator()); + } + }); }); resizeObserver.observe(containerRef.current); return () => { resizeObserver.disconnect(); + if (resizeFrame !== 0) { + cancelAnimationFrame(resizeFrame); + } + containerRef.current?.removeEventListener("wheel", syncZoomFromWheel); if (graph && !graph.destroyed) { graph.off("node:click", handleClick as (evt: unknown) => void); graph.destroy(); } graphRef.current = null; }; - }, [graphData, headings.length, layoutDirection, onNavigateHeading, themeVersion]); + }, [ + depthMode, + graphData, + headings.length, + layoutDirection, + onNavigateHeading, + themeVersion, + updateZoomIndicator, + ]); const toggleLayoutDirection = useCallback(() => { setLayoutDirection((prev) => { @@ -223,6 +283,44 @@ export function DocumentMindmapPanel({ }); }, []); + const zoomTo = useCallback( + (value: number) => { + const graph = graphRef.current; + if (!graph || graph.destroyed) return; + const next = Math.max(MINDMAP_MIN_ZOOM, Math.min(MINDMAP_MAX_ZOOM, value)); + void graph + .zoomTo(next) + .then(() => updateZoomIndicator()) + .catch(() => updateZoomIndicator()); + }, + [updateZoomIndicator], + ); + + const handleZoomIn = useCallback(() => { + const graph = graphRef.current; + if (!graph || graph.destroyed) return; + zoomTo(graph.getZoom() + MINDMAP_ZOOM_STEP); + }, [zoomTo]); + + const handleZoomOut = useCallback(() => { + const graph = graphRef.current; + if (!graph || graph.destroyed) return; + zoomTo(graph.getZoom() - MINDMAP_ZOOM_STEP); + }, [zoomTo]); + + const handleZoomReset = useCallback(() => { + zoomTo(1); + }, [zoomTo]); + + const handleFitView = useCallback(() => { + const graph = graphRef.current; + if (!graph || graph.destroyed) return; + void graph + .fitView() + .then(() => updateZoomIndicator()) + .catch(() => updateZoomIndicator()); + }, [updateZoomIndicator]); + if (headings.length === 0) { return (
@@ -233,11 +331,43 @@ export function DocumentMindmapPanel({ return (
-
+
- 标题已直接转为脑图(接近 XMind/幕布交互:缩放、拖拽、点击节点定位) + 标题已直接转为脑图(滚轮缩放、拖拽平移、点击节点定位)
+ + + +