refactor: pdf clean up
This commit is contained in:
parent
47c15e933c
commit
3154830edd
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { For } from 'solid-js';
|
||||||
|
import { marked } from '../../markdown';
|
||||||
|
import { getLayerStyle } from './hooks/dimensions';
|
||||||
|
import type { LayerConfig, Dimensions, CardData } from './types';
|
||||||
|
|
||||||
|
export interface CardLayerProps {
|
||||||
|
layers: LayerConfig[];
|
||||||
|
dimensions: Dimensions;
|
||||||
|
cardData: CardData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 body 内容中的 {{prop}} 语法并解析 markdown
|
||||||
|
*/
|
||||||
|
function processBody(body: string, currentRow: CardData): string {
|
||||||
|
const processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => currentRow[key] || '');
|
||||||
|
return marked.parse(processedBody) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 layer 内容
|
||||||
|
*/
|
||||||
|
function renderLayerContent(layer: { prop: string }, cardData: CardData): string {
|
||||||
|
const content = cardData[layer.prop] || '';
|
||||||
|
return processBody(content, cardData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardLayer(props: CardLayerProps) {
|
||||||
|
return (
|
||||||
|
<For each={props.layers}>
|
||||||
|
{(layer) => (
|
||||||
|
<div
|
||||||
|
class="absolute flex items-center justify-center text-center prose prose-sm"
|
||||||
|
style={{
|
||||||
|
...getLayerStyle(layer, props.dimensions),
|
||||||
|
'font-size': `${props.dimensions.fontSize}mm`
|
||||||
|
}}
|
||||||
|
innerHTML={renderLayerContent(layer, props.cardData)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,40 +1,19 @@
|
||||||
import { Show, For, createMemo } from 'solid-js';
|
import { Show, For, createMemo } from 'solid-js';
|
||||||
import { marked } from '../../markdown';
|
|
||||||
import { getLayerStyle } from './hooks/dimensions';
|
|
||||||
import { useCardSelection } from './hooks/useCardSelection';
|
import { useCardSelection } from './hooks/useCardSelection';
|
||||||
import { getSelectionBoxStyle } from './hooks/useCardSelection';
|
import { getSelectionBoxStyle } from './hooks/useCardSelection';
|
||||||
|
import { CardLayer } from './CardLayer';
|
||||||
import type { DeckStore } from './hooks/deckStore';
|
import type { DeckStore } from './hooks/deckStore';
|
||||||
import type { CardData } from './types';
|
|
||||||
|
|
||||||
export interface CardPreviewProps {
|
export interface CardPreviewProps {
|
||||||
store: DeckStore;
|
store: DeckStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染 layer 内容
|
|
||||||
*/
|
|
||||||
function renderLayerContent(layer: { prop: string }, cardData: CardData): string {
|
|
||||||
const content = cardData[layer.prop] || '';
|
|
||||||
return processBody(content, cardData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 卡牌预览组件
|
* 卡牌预览组件
|
||||||
*/
|
*/
|
||||||
export function CardPreview(props: CardPreviewProps) {
|
export function CardPreview(props: CardPreviewProps) {
|
||||||
const { store } = props;
|
const { store } = props;
|
||||||
|
|
||||||
// 使用 createMemo 优化计算
|
|
||||||
const currentCard = createMemo(() => store.state.cards[store.state.activeTab]);
|
const currentCard = createMemo(() => store.state.cards[store.state.activeTab]);
|
||||||
const visibleLayers = createMemo(() => store.state.layerConfigs.filter((l) => l.visible));
|
const visibleLayers = createMemo(() => store.state.layerConfigs.filter((l) => l.visible));
|
||||||
const selectionStyle = createMemo(() =>
|
const selectionStyle = createMemo(() =>
|
||||||
|
|
@ -60,7 +39,6 @@ export function CardPreview(props: CardPreviewProps) {
|
||||||
onMouseUp={selection.onMouseUp}
|
onMouseUp={selection.onMouseUp}
|
||||||
onMouseLeave={selection.onMouseLeave}
|
onMouseLeave={selection.onMouseLeave}
|
||||||
>
|
>
|
||||||
{/* 框选遮罩 */}
|
|
||||||
<Show when={store.state.isSelecting && selectionStyle()}>
|
<Show when={store.state.isSelecting && selectionStyle()}>
|
||||||
<div
|
<div
|
||||||
class="absolute bg-blue-500/30 border-2 border-blue-500 pointer-events-none"
|
class="absolute bg-blue-500/30 border-2 border-blue-500 pointer-events-none"
|
||||||
|
|
@ -68,7 +46,6 @@ export function CardPreview(props: CardPreviewProps) {
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* 网格区域容器 */}
|
|
||||||
<div
|
<div
|
||||||
class="absolute"
|
class="absolute"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -78,7 +55,6 @@ export function CardPreview(props: CardPreviewProps) {
|
||||||
height: `${store.state.dimensions?.gridAreaHeight}mm`
|
height: `${store.state.dimensions?.gridAreaHeight}mm`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 编辑模式下的网格线 */}
|
|
||||||
<Show when={store.state.isEditing && !store.state.fixed}>
|
<Show when={store.state.isEditing && !store.state.fixed}>
|
||||||
<div class="absolute inset-0 pointer-events-none">
|
<div class="absolute inset-0 pointer-events-none">
|
||||||
<For each={Array.from({ length: (store.state.dimensions?.gridW || 0) - 1 })}>
|
<For each={Array.from({ length: (store.state.dimensions?.gridW || 0) - 1 })}>
|
||||||
|
|
@ -100,23 +76,11 @@ export function CardPreview(props: CardPreviewProps) {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* 渲染每个 layer */}
|
<CardLayer
|
||||||
<For each={visibleLayers()}>
|
layers={visibleLayers()}
|
||||||
{(layer) => {
|
dimensions={store.state.dimensions!}
|
||||||
return (
|
cardData={currentCard()}
|
||||||
<div
|
/>
|
||||||
class={`absolute flex items-center justify-center text-center prose prose-sm ${
|
|
||||||
store.state.isEditing ? 'bg-blue-500/20 ring-2 ring-blue-500' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
...getLayerStyle(layer, store.state.dimensions!),
|
|
||||||
'font-size': `${store.state.dimensions?.fontSize}mm`
|
|
||||||
}}
|
|
||||||
innerHTML={renderLayerContent(layer, currentCard())}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { For, createMemo } from 'solid-js';
|
import { For } from 'solid-js';
|
||||||
import { marked } from '../../markdown';
|
|
||||||
import { getLayerStyle } from './hooks/dimensions';
|
|
||||||
import type { DeckStore } from './hooks/deckStore';
|
import type { DeckStore } from './hooks/deckStore';
|
||||||
import type { CardData } from './types';
|
import { usePageLayout } from './hooks/usePageLayout';
|
||||||
import jsPDF from 'jspdf';
|
import { usePDFExport, type ExportOptions } from './hooks/usePDFExport';
|
||||||
|
import { PrintPreviewHeader } from './PrintPreviewHeader';
|
||||||
|
import { PrintPreviewFooter } from './PrintPreviewFooter';
|
||||||
|
import { CardLayer } from './CardLayer';
|
||||||
|
|
||||||
export interface PrintPreviewProps {
|
export interface PrintPreviewProps {
|
||||||
store: DeckStore;
|
store: DeckStore;
|
||||||
|
|
@ -11,483 +12,44 @@ export interface PrintPreviewProps {
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染 layer 内容
|
|
||||||
*/
|
|
||||||
function renderLayerContent(layer: { prop: string }, cardData: CardData): string {
|
|
||||||
const content = cardData[layer.prop] || '';
|
|
||||||
return processBody(content, cardData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打印预览组件:在 A4 纸张上排列所有卡牌
|
* 打印预览组件:在 A4 纸张上排列所有卡牌
|
||||||
*/
|
*/
|
||||||
export function PrintPreview(props: PrintPreviewProps) {
|
export function PrintPreview(props: PrintPreviewProps) {
|
||||||
const { store } = props;
|
const { store } = props;
|
||||||
|
const { getA4Size, pages, cropMarks } = usePageLayout(store);
|
||||||
|
const { exportToPDF } = usePDFExport(store, props.onClose);
|
||||||
|
|
||||||
// A4 纸张尺寸(mm):210 x 297
|
const visibleLayers = () => store.state.layerConfigs.filter((l) => l.visible);
|
||||||
const A4_WIDTH_PORTRAIT = 210;
|
|
||||||
const A4_HEIGHT_PORTRAIT = 297;
|
|
||||||
const A4_WIDTH_LANDSCAPE = 297;
|
|
||||||
const A4_HEIGHT_LANDSCAPE = 210;
|
|
||||||
const PRINT_MARGIN = 5; // 打印边距
|
|
||||||
|
|
||||||
// 获取打印设置
|
const handleExport = async () => {
|
||||||
const orientation = () => store.state.printOrientation;
|
const options: ExportOptions = {
|
||||||
const oddPageOffsetX = () => store.state.printOddPageOffsetX;
|
orientation: store.state.printOrientation,
|
||||||
const oddPageOffsetY = () => store.state.printOddPageOffsetY;
|
cardWidth: store.state.dimensions?.cardWidth || 56,
|
||||||
|
cardHeight: store.state.dimensions?.cardHeight || 88,
|
||||||
// 根据方向获取 A4 尺寸
|
gridOriginX: store.state.dimensions?.gridOriginX || 0,
|
||||||
const getA4Size = () => {
|
gridOriginY: store.state.dimensions?.gridOriginY || 0,
|
||||||
if (orientation() === 'landscape') {
|
gridAreaWidth: store.state.dimensions?.gridAreaWidth || 56,
|
||||||
return { width: A4_WIDTH_LANDSCAPE, height: A4_HEIGHT_LANDSCAPE };
|
gridAreaHeight: store.state.dimensions?.gridAreaHeight || 88,
|
||||||
}
|
fontSize: store.state.dimensions?.fontSize || 3,
|
||||||
return { width: A4_WIDTH_PORTRAIT, height: A4_HEIGHT_PORTRAIT };
|
visibleLayers: visibleLayers(),
|
||||||
};
|
dimensions: store.state.dimensions!
|
||||||
|
};
|
||||||
// 计算每张卡牌在 A4 纸上的位置(居中布局)
|
await exportToPDF(pages(), cropMarks(), options);
|
||||||
const pages = createMemo(() => {
|
|
||||||
const cards = store.state.cards;
|
|
||||||
const cardWidth = store.state.dimensions?.cardWidth || 56;
|
|
||||||
const cardHeight = store.state.dimensions?.cardHeight || 88;
|
|
||||||
const { width: a4Width, height: a4Height } = getA4Size();
|
|
||||||
|
|
||||||
// 每行可容纳的卡牌数量
|
|
||||||
const usableWidth = a4Width - PRINT_MARGIN * 2;
|
|
||||||
const cardsPerRow = Math.floor(usableWidth / cardWidth);
|
|
||||||
|
|
||||||
// 每页可容纳的行数
|
|
||||||
const usableHeight = a4Height - PRINT_MARGIN * 2;
|
|
||||||
const rowsPerPage = Math.floor(usableHeight / cardHeight);
|
|
||||||
|
|
||||||
// 每页的卡牌数量
|
|
||||||
const cardsPerPage = cardsPerRow * rowsPerPage;
|
|
||||||
|
|
||||||
// 计算最大卡牌区域的尺寸(用于居中和外围框)
|
|
||||||
const maxGridWidth = cardsPerRow * cardWidth;
|
|
||||||
const maxGridHeight = rowsPerPage * cardHeight;
|
|
||||||
|
|
||||||
// 居中偏移量(使卡牌区域在 A4 纸上居中)
|
|
||||||
const baseOffsetX = (a4Width - maxGridWidth) / 2;
|
|
||||||
const baseOffsetY = (a4Height - maxGridHeight) / 2;
|
|
||||||
|
|
||||||
// 分页
|
|
||||||
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 } };
|
|
||||||
|
|
||||||
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);
|
|
||||||
currentPage = { pageIndex, cards: [], bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 奇数页应用偏移(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;
|
|
||||||
|
|
||||||
currentPage.cards.push({
|
|
||||||
data: cards[i],
|
|
||||||
x: cardX,
|
|
||||||
y: cardY
|
|
||||||
});
|
|
||||||
|
|
||||||
// 更新边界(含 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPage.cards.length > 0) {
|
|
||||||
result.push(currentPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每页添加固定的外围框尺寸(基于最大网格)
|
|
||||||
return result.map(page => ({
|
|
||||||
...page,
|
|
||||||
frameBounds: {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 计算裁切线和外围框位置
|
|
||||||
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 };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const visibleLayers = createMemo(() => store.state.layerConfigs.filter((l) => l.visible));
|
|
||||||
|
|
||||||
// 导出 PDF
|
|
||||||
const handleExportPDF = async () => {
|
|
||||||
const pagesData = pages();
|
|
||||||
const a4Size = getA4Size();
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绘制垂直裁切线
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染卡牌内容
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存 PDF 文件
|
|
||||||
pdf.save('deck.pdf');
|
|
||||||
|
|
||||||
// 完成,关闭预览
|
|
||||||
props.onClose();
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : '导出失败,未知错误';
|
|
||||||
store.actions.setExportError(errorMsg);
|
|
||||||
console.error('PDF 导出失败:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染单个卡片的 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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="fixed inset-0 bg-black/50 z-50 overflow-auto">
|
<div class="fixed inset-0 bg-black/50 z-50 overflow-auto">
|
||||||
<div class="min-h-screen py-20 px-4">
|
<div class="min-h-screen py-20 px-4">
|
||||||
{/* 打印预览控制栏 */}
|
<PrintPreviewHeader
|
||||||
<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">
|
store={store}
|
||||||
<div class="flex items-center gap-4">
|
pageCount={pages().length}
|
||||||
<h2 class="text-base font-bold mt-0 mb-0">打印预览</h2>
|
onExport={handleExport}
|
||||||
<p class="text-xs text-gray-500 mb-0">共 {pages().length} 页,{store.state.cards.length} 张卡牌</p>
|
onClose={props.onClose}
|
||||||
</div>
|
/>
|
||||||
<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>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleExportPDF}
|
|
||||||
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>导出 PDF</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>
|
|
||||||
|
|
||||||
{/* 进度条和错误信息 */}
|
<PrintPreviewFooter store={store} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* A4 纸张预览:每页都是一个完整的 SVG */}
|
|
||||||
<div class="flex flex-col items-center gap-8">
|
<div class="flex flex-col items-center gap-8">
|
||||||
<For each={pages()}>
|
<For each={pages()}>
|
||||||
{(page) => (
|
{(page) => (
|
||||||
|
|
@ -501,7 +63,6 @@ export function PrintPreview(props: PrintPreviewProps) {
|
||||||
data-page={page.pageIndex + 1}
|
data-page={page.pageIndex + 1}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
{/* 外围边框:黑色 0.2mm */}
|
|
||||||
<rect
|
<rect
|
||||||
x={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.x}mm`}
|
x={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.x}mm`}
|
||||||
y={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.y}mm`}
|
y={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.y}mm`}
|
||||||
|
|
@ -512,11 +73,9 @@ export function PrintPreview(props: PrintPreviewProps) {
|
||||||
stroke-width="0.2"
|
stroke-width="0.2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 水平裁切线 */}
|
|
||||||
<For each={cropMarks()[page.pageIndex]?.horizontalLines}>
|
<For each={cropMarks()[page.pageIndex]?.horizontalLines}>
|
||||||
{(line) => (
|
{(line) => (
|
||||||
<>
|
<>
|
||||||
{/* 左侧裁切线(外围框外部) */}
|
|
||||||
<line
|
<line
|
||||||
x1={`${line.xStart}mm`}
|
x1={`${line.xStart}mm`}
|
||||||
y1={`${line.y}mm`}
|
y1={`${line.y}mm`}
|
||||||
|
|
@ -525,7 +84,6 @@ export function PrintPreview(props: PrintPreviewProps) {
|
||||||
stroke="#888"
|
stroke="#888"
|
||||||
stroke-width="0.1"
|
stroke-width="0.1"
|
||||||
/>
|
/>
|
||||||
{/* 右侧裁切线(外围框外部) */}
|
|
||||||
<line
|
<line
|
||||||
x1={`${page.frameBounds.maxX}mm`}
|
x1={`${page.frameBounds.maxX}mm`}
|
||||||
y1={`${line.y}mm`}
|
y1={`${line.y}mm`}
|
||||||
|
|
@ -537,11 +95,10 @@ export function PrintPreview(props: PrintPreviewProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
{/* 垂直裁切线 */}
|
|
||||||
<For each={cropMarks()[page.pageIndex]?.verticalLines}>
|
<For each={cropMarks()[page.pageIndex]?.verticalLines}>
|
||||||
{(line) => (
|
{(line) => (
|
||||||
<>
|
<>
|
||||||
{/* 上方裁切线(外围框外部) */}
|
|
||||||
<line
|
<line
|
||||||
x1={`${line.x}mm`}
|
x1={`${line.x}mm`}
|
||||||
y1={`${line.yStart}mm`}
|
y1={`${line.yStart}mm`}
|
||||||
|
|
@ -550,7 +107,6 @@ export function PrintPreview(props: PrintPreviewProps) {
|
||||||
stroke="#888"
|
stroke="#888"
|
||||||
stroke-width="0.1"
|
stroke-width="0.1"
|
||||||
/>
|
/>
|
||||||
{/* 下方裁切线(外围框外部) */}
|
|
||||||
<line
|
<line
|
||||||
x1={`${line.x}mm`}
|
x1={`${line.x}mm`}
|
||||||
y1={`${page.frameBounds.maxY}mm`}
|
y1={`${page.frameBounds.maxY}mm`}
|
||||||
|
|
@ -563,9 +119,36 @@ export function PrintPreview(props: PrintPreviewProps) {
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
{/* 渲染该页的所有卡牌 */}
|
|
||||||
<For each={page.cards}>
|
<For each={page.cards}>
|
||||||
{(card) => renderCardInSvg(card, page.pageIndex)}
|
{(card) => (
|
||||||
|
<g class="card-group">
|
||||||
|
<foreignObject
|
||||||
|
x={`${card.x}mm`}
|
||||||
|
y={`${card.y}mm`}
|
||||||
|
width={`${store.state.dimensions?.cardWidth || 56}mm`}
|
||||||
|
height={`${store.state.dimensions?.cardHeight || 88}mm`}
|
||||||
|
>
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" class="w-full h-full bg-white">
|
||||||
|
<div
|
||||||
|
class="absolute"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${store.state.dimensions?.gridOriginX}mm`,
|
||||||
|
top: `${store.state.dimensions?.gridOriginY}mm`,
|
||||||
|
width: `${store.state.dimensions?.gridAreaWidth}mm`,
|
||||||
|
height: `${store.state.dimensions?.gridAreaHeight}mm`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardLayer
|
||||||
|
layers={visibleLayers()}
|
||||||
|
dimensions={store.state.dimensions!}
|
||||||
|
cardData={card.data}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Show } from 'solid-js';
|
||||||
|
import type { DeckStore } from './hooks/deckStore';
|
||||||
|
|
||||||
|
export interface PrintPreviewFooterProps {
|
||||||
|
store: DeckStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrintPreviewFooter(props: PrintPreviewFooterProps) {
|
||||||
|
const { store } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { DeckStore } from './hooks/deckStore';
|
||||||
|
|
||||||
|
export interface PrintPreviewHeaderProps {
|
||||||
|
store: DeckStore;
|
||||||
|
pageCount: number;
|
||||||
|
onExport: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
|
||||||
|
const { store } = props;
|
||||||
|
const orientation = () => store.state.printOrientation;
|
||||||
|
const oddPageOffsetX = () => store.state.printOddPageOffsetX;
|
||||||
|
const oddPageOffsetY = () => store.state.printOddPageOffsetY;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<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">共 {props.pageCount} 页,{store.state.cards.length} 张卡牌</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={props.onExport}
|
||||||
|
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>导出 PDF</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { marked } from '../../../markdown';
|
||||||
|
import { getLayerStyle } from './dimensions';
|
||||||
|
import type { DeckStore } from './deckStore';
|
||||||
|
import type { CardData, LayerConfig, Dimensions } from '../types';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 body 内容中的 {{prop}} 语法并解析 markdown
|
||||||
|
*/
|
||||||
|
function processBody(body: string, currentRow: CardData): string {
|
||||||
|
const processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => currentRow[key] || '');
|
||||||
|
return marked.parse(processedBody) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 layer 内容
|
||||||
|
*/
|
||||||
|
function renderLayerContent(layer: { prop: string }, cardData: CardData): string {
|
||||||
|
const content = cardData[layer.prop] || '';
|
||||||
|
return processBody(content, cardData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageCard {
|
||||||
|
data: CardData;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageData {
|
||||||
|
pageIndex: number;
|
||||||
|
cards: PageCard[];
|
||||||
|
bounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
frameBounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CropMarkData {
|
||||||
|
horizontalLines: { y: number; xStart: number; xEnd: number }[];
|
||||||
|
verticalLines: { x: number; yStart: number; yEnd: number }[];
|
||||||
|
frameBounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
frameBoundsWithMargin: { x: number; y: number; width: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportOptions {
|
||||||
|
orientation: 'portrait' | 'landscape';
|
||||||
|
cardWidth: number;
|
||||||
|
cardHeight: number;
|
||||||
|
gridOriginX: number;
|
||||||
|
gridOriginY: number;
|
||||||
|
gridAreaWidth: number;
|
||||||
|
gridAreaHeight: number;
|
||||||
|
fontSize: number;
|
||||||
|
visibleLayers: LayerConfig[];
|
||||||
|
dimensions: Dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染卡牌到 canvas
|
||||||
|
*/
|
||||||
|
async function renderCardToCanvas(card: PageCard, options: ExportOptions): Promise<HTMLCanvasElement> {
|
||||||
|
const { cardWidth, cardHeight, gridOriginX, gridOriginY, gridAreaWidth, gridAreaHeight, fontSize, visibleLayers, dimensions } = options;
|
||||||
|
|
||||||
|
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`;
|
||||||
|
|
||||||
|
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, dimensions));
|
||||||
|
layerEl.style.fontSize = `${fontSize}mm`;
|
||||||
|
layerEl.innerHTML = renderLayerContent(layer, card.data);
|
||||||
|
gridContainer.appendChild(layerEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(gridContainer);
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html2canvas = (await import('html2canvas')).default;
|
||||||
|
return await html2canvas(container, {
|
||||||
|
scale: 2,
|
||||||
|
backgroundColor: null,
|
||||||
|
logging: false,
|
||||||
|
useCORS: true
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制页面边框和裁切线
|
||||||
|
*/
|
||||||
|
function drawPageMarks(pdf: jsPDF, cropData: CropMarkData, pageFrameBounds: { minX: number; maxX: number; minY: number; maxY: number }) {
|
||||||
|
const frameMargin = cropData.frameBoundsWithMargin;
|
||||||
|
|
||||||
|
// 外围边框
|
||||||
|
pdf.setDrawColor(0);
|
||||||
|
pdf.setLineWidth(0.2);
|
||||||
|
pdf.rect(frameMargin.x, frameMargin.y, frameMargin.width, frameMargin.height);
|
||||||
|
|
||||||
|
// 水平裁切线
|
||||||
|
pdf.setDrawColor(136);
|
||||||
|
pdf.setLineWidth(0.1);
|
||||||
|
for (const line of cropData.horizontalLines) {
|
||||||
|
pdf.line(line.xStart, line.y, pageFrameBounds.minX, line.y);
|
||||||
|
pdf.line(pageFrameBounds.maxX, line.y, line.xEnd, line.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 垂直裁切线
|
||||||
|
for (const line of cropData.verticalLines) {
|
||||||
|
pdf.line(line.x, line.yStart, line.x, pageFrameBounds.minY);
|
||||||
|
pdf.line(line.x, pageFrameBounds.maxY, line.x, line.yEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePDFExportReturn {
|
||||||
|
exportToPDF: (pages: PageData[], cropMarks: CropMarkData[], options: ExportOptions) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF 导出 hook
|
||||||
|
*/
|
||||||
|
export function usePDFExport(store: DeckStore, onClose: () => void): UsePDFExportReturn {
|
||||||
|
const exportToPDF = async (pages: PageData[], cropMarks: CropMarkData[], options: ExportOptions) => {
|
||||||
|
const totalPages = pages.length;
|
||||||
|
|
||||||
|
store.actions.setExportProgress(0);
|
||||||
|
store.actions.setExportError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdf = new jsPDF({
|
||||||
|
orientation: options.orientation,
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4'
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < totalPages; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
pdf.addPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pages[i];
|
||||||
|
const cropData = cropMarks[i];
|
||||||
|
|
||||||
|
drawPageMarks(pdf, cropData, page.frameBounds);
|
||||||
|
|
||||||
|
const totalCards = page.cards.length;
|
||||||
|
for (let j = 0; j < totalCards; j++) {
|
||||||
|
const card = page.cards[j];
|
||||||
|
const canvas = await renderCardToCanvas(card, options);
|
||||||
|
const imgData = canvas.toDataURL('image/png');
|
||||||
|
pdf.addImage(imgData, 'PNG', card.x, card.y, options.cardWidth, options.cardHeight);
|
||||||
|
|
||||||
|
const currentCardIndex = i * totalCards + j + 1;
|
||||||
|
const totalCardCount = totalPages * totalCards;
|
||||||
|
const progress = Math.round((currentCardIndex / totalCardCount) * 100);
|
||||||
|
store.actions.setExportProgress(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.save('deck.pdf');
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : '导出失败,未知错误';
|
||||||
|
store.actions.setExportError(errorMsg);
|
||||||
|
console.error('PDF 导出失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { exportToPDF };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { createMemo } from 'solid-js';
|
||||||
|
import type { DeckStore } from './deckStore';
|
||||||
|
import type { PageData, CropMarkData } from './usePDFExport';
|
||||||
|
|
||||||
|
export interface A4Size {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePageLayoutReturn {
|
||||||
|
getA4Size: () => A4Size;
|
||||||
|
pages: ReturnType<typeof createMemo<PageData[]>>;
|
||||||
|
cropMarks: ReturnType<typeof createMemo<CropMarkData[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const A4_WIDTH_PORTRAIT = 210;
|
||||||
|
const A4_HEIGHT_PORTRAIT = 297;
|
||||||
|
const A4_WIDTH_LANDSCAPE = 297;
|
||||||
|
const A4_HEIGHT_LANDSCAPE = 210;
|
||||||
|
const PRINT_MARGIN = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面布局计算 hook
|
||||||
|
*/
|
||||||
|
export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
|
||||||
|
const orientation = () => store.state.printOrientation;
|
||||||
|
const oddPageOffsetX = () => store.state.printOddPageOffsetX;
|
||||||
|
const oddPageOffsetY = () => store.state.printOddPageOffsetY;
|
||||||
|
|
||||||
|
const getA4Size = () => {
|
||||||
|
if (orientation() === 'landscape') {
|
||||||
|
return { width: A4_WIDTH_LANDSCAPE, height: A4_HEIGHT_LANDSCAPE };
|
||||||
|
}
|
||||||
|
return { width: A4_WIDTH_PORTRAIT, height: A4_HEIGHT_PORTRAIT };
|
||||||
|
};
|
||||||
|
|
||||||
|
const pages = createMemo<PageData[]>(() => {
|
||||||
|
const cards = store.state.cards;
|
||||||
|
const cardWidth = store.state.dimensions?.cardWidth || 56;
|
||||||
|
const cardHeight = store.state.dimensions?.cardHeight || 88;
|
||||||
|
const { width: a4Width, height: a4Height } = getA4Size();
|
||||||
|
|
||||||
|
const usableWidth = a4Width - PRINT_MARGIN * 2;
|
||||||
|
const cardsPerRow = Math.floor(usableWidth / cardWidth);
|
||||||
|
const usableHeight = a4Height - PRINT_MARGIN * 2;
|
||||||
|
const rowsPerPage = Math.floor(usableHeight / cardHeight);
|
||||||
|
const cardsPerPage = cardsPerRow * rowsPerPage;
|
||||||
|
|
||||||
|
const maxGridWidth = cardsPerRow * cardWidth;
|
||||||
|
const maxGridHeight = rowsPerPage * cardHeight;
|
||||||
|
const baseOffsetX = (a4Width - maxGridWidth) / 2;
|
||||||
|
const baseOffsetY = (a4Height - maxGridHeight) / 2;
|
||||||
|
|
||||||
|
const result: PageData[] = [];
|
||||||
|
let currentPage: PageData = {
|
||||||
|
pageIndex: 0,
|
||||||
|
cards: [],
|
||||||
|
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||||
|
frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
currentPage = {
|
||||||
|
pageIndex,
|
||||||
|
cards: [],
|
||||||
|
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||||
|
frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
currentPage.cards.push({ data: cards[i], x: cardX, y: cardY });
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage.cards.length > 0) {
|
||||||
|
result.push(currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.map(page => ({
|
||||||
|
...page,
|
||||||
|
frameBounds: {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const cropMarks = createMemo<CropMarkData[]>(() => {
|
||||||
|
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;
|
||||||
|
|
||||||
|
const horizontalLines = sortedY.map(y => ({
|
||||||
|
y,
|
||||||
|
xStart: frameBounds.minX - OVERLAP,
|
||||||
|
xEnd: frameBounds.maxX + OVERLAP
|
||||||
|
}));
|
||||||
|
|
||||||
|
const verticalLines = sortedX.map(x => ({
|
||||||
|
x,
|
||||||
|
yStart: frameBounds.minY - OVERLAP,
|
||||||
|
yEnd: frameBounds.maxY + OVERLAP
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { getA4Size, pages, cropMarks };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue