Skip to content
Open
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
65 changes: 65 additions & 0 deletions pr_body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 标签页右键菜单增强 + PDF 导出样式系统

## 背景与动机
本 PR 解决两个用户反馈的问题,并顺带做了若干增强:
- **Issue 1**:标签页右键菜单只有「打开文件所在文件夹」,缺少关闭 / 刷新 / 关闭其他 / 关闭全部等常用操作。
- **Issue 2**:PDF 导出无法自定义字体与颜色。参考 md-to.com 的样式能力,新增独立设置页、24 套预设模板、紧凑 / 宽松间距与导出前实时预览。

## 一、标签页右键菜单增强
`TabBar.vue` 新增右键菜单项:
- 关闭 / 关闭其他(仅剩 1 个标签时禁用)/ 关闭全部(无标签时禁用)/ 刷新
- 复制路径(写入剪贴板,带成功 / 失败提示)
- 打开文件所在文件夹
`App.vue` 接入对应事件处理,新增 `copyPath()`(`navigator.clipboard` + `execCommand` 兜底)。

## 二、PDF 导出样式系统
### 独立设置标签页
设置对话框新增「PDF 导出」独立标签页(`settingsTab` 切换),与阅读设置分离,更清晰。

### 24 套预设模板
分为三组:
- **文档(doc)**:默认 / 极简 / 学术 / 护眼 / 商务蓝 / 经典棕 / GitHub
- **开发(dev)**:暗色 / 终端绿 / Dracula / VS Code 蓝 / Nord / Solarized 亮 / Solarized 暗 / Gruvbox 亮 / Gruvbox 暗 / Monokai / Catppuccin / One Dark
- **创意(creative)**:樱花粉 / 薰衣草 / 海洋青 / 日落橙 / 森林绿

### 间距变体(紧凑 / 标准 / 宽松)
对应 md-to.com 的 Compact / Standard / Loose。通过 `PdfDensity` + `PDF_DENSITY_SPACING`(lineHeight / blockGap)驱动,导出的 HTML 用 `calc(var(--pdf-block-gap,1em)*N)` 实现段落间距。

### 分组下拉
模板下拉按「文档 / 开发 / 创意」用 `<optgroup>` 分组,便于浏览。

### 实时预览
新增 `usePdfPreview.ts`,复用真实导出管线(`renderMarkdown` + `buildExportHtml`)在隔离 `<iframe :srcdoc>` 中渲染:
- 可编辑示例文本(textarea),输入即更新(150ms 防抖)。
- 明暗对比预览开关:同一套字体 / 排版,强制 GitHub 中性明暗配色并排显示。

### 字体 / 颜色 / 页面自定义
正文 / 标题 / 代码字体、正文字号、行高、正文色 / 标题色 / 链接色 / 代码背景 / 页面背景、页边距、页面尺寸(A4 / Letter)、方向(纵向 / 横向)均可调;通过 `--pdf-*` CSS 变量注入导出 HTML。

## 三、其他改进
- 设置页「恢复默认」修正了之前误嵌套在快捷键栅格中的 bug。
- Rust 侧 `pdf_export.rs` 移除 `cmd.arg("--no-margins")`,使注入的 `@page { margin }` 生效(需重新 `tauri build` 才能生效)。

## 四、国际化
`zh-CN.ts` / `en-US.ts` 新增:标签菜单(复制路径等)、PDF 样式(间距 / 紧凑 / 标准 / 宽松 / 预览 / 示例文本 / 对比 / 明 / 暗 / 分组标题)及 8 套开发模板名称,中英文一一对应。

## 五、文件清单
- src/components/TabBar.vue
- src/components/SettingsDialog.vue
- src/App.vue
- src/composables/usePdfStyle.ts
- src/composables/useExport.ts
- src/composables/exportStyles.ts
- src/composables/usePdfPreview.ts(新增)
- src-tauri/src/pdf_export.rs
- src/i18n/zh-CN.ts
- src/i18n/en-US.ts

## 六、验证
- `eslint .` 无错误
- `vue-tsc --noEmit` 类型通过
- `vite build` 构建通过

## 七、注意事项
- `pdf_export.rs` 的改动属于 Rust 侧,需重新编译(`tauri build` / `tauri dev`)后导出 PDF 才会应用新的页边距逻辑。
- 关于「双击关联 .md 文件时把最小化窗口置前」的需求,本 PR 暂未包含(需在 Rust 单实例 / DeepLink 处理中调用窗口 focus / unminimize),如需可另开 PR。
13 changes: 12 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,11 +464,22 @@ fn initial_open_file() -> Option<String> {
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
// Already-running instance: focus window and emit the new file path.
// Already-running instance: bring the window to the front (even when it is
// minimized) and emit the new file path so the frontend opens it in a tab.
use tauri::Emitter;
if let Some(window) = app.get_webview_window("main") {
// Restore from the minimized state first.
let _ = window.unminimize();
// Make sure it is actually visible.
let _ = window.show();
// Briefly raise to top-most and then drop it back, so Windows'
// foreground-lock cannot keep the window hidden behind others.
#[cfg(target_os = "windows")]
let _ = window.set_always_on_top(true);
// Move the window into the foreground.
let _ = window.set_focus();
#[cfg(target_os = "windows")]
let _ = window.set_always_on_top(false);
}
if let Some(path) = extract_md_path_from_args(&argv) {
let _ = app.emit("md-reader://open-file", path);
Expand Down
1 change: 0 additions & 1 deletion src-tauri/src/pdf_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ fn run_edge_print(
cmd.arg("--no-first-run");
cmd.arg("--no-default-browser-check");
cmd.arg("--no-pdf-header-footer");
cmd.arg("--no-margins");
cmd.arg("--run-all-compositor-stages-before-draw");
cmd.arg(format!(
"--virtual-time-budget={}",
Expand Down
67 changes: 67 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,27 @@ async function openInExplorer(path: string) {
errorMsg.value = e?.message ?? String(e);
}
}

async function copyPath(path: string) {
if (!path) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(path);
} else {
const ta = document.createElement("textarea");
ta.value = path;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
exportToast.value = t("tabs.copiedPath");
} catch {
exportToast.value = t("tabs.copyFailed");
}
}
const dialogFileName = computed(() =>
dialogTab.value ? basename(dialogTab.value.path) : ""
);
Expand Down Expand Up @@ -412,6 +433,48 @@ function onDialogCancel() {
resolveDialog("cancel");
}

async function closeOthers(id: string) {
const keep = tabs.value.find((x) => x.id === id);
if (!keep || tabs.value.length <= 1) return;
const others = tabs.value.filter((x) => x.id !== id);
for (const tab of others) {
if (tab.isDirty) {
if (tab.id !== activeTabId.value) switchToTab(tab.id);
const choice = await askUnsaved(tab, "unsaved");
if (choice === "cancel") return;
if (choice === "save") {
const ok = await saveTab(tab);
if (!ok) return;
}
}
}
for (const tab of others) removeTab(tab.id);
activateTab(keep.id);
}

async function closeAll() {
const ok = await confirmCloseAll();
if (!ok) return;
for (const tab of [...tabs.value]) removeTab(tab.id);
}

async function refreshTab(id: string) {
const tab = tabs.value.find((x) => x.id === id);
if (!tab) return;
if (tab.isDirty) {
if (id !== activeTabId.value) switchToTab(id);
const choice = await askUnsaved(tab, "external");
if (choice === "cancel") return;
if (choice === "save") {
// "重新加载" — discard unsaved edits and reload from disk.
await forceReloadTab(tab);
}
// "保留编辑" — keep current edits, do not reload.
return;
}
await forceReloadTab(tab);
}

function getPreviewTopSourceLine(): number {
const container = viewerEl.value;
const body = bodyRef.value;
Expand Down Expand Up @@ -1338,7 +1401,11 @@ watch(
:active-tab-id="activeTabId"
@activate="switchToTab"
@close="closeTab"
@close-others="closeOthers"
@close-all="closeAll"
@refresh="refreshTab"
@reveal-file="openInExplorer"
@copy-path="copyPath"
/>

<main class="layout">
Expand Down
Loading