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
+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>
);
}