diff --git a/.gitignore b/.gitignore
index 532e7a2..727dea3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ dist/
coverage/
.env
.env.local
+.conda_env
.local/
*.log
.DS_Store
diff --git a/README.md b/README.md
index 9710826..e86c46d 100644
--- a/README.md
+++ b/README.md
@@ -4,27 +4,24 @@
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
-当前版本:`v0.5.0`。
+当前版本:`v0.5.1`。
-## v0.5.0 主要能力
+## v0.5.1 主要能力
-- 新增“连续”预览模式:不分页、低测量开销,适合编辑时快速刷新;快速
- 预览继续提供分页近似,精确预览和导出 PDF 保持权威分页;
-- 分页引擎从 Web 应用抽取为 `@md-to-pdf/preview-engine`,Web、
- Playwright PDF 与 Electron PDF 直接复用同一运行时;
-- 快速预览支持按修改位置复用稳定前缀,只重新分页受影响的后缀;
-- Web 链接只处理当前文档锚点和 HTTP/HTTPS 外链;外链在新浏览器窗口
- 打开,本地路径及不支持协议不执行导航;
-- Desktop 支持 HTTP/HTTPS、系统协议、本地相对或绝对路径和 `file:`
- URI;Markdown 链接可选择在当前窗口或新窗口打开;
-- Desktop 支持多窗口和同一文件单例。窗口聚焦时检查磁盘文件变化,并
- 提示是否重新加载;
-- 四个内置主题统一命名为 Typora Github、Typora Pixyll、
- Typora whitey 和 Typora Clean,默认使用 Typora Github;
-- 围栏代码块输出 Typora 兼容的 `.md-fences` DOM,并避免内部
- `` 重复套用行内代码样式;
-- 桌面发行链会在每次打包前完整重建内嵌 Web 依赖,避免安装包携带陈旧
- 前端资源。
+- 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
+ 以及 3 套正式文档主题和 3 套标书主题;
+- 正式主题可声明推荐页边距、页眉页脚、页码和页面装饰;未声明时继续使用
+ 16mm 默认页边距,选择正式主题时自动采用其规范参数;
+- 新增公文、项目报告和标书结构化 Front Matter,支持发文机关、文号、
+ 签发人、密级、主送、抄送、印发信息、封面及落款等文档结构;
+- 内置方正书版兼容的 Fandol 中文字体资源,统一正文、红头标题与页眉页脚
+ 字体,并修复红头标题居中;代码块支持按正文宽度自动换行、跨页分片,
+ 同时保留 `8px` 左侧内容留白;
+- 内置 ECharts 与公文主题教程,并为 14 套主题提供示例 Markdown;
+ Desktop 发行包包含全部教程和示例,打开示例时自动切换相应主题;
+- “更多”菜单按文件、教程与示例、主题管理分区;Desktop 新增
+ `Ctrl+N/W/S/Shift+S` 工作流、未保存确认及跟随新路径的“另存为”,
+ Web 端隐藏不适用的“另存为”。
## Web 与桌面端
@@ -32,7 +29,7 @@
| --- | --- | --- |
| 新建 Markdown | 支持 | 支持 |
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
-| 保存 Markdown | 浏览器下载 | 原生另存为 |
+| 保存文件 | 浏览器下载 | 原生保存与另存为 |
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
@@ -129,8 +126,8 @@ npm run make -w @md-to-pdf/desktop
```
桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,不能
-复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.0/`。
-正式安装包命名为 `md-to-pdf-0.5.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
+复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.1/`。
+正式安装包命名为 `md-to-pdf-0.5.1-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
未配置代码签名。
## 文档与媒体
@@ -149,8 +146,8 @@ ECharts 围栏语法、支持系列和安全边界详见
## 主题
-仓库内置 Typora Github、Typora Pixyll、Typora whitey 和
-Typora Clean 四套主题,默认使用 Typora Github。主题由版本化
+仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
+3 套标书主题,默认使用 Typora Github。主题由版本化
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
- 本地开发与 Compose:将主题目录放入 `.local/themes/`;
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 746d2b5..9420003 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -14,10 +14,11 @@ preload、进程内应用服务、原生 Markdown 打开与保存、同目录图
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
重新加载,避免阅读或导出磁盘旧版本。
-“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开
-自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”,
+“打开 Markdown”是顶部独立主操作;新建、保存、另存为、教程、主题示例
+和自定义主题目录统一收纳在“更多”分区菜单中。新建文档未保存时显示“未命名文档”,
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
-自动清理 Windows 非法文件名字符。新文档无需先保存即可直接导出 PDF。
+自动清理 Windows 非法文件名字符。“另存为”完成后当前窗口会追踪新文件,
+行为与常见办公软件一致。新文档无需先保存即可直接导出 PDF。
## 开发
@@ -45,8 +46,8 @@ Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web,
然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
-`apps/desktop/out/v0.5.0/`。该目录被 Git 忽略。公司内部分发以
-`md-to-pdf-0.5.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
+`apps/desktop/out/v0.5.1/`。该目录被 Git 忽略。公司内部分发以
+`md-to-pdf-0.5.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。
diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs
index e7a149b..60d9bad 100644
--- a/apps/desktop/electron-builder.config.cjs
+++ b/apps/desktop/electron-builder.config.cjs
@@ -30,6 +30,11 @@ module.exports = {
to: "themes",
filter: ["**/*"]
},
+ {
+ from: "../../samples",
+ to: "samples",
+ filter: ["**/*"]
+ },
{
from: windowsIcon,
to: "app.ico"
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 6c1baa7..9ab4235 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@md-to-pdf/desktop",
- "version": "0.5.0",
+ "version": "0.5.1",
"private": true,
"productName": "Markdown PDF 导出器",
"description": "Markdown PDF 导出器桌面端",
diff --git a/apps/desktop/src/app-preload.ts b/apps/desktop/src/app-preload.ts
index 9c0aa55..18163f2 100644
--- a/apps/desktop/src/app-preload.ts
+++ b/apps/desktop/src/app-preload.ts
@@ -3,6 +3,7 @@ import type { PagedDocumentPayload } from "@md-to-pdf/core";
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
+ DESKTOP_CREATE_NEW_WINDOW,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
@@ -13,9 +14,12 @@ import {
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
+ DESKTOP_RESOLVE_WINDOW_CLOSE,
DESKTOP_SAVE_MARKDOWN,
+ DESKTOP_SAVE_MARKDOWN_AS,
DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN,
+ DESKTOP_WINDOW_CLOSE_REQUESTED,
DESKTOP_SAVE_PDF
} from "./channels.js";
@@ -38,12 +42,30 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
},
startNewMarkdown: () =>
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
+ createNewWindow: () =>
+ ipcRenderer.invoke(DESKTOP_CREATE_NEW_WINDOW),
+ resolveWindowClose: (close: boolean) =>
+ ipcRenderer.invoke(DESKTOP_RESOLVE_WINDOW_CLOSE, close),
+ onWindowCloseRequested: (listener: () => void) => {
+ const handleCloseRequested = () => listener();
+ ipcRenderer.on(
+ DESKTOP_WINDOW_CLOSE_REQUESTED,
+ handleCloseRequested
+ );
+ return () =>
+ ipcRenderer.removeListener(
+ DESKTOP_WINDOW_CLOSE_REQUESTED,
+ handleCloseRequested
+ );
+ },
setDocumentDirty: (dirty: boolean) =>
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
openDocumentLink: (href: string) =>
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
saveMarkdown: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
+ saveMarkdownAs: (fileName: string, markdown: string) =>
+ ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN_AS, fileName, markdown),
openThemeDirectory: () =>
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
diff --git a/apps/desktop/src/channels.ts b/apps/desktop/src/channels.ts
index 66abe76..4063aa0 100644
--- a/apps/desktop/src/channels.ts
+++ b/apps/desktop/src/channels.ts
@@ -14,8 +14,16 @@ export const DESKTOP_OPEN_DOCUMENT_LINK =
"md-to-pdf:desktop:open-document-link";
export const DESKTOP_START_NEW_MARKDOWN =
"md-to-pdf:desktop:start-new-markdown";
+export const DESKTOP_CREATE_NEW_WINDOW =
+ "md-to-pdf:desktop:create-new-window";
+export const DESKTOP_WINDOW_CLOSE_REQUESTED =
+ "md-to-pdf:desktop:window-close-requested";
+export const DESKTOP_RESOLVE_WINDOW_CLOSE =
+ "md-to-pdf:desktop:resolve-window-close";
export const DESKTOP_SAVE_MARKDOWN =
"md-to-pdf:desktop:save-markdown";
+export const DESKTOP_SAVE_MARKDOWN_AS =
+ "md-to-pdf:desktop:save-markdown-as";
export const DESKTOP_OPEN_THEME_DIRECTORY =
"md-to-pdf:desktop:open-theme-directory";
export const DESKTOP_REFRESH_THEMES =
diff --git a/apps/desktop/src/desktop-application-controller.ts b/apps/desktop/src/desktop-application-controller.ts
index f2091f7..33bd2f9 100644
--- a/apps/desktop/src/desktop-application-controller.ts
+++ b/apps/desktop/src/desktop-application-controller.ts
@@ -21,6 +21,7 @@ import {
} from "@md-to-pdf/application";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
+ DESKTOP_CREATE_NEW_WINDOW,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GENERATE_PDF,
DESKTOP_GET_THEME_CSS,
@@ -31,10 +32,13 @@ import {
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
+ DESKTOP_RESOLVE_WINDOW_CLOSE,
DESKTOP_SAVE_MARKDOWN,
+ DESKTOP_SAVE_MARKDOWN_AS,
DESKTOP_SAVE_PDF,
DESKTOP_SET_DOCUMENT_DIRTY,
- DESKTOP_START_NEW_MARKDOWN
+ DESKTOP_START_NEW_MARKDOWN,
+ DESKTOP_WINDOW_CLOSE_REQUESTED
} from "./channels.js";
import {
parseMarkdownRenderRequest,
@@ -55,6 +59,7 @@ import {
saveWindowState,
type WindowState
} from "./window-state.js";
+import { getWindowCloseDecision } from "./window-close-policy.js";
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
@@ -72,6 +77,7 @@ interface WindowSession {
window: BrowserWindow;
dirty: boolean;
allowClose: boolean;
+ closeRequestPending: boolean;
currentFilePath: string | undefined;
documentKey: string | undefined;
documentRoot: string | undefined;
@@ -159,7 +165,10 @@ export class DesktopApplicationController {
return this.#createWindow(pendingSnapshot);
}
- async #createWindow(pendingSnapshot?: MarkdownFileSnapshot) {
+ async #createWindow(
+ pendingSnapshot?: MarkdownFileSnapshot,
+ startBlank = false
+ ) {
const isPrimary = !this.#primarySession;
const restoredWindowState = isPrimary
? this.#latestWindowState ?? this.#savedWindowState
@@ -190,6 +199,7 @@ export class DesktopApplicationController {
window,
dirty: false,
allowClose: false,
+ closeRequestPending: false,
currentFilePath: undefined,
documentKey: undefined,
documentRoot: undefined,
@@ -219,7 +229,11 @@ export class DesktopApplicationController {
}
window.show();
});
- await window.loadURL(this.#applicationUrl);
+ const targetUrl = new URL(this.#applicationUrl);
+ if (startBlank) {
+ targetUrl.searchParams.set("new", "1");
+ }
+ await window.loadURL(targetUrl.href);
return window;
}
@@ -325,11 +339,17 @@ export class DesktopApplicationController {
void this.#checkForExternalChange(session);
});
window.on("close", (event) => {
- if (!session.dirty || session.allowClose) {
+ const decision = getWindowCloseDecision(session);
+ if (decision === "allow") {
return;
}
event.preventDefault();
- void this.#confirmDirtyWindowClose(session);
+ if (decision === "request") {
+ session.closeRequestPending = true;
+ session.window.webContents.send(
+ DESKTOP_WINDOW_CLOSE_REQUESTED
+ );
+ }
});
window.on("closed", () => {
const wasPrimary = this.#primarySession === session;
@@ -358,28 +378,6 @@ export class DesktopApplicationController {
this.#captureWindowState();
}
- async #confirmDirtyWindowClose(session: WindowSession) {
- if (session.window.isDestroyed()) {
- return;
- }
- const result = await dialog.showMessageBox(session.window, {
- type: "warning",
- title: "关闭未保存文档",
- message: "当前文档有未保存修改,确定要关闭窗口吗?",
- detail: "关闭后,尚未保存的修改将会丢失。",
- buttons: ["取消", "仍然关闭"],
- defaultId: 0,
- cancelId: 0,
- noLink: true
- });
- session.suppressFocusCheckUntil =
- Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
- if (result.response === 1 && !session.window.isDestroyed()) {
- session.allowClose = true;
- session.window.close();
- }
- }
-
async #checkForExternalChange(session: WindowSession) {
if (
session.focusCheckRunning ||
@@ -630,6 +628,28 @@ export class DesktopApplicationController {
this.#clearDocument(this.#getSession(event));
});
+ ipcMain.handle(DESKTOP_CREATE_NEW_WINDOW, async (event) => {
+ this.#getSession(event);
+ await this.#createWindow(undefined, true);
+ });
+
+ ipcMain.handle(
+ DESKTOP_RESOLVE_WINDOW_CLOSE,
+ async (event, unsafeClose: unknown) => {
+ if (typeof unsafeClose !== "boolean") {
+ throw new Error("窗口关闭决策无效");
+ }
+ const session = this.#getSession(event);
+ session.closeRequestPending = false;
+ session.suppressFocusCheckUntil =
+ Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
+ if (unsafeClose && !session.window.isDestroyed()) {
+ session.allowClose = true;
+ session.window.close();
+ }
+ }
+ );
+
ipcMain.handle(
DESKTOP_SET_DOCUMENT_DIRTY,
async (event, unsafeDirty: unknown) => {
@@ -640,13 +660,12 @@ export class DesktopApplicationController {
}
);
- ipcMain.handle(
- DESKTOP_SAVE_MARKDOWN,
- async (
- event,
- unsafeFileName: unknown,
- unsafeMarkdown: unknown
- ) => {
+ const saveMarkdown = async (
+ event: IpcMainInvokeEvent,
+ unsafeFileName: unknown,
+ unsafeMarkdown: unknown,
+ forceSaveAs: boolean
+ ) => {
const session = this.#getSession(event);
if (
typeof unsafeFileName !== "string" ||
@@ -659,18 +678,20 @@ export class DesktopApplicationController {
}
let targetPath = session.currentFilePath;
- if (!targetPath) {
+ if (forceSaveAs || !targetPath) {
const suggestedName = /\.(?:md|markdown)$/iu.test(
path.basename(unsafeFileName)
)
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.md`;
const selection = await dialog.showSaveDialog(session.window, {
- title: "保存 Markdown",
- defaultPath: path.join(
- app.getPath("documents"),
- suggestedName
- ),
+ title: forceSaveAs
+ ? "另存为 Markdown"
+ : "保存 Markdown",
+ defaultPath:
+ forceSaveAs && session.currentFilePath
+ ? session.currentFilePath
+ : path.join(app.getPath("documents"), suggestedName),
filters: [
{
name: "Markdown 文件",
@@ -724,7 +745,27 @@ export class DesktopApplicationController {
}
this.#commitSnapshot(session, snapshot);
return { fileName: snapshot.document.fileName };
- }
+ };
+
+ ipcMain.handle(
+ DESKTOP_SAVE_MARKDOWN,
+ (event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
+ saveMarkdown(
+ event,
+ unsafeFileName,
+ unsafeMarkdown,
+ false
+ )
+ );
+ ipcMain.handle(
+ DESKTOP_SAVE_MARKDOWN_AS,
+ (event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
+ saveMarkdown(
+ event,
+ unsafeFileName,
+ unsafeMarkdown,
+ true
+ )
);
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
diff --git a/apps/desktop/src/window-close-policy.ts b/apps/desktop/src/window-close-policy.ts
new file mode 100644
index 0000000..40ff6e2
--- /dev/null
+++ b/apps/desktop/src/window-close-policy.ts
@@ -0,0 +1,16 @@
+export interface WindowCloseState {
+ dirty: boolean;
+ allowClose: boolean;
+ closeRequestPending: boolean;
+}
+
+export type WindowCloseDecision = "allow" | "request" | "wait";
+
+export function getWindowCloseDecision(
+ state: WindowCloseState
+): WindowCloseDecision {
+ if (!state.dirty || state.allowClose) {
+ return "allow";
+ }
+ return state.closeRequestPending ? "wait" : "request";
+}
diff --git a/apps/desktop/tests/release-build.test.ts b/apps/desktop/tests/release-build.test.ts
index 93e8f06..fb06865 100644
--- a/apps/desktop/tests/release-build.test.ts
+++ b/apps/desktop/tests/release-build.test.ts
@@ -47,4 +47,33 @@ describe("桌面发行构建链", () => {
"npm run package -w @md-to-pdf/desktop"
);
});
+
+ it("将内置主题示例作为独立资源复制到发行目录", () => {
+ const builderConfig = readFileSync(
+ `${desktopRoot}/electron-builder.config.cjs`,
+ "utf8"
+ );
+
+ expect(builderConfig).toContain('from: "../../samples"');
+ expect(builderConfig).toContain('to: "samples"');
+ });
+
+ it("在 Docker Web 构建前复制样例并保留到运行镜像", () => {
+ const dockerfile = readFileSync(
+ `${projectRoot}/deploy/Dockerfile`,
+ "utf8"
+ );
+ const samplesBuildCopyIndex = dockerfile.indexOf(
+ "COPY samples ./samples"
+ );
+ const webBuildIndex = dockerfile.indexOf(
+ "npm run build -w @md-to-pdf/web"
+ );
+
+ expect(samplesBuildCopyIndex).toBeGreaterThan(-1);
+ expect(webBuildIndex).toBeGreaterThan(samplesBuildCopyIndex);
+ expect(dockerfile).toContain(
+ "COPY --chown=pwuser:pwuser --from=build /app/samples ./samples"
+ );
+ });
});
diff --git a/apps/desktop/tests/window-close-policy.test.ts b/apps/desktop/tests/window-close-policy.test.ts
new file mode 100644
index 0000000..8332c92
--- /dev/null
+++ b/apps/desktop/tests/window-close-policy.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { getWindowCloseDecision } from "../src/window-close-policy.js";
+
+describe("桌面窗口关闭策略", () => {
+ it("干净文档或已授权窗口可以直接关闭", () => {
+ expect(
+ getWindowCloseDecision({
+ dirty: false,
+ allowClose: false,
+ closeRequestPending: false
+ })
+ ).toBe("allow");
+ expect(
+ getWindowCloseDecision({
+ dirty: true,
+ allowClose: true,
+ closeRequestPending: false
+ })
+ ).toBe("allow");
+ });
+
+ it("未保存文档只发出一次关闭确认请求", () => {
+ expect(
+ getWindowCloseDecision({
+ dirty: true,
+ allowClose: false,
+ closeRequestPending: false
+ })
+ ).toBe("request");
+ expect(
+ getWindowCloseDecision({
+ dirty: true,
+ allowClose: false,
+ closeRequestPending: true
+ })
+ ).toBe("wait");
+ });
+});
diff --git a/apps/web/index.html b/apps/web/index.html
index 3992f2a..4d2b3e4 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -12,7 +12,7 @@
{
const module = await import("./PrecisePdfPreview");
@@ -67,18 +79,48 @@ const PrecisePdfPreview = lazy(async () => {
type PreviewMode = "quick" | "precise" | "continuous";
type DocumentKind = "sample" | "new" | "file";
+type PendingDocumentAction =
+ | {
+ type: "new-current";
+ }
+ | {
+ type: "close";
+ systemRequest: boolean;
+ };
+type DocumentDialogState =
+ | {
+ type: "new-location";
+ }
+ | {
+ type: "unsaved";
+ action: PendingDocumentAction;
+ };
const noImageResources: never[] = [];
+const startsWithBlankMarkdown =
+ Boolean(window.mdToPdfDesktop) &&
+ new URLSearchParams(window.location.search).get("new") === "1";
+const initialMarkdown = startsWithBlankMarkdown ? "" : sampleMarkdown;
+const initialFileName = startsWithBlankMarkdown ? "" : "示例文档.md";
+const initialDocumentKind: DocumentKind = startsWithBlankMarkdown
+ ? "new"
+ : "sample";
export function App() {
- const [markdown, setMarkdown] = useState(sampleMarkdown);
- const [fileName, setFileName] = useState("示例文档.md");
+ const [markdown, setMarkdown] = useState(initialMarkdown);
+ const [fileName, setFileName] = useState(initialFileName);
const [documentKind, setDocumentKind] =
- useState("sample");
- const [savedMarkdown, setSavedMarkdown] = useState(sampleMarkdown);
+ useState(initialDocumentKind);
+ const [savedMarkdown, setSavedMarkdown] = useState(initialMarkdown);
const [exportConfig, setExportConfig] =
useState(loadExportConfig);
const [settingsOpen, setSettingsOpen] = useState(false);
+ const [documentDialog, setDocumentDialog] =
+ useState();
+ const [documentActionBusy, setDocumentActionBusy] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
+ const [moreSubmenuOpen, setMoreSubmenuOpen] = useState<
+ "tutorials" | "samples" | undefined
+ >();
const [themeRefreshKey, setThemeRefreshKey] = useState(0);
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
const [status, setStatus] = useState("正在准备预览…");
@@ -186,21 +228,60 @@ export function App() {
);
}, [documentDirty]);
+ useEffect(() => {
+ const bridge = window.mdToPdfDesktop;
+ if (!bridge) {
+ return;
+ }
+ return bridge.onWindowCloseRequested(() => {
+ requestDocumentClose(true);
+ });
+ }, [documentDialog, documentDirty]);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const shortcut = getDocumentShortcut(
+ event,
+ Boolean(window.mdToPdfDesktop)
+ );
+ if (!shortcut) {
+ return;
+ }
+ event.preventDefault();
+ if (documentDialog) {
+ return;
+ }
+ if (shortcut === "save") {
+ void handleSaveMarkdown();
+ } else if (shortcut === "save-as") {
+ void handleSaveMarkdownAs();
+ } else if (shortcut === "new") {
+ requestNewMarkdown();
+ } else {
+ requestDocumentClose(false);
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [documentDialog, documentDirty, fileName, markdown]);
+
useEffect(() => {
if (!moreMenuOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
- if (
- event.target instanceof Node &&
- !moreMenuRef.current?.contains(event.target)
- ) {
+ if (!(event.target instanceof Node)) {
+ return;
+ }
+ if (!moreMenuRef.current?.contains(event.target)) {
setMoreMenuOpen(false);
+ setMoreSubmenuOpen(undefined);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMoreMenuOpen(false);
+ setMoreSubmenuOpen(undefined);
}
};
window.addEventListener("pointerdown", handlePointerDown);
@@ -214,19 +295,18 @@ export function App() {
const handleThemesLoaded = useCallback(
(availableThemes: ThemeSummary[], loadedRefreshKey: number) => {
setExportConfig((currentConfig) => {
- if (
- availableThemes.some(
- (theme) => theme.id === currentConfig.themeId
- )
- ) {
- return currentConfig;
+ const currentTheme = availableThemes.find(
+ (theme) => theme.id === currentConfig.themeId
+ );
+ if (currentTheme) {
+ return applyThemeDefaults(currentConfig, currentTheme);
}
const preferredTheme =
availableThemes.find((theme) => theme.id === "typora-github") ??
availableThemes.find((theme) => theme.id === "typora-like") ??
availableThemes[0];
return preferredTheme
- ? { ...currentConfig, themeId: preferredTheme.id }
+ ? selectTheme(currentConfig, preferredTheme)
: currentConfig;
});
if (themeRefreshTargetRef.current === loadedRefreshKey) {
@@ -268,6 +348,17 @@ export function App() {
pdfError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId);
+ const themeGroups = useMemo(
+ () => groupThemesByCategory(themes),
+ [themes]
+ );
+ const sampleThemeGroups = useMemo(
+ () =>
+ groupThemesByCategory(
+ themes.filter((theme) => getBuiltinThemeSample(theme.id))
+ ),
+ [themes]
+ );
const effectiveFileName =
documentKind === "new" || !fileName
? createMarkdownFileName(markdown)
@@ -806,11 +897,29 @@ export function App() {
}
}
- async function handleNewMarkdown() {
+ function requestNewMarkdown() {
setMoreMenuOpen(false);
- if (!confirmDiscardChanges()) {
+ setMoreSubmenuOpen(undefined);
+ if (window.mdToPdfDesktop) {
+ setDocumentDialog({ type: "new-location" });
return;
}
+ requestCurrentWindowNew();
+ }
+
+ function requestCurrentWindowNew() {
+ if (documentDirty) {
+ setDocumentDialog({
+ type: "unsaved",
+ action: { type: "new-current" }
+ });
+ return;
+ }
+ setDocumentDialog(undefined);
+ void createMarkdownInCurrentWindow();
+ }
+
+ async function createMarkdownInCurrentWindow() {
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
@@ -827,18 +936,193 @@ export function App() {
}
}
- async function handleSaveMarkdown() {
+ async function createMarkdownInNewWindow() {
+ setDocumentDialog(undefined);
+ try {
+ setAppError("");
+ await window.mdToPdfDesktop?.createNewWindow();
+ setStatus("已在新窗口创建空白 Markdown");
+ } catch (reason) {
+ setAppError(
+ reason instanceof Error
+ ? reason.message
+ : "无法创建新的 Markdown 窗口"
+ );
+ }
+ }
+
+ function requestDocumentClose(systemRequest: boolean) {
+ const bridge = window.mdToPdfDesktop;
+ if (!bridge) {
+ return;
+ }
+ if (documentDialog) {
+ if (systemRequest) {
+ void bridge.resolveWindowClose(false);
+ }
+ return;
+ }
+ if (documentDirty) {
+ setDocumentDialog({
+ type: "unsaved",
+ action: {
+ type: "close",
+ systemRequest
+ }
+ });
+ return;
+ }
+ void bridge.resolveWindowClose(true);
+ }
+
+ async function completePendingDocumentAction(
+ action: PendingDocumentAction
+ ) {
+ setDocumentDialog(undefined);
+ if (action.type === "new-current") {
+ await createMarkdownInCurrentWindow();
+ return;
+ }
+ await window.mdToPdfDesktop?.resolveWindowClose(true);
+ }
+
+ function cancelDocumentDialog() {
+ const currentDialog = documentDialog;
+ setDocumentDialog(undefined);
+ if (
+ currentDialog?.type === "unsaved" &&
+ currentDialog.action.type === "close" &&
+ currentDialog.action.systemRequest
+ ) {
+ void window.mdToPdfDesktop?.resolveWindowClose(false);
+ }
+ }
+
+ function discardPendingDocumentChanges() {
+ if (documentDialog?.type !== "unsaved") {
+ return;
+ }
+ void completePendingDocumentAction(documentDialog.action);
+ }
+
+ async function saveBeforePendingDocumentAction() {
+ if (documentDialog?.type !== "unsaved" || documentActionBusy) {
+ return;
+ }
+ const action = documentDialog.action;
+ setDocumentActionBusy(true);
+ try {
+ if (await saveMarkdown()) {
+ await completePendingDocumentAction(action);
+ }
+ } finally {
+ setDocumentActionBusy(false);
+ }
+ }
+
+ async function handleOpenThemeSample(theme: ThemeSummary) {
+ const sample = getBuiltinThemeSample(theme.id);
+ if (!sample) {
+ setAppError(`找不到“${theme.name}”的内置示例`);
+ return;
+ }
setMoreMenuOpen(false);
+ setMoreSubmenuOpen(undefined);
+ if (!confirmDiscardChanges()) {
+ return;
+ }
+ try {
+ setAppError("");
+ await window.mdToPdfDesktop?.startNewMarkdown();
+ setMarkdown(sample.markdown);
+ setFileName(sample.fileName);
+ setDocumentKind("sample");
+ setSavedMarkdown(sample.markdown);
+ setExportConfig((currentConfig) =>
+ selectTheme(
+ {
+ ...currentConfig,
+ paper: {
+ ...currentConfig.paper,
+ marginMode: "theme"
+ },
+ pageDecorationsMode: "theme"
+ },
+ theme
+ )
+ );
+ setStatus(
+ `已打开“${theme.name}”主题示例,可编辑后另存为 Markdown`
+ );
+ window.requestAnimationFrame(() => editorRef.current?.focus());
+ } catch (reason) {
+ setAppError(
+ reason instanceof Error ? reason.message : "无法打开主题示例"
+ );
+ }
+ }
+
+ async function handleOpenTutorial(tutorial: BuiltinTutorial) {
+ const theme = themes.find(
+ (availableTheme) => availableTheme.id === tutorial.themeId
+ );
+ if (!theme) {
+ setAppError(`找不到教程需要的主题:${tutorial.themeId}`);
+ return;
+ }
+ setMoreMenuOpen(false);
+ setMoreSubmenuOpen(undefined);
+ if (!confirmDiscardChanges()) {
+ return;
+ }
+ try {
+ setAppError("");
+ await window.mdToPdfDesktop?.startNewMarkdown();
+ setMarkdown(tutorial.markdown);
+ setFileName(tutorial.fileName);
+ setDocumentKind("sample");
+ setSavedMarkdown(tutorial.markdown);
+ setExportConfig((currentConfig) =>
+ selectTheme(
+ {
+ ...currentConfig,
+ paper: {
+ ...currentConfig.paper,
+ marginMode: "theme"
+ },
+ pageDecorationsMode: "theme"
+ },
+ theme
+ )
+ );
+ setStatus(
+ `已打开“${tutorial.name}”,可编辑后另存为 Markdown`
+ );
+ window.requestAnimationFrame(() => editorRef.current?.focus());
+ } catch (reason) {
+ setAppError(
+ reason instanceof Error ? reason.message : "无法打开内置教程"
+ );
+ }
+ }
+
+ async function saveMarkdown(forceSaveAs = false) {
const suggestedName = ensureMarkdownFileName(fileName, markdown);
try {
setAppError("");
const bridge = window.mdToPdfDesktop;
let savedFileName = suggestedName;
if (bridge) {
- const saved = await bridge.saveMarkdown(suggestedName, markdown);
+ const saved = forceSaveAs
+ ? await bridge.saveMarkdownAs(suggestedName, markdown)
+ : await bridge.saveMarkdown(suggestedName, markdown);
if (!saved) {
- setStatus("已取消 Markdown 保存");
- return;
+ setStatus(
+ forceSaveAs
+ ? "已取消 Markdown 另存为"
+ : "已取消 Markdown 保存"
+ );
+ return false;
}
savedFileName = saved.fileName;
} else {
@@ -847,16 +1131,39 @@ export function App() {
setFileName(savedFileName);
setDocumentKind("file");
setSavedMarkdown(markdown);
- setStatus(`Markdown 已保存:${savedFileName}`);
+ setStatus(
+ forceSaveAs
+ ? `Markdown 已另存为:${savedFileName}`
+ : `Markdown 已保存:${savedFileName}`
+ );
+ return true;
} catch (reason) {
setAppError(
- reason instanceof Error ? reason.message : "Markdown 保存失败"
+ reason instanceof Error
+ ? reason.message
+ : forceSaveAs
+ ? "Markdown 另存为失败"
+ : "Markdown 保存失败"
);
+ return false;
}
}
+ async function handleSaveMarkdown() {
+ setMoreMenuOpen(false);
+ setMoreSubmenuOpen(undefined);
+ return saveMarkdown();
+ }
+
+ async function handleSaveMarkdownAs() {
+ setMoreMenuOpen(false);
+ setMoreSubmenuOpen(undefined);
+ return saveMarkdown(true);
+ }
+
async function handleOpenThemeDirectory() {
setMoreMenuOpen(false);
+ setMoreSubmenuOpen(undefined);
try {
setAppError("");
const themeDirectory =
@@ -875,6 +1182,7 @@ export function App() {
async function handleRefreshThemes() {
setMoreMenuOpen(false);
+ setMoreSubmenuOpen(undefined);
try {
setAppError("");
await window.mdToPdfDesktop?.refreshThemes();
@@ -1011,7 +1319,10 @@ export function App() {
aria-controls="document-actions-menu"
aria-expanded={moreMenuOpen}
aria-haspopup="menu"
- onClick={() => setMoreMenuOpen((open) => !open)}
+ onClick={() => {
+ setMoreMenuOpen((open) => !open);
+ setMoreSubmenuOpen(undefined);
+ }}
>
更多 ▾
@@ -1023,20 +1334,129 @@ export function App() {
>
+ {window.mdToPdfDesktop ? (
+
+ ) : null}
+
+
+ setMoreSubmenuOpen("tutorials")
+ }
+ onPointerLeave={() => setMoreSubmenuOpen(undefined)}
+ >
+
+ {moreSubmenuOpen === "tutorials" ? (
+
+ ) : null}
+
+ setMoreSubmenuOpen("samples")}
+ onPointerLeave={() => setMoreSubmenuOpen(undefined)}
+ >
+
+ {moreSubmenuOpen === "samples" ? (
+
+ ) : null}
+
{window.mdToPdfDesktop ? (
<>
+
+
+
页眉
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。
+
updateHeader({ fontFamily })}
+ />
+ updateHeader({ fontSize })}
+ />
{(["left", "center", "right"] as const).map((slot) => {
const labels = { left: "左侧", center: "中间", right: "右侧" };
return (
@@ -567,6 +758,21 @@ export function ExportSettingsDrawer({
{config.footer.enabled ? "开启" : "关闭"}
+ updateFooter({ fontFamily })}
+ />
+ updateFooter({ fontSize })}
+ />
+