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/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 内容…"
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. 注入