diff --git a/.gitignore b/.gitignore index b468c69..532e7a2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ coverage/ Thumbs.db .idea/ .vscode/ +.serena/ tmp/ playwright-report/ test-results/ diff --git a/AGENTS.md b/AGENTS.md index d99e300..9c23e29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ flowchart LR - 应用服务:`packages/application`。 - 共享模型:`packages/core`。 - Markdown 渲染:`packages/renderer`。 +- 连续预览与分页引擎:`packages/preview-engine`。 - ECharts 协议与运行时:`packages/markdown-echarts`。 - PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。 - Markdown:markdown-it 及 `@mdit/plugin-*` 插件。 @@ -102,6 +103,7 @@ packages/application/ 共享应用服务、主题注册和图片资源处理 packages/core/ 导出配置和主题清单等共享模型 packages/renderer/ Markdown 到安全 HTML 的渲染管线 packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时 +packages/preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时 themes/ 内置主题 deploy/ Docker、Compose、Nginx 和部署文档 docs/ 进度、架构和部署文档 diff --git a/README.md b/README.md index cdb763a..9710826 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,27 @@ 导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和 分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。 -当前版本:`v0.4.5`。 +当前版本:`v0.5.0`。 -## v0.4.5 主要能力 +## v0.5.0 主要能力 -- 新建、打开和保存 Markdown;新文档可直接导出 PDF; -- Windows 注册 `.md`、`.markdown` 文件关联,系统打开文件时自动加载; -- 桌面窗口位置、尺寸和最大化状态延迟持久化,并在多显示器变化后自动 - 修正到可见工作区; -- “打开 Markdown”保留为独立主按钮,新建、保存和主题目录收纳在“更多” - 菜单; -- 桌面端提供可直接放置自定义主题的本地目录; -- 图片、Mermaid 和 ECharts 采用串行媒体分页:每个媒体块至多重排一次, - 严格保持文档顺序; -- 媒体先按内容限宽计算基准高度。当上一页空白接近媒体所需空间时,允许 - 将媒体内容等比例缩小回填;图片标题或图表标题保持原字号且与媒体成块; -- 示例文档覆盖横图、竖图、Base64 图片、Mermaid、ECharts 以及不同尺寸 - 的分页边界。 +- 新增“连续”预览模式:不分页、低测量开销,适合编辑时快速刷新;快速 + 预览继续提供分页近似,精确预览和导出 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 依赖,避免安装包携带陈旧 + 前端资源。 ## Web 与桌面端 @@ -30,6 +34,11 @@ | 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 | | 保存 Markdown | 浏览器下载 | 原生另存为 | | 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 | +| 文档内锚点 | 当前预览定位 | 当前预览定位 | +| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 | +| 本地文件链接 | 不执行 | 相对、绝对路径及 `file:` URI 均可尝试打开 | +| 多文件 | 当前浏览器文档 | 多窗口、同一文件单例 | +| 外部文件变化 | 不适用 | 窗口聚焦时提示重新加载 | | 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 | | 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” | | PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium | @@ -44,16 +53,18 @@ flowchart LR A["Markdown 与受限资源"] --> B["Renderer 安全渲染"] B --> C["统一 #write HTML"] D["主题 CSS 与导出配置"] --> C - C --> E["共享分页运行时"] - E --> F["Web 快速预览"] - E --> G["Playwright Chromium"] - E --> H["Electron Chromium"] - G --> I["Web PDF"] - H --> J["Desktop PDF"] + C --> E["共享 Preview Engine"] + E --> F["连续预览"] + E --> G["快速分页预览"] + E --> H["Playwright Chromium"] + E --> I["Electron Chromium"] + H --> J["Web PDF"] + I --> K["Desktop PDF"] ``` -快速预览适合编辑反馈;精确预览直接展示服务端生成的 PDF,是 Web 最终 -分页的权威结果。桌面 PDF 复用相同分页载荷和运行时。 +连续预览适合高频编辑;快速预览提供客户端分页反馈;精确预览直接展示 +服务端生成的 PDF,是 Web 最终分页的权威结果。桌面 PDF 复用相同分页 +载荷和运行时。 ## 项目目录 @@ -67,6 +78,7 @@ packages/ renderer/ Markdown 到安全 #write HTML 的渲染管线 application/ 共享渲染、主题注册和图片资源应用服务 markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时 + preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时 themes/ 内置主题与主题清单 deploy/ Dockerfile、Compose、Nginx 和部署文档 docs/ 项目进度与主题开发说明 @@ -75,9 +87,10 @@ logos/ 应用图标源文件 .local/themes/ 本地开发和 Compose 自定义主题目录 ``` -包依赖保持单向:`core` 提供基础协议;`markdown-echarts` 提供独立图表 -能力;`renderer` 消费两者生成安全 HTML;`application` 组合渲染器、主题 -和资源用例;三个 `apps/*` 只负责各自平台的界面、传输与 PDF 适配。 +包依赖保持单向:`core` 提供基础协议和链接分类;`markdown-echarts` +提供独立图表能力;`renderer` 消费两者生成安全 HTML;`application` +组合渲染器、主题和资源用例;`preview-engine` 消费渲染结果完成连续或 +分页排版;三个 `apps/*` 只负责平台界面、传输、链接动作与 PDF 适配。 各包的目录和用法: @@ -85,6 +98,7 @@ logos/ 应用图标源文件 - [core](packages/core/README.md) - [renderer](packages/renderer/README.md) - [markdown-echarts](packages/markdown-echarts/README.md) +- [preview-engine](packages/preview-engine/README.md) ## 本地开发 @@ -114,8 +128,9 @@ npm run desktop:package npm run make -w @md-to-pdf/desktop ``` -产物位于 `apps/desktop/out/v0.4.5/`。正式安装包命名为 -`md-to-pdf-0.4.5-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包 +桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,不能 +复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.0/`。 +正式安装包命名为 `md-to-pdf-0.5.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包 未配置代码签名。 ## 文档与媒体 @@ -134,8 +149,9 @@ ECharts 围栏语法、支持系列和安全边界详见 ## 主题 -仓库内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题。主题由 -版本化 `theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。 +仓库内置 Typora Github、Typora Pixyll、Typora whitey 和 +Typora Clean 四套主题,默认使用 Typora Github。主题由版本化 +`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。 - 本地开发与 Compose:将主题目录放入 `.local/themes/`; - Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题; diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 4065e7a..746d2b5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -8,7 +8,11 @@ preload、进程内应用服务、原生 Markdown 打开与保存、同目录图 桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入, 异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口 落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联, -双击文件或使用“打开方式”会复用已有应用窗口并加载文档。 +双击文件或使用“打开方式”会在对应文档窗口中加载文件;同一文件只保留 +一个窗口,其他文件可以同时使用多个窗口。 + +窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示 +重新加载,避免阅读或导出磁盘旧版本。 “打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开 自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”, @@ -36,9 +40,13 @@ npm run desktop:package npm run make -w @md-to-pdf/desktop ``` +`package` 和 `make` 会先调用根级 `build:web-runtime`,完整重建 +Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web, +然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。 + 版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到 -`apps/desktop/out/v0.4.5/`。该目录被 Git 忽略。公司内部分发以 -`md-to-pdf-0.4.5-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 +`apps/desktop/out/v0.5.0/`。该目录被 Git 忽略。公司内部分发以 +`md-to-pdf-0.5.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者” 提示。 @@ -57,6 +65,16 @@ NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安 Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过 `mdpdf://bundle/` 自定义协议加载。 +## 链接与窗口行为 + +- 当前文档 `#anchor` 在当前预览中定位; +- HTTP/HTTPS 链接交给系统默认浏览器; +- `mailto:` 等系统协议交给操作系统处理; +- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开; +- Markdown 文件会提示在当前窗口或新窗口打开; +- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口; +- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。 + 发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时 组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f991c73..6c1baa7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@md-to-pdf/desktop", - "version": "0.4.5", + "version": "0.5.0", "private": true, "productName": "Markdown PDF 导出器", "description": "Markdown PDF 导出器桌面端", @@ -9,12 +9,13 @@ "main": "dist/main.js", "scripts": { "build": "npm run clean && npm run build:main && npm run build:preload", + "build:embedded-web": "npm --prefix ../.. run build:web-runtime", "build:main": "node scripts/build-main.mjs", "build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173", - "package": "npm run build && electron-builder --dir --config electron-builder.config.cjs --x64", - "make": "npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64", + "package": "npm run build:embedded-web && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64", + "make": "npm run build:embedded-web && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" }, diff --git a/apps/desktop/src/app-preload.ts b/apps/desktop/src/app-preload.ts index bdc69f3..9c0aa55 100644 --- a/apps/desktop/src/app-preload.ts +++ b/apps/desktop/src/app-preload.ts @@ -9,10 +9,12 @@ import { DESKTOP_LIST_THEMES, DESKTOP_MARKDOWN_OPENED, DESKTOP_OPEN_MARKDOWN, + DESKTOP_OPEN_DOCUMENT_LINK, DESKTOP_OPEN_THEME_DIRECTORY, DESKTOP_REFRESH_THEMES, DESKTOP_RENDER_MARKDOWN, DESKTOP_SAVE_MARKDOWN, + DESKTOP_SET_DOCUMENT_DIRTY, DESKTOP_START_NEW_MARKDOWN, DESKTOP_SAVE_PDF } from "./channels.js"; @@ -23,14 +25,23 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", { ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN), discardPendingMarkdown: () => ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN), - onMarkdownOpened: (listener: () => void) => { - const handleOpened = () => listener(); + onMarkdownOpened: ( + listener: (reason: "open" | "reload" | "replace") => void + ) => { + const handleOpened = ( + _event: Electron.IpcRendererEvent, + reason: "open" | "reload" | "replace" + ) => listener(reason); ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened); return () => ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened); }, startNewMarkdown: () => ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN), + 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), openThemeDirectory: () => diff --git a/apps/desktop/src/channels.ts b/apps/desktop/src/channels.ts index 117cd06..66abe76 100644 --- a/apps/desktop/src/channels.ts +++ b/apps/desktop/src/channels.ts @@ -8,6 +8,10 @@ export const DESKTOP_DISCARD_PENDING_MARKDOWN = "md-to-pdf:desktop:discard-pending-markdown"; export const DESKTOP_MARKDOWN_OPENED = "md-to-pdf:desktop:markdown-opened"; +export const DESKTOP_SET_DOCUMENT_DIRTY = + "md-to-pdf:desktop:set-document-dirty"; +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_SAVE_MARKDOWN = diff --git a/apps/desktop/src/desktop-application-controller.ts b/apps/desktop/src/desktop-application-controller.ts new file mode 100644 index 0000000..f2091f7 --- /dev/null +++ b/apps/desktop/src/desktop-application-controller.ts @@ -0,0 +1,925 @@ +import { + app, + BrowserWindow, + dialog, + ipcMain, + screen, + shell, + type IpcMainInvokeEvent, + type Rectangle +} from "electron"; +import { + mkdir, + realpath, + stat, + writeFile +} from "node:fs/promises"; +import path from "node:path"; +import { + MAXIMUM_MARKDOWN_LENGTH, + type ApplicationService +} from "@md-to-pdf/application"; +import { + DESKTOP_CONSUME_PENDING_MARKDOWN, + DESKTOP_DISCARD_PENDING_MARKDOWN, + DESKTOP_GENERATE_PDF, + DESKTOP_GET_THEME_CSS, + DESKTOP_LIST_THEMES, + DESKTOP_MARKDOWN_OPENED, + DESKTOP_OPEN_DOCUMENT_LINK, + DESKTOP_OPEN_MARKDOWN, + DESKTOP_OPEN_THEME_DIRECTORY, + DESKTOP_REFRESH_THEMES, + DESKTOP_RENDER_MARKDOWN, + DESKTOP_SAVE_MARKDOWN, + DESKTOP_SAVE_PDF, + DESKTOP_SET_DOCUMENT_DIRTY, + DESKTOP_START_NEW_MARKDOWN +} from "./channels.js"; +import { + parseMarkdownRenderRequest, + parseThemeId +} from "./application-contract.js"; +import { ElectronPdfGenerator } from "./electron-pdf-generator.js"; +import { resolveDesktopDocumentLinkAction } from "./desktop-document-link.js"; +import { + isMarkdownFilePath, + createMarkdownDocumentKey, + readMarkdownFileSnapshot, + type MarkdownFileSnapshot +} from "./markdown-file.js"; +import { parsePagedDocumentPayload } from "./pdf-contract.js"; +import { + loadWindowState, + resolveWindowBounds, + saveWindowState, + type WindowState +} from "./window-state.js"; + +const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 }; +const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 }; +const CASCADE_OFFSET = 32; +const FOCUS_CHECK_SUPPRESSION_MS = 800; + +type MarkdownOpenReason = "open" | "reload" | "replace"; + +interface PendingOpenedMarkdown { + snapshot: MarkdownFileSnapshot; + reason: MarkdownOpenReason; +} + +interface WindowSession { + window: BrowserWindow; + dirty: boolean; + allowClose: boolean; + currentFilePath: string | undefined; + documentKey: string | undefined; + documentRoot: string | undefined; + baselineHash: string | undefined; + pendingOpenedMarkdown: PendingOpenedMarkdown | undefined; + focusCheckRunning: boolean; + suppressFocusCheckUntil: number; +} + +export interface DesktopApplicationControllerOptions { + applicationService: ApplicationService; + applicationUrl: string; + renderUrl: string; + preloadPath: string; + pdfPreloadPath: string; + iconPath: string; + themeDirectory: string; + windowStatePath: string; +} + +function focusWindow(window: BrowserWindow) { + if (window.isDestroyed()) { + return; + } + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); +} + +export class DesktopApplicationController { + readonly #applicationService: ApplicationService; + readonly #applicationUrl: string; + readonly #applicationOrigin: string; + readonly #preloadPath: string; + readonly #iconPath: string; + readonly #themeDirectory: string; + readonly #windowStatePath: string; + readonly #pdfGenerator: ElectronPdfGenerator; + readonly #sessions = new Map(); + readonly #documentSessions = new Map(); + #primarySession: WindowSession | undefined; + #savedWindowState: WindowState | undefined; + #saveWindowStateTimer: NodeJS.Timeout | undefined; + #pendingWindowStateWrite = Promise.resolve(); + #latestWindowState: WindowState | undefined; + + constructor(options: DesktopApplicationControllerOptions) { + this.#applicationService = options.applicationService; + this.#applicationUrl = options.applicationUrl; + this.#applicationOrigin = new URL(options.applicationUrl).origin; + this.#preloadPath = options.preloadPath; + this.#iconPath = options.iconPath; + this.#themeDirectory = options.themeDirectory; + this.#windowStatePath = options.windowStatePath; + this.#pdfGenerator = new ElectronPdfGenerator({ + renderUrl: options.renderUrl, + preloadPath: options.pdfPreloadPath + }); + } + + async initialize() { + this.#savedWindowState = await loadWindowState( + this.#windowStatePath + ); + this.#registerIpcHandlers(); + } + + async createWindow(filePath?: string) { + let pendingSnapshot: MarkdownFileSnapshot | undefined; + if (filePath) { + pendingSnapshot = await readMarkdownFileSnapshot( + filePath, + MAXIMUM_MARKDOWN_LENGTH + ); + const existing = this.#findDocumentSession( + pendingSnapshot.documentKey + ); + if (existing) { + focusWindow(existing.window); + return existing.window; + } + } + return this.#createWindow(pendingSnapshot); + } + + async #createWindow(pendingSnapshot?: MarkdownFileSnapshot) { + const isPrimary = !this.#primarySession; + const restoredWindowState = isPrimary + ? this.#latestWindowState ?? this.#savedWindowState + : undefined; + const initialBounds = this.#resolveInitialBounds(isPrimary); + const window = new BrowserWindow({ + title: `Markdown PDF 导出器 v${app.getVersion()}`, + icon: this.#iconPath, + ...initialBounds, + minWidth: Math.min( + MINIMUM_WINDOW_SIZE.width, + initialBounds.width + ), + minHeight: Math.min( + MINIMUM_WINDOW_SIZE.height, + initialBounds.height + ), + autoHideMenuBar: true, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + preload: this.#preloadPath + } + }); + const session: WindowSession = { + window, + dirty: false, + allowClose: false, + currentFilePath: undefined, + documentKey: undefined, + documentRoot: undefined, + baselineHash: undefined, + pendingOpenedMarkdown: pendingSnapshot + ? { snapshot: pendingSnapshot, reason: "open" } + : undefined, + focusCheckRunning: false, + suppressFocusCheckUntil: 0 + }; + this.#sessions.set(window.webContents.id, session); + if (isPrimary) { + this.#primarySession = session; + this.#captureWindowState(); + this.#bindPrimaryWindowState(session); + } + this.#lockDownWindow(window); + this.#bindWindowLifecycle(session); + + window.once("ready-to-show", () => { + if ( + isPrimary && + restoredWindowState?.maximized && + !window.isDestroyed() + ) { + window.maximize(); + } + window.show(); + }); + await window.loadURL(this.#applicationUrl); + return window; + } + + async openExternalMarkdown(filePath: string) { + const snapshot = await readMarkdownFileSnapshot( + filePath, + MAXIMUM_MARKDOWN_LENGTH + ); + const existing = this.#findDocumentSession(snapshot.documentKey); + if (existing) { + focusWindow(existing.window); + return; + } + await this.#createWindow(snapshot); + } + + focusMostRecentWindow() { + const focused = BrowserWindow.getFocusedWindow(); + const focusedSession = focused + ? [...this.#sessions.values()].find( + (session) => session.window === focused + ) + : undefined; + if (focusedSession) { + focusWindow(focusedSession.window); + return true; + } + const session = [...this.#sessions.values()].at(-1); + if (!session) { + return false; + } + focusWindow(session.window); + return true; + } + + async flushWindowState() { + if (this.#saveWindowStateTimer) { + clearTimeout(this.#saveWindowStateTimer); + this.#saveWindowStateTimer = undefined; + } + this.#captureWindowState(); + await this.#persistWindowState(); + } + + async closeResources() { + await this.#pdfGenerator.close(); + } + + #resolveInitialBounds(isPrimary: boolean): Rectangle { + const displays = screen + .getAllDisplays() + .map((display) => display.workArea); + const primaryWorkArea = screen.getPrimaryDisplay().workArea; + if (isPrimary) { + return resolveWindowBounds( + (this.#latestWindowState ?? this.#savedWindowState)?.bounds, + displays, + primaryWorkArea, + DEFAULT_WINDOW_SIZE, + MINIMUM_WINDOW_SIZE + ); + } + + const reference = + BrowserWindow.getFocusedWindow() ?? + [...this.#sessions.values()].at(-1)?.window; + const referenceBounds = + reference && !reference.isDestroyed() + ? reference.getNormalBounds() + : primaryWorkArea; + return resolveWindowBounds( + { + x: referenceBounds.x + CASCADE_OFFSET, + y: referenceBounds.y + CASCADE_OFFSET, + width: referenceBounds.width, + height: referenceBounds.height + }, + displays, + primaryWorkArea, + DEFAULT_WINDOW_SIZE, + MINIMUM_WINDOW_SIZE + ); + } + + #lockDownWindow(window: BrowserWindow) { + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event, targetUrl) => { + if (new URL(targetUrl).origin !== this.#applicationOrigin) { + event.preventDefault(); + } + }); + const applicationSession = window.webContents.session; + applicationSession.setPermissionCheckHandler(() => false); + applicationSession.setPermissionRequestHandler( + (_webContents, _permission, callback) => callback(false) + ); + } + + #bindWindowLifecycle(session: WindowSession) { + const { window } = session; + const webContentsId = window.webContents.id; + window.on("focus", () => { + void this.#checkForExternalChange(session); + }); + window.on("close", (event) => { + if (!session.dirty || session.allowClose) { + return; + } + event.preventDefault(); + void this.#confirmDirtyWindowClose(session); + }); + window.on("closed", () => { + const wasPrimary = this.#primarySession === session; + this.#sessions.delete(webContentsId); + if ( + session.documentKey && + this.#documentSessions.get(session.documentKey) === session + ) { + this.#documentSessions.delete(session.documentKey); + } + if (wasPrimary) { + this.#promotePrimarySession(); + } + }); + } + + #promotePrimarySession() { + const nextPrimary = [...this.#sessions.values()] + .filter((session) => !session.window.isDestroyed()) + .at(-1); + this.#primarySession = nextPrimary; + if (!nextPrimary) { + return; + } + this.#bindPrimaryWindowState(nextPrimary); + 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 || + session.window.isDestroyed() || + !session.currentFilePath || + !session.baselineHash || + Date.now() < session.suppressFocusCheckUntil + ) { + return; + } + session.focusCheckRunning = true; + try { + let snapshot: MarkdownFileSnapshot; + try { + snapshot = await readMarkdownFileSnapshot( + session.currentFilePath, + MAXIMUM_MARKDOWN_LENGTH + ); + } catch (error) { + await dialog.showMessageBox(session.window, { + type: "error", + title: "无法检查文件", + message: "当前 Markdown 文件已被删除或无法读取。", + detail: + error instanceof Error ? error.message : "未知文件错误", + buttons: ["确定"], + defaultId: 0, + noLink: true + }); + return; + } + if (snapshot.contentHash === session.baselineHash) { + return; + } + + const result = await dialog.showMessageBox(session.window, { + type: "warning", + title: "文件已在外部修改", + message: session.dirty + ? "文件已在外部修改,重新载入会丢弃当前未保存内容。" + : "文件已在外部修改,是否重新载入?", + detail: snapshot.filePath, + buttons: ["暂不载入", "重新载入"], + defaultId: 1, + cancelId: 0, + noLink: true + }); + if (result.response === 1) { + session.baselineHash = snapshot.contentHash; + session.pendingOpenedMarkdown = { + snapshot, + reason: "reload" + }; + session.window.webContents.send( + DESKTOP_MARKDOWN_OPENED, + "reload" + ); + } + } finally { + session.focusCheckRunning = false; + session.suppressFocusCheckUntil = + Date.now() + FOCUS_CHECK_SUPPRESSION_MS; + } + } + + #bindPrimaryWindowState(session: WindowSession) { + const schedule = () => { + this.#captureWindowState(); + if (this.#saveWindowStateTimer) { + clearTimeout(this.#saveWindowStateTimer); + } + this.#saveWindowStateTimer = setTimeout(() => { + this.#saveWindowStateTimer = undefined; + void this.#persistWindowState(); + }, 2_000); + }; + session.window.on("move", schedule); + session.window.on("resize", schedule); + session.window.on("maximize", schedule); + session.window.on("unmaximize", schedule); + } + + #captureWindowState() { + const window = this.#primarySession?.window; + if (!window || window.isDestroyed()) { + return; + } + this.#latestWindowState = { + version: 1, + bounds: window.getNormalBounds(), + maximized: window.isMaximized() + }; + } + + #persistWindowState() { + if (!this.#latestWindowState) { + return this.#pendingWindowStateWrite; + } + const state = structuredClone(this.#latestWindowState); + this.#pendingWindowStateWrite = this.#pendingWindowStateWrite + .catch(() => undefined) + .then(() => saveWindowState(this.#windowStatePath, state)) + .catch((error: unknown) => { + console.warn("无法保存窗口状态", error); + }); + return this.#pendingWindowStateWrite; + } + + #findDocumentSession(documentKey: string) { + const committed = this.#documentSessions.get(documentKey); + if (committed) { + return committed; + } + return [...this.#sessions.values()].find( + (session) => + session.pendingOpenedMarkdown?.snapshot.documentKey === + documentKey + ); + } + + #getSession(event: IpcMainInvokeEvent) { + const session = this.#sessions.get(event.sender.id); + let origin: string | undefined; + try { + origin = event.senderFrame + ? new URL(event.senderFrame.url).origin + : undefined; + } catch { + origin = undefined; + } + if ( + !session || + event.sender !== session.window.webContents || + origin !== this.#applicationOrigin + ) { + throw new Error("拒绝未授权的桌面请求"); + } + return session; + } + + #commitSnapshot( + session: WindowSession, + snapshot: MarkdownFileSnapshot + ) { + if ( + session.documentKey && + this.#documentSessions.get(session.documentKey) === session + ) { + this.#documentSessions.delete(session.documentKey); + } + session.currentFilePath = snapshot.filePath; + session.documentKey = snapshot.documentKey; + session.documentRoot = path.dirname(snapshot.filePath); + session.baselineHash = snapshot.contentHash; + session.dirty = false; + this.#documentSessions.set(snapshot.documentKey, session); + } + + #clearDocument(session: WindowSession) { + if ( + session.documentKey && + this.#documentSessions.get(session.documentKey) === session + ) { + this.#documentSessions.delete(session.documentKey); + } + session.currentFilePath = undefined; + session.documentKey = undefined; + session.documentRoot = undefined; + session.baselineHash = undefined; + session.pendingOpenedMarkdown = undefined; + session.dirty = false; + } + + #registerIpcHandlers() { + ipcMain.handle( + DESKTOP_OPEN_DOCUMENT_LINK, + async (event, unsafeHref: unknown) => { + const session = this.#getSession(event); + if ( + typeof unsafeHref !== "string" || + unsafeHref.length === 0 || + unsafeHref.length > 8_192 + ) { + throw new Error("文档链接无效"); + } + try { + await this.#openDocumentLink(session, unsafeHref); + } catch (error) { + await dialog.showMessageBox(session.window, { + type: "error", + title: "无法打开链接", + message: + error instanceof Error + ? error.message + : "无法打开文档链接", + detail: unsafeHref, + buttons: ["确定"], + defaultId: 0, + noLink: true + }); + } + } + ); + + ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => { + const session = this.#getSession(event); + const selection = await dialog.showOpenDialog(session.window, { + title: "打开 Markdown", + properties: ["openFile"], + filters: [ + { name: "Markdown 文件", extensions: ["md", "markdown"] } + ] + }); + const filePath = selection.filePaths[0]; + if (selection.canceled || !filePath) { + return undefined; + } + const snapshot = await readMarkdownFileSnapshot( + filePath, + MAXIMUM_MARKDOWN_LENGTH + ); + const existing = this.#findDocumentSession(snapshot.documentKey); + if (existing && existing !== session) { + focusWindow(existing.window); + return undefined; + } + this.#commitSnapshot(session, snapshot); + return snapshot.document; + }); + + ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => { + const session = this.#getSession(event); + const pending = session.pendingOpenedMarkdown; + session.pendingOpenedMarkdown = undefined; + if (!pending) { + return undefined; + } + this.#commitSnapshot(session, pending.snapshot); + return pending.snapshot.document; + }); + + ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => { + const session = this.#getSession(event); + session.pendingOpenedMarkdown = undefined; + }); + + ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => { + this.#clearDocument(this.#getSession(event)); + }); + + ipcMain.handle( + DESKTOP_SET_DOCUMENT_DIRTY, + async (event, unsafeDirty: unknown) => { + if (typeof unsafeDirty !== "boolean") { + throw new Error("文档修改状态无效"); + } + this.#getSession(event).dirty = unsafeDirty; + } + ); + + ipcMain.handle( + DESKTOP_SAVE_MARKDOWN, + async ( + event, + unsafeFileName: unknown, + unsafeMarkdown: unknown + ) => { + const session = this.#getSession(event); + if ( + typeof unsafeFileName !== "string" || + unsafeFileName.length === 0 || + unsafeFileName.length > 500 || + typeof unsafeMarkdown !== "string" || + unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH + ) { + throw new Error("Markdown 保存参数无效"); + } + + let targetPath = session.currentFilePath; + if (!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 + ), + filters: [ + { + name: "Markdown 文件", + extensions: ["md", "markdown"] + } + ] + }); + if (selection.canceled || !selection.filePath) { + return undefined; + } + targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath) + ? selection.filePath + : `${selection.filePath}.md`; + } + + let targetKey = createMarkdownDocumentKey( + path.resolve(targetPath) + ); + try { + targetKey = createMarkdownDocumentKey( + await realpath(targetPath) + ); + } catch (error) { + if ( + !( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) + ) { + throw error; + } + } + const existing = this.#findDocumentSession(targetKey); + if (existing && existing !== session) { + focusWindow(existing.window); + throw new Error("该 Markdown 已在另一个窗口中打开"); + } + + await writeFile(targetPath, unsafeMarkdown, "utf8"); + const snapshot = await readMarkdownFileSnapshot( + targetPath, + MAXIMUM_MARKDOWN_LENGTH + ); + const canonicalExisting = this.#findDocumentSession( + snapshot.documentKey + ); + if (canonicalExisting && canonicalExisting !== session) { + focusWindow(canonicalExisting.window); + throw new Error("该 Markdown 已在另一个窗口中打开"); + } + this.#commitSnapshot(session, snapshot); + return { fileName: snapshot.document.fileName }; + } + ); + + ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => { + this.#getSession(event); + await mkdir(this.#themeDirectory, { recursive: true }); + const errorMessage = await shell.openPath(this.#themeDirectory); + if (errorMessage) { + throw new Error( + `无法打开自定义主题目录:${errorMessage}` + ); + } + return this.#themeDirectory; + }); + + ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => { + this.#getSession(event); + this.#applicationService.invalidateThemes(); + }); + + ipcMain.handle( + DESKTOP_RENDER_MARKDOWN, + async (event, unsafeRequest: unknown) => { + const session = this.#getSession(event); + return this.#applicationService.render( + parseMarkdownRenderRequest(unsafeRequest), + session.documentRoot + ? { + localRoot: session.documentRoot, + allowUnrestrictedLocalFiles: true + } + : {} + ); + } + ); + + ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => { + this.#getSession(event); + return this.#applicationService.listThemes(); + }); + + ipcMain.handle( + DESKTOP_GET_THEME_CSS, + async (event, unsafeThemeId: unknown) => { + this.#getSession(event); + const css = await this.#applicationService.getThemeCss( + parseThemeId(unsafeThemeId) + ); + if (css === undefined) { + throw new Error("未找到指定主题"); + } + return css; + } + ); + + ipcMain.handle( + DESKTOP_GENERATE_PDF, + async (event, unsafePayload: unknown) => { + this.#getSession(event); + const result = await this.#pdfGenerator.generate( + parsePagedDocumentPayload(unsafePayload) + ); + return { + pdf: result.pdf, + pageCount: result.pageCount, + echartsErrors: result.echartsErrors, + mermaidErrors: result.mermaidErrors + }; + } + ); + + ipcMain.handle( + DESKTOP_SAVE_PDF, + async ( + event, + unsafeFileName: unknown, + unsafePdf: unknown + ) => { + const session = this.#getSession(event); + if ( + typeof unsafeFileName !== "string" || + unsafeFileName.length === 0 || + unsafeFileName.length > 500 || + !(unsafePdf instanceof Uint8Array) || + unsafePdf.byteLength === 0 || + unsafePdf.byteLength > 200 * 1024 * 1024 + ) { + throw new Error("PDF 保存参数无效"); + } + const suggestedName = path.basename(unsafeFileName).endsWith( + ".pdf" + ) + ? path.basename(unsafeFileName) + : `${path.basename(unsafeFileName)}.pdf`; + const selection = await dialog.showSaveDialog(session.window, { + title: "导出 PDF", + defaultPath: path.join( + app.getPath("documents"), + suggestedName + ), + filters: [{ name: "PDF 文件", extensions: ["pdf"] }] + }); + if (selection.canceled || !selection.filePath) { + return false; + } + await writeFile(selection.filePath, unsafePdf); + return true; + } + ); + } + + async #openDocumentLink( + session: WindowSession, + href: string + ) { + const action = resolveDesktopDocumentLinkAction( + href, + session.currentFilePath + ); + if (action.type === "ignore" || action.type === "anchor") { + return; + } + if (action.type === "external") { + await shell.openExternal(action.href); + return; + } + + let targetStats; + try { + targetStats = await stat(action.filePath); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + await dialog.showMessageBox(session.window, { + type: "warning", + title: "找不到文件", + message: "链接指向的本地文件不存在。", + detail: action.filePath, + buttons: ["确定"], + defaultId: 0, + noLink: true + }); + return; + } + throw error; + } + + if ( + targetStats.isFile() && + isMarkdownFilePath(action.filePath) + ) { + const snapshot = await readMarkdownFileSnapshot( + action.filePath, + MAXIMUM_MARKDOWN_LENGTH + ); + const existing = this.#findDocumentSession( + snapshot.documentKey + ); + if (existing) { + focusWindow(existing.window); + return; + } + + const result = await dialog.showMessageBox(session.window, { + type: "question", + title: "打开 Markdown", + message: `如何打开 ${snapshot.document.fileName}?`, + detail: session.dirty + ? "当前窗口有未保存修改;选择当前窗口会放弃这些修改。" + : snapshot.filePath, + buttons: ["取消", "当前窗口打开", "新窗口打开"], + defaultId: 2, + cancelId: 0, + noLink: true + }); + if (result.response === 1) { + session.pendingOpenedMarkdown = { + snapshot, + reason: "replace" + }; + session.window.webContents.send( + DESKTOP_MARKDOWN_OPENED, + "replace" + ); + } else if (result.response === 2) { + await this.#createWindow(snapshot); + } + return; + } + + const errorMessage = await shell.openPath(action.filePath); + if (errorMessage) { + throw new Error(`系统无法打开该路径:${errorMessage}`); + } + } +} diff --git a/apps/desktop/src/desktop-document-link.ts b/apps/desktop/src/desktop-document-link.ts new file mode 100644 index 0000000..60776b8 --- /dev/null +++ b/apps/desktop/src/desktop-document-link.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { classifyDocumentLink } from "@md-to-pdf/core"; + +export type DesktopDocumentLinkAction = + | { + type: "anchor"; + href: string; + } + | { + type: "external"; + href: string; + } + | { + type: "local"; + filePath: string; + } + | { + type: "ignore"; + }; + +function decodeLocalPath(href: string) { + const pathOnly = href.split(/[?#]/u, 1)[0] ?? ""; + try { + return decodeURIComponent(pathOnly); + } catch { + throw new Error("本地链接的 URL 编码无效"); + } +} + +export function resolveDesktopDocumentLinkAction( + href: string, + markdownFilePath?: string +): DesktopDocumentLinkAction { + const link = classifyDocumentLink(href); + if (link.kind === "anchor") { + return { type: "anchor", href: link.normalizedHref }; + } + if (link.kind === "network" || link.kind === "protocol") { + return { type: "external", href: link.normalizedHref }; + } + if (link.kind !== "local") { + return { type: "ignore" }; + } + + if (link.scheme === "file") { + try { + return { + type: "local", + filePath: path.normalize(fileURLToPath(new URL(link.href))) + }; + } catch { + throw new Error("file: 链接格式无效"); + } + } + + const decoded = decodeLocalPath(link.href); + if (path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)) { + return { type: "local", filePath: path.normalize(decoded) }; + } + if (!markdownFilePath) { + throw new Error("当前文档尚未保存,无法解析相对本地链接"); + } + return { + type: "local", + filePath: path.resolve(path.dirname(markdownFilePath), decoded) + }; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index de5bf45..908f532 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,79 +1,32 @@ import { app, - BrowserWindow, - dialog, - ipcMain, Menu, net, protocol, - screen, - session, - shell, - type IpcMainInvokeEvent + session } from "electron"; -import { - access, - mkdir, - writeFile -} from "node:fs/promises"; +import { access, mkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createApplicationService, - MAXIMUM_MARKDOWN_LENGTH, type ApplicationService } from "@md-to-pdf/application"; +import { parseThemeResourceUrl } from "./application-contract.js"; import { - DESKTOP_CONSUME_PENDING_MARKDOWN, - DESKTOP_DISCARD_PENDING_MARKDOWN, - DESKTOP_GET_THEME_CSS, - DESKTOP_GENERATE_PDF, - DESKTOP_LIST_THEMES, - DESKTOP_MARKDOWN_OPENED, - DESKTOP_OPEN_MARKDOWN, - DESKTOP_OPEN_THEME_DIRECTORY, - DESKTOP_REFRESH_THEMES, - DESKTOP_RENDER_MARKDOWN, - DESKTOP_SAVE_MARKDOWN, - DESKTOP_SAVE_PDF, - DESKTOP_START_NEW_MARKDOWN -} from "./channels.js"; -import { - parseMarkdownRenderRequest, - parseThemeId, - parseThemeResourceUrl -} from "./application-contract.js"; -import { - DESKTOP_PDF_PARTITION, - ElectronPdfGenerator -} from "./electron-pdf-generator.js"; -import { parsePagedDocumentPayload } from "./pdf-contract.js"; + DesktopApplicationController +} from "./desktop-application-controller.js"; +import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js"; import { findMarkdownFileArgument, - isMarkdownFilePath, - readMarkdownDocument, - type OpenedMarkdownDocument + isMarkdownFilePath } from "./markdown-file.js"; -import { - loadWindowState, - resolveWindowBounds, - saveWindowState, - type WindowState -} from "./window-state.js"; const APP_SCHEME = "mdpdf"; const APP_HOST = "bundle"; const THEME_HOST = "theme"; const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); -const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 }; -const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 }; -let applicationController: - | { - window: BrowserWindow; - openMarkdownFile(filePath: string): Promise; - flushWindowState(): Promise; - } - | undefined; +let applicationController: DesktopApplicationController | undefined; let pendingExternalMarkdownPath = findMarkdownFileArgument( process.argv, process.cwd() @@ -130,29 +83,16 @@ function encodeThemeAssetPath(assetPath: string) { .join("/"); } -function focusApplicationWindow() { - const window = applicationController?.window; - if (!window || window.isDestroyed()) { - return; - } - if (window.isMinimized()) { - window.restore(); - } - window.show(); - window.focus(); -} - function dispatchExternalMarkdown(filePath: string) { if (!applicationController) { pendingExternalMarkdownPath = filePath; return; } - void applicationController.openMarkdownFile(filePath).catch( + void applicationController.openExternalMarkdown(filePath).catch( (error: unknown) => { console.warn("无法打开系统传入的 Markdown 文件", error); } ); - focusApplicationWindow(); } function getDesktopThemeDirectory() { @@ -226,12 +166,10 @@ async function registerApplicationProtocol( } catch { return new Response("Bad request", { status: 400 }); } - const target = path.resolve(webRoot, relativePath); if (!isContainedPath(webRoot, target)) { return new Response("Forbidden", { status: 403 }); } - try { await access(target); } catch { @@ -248,376 +186,6 @@ async function registerApplicationProtocol( ]); } -function lockDownWindow(window: BrowserWindow, allowedOrigin: string) { - window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); - window.webContents.on("will-navigate", (event, targetUrl) => { - if (new URL(targetUrl).origin !== allowedOrigin) { - event.preventDefault(); - } - }); - const applicationSession = window.webContents.session; - applicationSession.setPermissionCheckHandler(() => false); - applicationSession.setPermissionRequestHandler( - (_webContents, _permission, callback) => callback(false) - ); -} - -async function createApplication( - applicationService: ApplicationService -) { - const developmentUrl = getCommandLineValue("web-url"); - const applicationUrl = - developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`; - const renderUrl = new URL( - "/preview-frame.html?target=pdf", - applicationUrl - ).href; - const windowStatePath = path.join( - app.getPath("userData"), - "window-state.json" - ); - const savedWindowState = await loadWindowState(windowStatePath); - const primaryWorkArea = screen.getPrimaryDisplay().workArea; - const initialBounds = resolveWindowBounds( - savedWindowState?.bounds, - screen.getAllDisplays().map((display) => display.workArea), - primaryWorkArea, - DEFAULT_WINDOW_SIZE, - MINIMUM_WINDOW_SIZE - ); - - const window = new BrowserWindow({ - title: `Markdown PDF 导出器 v${app.getVersion()}`, - icon: getApplicationIcon(), - ...initialBounds, - minWidth: Math.min(MINIMUM_WINDOW_SIZE.width, initialBounds.width), - minHeight: Math.min(MINIMUM_WINDOW_SIZE.height, initialBounds.height), - autoHideMenuBar: true, - show: false, - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - preload: path.join(currentDirectory, "app-preload.cjs") - } - }); - let saveWindowStateTimer: NodeJS.Timeout | undefined; - let pendingWindowStateWrite = Promise.resolve(); - let latestWindowState: WindowState = { - version: 1, - bounds: window.getNormalBounds(), - maximized: window.isMaximized() - }; - const captureWindowState = () => { - if (window.isDestroyed()) { - return; - } - latestWindowState = { - version: 1, - bounds: window.getNormalBounds(), - maximized: window.isMaximized() - }; - }; - const persistWindowState = () => { - const state = structuredClone(latestWindowState); - pendingWindowStateWrite = pendingWindowStateWrite - .catch(() => undefined) - .then(() => saveWindowState(windowStatePath, state)) - .catch((error: unknown) => { - console.warn("无法保存窗口状态", error); - }); - return pendingWindowStateWrite; - }; - const scheduleWindowStateSave = () => { - captureWindowState(); - if (saveWindowStateTimer) { - clearTimeout(saveWindowStateTimer); - } - saveWindowStateTimer = setTimeout(() => { - saveWindowStateTimer = undefined; - void persistWindowState(); - }, 2_000); - }; - const flushWindowState = async () => { - if (saveWindowStateTimer) { - clearTimeout(saveWindowStateTimer); - saveWindowStateTimer = undefined; - } - captureWindowState(); - await persistWindowState(); - }; - window.on("move", scheduleWindowStateSave); - window.on("resize", scheduleWindowStateSave); - window.on("maximize", scheduleWindowStateSave); - window.on("unmaximize", scheduleWindowStateSave); - lockDownWindow(window, new URL(applicationUrl).origin); - - const pdfGenerator = new ElectronPdfGenerator({ - renderUrl, - preloadPath: path.join(currentDirectory, "pdf-preload.cjs") - }); - const validateSender = (event: IpcMainInvokeEvent) => - event.sender === window.webContents && - event.senderFrame !== null && - new URL(event.senderFrame.url).origin === - new URL(applicationUrl).origin; - let documentRoot: string | undefined; - let currentMarkdownPath: string | undefined; - let pendingOpenedMarkdown: - | { - document: OpenedMarkdownDocument; - filePath: string; - } - | undefined; - const queueOpenedMarkdown = async (filePath: string) => { - const opened = await readMarkdownDocument( - filePath, - MAXIMUM_MARKDOWN_LENGTH - ); - pendingOpenedMarkdown = { - document: opened, - filePath - }; - window.webContents.send(DESKTOP_MARKDOWN_OPENED); - }; - - ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面文件请求"); - } - const selection = await dialog.showOpenDialog(window, { - title: "打开 Markdown", - properties: ["openFile"], - filters: [ - { name: "Markdown 文件", extensions: ["md", "markdown"] } - ] - }); - const filePath = selection.filePaths[0]; - if (selection.canceled || !filePath) { - return undefined; - } - const opened = await readMarkdownDocument( - filePath, - MAXIMUM_MARKDOWN_LENGTH - ); - documentRoot = path.dirname(filePath); - currentMarkdownPath = filePath; - return opened; - }); - ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面文件请求"); - } - const pending = pendingOpenedMarkdown; - pendingOpenedMarkdown = undefined; - if (!pending) { - return undefined; - } - currentMarkdownPath = pending.filePath; - documentRoot = path.dirname(pending.filePath); - return pending.document; - }); - ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面文件请求"); - } - pendingOpenedMarkdown = undefined; - }); - ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面文件请求"); - } - currentMarkdownPath = undefined; - documentRoot = undefined; - pendingOpenedMarkdown = undefined; - }); - ipcMain.handle( - DESKTOP_SAVE_MARKDOWN, - async ( - event, - unsafeFileName: unknown, - unsafeMarkdown: unknown - ) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面文件请求"); - } - if ( - typeof unsafeFileName !== "string" || - unsafeFileName.length === 0 || - unsafeFileName.length > 500 || - typeof unsafeMarkdown !== "string" || - unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH - ) { - throw new Error("Markdown 保存参数无效"); - } - - let targetPath = currentMarkdownPath; - if (!targetPath) { - const suggestedName = /\.(?:md|markdown)$/iu.test( - path.basename(unsafeFileName) - ) - ? path.basename(unsafeFileName) - : `${path.basename(unsafeFileName)}.md`; - const selection = await dialog.showSaveDialog(window, { - title: "保存 Markdown", - defaultPath: path.join( - app.getPath("documents"), - suggestedName - ), - filters: [ - { name: "Markdown 文件", extensions: ["md", "markdown"] } - ] - }); - if (selection.canceled || !selection.filePath) { - return undefined; - } - targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath) - ? selection.filePath - : `${selection.filePath}.md`; - } - - await writeFile(targetPath, unsafeMarkdown, "utf8"); - currentMarkdownPath = targetPath; - documentRoot = path.dirname(targetPath); - return { fileName: path.basename(targetPath) }; - } - ); - ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面主题请求"); - } - const themeDirectory = getDesktopThemeDirectory(); - await mkdir(themeDirectory, { recursive: true }); - const errorMessage = await shell.openPath(themeDirectory); - if (errorMessage) { - throw new Error(`无法打开自定义主题目录:${errorMessage}`); - } - return themeDirectory; - }); - ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面主题请求"); - } - applicationService.invalidateThemes(); - }); - ipcMain.handle( - DESKTOP_RENDER_MARKDOWN, - async (event, unsafeRequest: unknown) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面渲染请求"); - } - return applicationService.render( - parseMarkdownRenderRequest(unsafeRequest), - documentRoot ? { localRoot: documentRoot } : {} - ); - } - ); - ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面主题请求"); - } - return applicationService.listThemes(); - }); - ipcMain.handle( - DESKTOP_GET_THEME_CSS, - async (event, unsafeThemeId: unknown) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面主题请求"); - } - const css = await applicationService.getThemeCss( - parseThemeId(unsafeThemeId) - ); - if (css === undefined) { - throw new Error("未找到指定主题"); - } - return css; - } - ); - ipcMain.handle( - DESKTOP_GENERATE_PDF, - async (event, unsafePayload: unknown) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面 PDF 请求"); - } - const payload = parsePagedDocumentPayload(unsafePayload); - const result = await pdfGenerator.generate(payload); - return { - pdf: result.pdf, - pageCount: result.pageCount, - echartsErrors: result.echartsErrors, - mermaidErrors: result.mermaidErrors - }; - } - ); - ipcMain.handle( - DESKTOP_SAVE_PDF, - async ( - event, - unsafeFileName: unknown, - unsafePdf: unknown - ) => { - if (!validateSender(event)) { - throw new Error("拒绝未授权的桌面 PDF 保存请求"); - } - if ( - typeof unsafeFileName !== "string" || - unsafeFileName.length === 0 || - unsafeFileName.length > 500 || - !(unsafePdf instanceof Uint8Array) || - unsafePdf.byteLength === 0 || - unsafePdf.byteLength > 200 * 1024 * 1024 - ) { - throw new Error("PDF 保存参数无效"); - } - - const suggestedName = path.basename(unsafeFileName).endsWith(".pdf") - ? path.basename(unsafeFileName) - : `${path.basename(unsafeFileName)}.pdf`; - const selection = await dialog.showSaveDialog(window, { - title: "导出 PDF", - defaultPath: path.join(app.getPath("documents"), suggestedName), - filters: [{ name: "PDF 文件", extensions: ["pdf"] }] - }); - if (selection.canceled || !selection.filePath) { - return false; - } - await writeFile(selection.filePath, unsafePdf); - return true; - } - ); - - window.once("ready-to-show", () => { - if (savedWindowState?.maximized) { - window.maximize(); - } - window.show(); - }); - window.on("close", () => { - void flushWindowState(); - }); - window.on("closed", () => { - ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN); - ipcMain.removeHandler(DESKTOP_CONSUME_PENDING_MARKDOWN); - ipcMain.removeHandler(DESKTOP_DISCARD_PENDING_MARKDOWN); - ipcMain.removeHandler(DESKTOP_START_NEW_MARKDOWN); - ipcMain.removeHandler(DESKTOP_SAVE_MARKDOWN); - ipcMain.removeHandler(DESKTOP_OPEN_THEME_DIRECTORY); - ipcMain.removeHandler(DESKTOP_REFRESH_THEMES); - ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN); - ipcMain.removeHandler(DESKTOP_LIST_THEMES); - ipcMain.removeHandler(DESKTOP_GET_THEME_CSS); - ipcMain.removeHandler(DESKTOP_GENERATE_PDF); - ipcMain.removeHandler(DESKTOP_SAVE_PDF); - void pdfGenerator.close(); - }); - await window.loadURL(applicationUrl); - return { - window, - openMarkdownFile: queueOpenedMarkdown, - flushWindowState - }; -} - const hasSingleInstanceLock = app.requestSingleInstanceLock(); let windowStateReadyToQuit = false; @@ -631,8 +199,8 @@ if (!hasSingleInstanceLock) { ); if (filePath) { dispatchExternalMarkdown(filePath); - } else { - focusApplicationWindow(); + } else if (!applicationController?.focusMostRecentWindow()) { + void applicationController?.createWindow(); } }); app.on("open-file", (event, filePath) => { @@ -641,6 +209,11 @@ if (!hasSingleInstanceLock) { dispatchExternalMarkdown(filePath); } }); + app.on("activate", () => { + if (!applicationController?.focusMostRecentWindow()) { + void applicationController?.createWindow(); + } + }); app.on("before-quit", (event) => { if (windowStateReadyToQuit || !applicationController) { return; @@ -649,8 +222,14 @@ if (!hasSingleInstanceLock) { void applicationController.flushWindowState().finally(() => { windowStateReadyToQuit = true; app.quit(); + setTimeout(() => { + windowStateReadyToQuit = false; + }, 1_000); }); }); + app.on("will-quit", () => { + void applicationController?.closeResources(); + }); app .whenReady() .then(async () => { @@ -658,18 +237,41 @@ if (!hasSingleInstanceLock) { if (process.platform === "win32") { app.setAppUserModelId("com.md-to-pdf.desktop"); } - await mkdir(getDesktopThemeDirectory(), { recursive: true }); + const themeDirectory = getDesktopThemeDirectory(); + await mkdir(themeDirectory, { recursive: true }); const applicationService = createDesktopApplicationService(); await registerApplicationProtocol(applicationService); - applicationController = - await createApplication(applicationService); + + const developmentUrl = getCommandLineValue("web-url"); + const applicationUrl = + developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`; + applicationController = new DesktopApplicationController({ + applicationService, + applicationUrl, + renderUrl: new URL( + "/preview-frame.html?target=pdf", + applicationUrl + ).href, + preloadPath: path.join( + currentDirectory, + "app-preload.cjs" + ), + pdfPreloadPath: path.join( + currentDirectory, + "pdf-preload.cjs" + ), + iconPath: getApplicationIcon(), + themeDirectory, + windowStatePath: path.join( + app.getPath("userData"), + "window-state.json" + ) + }); + await applicationController.initialize(); + const startupMarkdownPath = pendingExternalMarkdownPath; pendingExternalMarkdownPath = undefined; - if (startupMarkdownPath) { - await applicationController.openMarkdownFile( - startupMarkdownPath - ); - } + await applicationController.createWindow(startupMarkdownPath); }) .catch((error: unknown) => { console.error("桌面应用启动失败", error); diff --git a/apps/desktop/src/markdown-file.ts b/apps/desktop/src/markdown-file.ts index cdd7291..7bbef6a 100644 --- a/apps/desktop/src/markdown-file.ts +++ b/apps/desktop/src/markdown-file.ts @@ -1,4 +1,5 @@ -import { readFile, stat } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; const markdownExtensions = new Set([".md", ".markdown"]); @@ -8,6 +9,24 @@ export interface OpenedMarkdownDocument { fileName: string; } +export interface MarkdownFileSnapshot { + document: OpenedMarkdownDocument; + filePath: string; + documentKey: string; + contentHash: string; +} + +export function createMarkdownContentHash(markdown: string) { + return createHash("sha256").update(markdown, "utf8").digest("hex"); +} + +export function createMarkdownDocumentKey(filePath: string) { + const normalized = path.normalize(filePath); + return process.platform === "win32" + ? normalized.toLocaleLowerCase("en-US") + : normalized; +} + export function isMarkdownFilePath(filePath: string) { return markdownExtensions.has(path.extname(filePath).toLowerCase()); } @@ -58,3 +77,20 @@ export async function readMarkdownDocument( fileName: path.basename(filePath) }; } + +export async function readMarkdownFileSnapshot( + filePath: string, + maximumLength: number +): Promise { + const canonicalPath = await realpath(filePath); + const document = await readMarkdownDocument( + canonicalPath, + maximumLength + ); + return { + document, + filePath: canonicalPath, + documentKey: createMarkdownDocumentKey(canonicalPath), + contentHash: createMarkdownContentHash(document.markdown) + }; +} diff --git a/apps/desktop/tests/desktop-document-link.test.ts b/apps/desktop/tests/desktop-document-link.test.ts new file mode 100644 index 0000000..7b26b31 --- /dev/null +++ b/apps/desktop/tests/desktop-document-link.test.ts @@ -0,0 +1,88 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { resolveDesktopDocumentLinkAction } from "../src/desktop-document-link.js"; + +describe("桌面文档链接动作", () => { + const markdownPath = path.resolve("D:/文档/章节/当前.md"); + + it("保留锚点并把网络和自定义协议交给系统", () => { + expect(resolveDesktopDocumentLinkAction("#标题")).toEqual({ + type: "anchor", + href: "#标题" + }); + expect( + resolveDesktopDocumentLinkAction("//example.com/path") + ).toEqual({ + type: "external", + href: "https://example.com/path" + }); + expect( + resolveDesktopDocumentLinkAction("mailto:user@example.com") + ).toEqual({ + type: "external", + href: "mailto:user@example.com" + }); + expect( + resolveDesktopDocumentLinkAction("obsidian://open?vault=docs") + ).toEqual({ + type: "external", + href: "obsidian://open?vault=docs" + }); + }); + + it("允许相对路径越过 Markdown 目录", () => { + expect( + resolveDesktopDocumentLinkAction( + "../../共享/说明.pdf#page=2", + markdownPath + ) + ).toEqual({ + type: "local", + filePath: path.resolve( + path.dirname(markdownPath), + "../../共享/说明.pdf" + ) + }); + }); + + it("解析绝对路径、file URI 和 URL 编码", () => { + const absolutePath = path.resolve("C:/资料/项目说明.md"); + expect( + resolveDesktopDocumentLinkAction(absolutePath, markdownPath) + ).toEqual({ + type: "local", + filePath: path.normalize(absolutePath) + }); + expect( + resolveDesktopDocumentLinkAction( + pathToFileURL(absolutePath).href, + markdownPath + ) + ).toEqual({ + type: "local", + filePath: path.normalize(absolutePath) + }); + expect( + resolveDesktopDocumentLinkAction( + "../%E5%85%B1%E4%BA%AB/%E8%AF%B4%E6%98%8E.pdf", + markdownPath + ) + ).toEqual({ + type: "local", + filePath: path.resolve( + path.dirname(markdownPath), + "../共享/说明.pdf" + ) + }); + }); + + it("拒绝危险链接和无文件基准的相对路径", () => { + expect( + resolveDesktopDocumentLinkAction("javascript:alert(1)") + ).toEqual({ type: "ignore" }); + expect(() => + resolveDesktopDocumentLinkAction("../说明.pdf") + ).toThrow("尚未保存"); + }); +}); diff --git a/apps/desktop/tests/markdown-file.test.ts b/apps/desktop/tests/markdown-file.test.ts index af4da37..125f923 100644 --- a/apps/desktop/tests/markdown-file.test.ts +++ b/apps/desktop/tests/markdown-file.test.ts @@ -3,9 +3,12 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + createMarkdownContentHash, + createMarkdownDocumentKey, findMarkdownFileArgument, isMarkdownFilePath, - readMarkdownDocument + readMarkdownDocument, + readMarkdownFileSnapshot } from "../src/markdown-file.js"; describe("桌面 Markdown 文件参数", () => { @@ -72,4 +75,33 @@ describe("桌面 Markdown 文件参数", () => { readMarkdownDocument("C:\\docs\\说明.txt", 100) ).rejects.toThrow("只允许打开 .md 或 .markdown 文件"); }); + + it("使用真实路径和内容哈希创建文件快照", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "md-to-pdf-snapshot-") + ); + const filePath = path.join(directory, "快照.md"); + try { + await writeFile(filePath, "# 第一版", "utf8"); + const snapshot = await readMarkdownFileSnapshot(filePath, 100); + + expect(snapshot.document).toEqual({ + markdown: "# 第一版", + fileName: "快照.md" + }); + expect(snapshot.filePath).toBe(filePath); + expect(snapshot.documentKey).toBe( + createMarkdownDocumentKey(filePath) + ); + expect(snapshot.contentHash).toBe( + createMarkdownContentHash("# 第一版") + ); + + await writeFile(filePath, "# 第二版", "utf8"); + const changed = await readMarkdownFileSnapshot(filePath, 100); + expect(changed.contentHash).not.toBe(snapshot.contentHash); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/tests/release-build.test.ts b/apps/desktop/tests/release-build.test.ts new file mode 100644 index 0000000..93e8f06 --- /dev/null +++ b/apps/desktop/tests/release-build.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +interface PackageManifest { + scripts?: Record; +} + +const desktopRoot = fileURLToPath(new URL("../", import.meta.url)); +const projectRoot = fileURLToPath(new URL("../../../", import.meta.url)); + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(path, "utf8")) as PackageManifest; +} + +describe("桌面发行构建链", () => { + it("打包前按依赖顺序完整重建内嵌 Web", () => { + const rootManifest = readManifest(`${projectRoot}/package.json`); + const desktopManifest = readManifest(`${desktopRoot}/package.json`); + const webBuild = rootManifest.scripts?.["build:web-runtime"] ?? ""; + const expectedWorkspaces = [ + "@md-to-pdf/markdown-echarts", + "@md-to-pdf/core", + "@md-to-pdf/renderer", + "@md-to-pdf/application", + "@md-to-pdf/preview-engine", + "@md-to-pdf/web" + ]; + + let previousIndex = -1; + for (const workspace of expectedWorkspaces) { + const workspaceIndex = webBuild.indexOf(workspace); + expect(workspaceIndex).toBeGreaterThan(previousIndex); + previousIndex = workspaceIndex; + } + + expect(desktopManifest.scripts?.["build:embedded-web"]).toBe( + "npm --prefix ../.. run build:web-runtime" + ); + expect(desktopManifest.scripts?.package).toMatch( + /^npm run build:embedded-web && / + ); + expect(desktopManifest.scripts?.make).toMatch( + /^npm run build:embedded-web && / + ); + expect(rootManifest.scripts?.["desktop:package"]).toBe( + "npm run package -w @md-to-pdf/desktop" + ); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index e7367e9..5dbb1bd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,11 +12,9 @@ "dependencies": { "@md-to-pdf/core": "0.1.0", "@md-to-pdf/markdown-echarts": "0.1.0", - "@mermaid-js/layout-elk": "0.2.2", + "@md-to-pdf/preview-engine": "0.1.0", "highlight.js": "^11.11.1", "katex": "^0.16.28", - "mermaid": "11.16.0", - "pagedjs": "^0.4.3", "pdfjs-dist": "6.1.200", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e70802e..92386d5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -27,7 +27,7 @@ import { createPagedPreviewRenderRequest, isPagedPreviewFrameMessage, type PagedPreviewPayload -} from "./paged-preview"; +} from "@md-to-pdf/preview-engine"; import { getNearestPageNumber, getPreviewPageScrollTop @@ -53,6 +53,7 @@ import { downloadMarkdown, ensureMarkdownFileName } from "./document-file"; +import { resolveWebDocumentLinkAction } from "./document-link"; import { type ThemeSummary, useThemeResources @@ -64,7 +65,7 @@ const PrecisePdfPreview = lazy(async () => { return { default: module.PrecisePdfPreview }; }); -type PreviewMode = "quick" | "precise"; +type PreviewMode = "quick" | "precise" | "continuous"; type DocumentKind = "sample" | "new" | "file"; const noImageResources: never[] = []; @@ -121,8 +122,11 @@ export function App() { return; } let active = true; - const consumeOpenedMarkdown = async () => { + const consumeOpenedMarkdown = async ( + reason: "open" | "reload" | "replace" = "open" + ) => { if ( + reason === "open" && documentDirtyRef.current && !window.confirm("当前文档有未保存修改,是否放弃并打开其他文件?") ) { @@ -160,8 +164,8 @@ export function App() { } } }; - const removeListener = bridge.onMarkdownOpened(() => { - void consumeOpenedMarkdown(); + const removeListener = bridge.onMarkdownOpened((reason) => { + void consumeOpenedMarkdown(reason); }); void consumeOpenedMarkdown(); return () => { @@ -170,6 +174,18 @@ export function App() { }; }, []); + useEffect(() => { + const bridge = window.mdToPdfDesktop; + if (!bridge) { + return; + } + void bridge.setDocumentDirty(documentDirty).catch( + (reason: unknown) => { + console.warn("无法同步桌面文档修改状态", reason); + } + ); + }, [documentDirty]); + useEffect(() => { if (!moreMenuOpen) { return; @@ -359,6 +375,11 @@ export function App() { return; } + if (event.data.type === "link") { + handlePagedPreviewLink(event.data.href); + return; + } + if (event.data.requestId !== previewRequestIdRef.current) { return; } @@ -369,7 +390,11 @@ export function App() { setMermaidError(""); setQuickPreviewContentHeight(undefined); setPreviewPagination({ current: 0, total: 0 }); - setStatus("正在分页…"); + setStatus( + event.data.layout === "continuous" + ? "正在更新连续预览…" + : "正在分页…" + ); return; } @@ -389,12 +414,20 @@ export function App() { ? `Mermaid:${event.data.mermaidErrors.join(";")}` : "" ); - setStatus(`预览已分页(${event.data.pageCount} 页)`); + setStatus( + event.data.layout === "continuous" + ? "连续预览已更新" + : `预览已分页(${event.data.pageCount} 页)` + ); setQuickPreviewContentHeight(event.data.contentHeight); - setPreviewPagination({ - current: event.data.pageCount > 0 ? 1 : 0, - total: event.data.pageCount - }); + setPreviewPagination( + event.data.layout === "continuous" + ? { current: 0, total: 0 } + : { + current: event.data.pageCount > 0 ? 1 : 0, + total: event.data.pageCount + } + ); window.requestAnimationFrame(() => { synchronizeScroll("editor"); window.requestAnimationFrame(updatePreviewCurrentPage); @@ -435,11 +468,14 @@ export function App() { frameWindow.postMessage( createPagedPreviewRenderRequest( previewRequestIdRef.current, - previewPayload + previewPayload, + previewMode === "continuous" + ? "continuous" + : "paged" ), window.location.origin ); - }, [previewFrameReady, previewPayload]); + }, [previewFrameReady, previewMode, previewPayload]); function getOuterPreviewScroller() { const previewScroller = previewScrollRef.current; @@ -456,6 +492,79 @@ export function App() { return getOuterPreviewScroller(); } + function handlePagedPreviewLink(href: string) { + const action = resolveWebDocumentLinkAction(href); + if (action.type !== "anchor" && window.mdToPdfDesktop) { + void window.mdToPdfDesktop.openDocumentLink(href).catch( + (reason: unknown) => { + setAppError( + reason instanceof Error + ? reason.message + : "无法打开文档链接" + ); + } + ); + return; + } + if (action.type === "open") { + window.open( + action.href, + "_blank", + "noopener,noreferrer" + ); + return; + } + if (action.type !== "anchor") { + return; + } + + const frame = previewFrameRef.current; + const frameDocument = frame?.contentDocument; + const scrollElement = getPreviewScroller(); + if (!frame || !frameDocument || !scrollElement) { + return; + } + + const rawAnchorId = action.href.slice(1); + const anchorIds = [rawAnchorId]; + try { + const decodedAnchorId = decodeURIComponent(rawAnchorId); + if (decodedAnchorId !== rawAnchorId) { + anchorIds.push(decodedAnchorId); + } + } catch { + // 保留原始片段继续查找,兼容不完整的百分号编码。 + } + const target = anchorIds + .map( + (anchorId) => + frameDocument.getElementById(anchorId) ?? + frameDocument.getElementsByName(anchorId).item(0) + ) + .find((element) => Boolean(element)); + if (!target) { + return; + } + + const scrollerViewportTop = + scrollElement === document.scrollingElement + ? 0 + : scrollElement.getBoundingClientRect().top; + const frameRect = frame.getBoundingClientRect(); + const frameScale = + frame.offsetWidth > 0 + ? frameRect.width / frame.offsetWidth + : 1; + const targetTop = getPreviewPageScrollTop( + scrollElement.scrollTop, + frameRect.top + + target.getBoundingClientRect().top * frameScale, + scrollerViewportTop + ); + scrollElement.scrollTo({ top: Math.max(0, targetTop) }); + window.requestAnimationFrame(updatePreviewCurrentPage); + } + function synchronizeScroll(origin: "editor" | "preview") { const source = origin === "editor" ? editorRef.current : getPreviewScroller(); @@ -487,6 +596,9 @@ export function App() { updatePrecisePreviewCurrentPage(); return; } + if (previewModeRef.current === "continuous") { + return; + } const frame = previewFrameRef.current; const frameDocument = frame?.contentDocument; const scrollElement = getPreviewScroller(); @@ -840,8 +952,12 @@ export function App() { if (mode === previewMode) { return; } + const keepsFrame = + previewMode !== "precise" && mode !== "precise"; setPreviewMode(mode); - setPreviewFrameReady(false); + if (!keepsFrame) { + setPreviewFrameReady(false); + } setQuickPreviewContentHeight(undefined); setPreviewPagination({ current: 0, total: 0 }); previewScrollRef.current?.scrollTo({ top: 0 }); @@ -1041,6 +1157,18 @@ export function App() { > 精确 +