feat: 完善导出设置与打印预览
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20,6 +21,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
+149
-30
@@ -1,12 +1,26 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ChangeEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||
import katexCss from "katex/dist/katex.min.css?inline";
|
||||
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
||||
import {
|
||||
loadExportConfig,
|
||||
resetExportConfig,
|
||||
saveExportConfig
|
||||
} from "./export-settings";
|
||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||
import { renderMermaidDefinitions } from "./mermaid-renderer";
|
||||
import { enablePrintMediaForPreview } from "./preview-styles";
|
||||
|
||||
interface RenderedMarkdown {
|
||||
articleHtml: string;
|
||||
@@ -89,12 +103,48 @@ svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#write table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
#write th,
|
||||
#write td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mermaid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 1.5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mermaid-error {
|
||||
display: block;
|
||||
padding: 0.85em 1em;
|
||||
border: 1px solid #dc2626;
|
||||
color: #991b1b;
|
||||
background: #fef2f2;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`;
|
||||
|
||||
const previewGeometryCss = `
|
||||
html,
|
||||
body {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#write {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | undefined;
|
||||
@@ -103,12 +153,7 @@ let mermaidRenderSequence = 0;
|
||||
async function loadMermaid() {
|
||||
if (!mermaidPromise) {
|
||||
mermaidPromise = import("mermaid").then(({ default: mermaid }) => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "neutral",
|
||||
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif"
|
||||
});
|
||||
mermaid.initialize(createMermaidSiteConfig());
|
||||
return mermaid;
|
||||
});
|
||||
}
|
||||
@@ -132,6 +177,8 @@ function buildPreviewDocument(
|
||||
result: RenderedMarkdown,
|
||||
themeCss: string
|
||||
) {
|
||||
const previewThemeCss = enablePrintMediaForPreview(themeCss);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="${escapeHtml(result.metadata.language || "zh-CN")}">
|
||||
<head>
|
||||
@@ -145,7 +192,8 @@ function buildPreviewDocument(
|
||||
<style>${escapeStyleContent(previewBaseCss)}</style>
|
||||
<style>${escapeStyleContent(highlightCss)}</style>
|
||||
<style>${escapeStyleContent(katexCss)}</style>
|
||||
<style>${escapeStyleContent(themeCss)}</style>
|
||||
<style>${escapeStyleContent(previewThemeCss)}</style>
|
||||
<style>${escapeStyleContent(previewGeometryCss)}</style>
|
||||
</head>
|
||||
<body>
|
||||
${result.articleHtml}
|
||||
@@ -158,8 +206,10 @@ export function App() {
|
||||
const [fileName, setFileName] = useState("示例文档.md");
|
||||
const [result, setResult] = useState<RenderedMarkdown | null>(null);
|
||||
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
||||
const [themeId, setThemeId] = useState("typora-like");
|
||||
const [exportConfig, setExportConfig] =
|
||||
useState<ExportConfig>(loadExportConfig);
|
||||
const [themeCss, setThemeCss] = useState("");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [status, setStatus] = useState("正在准备预览…");
|
||||
const [renderError, setRenderError] = useState("");
|
||||
const [themeError, setThemeError] = useState("");
|
||||
@@ -167,7 +217,21 @@ export function App() {
|
||||
const previewFrameRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const error = renderError || themeError || mermaidError;
|
||||
const themeId = exportConfig.themeId;
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId);
|
||||
const paperDimensions = getPaperDimensionsMm(
|
||||
exportConfig.paper.format,
|
||||
exportConfig.paper.orientation
|
||||
);
|
||||
const paperStyle = {
|
||||
width: `${paperDimensions.width}mm`,
|
||||
minWidth: `${paperDimensions.width}mm`,
|
||||
minHeight: `${paperDimensions.height}mm`,
|
||||
paddingTop: exportConfig.paper.margins.top,
|
||||
paddingRight: exportConfig.paper.margins.right,
|
||||
paddingBottom: exportConfig.paper.margins.bottom,
|
||||
paddingLeft: exportConfig.paper.margins.left
|
||||
} satisfies CSSProperties;
|
||||
const previewDocument = useMemo(
|
||||
() =>
|
||||
result && themeCss ? buildPreviewDocument(result, themeCss) : undefined,
|
||||
@@ -188,13 +252,22 @@ export function App() {
|
||||
})
|
||||
.then(({ themes: availableThemes }) => {
|
||||
setThemes(availableThemes);
|
||||
const preferredTheme =
|
||||
availableThemes.find((theme) => theme.id === "typora-github") ??
|
||||
availableThemes.find((theme) => theme.id === "typora-like") ??
|
||||
availableThemes[0];
|
||||
if (preferredTheme) {
|
||||
setThemeId(preferredTheme.id);
|
||||
}
|
||||
setExportConfig((currentConfig) => {
|
||||
if (
|
||||
availableThemes.some(
|
||||
(theme) => theme.id === currentConfig.themeId
|
||||
)
|
||||
) {
|
||||
return currentConfig;
|
||||
}
|
||||
const preferredTheme =
|
||||
availableThemes.find((theme) => theme.id === "typora-github") ??
|
||||
availableThemes.find((theme) => theme.id === "typora-like") ??
|
||||
availableThemes[0];
|
||||
return preferredTheme
|
||||
? { ...currentConfig, themeId: preferredTheme.id }
|
||||
: currentConfig;
|
||||
});
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -207,6 +280,14 @@ export function App() {
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
saveExportConfig(exportConfig);
|
||||
} catch {
|
||||
setRenderError("无法保存导出设置,当前设置仅在本次页面中有效");
|
||||
}
|
||||
}, [exportConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setThemeCss("");
|
||||
@@ -306,16 +387,42 @@ export function App() {
|
||||
if (nodes.length > 0) {
|
||||
try {
|
||||
const mermaid = await loadMermaid();
|
||||
for (const node of nodes) {
|
||||
const definition = node.textContent ?? "";
|
||||
mermaidRenderSequence += 1;
|
||||
const { svg, bindFunctions } = await mermaid.render(
|
||||
`mermaid-preview-${mermaidRenderSequence}`,
|
||||
definition
|
||||
);
|
||||
node.innerHTML = svg;
|
||||
const outcomes = await renderMermaidDefinitions(
|
||||
nodes.map((node) => node.textContent ?? ""),
|
||||
async (definition) => {
|
||||
mermaidRenderSequence += 1;
|
||||
return mermaid.render(
|
||||
`mermaid-preview-${mermaidRenderSequence}`,
|
||||
definition
|
||||
);
|
||||
}
|
||||
);
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [index, outcome] of outcomes.entries()) {
|
||||
const node = nodes[index];
|
||||
if (!node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
node.removeAttribute("data-mermaid-pending");
|
||||
bindFunctions?.(node);
|
||||
if (outcome.success) {
|
||||
node.innerHTML = outcome.svg;
|
||||
outcome.bindFunctions?.(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message =
|
||||
outcome.error instanceof Error
|
||||
? outcome.error.message
|
||||
: "未知错误";
|
||||
node.classList.add("mermaid-error");
|
||||
node.textContent = `Mermaid 图表 ${index + 1} 渲染失败:${message}`;
|
||||
errors.push(`图表 ${index + 1}:${message}`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setMermaidError(`Mermaid:${errors.join(";")}`);
|
||||
}
|
||||
} catch (reason: unknown) {
|
||||
setMermaidError(
|
||||
@@ -362,6 +469,9 @@ export function App() {
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={() => setSettingsOpen(true)}>
|
||||
导出设置
|
||||
</button>
|
||||
<button type="button" disabled title="下一阶段实现">
|
||||
导出 PDF
|
||||
</button>
|
||||
@@ -398,7 +508,12 @@ export function App() {
|
||||
<select
|
||||
aria-label="预览主题"
|
||||
value={themeId}
|
||||
onChange={(event) => setThemeId(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setExportConfig((currentConfig) => ({
|
||||
...currentConfig,
|
||||
themeId: event.target.value
|
||||
}))
|
||||
}
|
||||
>
|
||||
{themes.length === 0 ? (
|
||||
<option value={themeId}>正在加载主题…</option>
|
||||
@@ -416,11 +531,7 @@ export function App() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="preview-scroll">
|
||||
<div
|
||||
className={`paper${
|
||||
selectedTheme?.source === "local" ? " is-local-theme" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="paper" style={paperStyle}>
|
||||
{previewDocument ? (
|
||||
<iframe
|
||||
ref={previewFrameRef}
|
||||
@@ -437,6 +548,14 @@ export function App() {
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{settingsOpen ? (
|
||||
<ExportSettingsDrawer
|
||||
config={exportConfig}
|
||||
onChange={setExportConfig}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onReset={() => setExportConfig(resetExportConfig())}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
paperFormatLabels,
|
||||
supportedPaperFormats,
|
||||
type ExportConfig,
|
||||
type FooterConfig,
|
||||
type HeaderConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ExportSettingsDrawerProps {
|
||||
config: ExportConfig;
|
||||
onChange: (config: ExportConfig) => void;
|
||||
onClose: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
interface LengthInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
maximum: number;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
|
||||
page: "1",
|
||||
"page-total": "1 / 10",
|
||||
"chinese-page-total": "第 1 页 / 共 10 页",
|
||||
"dash-page": "- 1 -",
|
||||
custom: "自定义模板"
|
||||
};
|
||||
|
||||
function millimeters(value: string) {
|
||||
return Number.parseFloat(value.replace(/mm$/i, ""));
|
||||
}
|
||||
|
||||
function fitMarginPair(
|
||||
first: number,
|
||||
second: number,
|
||||
available: number
|
||||
) {
|
||||
const total = first + second;
|
||||
if (total <= available || total === 0) {
|
||||
return [first, second] as const;
|
||||
}
|
||||
const scale = available / total;
|
||||
const scaledFirst = Math.floor(first * scale * 10) / 10;
|
||||
return [
|
||||
scaledFirst,
|
||||
Math.floor((available - scaledFirst) * 10) / 10
|
||||
] as const;
|
||||
}
|
||||
|
||||
function LengthInput({
|
||||
label,
|
||||
value,
|
||||
maximum,
|
||||
onChange
|
||||
}: LengthInputProps) {
|
||||
const [draft, setDraft] = useState(String(millimeters(value)));
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(String(millimeters(value)));
|
||||
}, [value]);
|
||||
|
||||
function commit() {
|
||||
const number = Number.parseFloat(draft);
|
||||
if (!Number.isFinite(number)) {
|
||||
setDraft(String(millimeters(value)));
|
||||
return;
|
||||
}
|
||||
const normalized = Math.min(Math.max(number, 0), maximum);
|
||||
const rounded = Math.round(normalized * 10) / 10;
|
||||
setDraft(String(rounded));
|
||||
onChange(`${rounded}mm`);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="setting-field">
|
||||
<span>{label}</span>
|
||||
<span className="length-input">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={maximum}
|
||||
step="0.1"
|
||||
value={draft}
|
||||
onBlur={commit}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>mm</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function updateHeaderSlot(
|
||||
header: HeaderConfig,
|
||||
slot: "left" | "center" | "right",
|
||||
value: Partial<HeaderConfig[typeof slot]>
|
||||
) {
|
||||
return {
|
||||
...header,
|
||||
[slot]: {
|
||||
...header[slot],
|
||||
...value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function ExportSettingsDrawer({
|
||||
config,
|
||||
onChange,
|
||||
onClose,
|
||||
onReset
|
||||
}: ExportSettingsDrawerProps) {
|
||||
const dimensions = getPaperDimensionsMm(
|
||||
config.paper.format,
|
||||
config.paper.orientation
|
||||
);
|
||||
const margins = config.paper.margins;
|
||||
const horizontalGap = 1;
|
||||
const verticalGap = 1;
|
||||
|
||||
function updatePaper(paper: Partial<ExportConfig["paper"]>) {
|
||||
const nextPaper = {
|
||||
...config.paper,
|
||||
...paper
|
||||
};
|
||||
const nextDimensions = getPaperDimensionsMm(
|
||||
nextPaper.format,
|
||||
nextPaper.orientation
|
||||
);
|
||||
const [left, right] = fitMarginPair(
|
||||
millimeters(nextPaper.margins.left),
|
||||
millimeters(nextPaper.margins.right),
|
||||
nextDimensions.width - 1
|
||||
);
|
||||
const [top, bottom] = fitMarginPair(
|
||||
millimeters(nextPaper.margins.top),
|
||||
millimeters(nextPaper.margins.bottom),
|
||||
nextDimensions.height - 1
|
||||
);
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
paper: {
|
||||
...nextPaper,
|
||||
margins: {
|
||||
top: `${top}mm`,
|
||||
right: `${right}mm`,
|
||||
bottom: `${bottom}mm`,
|
||||
left: `${left}mm`
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateMargin(
|
||||
side: keyof ExportConfig["paper"]["margins"],
|
||||
value: string
|
||||
) {
|
||||
updatePaper({
|
||||
margins: {
|
||||
...margins,
|
||||
[side]: value
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateHeader(header: Partial<HeaderConfig>) {
|
||||
onChange({
|
||||
...config,
|
||||
header: {
|
||||
...config.header,
|
||||
...header
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateFooter(footer: Partial<FooterConfig>) {
|
||||
onChange({
|
||||
...config,
|
||||
footer: {
|
||||
...config.footer,
|
||||
...footer
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-layer">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-backdrop"
|
||||
aria-label="关闭导出设置"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
className="settings-drawer"
|
||||
aria-label="导出设置"
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="settings-heading">
|
||||
<div>
|
||||
<span className="panel-kicker">预览与未来 PDF 共用</span>
|
||||
<h2>导出设置</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="关闭导出设置"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<section className="settings-section">
|
||||
<h3>纸张</h3>
|
||||
<label className="setting-field">
|
||||
<span>尺寸</span>
|
||||
<select
|
||||
value={config.paper.format}
|
||||
onChange={(event) =>
|
||||
updatePaper({
|
||||
format: event.target
|
||||
.value as ExportConfig["paper"]["format"]
|
||||
})
|
||||
}
|
||||
>
|
||||
{supportedPaperFormats.map((format) => (
|
||||
<option key={format} value={format}>
|
||||
{paperFormatLabels[format]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="segmented-control">
|
||||
<legend>方向</legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="paper-orientation"
|
||||
checked={config.paper.orientation === "portrait"}
|
||||
onChange={() => updatePaper({ orientation: "portrait" })}
|
||||
/>
|
||||
纵向
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="paper-orientation"
|
||||
checked={config.paper.orientation === "landscape"}
|
||||
onChange={() => updatePaper({ orientation: "landscape" })}
|
||||
/>
|
||||
横向
|
||||
</label>
|
||||
</fieldset>
|
||||
<p className="setting-hint">
|
||||
当前纸张:{dimensions.width} × {dimensions.height}mm
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>页边距</h3>
|
||||
<div className="margin-grid">
|
||||
<LengthInput
|
||||
label="上"
|
||||
value={margins.top}
|
||||
maximum={
|
||||
dimensions.height - millimeters(margins.bottom) - verticalGap
|
||||
}
|
||||
onChange={(value) => updateMargin("top", value)}
|
||||
/>
|
||||
<LengthInput
|
||||
label="右"
|
||||
value={margins.right}
|
||||
maximum={
|
||||
dimensions.width - millimeters(margins.left) - horizontalGap
|
||||
}
|
||||
onChange={(value) => updateMargin("right", value)}
|
||||
/>
|
||||
<LengthInput
|
||||
label="下"
|
||||
value={margins.bottom}
|
||||
maximum={
|
||||
dimensions.height - millimeters(margins.top) - verticalGap
|
||||
}
|
||||
onChange={(value) => updateMargin("bottom", value)}
|
||||
/>
|
||||
<LengthInput
|
||||
label="左"
|
||||
value={margins.left}
|
||||
maximum={
|
||||
dimensions.width - millimeters(margins.right) - horizontalGap
|
||||
}
|
||||
onChange={(value) => updateMargin("left", value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="setting-section-title">
|
||||
<h3>页眉</h3>
|
||||
<label className="switch-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.header.enabled}
|
||||
onChange={(event) =>
|
||||
updateHeader({ enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>{config.header.enabled ? "开启" : "关闭"}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="setting-hint">
|
||||
支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。
|
||||
</p>
|
||||
{(["left", "center", "right"] as const).map((slot) => {
|
||||
const labels = { left: "左侧", center: "中间", right: "右侧" };
|
||||
return (
|
||||
<div className="header-slot" key={slot}>
|
||||
<label className="switch-control compact">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.header[slot].enabled}
|
||||
disabled={!config.header.enabled}
|
||||
onChange={(event) =>
|
||||
updateHeader(
|
||||
updateHeaderSlot(config.header, slot, {
|
||||
enabled: event.target.checked
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>{labels[slot]}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.header[slot].content}
|
||||
disabled={
|
||||
!config.header.enabled ||
|
||||
!config.header[slot].enabled
|
||||
}
|
||||
placeholder={`页眉${labels[slot]}内容`}
|
||||
onChange={(event) =>
|
||||
updateHeader(
|
||||
updateHeaderSlot(config.header, slot, {
|
||||
content: event.target.value
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<label className="switch-control divider-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.header.showDivider}
|
||||
disabled={!config.header.enabled}
|
||||
onChange={(event) =>
|
||||
updateHeader({ showDivider: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>显示页眉分隔线</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="setting-section-title">
|
||||
<h3>页脚页码</h3>
|
||||
<label className="switch-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.footer.enabled}
|
||||
onChange={(event) =>
|
||||
updateFooter({ enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="setting-field">
|
||||
<span>样式</span>
|
||||
<select
|
||||
value={config.footer.format}
|
||||
disabled={!config.footer.enabled}
|
||||
onChange={(event) =>
|
||||
updateFooter({
|
||||
format: event.target.value as FooterConfig["format"]
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.entries(pageNumberFormatLabels).map(
|
||||
([format, label]) => (
|
||||
<option key={format} value={format}>
|
||||
{label}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{config.footer.format === "custom" ? (
|
||||
<label className="setting-field stacked">
|
||||
<span>模板</span>
|
||||
<input
|
||||
type="text"
|
||||
value={config.footer.template ?? ""}
|
||||
disabled={!config.footer.enabled}
|
||||
placeholder="${page} / ${pages}"
|
||||
onChange={(event) =>
|
||||
updateFooter({ template: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="setting-field">
|
||||
<span>位置</span>
|
||||
<select
|
||||
value={config.footer.alignment}
|
||||
disabled={!config.footer.enabled}
|
||||
onChange={(event) =>
|
||||
updateFooter({
|
||||
alignment: event.target
|
||||
.value as FooterConfig["alignment"]
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="left">左侧</option>
|
||||
<option value="center">居中</option>
|
||||
<option value="right">右侧</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="setting-field">
|
||||
<span>起始页码</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={config.footer.startFrom}
|
||||
disabled={!config.footer.enabled}
|
||||
onChange={(event) =>
|
||||
updateFooter({
|
||||
startFrom: Math.max(
|
||||
1,
|
||||
Number.parseInt(event.target.value, 10) || 1
|
||||
)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="switch-control divider-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.footer.showDivider}
|
||||
disabled={!config.footer.enabled}
|
||||
onChange={(event) =>
|
||||
updateFooter({ showDivider: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>显示页脚分隔线</span>
|
||||
</label>
|
||||
<p className="setting-hint">
|
||||
页眉和页码将在下一阶段的分页预览中显示。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="settings-actions">
|
||||
<button type="button" className="secondary-button" onClick={onReset}>
|
||||
恢复默认
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
defaultExportConfig,
|
||||
exportConfigSchema,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v2";
|
||||
|
||||
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
function cloneDefaultExportConfig() {
|
||||
return structuredClone(defaultExportConfig);
|
||||
}
|
||||
|
||||
export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||
if (!rawValue) {
|
||||
return cloneDefaultExportConfig();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = exportConfigSchema.safeParse(JSON.parse(rawValue));
|
||||
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
||||
} catch {
|
||||
return cloneDefaultExportConfig();
|
||||
}
|
||||
}
|
||||
|
||||
export function loadExportConfig(
|
||||
storage: SettingsStorage = window.localStorage
|
||||
) {
|
||||
return parseStoredExportConfig(storage.getItem(EXPORT_CONFIG_STORAGE_KEY));
|
||||
}
|
||||
|
||||
export function saveExportConfig(
|
||||
config: ExportConfig,
|
||||
storage: SettingsStorage = window.localStorage
|
||||
) {
|
||||
const parsed = exportConfigSchema.parse(config);
|
||||
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(parsed));
|
||||
}
|
||||
|
||||
export function resetExportConfig() {
|
||||
return cloneDefaultExportConfig();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MermaidConfig } from "mermaid";
|
||||
|
||||
export const MERMAID_SECURE_CONFIG_KEYS = [
|
||||
"secure",
|
||||
"securityLevel",
|
||||
"startOnLoad",
|
||||
"maxTextSize",
|
||||
"maxEdges",
|
||||
"suppressErrorRendering",
|
||||
"themeCSS"
|
||||
];
|
||||
|
||||
export function createMermaidSiteConfig(): MermaidConfig {
|
||||
return {
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "default",
|
||||
look: "classic",
|
||||
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif",
|
||||
maxTextSize: 50_000,
|
||||
maxEdges: 500,
|
||||
suppressErrorRendering: true,
|
||||
secure: [...MERMAID_SECURE_CONFIG_KEYS]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface MermaidSvgResult {
|
||||
svg: string;
|
||||
bindFunctions?: (element: Element) => void;
|
||||
}
|
||||
|
||||
export type MermaidRenderOutcome =
|
||||
| ({ success: true } & MermaidSvgResult)
|
||||
| {
|
||||
success: false;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export async function renderMermaidDefinitions(
|
||||
definitions: string[],
|
||||
renderDefinition: (
|
||||
definition: string,
|
||||
index: number
|
||||
) => Promise<MermaidSvgResult>
|
||||
): Promise<MermaidRenderOutcome[]> {
|
||||
const outcomes: MermaidRenderOutcome[] = [];
|
||||
|
||||
for (const [index, definition] of definitions.entries()) {
|
||||
try {
|
||||
outcomes.push({
|
||||
success: true,
|
||||
...(await renderDefinition(definition, index))
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
outcomes.push({
|
||||
success: false,
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outcomes;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
const printMediaPattern =
|
||||
/@media(\s+)(only\s+)?print(?=\s*(?:\{|and\b|,))/gi;
|
||||
|
||||
export function enablePrintMediaForPreview(css: string) {
|
||||
return css.replace(
|
||||
printMediaPattern,
|
||||
(_match, whitespace: string, qualifier: string | undefined) =>
|
||||
`@media${whitespace}${qualifier ?? ""}screen`
|
||||
);
|
||||
}
|
||||
+279
-14
@@ -80,6 +80,7 @@ h1 {
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -247,19 +248,11 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.paper {
|
||||
width: 210mm;
|
||||
min-width: 210mm;
|
||||
min-height: 297mm;
|
||||
margin: 0 auto;
|
||||
padding: 20mm 18mm;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
|
||||
}
|
||||
|
||||
.paper.is-local-theme {
|
||||
padding: 8mm;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -276,6 +269,277 @@ textarea:focus {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-layer {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.settings-backdrop {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
inset: 0;
|
||||
background: rgb(24 35 30 / 38%);
|
||||
}
|
||||
|
||||
.settings-drawer {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(390px, 100vw);
|
||||
height: 100%;
|
||||
inset: 0 0 0 auto;
|
||||
background: #f8faf8;
|
||||
box-shadow: -20px 0 60px rgb(26 40 34 / 20%);
|
||||
}
|
||||
|
||||
.settings-heading,
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #d9dedb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.settings-heading h2 {
|
||||
margin: 1px 0 0;
|
||||
color: #25332d;
|
||||
font-family: Georgia, "Songti SC", serif;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #5f6c66;
|
||||
font-size: 1.7rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 4px 20px 28px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #dfe4e1;
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 0 0 14px;
|
||||
color: #30443a;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.setting-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.setting-section-title h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.setting-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 42px;
|
||||
color: #526159;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.setting-field.stacked {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.setting-field > select,
|
||||
.setting-field > input,
|
||||
.header-slot > input {
|
||||
width: 190px;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #c9d4ce;
|
||||
border-radius: 7px;
|
||||
outline: 0;
|
||||
background: #fff;
|
||||
color: #263a32;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.setting-field.stacked > input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-field input:focus,
|
||||
.setting-field select:focus,
|
||||
.header-slot > input:focus {
|
||||
border-color: #74a88f;
|
||||
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
|
||||
}
|
||||
|
||||
.setting-field input:disabled,
|
||||
.setting-field select:disabled,
|
||||
.header-slot > input:disabled {
|
||||
background: #edf0ee;
|
||||
color: #929b97;
|
||||
}
|
||||
|
||||
.segmented-control {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.segmented-control legend {
|
||||
margin-bottom: 8px;
|
||||
color: #526159;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.segmented-control label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-height: 36px;
|
||||
border: 1px solid #c9d4ce;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #3f554a;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segmented-control label:has(input:checked) {
|
||||
border-color: #74a88f;
|
||||
background: #e9f3ed;
|
||||
color: #2d654d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.margin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 18px;
|
||||
}
|
||||
|
||||
.margin-grid .setting-field {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.length-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid #c9d4ce;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.length-input:focus-within {
|
||||
border-color: #74a88f;
|
||||
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
|
||||
}
|
||||
|
||||
.length-input input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 4px 0 10px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #263a32;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.length-input span {
|
||||
padding-right: 9px;
|
||||
color: #7a8580;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.switch-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #526159;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-control.compact {
|
||||
width: 66px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.divider-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.header-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.header-slot > input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
margin: 7px 0 10px;
|
||||
color: #7a8580;
|
||||
font-size: 0.69rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid #d9dedb;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: #f3f5f4;
|
||||
color: #58665f;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
@@ -305,12 +569,7 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.paper {
|
||||
min-height: auto;
|
||||
padding: 14mm 10mm;
|
||||
}
|
||||
|
||||
.paper.is-local-theme {
|
||||
padding: 8mm;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.theme-control {
|
||||
@@ -321,3 +580,9 @@ textarea:focus {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.settings-drawer {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
EXPORT_CONFIG_STORAGE_KEY,
|
||||
loadExportConfig,
|
||||
parseStoredExportConfig,
|
||||
saveExportConfig
|
||||
} from "../src/export-settings";
|
||||
|
||||
function createMemoryStorage(initialValue: string | null = null) {
|
||||
let value = initialValue;
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return key === EXPORT_CONFIG_STORAGE_KEY ? value : null;
|
||||
},
|
||||
setItem(key: string, nextValue: string) {
|
||||
if (key === EXPORT_CONFIG_STORAGE_KEY) {
|
||||
value = nextValue;
|
||||
}
|
||||
},
|
||||
read() {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("导出设置缓存", () => {
|
||||
it("在没有缓存时返回独立的默认配置", () => {
|
||||
const first = parseStoredExportConfig(null);
|
||||
const second = parseStoredExportConfig(null);
|
||||
expect(first).toEqual(defaultExportConfig);
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("缓存损坏或版本过期时恢复默认配置", () => {
|
||||
expect(parseStoredExportConfig("{")).toEqual(defaultExportConfig);
|
||||
expect(
|
||||
parseStoredExportConfig(
|
||||
JSON.stringify({
|
||||
...defaultExportConfig,
|
||||
version: 1
|
||||
})
|
||||
)
|
||||
).toEqual(defaultExportConfig);
|
||||
});
|
||||
|
||||
it("保存并恢复经过校验的配置", () => {
|
||||
const storage = createMemoryStorage();
|
||||
const config = {
|
||||
...defaultExportConfig,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
format: "A3" as const,
|
||||
orientation: "landscape" as const
|
||||
}
|
||||
};
|
||||
|
||||
saveExportConfig(config, storage);
|
||||
expect(storage.read()).toContain('"format":"A3"');
|
||||
expect(loadExportConfig(storage)).toEqual(config);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMermaidSiteConfig,
|
||||
MERMAID_SECURE_CONFIG_KEYS
|
||||
} from "../src/mermaid-config";
|
||||
|
||||
describe("Mermaid 站点配置", () => {
|
||||
it("默认使用 default 主题和 classic 外观", () => {
|
||||
const config = createMermaidSiteConfig();
|
||||
expect(config.theme).toBe("default");
|
||||
expect(config.look).toBe("classic");
|
||||
expect(config.securityLevel).toBe("strict");
|
||||
expect(config.startOnLoad).toBe(false);
|
||||
});
|
||||
|
||||
it("锁定安全限制和原始主题 CSS", () => {
|
||||
expect(MERMAID_SECURE_CONFIG_KEYS).toEqual(
|
||||
expect.arrayContaining([
|
||||
"secure",
|
||||
"securityLevel",
|
||||
"startOnLoad",
|
||||
"maxTextSize",
|
||||
"maxEdges",
|
||||
"suppressErrorRendering",
|
||||
"themeCSS"
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("每次创建独立的 secure 配置数组", () => {
|
||||
const first = createMermaidSiteConfig();
|
||||
const second = createMermaidSiteConfig();
|
||||
expect(first.secure).toEqual(second.secure);
|
||||
expect(first.secure).not.toBe(second.secure);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderMermaidDefinitions } from "../src/mermaid-renderer";
|
||||
|
||||
describe("Mermaid 独立图表渲染", () => {
|
||||
it("单个图表失败后继续渲染后续图表", async () => {
|
||||
const renderDefinition = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("语法错误"))
|
||||
.mockResolvedValueOnce({ svg: "<svg>第二张图</svg>" });
|
||||
|
||||
const outcomes = await renderMermaidDefinitions(
|
||||
["invalid", "flowchart LR\nA --> B"],
|
||||
renderDefinition
|
||||
);
|
||||
|
||||
expect(renderDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(outcomes[0]).toMatchObject({
|
||||
success: false,
|
||||
error: expect.any(Error)
|
||||
});
|
||||
expect(outcomes[1]).toEqual({
|
||||
success: true,
|
||||
svg: "<svg>第二张图</svg>"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enablePrintMediaForPreview } from "../src/preview-styles";
|
||||
|
||||
describe("打印媒体预览", () => {
|
||||
it("将打印媒体规则转换为屏幕预览规则", () => {
|
||||
const css = `
|
||||
html { font-size: 16px; }
|
||||
@media print {
|
||||
html { font-size: 13px; }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(enablePrintMediaForPreview(css)).toContain("@media screen {");
|
||||
expect(enablePrintMediaForPreview(css)).toContain(
|
||||
"html { font-size: 13px; }"
|
||||
);
|
||||
});
|
||||
|
||||
it("支持 only print 和带条件的打印媒体规则", () => {
|
||||
const css = [
|
||||
"@media only print { body { color: black; } }",
|
||||
"@media print and (color) { body { background: white; } }"
|
||||
].join("\n");
|
||||
|
||||
expect(enablePrintMediaForPreview(css)).toBe(
|
||||
[
|
||||
"@media only screen { body { color: black; } }",
|
||||
"@media screen and (color) { body { background: white; } }"
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
it("不改变普通屏幕媒体规则", () => {
|
||||
const css = "@media screen and (min-width: 800px) { body { margin: 0; } }";
|
||||
expect(enablePrintMediaForPreview(css)).toBe(css);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user