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:
+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";
|
||||
}>;
|
||||
|
||||
Reference in New Issue
Block a user