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
+223 -138
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>
);
}
+142 -15
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"
/>
@@ -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);
}
+121 -42
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,
};
});
});
+135 -121
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>
);
});
);
},
);