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 { 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,58 @@ 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);
// ---- 跨牌组合并 ----
// 用户选中的额外牌组 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 { 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 +88,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 +99,7 @@ export function PrintPreview(props: PrintPreviewProps) {
setPltCode(data.pltCode); setPltCode(data.pltCode);
setShowPltPreview(true); setShowPltPreview(true);
} else { } else {
alert('没有可预览的卡片'); alert("没有可预览的卡片");
} }
}; };
@ -63,143 +109,182 @@ 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={{ // 查找该卡牌所属的 store主牌组用 store合并牌组用各自的 store
position: 'absolute', const cardStore =
left: `${store.state.dimensions?.gridOriginX}mm`, cardStoreMap().get(card.data) || store;
top: `${store.state.dimensions?.gridOriginY}mm`,
width: `${store.state.dimensions?.gridAreaWidth}mm`, return (
height: `${store.state.dimensions?.gridAreaHeight}mm` <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 <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: `${cardStore.state.dimensions?.gridOriginX}mm`,
</svg> top: `${cardStore.state.dimensions?.gridOriginY}mm`,
); width: `${cardStore.state.dimensions?.gridAreaWidth}mm`,
}} height: `${cardStore.state.dimensions?.gridAreaHeight}mm`,
</For> }}
>
<CardLayer
store={cardStore}
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> );
); },
}); );