Files
ttrpg-tools/src/components/md-print/PrintPreview.tsx
T

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