feat: implement multi-deck merging for print preview

Introduce a deck registry to track all active deck instances. This
allows users to select and merge multiple decks with matching
dimensions into a single print preview and export session.
This commit is contained in:
hypercross 2026-05-05 16:30:49 +08:00
parent d099cf5758
commit 4953d33f0f
5 changed files with 699 additions and 316 deletions

View File

@ -1,18 +1,21 @@
import { createSignal, For, Show } from 'solid-js'; import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { Portal } from 'solid-js/web'; import { Portal } from "solid-js/web";
import type { DeckStore } from './hooks/deckStore'; import type { DeckStore } from "./hooks/deckStore";
import { usePageLayout } from './hooks/usePageLayout'; import { usePageLayout } from "./hooks/usePageLayout";
import { usePDFExport, type ExportOptions } from './hooks/usePDFExport'; import { usePDFExport, type ExportOptions } from "./hooks/usePDFExport";
import { usePlotterExport } from './hooks/usePlotterExport'; import { usePlotterExport } from "./hooks/usePlotterExport";
import { getShapeSvgClipPath } from './hooks/shape-styles'; import { getShapeSvgClipPath } from "./hooks/shape-styles";
import { PrintPreviewHeader } from './PrintPreviewHeader'; import { PrintPreviewHeader } from "./PrintPreviewHeader";
import { PrintPreviewFooter } from './PrintPreviewFooter'; import { PrintPreviewFooter } from "./PrintPreviewFooter";
import { CardLayer } from './CardLayer'; import { CardLayer } from "./CardLayer";
import { PltPreview } from './PltPreview'; import { PltPreview } from "./PltPreview";
import { getDeckById } from "./hooks/deck-registry";
import type { CardData } from "./types";
export interface PrintPreviewProps { export interface PrintPreviewProps {
articlePath?: string, articlePath?: string;
store: DeckStore; store: DeckStore;
deckId: string;
onClose: () => void; onClose: () => void;
onExport: () => void; onExport: () => void;
} }
@ -22,15 +25,41 @@ export interface PrintPreviewProps {
*/ */
export function PrintPreview(props: PrintPreviewProps) { export function PrintPreview(props: PrintPreviewProps) {
const { store } = props; const { store } = props;
const { getA4Size, pages, cropMarks } = usePageLayout(store);
// 合并的卡牌列表:主牌组 + 选中的其他牌组
const [selectedDeckIds, setSelectedDeckIds] = createSignal<string[]>([]);
// 当某个已选中的牌组被卸载时,自动从选中列表中移除
createEffect(() => {
const ids = selectedDeckIds();
const stillValid = ids.filter((id) => getDeckById(id));
if (stillValid.length !== ids.length) {
setSelectedDeckIds(stillValid);
}
});
const mergedCards = createMemo(() => {
const base = store.state.cards as CardData[];
const extras = selectedDeckIds()
.map((id) => getDeckById(id))
.filter(Boolean)
.flatMap((entry) => entry!.store.state.cards as CardData[]);
return [...base, ...extras];
});
const mergedCardCount = () => mergedCards().length;
const { getA4Size, pages, cropMarks } = usePageLayout(store, mergedCards);
const { exportToPDF } = usePDFExport(store, props.onClose); const { exportToPDF } = usePDFExport(store, props.onClose);
const { generatePltData } = usePlotterExport(store); const { generatePltData } = usePlotterExport(store);
const [showPltPreview, setShowPltPreview] = createSignal(false); const [showPltPreview, setShowPltPreview] = createSignal(false);
const [pltCode, setPltCode] = createSignal(''); const [pltCode, setPltCode] = createSignal("");
const frontVisibleLayers = () => store.state.frontLayerConfigs.filter((l) => l.visible); const frontVisibleLayers = () =>
const backVisibleLayers = () => store.state.backLayerConfigs.filter((l) => l.visible); store.state.frontLayerConfigs.filter((l) => l.visible);
const backVisibleLayers = () =>
store.state.backLayerConfigs.filter((l) => l.visible);
const handleExport = async () => { const handleExport = async () => {
const options: ExportOptions = { const options: ExportOptions = {
@ -42,7 +71,7 @@ export function PrintPreview(props: PrintPreviewProps) {
gridAreaWidth: store.state.dimensions?.gridAreaWidth || 56, gridAreaWidth: store.state.dimensions?.gridAreaWidth || 56,
gridAreaHeight: store.state.dimensions?.gridAreaHeight || 88, gridAreaHeight: store.state.dimensions?.gridAreaHeight || 88,
visibleLayers: frontVisibleLayers(), visibleLayers: frontVisibleLayers(),
dimensions: store.state.dimensions! dimensions: store.state.dimensions!,
}; };
await exportToPDF(pages(), cropMarks(), options); await exportToPDF(pages(), cropMarks(), options);
}; };
@ -53,7 +82,7 @@ export function PrintPreview(props: PrintPreviewProps) {
setPltCode(data.pltCode); setPltCode(data.pltCode);
setShowPltPreview(true); setShowPltPreview(true);
} else { } else {
alert('没有可预览的卡片'); alert("没有可预览的卡片");
} }
}; };
@ -63,143 +92,178 @@ export function PrintPreview(props: PrintPreviewProps) {
return ( return (
<Portal> <Portal>
<Show when={!showPltPreview()} fallback={<PltPreview pltCode={pltCode()} onClose={handleClosePltPreview} />}> <Show
when={!showPltPreview()}
fallback={
<PltPreview pltCode={pltCode()} onClose={handleClosePltPreview} />
}
>
<div class="fixed inset-0 bg-black/50 z-[60] overflow-auto"> <div class="fixed inset-0 bg-black/50 z-[60] overflow-auto">
<div class="min-h-screen py-20 px-4"> <div class="min-h-screen py-20 px-4">
<PrintPreviewHeader <PrintPreviewHeader
store={store} store={store}
deckId={props.deckId}
pageCount={pages().length} pageCount={pages().length}
cardCount={mergedCardCount()}
selectedDeckIds={selectedDeckIds()}
onToggleDeck={(id) => {
setSelectedDeckIds((prev) =>
prev.includes(id)
? prev.filter((x) => x !== id)
: [...prev, id],
);
}}
onExport={handleExport} onExport={handleExport}
onOpenPltPreview={handleOpenPltPreview} onOpenPltPreview={handleOpenPltPreview}
onClose={props.onClose} onClose={props.onClose}
/> />
<PrintPreviewFooter store={store} /> <PrintPreviewFooter store={store} />
<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) => {
// 根据页面类型(正面/背面)决定使用哪个图层配置 // 根据页面类型(正面/背面)决定使用哪个图层配置
const isFrontPage = page.cards[0]?.side !== 'back'; const isFrontPage = page.cards[0]?.side !== "back";
const visibleLayersForPage = isFrontPage ? frontVisibleLayers() : backVisibleLayers(); const visibleLayersForPage = isFrontPage
? frontVisibleLayers()
return ( : backVisibleLayers();
<svg
class="bg-white shadow-xl"
viewBox={`0 0 ${getA4Size().width}mm ${getA4Size().height}mm`}
style={{
width: `${getA4Size().width}mm`,
height: `${getA4Size().height}mm`
}}
data-page={page.pageIndex + 1}
xmlns="http://www.w3.org/2000/svg"
>
<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}> return (
{(line) => ( <svg
<> class="bg-white shadow-xl"
<line viewBox={`0 0 ${getA4Size().width}mm ${getA4Size().height}mm`}
x1={`${line.xStart}mm`} style={{
y1={`${line.y}mm`} width: `${getA4Size().width}mm`,
x2={`${page.frameBounds.minX}mm`} height: `${getA4Size().height}mm`,
y2={`${line.y}mm`} }}
stroke="#888" data-page={page.pageIndex + 1}
stroke-width="0.1" xmlns="http://www.w3.org/2000/svg"
/> >
<line <rect
x1={`${page.frameBounds.maxX}mm`} x={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.x}mm`}
y1={`${line.y}mm`} y={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.y}mm`}
x2={`${line.xEnd}mm`} width={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.width}mm`}
y2={`${line.y}mm`} height={`${cropMarks()[page.pageIndex]?.frameBoundsWithMargin.height}mm`}
stroke="#888" fill="none"
stroke-width="0.1" stroke="black"
/> stroke-width="0.2"
</> />
)}
</For>
<For each={cropMarks()[page.pageIndex]?.verticalLines}> <For each={cropMarks()[page.pageIndex]?.horizontalLines}>
{(line) => ( {(line) => (
<> <>
<line <line
x1={`${line.x}mm`} x1={`${line.xStart}mm`}
y1={`${line.yStart}mm`} y1={`${line.y}mm`}
x2={`${line.x}mm`} x2={`${page.frameBounds.minX}mm`}
y2={`${page.frameBounds.minY}mm`} y2={`${line.y}mm`}
stroke="#888" stroke="#888"
stroke-width="0.1" stroke-width="0.1"
/> />
<line <line
x1={`${line.x}mm`} x1={`${page.frameBounds.maxX}mm`}
y1={`${page.frameBounds.maxY}mm`} y1={`${line.y}mm`}
x2={`${line.x}mm`} x2={`${line.xEnd}mm`}
y2={`${line.yEnd}mm`} y2={`${line.y}mm`}
stroke="#888" stroke="#888"
stroke-width="0.1" stroke-width="0.1"
/> />
</> </>
)} )}
</For> </For>
<For each={page.cards}> <For each={cropMarks()[page.pageIndex]?.verticalLines}>
{(card) => { {(line) => (
const cardWidth = store.state.dimensions?.cardWidth || 56; <>
const cardHeight = store.state.dimensions?.cardHeight || 88; <line
const clipPathId = `clip-${page.pageIndex}-${card.data.id || card.x}-${card.y}`; x1={`${line.x}mm`}
const shapeClipPath = getShapeSvgClipPath(clipPathId, cardWidth, cardHeight, store.state.shape, store.state.cornerRadius); 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>
return ( <For each={page.cards}>
<g class="card-group"> {(card) => {
<Show when={shapeClipPath}> const cardWidth =
<defs>{shapeClipPath}</defs> store.state.dimensions?.cardWidth || 56;
</Show> const cardHeight =
<foreignObject store.state.dimensions?.cardHeight || 88;
x={`${card.x}mm`} const clipPathId = `clip-${page.pageIndex}-${card.data.id || card.x}-${card.y}`;
y={`${card.y}mm`} const shapeClipPath = getShapeSvgClipPath(
width={`${cardWidth}mm`} clipPathId,
height={`${cardHeight}mm`} cardWidth,
clip-path={shapeClipPath ? `url(#${clipPathId})` : undefined} cardHeight,
> store.state.shape,
<div class="w-full h-full bg-white" {...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}> store.state.cornerRadius,
<article data-src={props.articlePath} );
class="absolute"
style={{ return (
position: 'absolute', <g class="card-group">
left: `${store.state.dimensions?.gridOriginX}mm`, <Show when={shapeClipPath}>
top: `${store.state.dimensions?.gridOriginY}mm`, <defs>{shapeClipPath}</defs>
width: `${store.state.dimensions?.gridAreaWidth}mm`, </Show>
height: `${store.state.dimensions?.gridAreaHeight}mm` <foreignObject
}} x={`${card.x}mm`}
y={`${card.y}mm`}
width={`${cardWidth}mm`}
height={`${cardHeight}mm`}
clip-path={
shapeClipPath
? `url(#${clipPathId})`
: undefined
}
> >
<CardLayer <div
store={store} class="w-full h-full bg-white"
cardData={card.data} {...({
side={card.side || 'front'} xmlns: "http://www.w3.org/1999/xhtml",
/> } as any)}
</article> >
</div> <article
</foreignObject> data-src={props.articlePath}
</g> class="absolute"
); style={{
}} position: "absolute",
</For> left: `${store.state.dimensions?.gridOriginX}mm`,
</svg> top: `${store.state.dimensions?.gridOriginY}mm`,
); width: `${store.state.dimensions?.gridAreaWidth}mm`,
}} height: `${store.state.dimensions?.gridAreaHeight}mm`,
</For> }}
>
<CardLayer
store={store}
cardData={card.data}
side={card.side || "front"}
/>
</article>
</div>
</foreignObject>
</g>
);
}}
</For>
</svg>
);
}}
</For>
</div>
</div>
</div> </div>
</div> </Show>
</div>
</Show>
</Portal> </Portal>
); );
} }

View File

@ -1,8 +1,14 @@
import type { DeckStore } from './hooks/deckStore'; import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
import type { DeckStore } from "./hooks/deckStore";
import { getCompatibleDecks } from "./hooks/deck-registry";
export interface PrintPreviewHeaderProps { export interface PrintPreviewHeaderProps {
store: DeckStore; store: DeckStore;
deckId: string;
pageCount: number; pageCount: number;
cardCount: number;
selectedDeckIds: string[];
onToggleDeck: (id: string) => void;
onExport: () => void; onExport: () => void;
onOpenPltPreview: () => void; onOpenPltPreview: () => void;
onClose: () => void; onClose: () => void;
@ -15,51 +21,164 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
const frontOddPageOffsetY = () => store.state.printFrontOddPageOffsetY; const frontOddPageOffsetY = () => store.state.printFrontOddPageOffsetY;
const doubleSided = () => store.state.printDoubleSided; const doubleSided = () => store.state.printDoubleSided;
const [showAddDeck, setShowAddDeck] = createSignal(false);
let addDeckRef: HTMLDivElement | undefined;
// 点击外部关闭下拉菜单
const handleClickOutside = (e: MouseEvent) => {
if (addDeckRef && !addDeckRef.contains(e.target as Node)) {
setShowAddDeck(false);
}
};
onMount(() => {
document.addEventListener("click", handleClickOutside);
});
onCleanup(() => {
document.removeEventListener("click", handleClickOutside);
});
const compatibleDecks = () =>
getCompatibleDecks(
store.state.sizeW,
store.state.sizeH,
store.state.bleed,
props.deckId,
);
const mergedDeckCount = () => props.selectedDeckIds.length + 1;
return ( 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="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"> <div class="flex items-center gap-4">
<h2 class="text-base font-bold mt-0 mb-0"></h2> <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> <p class="text-xs text-gray-500 mb-0">
{props.pageCount} {props.cardCount}
<Show when={mergedDeckCount() > 1}>
<span> ( {mergedDeckCount()} )</span>
</Show>
</p>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{/* Add Deck button */}
<div class="relative" ref={addDeckRef}>
<button
onClick={() => setShowAddDeck(!showAddDeck())}
disabled={compatibleDecks().length === 0}
class={`px-3 py-1 rounded text-sm font-medium cursor-pointer border flex items-center gap-1 ${
compatibleDecks().length === 0
? "bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed"
: "bg-purple-100 text-purple-700 border-purple-300 hover:bg-purple-200"
}`}
title={
compatibleDecks().length === 0
? "没有兼容的牌组"
: "添加其他牌组到打印"
}
>
<span></span>
<span></span>
<Show when={props.selectedDeckIds.length > 0}>
<span class="bg-purple-600 text-white text-xs rounded-full w-5 h-5 inline-flex items-center justify-center">
{props.selectedDeckIds.length}
</span>
</Show>
</button>
<Show when={showAddDeck()}>
<div class="absolute top-full right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 min-w-[280px]">
<div class="p-2 border-b border-gray-100">
<p class="text-xs text-gray-500 m-0">
{store.state.sizeW}×
{store.state.sizeH}mm
</p>
</div>
<div class="max-h-[200px] overflow-y-auto p-2">
<For each={compatibleDecks()}>
{(entry) => {
const isSelected = () =>
props.selectedDeckIds.includes(entry.id);
const cardCount = () => entry.store.state.cards.length;
return (
<label class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={isSelected()}
onChange={() => props.onToggleDeck(entry.id)}
class="cursor-pointer"
/>
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-700 truncate">
{entry.path}
</div>
<div class="text-xs text-gray-400">
{cardCount()}
</div>
</div>
</label>
);
}}
</For>
</div>
<Show when={compatibleDecks().length === 0}>
<div class="p-4 text-center text-sm text-gray-400">
</div>
</Show>
<div class="p-2 border-t border-gray-100">
<button
onClick={() => setShowAddDeck(false)}
class="w-full text-center text-xs text-gray-500 hover:text-gray-700 cursor-pointer py-1"
>
</button>
</div>
</div>
</Show>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-sm text-gray-600">:</label> <label class="text-sm text-gray-600">:</label>
<div class="flex gap-1"> <div class="flex gap-1">
<button <button
onClick={() => store.actions.setPrintOrientation('portrait')} onClick={() => store.actions.setPrintOrientation("portrait")}
class={`px-3 py-1 rounded text-sm font-medium cursor-pointer border ${ class={`px-3 py-1 rounded text-sm font-medium cursor-pointer border ${
orientation() === 'portrait' orientation() === "portrait"
? 'bg-blue-600 text-white border-blue-600' ? "bg-blue-600 text-white border-blue-600"
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50' : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`} }`}
> >
</button> </button>
<button <button
onClick={() => store.actions.setPrintOrientation('landscape')} onClick={() => store.actions.setPrintOrientation("landscape")}
class={`px-3 py-1 rounded text-sm font-medium cursor-pointer border ${ class={`px-3 py-1 rounded text-sm font-medium cursor-pointer border ${
orientation() === 'landscape' orientation() === "landscape"
? 'bg-blue-600 text-white border-blue-600' ? "bg-blue-600 text-white border-blue-600"
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50' : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`} }`}
> >
</button> </button>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="flex items-center gap-1 cursor-pointer"> <label class="flex items-center gap-1 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={doubleSided()} checked={doubleSided()}
onChange={(e) => store.actions.setPrintDoubleSided(e.target.checked)} onChange={(e) =>
store.actions.setPrintDoubleSided(e.target.checked)
}
class="cursor-pointer" class="cursor-pointer"
/> />
<span class="text-sm text-gray-600"></span> <span class="text-sm text-gray-600"></span>
</label> </label>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-sm text-gray-600">:</label> <label class="text-sm text-gray-600">:</label>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@ -67,7 +186,11 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
<input <input
type="number" type="number"
value={frontOddPageOffsetX()} value={frontOddPageOffsetX()}
onChange={(e) => store.actions.setPrintFrontOddPageOffsetX(Number(e.target.value))} onChange={(e) =>
store.actions.setPrintFrontOddPageOffsetX(
Number(e.target.value),
)
}
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm" class="w-16 px-2 py-1 border border-gray-300 rounded text-sm"
step="0.1" step="0.1"
/> />
@ -78,7 +201,11 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
<input <input
type="number" type="number"
value={frontOddPageOffsetY()} value={frontOddPageOffsetY()}
onChange={(e) => store.actions.setPrintFrontOddPageOffsetY(Number(e.target.value))} onChange={(e) =>
store.actions.setPrintFrontOddPageOffsetY(
Number(e.target.value),
)
}
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm" class="w-16 px-2 py-1 border border-gray-300 rounded text-sm"
step="0.1" step="0.1"
/> />

View File

@ -0,0 +1,99 @@
import { createSignal } from 'solid-js';
import type { DeckStore } from './deckStore';
export interface DeckRegistryEntry {
id: string;
store: DeckStore;
path: string;
label?: string;
}
/**
*
* 收集页面上所有已加载的 :md-deck
*/
const [registry, setRegistry] = createSignal<Map<string, DeckRegistryEntry>>(new Map());
function getRegistry(): Map<string, DeckRegistryEntry> {
return registry();
}
function updateRegistry(fn: (map: Map<string, DeckRegistryEntry>) => void) {
const newMap = new Map(registry());
fn(newMap);
setRegistry(newMap);
}
/**
*
*/
export function registerDeck(id: string, store: DeckStore, path: string, label?: string): void {
updateRegistry((map) => {
map.set(id, { id, store, path, label });
});
}
/**
*
*/
export function unregisterDeck(id: string): void {
updateRegistry((map) => {
map.delete(id);
});
}
/**
*
* @param sizeW (mm)
* @param sizeH (mm)
* @param bleed (mm)
* @param excludeId ID
* @returns
*/
export function getCompatibleDecks(
sizeW: number,
sizeH: number,
bleed: number,
excludeId?: string
): DeckRegistryEntry[] {
const result: DeckRegistryEntry[] = [];
const map = getRegistry();
for (const [id, entry] of map) {
if (id === excludeId) continue;
const s = entry.store.state;
// 必须尺寸和出血完全一致才算兼容
if (s.sizeW === sizeW && s.sizeH === sizeH && s.bleed === bleed) {
result.push(entry);
}
}
return result;
}
/**
* ID
*/
export function getDeckById(id: string): DeckRegistryEntry | undefined {
return getRegistry().get(id);
}
/**
*
*/
export function getAllDecks(): DeckRegistryEntry[] {
return Array.from(getRegistry().values());
}
/**
* 使
*/
export function useCompatibleDecks(
sizeW: () => number,
sizeH: () => number,
bleed: () => number,
excludeId?: string
) {
return () => getCompatibleDecks(sizeW(), sizeH(), bleed(), excludeId);
}

View File

@ -1,6 +1,7 @@
import { createMemo } from 'solid-js'; import { createMemo } from "solid-js";
import type { DeckStore } from './deckStore'; import type { DeckStore } from "./deckStore";
import type { PageData, CropMarkData } from './usePDFExport'; import type { PageData, CropMarkData } from "./usePDFExport";
import type { CardData } from "../types";
export interface A4Size { export interface A4Size {
width: number; width: number;
@ -21,22 +22,29 @@ const PRINT_MARGIN = 5;
/** /**
* hook * hook
* @param store store
* @param extraCards
*/ */
export function usePageLayout(store: DeckStore): UsePageLayoutReturn { export function usePageLayout(
store: DeckStore,
extraCards?: () => CardData[],
): UsePageLayoutReturn {
const orientation = () => store.state.printOrientation; const orientation = () => store.state.printOrientation;
const doubleSided = () => store.state.printDoubleSided; const doubleSided = () => store.state.printDoubleSided;
const frontOddPageOffsetX = () => store.state.printFrontOddPageOffsetX; const frontOddPageOffsetX = () => store.state.printFrontOddPageOffsetX;
const frontOddPageOffsetY = () => store.state.printFrontOddPageOffsetY; const frontOddPageOffsetY = () => store.state.printFrontOddPageOffsetY;
const getA4Size = () => { const getA4Size = () => {
if (orientation() === 'landscape') { if (orientation() === "landscape") {
return { width: A4_WIDTH_LANDSCAPE, height: A4_HEIGHT_LANDSCAPE }; return { width: A4_WIDTH_LANDSCAPE, height: A4_HEIGHT_LANDSCAPE };
} }
return { width: A4_WIDTH_PORTRAIT, height: A4_HEIGHT_PORTRAIT }; return { width: A4_WIDTH_PORTRAIT, height: A4_HEIGHT_PORTRAIT };
}; };
const pages = createMemo<PageData[]>(() => { const pages = createMemo<PageData[]>(() => {
const cards = store.state.cards; const baseCards = store.state.cards as CardData[];
const extra = extraCards?.() ?? [];
const cards = [...baseCards, ...extra];
const cardWidth = store.state.dimensions?.cardWidth || 56; const cardWidth = store.state.dimensions?.cardWidth || 56;
const cardHeight = store.state.dimensions?.cardHeight || 88; const cardHeight = store.state.dimensions?.cardHeight || 88;
const { width: a4Width, height: a4Height } = getA4Size(); const { width: a4Width, height: a4Height } = getA4Size();
@ -68,8 +76,18 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
result.push({ result.push({
pageIndex: result.length, pageIndex: result.length,
cards: [], cards: [],
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, bounds: {
frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
},
frameBounds: {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
},
}); });
} }
@ -78,7 +96,10 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
// 计算当前正面页的卡牌范围 // 计算当前正面页的卡牌范围
const startCardIndex = pageIndex * cardsPerPage; const startCardIndex = pageIndex * cardsPerPage;
const endCardIndex = Math.min(startCardIndex + cardsPerPage, totalCards); const endCardIndex = Math.min(
startCardIndex + cardsPerPage,
totalCards,
);
for (let i = startCardIndex; i < endCardIndex; i++) { for (let i = startCardIndex; i < endCardIndex; i++) {
// 正面:正常顺序排列 // 正面:正常顺序排列
@ -92,30 +113,50 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
const frontX = baseOffsetX + col * cardWidth + pageOffsetX; const frontX = baseOffsetX + col * cardWidth + pageOffsetX;
const frontY = baseOffsetY + row * cardHeight + pageOffsetY; const frontY = baseOffsetY + row * cardHeight + pageOffsetY;
frontPage.cards.push({ data: cards[i], x: frontX, y: frontY, side: 'front' as const }); frontPage.cards.push({
data: cards[i],
x: frontX,
y: frontY,
side: "front" as const,
});
frontPage.bounds.minX = Math.min(frontPage.bounds.minX, frontX); frontPage.bounds.minX = Math.min(frontPage.bounds.minX, frontX);
frontPage.bounds.minY = Math.min(frontPage.bounds.minY, frontY); frontPage.bounds.minY = Math.min(frontPage.bounds.minY, frontY);
frontPage.bounds.maxX = Math.max(frontPage.bounds.maxX, frontX + cardWidth); frontPage.bounds.maxX = Math.max(
frontPage.bounds.maxY = Math.max(frontPage.bounds.maxY, frontY + cardHeight); frontPage.bounds.maxX,
frontX + cardWidth,
);
frontPage.bounds.maxY = Math.max(
frontPage.bounds.maxY,
frontY + cardHeight,
);
// 背面:逆转顺序排列(长边方向) // 背面:逆转顺序排列(长边方向)
// 对于竖向打印,长边是垂直方向,所以逆转行 // 对于竖向打印,长边是垂直方向,所以逆转行
// 对于横向打印,长边是水平方向,所以逆转列 // 对于横向打印,长边是水平方向,所以逆转列
const backRow = orientation() === 'portrait' const backRow =
? (rowsPerPage - 1 - row) orientation() === "portrait" ? rowsPerPage - 1 - row : row;
: row; const backCol =
const backCol = orientation() === 'portrait' orientation() === "portrait" ? col : cardsPerRow - 1 - col;
? col
: (cardsPerRow - 1 - col);
const backX = baseOffsetX + backCol * cardWidth; const backX = baseOffsetX + backCol * cardWidth;
const backY = baseOffsetY + backRow * cardHeight; const backY = baseOffsetY + backRow * cardHeight;
backPage.cards.push({ data: cards[i], x: backX, y: backY, side: 'back' as const }); backPage.cards.push({
data: cards[i],
x: backX,
y: backY,
side: "back" as const,
});
backPage.bounds.minX = Math.min(backPage.bounds.minX, backX); backPage.bounds.minX = Math.min(backPage.bounds.minX, backX);
backPage.bounds.minY = Math.min(backPage.bounds.minY, backY); backPage.bounds.minY = Math.min(backPage.bounds.minY, backY);
backPage.bounds.maxX = Math.max(backPage.bounds.maxX, backX + cardWidth); backPage.bounds.maxX = Math.max(
backPage.bounds.maxY = Math.max(backPage.bounds.maxY, backY + cardHeight); backPage.bounds.maxX,
backX + cardWidth,
);
backPage.bounds.maxY = Math.max(
backPage.bounds.maxY,
backY + cardHeight,
);
} }
} }
} else { } else {
@ -123,8 +164,18 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
let currentPage: PageData = { let currentPage: PageData = {
pageIndex: 0, pageIndex: 0,
cards: [], cards: [],
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, bounds: {
frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } 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++) { for (let i = 0; i < cards.length; i++) {
@ -138,8 +189,18 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
currentPage = { currentPage = {
pageIndex, pageIndex,
cards: [], cards: [],
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, bounds: {
frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
},
frameBounds: {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
},
}; };
} }
@ -150,11 +211,22 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
const cardX = baseOffsetX + col * cardWidth + pageOffsetX; const cardX = baseOffsetX + col * cardWidth + pageOffsetX;
const cardY = baseOffsetY + row * cardHeight + pageOffsetY; const cardY = baseOffsetY + row * cardHeight + pageOffsetY;
currentPage.cards.push({ data: cards[i], x: cardX, y: cardY, side: 'front' as const }); currentPage.cards.push({
data: cards[i],
x: cardX,
y: cardY,
side: "front" as const,
});
currentPage.bounds.minX = Math.min(currentPage.bounds.minX, cardX); currentPage.bounds.minX = Math.min(currentPage.bounds.minX, cardX);
currentPage.bounds.minY = Math.min(currentPage.bounds.minY, cardY); currentPage.bounds.minY = Math.min(currentPage.bounds.minY, cardY);
currentPage.bounds.maxX = Math.max(currentPage.bounds.maxX, cardX + cardWidth); currentPage.bounds.maxX = Math.max(
currentPage.bounds.maxY = Math.max(currentPage.bounds.maxY, cardY + cardHeight); currentPage.bounds.maxX,
cardX + cardWidth,
);
currentPage.bounds.maxY = Math.max(
currentPage.bounds.maxY,
cardY + cardHeight,
);
} }
if (currentPage.cards.length > 0) { if (currentPage.cards.length > 0) {
@ -162,25 +234,27 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
} }
} }
return result.map(page => { return result.map((page) => {
const offsetX = doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetX() : 0; const offsetX =
const offsetY = doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetY() : 0; doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetX() : 0;
const offsetY =
doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetY() : 0;
return { return {
...page, ...page,
frameBounds: { frameBounds: {
minX: baseOffsetX + offsetX, minX: baseOffsetX + offsetX,
minY: baseOffsetY + offsetY, minY: baseOffsetY + offsetY,
maxX: baseOffsetX + maxGridWidth + offsetX, maxX: baseOffsetX + maxGridWidth + offsetX,
maxY: baseOffsetY + maxGridHeight + offsetY maxY: baseOffsetY + maxGridHeight + offsetY,
} },
}; };
}); });
}); });
const cropMarks = createMemo<CropMarkData[]>(() => { const cropMarks = createMemo<CropMarkData[]>(() => {
const pagesData = pages(); const pagesData = pages();
return pagesData.map(page => { return pagesData.map((page) => {
const { frameBounds, cards } = page; const { frameBounds, cards } = page;
const cardWidth = store.state.dimensions?.cardWidth || 56; const cardWidth = store.state.dimensions?.cardWidth || 56;
const cardHeight = store.state.dimensions?.cardHeight || 88; const cardHeight = store.state.dimensions?.cardHeight || 88;
@ -188,7 +262,7 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
const xPositions = new Set<number>(); const xPositions = new Set<number>();
const yPositions = new Set<number>(); const yPositions = new Set<number>();
cards.forEach(card => { cards.forEach((card) => {
xPositions.add(card.x); xPositions.add(card.x);
xPositions.add(card.x + cardWidth); xPositions.add(card.x + cardWidth);
yPositions.add(card.y); yPositions.add(card.y);
@ -200,26 +274,31 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
const OVERLAP = 3; const OVERLAP = 3;
const horizontalLines = sortedY.map(y => ({ const horizontalLines = sortedY.map((y) => ({
y, y,
xStart: frameBounds.minX - OVERLAP, xStart: frameBounds.minX - OVERLAP,
xEnd: frameBounds.maxX + OVERLAP xEnd: frameBounds.maxX + OVERLAP,
})); }));
const verticalLines = sortedX.map(x => ({ const verticalLines = sortedX.map((x) => ({
x, x,
yStart: frameBounds.minY - OVERLAP, yStart: frameBounds.minY - OVERLAP,
yEnd: frameBounds.maxY + OVERLAP yEnd: frameBounds.maxY + OVERLAP,
})); }));
const frameBoundsWithMargin = { const frameBoundsWithMargin = {
x: frameBounds.minX - 1, x: frameBounds.minX - 1,
y: frameBounds.minY - 1, y: frameBounds.minY - 1,
width: frameBounds.maxX - frameBounds.minX + 2, width: frameBounds.maxX - frameBounds.minX + 2,
height: frameBounds.maxY - frameBounds.minY + 2 height: frameBounds.maxY - frameBounds.minY + 2,
}; };
return { horizontalLines, verticalLines, frameBounds, frameBoundsWithMargin }; return {
horizontalLines,
verticalLines,
frameBounds,
frameBoundsWithMargin,
};
}); });
}); });

View File

@ -1,12 +1,17 @@
import { customElement, noShadowDOM } from 'solid-element'; import { customElement, noShadowDOM } from "solid-element";
import { Show, onCleanup } from 'solid-js'; import { Show, onCleanup } from "solid-js";
import { resolvePath } from '../utils/path'; import { resolvePath } from "../utils/path";
import { createDeckStore } from './hooks/deckStore'; import { createDeckStore } from "./hooks/deckStore";
import type { CardShape } from './types'; import { registerDeck, unregisterDeck } from "./hooks/deck-registry";
import { DeckHeader } from './DeckHeader'; import type { CardShape } from "./types";
import { DeckContent } from './DeckContent'; import { DeckHeader } from "./DeckHeader";
import { PrintPreview } from './PrintPreview'; import { DeckContent } from "./DeckContent";
import {DataEditorPanel, LayerEditorPanel, PropertiesEditorPanel} from './editor-panel'; import { PrintPreview } from "./PrintPreview";
import {
DataEditorPanel,
LayerEditorPanel,
PropertiesEditorPanel,
} from "./editor-panel";
interface DeckProps { interface DeckProps {
size?: string; size?: string;
@ -23,133 +28,142 @@ interface DeckProps {
fixed?: boolean | string; fixed?: boolean | string;
} }
customElement<DeckProps>('md-deck', { customElement<DeckProps>(
size: '', "md-deck",
sizeW: 54, {
sizeH: 86, size: "",
grid: '', sizeW: 54,
gridW: 5, sizeH: 86,
gridH: 8, grid: "",
bleed: 1, gridW: 5,
padding: 2, gridH: 8,
shape: 'rectangle', bleed: 1,
layers: '', padding: 2,
backLayers: '', shape: "rectangle",
fixed: false layers: "",
}, (props, { element }) => { backLayers: "",
noShadowDOM(); fixed: false,
},
(props, { element }) => {
noShadowDOM();
// 从 element 的 textContent 获取 CSV 路径 // 从 element 的 textContent 获取 CSV 路径
const csvPath = element?.textContent?.trim() || ''; const csvPath = element?.textContent?.trim() || "";
// 隐藏原始文本内容 // 隐藏原始文本内容
if (element) { if (element) {
element.textContent = ''; element.textContent = "";
} }
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径 // 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest('article[data-src]'); const articleEl = element?.closest("article[data-src]");
const articlePath = articleEl?.getAttribute('data-src') || ''; const articlePath = articleEl?.getAttribute("data-src") || "";
// 解析相对路径 // 解析相对路径
const resolvedSrc = resolvePath(articlePath, csvPath); const resolvedSrc = resolvePath(articlePath, csvPath);
// 创建 store 并加载数据 // 创建 store 并加载数据
const store = createDeckStore(resolvedSrc); const store = createDeckStore(resolvedSrc);
// 解析 size 属性(支持旧格式 "54x86" 和新格式) // 生成唯一 ID 并注册到全局注册表
if (props.size && props.size.includes('x')) { const deckId = `deck-${crypto.randomUUID()}`;
const [w, h] = props.size.split('x').map(Number); registerDeck(deckId, store, resolvedSrc, csvPath);
store.actions.setSizeW(w);
store.actions.setSizeH(h);
} else {
store.actions.setSizeW(props.sizeW ?? 54);
store.actions.setSizeH(props.sizeH ?? 86);
}
// 解析 grid 属性(支持旧格式 "5x8" 和新格式) // 解析 size 属性(支持旧格式 "54x86" 和新格式)
if (props.grid && props.grid.includes('x')) { if (props.size && props.size.includes("x")) {
const [w, h] = props.grid.split('x').map(Number); const [w, h] = props.size.split("x").map(Number);
store.actions.setGridW(w); store.actions.setSizeW(w);
store.actions.setGridH(h); store.actions.setSizeH(h);
} else { } else {
store.actions.setGridW(props.gridW ?? 5); store.actions.setSizeW(props.sizeW ?? 54);
store.actions.setGridH(props.gridH ?? 8); store.actions.setSizeH(props.sizeH ?? 86);
} }
// 解析 bleed 和 padding支持旧字符串格式和新数字格式 // 解析 grid 属性(支持旧格式 "5x8" 和新格式)
if (typeof props.bleed === 'string') { if (props.grid && props.grid.includes("x")) {
store.actions.setBleed(Number(props.bleed)); const [w, h] = props.grid.split("x").map(Number);
} else { store.actions.setGridW(w);
store.actions.setBleed(props.bleed ?? 1); store.actions.setGridH(h);
} } else {
store.actions.setGridW(props.gridW ?? 5);
store.actions.setGridH(props.gridH ?? 8);
}
if (typeof props.padding === 'string') { // 解析 bleed 和 padding支持旧字符串格式和新数字格式
store.actions.setPadding(Number(props.padding)); if (typeof props.bleed === "string") {
} else { store.actions.setBleed(Number(props.bleed));
store.actions.setPadding(props.padding ?? 2); } else {
} store.actions.setBleed(props.bleed ?? 1);
}
// 设置形状 if (typeof props.padding === "string") {
store.actions.setShape(props.shape ?? 'rectangle'); store.actions.setPadding(Number(props.padding));
} else {
store.actions.setPadding(props.padding ?? 2);
}
// 加载 CSV 数据 // 设置形状
store.actions.loadCardsFromPath( store.actions.setShape(props.shape ?? "rectangle");
resolvedSrc,
csvPath,
(props.layers as string) || '',
(props.backLayers as string) || ''
);
// 清理函数 // 加载 CSV 数据
onCleanup(() => { store.actions.loadCardsFromPath(
store.actions.clearError(); resolvedSrc,
}); csvPath,
(props.layers as string) || "",
(props.backLayers as string) || "",
);
return ( // 清理函数
<div class="md-deck mb-4"> onCleanup(() => {
{/* 导出 PDF 预览弹窗 */} unregisterDeck(deckId);
<Show when={store.state.isExporting}> store.actions.clearError();
<PrintPreview });
articlePath={articlePath}
store={store}
onClose={() => store.actions.setExporting(false)}
onExport={() => {}}
/>
</Show>
{/* Tab 选择器和编辑按钮 */} return (
<Show when={store.state.cards.length > 0 && !store.state.error}> <div class="md-deck mb-4">
<DeckHeader store={store} /> {/* 导出 PDF 预览弹窗 */}
</Show> <Show when={store.state.isExporting}>
<PrintPreview
<div class="flex gap-4"> articlePath={articlePath}
store={store}
{/* 内容区域:错误/加载/卡牌预览/空状态 */} deckId={deckId}
{/* 左侧CSV 数据编辑 */} onClose={() => store.actions.setExporting(false)}
{/*<Show when={store.state.isEditing && !store.state.fixed}>*/} onExport={() => {}}
{/* <DataEditorPanel*/} />
{/* activeTab={store.state.activeTab}*/}
{/* cards={store.state.cards}*/}
{/* updateCardData={store.actions.updateCardData}*/}
{/* />*/}
{/*</Show>*/}
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<PropertiesEditorPanel store={store} />
</div>
</Show> </Show>
<DeckContent store={store} isLoading={store.state.isLoading} /> {/* Tab 选择器和编辑按钮 */}
<Show when={store.state.cards.length > 0 && !store.state.error}>
{/* 右侧:属性/图层编辑面板 */} <DeckHeader store={store} />
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<LayerEditorPanel store={store} />
</div>
</Show> </Show>
<div class="flex gap-4">
{/* 内容区域:错误/加载/卡牌预览/空状态 */}
{/* 左侧CSV 数据编辑 */}
{/*<Show when={store.state.isEditing && !store.state.fixed}>*/}
{/* <DataEditorPanel*/}
{/* activeTab={store.state.activeTab}*/}
{/* cards={store.state.cards}*/}
{/* updateCardData={store.actions.updateCardData}*/}
{/* />*/}
{/*</Show>*/}
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<PropertiesEditorPanel store={store} />
</div>
</Show>
<DeckContent store={store} isLoading={store.state.isLoading} />
{/* 右侧:属性/图层编辑面板 */}
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<LayerEditorPanel store={store} />
</div>
</Show>
</div>
</div> </div>
</div> );
); },
}); );