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