Compare commits

...

2 Commits

Author SHA1 Message Date
hypercross d4de95b465 refactor: fix layout for merged decks in PrintPreview
Update PrintPreview to correctly handle dimensions and layer
configurations when multiple decks are merged. Instead of using the
base store for all cards, it now maps each card to its specific
source store to ensure correct grid positioning and styling.
2026-05-05 16:46:10 +08:00
hypercross 4953d33f0f 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.
2026-05-05 16:30:49 +08:00
5 changed files with 720 additions and 316 deletions

View File

@ -1,18 +1,21 @@
import { createSignal, For, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { DeckStore } from './hooks/deckStore';
import { usePageLayout } from './hooks/usePageLayout';
import { usePDFExport, type ExportOptions } from './hooks/usePDFExport';
import { usePlotterExport } from './hooks/usePlotterExport';
import { getShapeSvgClipPath } from './hooks/shape-styles';
import { PrintPreviewHeader } from './PrintPreviewHeader';
import { PrintPreviewFooter } from './PrintPreviewFooter';
import { CardLayer } from './CardLayer';
import { PltPreview } from './PltPreview';
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { Portal } from "solid-js/web";
import type { DeckStore } from "./hooks/deckStore";
import { usePageLayout } from "./hooks/usePageLayout";
import { usePDFExport, type ExportOptions } from "./hooks/usePDFExport";
import { usePlotterExport } from "./hooks/usePlotterExport";
import { getShapeSvgClipPath } from "./hooks/shape-styles";
import { PrintPreviewHeader } from "./PrintPreviewHeader";
import { PrintPreviewFooter } from "./PrintPreviewFooter";
import { CardLayer } from "./CardLayer";
import { PltPreview } from "./PltPreview";
import { getDeckById } from "./hooks/deck-registry";
import type { CardData } from "./types";
export interface PrintPreviewProps {
articlePath?: string,
articlePath?: string;
store: DeckStore;
deckId: string;
onClose: () => void;
onExport: () => void;
}
@ -22,15 +25,58 @@ export interface PrintPreviewProps {
*/
export function PrintPreview(props: PrintPreviewProps) {
const { store } = props;
const { getA4Size, pages, cropMarks } = usePageLayout(store);
// ---- 跨牌组合并 ----
// 用户选中的额外牌组 ID 列表
const [selectedDeckIds, setSelectedDeckIds] = createSignal<string[]>([]);
// 当某个已选中的牌组被卸载时,自动从选中列表中移除
createEffect(() => {
const ids = selectedDeckIds();
const stillValid = ids.filter((id) => getDeckById(id));
if (stillValid.length !== ids.length) {
setSelectedDeckIds(stillValid);
}
});
// 仅额外牌组的卡牌(不含主牌组),传给 usePageLayout 避免重复
const extraCards = createMemo(() => {
return selectedDeckIds()
.map((id) => getDeckById(id))
.filter(Boolean)
.flatMap((entry) => entry!.store.state.cards as CardData[]);
});
// 卡牌 → 来源 store 的映射,用于渲染时选择正确的图层配置
const cardStoreMap = createMemo(() => {
const map = new Map<CardData, DeckStore>();
for (const id of selectedDeckIds()) {
const entry = getDeckById(id);
if (entry) {
for (const card of entry.store.state.cards as CardData[]) {
map.set(card, entry.store);
}
}
}
return map;
});
const mergedCardCount = () => store.state.cards.length + extraCards().length;
// ---- 布局 & 导出 hooks ----
const { getA4Size, pages, cropMarks } = usePageLayout(store, extraCards);
const { exportToPDF } = usePDFExport(store, props.onClose);
const { generatePltData } = usePlotterExport(store);
const [showPltPreview, setShowPltPreview] = createSignal(false);
const [pltCode, setPltCode] = createSignal('');
const [pltCode, setPltCode] = createSignal("");
const frontVisibleLayers = () => store.state.frontLayerConfigs.filter((l) => l.visible);
const backVisibleLayers = () => store.state.backLayerConfigs.filter((l) => l.visible);
const frontVisibleLayers = () =>
store.state.frontLayerConfigs.filter((l) => l.visible);
const backVisibleLayers = () =>
store.state.backLayerConfigs.filter((l) => l.visible);
const handleExport = async () => {
const options: ExportOptions = {
@ -42,7 +88,7 @@ export function PrintPreview(props: PrintPreviewProps) {
gridAreaWidth: store.state.dimensions?.gridAreaWidth || 56,
gridAreaHeight: store.state.dimensions?.gridAreaHeight || 88,
visibleLayers: frontVisibleLayers(),
dimensions: store.state.dimensions!
dimensions: store.state.dimensions!,
};
await exportToPDF(pages(), cropMarks(), options);
};
@ -53,7 +99,7 @@ export function PrintPreview(props: PrintPreviewProps) {
setPltCode(data.pltCode);
setShowPltPreview(true);
} else {
alert('没有可预览的卡片');
alert("没有可预览的卡片");
}
};
@ -63,143 +109,182 @@ export function PrintPreview(props: PrintPreviewProps) {
return (
<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="min-h-screen py-20 px-4">
<PrintPreviewHeader
store={store}
deckId={props.deckId}
pageCount={pages().length}
cardCount={mergedCardCount()}
selectedDeckIds={selectedDeckIds()}
onToggleDeck={(id) => {
setSelectedDeckIds((prev) =>
prev.includes(id)
? prev.filter((x) => x !== id)
: [...prev, id],
);
}}
onExport={handleExport}
onOpenPltPreview={handleOpenPltPreview}
onClose={props.onClose}
/>
<PrintPreviewFooter store={store} />
<PrintPreviewFooter store={store} />
<div class="flex flex-col items-center gap-8">
<For each={pages()}>
{(page) => {
// 根据页面类型(正面/背面)决定使用哪个图层配置
const isFrontPage = page.cards[0]?.side !== 'back';
const visibleLayersForPage = isFrontPage ? frontVisibleLayers() : backVisibleLayers();
return (
<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"
/>
<div class="flex flex-col items-center gap-8">
<For each={pages()}>
{(page) => {
// 根据页面类型(正面/背面)决定使用哪个图层配置
const isFrontPage = page.cards[0]?.side !== "back";
const visibleLayersForPage = isFrontPage
? frontVisibleLayers()
: backVisibleLayers();
<For each={cropMarks()[page.pageIndex]?.horizontalLines}>
{(line) => (
<>
<line
x1={`${line.xStart}mm`}
y1={`${line.y}mm`}
x2={`${page.frameBounds.minX}mm`}
y2={`${line.y}mm`}
stroke="#888"
stroke-width="0.1"
/>
<line
x1={`${page.frameBounds.maxX}mm`}
y1={`${line.y}mm`}
x2={`${line.xEnd}mm`}
y2={`${line.y}mm`}
stroke="#888"
stroke-width="0.1"
/>
</>
)}
</For>
return (
<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]?.verticalLines}>
{(line) => (
<>
<line
x1={`${line.x}mm`}
y1={`${line.yStart}mm`}
x2={`${line.x}mm`}
y2={`${page.frameBounds.minY}mm`}
stroke="#888"
stroke-width="0.1"
/>
<line
x1={`${line.x}mm`}
y1={`${page.frameBounds.maxY}mm`}
x2={`${line.x}mm`}
y2={`${line.yEnd}mm`}
stroke="#888"
stroke-width="0.1"
/>
</>
)}
</For>
<For each={cropMarks()[page.pageIndex]?.horizontalLines}>
{(line) => (
<>
<line
x1={`${line.xStart}mm`}
y1={`${line.y}mm`}
x2={`${page.frameBounds.minX}mm`}
y2={`${line.y}mm`}
stroke="#888"
stroke-width="0.1"
/>
<line
x1={`${page.frameBounds.maxX}mm`}
y1={`${line.y}mm`}
x2={`${line.xEnd}mm`}
y2={`${line.y}mm`}
stroke="#888"
stroke-width="0.1"
/>
</>
)}
</For>
<For each={page.cards}>
{(card) => {
const cardWidth = store.state.dimensions?.cardWidth || 56;
const cardHeight = store.state.dimensions?.cardHeight || 88;
const clipPathId = `clip-${page.pageIndex}-${card.data.id || card.x}-${card.y}`;
const shapeClipPath = getShapeSvgClipPath(clipPathId, cardWidth, cardHeight, store.state.shape, store.state.cornerRadius);
<For each={cropMarks()[page.pageIndex]?.verticalLines}>
{(line) => (
<>
<line
x1={`${line.x}mm`}
y1={`${line.yStart}mm`}
x2={`${line.x}mm`}
y2={`${page.frameBounds.minY}mm`}
stroke="#888"
stroke-width="0.1"
/>
<line
x1={`${line.x}mm`}
y1={`${page.frameBounds.maxY}mm`}
x2={`${line.x}mm`}
y2={`${line.yEnd}mm`}
stroke="#888"
stroke-width="0.1"
/>
</>
)}
</For>
return (
<g class="card-group">
<Show when={shapeClipPath}>
<defs>{shapeClipPath}</defs>
</Show>
<foreignObject
x={`${card.x}mm`}
y={`${card.y}mm`}
width={`${cardWidth}mm`}
height={`${cardHeight}mm`}
clip-path={shapeClipPath ? `url(#${clipPathId})` : undefined}
>
<div class="w-full h-full bg-white" {...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}>
<article data-src={props.articlePath}
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`
}}
<For each={page.cards}>
{(card) => {
const cardWidth =
store.state.dimensions?.cardWidth || 56;
const cardHeight =
store.state.dimensions?.cardHeight || 88;
const clipPathId = `clip-${page.pageIndex}-${card.data.id || card.x}-${card.y}`;
const shapeClipPath = getShapeSvgClipPath(
clipPathId,
cardWidth,
cardHeight,
store.state.shape,
store.state.cornerRadius,
);
// 查找该卡牌所属的 store主牌组用 store合并牌组用各自的 store
const cardStore =
cardStoreMap().get(card.data) || store;
return (
<g class="card-group">
<Show when={shapeClipPath}>
<defs>{shapeClipPath}</defs>
</Show>
<foreignObject
x={`${card.x}mm`}
y={`${card.y}mm`}
width={`${cardWidth}mm`}
height={`${cardHeight}mm`}
clip-path={
shapeClipPath
? `url(#${clipPathId})`
: undefined
}
>
<CardLayer
store={store}
cardData={card.data}
side={card.side || 'front'}
/>
</article>
</div>
</foreignObject>
</g>
);
}}
</For>
</svg>
);
}}
</For>
<div
class="w-full h-full bg-white"
{...({
xmlns: "http://www.w3.org/1999/xhtml",
} as any)}
>
<article
data-src={props.articlePath}
class="absolute"
style={{
position: "absolute",
left: `${cardStore.state.dimensions?.gridOriginX}mm`,
top: `${cardStore.state.dimensions?.gridOriginY}mm`,
width: `${cardStore.state.dimensions?.gridAreaWidth}mm`,
height: `${cardStore.state.dimensions?.gridAreaHeight}mm`,
}}
>
<CardLayer
store={cardStore}
cardData={card.data}
side={card.side || "front"}
/>
</article>
</div>
</foreignObject>
</g>
);
}}
</For>
</svg>
);
}}
</For>
</div>
</div>
</div>
</div>
</div>
</Show>
</Show>
</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 {
store: DeckStore;
deckId: string;
pageCount: number;
cardCount: number;
selectedDeckIds: string[];
onToggleDeck: (id: string) => void;
onExport: () => void;
onOpenPltPreview: () => void;
onClose: () => void;
@ -15,51 +21,164 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
const frontOddPageOffsetY = () => store.state.printFrontOddPageOffsetY;
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 (
<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>
<p class="text-xs text-gray-500 mb-0">
{props.pageCount} {props.cardCount}
<Show when={mergedDeckCount() > 1}>
<span> ( {mergedDeckCount()} )</span>
</Show>
</p>
</div>
<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">
<label class="text-sm text-gray-600">:</label>
<div class="flex gap-1">
<button
onClick={() => store.actions.setPrintOrientation('portrait')}
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'
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')}
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'
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="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={doubleSided()}
onChange={(e) => store.actions.setPrintDoubleSided(e.target.checked)}
onChange={(e) =>
store.actions.setPrintDoubleSided(e.target.checked)
}
class="cursor-pointer"
/>
<span class="text-sm text-gray-600"></span>
</label>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600">:</label>
<div class="flex items-center gap-1">
@ -67,7 +186,11 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
<input
type="number"
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"
step="0.1"
/>
@ -78,7 +201,11 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
<input
type="number"
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"
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 type { DeckStore } from './deckStore';
import type { PageData, CropMarkData } from './usePDFExport';
import { createMemo } from "solid-js";
import type { DeckStore } from "./deckStore";
import type { PageData, CropMarkData } from "./usePDFExport";
import type { CardData } from "../types";
export interface A4Size {
width: number;
@ -21,22 +22,29 @@ const PRINT_MARGIN = 5;
/**
* 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 doubleSided = () => store.state.printDoubleSided;
const frontOddPageOffsetX = () => store.state.printFrontOddPageOffsetX;
const frontOddPageOffsetY = () => store.state.printFrontOddPageOffsetY;
const getA4Size = () => {
if (orientation() === 'landscape') {
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 baseCards = store.state.cards as CardData[];
const extra = extraCards?.() ?? [];
const cards = [...baseCards, ...extra];
const cardWidth = store.state.dimensions?.cardWidth || 56;
const cardHeight = store.state.dimensions?.cardHeight || 88;
const { width: a4Width, height: a4Height } = getA4Size();
@ -68,8 +76,18 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
result.push({
pageIndex: result.length,
cards: [],
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
bounds: {
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 endCardIndex = Math.min(startCardIndex + cardsPerPage, totalCards);
const endCardIndex = Math.min(
startCardIndex + cardsPerPage,
totalCards,
);
for (let i = startCardIndex; i < endCardIndex; i++) {
// 正面:正常顺序排列
@ -92,30 +113,50 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
const frontX = baseOffsetX + col * cardWidth + pageOffsetX;
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.minY = Math.min(frontPage.bounds.minY, frontY);
frontPage.bounds.maxX = Math.max(frontPage.bounds.maxX, frontX + cardWidth);
frontPage.bounds.maxY = Math.max(frontPage.bounds.maxY, frontY + cardHeight);
frontPage.bounds.maxX = Math.max(
frontPage.bounds.maxX,
frontX + cardWidth,
);
frontPage.bounds.maxY = Math.max(
frontPage.bounds.maxY,
frontY + cardHeight,
);
// 背面:逆转顺序排列(长边方向)
// 对于竖向打印,长边是垂直方向,所以逆转行
// 对于横向打印,长边是水平方向,所以逆转列
const backRow = orientation() === 'portrait'
? (rowsPerPage - 1 - row)
: row;
const backCol = orientation() === 'portrait'
? col
: (cardsPerRow - 1 - col);
const backRow =
orientation() === "portrait" ? rowsPerPage - 1 - row : row;
const backCol =
orientation() === "portrait" ? col : cardsPerRow - 1 - col;
const backX = baseOffsetX + backCol * cardWidth;
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.minY = Math.min(backPage.bounds.minY, backY);
backPage.bounds.maxX = Math.max(backPage.bounds.maxX, backX + cardWidth);
backPage.bounds.maxY = Math.max(backPage.bounds.maxY, backY + cardHeight);
backPage.bounds.maxX = Math.max(
backPage.bounds.maxX,
backX + cardWidth,
);
backPage.bounds.maxY = Math.max(
backPage.bounds.maxY,
backY + cardHeight,
);
}
}
} else {
@ -123,8 +164,18 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
let currentPage: PageData = {
pageIndex: 0,
cards: [],
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
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++) {
@ -138,8 +189,18 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
currentPage = {
pageIndex,
cards: [],
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
bounds: {
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 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.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);
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) {
@ -162,25 +234,27 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
}
}
return result.map(page => {
const offsetX = doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetX() : 0;
const offsetY = doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetY() : 0;
return result.map((page) => {
const offsetX =
doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetX() : 0;
const offsetY =
doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetY() : 0;
return {
...page,
frameBounds: {
minX: baseOffsetX + offsetX,
minY: baseOffsetY + offsetY,
maxX: baseOffsetX + maxGridWidth + offsetX,
maxY: baseOffsetY + maxGridHeight + offsetY
}
maxY: baseOffsetY + maxGridHeight + offsetY,
},
};
});
});
const cropMarks = createMemo<CropMarkData[]>(() => {
const pagesData = pages();
return pagesData.map(page => {
return pagesData.map((page) => {
const { frameBounds, cards } = page;
const cardWidth = store.state.dimensions?.cardWidth || 56;
const cardHeight = store.state.dimensions?.cardHeight || 88;
@ -188,7 +262,7 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
const xPositions = new Set<number>();
const yPositions = new Set<number>();
cards.forEach(card => {
cards.forEach((card) => {
xPositions.add(card.x);
xPositions.add(card.x + cardWidth);
yPositions.add(card.y);
@ -200,26 +274,31 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
const OVERLAP = 3;
const horizontalLines = sortedY.map(y => ({
const horizontalLines = sortedY.map((y) => ({
y,
xStart: frameBounds.minX - OVERLAP,
xEnd: frameBounds.maxX + OVERLAP
xEnd: frameBounds.maxX + OVERLAP,
}));
const verticalLines = sortedX.map(x => ({
const verticalLines = sortedX.map((x) => ({
x,
yStart: frameBounds.minY - OVERLAP,
yEnd: frameBounds.maxY + 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
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 { Show, onCleanup } from 'solid-js';
import { resolvePath } from '../utils/path';
import { createDeckStore } from './hooks/deckStore';
import type { CardShape } from './types';
import { DeckHeader } from './DeckHeader';
import { DeckContent } from './DeckContent';
import { PrintPreview } from './PrintPreview';
import {DataEditorPanel, LayerEditorPanel, PropertiesEditorPanel} from './editor-panel';
import { customElement, noShadowDOM } from "solid-element";
import { Show, onCleanup } from "solid-js";
import { resolvePath } from "../utils/path";
import { createDeckStore } from "./hooks/deckStore";
import { registerDeck, unregisterDeck } from "./hooks/deck-registry";
import type { CardShape } from "./types";
import { DeckHeader } from "./DeckHeader";
import { DeckContent } from "./DeckContent";
import { PrintPreview } from "./PrintPreview";
import {
DataEditorPanel,
LayerEditorPanel,
PropertiesEditorPanel,
} from "./editor-panel";
interface DeckProps {
size?: string;
@ -23,133 +28,142 @@ interface DeckProps {
fixed?: boolean | string;
}
customElement<DeckProps>('md-deck', {
size: '',
sizeW: 54,
sizeH: 86,
grid: '',
gridW: 5,
gridH: 8,
bleed: 1,
padding: 2,
shape: 'rectangle',
layers: '',
backLayers: '',
fixed: false
}, (props, { element }) => {
noShadowDOM();
customElement<DeckProps>(
"md-deck",
{
size: "",
sizeW: 54,
sizeH: 86,
grid: "",
gridW: 5,
gridH: 8,
bleed: 1,
padding: 2,
shape: "rectangle",
layers: "",
backLayers: "",
fixed: false,
},
(props, { element }) => {
noShadowDOM();
// 从 element 的 textContent 获取 CSV 路径
const csvPath = element?.textContent?.trim() || '';
// 从 element 的 textContent 获取 CSV 路径
const csvPath = element?.textContent?.trim() || "";
// 隐藏原始文本内容
if (element) {
element.textContent = '';
}
// 隐藏原始文本内容
if (element) {
element.textContent = "";
}
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest('article[data-src]');
const articlePath = articleEl?.getAttribute('data-src') || '';
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest("article[data-src]");
const articlePath = articleEl?.getAttribute("data-src") || "";
// 解析相对路径
const resolvedSrc = resolvePath(articlePath, csvPath);
// 解析相对路径
const resolvedSrc = resolvePath(articlePath, csvPath);
// 创建 store 并加载数据
const store = createDeckStore(resolvedSrc);
// 创建 store 并加载数据
const store = createDeckStore(resolvedSrc);
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
if (props.size && props.size.includes('x')) {
const [w, h] = props.size.split('x').map(Number);
store.actions.setSizeW(w);
store.actions.setSizeH(h);
} else {
store.actions.setSizeW(props.sizeW ?? 54);
store.actions.setSizeH(props.sizeH ?? 86);
}
// 生成唯一 ID 并注册到全局注册表
const deckId = `deck-${crypto.randomUUID()}`;
registerDeck(deckId, store, resolvedSrc, csvPath);
// 解析 grid 属性(支持旧格式 "5x8" 和新格式)
if (props.grid && props.grid.includes('x')) {
const [w, h] = props.grid.split('x').map(Number);
store.actions.setGridW(w);
store.actions.setGridH(h);
} else {
store.actions.setGridW(props.gridW ?? 5);
store.actions.setGridH(props.gridH ?? 8);
}
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
if (props.size && props.size.includes("x")) {
const [w, h] = props.size.split("x").map(Number);
store.actions.setSizeW(w);
store.actions.setSizeH(h);
} else {
store.actions.setSizeW(props.sizeW ?? 54);
store.actions.setSizeH(props.sizeH ?? 86);
}
// 解析 bleed 和 padding支持旧字符串格式和新数字格式
if (typeof props.bleed === 'string') {
store.actions.setBleed(Number(props.bleed));
} else {
store.actions.setBleed(props.bleed ?? 1);
}
// 解析 grid 属性(支持旧格式 "5x8" 和新格式)
if (props.grid && props.grid.includes("x")) {
const [w, h] = props.grid.split("x").map(Number);
store.actions.setGridW(w);
store.actions.setGridH(h);
} else {
store.actions.setGridW(props.gridW ?? 5);
store.actions.setGridH(props.gridH ?? 8);
}
if (typeof props.padding === 'string') {
store.actions.setPadding(Number(props.padding));
} else {
store.actions.setPadding(props.padding ?? 2);
}
// 解析 bleed 和 padding支持旧字符串格式和新数字格式
if (typeof props.bleed === "string") {
store.actions.setBleed(Number(props.bleed));
} else {
store.actions.setBleed(props.bleed ?? 1);
}
// 设置形状
store.actions.setShape(props.shape ?? 'rectangle');
if (typeof props.padding === "string") {
store.actions.setPadding(Number(props.padding));
} else {
store.actions.setPadding(props.padding ?? 2);
}
// 加载 CSV 数据
store.actions.loadCardsFromPath(
resolvedSrc,
csvPath,
(props.layers as string) || '',
(props.backLayers as string) || ''
);
// 设置形状
store.actions.setShape(props.shape ?? "rectangle");
// 清理函数
onCleanup(() => {
store.actions.clearError();
});
// 加载 CSV 数据
store.actions.loadCardsFromPath(
resolvedSrc,
csvPath,
(props.layers as string) || "",
(props.backLayers as string) || "",
);
return (
<div class="md-deck mb-4">
{/* 导出 PDF 预览弹窗 */}
<Show when={store.state.isExporting}>
<PrintPreview
articlePath={articlePath}
store={store}
onClose={() => store.actions.setExporting(false)}
onExport={() => {}}
/>
</Show>
// 清理函数
onCleanup(() => {
unregisterDeck(deckId);
store.actions.clearError();
});
{/* Tab 选择器和编辑按钮 */}
<Show when={store.state.cards.length > 0 && !store.state.error}>
<DeckHeader store={store} />
</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>
return (
<div class="md-deck mb-4">
{/* 导出 PDF 预览弹窗 */}
<Show when={store.state.isExporting}>
<PrintPreview
articlePath={articlePath}
store={store}
deckId={deckId}
onClose={() => store.actions.setExporting(false)}
onExport={() => {}}
/>
</Show>
<DeckContent store={store} isLoading={store.state.isLoading} />
{/* 右侧:属性/图层编辑面板 */}
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<LayerEditorPanel store={store} />
</div>
{/* Tab 选择器和编辑按钮 */}
<Show when={store.state.cards.length > 0 && !store.state.error}>
<DeckHeader store={store} />
</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>
);
});
);
},
);