feat: 完善导出设置与打印预览
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user