From e13cdc718cdcd60529edd2d74a9699c69d8df046 Mon Sep 17 00:00:00 2001 From: leonan Date: Tue, 23 Jun 2026 10:14:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?mac=E4=B8=8B=E7=BC=96=E8=BE=91=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E9=97=AE=E9=A2=98=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: leonan --- src/components/Editor/Editor.tsx | 43 ++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx index 045f0e6..ce68cd0 100644 --- a/src/components/Editor/Editor.tsx +++ b/src/components/Editor/Editor.tsx @@ -28,11 +28,17 @@ function highlightMarkdownLine(line: string): string { if (!/[`!\[\]*_~]/.test(line)) return esc(line); let result = esc(line); + // 内联代码(单反引号) result = result.replace(/`([^`]+)`/g, '`$1`'); - result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '![$1]($2)'); - result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1]($2)'); + // 图片 + 链接(支持含嵌套括号的 URL,如 wiki 链接) + result = result.replace(/!\[([^\]]*)\]\(([^()]*(?:\([^()]*\)[^()]*?)*)\)/g, '![$1]($2)'); + result = result.replace(/\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*?)*)\)/g, '[$1]($2)'); + // 粗体 result = result.replace(/(\*\*|__)(.+?)\1/g, '$1$2$1'); - result = result.replace(/(\*|_)(.+?)\1/g, '$1$2$1'); + // 斜体:lookahead/lookbehind 防止跨越粗体标记,[^<>()]+ 防止跨越 HTML 标签和 URL 括号 + result = result.replace(/(?()]+)(?*$1*'); + result = result.replace(/(?()]+)(?_$1_'); + // 删除线 result = result.replace(/~~(.+?)~~/g, '~~$1~~'); return result; } @@ -92,7 +98,7 @@ function syncHighlightedLines(pre: HTMLPreElement, source: string, previousLines /** * Markdown 文本编辑器——在编辑模式下替代 Content 区域。 * 双层方案:透明 textarea 叠加高亮
,实现语法着色。
- * 高亮通过 debounce + 直接 innerHTML 更新,避免击键卡顿。
+ * 高亮通过 requestAnimationFrame 更新,与浏览器渲染周期同步,减少布局抖动。
  */
 export function Editor() {
   const textareaRef = useRef(null);
@@ -102,7 +108,8 @@ export function Editor() {
   const isDirty = useEditStore((s) => s.isDirty);
   const setDirty = useEditStore((s) => s.setDirty);
   const markSaved = useEditStore((s) => s.markSaved);
-  const debounceRef = useRef | null>(null);
+  const rafRef = useRef(0);
+  const composingRef = useRef(false);
   const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');
 
   /** 更新高亮层 — 按行替换变更节点,不重建整篇 HTML */
@@ -128,20 +135,34 @@ export function Editor() {
       setDirty(false);
     }
     return () => {
-      if (debounceRef.current) clearTimeout(debounceRef.current);
+      cancelAnimationFrame(rafRef.current);
     };
   }, [activeTab?.id, updateHighlight, setDirty]);
 
   const handleChange = useCallback(() => {
     setDirty(true);
-    // debounce 高亮:150ms 内只触发一次
-    if (debounceRef.current) clearTimeout(debounceRef.current);
-    debounceRef.current = setTimeout(() => {
+    // IME 组合期间(macOS 输入法)不更新高亮,防止干扰输入法和键盘事件
+    if (composingRef.current) return;
+    // 使用 requestAnimationFrame 替代 setTimeout(150ms):
+    // 与浏览器渲染周期同步,减少布局抖动,降低 macOS WKWebView 丢键风险
+    cancelAnimationFrame(rafRef.current);
+    rafRef.current = requestAnimationFrame(() => {
       const val = textareaRef.current?.value ?? '';
       updateHighlight(val);
-    }, 150);
+    });
   }, [setDirty, updateHighlight]);
 
+  // IME 组合事件(macOS 输入法安全):组合期间暂停高亮更新
+  const handleCompositionStart = useCallback(() => {
+    composingRef.current = true;
+  }, []);
+
+  const handleCompositionEnd = useCallback(() => {
+    composingRef.current = false;
+    const val = textareaRef.current?.value ?? '';
+    updateHighlight(val);
+  }, [updateHighlight]);
+
   const handleScroll = useCallback(() => {
     if (!activeTab || !textareaRef.current) return;
     useTabsStore.getState().setScrollTop(activeTab.id, textareaRef.current.scrollTop);
@@ -216,6 +237,8 @@ export function Editor() {
           className="editor-textarea"
           defaultValue={activeTab.source}
           onChange={handleChange}
+          onCompositionStart={handleCompositionStart}
+          onCompositionEnd={handleCompositionEnd}
           onScroll={handleScroll}
           spellCheck={false}
           placeholder="输入 Markdown 内容…"

From b61749c202dee17762abfaa6a41e31f1f3ef1fe4 Mon Sep 17 00:00:00 2001
From: leonan 
Date: Tue, 23 Jun 2026 17:25:19 +0800
Subject: [PATCH 2/2] =?UTF-8?q?0.3=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: leonan 
---
 .gitignore                                    |   1 -
 CLAUDE.md                                     | 151 ++++++
 README.md                                     | 130 +++--
 README_EN.md                                  | 165 +++++++
 package.json                                  |   4 +-
 pnpm-lock.yaml                                |  52 ++
 src/App.tsx                                   |  43 +-
 src/components/AboutModal/AboutModal.tsx      |  29 +-
 src/components/Content/Content.tsx            | 374 +++++++++++++-
 .../ExportPdfModal/ExportPdfModal.tsx         |  47 +-
 src/components/Lightbox/Lightbox.tsx          |  36 +-
 src/components/SearchBar/SearchBar.tsx        |  17 +-
 src/components/ThemeManager/ThemeManager.tsx  |  29 +-
 src/components/TitleBar/TitleBar.tsx          | 132 +++--
 src/components/Welcome/Welcome.tsx            |  39 +-
 src/features/export/export.test.ts            |  28 +-
 src/features/export/export.ts                 |  99 ++--
 src/features/fileWatch/useFileWatcher.ts      |  35 ++
 src/features/markdown/plugins.ts              |  11 +-
 src/features/markdown/render.ts               |   8 +-
 src/hooks/useRevisionShortcuts.ts             |  29 ++
 src/main.tsx                                  |  64 ++-
 src/stores/revisionStore.ts                   |  98 ++++
 src/styles/prose.css                          |   2 +-
 src/styles/themes.css                         | 461 +++++++++++++++++-
 src/types/index.ts                            |  17 +
 src/utils/diff.test.ts                        | 191 ++++++++
 src/utils/diff.ts                             |  77 +++
 28 files changed, 2118 insertions(+), 251 deletions(-)
 create mode 100644 CLAUDE.md
 create mode 100644 README_EN.md
 create mode 100644 src/hooks/useRevisionShortcuts.ts
 create mode 100644 src/stores/revisionStore.ts
 create mode 100644 src/utils/diff.test.ts
 create mode 100644 src/utils/diff.ts

diff --git a/.gitignore b/.gitignore
index 2f2f714..53534e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,5 +43,4 @@ coverage/
 # Agent/IDE local state
 .omc/
 .omx/
-CLAUDE.md
 docs/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..2bf5831
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,151 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+md++ (package name: `easy-md`) is a lightweight Markdown viewer and editor for Windows and macOS, built with Tauri v2 (Rust backend + React/TypeScript frontend). Licensed MIT, version 0.2.0.
+
+## Commands
+
+```bash
+pnpm install              # Install dependencies
+pnpm tauri dev            # Start dev server (Vite on port 14300 + Tauri backend)
+pnpm dev                  # Frontend-only dev server
+pnpm build                # Frontend build (tsc + vite)
+pnpm test                 # Run frontend tests (vitest run)
+pnpm test:watch           # Frontend tests in watch mode
+pnpm test -- src/stores/tabsStore.test.ts  # Run single test file
+cd src-tauri && cargo test  # Run Rust backend tests
+pnpm tauri build          # Production build (MSI + NSIS installers)
+```
+
+First `pnpm tauri dev` compiles Rust dependencies (~5-10 min); subsequent starts are fast.
+
+## CI/CD
+
+- **CI** (`.github/workflows/ci.yml`) — push/PR to main 触发,跑前端测试 + 构建检查
+- **Release** (`.github/workflows/release.yml`) — push `v*` tag 触发,自动构建安装包并创建 Draft Release
+- Release 发布流程:改版本号 → `git tag v0.x.x` → `git push origin v0.x.x` → GitHub Actions 自动构建
+
+## Keyboard Shortcuts
+
+| Shortcut | Action |
+|----------|--------|
+| `Ctrl+O` | Open file |
+| `Ctrl+W` | Close current tab |
+| `Ctrl+F` | Full-text search |
+| `Ctrl+Shift+T` | Toggle light/dark theme |
+| `Ctrl+P` | Print / export PDF |
+| `Ctrl+Shift+C` | Copy rendered content |
+| `Ctrl+Shift+R` | Toggle revision mode (track changes) |
+| `Ctrl+E` | Toggle edit mode |
+
+macOS will use `⌘` instead of `Ctrl`.
+
+## Architecture
+
+**Tauri v2 separation:** Rust backend handles only file I/O, file watching, and system integration. All rendering, UI, and business logic lives in the React frontend. Platform differences are isolated behind Rust `#[cfg(...)]` — the frontend has zero platform coupling.
+
+### Backend (src-tauri/src/)
+
+- `lib.rs` — Tauri Builder setup, plugin registration, CLI arg parsing, emits `open-on-startup` event
+- `commands/files.rs` — `read_text_file`, `write_file`, `resolve_image` (local images → base64 data URLs)
+- `commands/recent.rs` — `list_recent`/`add_recent` persisted to `recent.json` in app data dir
+- `commands/opener.rs` — `open_containing_folder`, `check_file_association`, `register_file_association` (file association management)
+- `watcher.rs` — `notify` crate with 300ms debounce, emits `file-changed` events to frontend
+
+### Frontend (src/)
+
+- `App.tsx` — Root layout orchestrator, wires hooks: `useOpenFile`, `useThemeInit`, `useGlobalShortcuts`, `useFileWatcher`, `useRevisionShortcuts`
+- `stores/tabsStore.ts` — Core state: tab CRUD, active tab, scroll memory, file reading + rendering pipeline
+- `stores/revisionStore.ts` — Revision mode state: track changes, navigate between diffs
+- `features/markdown/render.ts` — Main rendering entry: markdown-it → shiki → mermaid → image resolution → DOM injection
+- `features/export/export.ts` — Document export: PDF (via system print), HTML, Markdown
+- `utils/diff.ts` — Line-level diff computation using `diff` package
+- `ipc/` — Tauri command wrappers (`invoke` calls)
+
+### Markdown Rendering Pipeline
+
+```
+Source → markdown-it (katex, task-lists, footnote, mark, linkify, emoji, containers, tabs)
+      → shiki code highlighting
+      → mermaid async rendering
+      → image resolution (local paths → base64 via Rust)
+      → DOM injection + post-processing (anchors, copy buttons, lightbox)
+```
+
+### Revision Mode
+
+Revision mode allows users to track and navigate changes in markdown files:
+
+- **Activation**: `Ctrl+Shift+R` or click the revision icon in TitleBar
+- **State**: `revisionStore` manages revision history, current revision, and segment navigation
+- **Diff computation**: `utils/diff.ts` uses the `diff` package for line-level diffing
+- **Rendering**: `renderMarkdownWithHtml()` allows HTML comments for injecting highlight markers
+- **Types**: `RevisionHunk`, `Revision` in `types/index.ts`
+- **Navigation**: Navigate between change segments within and across revisions
+
+### State Management (Zustand)
+
+- `tabsStore` — Tab lifecycle, scroll memory, rendering, watcher sync
+- `themeStore` — Light/dark/system theme, CSS variables via `data-theme` attribute
+- `searchStore` — Full-text search keyword, visibility, match navigation
+- `recentStore` — Recent files synced with Rust backend
+- `revisionStore` — Revision mode: track changes, navigate between diffs
+- `editStore` — Edit mode state management
+- `zenStore` — Zen (distraction-free) mode state
+- `customThemeStore` — Custom theme management
+
+### Styling
+
+- CSS variables for theming in `themes.css` (applied via `data-theme` on ``)
+- TailwindCSS for utilities (custom color tokens map to CSS vars)
+- `prose.css` for GitHub-like markdown typography
+- Responsive breakpoints at 768px and 600px
+
+## Key Conventions
+
+- **Package manager:** pnpm (v9+), not npm/yarn
+- **Dev server port:** 14300 (avoids Windows reserved ports 1394-1493 for Hyper-V/WSL)
+- **TypeScript types:** Shared types in `src/types/index.ts` (Tab, TocItem, RecentFile, ThemeMode, Revision, RevisionHunk)
+- **IPC pattern:** All Tauri commands wrapped in `src/ipc/` files, never called raw from components
+- **Test framework:** Vitest with jsdom; setup in `src/test/setup.ts`
+- **Test location:** Co-located with source (`*.test.ts` next to implementation)
+- **Git 提交注释:** 使用中文撰写 commit message(如 `feat: 添加文件拖拽支持`、`fix: 修复主题切换闪屏`)
+- **作者:** leonan
+- **仓库:** https://github.com/Gitleonan/easy-md
+
+## Features
+
+- **Multi-tab interface** — Open multiple markdown files in tabs
+- **Edit mode** — Toggle with `Ctrl+E` to edit markdown source
+- **Zen mode** — Distraction-free reading mode
+- **Revision mode** — Track and navigate changes in documents
+- **File association** — Register as default app for `.md`/`.markdown` files
+- **Export** — PDF (via system print), HTML, and Markdown formats
+- **Custom themes** — Create and manage custom color themes
+- **Search** — Full-text search with match navigation
+- **Table of Contents** — Sidebar TOC with active heading tracking
+- **Lightbox** — Image preview with zoom
+- **Syntax highlighting** — Shiki-based code highlighting with 100+ languages
+- **Mermaid diagrams** — Render Mermaid flowcharts, sequence diagrams, etc.
+- **KaTeX math** — LaTeX math expressions rendering
+- **GitHub Alerts** — Support for `> [!NOTE]`, `> [!TIP]`, etc.
+- **Task lists** — GitHub-style checkboxes
+- **Footnotes** — Markdown footnote syntax
+- **Emoji** — Shortcode emoji support (`:smile:` → 😄)
+
+## Agent skills
+
+### Issue tracker
+
+Local markdown — issues live as files under `.scratch//`. See `docs/agents/issue-tracker.md`.
+
+### Triage labels
+
+Five canonical roles: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
+
+### Domain docs
+
+Single-context — `CONTEXT.md` + `docs/adr/` at repo root. See `docs/agents/domain.md`.
diff --git a/README.md b/README.md
index 678ba06..b960efb 100644
--- a/README.md
+++ b/README.md
@@ -1,65 +1,67 @@
-# md++
+# md++ — 轻量 Markdown 阅读器
 
-> **md++** — 轻量化 Markdown 阅读器,支持 Windows / macOS。
+> **md++** (easy-md) — 快速、轻量的 Markdown 阅读器,支持 Windows & macOS。双击 `.md` 即开即看,无需重型编辑器。内置文件监听模式,实时捕获外部修改并可视化展示内容变更。
+
+> 🌐 [English Version](./README_EN.md)
 
 [![made with Tauri](https://img.shields.io/badge/made%20with-Tauri%20v2-orange)](https://tauri.app)
 [![React](https://img.shields.io/badge/frontend-React%2018%20%2B%20TS-blue)](https://react.dev)
 [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
+[![GitHub Release](https://img.shields.io/github/v/release/Gitleonan/easy-md)](https://github.com/Gitleonan/easy-md/releases)
+[![GitHub Stars](https://img.shields.io/github/stars/Gitleonan/easy-md?style=social)](https://github.com/Gitleonan/easy-md)
 
 ---
 ![md++ 软件界面介绍图](assets/github-promo.png)
 
+---
+
 ## 🤔 为什么做这个
 
 Windows 下一直没有好用的 Markdown 预览工具。想快速看一眼 `.md` 文件的内容,要么得打开 VS Code 这样的重量级编辑器,要么得用浏览器配合插件,体验都不够便捷。
 
-**md++** 就是为了解决这个问题——一个轻量、纯粹的 Markdown 阅读器,双击文件即可预览,需要时也能快速编辑。
+**md++** 就是为了解决这个问题——一个轻量、纯粹的 Markdown 阅读器,双击文件即可预览,需要时也能快速编辑。**v0.3 新增的监听模式**,能像专业 diff 工具一样监听文件的外部变化、自动计算差异并可视化展示变更内容。
+
+[📥 下载](https://github.com/Gitleonan/easy-md/releases) · [🐛 报告问题](https://github.com/Gitleonan/easy-md/issues) · [💬 讨论](https://github.com/Gitleonan/easy-md/discussions)
 
-## ✨ 特性
+### ✨ 特性
 
-### 阅读体验
+#### 阅读体验
 - 🎨 **全语法渲染** — 标准 Markdown + KaTeX 数学公式 + shiki 代码高亮 + Mermaid 图表
 - 📑 **双栏 TOC** — 左侧目录树,点击跳转,滚动联动高亮,支持搜索过滤
 - 🖼️ **图片增强** — 相对/绝对/网络图片,点击 Lightbox 放大,支持缩放(`+` / `-`)
 - 📝 **代码块增强** — 行号、语言标签、一键复制
 - 💡 **GitHub 风格** — NOTE / TIP / WARNING / CAUTION 警报块 + `:::` 自定义容器
 - 🔗 **智能链接** — 外链用系统浏览器打开,`.md` 链接自动在新标签页打开
-- ✏️ **快速编辑** — `Ctrl+E` 切换编辑模式,语法着色,`Ctrl+S` 保存
 
-### 效率工具
+#### 监听模式(新功能 🆕)
+- 🔍 **文件监听** — `Ctrl+Shift+R` 开启监听,自动监控当前文件的外部变化
+- 🟢 **新增行** — 绿色文字 + 浅绿背景 + 绿色左边框,一目了然
+- 🔴 **删除行** — 红色文字 + 删除线 + 浅红背景 + 红色左边框,清晰标注
+- 🧭 **逐条浏览** — 「上一个」「下一个」按钮逐条浏览变更,浮动工具栏显示「变更 1/3」
+- 📊 **变更摘要** — 底部悬浮胶囊实时显示「+5 行新增  -2 行删除」
+- 🔄 **自动捕获** — 文件监听器自动捕获外部修改,计算逐行差异并记录变更历史
+- 🫧 **活跃指示器** — 呼吸光点 + 毛玻璃胶囊,直观展示监听状态与变更统计
+
+#### 效率工具
 - 🗂️ **多标签页** — 浏览器风格标签栏,拖拽排序,独立记忆滚动位置与目录状态
 - 🔍 **全文搜索** — `Ctrl+F` 浮动搜索栏,高亮所有匹配,上下导航
+- ✏️ **快速编辑** — `Ctrl+E` 切换编辑模式,语法着色,`Ctrl+S` 保存
 - 📤 **多格式导出** — PDF / HTML / Markdown 原文件
 - 🔄 **文件监听** — 外部修改自动刷新,编辑模式下自动跳过
 - 🚀 **多入口** — 右键关联 / 拖拽 / 文件对话框 / 最近文件列表
 - 💾 **会话恢复** — 重启后自动恢复上次打开的所有标签
 
-### 界面与主题
+#### 界面与主题
 - 🌗 **深浅主题** — 浅色 / 深色 / 跟随系统,一键切换(`Ctrl+Shift+T`)
 - 🎨 **自定义主题** — 导入 CSS 文件自定义正文样式,内置「清新」「午夜」「学术」三套主题
 - 🧘 **Zen 专注模式** — `Ctrl+Shift+Z` 进入,隐藏所有 UI 仅显示内容,`ESC` 退出
 - 🪟 **毛玻璃 UI** — 标题栏、搜索栏、字数统计等采用毛玻璃模糊效果
 - 🔝 **平滑滚动** — 回到顶部 / TOC 跳转 / 搜索导航均带缓动动画
 
-### 跨平台
+#### 跨平台
 - 🍎 平台差异隔离在 Rust 层,前端零耦合,Windows / macOS 均已适配
 
----
-
-## 📦 下载安装
-
-前往 [Releases](../../releases) 下载对应平台的安装包:
-
-| 平台 | 格式 | 系统要求 |
-|------|------|----------|
-| Windows | `.msi` | Windows 10 1809+(需 WebView2 运行时,Win11 默认自带) |
-| macOS | `.dmg` | macOS 11.0+ (Big Sur) |
-
-安装后可自动注册 `.md` / `.markdown` 文件的右键菜单 **"用 md++ 打开"**
-
----
-
-## 🎹 快捷键
+### ⌨️ 快捷键
 
 | 快捷键 | 功能 |
 |--------|------|
@@ -68,17 +70,59 @@ Windows 下一直没有好用的 Markdown 预览工具。想快速看一眼 `.md
 | `Ctrl+E` | 切换编辑 / 预览模式 |
 | `Ctrl+S` | 保存(编辑模式) |
 | `Ctrl+P` | 导出 |
+| `Ctrl+Shift+R` | 切换文件监听模式 |
 | `Ctrl+Shift+T` | 切换深浅主题 |
 | `Ctrl+Shift+Z` | 进入 / 退出 Zen 专注模式 |
 | `ESC` | 关闭搜索 / 退出 Zen 模式 / 关闭灯箱 |
 
 > macOS 使用 `⌘` 替代 `Ctrl`。
 
----
+### 📦 下载安装
+
+前往 [Releases](https://github.com/Gitleonan/easy-md/releases) 下载对应平台的安装包:
+
+| 平台 | 格式 | 系统要求 |
+|------|------|----------|
+| Windows | `.msi` | Windows 10 1809+(需 WebView2 运行时,Win11 默认自带) |
+| macOS | `.dmg` | macOS 11.0+ (Big Sur) |
+
+安装后可自动注册 `.md` / `.markdown` 文件的右键菜单 **"用 md++ 打开"**
+
+### 🏗️ 架构
+
+```
+Tauri v2 (Rust)  ── 文件 IO / 文件监听 / 系统集成 / 平台隔离
+       ↕ Tauri Command 契约
+React 18 + TS    ── 渲染管线 / UI / 状态管理 / 业务逻辑
+```
+
+- **Rust 后端**只做前端做不了的事(文件系统、监听、系统集成)
+- **前端**完全平台无关,所有平台差异由 Rust `#[cfg(...)]` 隔离
+- **渲染管线**纯前端:`markdown-it` → `shiki` → `mermaid` → 图片路径解析
+- **状态管理**:zustand(tabsStore / editStore / themeStore / zenStore / searchStore / revisionStore)
+- **图标库**:lucide-react(按需导入,最小打包)
+
+### 🔎 适用场景
+
+| 场景 | 说明 |
+|------|------|
+| **Markdown 快速预览** | 双击 `.md` 文件即开即看,无需启动 IDE |
+| **Typora 替代品** | 免费、开源、轻量的 Markdown 阅读器 |
+| **Windows Markdown 查看器** | 原生 Windows 应用,非 Electron,内存占用低 |
+| **macOS Markdown 阅读器** | 原生 macOS 适配,支持 `.dmg` 安装 |
+| **技术文档阅读** | KaTeX 数学公式 + Mermaid 图表 + 代码高亮 |
+| **笔记浏览** | 多标签页、目录导航、全文搜索 |
+| **文档变更追踪** | 监听文件外部变化,差异可视化对比 |
+
+### 🗺️ 路线图
 
-## 🛠️ 开发
+- [x] v0.1 — Windows 首发,完整阅读功能
+- [x] v0.2 — 编辑模式 / 自定义主题 / Zen 专注模式 / macOS 适配
+- [x] v0.3 — 文件监听模式(逐行差异对比、变更导航、实时指示器)
 
-### 前置环境
+### 🛠️ 开发
+
+**前置环境**
 
 | 工具 | 版本 | 说明 |
 |------|------|------|
@@ -87,7 +131,7 @@ Windows 下一直没有好用的 Markdown 预览工具。想快速看一眼 `.md
 | Rust | stable | Tauri 后端编译([安装](https://www.rust-lang.org/tools/install)) |
 | Git | ≥ 2.30 | 版本控制 |
 
-### 启动开发
+**启动开发**
 
 ```bash
 git clone https://github.com/Gitleonan/easy-md.git
@@ -98,14 +142,14 @@ pnpm tauri dev
 
 首次启动会编译 Rust 依赖(约 5-10 分钟),之后启动很快。
 
-### 运行测试
+**运行测试**
 
 ```bash
 pnpm test          # 前端单元测试(Vitest)
 cd src-tauri && cargo test   # 后端 Rust 测试
 ```
 
-### 构建安装包
+**构建安装包**
 
 ```bash
 pnpm tauri build
@@ -117,30 +161,16 @@ pnpm tauri build
 
 ---
 
-## 🏗️ 架构
-
-```
-Tauri v2 (Rust)  ── 文件 IO / 文件监听 / 系统集成 / 平台隔离
-       ↕ Tauri Command 契约
-React 18 + TS    ── 渲染管线 / UI / 状态管理 / 业务逻辑
-```
+## 🙏 致谢
 
-- **Rust 后端**只做前端做不了的事(文件系统、监听、系统集成)
-- **前端**完全平台无关,所有平台差异由 Rust `#[cfg(...)]` 隔离
-- **渲染管线**纯前端:`markdown-it` → `shiki` → `mermaid` → 图片路径解析
-- **状态管理**:zustand(tabsStore / editStore / themeStore / zenStore / searchStore)
-- **图标库**:lucide-react(按需导入,最小打包)
+本项目 Markdown 渲染方案参考了 [vuepress-theme-plume](https://github.com/pengzhanbo/vuepress-theme-plume) 项目,特此感谢。
 
 ---
 
-## 🗺️ 路线图
+## 📄 License
 
-- [x] v0.1 — Windows 首发,完整阅读功能
-- [x] v0.2 — 编辑模式 / 自定义主题 / Zen 专注模式 / macOS 适配
-- [ ] v0.3 — 监听修订模式
+MIT
 
 ---
 
-## 📄 License
-
-MIT
+**Keywords**: markdown viewer, markdown reader, markdown preview, windows markdown, macos markdown, tauri app, open source, lightweight, multi-tab, pdf export, code highlight, katex, mermaid, file monitoring, change diff, line diff, markdown-it, react, zustand
diff --git a/README_EN.md b/README_EN.md
new file mode 100644
index 0000000..d328ac4
--- /dev/null
+++ b/README_EN.md
@@ -0,0 +1,165 @@
+# md++ — Lightweight Markdown Viewer
+
+> **md++** (easy-md) — A fast, lightweight Markdown viewer and reader for Windows & macOS. Open `.md` files instantly — no heavy editors needed. Built-in file monitoring mode captures external changes and visualizes content diffs in real time.
+
+> 🌐 [中文版本](./README.md)
+
+[![made with Tauri](https://img.shields.io/badge/made%20with-Tauri%20v2-orange)](https://tauri.app)
+[![React](https://img.shields.io/badge/frontend-React%2018%20%2B%20TS-blue)](https://react.dev)
+[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
+[![GitHub Release](https://img.shields.io/github/v/release/Gitleonan/easy-md)](https://github.com/Gitleonan/easy-md/releases)
+[![GitHub Stars](https://img.shields.io/github/stars/Gitleonan/easy-md?style=social)](https://github.com/Gitleonan/easy-md)
+
+---
+![md++ Software Interface](assets/github-promo.png)
+
+---
+
+**md++** is a fast, lightweight **Markdown viewer and reader** built with [Tauri v2](https://tauri.app) (Rust + React). Open `.md` files instantly on **Windows** or **macOS** — no heavy editors needed.
+
+**Highlights:** Full Markdown rendering (KaTeX math, Mermaid diagrams, shiki code highlighting), multi-tab browsing, TOC navigation, full-text search, PDF/HTML export, dark/light themes, file-watching auto-reload, Zen focus mode, and **file monitoring** — watch external file changes and visualize content diffs in real time.
+
+[📥 Download](https://github.com/Gitleonan/easy-md/releases) · [🐛 Report Issue](https://github.com/Gitleonan/easy-md/issues) · [💬 Discussions](https://github.com/Gitleonan/easy-md/discussions)
+
+### ✨ Features
+
+#### Reading Experience
+- 🎨 **Full syntax rendering** — Standard Markdown + KaTeX math + shiki code highlighting + Mermaid diagrams
+- 📑 **Sidebar TOC** — Auto-generated table of contents with scroll sync and search filter
+- 🖼️ **Image enhancement** — Relative/absolute/remote images, click-to-zoom lightbox with scaling
+- 📝 **Code block enhancement** — Line numbers, language labels, one-click copy
+- 💡 **GitHub-flavored** — Alerts (NOTE / TIP / WARNING / CAUTION) + `:::` custom containers
+- 🔗 **Smart links** — External links open in system browser, `.md` links open in new tab
+
+#### File Monitoring & Change Diff (New! 🆕)
+- 🔍 **File monitoring** — `Ctrl+Shift+R` starts monitoring the current file for external changes
+- 🟢 **Added lines** — Displayed in green with a subtle green left border
+- 🔴 **Deleted lines** — Displayed in red with strikethrough and a red left border
+- 🧭 **Change navigation** — Browse detected changes one by one with Previous / Next buttons
+- 📊 **Change summary** — View counts of added and deleted lines at a glance
+- 🔄 **Auto-capture** — File watcher automatically captures external modifications and computes line-level diffs
+- 🫧 **Live indicator** — A pulsing "monitoring" badge shows the active monitoring state and current change stats
+
+#### Productivity
+- 🗂️ **Multi-tab** — Browser-style tabs with drag-to-reorder; each tab remembers its scroll position
+- 🔍 **Full-text search** — `Ctrl+F` floating search bar; highlights all matches with up/down navigation
+- ✏️ **Quick editing** — `Ctrl+E` toggles edit mode with syntax highlighting; `Ctrl+S` to save
+- 📤 **Multi-format export** — Export to PDF, standalone HTML, or raw Markdown
+- 🔄 **File watching** — Auto-reloads when external tools modify the file (skips during editing)
+- 🚀 **Multiple entry points** — Right-click association / drag-and-drop / file dialog / recent files
+- 💾 **Session restore** — Reopens all tabs from last session after restart
+
+#### UI & Themes
+- 🌗 **Dark & Light** — Light / Dark / System themes; one-key toggle (`Ctrl+Shift+T`)
+- 🎨 **Custom themes** — Import CSS files; includes "Fresh", "Midnight", and "Academic" presets
+- 🧘 **Zen mode** — `Ctrl+Shift+Z` hides all UI, showing only content; `ESC` to exit
+- 🪟 **Glass-morphism UI** — Frosted-glass effect on title bar, search bar, and badges
+- 🔝 **Smooth scrolling** — Eased animations for back-to-top, TOC jumps, and search navigation
+
+#### Cross-platform
+- 🍎 Platform-specific logic isolated in Rust layer; zero coupling in frontend
+- 🪟 Windows 10 1809+ (WebView2 required; built-in on Win11)
+- 🍎 macOS 11.0+ (Big Sur)
+
+### ⌨️ Keyboard Shortcuts
+
+| Shortcut | Action |
+|----------|--------|
+| `Ctrl+O` | Open file |
+| `Ctrl+F` | Full-text search |
+| `Ctrl+E` | Toggle edit / preview mode |
+| `Ctrl+S` | Save (edit mode) |
+| `Ctrl+P` | Export |
+| `Ctrl+Shift+R` | Toggle file monitoring mode |
+| `Ctrl+Shift+T` | Toggle dark/light theme |
+| `Ctrl+Shift+Z` | Toggle Zen mode |
+| `ESC` | Close search / exit Zen / close lightbox |
+
+> On macOS, use `⌘` (Cmd) instead of `Ctrl`.
+
+### 🏗️ Architecture
+
+```
+Tauri v2 (Rust)  ── File I/O / File watching / System integration / Platform isolation
+       ↕ Tauri Command contract
+React 18 + TS    ── Render pipeline / UI / State management / Business logic
+```
+
+- **Rust backend** handles what the frontend can't: file system, file watching, system integration
+- **Frontend** is fully platform-agnostic; all platform differences are abstracted by Rust `#[cfg(...)]`
+- **Render pipeline**: `markdown-it` → `shiki` → `mermaid` → image path resolution
+- **State management**: zustand (tabs / edit / theme / zen / search / revision stores)
+- **Icons**: lucide-react (tree-shaken, minimal bundle)
+
+### 🔎 Use Cases
+
+| Scenario | Description |
+|----------|-------------|
+| **Quick Markdown preview** | Double-click `.md` files — no IDE required |
+| **Typora alternative** | Free, open-source, lightweight Markdown reader |
+| **Windows Markdown viewer** | Native Windows app (not Electron); low memory footprint |
+| **macOS Markdown reader** | Native macOS support with `.dmg` installation |
+| **Technical docs** | KaTeX math + Mermaid diagrams + syntax-highlighted code |
+| **Note browsing** | Multi-tab, TOC navigation, full-text search |
+| **File monitoring** | Watch external file changes and visualize content diffs |
+
+### 🗺️ Roadmap
+
+- [x] v0.1 — Windows launch, complete reading features
+- [x] v0.2 — Edit mode / Custom themes / Zen mode / macOS support
+- [x] v0.3 — File monitoring & change diff (line-level diff, change navigation, live indicator)
+
+### 🛠️ Development
+
+**Prerequisites**
+
+| Tool | Version | Notes |
+|------|---------|-------|
+| Node.js | ≥ 20 | Frontend build |
+| pnpm | ≥ 9 | Package manager (`npm i -g pnpm`) |
+| Rust | stable | Tauri backend ([install](https://www.rust-lang.org/tools/install)) |
+| Git | ≥ 2.30 | Version control |
+
+**Quick start**
+
+```bash
+git clone https://github.com/Gitleonan/easy-md.git
+cd easy-md
+pnpm install
+pnpm tauri dev
+```
+
+First run compiles Rust dependencies (~5–10 min); subsequent starts are fast.
+
+**Running tests**
+
+```bash
+pnpm test                  # Frontend unit tests (Vitest)
+cd src-tauri && cargo test # Backend Rust tests
+```
+
+**Building installers**
+
+```bash
+pnpm tauri build
+```
+
+Outputs are in `src-tauri/target/release/bundle/`:
+- **Windows** — `.msi` installer
+- **macOS** — `.dmg` disk image
+
+---
+
+## 🙏 Acknowledgments
+
+The Markdown rendering approach in this project was inspired by [vuepress-theme-plume](https://github.com/pengzhanbo/vuepress-theme-plume). Many thanks to the author.
+
+---
+
+## 📄 License
+
+MIT
+
+---
+
+**Keywords**: markdown viewer, markdown reader, markdown preview, windows markdown, macos markdown, tauri app, open source, lightweight, multi-tab, pdf export, code highlight, katex, mermaid, file monitoring, change diff, line diff, markdown-it, react, zustand
diff --git a/package.json b/package.json
index 462a055..d0b206b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "easy-md",
   "private": true,
-  "version": "0.2.0",
+  "version": "0.3.0",
   "type": "module",
   "description": "md++ — 轻量便捷的 Markdown 阅读器 (Windows / macOS)",
   "author": "leonan",
@@ -19,6 +19,8 @@
     "@tauri-apps/plugin-dialog": "^2.7.1",
     "@tauri-apps/plugin-opener": "^2",
     "@traptitech/markdown-it-katex": "^3.6.0",
+    "diff": "^9.0.0",
+    "framer-motion": "^12.40.0",
     "katex": "^0.17.0",
     "lucide-react": "^1.21.0",
     "markdown-it": "^14.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 88e2298..5c5dca0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,12 @@ importers:
       '@traptitech/markdown-it-katex':
         specifier: ^3.6.0
         version: 3.6.0
+      diff:
+        specifier: ^9.0.0
+        version: 9.0.0
+      framer-motion:
+        specifier: ^12.40.0
+        version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       katex:
         specifier: ^0.17.0
         version: 0.17.0
@@ -1232,6 +1238,10 @@ packages:
   didyoumean@1.2.2:
     resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
 
+  diff@9.0.0:
+    resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
+    engines: {node: '>=0.3.1'}
+
   dlv@1.1.3:
     resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
 
@@ -1324,6 +1334,20 @@ packages:
   fraction.js@5.3.4:
     resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
 
+  framer-motion@12.40.0:
+    resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==}
+    peerDependencies:
+      '@emotion/is-prop-valid': '*'
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@emotion/is-prop-valid':
+        optional: true
+      react:
+        optional: true
+      react-dom:
+        optional: true
+
   fsevents@2.3.3:
     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1595,6 +1619,12 @@ packages:
     resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
     engines: {node: '>=4'}
 
+  motion-dom@12.40.0:
+    resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==}
+
+  motion-utils@12.39.0:
+    resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
+
   ms@2.1.3:
     resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
 
@@ -1931,6 +1961,9 @@ packages:
   ts-interface-checker@0.1.13:
     resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
 
+  tslib@2.8.1:
+    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
   typescript@5.9.3:
     resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
     engines: {node: '>=14.17'}
@@ -3153,6 +3186,8 @@ snapshots:
 
   didyoumean@1.2.2: {}
 
+  diff@9.0.0: {}
+
   dlv@1.1.3: {}
 
   dom-accessibility-api@0.5.16: {}
@@ -3258,6 +3293,15 @@ snapshots:
 
   fraction.js@5.3.4: {}
 
+  framer-motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+    dependencies:
+      motion-dom: 12.40.0
+      motion-utils: 12.39.0
+      tslib: 2.8.1
+    optionalDependencies:
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+
   fsevents@2.3.3:
     optional: true
 
@@ -3554,6 +3598,12 @@ snapshots:
 
   min-indent@1.0.1: {}
 
+  motion-dom@12.40.0:
+    dependencies:
+      motion-utils: 12.39.0
+
+  motion-utils@12.39.0: {}
+
   ms@2.1.3: {}
 
   mz@2.7.0:
@@ -3895,6 +3945,8 @@ snapshots:
 
   ts-interface-checker@0.1.13: {}
 
+  tslib@2.8.1: {}
+
   typescript@5.9.3: {}
 
   uc.micro@2.1.0: {}
diff --git a/src/App.tsx b/src/App.tsx
index 8886b26..94a569c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
 import { useRef, useEffect, useState, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
 import { listen } from '@tauri-apps/api/event';
 import { TitleBar } from './components/TitleBar/TitleBar';
 import { Sidebar } from './components/Sidebar/Sidebar';
@@ -17,6 +18,8 @@ import { useCustomThemeStore } from './stores/customThemeStore';
 import { useOpenFile } from './hooks/useOpenFile';
 import { useThemeInit } from './features/theme/useThemeInit';
 import { useGlobalShortcuts } from './hooks/useGlobalShortcuts';
+import { useRevisionShortcuts } from './hooks/useRevisionShortcuts';
+import { useRevisionStore } from './stores/revisionStore';
 import { useFileWatcher } from './features/fileWatch/useFileWatcher';
 import { useRecentStore } from './stores/recentStore';
 import { useThemeStore } from './stores/themeStore';
@@ -31,6 +34,7 @@ export default function App() {
   useOpenFile();
   useThemeInit();
   useFileWatcher();
+  useRevisionShortcuts();
 
   const contentRef = useRef(null);
   const hasTabs = useTabsStore((s) => s.tabs.length > 0);
@@ -83,9 +87,10 @@ export default function App() {
     if (activeTab) record(activeTab.filePath, activeTab.fileName);
   }, [activeTab?.id, record]);
 
-  // 切换 tab 时退出编辑模式
+  // 切换 tab 时退出编辑模式和修订模式
   useEffect(() => {
     useEditStore.getState().reset();
+    useRevisionStore.getState().disableRevisionMode();
   }, [activeTab?.id]);
 
   // Zen 模式下 ESC 退出
@@ -108,15 +113,35 @@ export default function App() {
       {hasTabs && activeTab && !isEditing && !isZen && }
       
{hasTabs && activeTab && !isZen && } - {hasTabs ? ( - isEditing ? ( - + + {hasTabs ? ( + isEditing ? ( + + + + ) : ( + + + + ) ) : ( - - ) - ) : ( - - )} + + )} +
{ const next = !debug; @@ -75,8 +76,24 @@ export function AboutModal({ open, onClose }: AboutModalProps) { }; return ( -
-
e.stopPropagation()}> + + {open && ( + + e.stopPropagation()} + >

md++

@@ -150,7 +167,9 @@ export function AboutModal({ open, onClose }: AboutModalProps) { 关闭
-
-
+ + + )} + ); } diff --git a/src/components/Content/Content.tsx b/src/components/Content/Content.tsx index 8975903..c563eeb 100644 --- a/src/components/Content/Content.tsx +++ b/src/components/Content/Content.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import { ChevronUp } from 'lucide-react'; import { useTabsStore } from '../../stores/tabsStore'; import { useZenStore } from '../../stores/zenStore'; +import { countChangeSegments, useRevisionStore } from '../../stores/revisionStore'; import { resolveImages } from '../../ipc/files'; import { openExternalUrl, openLocalPath } from '../../ipc/opener'; import { renderMermaidInContainer } from '../../features/markdown/mermaid'; @@ -9,6 +11,11 @@ import { injectAnchors } from '../../features/markdown/anchors'; import { classifyLink, resolveLocalPath } from '../../features/markdown/links'; import { useLightbox } from '../../hooks/useLightbox'; import { smoothScrollTo } from '../../utils/smoothScroll'; +import { segmentHunks, groupChangeSegments } from '../../utils/diff'; +import { renderMarkdownWithHtml } from '../../features/markdown/render'; +import { highlightCodeBlocks } from '../../features/markdown/highlight'; +import { extractToc } from '../../features/markdown/toc'; +import type { RevisionHunk } from '../../types'; interface ContentProps { contentRef?: React.RefObject; @@ -189,6 +196,260 @@ function ZenHint() { ); } + +/** 修订模式预览视图:分组渲染,新增内容红色字体,删除内容带删除线 */ +function RevisionPreview({ + newSource, + oldSource, + filePath, + hunks, + onContentReady, +}: { + newSource: string; + oldSource: string; + filePath: string; + hunks: RevisionHunk[]; + onContentReady?: (el: HTMLElement) => void; +}) { + const containerRef = useRef(null); + const [html, setHtml] = useState(null); + const currentSegmentIndex = useRevisionStore((s) => s.currentSegmentIndex); + const currentRevisionId = useRevisionStore((s) => s.currentRevisionId); + + useEffect(() => { + let cancelled = false; + const theme = + document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; + (async () => { + try { + const segments = segmentHunks(hunks); + const changeGroups = groupChangeSegments(segments); + // 将每个段落渲染为 HTML;变更块外层带 id 锚点 + data-rev-seg + const parts: string[] = []; + segments.forEach((seg, segIdx) => { + if (seg.kind === 'unchanged') { + parts.push(renderMarkdownWithHtml(seg.source)); + return; + } + // 该段属于第几个变更点 + const groupIdx = changeGroups.findIndex(g => g.includes(segIdx)); + const blockId = groupIdx >= 0 ? `rev-seg-${groupIdx}` : ''; + const idAttr = blockId ? ` id="${blockId}" data-rev-seg="${groupIdx}"` : ''; + const cls = seg.kind === 'added' ? 'rev-add' : 'rev-del'; + parts.push( + `
${renderMarkdownWithHtml(seg.source)}
`, + ); + }); + const rendered = parts.join('\n'); + const highlighted = await highlightCodeBlocks(rendered, theme); + if (!cancelled) setHtml(highlighted); + } catch (err) { + console.error('[RevisionPreview] render failed', err); + if (!cancelled) setHtml(`

渲染失败

`); + } + })(); + return () => { + cancelled = true; + }; + }, [newSource, oldSource, hunks]); + + // DOM 注入后处理并滚动到第一个变更块 + useEffect(() => { + if (!html || !containerRef.current) return; + const el = containerRef.current; + el.innerHTML = html; + + // 注入锚点(用新版本 TOC) + const toc = extractToc(newSource); + injectAnchors(el, toc); + + // 增强代码块 + enhanceCodeBlocks(el); + + // 图片和 Mermaid + Promise.all([ + resolveImages(el, filePath), + renderMermaidInContainer(el), + ]).catch(console.error); + + onContentReady?.(el); + + // 自动滚动到当前变更块 + const target = currentSegmentIndex >= 0 + ? (el.querySelector(`#rev-seg-${currentSegmentIndex}`) as HTMLElement | null) + : null; + const firstChange = target ?? (el.querySelector('.rev-block') as HTMLElement | null); + if (firstChange) { + requestAnimationFrame(() => { + firstChange.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + } + }, [html, filePath, newSource, hunks, onContentReady, currentSegmentIndex, currentRevisionId]); + + if (!html) { + return ( +
+
+

正在渲染修订预览…

+
+ ); + } + + return ( +
+ ); +} + +/** 监听变更悬浮栏:光点状态 + 上一个/下一个导航 + 停止监听 */ +function MonitoringIndicator() { + const isRevisionMode = useRevisionStore((s) => s.isRevisionMode); + const revisions = useRevisionStore((s) => s.revisions); + const currentRevisionId = useRevisionStore((s) => s.currentRevisionId); + const currentSegmentIndex = useRevisionStore((s) => s.currentSegmentIndex); + const navigatePrev = useRevisionStore((s) => s.navigatePrev); + const navigateNext = useRevisionStore((s) => s.navigateNext); + const disableRevisionMode = useRevisionStore((s) => s.disableRevisionMode); + + const currentRevision = revisions.find((r) => r.id === currentRevisionId) ?? null; + + if (!isRevisionMode) return null; + + const revIndex = revisions.findIndex((r) => r.id === currentRevisionId); + const segTotal = currentRevision ? countChangeSegments(currentRevision.hunks) : 0; + // 是否处于可导航的最末/最前(跨 revision) + const atFirst = revIndex <= 0 && currentSegmentIndex <= 0; + const atLast = revIndex >= revisions.length - 1 && currentSegmentIndex >= segTotal - 1; + + const addedCount = currentRevision?.hunks.filter((h) => h.type === 'added').length ?? 0; + const removedCount = currentRevision?.hunks.filter((h) => h.type === 'removed').length ?? 0; + const hasChanges = segTotal > 0; + + const btn = (delay: number) => ({ duration: 0.4, ease: [0.32, 0.72, 0, 1] as [number, number, number, number], delay }); + + return ( + + {/* 光点状态胶囊 */} + + + {/* 外圈扩散涟漪 */} + + {/* 内核呼吸光点 */} + + + + + {/* 变更统计 */} + + {hasChanges && ( + + {addedCount > 0 && +{addedCount}} + + {removedCount > 0 && -{removedCount}} + + )} + + + {/* 分隔线 */} + + {hasChanges && ( + + )} + + + {/* 上一个 */} + + ← 上一个 + + + {/* 计数:当前变更点 / 该 revision 变更点总数 */} + + {hasChanges && ( + + {currentSegmentIndex + 1}/{segTotal} + + )} + + + {/* 下一个 */} + + 下一个 → + + + {/* 停止监听 */} + + ✕ + + + ); +} + export function Content({ contentRef, onActiveHeadingChange }: ContentProps) { // 仅订阅会改变渲染结果的字段,避免滚动时 setScrollTop 触发本组件重渲染—— // 否则下面的 useEffect 会因为 tab 引用变化重新跑,把 el.innerHTML 重置一次, @@ -199,6 +460,13 @@ export function Content({ contentRef, onActiveHeadingChange }: ContentProps) { const tabIsLoading = useTabsStore((s) => s.tabs.find((t) => t.id === s.activeTabId)?.isLoading ?? false); const setScrollTop = useTabsStore((s) => s.setScrollTop); const isZen = useZenStore((s) => s.isZen); + + // 修订模式状态 + const isRevisionMode = useRevisionStore(s => s.isRevisionMode); + const currentRevision = useRevisionStore(s => + s.revisions.find(r => r.id === s.currentRevisionId) ?? null, + ); + const internalRef = useRef(null); const ref = contentRef || internalRef; const [showBackToTop, setShowBackToTop] = useState(false); @@ -212,14 +480,18 @@ export function Content({ contentRef, onActiveHeadingChange }: ContentProps) { // 通过 getState 读取,避免把 tab 整体写进 deps 而被滚动期间的引用刷新带歪。 const tab = useTabsStore.getState().tabs.find((t) => t.id === tabId); if (!tab) return; - const el = ref.current; - el.innerHTML = tab.html; - - // 恢复滚动位置时,短暂屏蔽 scroll 事件,防止 setScrollTop 覆盖刚恢复的值 - let restoring = true; - el.scrollTop = tab.scrollTop; - setShowBackToTop(el.scrollTop > 300); - requestAnimationFrame(() => { restoring = false; }); + const el = ref.current; + el.innerHTML = tab.html; + + // 恢复滚动位置时,短暂屏蔽 scroll 事件,防止 setScrollTop 覆盖刚恢复的值 + // 延迟到 rAF 确保浏览器完成布局后再设置 scrollTop + let restoring = true; + requestAnimationFrame(() => { + el.scrollTop = tab.scrollTop; + setShowBackToTop(el.scrollTop > 300); + // 再延迟一帧清除 restoring,避免 scroll 事件覆盖 + requestAnimationFrame(() => { restoring = false; }); + }); // 注入锚点 ID injectAnchors(el, tab.toc); @@ -312,18 +584,78 @@ export function Content({ contentRef, onActiveHeadingChange }: ContentProps) { el.removeEventListener('click', onLinkClick); imgHandlers.forEach((fn) => fn()); }; - }, [ - tabId, - tabHtml, - tabIsLoading, - setScrollTop, - ref, - onActiveHeadingChange, - ]); + }, [ + tabId, + tabHtml, + tabIsLoading, + setScrollTop, + ref, + onActiveHeadingChange, + isRevisionMode, // 修复 Bug 3: 从修订模式切回预览时需重新填充 innerHTML + currentRevision?.id, // 修复 Bug 2: 拒绝修订后 currentRevision 变为 null 时重新填充 + ]); if (!tabId || (tabHtml == null && !tabIsLoading)) return null; + + // 修订模式 + 有当前修订:渲染新版本的 HTML 预览(而非原始 diff 文本) + if (isRevisionMode && currentRevision) { + const tab = useTabsStore.getState().tabs.find((t) => t.id === tabId); + return ( +
+ + { + // 图片点击 Lightbox + const open = useLightbox.getState().open; + el.querySelectorAll('img').forEach((img) => { + if (img.closest('a')) return; + img.addEventListener('click', () => open(img.src)); + }); + // 链接点击拦截 + const onLinkClick = (e: MouseEvent) => { + const target = + e.target instanceof Element + ? e.target + : e.target instanceof Node + ? e.target.parentElement + : null; + const a = target?.closest('a'); + if (!a) return; + const href = a.getAttribute('href'); + if (!href) return; + const link = classifyLink(href); + if (link.kind === 'anchor') return; + e.preventDefault(); + if (link.kind === 'external') { + openExternalUrl(link.url).catch(console.error); + return; + } + if (link.kind === 'mdFile' || link.kind === 'localFile') { + const resolved = resolveLocalPath(tab?.filePath ?? '', link.path); + if (link.kind === 'mdFile') { + useTabsStore.getState().openTab(resolved).catch(console.error); + } else { + openLocalPath(resolved).catch(console.error); + } + } + }; + el.addEventListener('click', onLinkClick); + }} + /> +
字数 {countMarkdownWords(currentRevision.newSource)}
+ {isZen && } +
+ ); + } + + // 正常模式 / 修订模式无修订:保持原有结构,useEffect 正常填充 innerHTML return (
+ {tabIsLoading ? (
@@ -340,14 +672,18 @@ export function Content({ contentRef, onActiveHeadingChange }: ContentProps) { />
字数 {countMarkdownWords(tabSource ?? '')}
{isZen && } - + )}
diff --git a/src/components/ExportPdfModal/ExportPdfModal.tsx b/src/components/ExportPdfModal/ExportPdfModal.tsx index 5877166..2ab833e 100644 --- a/src/components/ExportPdfModal/ExportPdfModal.tsx +++ b/src/components/ExportPdfModal/ExportPdfModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import type { ExportFormat } from '../../features/export/export'; interface ExportModalProps { @@ -40,8 +41,6 @@ export function ExportPdfModal({ open, fileName, onConfirm, onClose }: ExportMod return () => window.removeEventListener('keydown', onKey); }, [open, busy, onClose]); - if (!open) return null; - const handleConfirm = async () => { if (busy) return; setBusy(true); @@ -54,20 +53,30 @@ export function ExportPdfModal({ open, fileName, onConfirm, onClose }: ExportMod }; return ( -
{ - if (e.target === e.currentTarget && !busy) onClose(); - }} - > -
+ + {open && ( + { + if (e.target === e.currentTarget && !busy) onClose(); + }} + > +
md++ @@ -109,7 +118,9 @@ export function ExportPdfModal({ open, fileName, onConfirm, onClose }: ExportMod
-
-
+ + + )} + ); } diff --git a/src/components/Lightbox/Lightbox.tsx b/src/components/Lightbox/Lightbox.tsx index 8e57a5a..d18b20c 100644 --- a/src/components/Lightbox/Lightbox.tsx +++ b/src/components/Lightbox/Lightbox.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import { useLightbox } from '../../hooks/useLightbox'; export function Lightbox() { @@ -18,17 +19,30 @@ export function Lightbox() { return () => window.removeEventListener('keydown', onKey); }, [src, close]); - if (!src) return null; - return ( -
- e.stopPropagation()} - alt="" - /> -
+ + {src && ( + + e.stopPropagation()} + alt="" + /> + + )} + ); } diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx index 7bf4caa..449f3f6 100644 --- a/src/components/SearchBar/SearchBar.tsx +++ b/src/components/SearchBar/SearchBar.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import { X, ChevronUp, ChevronDown } from 'lucide-react'; import { useSearchStore } from '../../stores/searchStore'; import { useTabsStore } from '../../stores/tabsStore'; @@ -65,8 +66,6 @@ export function SearchBar({ contentRef }: SearchBarProps) { focusMatch(marks, current); }, [current, total, contentRef]); - if (!visible) return null; - const clearKeyword = () => { const el = contentRef.current; if (el) clearHighlights(el); @@ -76,7 +75,15 @@ export function SearchBar({ contentRef }: SearchBarProps) { }; return ( -
+ + {visible && ( +
setVisible(false)} title="关闭"> -
+
+ )} +
); } diff --git a/src/components/ThemeManager/ThemeManager.tsx b/src/components/ThemeManager/ThemeManager.tsx index 3f1a172..57a50da 100644 --- a/src/components/ThemeManager/ThemeManager.tsx +++ b/src/components/ThemeManager/ThemeManager.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import { useCustomThemeStore, BUILTIN_THEMES } from '../../stores/customThemeStore'; import templateCss from '../../themes/template.css?raw'; import { save as saveDialog } from '@tauri-apps/plugin-dialog'; @@ -28,8 +29,6 @@ export function ThemeManager({ open, onClose }: ThemeManagerProps) { if (open) loadThemes(); }, [open, loadThemes]); - if (!open) return null; - const handleImportClick = () => { fileInputRef.current?.click(); }; @@ -75,8 +74,24 @@ export function ThemeManager({ open, onClose }: ThemeManagerProps) { const noThemeActive = !activeTheme && !activeBuiltin; return ( -
-
e.stopPropagation()}> + + {open && ( + + e.stopPropagation()} + >

自定义主题

@@ -171,7 +186,9 @@ export function ThemeManager({ open, onClose }: ThemeManagerProps) {

-
-
+ + + )} + ); } diff --git a/src/components/TitleBar/TitleBar.tsx b/src/components/TitleBar/TitleBar.tsx index 7f3c4a1..6f31ab0 100644 --- a/src/components/TitleBar/TitleBar.tsx +++ b/src/components/TitleBar/TitleBar.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; -import { Plus, RotateCw, Download, Sun, Moon, Pencil, Eye, Palette, Info, X, ScanEye } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Plus, RotateCw, Download, Sun, Moon, Pencil, Eye, Palette, Info, X, ScanEye, GitCompareArrows } from 'lucide-react'; import { useTabsStore } from '../../stores/tabsStore'; import { useThemeStore } from '../../stores/themeStore'; import { useEditStore } from '../../stores/editStore'; import { useZenStore } from '../../stores/zenStore'; +import { useRevisionStore } from '../../stores/revisionStore'; import { openViaDialog } from '../../hooks/useOpenFile'; import { openContainingFolder } from '../../ipc/opener'; @@ -40,6 +42,9 @@ export function TitleBar({ onExport, onAbout, onThemeManager }: TitleBarProps) { const toggleEditing = useEditStore((s) => s.toggleEditing); const isZen = useZenStore((s) => s.isZen); const toggleZen = useZenStore((s) => s.toggle); + const isMonitoring = useRevisionStore(s => s.isRevisionMode); + const enableRevisionMode = useRevisionStore(s => s.enableRevisionMode); + const disableRevisionMode = useRevisionStore(s => s.disableRevisionMode); const [menu, setMenu] = useState(null); const [draggingTabId, setDraggingTabId] = useState(null); const menuTab = menu ? tabs.find((t) => t.id === menu.tabId) : null; @@ -66,6 +71,14 @@ export function TitleBar({ onExport, onAbout, onThemeManager }: TitleBarProps) { Promise.resolve(action()).catch((err) => console.error('[tab menu] action failed', err)); }; + const handleToggleMonitoring = () => { + if (isMonitoring) { + disableRevisionMode(); + } else if (activeTab) { + enableRevisionMode(activeTab.source); + } + }; + return (
+ {/* 1. 监听变更 — Framer Motion 驱动 switch 按钮 */} + + {/* 图标 */} + + + + + {/* 文字标签 */} + + + {isMonitoring ? '监听中' : '监听变更'} + + + + + {/* 2. Zen 模式 */} + + {/* 3. 编辑模式 */} + + + {/* 4. 刷新 */} + + + {/* 导出 */} + {activeTab && ( + + )} + + {/* 5. 夜间模式 */} - {activeTab && ( - <> - - - - - )} + + {/* 6. 主题切换 */} + + {/* 7. 关于 */} -

或将 .md 文件拖入窗口

-

{modKey()}+O 打开文件

+ +
); } diff --git a/src/features/export/export.test.ts b/src/features/export/export.test.ts index c9fc9e3..777b826 100644 --- a/src/features/export/export.test.ts +++ b/src/features/export/export.test.ts @@ -40,28 +40,12 @@ describe('export', () => { expect(html).toContain('katex'); }); - it('exports pdf via a hidden iframe + window.print, not via saveDialog or rust', async () => { + it('exports pdf via main window.print + overlay, not via saveDialog or rust', async () => { const root = document.createElement('div'); root.innerHTML = '

你好

Hi

'; - // jsdom 没实现 print,给 HTMLIFrameElement.contentWindow.print 打桩 - const printSpy = vi.fn(); - // 拦截 iframe 的 srcdoc 设值:onload 在 jsdom 里不会自动触发,手动派发 - const origAppendChild = document.body.appendChild.bind(document.body); - const appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => { - const result = origAppendChild(node); - if (node instanceof HTMLIFrameElement) { - // 等下一个微任务,模拟 srcdoc 完成 - Promise.resolve().then(() => { - Object.defineProperty(node, 'contentWindow', { - configurable: true, - value: { focus: vi.fn(), print: printSpy }, - }); - node.dispatchEvent(new Event('load')); - }); - } - return result; - }); + // jsdom 没实现 print,给 window.print 打桩 + const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {}); await exportDocument({ element: root, @@ -75,7 +59,11 @@ describe('export', () => { expect(writeFileMock).not.toHaveBeenCalled(); expect(printSpy).toHaveBeenCalledTimes(1); - appendSpy.mockRestore(); + // 验证 overlay 被注入后又清理了 + expect(document.querySelector('.print-overlay')).toBeNull(); + expect(document.getElementById('print-overlay-style')).toBeNull(); + + printSpy.mockRestore(); }); it('exports markdown as a new md file', async () => { diff --git a/src/features/export/export.ts b/src/features/export/export.ts index 563f426..243f596 100644 --- a/src/features/export/export.ts +++ b/src/features/export/export.ts @@ -88,51 +88,78 @@ export async function exportPdf(el: HTMLElement, theme: 'light' | 'dark', fileNa } /** - * 用 hidden iframe + window.print() 触发系统打印。 + * 用主窗口 window.print() + @media print CSS 触发系统打印。 * - * 为什么不调用主窗口的 window.print():那样会把 TitleBar / Sidebar / - * SearchBar 一起塞进 PDF。打印 iframe 的好处是 iframe 内的 document - * 是一个干净的 HTML 文档,只有我们注入的内容和样式。 + * macOS 的 WKWebView 不支持 iframe.contentWindow.print()(静默失败), + * 因此改为在主文档中注入一个 .print-overlay 容器,用 @media print CSS + * 隐藏所有 UI 元素(TitleBar / Sidebar / SearchBar),只保留要打印的内容。 * - * 注意:在 jsdom 测试环境里 iframe.contentWindow.print 不存在, - * 用 buildStandaloneHtml 的可注入性来做替身验证(见 export.test.ts)。 + * 流程: + * 1. 注入