ttrpg-tools/src/components/md-deck/PrintPreview.tsx

578 lines
22 KiB
TypeScript
Raw Normal View History

2026-02-27 16:32:45 +08:00
import { For, createMemo } from 'solid-js';
2026-02-27 16:02:53 +08:00
import { marked } from '../../markdown';
import { getLayerStyle } from './hooks/dimensions';
import type { DeckStore } from './hooks/deckStore';
2026-02-27 20:29:48 +08:00
import type { CardData } from './types';
2026-02-27 20:27:26 +08:00
import jsPDF from 'jspdf';
2026-02-27 16:02:53 +08:00
export interface PrintPreviewProps {
store: DeckStore;
onClose: () => void;
2026-02-27 20:27:26 +08:00
onExport: () => void;
2026-02-27 16:02:53 +08:00
}
2026-02-27 20:29:48 +08:00
/**
* body {{prop}} markdown
*/
function processBody(body: string, currentRow: CardData): string {
// 替换 {{prop}} 为对应列的内容
const processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => currentRow[key] || '');
// 使用 marked 解析 markdown
return marked.parse(processedBody) as string;
}
2026-02-27 16:02:53 +08:00
/**
* layer
*/
2026-02-27 20:29:48 +08:00
function renderLayerContent(layer: { prop: string }, cardData: CardData): string {
2026-02-27 16:02:53 +08:00
const content = cardData[layer.prop] || '';
2026-02-27 20:29:48 +08:00
return processBody(content, cardData);
2026-02-27 16:02:53 +08:00
}
/**
* A4
*/
export function PrintPreview(props: PrintPreviewProps) {
const { store } = props;
// A4 纸张尺寸mm210 x 297
2026-02-27 17:55:02 +08:00
const A4_WIDTH_PORTRAIT = 210;
const A4_HEIGHT_PORTRAIT = 297;
const A4_WIDTH_LANDSCAPE = 297;
const A4_HEIGHT_LANDSCAPE = 210;
2026-02-27 16:02:53 +08:00
const PRINT_MARGIN = 5; // 打印边距
2026-02-27 17:55:02 +08:00
// 获取打印设置
const orientation = () => store.state.printOrientation;
const oddPageOffsetX = () => store.state.printOddPageOffsetX;
const oddPageOffsetY = () => store.state.printOddPageOffsetY;
// 根据方向获取 A4 尺寸
const getA4Size = () => {
if (orientation() === 'landscape') {
return { width: A4_WIDTH_LANDSCAPE, height: A4_HEIGHT_LANDSCAPE };
}
return { width: A4_WIDTH_PORTRAIT, height: A4_HEIGHT_PORTRAIT };
};
2026-02-27 16:32:45 +08:00
// 计算每张卡牌在 A4 纸上的位置(居中布局)
2026-02-27 16:02:53 +08:00
const pages = createMemo(() => {
const cards = store.state.cards;
const cardWidth = store.state.dimensions?.cardWidth || 56;
const cardHeight = store.state.dimensions?.cardHeight || 88;
2026-02-27 17:55:02 +08:00
const { width: a4Width, height: a4Height } = getA4Size();
2026-02-27 16:02:53 +08:00
// 每行可容纳的卡牌数量
2026-02-27 17:55:02 +08:00
const usableWidth = a4Width - PRINT_MARGIN * 2;
2026-02-27 16:02:53 +08:00
const cardsPerRow = Math.floor(usableWidth / cardWidth);
// 每页可容纳的行数
2026-02-27 17:55:02 +08:00
const usableHeight = a4Height - PRINT_MARGIN * 2;
2026-02-27 16:02:53 +08:00
const rowsPerPage = Math.floor(usableHeight / cardHeight);
// 每页的卡牌数量
const cardsPerPage = cardsPerRow * rowsPerPage;
2026-02-27 16:32:45 +08:00
// 计算最大卡牌区域的尺寸(用于居中和外围框)
const maxGridWidth = cardsPerRow * cardWidth;
const maxGridHeight = rowsPerPage * cardHeight;
// 居中偏移量(使卡牌区域在 A4 纸上居中)
2026-02-27 17:55:02 +08:00
const baseOffsetX = (a4Width - maxGridWidth) / 2;
const baseOffsetY = (a4Height - maxGridHeight) / 2;
2026-02-27 16:32:45 +08:00
2026-02-27 16:02:53 +08:00
// 分页
2026-02-27 16:32:45 +08:00
const result: {
pageIndex: number;
cards: Array<{ data: typeof cards[0]; x: number; y: number }>;
bounds: { minX: number; minY: number; maxX: number; maxY: number };
}[] = [];
let currentPage: typeof result[0] = { pageIndex: 0, cards: [], bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } };
2026-02-27 16:02:53 +08:00
for (let i = 0; i < cards.length; i++) {
const pageIndex = Math.floor(i / cardsPerPage);
const indexInPage = i % cardsPerPage;
const row = Math.floor(indexInPage / cardsPerRow);
const col = indexInPage % cardsPerRow;
if (pageIndex !== currentPage.pageIndex) {
result.push(currentPage);
2026-02-27 16:32:45 +08:00
currentPage = { pageIndex, cards: [], bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } };
2026-02-27 16:02:53 +08:00
}
2026-02-27 17:55:02 +08:00
// 奇数页应用偏移pageIndex 从 0 开始,所以偶数索引是奇数页)
const isOddPage = pageIndex % 2 === 0;
const pageOffsetX = isOddPage ? oddPageOffsetX() : 0;
const pageOffsetY = isOddPage ? oddPageOffsetY() : 0;
// 使用居中偏移量 + 奇数页偏移计算卡牌位置
const cardX = baseOffsetX + col * cardWidth + pageOffsetX;
const cardY = baseOffsetY + row * cardHeight + pageOffsetY;
2026-02-27 16:32:45 +08:00
2026-02-27 16:02:53 +08:00
currentPage.cards.push({
data: cards[i],
2026-02-27 16:32:45 +08:00
x: cardX,
y: cardY
2026-02-27 16:02:53 +08:00
});
2026-02-27 16:32:45 +08:00
// 更新边界(含 1mm 边距)
currentPage.bounds.minX = Math.min(currentPage.bounds.minX, cardX);
currentPage.bounds.minY = Math.min(currentPage.bounds.minY, cardY);
currentPage.bounds.maxX = Math.max(currentPage.bounds.maxX, cardX + cardWidth);
currentPage.bounds.maxY = Math.max(currentPage.bounds.maxY, cardY + cardHeight);
2026-02-27 16:02:53 +08:00
}
if (currentPage.cards.length > 0) {
result.push(currentPage);
}
2026-02-27 16:32:45 +08:00
// 为每页添加固定的外围框尺寸(基于最大网格)
return result.map(page => ({
...page,
frameBounds: {
2026-02-27 17:55:02 +08:00
minX: baseOffsetX + (page.pageIndex % 2 === 0 ? oddPageOffsetX() : 0),
minY: baseOffsetY + (page.pageIndex % 2 === 0 ? oddPageOffsetY() : 0),
maxX: baseOffsetX + maxGridWidth + (page.pageIndex % 2 === 0 ? oddPageOffsetX() : 0),
maxY: baseOffsetY + maxGridHeight + (page.pageIndex % 2 === 0 ? oddPageOffsetY() : 0)
2026-02-27 16:32:45 +08:00
}
}));
});
// 计算裁切线和外围框位置
const cropMarks = createMemo(() => {
const pagesData = pages();
return pagesData.map(page => {
const { frameBounds, cards } = page;
const cardWidth = store.state.dimensions?.cardWidth || 56;
const cardHeight = store.state.dimensions?.cardHeight || 88;
// 收集所有唯一的裁切线位置
const xPositions = new Set<number>();
const yPositions = new Set<number>();
cards.forEach(card => {
xPositions.add(card.x);
xPositions.add(card.x + cardWidth);
yPositions.add(card.y);
yPositions.add(card.y + cardHeight);
});
const sortedX = Array.from(xPositions).sort((a, b) => a - b);
const sortedY = Array.from(yPositions).sort((a, b) => a - b);
// 裁切线超出外围框的距离
const OVERLAP = 3; // 3mm
// 生成水平裁切线(沿 Y 轴)
const horizontalLines = sortedY.map(y => ({
y,
xStart: frameBounds.minX - OVERLAP,
xEnd: frameBounds.maxX + OVERLAP
}));
// 生成垂直裁切线(沿 X 轴)
const verticalLines = sortedX.map(x => ({
x,
yStart: frameBounds.minY - OVERLAP,
yEnd: frameBounds.maxY + OVERLAP
}));
// 外围框边界(离卡牌区域边缘 1mm
const frameBoundsWithMargin = {
x: frameBounds.minX - 1,
y: frameBounds.minY - 1,
width: frameBounds.maxX - frameBounds.minX + 2,
height: frameBounds.maxY - frameBounds.minY + 2
};
return { horizontalLines, verticalLines, frameBounds, frameBoundsWithMargin };
});
2026-02-27 16:02:53 +08:00
});
const visibleLayers = createMemo(() => store.state.layerConfigs.filter((l) => l.visible));
2026-02-27 20:27:26 +08:00
// 导出 PDF
const handleExportPDF = async () => {
const pagesData = pages();
const a4Size = getA4Size();
2026-02-27 21:02:33 +08:00
const totalPages = pagesData.length;
// 重置状态
store.actions.setExportProgress(0);
store.actions.setExportError(null);
try {
// 创建 jsPDF 实例
const pdf = new jsPDF({
orientation: orientation() === 'landscape' ? 'landscape' : 'portrait',
unit: 'mm',
format: 'a4'
});
2026-02-27 20:54:28 +08:00
2026-02-27 21:02:33 +08:00
const cardWidth = store.state.dimensions?.cardWidth || 56;
const cardHeight = store.state.dimensions?.cardHeight || 88;
const gridOriginX = store.state.dimensions?.gridOriginX || 0;
const gridOriginY = store.state.dimensions?.gridOriginY || 0;
const gridAreaWidth = store.state.dimensions?.gridAreaWidth || cardWidth;
const gridAreaHeight = store.state.dimensions?.gridAreaHeight || cardHeight;
const fontSize = store.state.dimensions?.fontSize || 3;
// 为每页生成内容
for (let i = 0; i < totalPages; i++) {
if (i > 0) {
pdf.addPage();
}
2026-02-27 20:27:26 +08:00
2026-02-27 21:02:33 +08:00
const page = pagesData[i];
const cropData = cropMarks()[i];
// 绘制外围边框
const frameMargin = cropData.frameBoundsWithMargin;
pdf.setDrawColor(0);
pdf.setLineWidth(0.2);
pdf.rect(frameMargin.x, frameMargin.y, frameMargin.width, frameMargin.height);
// 绘制水平裁切线
for (const line of cropData.horizontalLines) {
pdf.setDrawColor(136);
pdf.setLineWidth(0.1);
// 左侧裁切线
pdf.line(line.xStart, line.y, page.frameBounds.minX, line.y);
// 右侧裁切线
pdf.line(page.frameBounds.maxX, line.y, line.xEnd, line.y);
2026-02-27 20:27:26 +08:00
}
2026-02-27 21:02:33 +08:00
// 绘制垂直裁切线
for (const line of cropData.verticalLines) {
pdf.setDrawColor(136);
pdf.setLineWidth(0.1);
// 上方裁切线
pdf.line(line.x, line.yStart, line.x, page.frameBounds.minY);
// 下方裁切线
pdf.line(line.x, page.frameBounds.maxY, line.x, line.yEnd);
2026-02-27 20:27:26 +08:00
}
2026-02-27 21:02:33 +08:00
// 渲染卡牌内容
const totalCards = page.cards.length;
for (let j = 0; j < totalCards; j++) {
const card = page.cards[j];
// 创建临时容器渲染卡牌内容
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.left = '-9999px';
container.style.top = '-9999px';
container.style.width = `${cardWidth}mm`;
container.style.height = `${cardHeight}mm`;
container.style.background = 'white';
// 网格区域容器
const gridContainer = document.createElement('div');
gridContainer.style.position = 'absolute';
gridContainer.style.left = `${gridOriginX}mm`;
gridContainer.style.top = `${gridOriginY}mm`;
gridContainer.style.width = `${gridAreaWidth}mm`;
gridContainer.style.height = `${gridAreaHeight}mm`;
// 渲染每个 layer
for (const layer of visibleLayers()) {
const layerEl = document.createElement('div');
layerEl.className = 'absolute flex items-center justify-center text-center prose prose-sm';
Object.assign(layerEl.style, getLayerStyle(layer, store.state.dimensions!));
layerEl.style.fontSize = `${fontSize}mm`;
layerEl.innerHTML = renderLayerContent(layer, card.data);
gridContainer.appendChild(layerEl);
}
container.appendChild(gridContainer);
document.body.appendChild(container);
// 使用 html2canvas 渲染
try {
const html2canvas = (await import('html2canvas')).default;
const canvas = await html2canvas(container, {
scale: 2,
backgroundColor: null,
logging: false,
useCORS: true
});
const imgData = canvas.toDataURL('image/png');
pdf.addImage(imgData, 'PNG', card.x, card.y, cardWidth, cardHeight);
} catch (e) {
console.error('渲染卡牌内容失败:', e);
}
document.body.removeChild(container);
// 更新进度(按卡牌数量计算)
const currentCardIndex = i * totalCards + j + 1;
const totalCardCount = totalPages * totalCards;
const progress = Math.round((currentCardIndex / totalCardCount) * 100);
store.actions.setExportProgress(progress);
}
2026-02-27 20:27:26 +08:00
}
2026-02-27 21:02:33 +08:00
// 保存 PDF 文件
pdf.save('deck.pdf');
// 完成,关闭预览
props.onClose();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '导出失败,未知错误';
store.actions.setExportError(errorMsg);
console.error('PDF 导出失败:', err);
}
2026-02-27 20:27:26 +08:00
};
2026-02-27 18:19:37 +08:00
// 渲染单个卡片的 SVG 内容(使用 foreignObject
const renderCardInSvg = (card: { data: typeof store.state.cards[0]; x: number; y: number }, pageIndex: number) => {
const cardWidth = store.state.dimensions?.cardWidth || 56;
const cardHeight = store.state.dimensions?.cardHeight || 88;
const gridOriginX = store.state.dimensions?.gridOriginX || 0;
const gridOriginY = store.state.dimensions?.gridOriginY || 0;
const gridAreaWidth = store.state.dimensions?.gridAreaWidth || cardWidth;
const gridAreaHeight = store.state.dimensions?.gridAreaHeight || cardHeight;
const fontSize = store.state.dimensions?.fontSize || 3;
return (
<g class="card-group">
<foreignObject
x={`${card.x}mm`}
y={`${card.y}mm`}
width={`${cardWidth}mm`}
height={`${cardHeight}mm`}
>
<div xmlns="http://www.w3.org/1999/xhtml" class="w-full h-full bg-white">
{/* 网格区域容器 */}
<div
class="absolute"
style={{
position: 'absolute',
left: `${gridOriginX}mm`,
top: `${gridOriginY}mm`,
width: `${gridAreaWidth}mm`,
height: `${gridAreaHeight}mm`
}}
>
{/* 渲染每个 layer */}
<For each={visibleLayers()}>
{(layer) => (
<div
class="absolute flex items-center justify-center text-center prose prose-sm"
style={{
...getLayerStyle(layer, store.state.dimensions!),
'font-size': `${fontSize}mm`
}}
innerHTML={renderLayerContent(layer, card.data)}
/>
)}
</For>
</div>
</div>
</foreignObject>
</g>
);
};
2026-02-27 16:02:53 +08:00
return (
2026-02-27 20:27:26 +08:00
<div class="fixed inset-0 bg-black/50 z-50 overflow-auto">
<div class="min-h-screen py-20 px-4">
2026-02-27 16:02:53 +08:00
{/* 打印预览控制栏 */}
2026-02-27 17:55:02 +08:00
<div class="fixed top-0 left-0 right-0 z-50 bg-white shadow-lg rounded-lg mx-4 mt-4 px-4 py-3 flex items-center justify-between gap-4">
2026-02-27 16:02:53 +08:00
<div class="flex items-center gap-4">
<h2 class="text-base font-bold mt-0 mb-0"></h2>
<p class="text-xs text-gray-500 mb-0"> {pages().length} {store.state.cards.length} </p>
</div>
2026-02-27 17:55:02 +08:00
<div class="flex items-center gap-4">
{/* 方向选择 */}
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600">:</label>
<div class="flex gap-1">
<button
onClick={() => store.actions.setPrintOrientation('portrait')}
class={`px-3 py-1 rounded text-sm font-medium cursor-pointer border ${
orientation() === 'portrait'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
</button>
<button
onClick={() => store.actions.setPrintOrientation('landscape')}
class={`px-3 py-1 rounded text-sm font-medium cursor-pointer border ${
orientation() === 'landscape'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
</button>
</div>
</div>
{/* 奇数页偏移 */}
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600">:</label>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500">X:</span>
<input
type="number"
value={oddPageOffsetX()}
onChange={(e) => store.actions.setPrintOddPageOffsetX(Number(e.target.value))}
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm"
step="0.1"
/>
<span class="text-xs text-gray-500 ml-1">mm</span>
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-500">Y:</span>
<input
type="number"
value={oddPageOffsetY()}
onChange={(e) => store.actions.setPrintOddPageOffsetY(Number(e.target.value))}
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm"
step="0.1"
/>
<span class="text-xs text-gray-500 ml-1">mm</span>
</div>
</div>
</div>
2026-02-27 16:02:53 +08:00
<div class="flex gap-2">
<button
2026-02-27 20:27:26 +08:00
onClick={handleExportPDF}
2026-02-27 16:02:53 +08:00
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded text-sm font-medium cursor-pointer flex items-center gap-2"
>
2026-02-27 20:27:26 +08:00
<span>📥</span>
<span> PDF</span>
2026-02-27 16:02:53 +08:00
</button>
<button
onClick={props.onClose}
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-1.5 rounded text-sm font-medium cursor-pointer"
>
</button>
</div>
</div>
2026-02-27 21:02:33 +08:00
{/* 进度条和错误信息 */}
<Show when={store.state.exportProgress > 0 || store.state.exportError}>
<div class="fixed bottom-0 left-0 right-0 z-50 bg-white shadow-lg rounded-lg mx-4 mb-4 px-4 py-3">
<Show when={!store.state.exportError}>
<div class="flex items-center gap-4">
<span class="text-sm text-gray-600"></span>
<div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full bg-blue-600 transition-all duration-200"
style={{ width: `${store.state.exportProgress}%` }}
/>
</div>
<span class="text-sm font-medium text-gray-700">{store.state.exportProgress}%</span>
</div>
</Show>
<Show when={store.state.exportError}>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2 text-red-600">
<span></span>
<span class="text-sm font-medium">{store.state.exportError}</span>
</div>
<button
onClick={() => store.actions.clearExportError()}
class="text-gray-500 hover:text-gray-700 cursor-pointer"
>
</button>
</div>
</Show>
</div>
</Show>
2026-02-27 18:19:37 +08:00
{/* A4 纸张预览:每页都是一个完整的 SVG */}
2026-02-27 20:27:26 +08:00
<div class="flex flex-col items-center gap-8">
2026-02-27 16:02:53 +08:00
<For each={pages()}>
{(page) => (
2026-02-27 18:19:37 +08:00
<svg
2026-02-27 20:27:26 +08:00
class="bg-white shadow-xl"
2026-02-27 18:19:37 +08:00
viewBox={`0 0 ${getA4Size().width}mm ${getA4Size().height}mm`}
2026-02-27 16:02:53 +08:00
style={{
2026-02-27 17:55:02 +08:00
width: `${getA4Size().width}mm`,
height: `${getA4Size().height}mm`
2026-02-27 16:02:53 +08:00
}}
data-page={page.pageIndex + 1}
2026-02-27 18:19:37 +08:00
xmlns="http://www.w3.org/2000/svg"
2026-02-27 16:02:53 +08:00
>
2026-02-27 18:19:37 +08:00
{/* 外围边框:黑色 0.2mm */}
<rect
x={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.x}mm`}
y={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.y}mm`}
width={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.width}mm`}
height={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.height}mm`}
fill="none"
stroke="black"
stroke-width="0.2"
/>
{/* 水平裁切线 */}
<For each={cropMarks()[page.pageIndex]?.horizontalLines}>
{(line) => (
<>
{/* 左侧裁切线(外围框外部) */}
<line
x1={`${line.xStart}mm`}
y1={`${line.y}mm`}
x2={`${page.frameBounds.minX}mm`}
y2={`${line.y}mm`}
stroke="#888"
stroke-width="0.1"
/>
{/* 右侧裁切线(外围框外部) */}
<line
x1={`${page.frameBounds.maxX}mm`}
y1={`${line.y}mm`}
x2={`${line.xEnd}mm`}
y2={`${line.y}mm`}
stroke="#888"
stroke-width="0.1"
/>
</>
)}
</For>
{/* 垂直裁切线 */}
<For each={cropMarks()[page.pageIndex]?.verticalLines}>
{(line) => (
<>
{/* 上方裁切线(外围框外部) */}
<line
x1={`${line.x}mm`}
y1={`${line.yStart}mm`}
x2={`${line.x}mm`}
y2={`${page.frameBounds.minY}mm`}
stroke="#888"
stroke-width="0.1"
/>
{/* 下方裁切线(外围框外部) */}
<line
x1={`${line.x}mm`}
y1={`${page.frameBounds.maxY}mm`}
x2={`${line.x}mm`}
y2={`${line.yEnd}mm`}
stroke="#888"
stroke-width="0.1"
/>
</>
)}
</For>
2026-02-27 16:02:53 +08:00
{/* 渲染该页的所有卡牌 */}
2026-02-27 18:19:37 +08:00
<For each={page.cards}>
{(card) => renderCardInSvg(card, page.pageIndex)}
</For>
</svg>
2026-02-27 16:02:53 +08:00
)}
</For>
</div>
</div>
</div>
);
}