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 ? ( <> +
+ {isNewLocation ? ( + <> + + + + ) : ( + <> + + + + )} +
+ +
+ ); +} diff --git a/apps/web/src/ExportSettingsDrawer.tsx b/apps/web/src/ExportSettingsDrawer.tsx index 11e9a0e..d3ff7ba 100644 --- a/apps/web/src/ExportSettingsDrawer.tsx +++ b/apps/web/src/ExportSettingsDrawer.tsx @@ -1,4 +1,5 @@ import { + defaultExportConfig, getPaperDimensionsMm, lengthToMillimeters, paperFormatLabels, @@ -12,9 +13,11 @@ import { type MermaidExportConfig } from "@md-to-pdf/core"; import { useEffect, useState } from "react"; +import type { ThemeSummary } from "./application-backend"; interface ExportSettingsDrawerProps { config: ExportConfig; + theme: ThemeSummary | undefined; onChange: (config: ExportConfig) => void; onClose: () => void; onReset: () => void; @@ -24,6 +27,9 @@ interface LengthInputProps { label: string; value: string; maximum: number; + disabled?: boolean; + precision?: number; + step?: number; onChange: (value: string) => void; } @@ -32,6 +38,7 @@ const pageNumberFormatLabels: Record = { "page-total": "1 / 10", "chinese-page-total": "第 1 页 / 共 10 页", "dash-page": "- 1 -", + "official-page": "— 1 —(标准公文)", custom: "自定义模板" }; @@ -98,6 +105,9 @@ function LengthInput({ label, value, maximum, + disabled = false, + precision = 1, + step = 0.1, onChange }: LengthInputProps) { const [draft, setDraft] = useState( @@ -115,7 +125,8 @@ function LengthInput({ return; } const normalized = Math.min(Math.max(number, 0), maximum); - const rounded = Math.round(normalized * 10) / 10; + const factor = 10 ** precision; + const rounded = Math.round(normalized * factor) / factor; setDraft(String(rounded)); onChange(`${rounded}mm`); } @@ -128,8 +139,9 @@ function LengthInput({ type="number" min="0" max={maximum} - step="0.1" + step={step} value={draft} + disabled={disabled} onBlur={commit} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { @@ -144,6 +156,57 @@ function LengthInput({ ); } +function PageDecorationFontInput({ + label, + value, + disabled, + onChange +}: { + label: string; + value: string; + disabled: boolean; + onChange: (value: string) => void; +}) { + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + function commit() { + const normalized = draft.trim(); + if ( + !normalized || + !/^[\p{L}\p{N}\s,"'-]+$/u.test(normalized) + ) { + setDraft(value); + return; + } + setDraft(normalized); + onChange(normalized); + } + + return ( + + ); +} + function MermaidFontInput({ value, onChange @@ -208,6 +271,7 @@ function updateHeaderSlot( export function ExportSettingsDrawer({ config, + theme, onChange, onClose, onReset @@ -259,6 +323,7 @@ export function ExportSettingsDrawer({ value: string ) { updatePaper({ + marginMode: "custom", margins: { ...margins, [side]: value @@ -266,9 +331,22 @@ export function ExportSettingsDrawer({ }); } + function followThemeMargins() { + updatePaper({ + marginMode: "theme", + margins: theme?.pageDefaults?.margins ?? { + top: "16mm", + right: "16mm", + bottom: "16mm", + left: "16mm" + } + }); + } + function updateHeader(header: Partial) { onChange({ ...config, + pageDecorationsMode: "custom", header: { ...config.header, ...header @@ -279,6 +357,7 @@ export function ExportSettingsDrawer({ function updateFooter(footer: Partial) { onChange({ ...config, + pageDecorationsMode: "custom", footer: { ...config.footer, ...footer @@ -286,6 +365,19 @@ export function ExportSettingsDrawer({ }); } + function followThemePageDecorations() { + onChange({ + ...config, + pageDecorationsMode: "theme", + header: structuredClone( + theme?.pageDefaults?.header ?? defaultExportConfig.header + ), + footer: structuredClone( + theme?.pageDefaults?.footer ?? defaultExportConfig.footer + ) + }); + } + function updateMermaid(mermaid: Partial) { onChange({ ...config, @@ -374,6 +466,34 @@ export function ExportSettingsDrawer({

页边距

+
+ 页边距来源 + + +
+

+ {config.paper.marginMode === "theme" + ? theme?.pageDefaults + ? `当前采用“${theme.name}”推荐值` + : "当前主题未设置推荐值,采用全局默认 16mm" + : "当前采用自定义值;修改任意页边距会自动进入此模式"} +

updateMargin("left", value)} />
+ {config.paper.marginMode === "custom" ? ( + + ) : null}
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({

+
+

页眉与页码来源

+
+ 页面装饰来源 + + +
+

+ {config.pageDecorationsMode === "theme" + ? theme?.pageDefaults?.header || + theme?.pageDefaults?.footer + ? `当前采用“${theme.name}”推荐的页眉与页码` + : "当前主题未设置推荐值,采用全局默认配置" + : "当前采用自定义配置;修改页眉或页码会自动进入此模式"} +

+ {config.pageDecorationsMode === "custom" ? ( + + ) : null} +
+

页眉

@@ -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 })} + /> +