release: 发布 v0.5.1
新增能力:内置 4 套红头、3 套正式文档和 3 套标书主题,支持主题推荐页边距、页面装饰、页眉页脚和页码;增加结构化公文、项目报告及标书 Front Matter,内置 Fandol 中文字体、两份教程和 14 份主题示例。 桌面工作流:重组更多菜单,增加新建、保存、另存为快捷键和未保存确认;另存为后跟随新路径,主题示例自动切换主题,Desktop 发行包完整携带教程、示例与字体。 问题修复:修正红头标题居中与正式文档字体;围栏代码按正文宽度自动换行,长内容安全断行,至少两行即可在当前页分页,并统一保留 8px 左侧内容留白;Docker Web 镜像正确打包 samples。 兼容与部署:未声明主题推荐设置时继续使用 16mm 默认页边距;Web 隐藏不适用的另存为。正式镜像 yixiong/md-to-pdf:v0.5.1 内容 ID 为 sha256:72f519a69bfd6cb2f6df30c2be938e58ceb6f20e4748a32551874de3d436b276,Compose 健康运行。 验证结果:最终修复前 65 个测试文件、286 项测试通过,最终代码块与主题定向测试 27 项通过;全项目类型检查、生产构建和 git diff --check 通过。容器检出 14 套主题、17 份 Markdown,代码块回归 PDF 为 2 页且无越界。NSIS SHA-256 为 676CCE73D782B0B15AC6BC68F253CFBC4740383583E4DAA98F746EDF9999C5CC,ZIP SHA-256 为 82BF6ADF1200604F145CC86FA3ED193955CF6741EBEE3DF8953483515CFF621F。
This commit is contained in:
@@ -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 首次运行可能显示“未知发布者”
|
||||
提示。
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ module.exports = {
|
||||
to: "themes",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: "../../samples",
|
||||
to: "samples",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: windowsIcon,
|
||||
to: "app.ico"
|
||||
|
||||
@@ -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 导出器桌面端",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:;"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:; manifest-src 'self';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
|
||||
+485
-37
@@ -59,6 +59,18 @@ import {
|
||||
useThemeResources
|
||||
} from "./use-theme-resources";
|
||||
import { sampleMarkdown } from "./sample-markdown";
|
||||
import { groupThemesByCategory } from "./theme-groups";
|
||||
import { getBuiltinThemeSample } from "./builtin-theme-samples";
|
||||
import {
|
||||
builtinTutorials,
|
||||
type BuiltinTutorial
|
||||
} from "./builtin-tutorials";
|
||||
import { DocumentActionDialog } from "./DocumentActionDialog";
|
||||
import { getDocumentShortcut } from "./document-shortcuts";
|
||||
import {
|
||||
applyThemeDefaults,
|
||||
selectTheme
|
||||
} from "./theme-margins";
|
||||
|
||||
const PrecisePdfPreview = lazy(async () => {
|
||||
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<DocumentKind>("sample");
|
||||
const [savedMarkdown, setSavedMarkdown] = useState(sampleMarkdown);
|
||||
useState<DocumentKind>(initialDocumentKind);
|
||||
const [savedMarkdown, setSavedMarkdown] = useState(initialMarkdown);
|
||||
const [exportConfig, setExportConfig] =
|
||||
useState<ExportConfig>(loadExportConfig);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [documentDialog, setDocumentDialog] =
|
||||
useState<DocumentDialogState>();
|
||||
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);
|
||||
}}
|
||||
>
|
||||
更多 ▾
|
||||
</button>
|
||||
@@ -1023,20 +1334,129 @@ export function App() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="menu-command"
|
||||
role="menuitem"
|
||||
onClick={() => void handleNewMarkdown()}
|
||||
onClick={requestNewMarkdown}
|
||||
>
|
||||
新建 Markdown
|
||||
<span>新建文件</span>
|
||||
{window.mdToPdfDesktop ? <kbd>Ctrl+N</kbd> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="menu-command"
|
||||
role="menuitem"
|
||||
onClick={() => void handleSaveMarkdown()}
|
||||
>
|
||||
保存 Markdown
|
||||
<span>保存文件</span>
|
||||
<kbd>Ctrl+S</kbd>
|
||||
</button>
|
||||
{window.mdToPdfDesktop ? (
|
||||
<button
|
||||
type="button"
|
||||
className="menu-command"
|
||||
role="menuitem"
|
||||
onClick={() => void handleSaveMarkdownAs()}
|
||||
>
|
||||
<span>另存为文件</span>
|
||||
<kbd>Ctrl+Shift+S</kbd>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="menu-separator" role="separator" />
|
||||
<div
|
||||
className="more-menu-submenu-item"
|
||||
onPointerEnter={() =>
|
||||
setMoreSubmenuOpen("tutorials")
|
||||
}
|
||||
onPointerLeave={() => setMoreSubmenuOpen(undefined)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="submenu-trigger"
|
||||
role="menuitem"
|
||||
aria-controls="tutorials-menu"
|
||||
aria-expanded={moreSubmenuOpen === "tutorials"}
|
||||
aria-haspopup="menu"
|
||||
onFocus={() => setMoreSubmenuOpen("tutorials")}
|
||||
onClick={() => setMoreSubmenuOpen("tutorials")}
|
||||
>
|
||||
<span>使用教程</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
{moreSubmenuOpen === "tutorials" ? (
|
||||
<div
|
||||
id="tutorials-menu"
|
||||
className="more-menu-submenu"
|
||||
role="menu"
|
||||
aria-label="使用教程"
|
||||
>
|
||||
{builtinTutorials.map((tutorial) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
key={tutorial.id}
|
||||
onClick={() =>
|
||||
void handleOpenTutorial(tutorial)
|
||||
}
|
||||
>
|
||||
{tutorial.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="more-menu-submenu-item"
|
||||
onPointerEnter={() => setMoreSubmenuOpen("samples")}
|
||||
onPointerLeave={() => setMoreSubmenuOpen(undefined)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="submenu-trigger"
|
||||
role="menuitem"
|
||||
aria-controls="theme-samples-menu"
|
||||
aria-expanded={moreSubmenuOpen === "samples"}
|
||||
aria-haspopup="menu"
|
||||
onFocus={() => setMoreSubmenuOpen("samples")}
|
||||
onClick={() => setMoreSubmenuOpen("samples")}
|
||||
>
|
||||
<span>主题示例</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
{moreSubmenuOpen === "samples" ? (
|
||||
<div
|
||||
id="theme-samples-menu"
|
||||
className="more-menu-submenu"
|
||||
role="menu"
|
||||
aria-label="主题示例"
|
||||
>
|
||||
{sampleThemeGroups.map((group) => (
|
||||
<section
|
||||
className="sample-menu-group"
|
||||
key={group.category}
|
||||
>
|
||||
<span className="sample-menu-group-label">
|
||||
{group.label}
|
||||
</span>
|
||||
{group.themes.map((theme) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
key={theme.id}
|
||||
onClick={() =>
|
||||
void handleOpenThemeSample(theme)
|
||||
}
|
||||
>
|
||||
{theme.name}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{window.mdToPdfDesktop ? (
|
||||
<>
|
||||
<div className="menu-separator" role="separator" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -1175,20 +1595,31 @@ export function App() {
|
||||
<select
|
||||
aria-label="预览主题"
|
||||
value={themeId}
|
||||
onChange={(event) =>
|
||||
setExportConfig((currentConfig) => ({
|
||||
...currentConfig,
|
||||
themeId: event.target.value
|
||||
}))
|
||||
}
|
||||
onChange={(event) => {
|
||||
const nextTheme = themes.find(
|
||||
(theme) => theme.id === event.target.value
|
||||
);
|
||||
if (nextTheme) {
|
||||
setExportConfig((currentConfig) =>
|
||||
selectTheme(currentConfig, nextTheme)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{themes.length === 0 ? (
|
||||
<option value={themeId}>正在加载主题…</option>
|
||||
) : (
|
||||
themes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
themeGroups.map((group) => (
|
||||
<optgroup
|
||||
key={group.category}
|
||||
label={group.label}
|
||||
>
|
||||
{group.themes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
@@ -1280,11 +1711,28 @@ export function App() {
|
||||
{settingsOpen ? (
|
||||
<ExportSettingsDrawer
|
||||
config={exportConfig}
|
||||
theme={selectedTheme}
|
||||
onChange={setExportConfig}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onReset={() => setExportConfig(resetExportConfig())}
|
||||
/>
|
||||
) : null}
|
||||
{documentDialog?.type === "new-location" ? (
|
||||
<DocumentActionDialog
|
||||
type="new-location"
|
||||
onCurrentWindow={requestCurrentWindowNew}
|
||||
onNewWindow={() => void createMarkdownInNewWindow()}
|
||||
onCancel={cancelDocumentDialog}
|
||||
/>
|
||||
) : documentDialog?.type === "unsaved" ? (
|
||||
<DocumentActionDialog
|
||||
type="unsaved"
|
||||
busy={documentActionBusy}
|
||||
onSave={() => void saveBeforePendingDocumentAction()}
|
||||
onDiscard={discardPendingDocumentChanges}
|
||||
onCancel={cancelDocumentDialog}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
type DocumentActionDialogProps =
|
||||
| {
|
||||
type: "new-location";
|
||||
busy?: false;
|
||||
onCurrentWindow: () => void;
|
||||
onNewWindow: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
| {
|
||||
type: "unsaved";
|
||||
busy: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function DocumentActionDialog(
|
||||
props: DocumentActionDialogProps
|
||||
) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !props.busy) {
|
||||
event.preventDefault();
|
||||
props.onCancel();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [props]);
|
||||
|
||||
const isNewLocation = props.type === "new-location";
|
||||
return (
|
||||
<div className="document-dialog-layer">
|
||||
<div className="document-dialog-backdrop" />
|
||||
<section
|
||||
className="document-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="document-dialog-title"
|
||||
aria-describedby="document-dialog-description"
|
||||
>
|
||||
<span className="panel-kicker">文档操作</span>
|
||||
<h2 id="document-dialog-title">
|
||||
{isNewLocation ? "新建 Markdown" : "保存当前修改?"}
|
||||
</h2>
|
||||
<p id="document-dialog-description">
|
||||
{isNewLocation
|
||||
? "请选择在当前窗口新建,或者保留当前文档并打开一个新窗口。"
|
||||
: "当前文档包含尚未保存的修改。继续操作前,可以先保存这些内容。"}
|
||||
</p>
|
||||
<div className="document-dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.busy}
|
||||
onClick={props.onCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{isNewLocation ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onNewWindow}
|
||||
>
|
||||
新窗口新建
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="is-primary"
|
||||
autoFocus
|
||||
onClick={props.onCurrentWindow}
|
||||
>
|
||||
当前窗口新建
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.busy}
|
||||
onClick={props.onDiscard}
|
||||
>
|
||||
不保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="is-primary"
|
||||
disabled={props.busy}
|
||||
autoFocus
|
||||
onClick={props.onSave}
|
||||
>
|
||||
{props.busy ? "正在保存…" : "保存"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<FooterConfig["format"], string> = {
|
||||
"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 (
|
||||
<label className="setting-field stacked">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={300}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
placeholder='"Segoe UI", "Microsoft YaHei", sans-serif'
|
||||
onBlur={commit}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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<HeaderConfig>) {
|
||||
onChange({
|
||||
...config,
|
||||
pageDecorationsMode: "custom",
|
||||
header: {
|
||||
...config.header,
|
||||
...header
|
||||
@@ -279,6 +357,7 @@ export function ExportSettingsDrawer({
|
||||
function updateFooter(footer: Partial<FooterConfig>) {
|
||||
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<MermaidExportConfig>) {
|
||||
onChange({
|
||||
...config,
|
||||
@@ -374,6 +466,34 @@ export function ExportSettingsDrawer({
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>页边距</h3>
|
||||
<fieldset className="segmented-control margin-mode-control">
|
||||
<legend>页边距来源</legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="margin-mode"
|
||||
checked={config.paper.marginMode === "theme"}
|
||||
onChange={followThemeMargins}
|
||||
/>
|
||||
跟随主题
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="margin-mode"
|
||||
checked={config.paper.marginMode === "custom"}
|
||||
onChange={() => updatePaper({ marginMode: "custom" })}
|
||||
/>
|
||||
自定义
|
||||
</label>
|
||||
</fieldset>
|
||||
<p className="setting-hint margin-source-hint">
|
||||
{config.paper.marginMode === "theme"
|
||||
? theme?.pageDefaults
|
||||
? `当前采用“${theme.name}”推荐值`
|
||||
: "当前主题未设置推荐值,采用全局默认 16mm"
|
||||
: "当前采用自定义值;修改任意页边距会自动进入此模式"}
|
||||
</p>
|
||||
<div className="margin-grid">
|
||||
<LengthInput
|
||||
label="上"
|
||||
@@ -416,6 +536,15 @@ export function ExportSettingsDrawer({
|
||||
onChange={(value) => updateMargin("left", value)}
|
||||
/>
|
||||
</div>
|
||||
{config.paper.marginMode === "custom" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action margin-reset-action"
|
||||
onClick={followThemeMargins}
|
||||
>
|
||||
恢复主题推荐值
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>页眉与页码来源</h3>
|
||||
<fieldset className="segmented-control margin-mode-control">
|
||||
<legend>页面装饰来源</legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="page-decorations-mode"
|
||||
checked={config.pageDecorationsMode === "theme"}
|
||||
onChange={followThemePageDecorations}
|
||||
/>
|
||||
跟随主题
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="page-decorations-mode"
|
||||
checked={config.pageDecorationsMode === "custom"}
|
||||
onChange={() =>
|
||||
onChange({
|
||||
...config,
|
||||
pageDecorationsMode: "custom"
|
||||
})
|
||||
}
|
||||
/>
|
||||
自定义
|
||||
</label>
|
||||
</fieldset>
|
||||
<p className="setting-hint margin-source-hint">
|
||||
{config.pageDecorationsMode === "theme"
|
||||
? theme?.pageDefaults?.header ||
|
||||
theme?.pageDefaults?.footer
|
||||
? `当前采用“${theme.name}”推荐的页眉与页码`
|
||||
: "当前主题未设置推荐值,采用全局默认配置"
|
||||
: "当前采用自定义配置;修改页眉或页码会自动进入此模式"}
|
||||
</p>
|
||||
{config.pageDecorationsMode === "custom" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action margin-reset-action"
|
||||
onClick={followThemePageDecorations}
|
||||
>
|
||||
恢复主题推荐值
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="setting-section-title">
|
||||
<h3>页眉</h3>
|
||||
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
|
||||
<p className="setting-hint">
|
||||
支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。
|
||||
</p>
|
||||
<PageDecorationFontInput
|
||||
label="页眉字体"
|
||||
value={config.header.fontFamily}
|
||||
disabled={!config.header.enabled}
|
||||
onChange={(fontFamily) => updateHeader({ fontFamily })}
|
||||
/>
|
||||
<LengthInput
|
||||
label="页眉字号"
|
||||
value={config.header.fontSize}
|
||||
maximum={20}
|
||||
disabled={!config.header.enabled}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
onChange={(fontSize) => updateHeader({ fontSize })}
|
||||
/>
|
||||
{(["left", "center", "right"] as const).map((slot) => {
|
||||
const labels = { left: "左侧", center: "中间", right: "右侧" };
|
||||
return (
|
||||
@@ -567,6 +758,21 @@ export function ExportSettingsDrawer({
|
||||
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
|
||||
</label>
|
||||
</div>
|
||||
<PageDecorationFontInput
|
||||
label="页码字体"
|
||||
value={config.footer.fontFamily}
|
||||
disabled={!config.footer.enabled}
|
||||
onChange={(fontFamily) => updateFooter({ fontFamily })}
|
||||
/>
|
||||
<LengthInput
|
||||
label="页码字号"
|
||||
value={config.footer.fontSize}
|
||||
maximum={20}
|
||||
disabled={!config.footer.enabled}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
onChange={(fontSize) => updateFooter({ fontSize })}
|
||||
/>
|
||||
<label className="setting-field">
|
||||
<span>样式</span>
|
||||
<select
|
||||
@@ -616,6 +822,7 @@ export function ExportSettingsDrawer({
|
||||
<option value="left">左侧</option>
|
||||
<option value="center">居中</option>
|
||||
<option value="right">右侧</option>
|
||||
<option value="outer">外侧(奇数页右、偶数页左)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="setting-field">
|
||||
@@ -636,6 +843,19 @@ export function ExportSettingsDrawer({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="switch-control divider-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.footer.showOnFirstPage}
|
||||
disabled={!config.footer.enabled}
|
||||
onChange={(event) =>
|
||||
updateFooter({
|
||||
showOnFirstPage: event.target.checked
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>首页显示页码</span>
|
||||
</label>
|
||||
<label className="switch-control divider-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||
import type {
|
||||
DocumentProfileName,
|
||||
FooterConfig,
|
||||
HeaderConfig,
|
||||
PageMargins,
|
||||
RenderedMarkdownDocument,
|
||||
ThemeCategory
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface ThemeSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
category: ThemeCategory;
|
||||
compatibleProfiles: DocumentProfileName[];
|
||||
pageDefaults?: {
|
||||
margins: PageMargins;
|
||||
header?: HeaderConfig;
|
||||
footer?: FooterConfig;
|
||||
};
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const sampleModules = import.meta.glob<string>(
|
||||
"../../../samples/themes/*.md",
|
||||
{
|
||||
eager: true,
|
||||
query: "?raw",
|
||||
import: "default"
|
||||
}
|
||||
);
|
||||
|
||||
export interface BuiltinThemeSample {
|
||||
themeId: string;
|
||||
fileName: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
function getFileName(modulePath: string) {
|
||||
return modulePath.split("/").at(-1) ?? "";
|
||||
}
|
||||
|
||||
export const builtinThemeSamples: BuiltinThemeSample[] = Object.entries(
|
||||
sampleModules
|
||||
)
|
||||
.map(([modulePath, markdown]) => {
|
||||
const fileName = getFileName(modulePath);
|
||||
return {
|
||||
themeId: fileName.replace(/\.md$/i, ""),
|
||||
fileName,
|
||||
markdown
|
||||
};
|
||||
})
|
||||
.sort((first, second) =>
|
||||
first.themeId.localeCompare(second.themeId, "en")
|
||||
);
|
||||
|
||||
const builtinThemeSampleMap = new Map(
|
||||
builtinThemeSamples.map((sample) => [sample.themeId, sample])
|
||||
);
|
||||
|
||||
export function getBuiltinThemeSample(themeId: string) {
|
||||
return builtinThemeSampleMap.get(themeId);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const tutorialModules = import.meta.glob<string>(
|
||||
"../../../samples/tutorials/*.md",
|
||||
{
|
||||
eager: true,
|
||||
query: "?raw",
|
||||
import: "default"
|
||||
}
|
||||
);
|
||||
|
||||
export interface BuiltinTutorial {
|
||||
id: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
themeId: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
const tutorialDefinitions = [
|
||||
{
|
||||
id: "echarts-tutorial",
|
||||
name: "ECharts 图表教程",
|
||||
themeId: "typora-github"
|
||||
},
|
||||
{
|
||||
id: "formal-document-tutorial",
|
||||
name: "公文主题使用教程",
|
||||
themeId: "typora-github"
|
||||
}
|
||||
] as const;
|
||||
|
||||
const tutorialMarkdownById = new Map(
|
||||
Object.entries(tutorialModules).map(([modulePath, markdown]) => {
|
||||
const fileName = modulePath.split("/").at(-1) ?? "";
|
||||
return [fileName.replace(/\.md$/i, ""), markdown];
|
||||
})
|
||||
);
|
||||
|
||||
export const builtinTutorials: BuiltinTutorial[] =
|
||||
tutorialDefinitions.map((definition) => {
|
||||
const markdown = tutorialMarkdownById.get(definition.id);
|
||||
if (!markdown) {
|
||||
throw new Error(`缺少内置教程:${definition.id}.md`);
|
||||
}
|
||||
return {
|
||||
...definition,
|
||||
fileName: `${definition.id}.md`,
|
||||
markdown
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export type DocumentShortcut =
|
||||
| "new"
|
||||
| "save"
|
||||
| "save-as"
|
||||
| "close";
|
||||
|
||||
export interface DocumentShortcutEvent {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
isComposing: boolean;
|
||||
repeat: boolean;
|
||||
}
|
||||
|
||||
export function getDocumentShortcut(
|
||||
event: DocumentShortcutEvent,
|
||||
desktop: boolean
|
||||
): DocumentShortcut | undefined {
|
||||
if (
|
||||
event.isComposing ||
|
||||
event.repeat ||
|
||||
event.altKey ||
|
||||
(!event.ctrlKey && !event.metaKey)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (event.shiftKey) {
|
||||
return desktop && key === "s" ? "save-as" : undefined;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "s":
|
||||
return "save";
|
||||
case "n":
|
||||
return desktop ? "new" : undefined;
|
||||
case "w":
|
||||
return desktop ? "close" : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+121
-14
@@ -4,8 +4,10 @@ import {
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
|
||||
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
|
||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
|
||||
export const LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY =
|
||||
"md-to-pdf.export-config.v3";
|
||||
export const LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY =
|
||||
"md-to-pdf.export-config.v2";
|
||||
|
||||
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
@@ -14,6 +16,119 @@ function cloneDefaultExportConfig() {
|
||||
return structuredClone(defaultExportConfig);
|
||||
}
|
||||
|
||||
function pickLegacyPageDecorations(value: unknown) {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return value;
|
||||
}
|
||||
const source = value as Record<string, unknown>;
|
||||
if ("left" in source) {
|
||||
return {
|
||||
enabled: source.enabled,
|
||||
height: source.height,
|
||||
showDivider: source.showDivider,
|
||||
fontSize: source.fontSize,
|
||||
color: source.color,
|
||||
left: source.left,
|
||||
center: source.center,
|
||||
right: source.right
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: source.enabled,
|
||||
height: source.height,
|
||||
showDivider: source.showDivider,
|
||||
fontSize: source.fontSize,
|
||||
color: source.color,
|
||||
alignment: source.alignment,
|
||||
format: source.format,
|
||||
template: source.template,
|
||||
startFrom: source.startFrom
|
||||
};
|
||||
}
|
||||
|
||||
function usesLegacyPageDecorationDefaults(
|
||||
header: unknown,
|
||||
footer: unknown
|
||||
) {
|
||||
return (
|
||||
JSON.stringify(pickLegacyPageDecorations(header)) ===
|
||||
JSON.stringify(
|
||||
pickLegacyPageDecorations(defaultExportConfig.header)
|
||||
) &&
|
||||
JSON.stringify(pickLegacyPageDecorations(footer)) ===
|
||||
JSON.stringify(
|
||||
pickLegacyPageDecorations(defaultExportConfig.footer)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function migrateStoredExportConfig(value: unknown): unknown {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("version" in value) ||
|
||||
!("paper" in value)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (
|
||||
value.version !== 2 &&
|
||||
value.version !== 3 &&
|
||||
value.version !== defaultExportConfig.version
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>;
|
||||
const hasPageDecorationsMode =
|
||||
typeof source.pageDecorationsMode === "string";
|
||||
|
||||
const paper =
|
||||
typeof value.paper === "object" && value.paper !== null
|
||||
? value.paper
|
||||
: {};
|
||||
const margins =
|
||||
"margins" in paper &&
|
||||
typeof paper.margins === "object" &&
|
||||
paper.margins !== null
|
||||
? paper.margins
|
||||
: {};
|
||||
const usesLegacyDefaults =
|
||||
JSON.stringify(margins) ===
|
||||
JSON.stringify(defaultExportConfig.paper.margins);
|
||||
const hasMarginMode =
|
||||
typeof (paper as Record<string, unknown>).marginMode === "string";
|
||||
|
||||
return {
|
||||
...value,
|
||||
...(value.version === 2 || value.version === 3
|
||||
? { version: defaultExportConfig.version }
|
||||
: {}),
|
||||
...(value.version === 2
|
||||
? { mermaid: structuredClone(defaultExportConfig.mermaid) }
|
||||
: {}),
|
||||
...(!hasPageDecorationsMode
|
||||
? {
|
||||
pageDecorationsMode: usesLegacyPageDecorationDefaults(
|
||||
source.header,
|
||||
source.footer
|
||||
)
|
||||
? "theme"
|
||||
: "custom"
|
||||
}
|
||||
: {}),
|
||||
paper: {
|
||||
...paper,
|
||||
...(!hasMarginMode
|
||||
? {
|
||||
marginMode: usesLegacyDefaults ? "theme" : "custom"
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||
if (!rawValue) {
|
||||
return cloneDefaultExportConfig();
|
||||
@@ -21,17 +136,7 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||
|
||||
try {
|
||||
const value: unknown = JSON.parse(rawValue);
|
||||
const migratedValue =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"version" in value &&
|
||||
value.version === 2
|
||||
? {
|
||||
...value,
|
||||
version: defaultExportConfig.version,
|
||||
mermaid: structuredClone(defaultExportConfig.mermaid)
|
||||
}
|
||||
: value;
|
||||
const migratedValue = migrateStoredExportConfig(value);
|
||||
const parsed = exportConfigSchema.safeParse(migratedValue);
|
||||
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
||||
} catch {
|
||||
@@ -47,7 +152,9 @@ export function loadExportConfig(
|
||||
return parseStoredExportConfig(currentValue);
|
||||
}
|
||||
|
||||
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY);
|
||||
const legacyValue =
|
||||
storage.getItem(LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY) ??
|
||||
storage.getItem(LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY);
|
||||
const config = parseStoredExportConfig(legacyValue);
|
||||
if (legacyValue) {
|
||||
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||
|
||||
@@ -188,6 +188,72 @@ h1 {
|
||||
background: #edf3f0;
|
||||
}
|
||||
|
||||
.more-menu-popover .menu-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.menu-command kbd {
|
||||
color: #78857e;
|
||||
font-family: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.more-menu-submenu-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-separator {
|
||||
height: 1px;
|
||||
margin: 5px 7px;
|
||||
background: #e0e6e3;
|
||||
}
|
||||
|
||||
.submenu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.more-menu-submenu {
|
||||
position: absolute;
|
||||
z-index: 31;
|
||||
top: -7px;
|
||||
right: calc(100% + 8px);
|
||||
width: 250px;
|
||||
max-height: min(70vh, 560px);
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #c8d1cc;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
|
||||
}
|
||||
|
||||
.sample-menu-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sample-menu-group + .sample-menu-group {
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid #e3e8e5;
|
||||
}
|
||||
|
||||
.sample-menu-group-label {
|
||||
padding: 6px 11px 3px;
|
||||
color: #718078;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
||||
@@ -749,6 +815,59 @@ textarea:focus {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.document-dialog-layer {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.document-dialog-backdrop {
|
||||
position: absolute;
|
||||
background: rgb(24 35 30 / 46%);
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.document-dialog {
|
||||
position: relative;
|
||||
width: min(480px, 100%);
|
||||
padding: 24px;
|
||||
border: 1px solid #c9d2cd;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 70px rgb(20 34 28 / 28%);
|
||||
}
|
||||
|
||||
.document-dialog h2 {
|
||||
margin: 4px 0 10px;
|
||||
color: #25332d;
|
||||
font-family: Georgia, "Songti SC", serif;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.document-dialog p {
|
||||
margin: 0;
|
||||
color: #5d6a64;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.document-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.document-dialog-actions .is-primary {
|
||||
border-color: #326b54;
|
||||
background: #326b54;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-backdrop {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -927,6 +1046,19 @@ textarea:focus {
|
||||
gap: 8px 18px;
|
||||
}
|
||||
|
||||
.margin-mode-control {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.margin-source-hint {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.margin-reset-action {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.margin-grid .setting-field {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
@@ -1133,4 +1265,31 @@ textarea:focus {
|
||||
.settings-drawer {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.more-menu-popover {
|
||||
max-height: min(72vh, 620px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.more-menu-submenu {
|
||||
position: static;
|
||||
width: auto;
|
||||
max-height: 48vh;
|
||||
margin: 4px 0 0 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.document-dialog-layer {
|
||||
align-items: end;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.document-dialog {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.document-dialog-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ThemeCategory } from "@md-to-pdf/core";
|
||||
import type { ThemeSummary } from "./application-backend";
|
||||
|
||||
const themeCategoryOrder: readonly ThemeCategory[] = [
|
||||
"general",
|
||||
"red-letter",
|
||||
"formal",
|
||||
"tender"
|
||||
];
|
||||
|
||||
const themeCategoryLabels: Record<ThemeCategory, string> = {
|
||||
general: "通用主题",
|
||||
"red-letter": "政企红头",
|
||||
formal: "政企正式",
|
||||
tender: "标书正式"
|
||||
};
|
||||
|
||||
export interface ThemeGroup {
|
||||
category: ThemeCategory;
|
||||
label: string;
|
||||
themes: ThemeSummary[];
|
||||
}
|
||||
|
||||
export function groupThemesByCategory(
|
||||
themes: readonly ThemeSummary[]
|
||||
): ThemeGroup[] {
|
||||
return themeCategoryOrder
|
||||
.map((category) => ({
|
||||
category,
|
||||
label: themeCategoryLabels[category],
|
||||
themes: themes.filter((theme) => theme.category === category)
|
||||
}))
|
||||
.filter((group) => group.themes.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
defaultExportConfig,
|
||||
resolvePageMargins,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import type { ThemeSummary } from "./application-backend";
|
||||
|
||||
function marginsEqual(
|
||||
first: ExportConfig["paper"]["margins"],
|
||||
second: ExportConfig["paper"]["margins"]
|
||||
) {
|
||||
return (
|
||||
first.top === second.top &&
|
||||
first.right === second.right &&
|
||||
first.bottom === second.bottom &&
|
||||
first.left === second.left
|
||||
);
|
||||
}
|
||||
|
||||
function applyThemePageDecorations(
|
||||
config: ExportConfig,
|
||||
theme?: ThemeSummary
|
||||
): ExportConfig {
|
||||
if (config.pageDecorationsMode !== "theme") {
|
||||
return config;
|
||||
}
|
||||
|
||||
const header =
|
||||
theme?.pageDefaults?.header ?? defaultExportConfig.header;
|
||||
const footer =
|
||||
theme?.pageDefaults?.footer ?? defaultExportConfig.footer;
|
||||
if (
|
||||
JSON.stringify(config.header) === JSON.stringify(header) &&
|
||||
JSON.stringify(config.footer) === JSON.stringify(footer)
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
header: structuredClone(header),
|
||||
footer: structuredClone(footer)
|
||||
};
|
||||
}
|
||||
|
||||
export function applyThemeMargins(
|
||||
config: ExportConfig,
|
||||
theme?: ThemeSummary
|
||||
): ExportConfig {
|
||||
if (config.paper.marginMode !== "theme") {
|
||||
return config;
|
||||
}
|
||||
|
||||
const margins = resolvePageMargins(
|
||||
config.paper,
|
||||
theme?.pageDefaults?.margins
|
||||
);
|
||||
if (marginsEqual(config.paper.margins, margins)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
paper: {
|
||||
...config.paper,
|
||||
margins
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function applyThemeDefaults(
|
||||
config: ExportConfig,
|
||||
theme?: ThemeSummary
|
||||
): ExportConfig {
|
||||
return applyThemePageDecorations(
|
||||
applyThemeMargins(config, theme),
|
||||
theme
|
||||
);
|
||||
}
|
||||
|
||||
export function selectTheme(
|
||||
config: ExportConfig,
|
||||
theme: ThemeSummary
|
||||
): ExportConfig {
|
||||
return applyThemeDefaults(
|
||||
{
|
||||
...config,
|
||||
themeId: theme.id
|
||||
},
|
||||
theme
|
||||
);
|
||||
}
|
||||
Vendored
+12
-1
@@ -3,7 +3,9 @@
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult,
|
||||
RenderedMarkdownDocument
|
||||
RenderedMarkdownDocument,
|
||||
ThemeCategory,
|
||||
DocumentProfileName
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
declare global {
|
||||
@@ -36,12 +38,19 @@ declare global {
|
||||
listener: (reason: "open" | "reload" | "replace") => void
|
||||
): () => void;
|
||||
startNewMarkdown(): Promise<void>;
|
||||
createNewWindow(): Promise<void>;
|
||||
resolveWindowClose(close: boolean): Promise<void>;
|
||||
onWindowCloseRequested(listener: () => void): () => void;
|
||||
setDocumentDirty(dirty: boolean): Promise<void>;
|
||||
openDocumentLink(href: string): Promise<void>;
|
||||
saveMarkdown(
|
||||
fileName: string,
|
||||
markdown: string
|
||||
): Promise<{ fileName: string } | undefined>;
|
||||
saveMarkdownAs(
|
||||
fileName: string,
|
||||
markdown: string
|
||||
): Promise<{ fileName: string } | undefined>;
|
||||
openThemeDirectory(): Promise<string>;
|
||||
refreshThemes(): Promise<void>;
|
||||
renderMarkdown(input: {
|
||||
@@ -58,6 +67,8 @@ declare global {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
category: ThemeCategory;
|
||||
compatibleProfiles: DocumentProfileName[];
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}>;
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
APP_VERSION,
|
||||
APP_VERSION_LABEL
|
||||
} from "../src/app-version";
|
||||
|
||||
const webRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
|
||||
describe("应用版本", () => {
|
||||
it("从统一构建版本生成标题徽标", () => {
|
||||
expect(APP_VERSION).toBe("0.5.0");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.5.0");
|
||||
expect(APP_VERSION).toBe("0.5.1");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.5.1");
|
||||
});
|
||||
|
||||
it("允许浏览器加载同源 Web Manifest", () => {
|
||||
const indexHtml = readFileSync(`${webRoot}/index.html`, "utf8");
|
||||
|
||||
expect(indexHtml).toContain('rel="manifest"');
|
||||
expect(indexHtml).toContain("manifest-src 'self'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
builtinThemeSamples,
|
||||
getBuiltinThemeSample
|
||||
} from "../src/builtin-theme-samples";
|
||||
|
||||
const expectedThemeIds = [
|
||||
"enterprise-red-simple",
|
||||
"formal-feasibility",
|
||||
"formal-regulation",
|
||||
"formal-report",
|
||||
"gov-red-letter",
|
||||
"gov-red-standard",
|
||||
"red-briefing",
|
||||
"tender-blind",
|
||||
"tender-business-blue",
|
||||
"tender-classic",
|
||||
"typora-github",
|
||||
"typora-like",
|
||||
"typora-pixyll",
|
||||
"typora-whitey"
|
||||
];
|
||||
|
||||
describe("内置主题示例", () => {
|
||||
it("为全部内置主题注册同名 Markdown 文件", () => {
|
||||
expect(
|
||||
builtinThemeSamples.map((sample) => sample.themeId).sort()
|
||||
).toEqual(expectedThemeIds);
|
||||
expect(
|
||||
builtinThemeSamples.every(
|
||||
(sample) => sample.fileName === `${sample.themeId}.md`
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("提供可直接编辑的非空 Markdown 内容", () => {
|
||||
for (const sample of builtinThemeSamples) {
|
||||
expect(sample.markdown.startsWith("---")).toBe(true);
|
||||
expect(sample.markdown.length).toBeGreaterThan(300);
|
||||
expect(getBuiltinThemeSample(sample.themeId)).toBe(sample);
|
||||
}
|
||||
expect(getBuiltinThemeSample("unknown-theme")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { builtinTutorials } from "../src/builtin-tutorials";
|
||||
|
||||
describe("内置教程", () => {
|
||||
it("注册 ECharts 与公文主题教程", () => {
|
||||
expect(
|
||||
builtinTutorials.map(({ id, name, themeId, fileName }) => ({
|
||||
id,
|
||||
name,
|
||||
themeId,
|
||||
fileName
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
id: "echarts-tutorial",
|
||||
name: "ECharts 图表教程",
|
||||
themeId: "typora-github",
|
||||
fileName: "echarts-tutorial.md"
|
||||
},
|
||||
{
|
||||
id: "formal-document-tutorial",
|
||||
name: "公文主题使用教程",
|
||||
themeId: "typora-github",
|
||||
fileName: "formal-document-tutorial.md"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("教程内容完整且能够直接作为 Markdown 打开", () => {
|
||||
for (const tutorial of builtinTutorials) {
|
||||
expect(tutorial.markdown.startsWith("---")).toBe(true);
|
||||
expect(tutorial.markdown.length).toBeGreaterThan(1_000);
|
||||
}
|
||||
expect(builtinTutorials[0].markdown).toContain("```echarts");
|
||||
expect(builtinTutorials[1].markdown).toContain(
|
||||
"document.profile: official"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDocumentShortcut } from "../src/document-shortcuts";
|
||||
|
||||
function shortcutEvent(
|
||||
key: string,
|
||||
overrides: Partial<Parameters<typeof getDocumentShortcut>[0]> = {}
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
repeat: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("文档快捷键", () => {
|
||||
it("在桌面端识别新建、保存、另存为和关闭", () => {
|
||||
expect(getDocumentShortcut(shortcutEvent("n"), true)).toBe("new");
|
||||
expect(getDocumentShortcut(shortcutEvent("S"), true)).toBe("save");
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("S", { shiftKey: true }),
|
||||
true
|
||||
)
|
||||
).toBe("save-as");
|
||||
expect(getDocumentShortcut(shortcutEvent("w"), true)).toBe("close");
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("s", { ctrlKey: false, metaKey: true }),
|
||||
true
|
||||
)
|
||||
).toBe("save");
|
||||
});
|
||||
|
||||
it("Web 端只接管保存", () => {
|
||||
expect(getDocumentShortcut(shortcutEvent("n"), false)).toBeUndefined();
|
||||
expect(getDocumentShortcut(shortcutEvent("w"), false)).toBeUndefined();
|
||||
expect(getDocumentShortcut(shortcutEvent("s"), false)).toBe("save");
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("s", { shiftKey: true }),
|
||||
false
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("忽略组合输入、长按和带附加修饰键的按键", () => {
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("s", { isComposing: true }),
|
||||
true
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getDocumentShortcut(shortcutEvent("s", { repeat: true }), true)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getDocumentShortcut(shortcutEvent("s", { altKey: true }), true)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("n", { shiftKey: true }),
|
||||
true
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("s", { shiftKey: true, altKey: true }),
|
||||
true
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
EXPORT_CONFIG_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY,
|
||||
loadExportConfig,
|
||||
parseStoredExportConfig,
|
||||
saveExportConfig
|
||||
@@ -10,14 +11,15 @@ import {
|
||||
|
||||
function createMemoryStorage(
|
||||
initialValue: string | null = null,
|
||||
legacyValue: string | null = null
|
||||
legacyValue: string | null = null,
|
||||
legacyKey = LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY
|
||||
) {
|
||||
const values = new Map<string, string>();
|
||||
if (initialValue) {
|
||||
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
|
||||
}
|
||||
if (legacyValue) {
|
||||
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue);
|
||||
values.set(legacyKey, legacyValue);
|
||||
}
|
||||
return {
|
||||
getItem(key: string) {
|
||||
@@ -52,26 +54,106 @@ describe("导出设置缓存", () => {
|
||||
).toEqual(defaultExportConfig);
|
||||
});
|
||||
|
||||
it("将 v2 缓存迁移为 v3 并保留已有设置", () => {
|
||||
it("将 v2 缓存迁移为 v4 并让默认页边距跟随主题", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
version: 2,
|
||||
mermaid: undefined,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
marginMode: undefined,
|
||||
format: "A5"
|
||||
}
|
||||
};
|
||||
const storage = createMemoryStorage(
|
||||
null,
|
||||
JSON.stringify(legacyConfig)
|
||||
JSON.stringify(legacyConfig),
|
||||
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY
|
||||
);
|
||||
|
||||
const migrated = loadExportConfig(storage);
|
||||
expect(migrated.version).toBe(3);
|
||||
expect(migrated.version).toBe(4);
|
||||
expect(migrated.paper.format).toBe("A5");
|
||||
expect(migrated.paper.marginMode).toBe("theme");
|
||||
expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid);
|
||||
expect(storage.read()).toContain('"version":3');
|
||||
expect(storage.read()).toContain('"version":4');
|
||||
});
|
||||
|
||||
it("将改过页边距的 v3 缓存迁移为自定义模式", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
version: 3,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
marginMode: undefined,
|
||||
margins: {
|
||||
top: "20mm",
|
||||
right: "18mm",
|
||||
bottom: "20mm",
|
||||
left: "18mm"
|
||||
}
|
||||
}
|
||||
};
|
||||
const migrated = parseStoredExportConfig(
|
||||
JSON.stringify(legacyConfig)
|
||||
);
|
||||
|
||||
expect(migrated.version).toBe(4);
|
||||
expect(migrated.paper.marginMode).toBe("custom");
|
||||
expect(migrated.paper.margins).toEqual(legacyConfig.paper.margins);
|
||||
});
|
||||
|
||||
it("将未改页眉页码的旧 v4 缓存迁移为跟随主题", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: undefined,
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
fontFamily: undefined
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
fontFamily: undefined,
|
||||
showOnFirstPage: undefined
|
||||
}
|
||||
};
|
||||
const migrated = parseStoredExportConfig(
|
||||
JSON.stringify(legacyConfig)
|
||||
);
|
||||
|
||||
expect(migrated.pageDecorationsMode).toBe("theme");
|
||||
expect(migrated.header.fontFamily).toContain("Microsoft YaHei");
|
||||
expect(migrated.footer.showOnFirstPage).toBe(true);
|
||||
});
|
||||
|
||||
it("保留旧 v4 缓存中用户修改过的页眉页码", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: undefined,
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
enabled: true,
|
||||
fontFamily: undefined,
|
||||
center: {
|
||||
enabled: true,
|
||||
content: "${title}"
|
||||
}
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
alignment: "right",
|
||||
fontFamily: undefined,
|
||||
showOnFirstPage: undefined
|
||||
}
|
||||
};
|
||||
const migrated = parseStoredExportConfig(
|
||||
JSON.stringify(legacyConfig)
|
||||
);
|
||||
|
||||
expect(migrated.pageDecorationsMode).toBe("custom");
|
||||
expect(migrated.header.enabled).toBe(true);
|
||||
expect(migrated.header.center.content).toBe("${title}");
|
||||
expect(migrated.footer.alignment).toBe("right");
|
||||
});
|
||||
|
||||
it("保存并恢复经过校验的配置", () => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ThemeSummary } from "../src/application-backend";
|
||||
import { groupThemesByCategory } from "../src/theme-groups";
|
||||
|
||||
function createTheme(
|
||||
id: string,
|
||||
name: string,
|
||||
category: ThemeSummary["category"]
|
||||
): ThemeSummary {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
compatibleProfiles: [],
|
||||
bundled: true,
|
||||
source: "bundled"
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupThemesByCategory", () => {
|
||||
it("按稳定顺序输出非空主题分组", () => {
|
||||
const groups = groupThemesByCategory([
|
||||
createTheme("tender", "标书", "tender"),
|
||||
createTheme("general", "通用", "general"),
|
||||
createTheme("red", "红头", "red-letter")
|
||||
]);
|
||||
|
||||
expect(groups.map(({ category, label }) => ({ category, label })))
|
||||
.toEqual([
|
||||
{ category: "general", label: "通用主题" },
|
||||
{ category: "red-letter", label: "政企红头" },
|
||||
{ category: "tender", label: "标书正式" }
|
||||
]);
|
||||
expect(groups[0]?.themes[0]?.id).toBe("general");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import type { ThemeSummary } from "../src/application-backend";
|
||||
import {
|
||||
applyThemeDefaults,
|
||||
applyThemeMargins,
|
||||
selectTheme
|
||||
} from "../src/theme-margins";
|
||||
|
||||
function createTheme(
|
||||
id: string,
|
||||
margins?: ThemeSummary["pageDefaults"]
|
||||
): ThemeSummary {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
category: "formal",
|
||||
compatibleProfiles: [],
|
||||
pageDefaults: margins,
|
||||
bundled: true,
|
||||
source: "bundled"
|
||||
};
|
||||
}
|
||||
|
||||
const formalTheme = createTheme("formal", {
|
||||
margins: {
|
||||
top: "37mm",
|
||||
right: "26mm",
|
||||
bottom: "35mm",
|
||||
left: "28mm"
|
||||
}
|
||||
});
|
||||
|
||||
const officialTheme = createTheme("official", {
|
||||
...formalTheme.pageDefaults,
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
fontFamily: '"Mdpdf Fandol Song", SimSun, serif'
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
height: "7mm",
|
||||
fontSize: "4.94mm",
|
||||
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
|
||||
color: "#000000",
|
||||
alignment: "outer",
|
||||
format: "official-page",
|
||||
showOnFirstPage: false
|
||||
}
|
||||
});
|
||||
|
||||
describe("主题推荐页边距", () => {
|
||||
it("跟随模式切换主题时同步推荐值", () => {
|
||||
const selected = selectTheme(defaultExportConfig, formalTheme);
|
||||
|
||||
expect(selected.themeId).toBe("formal");
|
||||
expect(selected.paper.marginMode).toBe("theme");
|
||||
expect(selected.paper.margins).toEqual(
|
||||
formalTheme.pageDefaults?.margins
|
||||
);
|
||||
});
|
||||
|
||||
it("主题未声明推荐值时恢复全局 16mm", () => {
|
||||
const config = selectTheme(defaultExportConfig, formalTheme);
|
||||
const selected = selectTheme(config, createTheme("general"));
|
||||
|
||||
expect(selected.paper.margins).toEqual({
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
bottom: "16mm",
|
||||
left: "16mm"
|
||||
});
|
||||
});
|
||||
|
||||
it("自定义模式切换主题时保留用户值", () => {
|
||||
const config = {
|
||||
...defaultExportConfig,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
marginMode: "custom" as const,
|
||||
margins: {
|
||||
top: "20mm",
|
||||
right: "18mm",
|
||||
bottom: "20mm",
|
||||
left: "18mm"
|
||||
}
|
||||
}
|
||||
};
|
||||
const selected = selectTheme(config, formalTheme);
|
||||
|
||||
expect(selected.themeId).toBe("formal");
|
||||
expect(selected.paper.margins).toEqual(config.paper.margins);
|
||||
});
|
||||
|
||||
it("主题清单加载后补齐当前主题推荐值", () => {
|
||||
expect(
|
||||
applyThemeMargins(defaultExportConfig, formalTheme).paper.margins
|
||||
).toEqual(formalTheme.pageDefaults?.margins);
|
||||
});
|
||||
|
||||
it("跟随主题时同步推荐的页眉和页码", () => {
|
||||
const selected = selectTheme(defaultExportConfig, officialTheme);
|
||||
|
||||
expect(selected.pageDecorationsMode).toBe("theme");
|
||||
expect(selected.header.fontFamily).toContain("Fandol Song");
|
||||
expect(selected.footer).toMatchObject({
|
||||
alignment: "outer",
|
||||
format: "official-page",
|
||||
showOnFirstPage: false
|
||||
});
|
||||
});
|
||||
|
||||
it("页面装饰自定义模式切换主题时保留用户配置", () => {
|
||||
const config = {
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: "custom" as const,
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
alignment: "left" as const,
|
||||
format: "page" as const
|
||||
}
|
||||
};
|
||||
const selected = selectTheme(config, officialTheme);
|
||||
|
||||
expect(selected.header).toEqual(config.header);
|
||||
expect(selected.footer).toEqual(config.footer);
|
||||
});
|
||||
|
||||
it("主题清单加载后补齐当前主题页面装饰", () => {
|
||||
const applied = applyThemeDefaults(
|
||||
defaultExportConfig,
|
||||
officialTheme
|
||||
);
|
||||
|
||||
expect(applied.footer.format).toBe("official-page");
|
||||
expect(applied.footer.alignment).toBe("outer");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user