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 a4a0af5..09c59e3 100644 --- a/README.md +++ b/README.md @@ -8,171 +8,184 @@ [![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` 为准。 + +> [!NOTE] +> **项目来源与版权说明** +> +> 本项目基于 [k1LoW/mo](https://github.com/k1LoW/mo) 演进开发,遵循其开源许可(MIT)。 +> 为尊重原作者与版权,本仓库保留来源标注与许可说明;详见根目录 `NOTICE` 与 `LICENSE`。 + +## 功能特性 + +- 支持 GitHub 风格 Markdown(表格、任务列表、脚注等) +- 代码高亮([Shiki](https://shiki.style/)) +- [Mermaid](https://mermaid.js.org/) 图表渲染 +- LaTeX 数学公式([KaTeX](https://katex.org/)) +- 浅色主题 深色 / 深色主题 浅色主题切换 +- 分组 文件分组管理 +- 目录 文档目录面板 +- 平铺 平铺 / 树形 树形侧边栏(支持拖拽排序与文件搜索) +- 大纲笔记模式(类幕布):左侧思维导图/大纲笔记均基于当前文档标题解析,右侧文档联动阅读(支持标题定位) +- YAML 前置元数据展示(可折叠元数据区域) +- MDX 支持(渲染 Markdown,去除 `import`/`export`,转义 JSX 标签) +- 宽布局 宽版 / 窄布局 窄版阅读宽度切换 +- 原文 原始 Markdown 视图 +- 复制 内容复制(Markdown / 文本 / HTML) +- 重启 服务重启并保留会话 +- 自动会话备份与恢复 +- 支持从操作系统文件管理器拖拽添加文件(内容以内存形式加载,拖拽文件不支持实时刷新) +- 对通过命令行打开的文件支持保存后实时刷新 + +## 安装 + +**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 +### 服务重启 -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. +你可以点击页面右下角的 重启 重启按钮,或执行 `markview --restart`。 -### 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. +### 会话备份与恢复 -``` 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 +`markview` 会在文件或模式发生变更时自动保存会话状态(按分组保存文件与监听模式)。 + +当服务再次启动时,会自动恢复上次会话,并与本次命令行参数合并(恢复项优先,新参数追加,去重处理)。 + +```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 +199,9 @@ $ markview --json README.md } ``` -`--status` also supports `--json`: +`--status` 也支持 `--json`: -``` console +```console $ markview --status --json [ { @@ -208,42 +221,77 @@ $ 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/quick-start-visual.md) +- [设计文档](docs/design.md) +- [架构文档](docs/architecture.md) +- [Markdown 能力清单](docs/markdown-capabilities.md) +- [全局搜索功能说明](docs/global-search.md) + +### 中文速览 -- [yusukebe/gh-markdown-preview](https://github.com/yusukebe/gh-markdown-preview): GitHub CLI extension to preview Markdown looks like GitHub. +- 架构形态:Go 后端 + React 前端,最终以单二进制分发 +- 运行机制:命令行单实例复用 + HTTP 接口 + 服务端事件流实时刷新 +- 状态策略:服务端状态中心(groups/files/patterns)+ XDG 会话备份恢复 +- 工程化:Makefile、CI(lint/test/coverage)、GoReleaser 多平台发布 -## License +## 术语说明 + +| 中文称呼 | 代码中的常见写法 | 说明 | +| ------------ | ---------------- | --------------------------------- | +| 命令行入口 | `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 扩展。 + +## 许可证 - [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 技术说明场景时,通常可不强制要求额外版权标注。 + +### 上游来源致谢 + +- Upstream: [k1LoW/mo](https://github.com/k1LoW/mo) +- 上游许可:MIT(Copyright © 2026 Ken'ichiro Oyama) +- 归属与说明:见本仓库 `NOTICE` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..93164f8 --- /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 --> STATE[状态中心\n(groups/files/patterns)] + HTTP --> WATCH[fsnotify] + WATCH --> SSE[服务端事件流 /_/events] + SSE --> SPA[React 前端界面] + SPA --> API[/_/api/*] + API --> STATE + STATE --> BACKUP[会话备份 JSON\nXDG_STATE_HOME] + SPA --> STATIC[前端静态资源] + STATIC --> HTTP +``` + +核心特征: + +- **单实例复用**:同端口优先复用已有进程。 +- **服务端状态中心**:分组、文件、watch 模式统一由后端维护。 +- **前后端松耦合**:通过 HTTP 接口 + 服务端事件流通信。 +- **内嵌前端静态资源**:最终交付为单可执行文件。 + +## 2. 目录与模块职责 + +| 模块 | 路径 | 职责 | +| --------------- | -------------------------------------------- | ------------------------------------------ | +| 命令行入口 | `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 渲染与图表增强 | +| 接口封装 | `frontend/src/hooks/useApi.ts` | 前端到后端接口调用 | +| 事件流订阅 | `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 服务已存在 + C->>S: POST /_/api/files + C-->>U: 输出 URL 并退出 + else 服务未找到 + C->>C: 启动新服务进程 + C-->>U: 输出 URL + end +``` + +## 3.2 文件变更到页面刷新 + +```mermaid +sequenceDiagram + participant FS as 文件系统 + participant W as fsnotify + participant ST as 状态中心 + participant FE as 前端(服务端事件流) + + FS->>W: 文件写入/重命名 + W->>ST: 变更事件 + ST->>FE: 事件流推送(file-changed) + FE->>FE: 拉取 /_/api/files/{id}/content + FE-->>FE: 重新渲染 Markdown +``` + +## 4. 数据与状态模型 + +## 4.1 服务端状态 + +- `groups`: 当前分组及文件列表 +- `patterns`: 当前监听通配模式集合 +- `watchedDirs`: 被监听目录的引用计数 +- `subscribers`: SSE 订阅者集合 + +通过 `RWMutex` 保护并发读写。 + +## 4.2 持久化模型 + +`RestoreData` 结构包含: + +- `Groups`: 分组到文件路径列表 +- `Patterns`: 分组到 watch 模式列表 +- `UploadedFiles`: 内存上传文件快照 + +状态在变更后触发去抖保存;启动时读取并合并 CLI 参数。 + +## 5. 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 持续集成与发布 + +- 持续集成:前端 lint/format、Go lint、测试覆盖率 +- 发布:`tagpr` 管理版本,`goreleaser` 产出多平台二进制 +- 许可证检查: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..cffe7e0 --- /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 单实例复用策略 + +命令行启动时先探测 `/_/api/status`: + +- 若服务存在:通过接口将文件或模式加入现有会话。 +- 若服务不存在:启动新服务并打开浏览器。 + +这可避免同端口多进程竞争,并让命令行调用天然支持“追加文件”。 + +## 4.3 实时刷新设计 + +- 后端使用 `fsnotify` 监听文件变化。 +- 前端通过服务端事件流 (`/_/events`) 订阅事件: + - `update`:文件集或分组变化 + - `file-changed`:文件内容变化 + - `started`:服务进程身份(PID) + +当进程 PID 变化时,前端自动刷新页面,保证重启后的会话一致性。 + +## 4.4 会话持久化设计 + +- 存储位置:`$XDG_STATE_HOME/markview/backup/markview-.json` +- 保存内容:分组、文件路径、监听模式、上传文件内容 +- 写入策略:原子写入(临时文件 + 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. 上传文件为内存态,不支持文件系统实时刷新。 +2. 监听与搜索范围是“已加入会话的文件集合”,不是全盘索引。 +3. 远程可访问模式(非 loopback 绑定)需用户明确确认,适合可信网络。 + +## 7. 后续演进方向 + +1. 搜索键盘导航和防抖优化 +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 执行 ### 功能增强 diff --git a/docs/markdown-capabilities.md b/docs/markdown-capabilities.md new file mode 100644 index 0000000..9eebe15 --- /dev/null +++ b/docs/markdown-capabilities.md @@ -0,0 +1,112 @@ +# 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. 前置元数据支持更多格式(如 `...` 结束符、BOM 容错) +2. 图片远程加载失败的占位与重试提示 +3. 外部图表服务多次失败时提供更明确的排障建议 + +### P1(建议) + +1. 图片懒加载与错误占位样式增强 +2. 图表导出能力统一(Mermaid/PlantUML/SVG Bob) +3. 搜索结果键盘导航与高亮体验提升 + +### P2(可选) + +1. 增加可配置 Markdown 扩展开关 +2. 增加本地 PlantUML 渲染兜底方案 +3. 增强图谱维度(标签、引用类型、跨分组统计) 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 6e908ed..93b2257 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import { captureArticleForMergedPdf, exportMergedPdfFromSnapshots } from "./util import { OutlineGraphView } from "./components/OutlineGraphView"; import { OutlineGravityView } from "./components/OutlineGravityView"; import { OutlineTreeView } from "./components/OutlineTreeView"; +import { DocumentMindmapPanel } from "./components/DocumentMindmapPanel"; import { isStaticMode } from "./utils/staticData"; const WIDTH_STORAGE_KEY = "markview-layout-width"; @@ -62,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); @@ -84,7 +86,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()); @@ -604,6 +606,28 @@ export function App() { + setIsWide((v) => !v)} /> + +
+ +
+ +
+ {activeFileId != null ? ( + setTocOpen((v) => !v)} + isMindmapOpen={mindmapOpen} + onMindmapToggle={() => setMindmapOpen((v) => !v)} + onRemoveFile={handleRemoveFile} + isWide={isWide} + searchJumpRequest={ + pendingSearchJump?.fileId === activeFileId ? pendingSearchJump : null + } + onSearchJumpHandled={() => setPendingSearchJump(null)} + /> + ) : ( +
+ 请在左侧选择一个文档开始大纲笔记浏览 +
+ )} +
+ ) : ( setShowGraph(false)} /> ) @@ -659,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={ @@ -673,12 +747,27 @@ export function App() { )} - {tocOpen && ( - + {(mindmapOpen || tocOpen) && ( +
+ {mindmapOpen && ( + + )} + {tocOpen && ( + + )} +
)} { 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..cbda9af --- /dev/null +++ b/frontend/src/components/DocumentMindmapPanel.tsx @@ -0,0 +1,394 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Graph, idOf, treeToGraphData } from "@antv/g6"; +import type { NotebookHeadingLike } from "../utils/notebookOutline"; +import { + buildDocumentMindmapGraphTree, + type DocumentMindmapGraphNode, +} from "../utils/documentMindmap"; + +interface DocumentMindmapPanelProps { + headings: NotebookHeadingLike[]; + fileName?: string; + onNavigateHeading?: (headingId: string) => void; +} + +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" }, + { 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({ + headings, + fileName, + onNavigateHeading, +}: DocumentMindmapPanelProps) { + 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); + return raw === "V" ? "V" : "H"; + } catch { + 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], + ); + 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)); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + try { + localStorage.setItem(DEPTH_STORAGE_KEY, depthMode); + } catch { + /* ignore */ + } + }, [depthMode]); + + useEffect(() => { + if (!containerRef.current || headings.length === 0) { + return; + } + + 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: () => (depthMode === "full" ? 22 : 26), + getHGap: () => (depthMode === "full" ? 86 : 96), + 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: layoutDirection === "H" ? "cubic-horizontal" : "cubic-vertical", + 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(); + void graph + .fitView() + .then(() => updateZoomIndicator()) + .catch(() => updateZoomIndicator()); + + 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 syncZoomFromWheel = () => { + requestAnimationFrame(() => updateZoomIndicator()); + }; + containerRef.current.addEventListener("wheel", syncZoomFromWheel, { passive: true }); + + let resizeFrame = 0; + const resizeObserver = new ResizeObserver(() => { + 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; + }; + }, [ + depthMode, + graphData, + headings.length, + layoutDirection, + onNavigateHeading, + themeVersion, + updateZoomIndicator, + ]); + + const toggleLayoutDirection = useCallback(() => { + setLayoutDirection((prev) => { + const next = prev === "H" ? "V" : "H"; + try { + localStorage.setItem(DIRECTION_STORAGE_KEY, next); + } catch { + /* ignore */ + } + return next; + }); + }, []); + + 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 ( +
+ 当前文档暂无可解析标题 +
+ ); + } + + return ( +
+
+
+ 标题已直接转为脑图(滚轮缩放、拖拽平移、点击节点定位) +
+
+ + + + + + +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/MarkdownViewer.test.tsx b/frontend/src/components/MarkdownViewer.test.tsx new file mode 100644 index 0000000..d9df67d --- /dev/null +++ b/frontend/src/components/MarkdownViewer.test.tsx @@ -0,0 +1,168 @@ +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("./MindmapToggle", () => ({ + MindmapToggle: () => 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={() => { }} + isMindmapOpen={false} + onMindmapToggle={() => { }} + 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={() => { }} + isMindmapOpen={false} + onMindmapToggle={() => { }} + 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(); + }); + + it("toggles section visibility when clicking headings", async () => { + vi.mocked(fetchFileContent).mockResolvedValue({ + content: `# 第一章 + +段落一 + +## 子节 + +段落二 + +# 第二章 + +段落三`, + baseDir: "/tmp", + }); + + render( + { }} + onHeadingsChange={() => { }} + isTocOpen={false} + onTocToggle={() => { }} + isMindmapOpen={false} + onMindmapToggle={() => { }} + 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 6d37d54..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"; @@ -149,6 +150,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; @@ -158,6 +207,8 @@ interface MarkdownViewerProps { onContentRendered?: () => void; isTocOpen: boolean; onTocToggle: () => void; + isMindmapOpen: boolean; + onMindmapToggle: () => void; onRemoveFile: () => void; isWide: boolean; searchJumpRequest?: { @@ -593,6 +644,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 +783,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 +830,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"); } } @@ -872,6 +926,11 @@ export function MermaidBlock({ code }: { code: string }) { } return (
+ {renderStatus === "failed" && ( +
+ 图表渲染失败:{renderError ?? "未知错误"}。已回退为代码块显示。 +
+ )}
         {code}
       
@@ -883,6 +942,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); @@ -985,6 +1045,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"); @@ -1000,13 +1061,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"); } } @@ -1068,6 +1130,11 @@ export function SvgBobBlock({ code }: { code: string }) { return (
+ {renderStatus === "failed" && ( +
+ 图表渲染失败:{renderError ?? "未知错误"}。已回退为代码块显示。 +
+ )}
         {code}
       
@@ -1079,7 +1146,9 @@ 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 [retryVersion, setRetryVersion] = useState(0); const [isFullscreen, setIsFullscreen] = useState(false); const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); @@ -1163,6 +1232,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); @@ -1181,6 +1256,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); @@ -1196,13 +1272,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"); } } @@ -1217,7 +1294,7 @@ export function PlantUmlBlock({ code }: { code: string }) { objectUrlRef.current = null; } }; - }, [code, themeVersion]); + }, [code, themeVersion, retryVersion]); if (svgUrl) { const canvasStyle = isFullscreen @@ -1260,6 +1337,18 @@ export function PlantUmlBlock({ code }: { code: string }) { return (
+ {renderStatus === "failed" && ( +
+

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

+ +
+ )}
         {code}
       
@@ -1638,6 +1727,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, @@ -1647,6 +1777,8 @@ export function MarkdownViewer({ onContentRendered, isTocOpen, onTocToggle, + isMindmapOpen, + onMindmapToggle, onRemoveFile, isWide, searchJumpRequest, @@ -1655,6 +1787,8 @@ 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 }); @@ -1665,6 +1799,7 @@ export function MarkdownViewer({ useEffect(() => { let cancelled = false; + setLinkOpenError(null); fetchFileContent(fileId) .then((data) => { if (!cancelled) { @@ -1683,9 +1818,26 @@ 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(); + setLinkOpenError(null); try { const entry = await openRelativeFile(fileId, href); onFileOpened(entry.id); @@ -1699,7 +1851,7 @@ export function MarkdownViewer({ }); } } catch { - // fallback: do nothing + setLinkOpenError(`无法打开链接:${href}`); } }, [fileId, onFileOpened], @@ -1708,6 +1860,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$/, ""); @@ -1772,7 +1996,7 @@ export function MarkdownViewer({ } }, }), - [fileId, handleLinkClick], + [collapsedHeadingIds, fileId, handleLinkClick, toggleHeadingCollapse], ); const parsed = useMemo( @@ -1801,6 +2025,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[] = []; @@ -1894,11 +2158,20 @@ export function MarkdownViewer({ data-file-id={fileId} className={`markdown-body min-w-0 flex-1${isWide ? " markdown-body--wide" : ""}`} > + {linkOpenError && ( +
+ {linkOpenError} +
+ )} {renderedContent}
+ setIsRawView((v) => !v)} /> diff --git a/frontend/src/components/MermaidBlock.test.tsx b/frontend/src/components/MermaidBlock.test.tsx index abca0a4..9480e0d 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,11 +93,24 @@ describe("MermaidBlock", () => { render(); await waitFor(() => { + 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/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 ( + + ); +} diff --git a/frontend/src/components/OutlineNotebookPanel.tsx b/frontend/src/components/OutlineNotebookPanel.tsx new file mode 100644 index 0000000..b0712c7 --- /dev/null +++ b/frontend/src/components/OutlineNotebookPanel.tsx @@ -0,0 +1,155 @@ +import { useMemo, useState } from "react"; +import type { NotebookHeadingLike } from "../utils/notebookOutline"; + +interface OutlineNotebookPanelProps { + groupName: string; + headings: NotebookHeadingLike[]; + onNavigateHeading?: (headingId: string) => void; +} + +interface ParsedHeadingItem { + id: string; + text: string; + level: number; +} + +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; +} + +function getHiddenIndices(items: ParsedHeadingItem[], 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) && hasChildren(items, i)) { + collapseLevels.push(current.level); + } + } + + return hidden; +} + +export function OutlineNotebookPanel({ + headings, + onNavigateHeading, +}: OutlineNotebookPanelProps) { + const [collapsedIds, setCollapsedIds] = useState>(() => new Set()); + + const items = useMemo(() => { + return headings.map((heading) => ({ + id: heading.id, + text: heading.text, + level: Math.max(1, Math.min(6, heading.level)), + })); + }, [headings]); + + const hiddenIndices = useMemo(() => getHiddenIndices(items, collapsedIds), [items, collapsedIds]); + + const handleToggleCollapse = (id: string) => { + setCollapsedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const handleExpandAll = () => { + setCollapsedIds(new Set()); + }; + + return ( +
+
+ + 当前文档解析到 {items.length} 个标题 +
+ +
+ {items.length === 0 && ( +
+ 当前文档暂无标题,请先在右侧文档中使用 `#`/`##` 等标题语法。 +
+ )} + {items.map((item, index) => { + if (hiddenIndices.has(index)) return null; + + const hasChild = hasChildren(items, index); + const isCollapsed = collapsedIds.has(item.id); + + return ( +
+ + +
+ ); + })} +
+ +
+ 大纲笔记已切换为当前文档标题解析视图(点击任一标题可定位到右侧内容)。 +
+
+ ); +} 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 && ( + + )}
diff --git a/frontend/src/components/PlantUmlBlock.test.tsx b/frontend/src/components/PlantUmlBlock.test.tsx index 44014b0..9ca8ed4 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("图表渲染失败:渲染服务异常,请稍后重试(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(); @@ -69,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", diff --git a/frontend/src/components/SvgBobBlock.test.tsx b/frontend/src/components/SvgBobBlock.test.tsx index 6f5517b..7836a8f 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("图表渲染失败:语法可能有误,请检查图表代码(svgbob parse error)。已回退为代码块显示。")).toBeInTheDocument(); expect(screen.getByText("+---+")).toBeInTheDocument(); expect(screen.queryByRole("img", { name: "SVG Bob diagram" })).not.toBeInTheDocument(); expect(screen.getByTitle("Copy code")).toBeInTheDocument(); diff --git a/frontend/src/utils/documentMindmap.test.ts b/frontend/src/utils/documentMindmap.test.ts new file mode 100644 index 0000000..2b19203 --- /dev/null +++ b/frontend/src/utils/documentMindmap.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + buildDocumentMindmapGraphTree, + 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'"); + }); + + 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 new file mode 100644 index 0000000..bfbcf42 --- /dev/null +++ b/frontend/src/utils/documentMindmap.ts @@ -0,0 +1,134 @@ +import type { NotebookHeadingLike } from "./notebookOutline"; + +export interface DocumentMindmapNode { + id: string; + text: string; + level: number; + 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))); +} + +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"); +} + +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 } : {}), + }; +} diff --git a/frontend/src/utils/notebookOutline.test.ts b/frontend/src/utils/notebookOutline.test.ts new file mode 100644 index 0000000..5cdf72d --- /dev/null +++ b/frontend/src/utils/notebookOutline.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { + createNotebookOutlineItem, + getHiddenNotebookIndices, + indentNotebookItemsByIds, + importHeadingsAsNotebookItems, + indentNotebookItem, + insertNotebookItemAfter, + moveNotebookItemBeforeById, + moveNotebookItem, + outdentNotebookItemsByIds, + outdentNotebookItem, + removeNotebookItemAt, + removeNotebookItemsByIds, + 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(""); + }); + + 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); + }); + + 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 new file mode 100644 index 0000000..8b2d232 --- /dev/null +++ b/frontend/src/utils/notebookOutline.ts @@ -0,0 +1,385 @@ +export interface NotebookOutlineItem { + id: string; + text: string; + level: number; + headingId?: string; +} + +export interface NotebookHeadingLike { + id: string; + text: string; + level: number; +} + +export type NotebookMoveDirection = "up" | "down"; +export type NotebookSelectionInput = Set | string[]; + +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; +} + +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, + }; +} + +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)); +} + +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, +): { 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) }; +}