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:
@@ -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 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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user