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:
SkyJourney
2026-07-29 16:58:17 +08:00
parent 58087d0c7e
commit 83e6559c2c
111 changed files with 8274 additions and 173 deletions
+485 -37
View File
@@ -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>
);
}