From 88ddae903d7e8d7ccad3c0cf06329253eac2a882 Mon Sep 17 00:00:00 2001 From: "liunan_csd@si-tech.com.cn" Date: Tue, 23 Jun 2026 20:48:36 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20README=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=20Typora=20=E5=AF=B9=E6=AF=94=E8=AF=9D?= =?UTF-8?q?=E6=9C=AF=EF=BC=8C=E5=A2=9E=E5=8A=A0=20Vibe=20Coding=20?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- README_EN.md | 4 +- src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands/opener.rs | 41 +++++++++++ src-tauri/src/lib.rs | 1 + src/features/export/export.test.ts | 38 +++++++--- src/features/export/export.ts | 110 ++++++++-------------------- src/ipc/opener.ts | 5 ++ 8 files changed, 113 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index b960efb..6fe94eb 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ ## 🤔 为什么做这个 -Windows 下一直没有好用的 Markdown 预览工具。想快速看一眼 `.md` 文件的内容,要么得打开 VS Code 这样的重量级编辑器,要么得用浏览器配合插件,体验都不够便捷。 +Vibe Coding 时代,AI 代码生成变得越来越普遍。项目中充斥着大量的 `.md` 文件——需求文档、架构设计、接口说明、PRD……频繁查阅这些文档成了日常刚需。但每次都要打开 VS Code 或浏览器,体验笨重且割裂。 + +Windows 下也一直没有好用的 Markdown 预览工具。想快速看一眼 `.md` 文件的内容,要么得打开 VS Code 这样的重量级编辑器,要么得用浏览器配合插件,体验都不够便捷。 **md++** 就是为了解决这个问题——一个轻量、纯粹的 Markdown 阅读器,双击文件即可预览,需要时也能快速编辑。**v0.3 新增的监听模式**,能像专业 diff 工具一样监听文件的外部变化、自动计算差异并可视化展示变更内容。 @@ -107,7 +109,7 @@ React 18 + TS ── 渲染管线 / UI / 状态管理 / 业务逻辑 | 场景 | 说明 | |------|------| | **Markdown 快速预览** | 双击 `.md` 文件即开即看,无需启动 IDE | -| **Typora 替代品** | 免费、开源、轻量的 Markdown 阅读器 | +| **Vibe Coding 搭档** | 查阅 AI 生成的需求文档、架构设计、PRD,轻量不拖沓 | | **Windows Markdown 查看器** | 原生 Windows 应用,非 Electron,内存占用低 | | **macOS Markdown 阅读器** | 原生 macOS 适配,支持 `.dmg` 安装 | | **技术文档阅读** | KaTeX 数学公式 + Mermaid 图表 + 代码高亮 | diff --git a/README_EN.md b/README_EN.md index d328ac4..703647a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -17,6 +17,8 @@ **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. +In the era of **Vibe Coding**, AI-generated code is everywhere — and so are `.md` files: requirements documents, architecture designs, API specs, PRDs. You need to check them constantly, but launching VS Code or a browser every time is overkill. **md++** fills that gap — a distraction-free Markdown viewer that stays out of your way. + **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) @@ -96,7 +98,7 @@ React 18 + TS ── Render pipeline / UI / State management / Business logic | Scenario | Description | |----------|-------------| | **Quick Markdown preview** | Double-click `.md` files — no IDE required | -| **Typora alternative** | Free, open-source, lightweight Markdown reader | +| **Vibe Coding companion** | Browse AI-generated requirements, architecture docs, and PRDs on the fly | | **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 | diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 48475a7..2620107 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "fs:allow-exists", "opener:default", "opener:allow-open-url", - "opener:allow-open-path" + "opener:allow-open-path", + "core:webview:allow-print" ] } diff --git a/src-tauri/src/commands/opener.rs b/src-tauri/src/commands/opener.rs index b7a2c73..47270c1 100644 --- a/src-tauri/src/commands/opener.rs +++ b/src-tauri/src/commands/opener.rs @@ -5,6 +5,47 @@ use std::process::Command; #[cfg(target_os = "macos")] use std::io::Write; +/// 用系统默认程序打开任意文件(绕过 opener plugin 的路径 ACL) +/// 适用于打开 app data dir 下的临时导出文件等不受 opener plugin 信任的路径 +#[tauri::command] +pub async fn open_file_with_system(path: String) -> Result<(), String> { + if path.trim().is_empty() { + return Err("文件路径不能为空".to_string()); + } + let path = PathBuf::from(path); + tauri::async_runtime::spawn_blocking(move || open_file(&path)) + .await + .map_err(|e| format!("打开文件失败: {}", e))? +} + +#[cfg(target_os = "windows")] +fn open_file(path: &Path) -> Result<(), String> { + Command::new("cmd") + .args(["/C", "start", "", "/B"]) + .arg(path) + .spawn() + .map_err(|e| format!("启动关联程序失败: {}", e))?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn open_file(path: &Path) -> Result<(), String> { + Command::new("open") + .arg(path) + .spawn() + .map_err(|e| format!("启动关联程序失败: {}", e))?; + Ok(()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn open_file(path: &Path) -> Result<(), String> { + Command::new("xdg-open") + .arg(path) + .spawn() + .map_err(|e| format!("启动关联程序失败: {}", e))?; + Ok(()) +} + #[tauri::command] pub async fn open_containing_folder(path: String) -> Result<(), String> { if path.trim().is_empty() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79b8627..abcfc0b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,6 +49,7 @@ pub fn run() { commands::files::delete_custom_theme, commands::files::get_app_data_dir, commands::opener::open_containing_folder, + commands::opener::open_file_with_system, commands::opener::check_file_association, commands::opener::register_file_association, commands::recent::list_recent, diff --git a/src/features/export/export.test.ts b/src/features/export/export.test.ts index 777b826..9e35480 100644 --- a/src/features/export/export.test.ts +++ b/src/features/export/export.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { save } from '@tauri-apps/plugin-dialog'; -import { writeFile } from '../../ipc/files'; +import { writeFile, getAppDataDir } from '../../ipc/files'; +import { openFileWithSystem } from '../../ipc/opener'; import { buildStandaloneHtml, exportDocument } from './export'; vi.mock('@tauri-apps/plugin-dialog', () => ({ @@ -9,16 +10,26 @@ vi.mock('@tauri-apps/plugin-dialog', () => ({ vi.mock('../../ipc/files', () => ({ writeFile: vi.fn().mockResolvedValue(undefined), + getAppDataDir: vi.fn().mockResolvedValue('/app/data'), +})); + +vi.mock('../../ipc/opener', () => ({ + openFileWithSystem: vi.fn().mockResolvedValue(undefined), })); describe('export', () => { const saveMock = vi.mocked(save); const writeFileMock = vi.mocked(writeFile); + const openFileMock = vi.mocked(openFileWithSystem); + const getAppDataDirMock = vi.mocked(getAppDataDir); beforeEach(() => { saveMock.mockReset(); writeFileMock.mockClear(); + openFileMock.mockClear(); + getAppDataDirMock.mockClear(); saveMock.mockResolvedValue('C:\\out\\doc.html'); + getAppDataDirMock.mockResolvedValue('/app/data'); document.body.innerHTML = ''; }); @@ -40,13 +51,10 @@ describe('export', () => { expect(html).toContain('katex'); }); - it('exports pdf via main window.print + overlay, not via saveDialog or rust', async () => { + it('exports pdf by saving html to app data dir and opening in browser', async () => { const root = document.createElement('div'); root.innerHTML = '

你好

Hi

'; - // jsdom 没实现 print,给 window.print 打桩 - const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {}); - await exportDocument({ element: root, source: '# 你好', @@ -55,15 +63,21 @@ describe('export', () => { format: 'pdf', }); + // PDF export does NOT show a save dialog — writes to temp dir instead expect(saveMock).not.toHaveBeenCalled(); - expect(writeFileMock).not.toHaveBeenCalled(); - expect(printSpy).toHaveBeenCalledTimes(1); - - // 验证 overlay 被注入后又清理了 - expect(document.querySelector('.print-overlay')).toBeNull(); - expect(document.getElementById('print-overlay-style')).toBeNull(); - printSpy.mockRestore(); + // Should write standalone HTML (with print CSS + auto-print script) + expect(writeFileMock).toHaveBeenCalledWith( + '/app/data/note.html', + expect.stringContaining(''), + ); + expect(writeFileMock).toHaveBeenCalledWith( + '/app/data/note.html', + expect.stringContaining('window.print()'), + ); + + // Should open the temp HTML in the default browser (via system cmd, not opener plugin) + expect(openFileMock).toHaveBeenCalledWith('/app/data/note.html'); }); it('exports markdown as a new md file', async () => { diff --git a/src/features/export/export.ts b/src/features/export/export.ts index 243f596..b1f600c 100644 --- a/src/features/export/export.ts +++ b/src/features/export/export.ts @@ -1,5 +1,6 @@ import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { writeFile } from '../../ipc/files'; +import { openFileWithSystem } from '../../ipc/opener'; import themesCss from '../../styles/themes.css?raw'; import proseCss from '../../styles/prose.css?raw'; @@ -51,6 +52,7 @@ export function buildStandaloneHtml( ${BASE_CSS} ${css} ${getActiveThemeCss()} + ${PRINT_CSS} ${contentHtml} @@ -59,10 +61,23 @@ export function buildStandaloneHtml( export async function exportDocument(options: ExportDocumentOptions): Promise { if (options.format === 'pdf') { - // PDF 不走 saveDialog 也不走 Rust:注入隐藏 iframe + 系统打印, - // 让用户在打印对话框里选"另存为 PDF"。这样 themes.css / prose.css / - // KaTeX / Mermaid SVG 全部按预览样式输出,而不是把 DOM 抠成纯文本。 - await printDocumentToPdf(options); + // macOS WKWebView does not reliably support window.print() with @media + // print overlays — the print dialog shows the app UI rather than exported + // content. Instead, generate standalone HTML, save it as a temp file, and + // open it in the user's default browser where "Save as PDF" works natively + // on every platform. + const html = buildStandaloneHtml(options.element.innerHTML, '', options.theme); + // Inject an auto-print script so the browser's print dialog opens on load + const printHtml = html.replace( + '', + '', + ); + // Write to a temp .html file (not .pdf) so the OS opens it in a browser + const tmpPath = await getTempExportPath(options.fileName); + await writeFile(tmpPath, printHtml); + // Ask the OS to open the HTML file in the default browser (bypassing + // opener plugin ACL — the temp dir isn't in the plugin's allowlist) + await openFileWithSystem(tmpPath); return null; } @@ -77,6 +92,18 @@ export async function exportDocument(options: ExportDocumentOptions): Promise { + const { getAppDataDir } = await import('../../ipc/files'); + const dir = await getAppDataDir(); + const base = replaceExtension(fileName, 'html'); + // 替换路径分隔符避免子目录 + const safe = base.replace(/[/\\]/g, '_'); + // 路径拼接:在 Linux 沙盒中 app_data_dir 返回的路径已规范化 + const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/'; + return `${dir}${sep}${safe}`; +} + export async function exportPdf(el: HTMLElement, theme: 'light' | 'dark', fileName = 'document.md'): Promise { return exportDocument({ element: el, @@ -87,81 +114,6 @@ export async function exportPdf(el: HTMLElement, theme: 'light' | 'dark', fileNa }); } -/** - * 用主窗口 window.print() + @media print CSS 触发系统打印。 - * - * macOS 的 WKWebView 不支持 iframe.contentWindow.print()(静默失败), - * 因此改为在主文档中注入一个 .print-overlay 容器,用 @media print CSS - * 隐藏所有 UI 元素(TitleBar / Sidebar / SearchBar),只保留要打印的内容。 - * - * 流程: - * 1. 注入