diff --git a/src/components/md-deck/CardLayer.tsx b/src/components/md-deck/CardLayer.tsx new file mode 100644 index 0000000..23acba8 --- /dev/null +++ b/src/components/md-deck/CardLayer.tsx @@ -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 ( + + {(layer) => ( +
+ )} + + ); +} diff --git a/src/components/md-deck/CardPreview.tsx b/src/components/md-deck/CardPreview.tsx index 1cfca1e..591fec3 100644 --- a/src/components/md-deck/CardPreview.tsx +++ b/src/components/md-deck/CardPreview.tsx @@ -1,40 +1,19 @@ import { Show, For, createMemo } from 'solid-js'; -import { marked } from '../../markdown'; -import { getLayerStyle } from './hooks/dimensions'; import { useCardSelection } from './hooks/useCardSelection'; import { getSelectionBoxStyle } from './hooks/useCardSelection'; +import { CardLayer } from './CardLayer'; import type { DeckStore } from './hooks/deckStore'; -import type { CardData } from './types'; export interface CardPreviewProps { 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) { const { store } = props; - // 使用 createMemo 优化计算 const currentCard = createMemo(() => store.state.cards[store.state.activeTab]); const visibleLayers = createMemo(() => store.state.layerConfigs.filter((l) => l.visible)); const selectionStyle = createMemo(() => @@ -60,7 +39,6 @@ export function CardPreview(props: CardPreviewProps) { onMouseUp={selection.onMouseUp} onMouseLeave={selection.onMouseLeave} > - {/* 框选遮罩 */}
- {/* 网格区域容器 */}
- {/* 编辑模式下的网格线 */}
@@ -100,23 +76,11 @@ export function CardPreview(props: CardPreviewProps) {
- {/* 渲染每个 layer */} - - {(layer) => { - return ( -
- ); - }} - +
diff --git a/src/components/md-deck/PrintPreview.tsx b/src/components/md-deck/PrintPreview.tsx index ea6ffd1..82ddda5 100644 --- a/src/components/md-deck/PrintPreview.tsx +++ b/src/components/md-deck/PrintPreview.tsx @@ -1,9 +1,10 @@ -import { For, createMemo } from 'solid-js'; -import { marked } from '../../markdown'; -import { getLayerStyle } from './hooks/dimensions'; +import { For } from 'solid-js'; import type { DeckStore } from './hooks/deckStore'; -import type { CardData } from './types'; -import jsPDF from 'jspdf'; +import { usePageLayout } from './hooks/usePageLayout'; +import { usePDFExport, type ExportOptions } from './hooks/usePDFExport'; +import { PrintPreviewHeader } from './PrintPreviewHeader'; +import { PrintPreviewFooter } from './PrintPreviewFooter'; +import { CardLayer } from './CardLayer'; export interface PrintPreviewProps { store: DeckStore; @@ -11,483 +12,44 @@ export interface PrintPreviewProps { 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 纸张上排列所有卡牌 */ export function PrintPreview(props: PrintPreviewProps) { const { store } = props; + const { getA4Size, pages, cropMarks } = usePageLayout(store); + const { exportToPDF } = usePDFExport(store, props.onClose); - // A4 纸张尺寸(mm):210 x 297 - 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 visibleLayers = () => store.state.layerConfigs.filter((l) => l.visible); - // 获取打印设置 - 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 }; - }; - - // 计算每张卡牌在 A4 纸上的位置(居中布局) - 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(); - const yPositions = new Set(); - - 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 ( - - -
- {/* 网格区域容器 */} -
- {/* 渲染每个 layer */} - - {(layer) => ( -
- )} - -
-
- - - ); + const handleExport = async () => { + const options: ExportOptions = { + orientation: store.state.printOrientation, + cardWidth: store.state.dimensions?.cardWidth || 56, + cardHeight: store.state.dimensions?.cardHeight || 88, + gridOriginX: store.state.dimensions?.gridOriginX || 0, + gridOriginY: store.state.dimensions?.gridOriginY || 0, + gridAreaWidth: store.state.dimensions?.gridAreaWidth || 56, + gridAreaHeight: store.state.dimensions?.gridAreaHeight || 88, + fontSize: store.state.dimensions?.fontSize || 3, + visibleLayers: visibleLayers(), + dimensions: store.state.dimensions! + }; + await exportToPDF(pages(), cropMarks(), options); }; return (
- {/* 打印预览控制栏 */} -
-
-

打印预览

-

共 {pages().length} 页,{store.state.cards.length} 张卡牌

-
-
- {/* 方向选择 */} -
- -
- - -
-
- {/* 奇数页偏移 */} -
- -
- X: - store.actions.setPrintOddPageOffsetX(Number(e.target.value))} - class="w-16 px-2 py-1 border border-gray-300 rounded text-sm" - step="0.1" - /> - mm -
-
- Y: - store.actions.setPrintOddPageOffsetY(Number(e.target.value))} - class="w-16 px-2 py-1 border border-gray-300 rounded text-sm" - step="0.1" - /> - mm -
-
-
-
- - -
-
+ - {/* 进度条和错误信息 */} - 0 || store.state.exportError}> -
- -
- 导出进度: -
-
-
- {store.state.exportProgress}% -
- - -
-
- - {store.state.exportError} -
- -
-
-
-
+ - {/* A4 纸张预览:每页都是一个完整的 SVG */}
{(page) => ( @@ -501,7 +63,6 @@ export function PrintPreview(props: PrintPreviewProps) { data-page={page.pageIndex + 1} xmlns="http://www.w3.org/2000/svg" > - {/* 外围边框:黑色 0.2mm */} - {/* 水平裁切线 */} {(line) => ( <> - {/* 左侧裁切线(外围框外部) */} - {/* 右侧裁切线(外围框外部) */} )} - {/* 垂直裁切线 */} + {(line) => ( <> - {/* 上方裁切线(外围框外部) */} - {/* 下方裁切线(外围框外部) */} - {/* 渲染该页的所有卡牌 */} - {(card) => renderCardInSvg(card, page.pageIndex)} + {(card) => ( + + +
+
+ +
+
+
+
+ )}
)} diff --git a/src/components/md-deck/PrintPreviewFooter.tsx b/src/components/md-deck/PrintPreviewFooter.tsx new file mode 100644 index 0000000..b3676f4 --- /dev/null +++ b/src/components/md-deck/PrintPreviewFooter.tsx @@ -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 ( + 0 || store.state.exportError}> +
+ +
+ 导出进度: +
+
+
+ {store.state.exportProgress}% +
+ + +
+
+ + {store.state.exportError} +
+ +
+
+
+
+ ); +} diff --git a/src/components/md-deck/PrintPreviewHeader.tsx b/src/components/md-deck/PrintPreviewHeader.tsx new file mode 100644 index 0000000..7aa5f40 --- /dev/null +++ b/src/components/md-deck/PrintPreviewHeader.tsx @@ -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 ( +
+
+

打印预览

+

共 {props.pageCount} 页,{store.state.cards.length} 张卡牌

+
+
+
+ +
+ + +
+
+
+ +
+ X: + store.actions.setPrintOddPageOffsetX(Number(e.target.value))} + class="w-16 px-2 py-1 border border-gray-300 rounded text-sm" + step="0.1" + /> + mm +
+
+ Y: + store.actions.setPrintOddPageOffsetY(Number(e.target.value))} + class="w-16 px-2 py-1 border border-gray-300 rounded text-sm" + step="0.1" + /> + mm +
+
+
+
+ + +
+
+ ); +} diff --git a/src/components/md-deck/hooks/usePDFExport.ts b/src/components/md-deck/hooks/usePDFExport.ts new file mode 100644 index 0000000..fb06863 --- /dev/null +++ b/src/components/md-deck/hooks/usePDFExport.ts @@ -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 { + 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; +} + +/** + * 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 }; +} diff --git a/src/components/md-deck/hooks/usePageLayout.ts b/src/components/md-deck/hooks/usePageLayout.ts new file mode 100644 index 0000000..27ffd37 --- /dev/null +++ b/src/components/md-deck/hooks/usePageLayout.ts @@ -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>; + cropMarks: ReturnType>; +} + +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(() => { + 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(() => { + 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(); + const yPositions = new Set(); + + 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 }; +}