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';
|
|
|
|
|
|
|
|
|
|
|
|
export interface PrintPreviewProps {
|
|
|
|
|
|
store: DeckStore;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onPrint: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 渲染 layer 内容
|
|
|
|
|
|
*/
|
|
|
|
|
|
function renderLayerContent(layer: { prop: string }, cardData: { [key: string]: string }): string {
|
|
|
|
|
|
const content = cardData[layer.prop] || '';
|
|
|
|
|
|
return marked.parse(content) as string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 打印预览组件:在 A4 纸张上排列所有卡牌
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function PrintPreview(props: PrintPreviewProps) {
|
|
|
|
|
|
const { store } = props;
|
|
|
|
|
|
|
|
|
|
|
|
// A4 纸张尺寸(mm):210 x 297
|
|
|
|
|
|
const A4_WIDTH = 210;
|
|
|
|
|
|
const A4_HEIGHT = 297;
|
|
|
|
|
|
const PRINT_MARGIN = 5; // 打印边距
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
// 每行可容纳的卡牌数量
|
|
|
|
|
|
const usableWidth = A4_WIDTH - PRINT_MARGIN * 2;
|
|
|
|
|
|
const cardsPerRow = Math.floor(usableWidth / cardWidth);
|
|
|
|
|
|
|
|
|
|
|
|
// 每页可容纳的行数
|
|
|
|
|
|
const usableHeight = A4_HEIGHT - PRINT_MARGIN * 2;
|
|
|
|
|
|
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 纸上居中)
|
|
|
|
|
|
const offsetX = (A4_WIDTH - maxGridWidth) / 2;
|
|
|
|
|
|
const offsetY = (A4_HEIGHT - maxGridHeight) / 2;
|
|
|
|
|
|
|
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 16:32:45 +08:00
|
|
|
|
// 使用居中偏移量计算卡牌位置
|
|
|
|
|
|
const cardX = offsetX + col * cardWidth;
|
|
|
|
|
|
const cardY = offsetY + row * cardHeight;
|
|
|
|
|
|
|
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: {
|
|
|
|
|
|
minX: offsetX,
|
|
|
|
|
|
minY: offsetY,
|
|
|
|
|
|
maxX: offsetX + maxGridWidth,
|
|
|
|
|
|
maxY: offsetY + maxGridHeight
|
|
|
|
|
|
}
|
|
|
|
|
|
}));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 计算裁切线和外围框位置
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-27 17:02:38 +08:00
|
|
|
|
<div class="fixed inset-0 bg-black/50 z-50 overflow-auto print:overflow-visible print:absolute">
|
|
|
|
|
|
<div class="min-h-screen py-20 px-4 print:p-0">
|
2026-02-27 16:02:53 +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-1 flex items-center justify-between gap-4">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<div class="flex gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={props.onPrint}
|
|
|
|
|
|
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"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>🖨️</span>
|
|
|
|
|
|
<span>打印</span>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* A4 纸张预览 */}
|
2026-02-27 17:02:38 +08:00
|
|
|
|
<div class="flex flex-col items-center gap-8 print-root">
|
2026-02-27 16:02:53 +08:00
|
|
|
|
<For each={pages()}>
|
|
|
|
|
|
{(page) => (
|
|
|
|
|
|
<div
|
2026-02-27 17:02:38 +08:00
|
|
|
|
class="bg-white shadow-xl print:shadow-none print:w-full"
|
2026-02-27 16:02:53 +08:00
|
|
|
|
style={{
|
|
|
|
|
|
width: `${A4_WIDTH}mm`,
|
|
|
|
|
|
height: `${A4_HEIGHT}mm`
|
|
|
|
|
|
}}
|
|
|
|
|
|
data-page={page.pageIndex + 1}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 渲染该页的所有卡牌 */}
|
|
|
|
|
|
<div class="relative w-full h-full">
|
2026-02-27 16:32:45 +08:00
|
|
|
|
{/* 裁切线和外围框层 */}
|
|
|
|
|
|
<svg class="absolute inset-0 w-full h-full pointer-events-none" style={{ overflow: 'visible' }}>
|
|
|
|
|
|
{/* 外围边框:黑色 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>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
2026-02-27 16:02:53 +08:00
|
|
|
|
<For each={page.cards}>
|
|
|
|
|
|
{(card) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute bg-white"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: `${card.x}mm`,
|
|
|
|
|
|
top: `${card.y}mm`,
|
|
|
|
|
|
width: `${store.state.dimensions?.cardWidth}mm`,
|
|
|
|
|
|
height: `${store.state.dimensions?.cardHeight}mm`
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 网格区域容器 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: `${store.state.dimensions?.gridOriginX}mm`,
|
|
|
|
|
|
top: `${store.state.dimensions?.gridOriginY}mm`,
|
|
|
|
|
|
width: `${store.state.dimensions?.gridAreaWidth}mm`,
|
|
|
|
|
|
height: `${store.state.dimensions?.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': `${store.state.dimensions?.fontSize}mm`
|
|
|
|
|
|
}}
|
|
|
|
|
|
innerHTML={renderLayerContent(layer, card.data)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</For>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</For>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</For>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|