283 lines
8.7 KiB
TypeScript
283 lines
8.7 KiB
TypeScript
import {
|
|
createEffect,
|
|
createResource,
|
|
createSignal,
|
|
For,
|
|
onCleanup,
|
|
onMount,
|
|
Show,
|
|
} from "solid-js";
|
|
import { Portal } from "solid-js/web";
|
|
import {
|
|
TbOutlineDownload,
|
|
TbOutlinePrinter,
|
|
TbOutlineRefresh,
|
|
TbOutlineX,
|
|
} from "solid-icons/tb";
|
|
import { getIndexedData } from "../../data-loader/file-index";
|
|
import { extractSection } from "../../data-loader";
|
|
import { parseMarkdown } from "../../markdown";
|
|
import type { PrintConfig } from "./types";
|
|
import {
|
|
columnWidthMm,
|
|
createPageElement,
|
|
mmToPx,
|
|
paginate,
|
|
waitForStableSize,
|
|
type PaginateOptions,
|
|
} from "./paginate";
|
|
import { exportPrintPDF } from "./print-export";
|
|
|
|
function formatTimestamp(date: Date): string {
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
}
|
|
|
|
export interface PrintPreviewProps {
|
|
src: string;
|
|
section?: string;
|
|
articlePath: string;
|
|
config: PrintConfig;
|
|
onClose: () => void;
|
|
}
|
|
|
|
async function fetchPrintContent(params: {
|
|
src: string;
|
|
section?: string;
|
|
}): Promise<string> {
|
|
const text = await getIndexedData(params.src);
|
|
return params.section ? extractSection(text, params.section) : text;
|
|
}
|
|
|
|
export function PrintPreview(props: PrintPreviewProps) {
|
|
const [content] = createResource(
|
|
() => ({ src: props.src, section: props.section }),
|
|
fetchPrintContent,
|
|
);
|
|
|
|
const [pages, setPages] = createSignal<HTMLElement[]>([]);
|
|
const [paginating, setPaginating] = createSignal(false);
|
|
const [warnings, setWarnings] = createSignal<string[]>([]);
|
|
const [error, setError] = createSignal<string | null>(null);
|
|
const [scale, setScale] = createSignal(0.8);
|
|
const [sourceEl, setSourceEl] = createSignal<HTMLElement>();
|
|
const [mountEl, setMountEl] = createSignal<HTMLElement>();
|
|
const [sheetEl, setSheetEl] = createSignal<HTMLElement>();
|
|
|
|
let running = false;
|
|
|
|
const title = () =>
|
|
props.config.title || props.src.split("/").pop() || "打印预览";
|
|
|
|
const options = (): PaginateOptions => ({
|
|
...props.config,
|
|
articlePath: props.articlePath,
|
|
});
|
|
|
|
const applyFooter = (pageList: HTMLElement[], timestamp: string) => {
|
|
pageList.forEach((page, index) => {
|
|
const footer = page.querySelector(".md-print-footer");
|
|
if (!footer) return;
|
|
const pageLabel = props.config.pageNumbers
|
|
? `${index + 1} / ${pageList.length}`
|
|
: "";
|
|
footer.textContent = [pageLabel, timestamp].filter(Boolean).join(" · ");
|
|
// Left A5 gets the left corner, right A5 gets the right corner.
|
|
footer.classList.add(
|
|
index % 2 === 0
|
|
? "md-print-footer--left"
|
|
: "md-print-footer--right",
|
|
);
|
|
});
|
|
};
|
|
|
|
const runPagination = async () => {
|
|
const md = content();
|
|
const source = sourceEl();
|
|
const mount = mountEl();
|
|
if (md === undefined || !source || !mount || running) return;
|
|
|
|
running = true;
|
|
setPaginating(true);
|
|
setError(null);
|
|
setWarnings([]);
|
|
setPages([]);
|
|
mount.innerHTML = "";
|
|
|
|
try {
|
|
source.innerHTML = parseMarkdown(md, props.articlePath);
|
|
await document.fonts.ready;
|
|
await waitForStableSize(source, 300, 4000);
|
|
|
|
const result = paginate(source, options(), mount);
|
|
applyFooter(
|
|
result.pages,
|
|
props.config.timestamp ? formatTimestamp(new Date()) : "",
|
|
);
|
|
setPages(result.pages);
|
|
setWarnings(result.warnings);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
running = false;
|
|
setPaginating(false);
|
|
}
|
|
};
|
|
|
|
createEffect(() => {
|
|
if (content() !== undefined && sourceEl() && mountEl()) {
|
|
void runPagination();
|
|
}
|
|
});
|
|
|
|
createEffect(() => {
|
|
const container = sheetEl();
|
|
const pageList = pages();
|
|
if (!container) return;
|
|
|
|
container.innerHTML = "";
|
|
for (let i = 0; i < pageList.length; i += 2) {
|
|
const sheet = document.createElement("div");
|
|
sheet.className = "md-print-sheet md-print-sheet--preview";
|
|
sheet.appendChild(pageList[i]);
|
|
if (pageList[i + 1]) {
|
|
sheet.appendChild(pageList[i + 1]);
|
|
} else {
|
|
sheet.appendChild(createPageElement(options()).page);
|
|
}
|
|
if (props.config.cutMarks) {
|
|
const line = document.createElement("div");
|
|
line.className = "md-print-cut-line";
|
|
sheet.appendChild(line);
|
|
}
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "md-print-sheet-wrapper";
|
|
wrapper.appendChild(sheet);
|
|
container.appendChild(wrapper);
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
const container = sheetEl();
|
|
if (container) {
|
|
const observer = new ResizeObserver(() => {
|
|
const available = container.clientWidth;
|
|
if (available <= 0) return;
|
|
setScale(Math.min(1, available / mmToPx(297)));
|
|
});
|
|
observer.observe(container);
|
|
onCleanup(() => observer.disconnect());
|
|
}
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") props.onClose();
|
|
};
|
|
document.addEventListener("keydown", onKeyDown);
|
|
onCleanup(() => document.removeEventListener("keydown", onKeyDown));
|
|
});
|
|
|
|
const handleExport = async () => {
|
|
try {
|
|
// Refresh the timestamp so the exported file records the export time.
|
|
if (props.config.timestamp) {
|
|
applyFooter(pages(), formatTimestamp(new Date()));
|
|
}
|
|
await exportPrintPDF(pages(), props.config, title(), props.articlePath);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Portal>
|
|
<div class="fixed inset-0 bg-black/60 z-60 flex flex-col">
|
|
<div class="shrink-0 bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-3">
|
|
<TbOutlinePrinter class="text-xl text-gray-600 shrink-0" />
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-semibold text-gray-900 truncate">
|
|
{title()}
|
|
</div>
|
|
<div class="text-xs text-gray-500">
|
|
{pages().length} 页 · A4 横版 · 每页 {props.config.columns} 列
|
|
</div>
|
|
</div>
|
|
<div class="flex-1" />
|
|
<button
|
|
onClick={() => void runPagination()}
|
|
disabled={paginating()}
|
|
class="px-3 py-1.5 rounded text-sm font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50"
|
|
>
|
|
<TbOutlineRefresh class="inline-block align-[-0.125em]" /> 重新分页
|
|
</button>
|
|
<button
|
|
onClick={() => void handleExport()}
|
|
disabled={paginating() || pages().length === 0}
|
|
class="px-3 py-1.5 rounded text-sm font-medium bg-green-100 text-green-700 hover:bg-green-200 disabled:opacity-50"
|
|
>
|
|
<TbOutlineDownload class="inline-block align-[-0.125em]" /> 打印 /
|
|
导出 PDF
|
|
</button>
|
|
<button
|
|
onClick={props.onClose}
|
|
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
|
title="关闭"
|
|
>
|
|
<TbOutlineX />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-auto bg-gray-100 p-6">
|
|
<Show when={content.loading || paginating()}>
|
|
<div class="text-center text-gray-500 py-20">正在分页…</div>
|
|
</Show>
|
|
|
|
<Show when={content.error || error()}>
|
|
<div class="text-center text-red-500 py-20">
|
|
{error() || content.error?.message}
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={warnings().length > 0}>
|
|
<div class="max-w-3xl mx-auto mb-4 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
|
<For each={warnings()}>{(warning) => <div>· {warning}</div>}</For>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Hidden measuring container: one column wide, same typography. */}
|
|
<article
|
|
ref={setSourceEl}
|
|
data-src={props.articlePath}
|
|
class="md-print-content prose"
|
|
style={{
|
|
width: `${columnWidthMm(props.config)}mm`,
|
|
"--md-print-font-size": `${props.config.font}mm`,
|
|
position: "absolute",
|
|
left: "-99999px",
|
|
top: "0",
|
|
visibility: "hidden",
|
|
"pointer-events": "none",
|
|
}}
|
|
/>
|
|
|
|
{/* Hidden mount for pages while they are being measured. */}
|
|
<div
|
|
ref={setMountEl}
|
|
style={{
|
|
position: "absolute",
|
|
left: "-99999px",
|
|
top: "0",
|
|
visibility: "hidden",
|
|
"pointer-events": "none",
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
ref={setSheetEl}
|
|
style={{ "--md-print-scale": String(scale()) }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
);
|
|
}
|