feat: 完善导出设置与打印预览

This commit is contained in:
SkyJourney
2026-07-26 01:36:51 +08:00
parent ec14f7970a
commit 52cf816683
20 changed files with 1552 additions and 146 deletions
+3 -1
View File
@@ -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
View File
@@ -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>
);
}
+489
View File
@@ -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>
);
}
+44
View File
@@ -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();
}
+25
View File
@@ -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]
};
}
+37
View File
@@ -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;
}
+10
View File
@@ -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
View File
@@ -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;
}
}
+62
View File
@@ -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);
});
});
+36
View File
@@ -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);
});
});
+26
View File
@@ -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>"
});
});
});
+37
View File
@@ -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);
});
});