Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 工具一样监听文件的外部变化、自动计算差异并可视化展示变更内容。

Expand Down Expand Up @@ -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 图表 + 代码高亮 |
Expand Down
4 changes: 3 additions & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
41 changes: 41 additions & 0 deletions src-tauri/src/commands/opener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 26 additions & 12 deletions src/features/export/export.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -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 = '';
});

Expand All @@ -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 = '<h1>你好</h1><p>Hi</p>';

// jsdom 没实现 print,给 window.print 打桩
const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {});

await exportDocument({
element: root,
source: '# 你好',
Expand All @@ -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('<!DOCTYPE html>'),
);
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 () => {
Expand Down
110 changes: 31 additions & 79 deletions src/features/export/export.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -51,6 +52,7 @@ export function buildStandaloneHtml(
${BASE_CSS}
${css}
${getActiveThemeCss()}
${PRINT_CSS}
</style>
</head>
<body class="md-content">${contentHtml}</body>
Expand All @@ -59,10 +61,23 @@ export function buildStandaloneHtml(

export async function exportDocument(options: ExportDocumentOptions): Promise<string | null> {
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(
'</head>',
'<script>window.addEventListener("DOMContentLoaded",()=>{setTimeout(()=>window.print(),400)});</script></head>',
);
// 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;
}

Expand All @@ -77,6 +92,18 @@ export async function exportDocument(options: ExportDocumentOptions): Promise<st
return path;
}

/** 生成临时导出文件路径(在 app data dir 下,避免权限问题) */
async function getTempExportPath(fileName: string): Promise<string> {
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<string | null> {
return exportDocument({
element: el,
Expand All @@ -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. 注入 <style id="print-overlay-style"> 定义 @media print 规则
* 2. 注入 <div class="print-overlay"> 包含渲染好的独立 HTML 内容
* 3. 调用 window.print() 触发系统打印对话框
* 4. 监听 afterprint 事件清理注入的元素
*/
async function printDocumentToPdf(options: ExportDocumentOptions): Promise<void> {
const contentHtml = buildStandaloneHtml(options.element.innerHTML, PRINT_CSS, options.theme);

// 提取 <body> 内的 HTML(跳过外层 html/head/body 标签)
const bodyMatch = contentHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
const innerHtml = bodyMatch ? bodyMatch[1] : options.element.innerHTML;

// 提取 <style> 块
const styleMatches = contentHtml.match(/<style>([\s\S]*?)<\/style>/gi) || [];
const combinedStyle = styleMatches
.map((s) => s.replace(/<\/?style>/gi, ''))
.join('\n');

// 注入 @media print 样式:隐藏一切,只显示 .print-overlay
const printStyle = document.createElement('style');
printStyle.id = 'print-overlay-style';
printStyle.textContent = `
@media print {
body > *:not(.print-overlay) { display: none !important; }
.print-overlay {
position: static !important;
width: auto !important;
height: auto !important;
overflow: visible !important;
background: #ffffff !important;
}
}
${combinedStyle}
`;
document.head.appendChild(printStyle);

// 注入打印内容容器
const overlay = document.createElement('div');
overlay.className = 'print-overlay';
overlay.style.cssText =
'position:fixed;inset:0;z-index:99999;background:#fff;overflow:auto;padding:40px 32px;';
overlay.innerHTML = `<div class="md-content" style="max-width:860px;margin:0 auto;">${innerHtml}</div>`;
document.body.appendChild(overlay);

try {
await new Promise<void>((resolve) => {
const cleanup = () => {
window.removeEventListener('afterprint', cleanup);
overlay.remove();
printStyle.remove();
resolve();
};
window.addEventListener('afterprint', cleanup);

// 等下一帧让浏览器把 KaTeX / shiki / mermaid SVG 都布好版
requestAnimationFrame(() => {
window.print();
// 兜底:如果 afterprint 未触发(某些 WebView),3 秒后清理
setTimeout(cleanup, 3000);
});
});
} catch {
// 异常时也要清理
overlay.remove();
printStyle.remove();
}
}

function buildExportContent(options: ExportDocumentOptions): string {
if (options.format === 'markdown') return options.source;
Expand Down
5 changes: 5 additions & 0 deletions src/ipc/opener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export async function openLocalPath(path: string): Promise<void> {
await openPathPlugin(path);
}

/** 用系统默认应用打开文件(绕过 opener plugin 路径 ACL,适合临时导出文件) */
export async function openFileWithSystem(path: string): Promise<void> {
await invoke('open_file_with_system', { path });
}

/** 在系统文件管理器中显示本地文件。 */
export async function openContainingFolder(path: string): Promise<void> {
await invoke('open_containing_folder', { path });
Expand Down
Loading