Compare commits

...

3 Commits

Author SHA1 Message Date
hypercross 984b8aa1c8 fix: double side printing 2026-03-13 17:44:17 +08:00
hypercross 4b08a738a7 fix: generateCode 2026-03-13 17:34:00 +08:00
hypercross b3dc768786 feat: card back support? 2026-03-13 17:26:00 +08:00
12 changed files with 445 additions and 228 deletions

View File

@ -1,7 +1,7 @@
import {createMemo, For} from 'solid-js'; import {createMemo, For} from 'solid-js';
import {parseMarkdown} from '../../markdown'; import {parseMarkdown} from '../../markdown';
import { getLayerStyle } from './hooks/dimensions'; import { getLayerStyle } from './hooks/dimensions';
import type { CardData } from './types'; import type { CardData, CardSide } from './types';
import {DeckStore} from "./hooks/deckStore"; import {DeckStore} from "./hooks/deckStore";
import {processVariables} from "../utils/csv-loader"; import {processVariables} from "../utils/csv-loader";
import {resolvePath} from "../utils/path"; import {resolvePath} from "../utils/path";
@ -9,10 +9,16 @@ import {resolvePath} from "../utils/path";
export interface CardLayerProps { export interface CardLayerProps {
cardData: CardData; cardData: CardData;
store: DeckStore; store: DeckStore;
side?: CardSide;
} }
export function CardLayer(props: CardLayerProps) { export function CardLayer(props: CardLayerProps) {
const layers = createMemo(() => props.store.state.layerConfigs.filter((l) => l.visible)); const side = () => props.side || 'front';
const layers = createMemo(() =>
side() === 'front'
? props.store.state.frontLayerConfigs.filter((l) => l.visible)
: props.store.state.backLayerConfigs.filter((l) => l.visible)
);
const dimensions = () => props.store.state.dimensions!; const dimensions = () => props.store.state.dimensions!;
const showBounds = () => props.store.state.isEditing; const showBounds = () => props.store.state.isEditing;

View File

@ -79,6 +79,7 @@ export function CardPreview(props: CardPreviewProps) {
<CardLayer <CardLayer
cardData={currentCard()} cardData={currentCard()}
store={store} store={store}
side={store.state.activeSide}
/> />
</div> </div>
</div> </div>

View File

@ -20,7 +20,8 @@ export function PrintPreview(props: PrintPreviewProps) {
const { getA4Size, pages, cropMarks } = usePageLayout(store); const { getA4Size, pages, cropMarks } = usePageLayout(store);
const { exportToPDF } = usePDFExport(store, props.onClose); const { exportToPDF } = usePDFExport(store, props.onClose);
const visibleLayers = () => store.state.layerConfigs.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 handleExport = async () => {
const options: ExportOptions = { const options: ExportOptions = {
@ -31,7 +32,7 @@ export function PrintPreview(props: PrintPreviewProps) {
gridOriginY: store.state.dimensions?.gridOriginY || 0, gridOriginY: store.state.dimensions?.gridOriginY || 0,
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: visibleLayers(), visibleLayers: frontVisibleLayers(),
dimensions: store.state.dimensions! dimensions: store.state.dimensions!
}; };
await exportToPDF(pages(), cropMarks(), options); await exportToPDF(pages(), cropMarks(), options);
@ -51,7 +52,12 @@ export function PrintPreview(props: PrintPreviewProps) {
<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 visibleLayersForPage = isFrontPage ? frontVisibleLayers() : backVisibleLayers();
return (
<svg <svg
class="bg-white shadow-xl" class="bg-white shadow-xl"
viewBox={`0 0 ${getA4Size().width}mm ${getA4Size().height}mm`} viewBox={`0 0 ${getA4Size().width}mm ${getA4Size().height}mm`}
@ -138,7 +144,10 @@ export function PrintPreview(props: PrintPreviewProps) {
height: `${store.state.dimensions?.gridAreaHeight}mm` height: `${store.state.dimensions?.gridAreaHeight}mm`
}} }}
> >
<CardLayer store={store} cardData={card.data} <CardLayer
store={store}
cardData={card.data}
side={card.side || 'front'}
/> />
</div> </div>
</div> </div>
@ -147,7 +156,8 @@ export function PrintPreview(props: PrintPreviewProps) {
)} )}
</For> </For>
</svg> </svg>
)} );
}}
</For> </For>
</div> </div>
</div> </div>

View File

@ -10,8 +10,9 @@ export interface PrintPreviewHeaderProps {
export function PrintPreviewHeader(props: PrintPreviewHeaderProps) { export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
const { store } = props; const { store } = props;
const orientation = () => store.state.printOrientation; const orientation = () => store.state.printOrientation;
const oddPageOffsetX = () => store.state.printOddPageOffsetX; const frontOddPageOffsetX = () => store.state.printFrontOddPageOffsetX;
const oddPageOffsetY = () => store.state.printOddPageOffsetY; const frontOddPageOffsetY = () => store.state.printFrontOddPageOffsetY;
const doubleSided = () => store.state.printDoubleSided;
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">
@ -45,14 +46,27 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
</button> </button>
</div> </div>
</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="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={doubleSided()}
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"> <div class="flex items-center gap-1">
<span class="text-xs text-gray-500">X:</span> <span class="text-xs text-gray-500">X:</span>
<input <input
type="number" type="number"
value={oddPageOffsetX()} value={frontOddPageOffsetX()}
onChange={(e) => store.actions.setPrintOddPageOffsetX(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"
/> />
@ -62,8 +76,8 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
<span class="text-xs text-gray-500">Y:</span> <span class="text-xs text-gray-500">Y:</span>
<input <input
type="number" type="number"
value={oddPageOffsetY()} value={frontOddPageOffsetY()}
onChange={(e) => store.actions.setPrintOddPageOffsetY(Number(e.target.value))} onChange={(e) => store.actions.setPrintFrontOddPageOffsetY(Number(e.target.value))}
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm" class="w-16 px-2 py-1 border border-gray-300 rounded text-sm"
step="0.1" step="0.1"
/> />

View File

@ -18,33 +18,54 @@ const ORIENTATION_OPTIONS = [
export function LayerEditorPanel(props: LayerEditorPanelProps) { export function LayerEditorPanel(props: LayerEditorPanelProps) {
const { store } = props; const { store } = props;
// 根据当前激活的面获取图层配置
const currentLayerConfigs = () =>
store.state.activeSide === 'front'
? store.state.frontLayerConfigs
: store.state.backLayerConfigs;
const updateLayerOrientation = (layerProp: string, orientation: 'n' | 's' | 'e' | 'w') => { const updateLayerOrientation = (layerProp: string, orientation: 'n' | 's' | 'e' | 'w') => {
const layer = store.state.layerConfigs.find(l => l.prop === layerProp); const updateFn = store.state.activeSide === 'front'
if (layer) { ? store.actions.updateFrontLayerConfig
store.actions.updateLayerConfig(layerProp, { ...layer, orientation }); : store.actions.updateBackLayerConfig;
} updateFn(layerProp, { orientation });
}; };
const updateLayerFontSize = (layerProp: string, fontSize?: number) => { const updateLayerFontSize = (layerProp: string, fontSize?: number) => {
const layer = store.state.layerConfigs.find(l => l.prop === layerProp); const updateFn = store.state.activeSide === 'front'
if (layer) { ? store.actions.updateFrontLayerConfig
store.actions.updateLayerConfig(layerProp, { ...layer, fontSize }); : store.actions.updateBackLayerConfig;
} updateFn(layerProp, { fontSize });
};
const toggleLayerVisible = (layerProp: string) => {
const toggleFn = store.state.activeSide === 'front'
? store.actions.toggleFrontLayerVisible
: store.actions.toggleBackLayerVisible;
toggleFn(layerProp);
};
const setEditingLayer = (layerProp: string) => {
store.actions.setEditingLayer(
store.state.editingLayer === layerProp ? null : layerProp
);
}; };
return ( return (
<div class="w-64 flex-shrink-0"> <div class="w-64 flex-shrink-0">
<h3 class="font-bold mb-2 mt-0"></h3> <h3 class="font-bold mb-2 mt-0">
({store.state.activeSide === 'front' ? '正面' : '背面'})
</h3>
<div class="space-y-2"> <div class="space-y-2">
<For each={store.state.layerConfigs}> <For each={currentLayerConfigs()}>
{(layer) => ( {(layer) => (
<div class="flex flex-row flex-wrap gap-1 p-2 bg-gray-50 rounded"> <div class="flex flex-row flex-wrap gap-1 p-2 bg-gray-50 rounded">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={layer.visible} checked={layer.visible}
onChange={() => store.actions.toggleLayerVisible(layer.prop)} onChange={() => toggleLayerVisible(layer.prop)}
class="cursor-pointer" class="cursor-pointer"
/> />
<span class="text-sm flex-1">{layer.prop}</span> <span class="text-sm flex-1">{layer.prop}</span>
@ -52,7 +73,7 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
{layer.visible && ( {layer.visible && (
<> <>
<button <button
onClick={() => store.actions.setEditingLayer(store.state.editingLayer === layer.prop ? null : layer.prop)} onClick={() => setEditingLayer(layer.prop)}
class={`text-xs px-2 py-1 rounded cursor-pointer ${ class={`text-xs px-2 py-1 rounded cursor-pointer ${
store.state.editingLayer === layer.prop store.state.editingLayer === layer.prop
? 'bg-blue-500 text-white' ? 'bg-blue-500 text-white'
@ -99,10 +120,11 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
<hr class="my-4" /> <hr class="my-4" />
<button <button
onClick={store.actions.copyCode} onClick={() => store.actions.copyCode()}
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded text-sm font-medium cursor-pointer" class="w-full bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded text-sm font-medium cursor-pointer flex items-center gap-2 justify-center"
> >
📋 <span>📋</span>
<span></span>
</button> </button>
</div> </div>
); );

View File

@ -5,7 +5,7 @@ export interface PropertiesEditorPanelProps {
} }
/** /**
* *
*/ */
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) { export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
const { store } = props; const { store } = props;
@ -14,6 +14,32 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
<div class="w-64 flex-shrink-0"> <div class="w-64 flex-shrink-0">
<h3 class="font-bold mb-2 mt-0"></h3> <h3 class="font-bold mb-2 mt-0"></h3>
{/* 正面/背面切换标签页 */}
<div class="mb-4">
<div class="flex gap-1">
<button
onClick={() => store.actions.setActiveSide('front')}
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.activeSide === 'front'
? '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.setActiveSide('back')}
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.activeSide === 'back'
? '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="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-sm font-medium text-gray-700"> (mm)</label> <label class="block text-sm font-medium text-gray-700"> (mm)</label>

View File

@ -1,8 +1,8 @@
import { createStore } from 'solid-js/store'; import { createStore } from 'solid-js/store';
import { calculateDimensions } from './dimensions'; import { calculateDimensions } from './dimensions';
import { loadCSV, CSV } from '../../utils/csv-loader'; import { loadCSV, CSV } from '../../utils/csv-loader';
import { initLayerConfigs, formatLayers } from './layer-parser'; import { initLayerConfigs, formatLayers, initLayerConfigsForSide } from './layer-parser';
import type { CardData, LayerConfig, Dimensions } from '../types'; import type { CardData, LayerConfig, Dimensions, CardSide } from '../types';
/** /**
* *
@ -36,11 +36,13 @@ export interface DeckState {
activeTab: number; activeTab: number;
// 图层配置 // 图层配置
layerConfigs: LayerConfig[]; frontLayerConfigs: LayerConfig[];
backLayerConfigs: LayerConfig[];
// 编辑状态 // 编辑状态
isEditing: boolean; isEditing: boolean;
editingLayer: string | null; editingLayer: string | null;
activeSide: CardSide;
// 框选状态 // 框选状态
isSelecting: boolean; isSelecting: boolean;
@ -60,8 +62,9 @@ export interface DeckState {
// 打印设置 // 打印设置
printOrientation: 'portrait' | 'landscape'; printOrientation: 'portrait' | 'landscape';
printOddPageOffsetX: number; printFrontOddPageOffsetX: number;
printOddPageOffsetY: number; printFrontOddPageOffsetY: number;
printDoubleSided: boolean;
} }
export interface DeckActions { export interface DeckActions {
@ -78,15 +81,21 @@ export interface DeckActions {
setActiveTab: (index: number) => void; setActiveTab: (index: number) => void;
updateCardData: (index: number, key: string, value: string) => void; updateCardData: (index: number, key: string, value: string) => void;
// 图层操作 // 图层操作 - 正面
setLayerConfigs: (configs: LayerConfig[]) => void; setFrontLayerConfigs: (configs: LayerConfig[]) => void;
updateLayerConfig: (prop: string, updates: Partial<LayerConfig>) => void; updateFrontLayerConfig: (prop: string, updates: Partial<LayerConfig>) => void;
toggleLayerVisible: (prop: string) => void; toggleFrontLayerVisible: (prop: string) => void;
// 图层操作 - 背面
setBackLayerConfigs: (configs: LayerConfig[]) => void;
updateBackLayerConfig: (prop: string, updates: Partial<LayerConfig>) => void;
toggleBackLayerVisible: (prop: string) => void;
// 编辑状态 // 编辑状态
setIsEditing: (editing: boolean) => void; setIsEditing: (editing: boolean) => void;
setEditingLayer: (layer: string | null) => void; setEditingLayer: (layer: string | null) => void;
updateLayerPosition: (x1: number, y1: number, x2: number, y2: number) => void; updateLayerPosition: (x1: number, y1: number, x2: number, y2: number) => void;
setActiveSide: (side: CardSide) => void;
// 框选操作 // 框选操作
setIsSelecting: (selecting: boolean) => void; setIsSelecting: (selecting: boolean) => void;
@ -95,13 +104,13 @@ export interface DeckActions {
cancelSelection: () => void; cancelSelection: () => void;
// 数据加载 // 数据加载
loadCardsFromPath: (path: string, rawSrc: string, layersStr?: string) => Promise<void>; loadCardsFromPath: (path: string, rawSrc: string, layersStr?: string, backLayersStr?: string) => Promise<void>;
setError: (error: string | null) => void; setError: (error: string | null) => void;
clearError: () => void; clearError: () => void;
// 生成代码 // 生成代码
generateCode: () => string; generateCode: (backLayersStr?: string) => string;
copyCode: () => Promise<void>; copyCode: (backLayersStr?: string) => Promise<void>;
// 导出操作 // 导出操作
setExporting: (exporting: boolean) => void; setExporting: (exporting: boolean) => void;
@ -112,8 +121,9 @@ export interface DeckActions {
// 打印设置 // 打印设置
setPrintOrientation: (orientation: 'portrait' | 'landscape') => void; setPrintOrientation: (orientation: 'portrait' | 'landscape') => void;
setPrintOddPageOffsetX: (offset: number) => void; setPrintFrontOddPageOffsetX: (offset: number) => void;
setPrintOddPageOffsetY: (offset: number) => void; setPrintFrontOddPageOffsetY: (offset: number) => void;
setPrintDoubleSided: (doubleSided: boolean) => void;
} }
export interface DeckStore { export interface DeckStore {
@ -126,7 +136,6 @@ export interface DeckStore {
*/ */
export function createDeckStore( export function createDeckStore(
initialSrc: string = '', initialSrc: string = '',
initialLayers: string = ''
): DeckStore { ): DeckStore {
const [state, setState] = createStore<DeckState>({ const [state, setState] = createStore<DeckState>({
sizeW: DECK_DEFAULTS.SIZE_W, sizeW: DECK_DEFAULTS.SIZE_W,
@ -141,9 +150,11 @@ export function createDeckStore(
dimensions: null, dimensions: null,
cards: [] as any, cards: [] as any,
activeTab: 0, activeTab: 0,
layerConfigs: [], frontLayerConfigs: [],
backLayerConfigs: [],
isEditing: false, isEditing: false,
editingLayer: null, editingLayer: null,
activeSide: 'front',
isSelecting: false, isSelecting: false,
selectStart: null, selectStart: null,
selectEnd: null, selectEnd: null,
@ -153,8 +164,9 @@ export function createDeckStore(
exportProgress: 0, exportProgress: 0,
exportError: null, exportError: null,
printOrientation: 'portrait', printOrientation: 'portrait',
printOddPageOffsetX: 0, printFrontOddPageOffsetX: 0,
printOddPageOffsetY: 0 printFrontOddPageOffsetY: 0,
printDoubleSided: false
}); });
// 更新尺寸并重新计算 dimensions // 更新尺寸并重新计算 dimensions
@ -201,22 +213,39 @@ export function createDeckStore(
setState('cards', index, key, value); setState('cards', index, key, value);
}; };
const setLayerConfigs = (configs: LayerConfig[]) => setState({ layerConfigs: configs }); // 正面图层操作
const updateLayerConfig = (prop: string, updates: Partial<LayerConfig>) => { const setFrontLayerConfigs = (configs: LayerConfig[]) => setState({ frontLayerConfigs: configs });
setState('layerConfigs', (prev) => prev.map((config) => config.prop === prop ? { ...config, ...updates } : config)); const updateFrontLayerConfig = (prop: string, updates: Partial<LayerConfig>) => {
setState('frontLayerConfigs', (prev) => prev.map((config) => config.prop === prop ? { ...config, ...updates } : config));
}; };
const toggleLayerVisible = (prop: string) => { const toggleFrontLayerVisible = (prop: string) => {
setState('layerConfigs', (prev) => prev.map((config) => setState('frontLayerConfigs', (prev) => prev.map((config) =>
config.prop === prop ? { ...config, visible: !config.visible } : config
));
};
// 背面图层操作
const setBackLayerConfigs = (configs: LayerConfig[]) => setState({ backLayerConfigs: configs });
const updateBackLayerConfig = (prop: string, updates: Partial<LayerConfig>) => {
setState('backLayerConfigs', (prev) => prev.map((config) => config.prop === prop ? { ...config, ...updates } : config));
};
const toggleBackLayerVisible = (prop: string) => {
setState('backLayerConfigs', (prev) => prev.map((config) =>
config.prop === prop ? { ...config, visible: !config.visible } : config config.prop === prop ? { ...config, visible: !config.visible } : config
)); ));
}; };
const setIsEditing = (editing: boolean) => setState({ isEditing: editing }); const setIsEditing = (editing: boolean) => setState({ isEditing: editing });
const setEditingLayer = (layer: string | null) => setState({ editingLayer: layer }); const setEditingLayer = (layer: string | null) => setState({ editingLayer: layer });
const setActiveSide = (side: CardSide) => setState({ activeSide: side });
const updateLayerPosition = (x1: number, y1: number, x2: number, y2: number) => { const updateLayerPosition = (x1: number, y1: number, x2: number, y2: number) => {
const layer = state.editingLayer; const layer = state.editingLayer;
if (!layer) return; if (!layer) return;
setState('layerConfigs', (prev) => prev.map((config) => const currentSide = state.activeSide;
const configs = currentSide === 'front' ? state.frontLayerConfigs : state.backLayerConfigs;
const setter = currentSide === 'front' ? setFrontLayerConfigs : setBackLayerConfigs;
setter(configs.map((config) =>
config.prop === layer ? { ...config, x1, y1, x2, y2 } : config config.prop === layer ? { ...config, x1, y1, x2, y2 } : config
)); ));
setState({ editingLayer: null }); setState({ editingLayer: null });
@ -230,7 +259,7 @@ export function createDeckStore(
}; };
// 加载卡牌数据(核心逻辑) // 加载卡牌数据(核心逻辑)
const loadCardsFromPath = async (path: string, rawSrc: string, layersStr: string = '') => { const loadCardsFromPath = async (path: string, rawSrc: string, layersStr: string = '', backLayersStr: string = '') => {
if (!path) { if (!path) {
setState({ error: '未指定 CSV 文件路径' }); setState({ error: '未指定 CSV 文件路径' });
return; return;
@ -252,7 +281,8 @@ export function createDeckStore(
setState({ setState({
cards: data, cards: data,
activeTab: 0, activeTab: 0,
layerConfigs: initLayerConfigs(data, layersStr), frontLayerConfigs: initLayerConfigsForSide(data, layersStr),
backLayerConfigs: initLayerConfigsForSide(data, backLayersStr),
isLoading: false isLoading: false
}); });
updateDimensions(); updateDimensions();
@ -267,8 +297,9 @@ export function createDeckStore(
const setError = (error: string | null) => setState({ error }); const setError = (error: string | null) => setState({ error });
const clearError = () => setState({ error: null }); const clearError = () => setState({ error: null });
const generateCode = () => { const generateCode = (backLayersStr?: string) => {
const layersStr = formatLayers(state.layerConfigs); const frontLayersStr = formatLayers(state.frontLayerConfigs);
const backLayersString = backLayersStr || formatLayers(state.backLayerConfigs);
const parts = [ const parts = [
`:md-deck[${state.rawSrc || state.src}]`, `:md-deck[${state.rawSrc || state.src}]`,
`{size="${state.sizeW}x${state.sizeH}" `, `{size="${state.sizeW}x${state.sizeH}" `,
@ -283,12 +314,16 @@ export function createDeckStore(
parts.push(`padding="${state.padding}" `); parts.push(`padding="${state.padding}" `);
} }
parts.push(`layers="${layersStr}"}`); parts.push(`layers="${frontLayersStr}" `);
if (backLayersString) {
parts.push(`back-layers="${backLayersString}" `);
}
parts.push('}');
return parts.join(''); return parts.join('');
}; };
const copyCode = async () => { const copyCode = async (backLayersStr?: string) => {
const code = generateCode(); const code = generateCode(backLayersStr);
try { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
alert('已复制到剪贴板!'); alert('已复制到剪贴板!');
@ -314,12 +349,16 @@ export function createDeckStore(
setState({ printOrientation: orientation }); setState({ printOrientation: orientation });
}; };
const setPrintOddPageOffsetX = (offset: number) => { const setPrintFrontOddPageOffsetX = (offset: number) => {
setState({ printOddPageOffsetX: offset }); setState({ printFrontOddPageOffsetX: offset });
}; };
const setPrintOddPageOffsetY = (offset: number) => { const setPrintFrontOddPageOffsetY = (offset: number) => {
setState({ printOddPageOffsetY: offset }); setState({ printFrontOddPageOffsetY: offset });
};
const setPrintDoubleSided = (doubleSided: boolean) => {
setState({ printDoubleSided: doubleSided });
}; };
const actions: DeckActions = { const actions: DeckActions = {
@ -332,12 +371,16 @@ export function createDeckStore(
setCards, setCards,
setActiveTab, setActiveTab,
updateCardData, updateCardData,
setLayerConfigs, setFrontLayerConfigs,
updateLayerConfig, updateFrontLayerConfig,
toggleLayerVisible, toggleFrontLayerVisible,
setBackLayerConfigs,
updateBackLayerConfig,
toggleBackLayerVisible,
setIsEditing, setIsEditing,
setEditingLayer, setEditingLayer,
updateLayerPosition, updateLayerPosition,
setActiveSide,
setIsSelecting, setIsSelecting,
setSelectStart, setSelectStart,
setSelectEnd, setSelectEnd,
@ -353,8 +396,9 @@ export function createDeckStore(
setExportError, setExportError,
clearExportError, clearExportError,
setPrintOrientation, setPrintOrientation,
setPrintOddPageOffsetX, setPrintFrontOddPageOffsetX,
setPrintOddPageOffsetY setPrintFrontOddPageOffsetY,
setPrintDoubleSided
}; };
return { state, actions }; return { state, actions };

View File

@ -49,13 +49,13 @@ export function formatLayers(layers: LayerConfig[]): string {
} }
/** /**
* *
*/ */
export function initLayerConfigs( export function initLayerConfigsForSide(
data: CSV<any>, data: CSV<any>,
existingLayersStr: string layersStr: string
): LayerConfig[] { ): LayerConfig[] {
const parsed = parseLayers(existingLayersStr); const parsed = parseLayers(layersStr);
const allProps = Object.keys(data[0] || {}).filter(k => k !== 'label'); const allProps = Object.keys(data[0] || {}).filter(k => k !== 'label');
return allProps.map(prop => { return allProps.map(prop => {
@ -72,3 +72,13 @@ export function initLayerConfigs(
}; };
}); });
} }
/**
*
*/
export function initLayerConfigs(
data: CSV<any>,
existingLayersStr: string
): LayerConfig[] {
return initLayerConfigsForSide(data, existingLayersStr);
}

View File

@ -1,10 +1,11 @@
import type { DeckStore } from './deckStore'; import type { DeckStore } from './deckStore';
import type { CardData, LayerConfig, Dimensions } from '../types'; import type { CardData, LayerConfig, Dimensions, CardSide } from '../types';
export interface PageCard { export interface PageCard {
data: CardData; data: CardData;
x: number; x: number;
y: number; y: number;
side?: CardSide;
} }
export interface PageData { export interface PageData {

View File

@ -24,8 +24,9 @@ const PRINT_MARGIN = 5;
*/ */
export function usePageLayout(store: DeckStore): UsePageLayoutReturn { export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
const orientation = () => store.state.printOrientation; const orientation = () => store.state.printOrientation;
const oddPageOffsetX = () => store.state.printOddPageOffsetX; const doubleSided = () => store.state.printDoubleSided;
const oddPageOffsetY = () => store.state.printOddPageOffsetY; const frontOddPageOffsetX = () => store.state.printFrontOddPageOffsetX;
const frontOddPageOffsetY = () => store.state.printFrontOddPageOffsetY;
const getA4Size = () => { const getA4Size = () => {
if (orientation() === 'landscape') { if (orientation() === 'landscape') {
@ -52,6 +53,73 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
const baseOffsetY = (a4Height - maxGridHeight) / 2; const baseOffsetY = (a4Height - maxGridHeight) / 2;
const result: PageData[] = []; const result: PageData[] = [];
if (doubleSided()) {
// 双面打印模式:每页多张卡牌,正面和背面分别在相邻的两页
const totalCards = cards.length;
const totalPages = Math.ceil(totalCards / cardsPerPage);
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
const frontPageIndex = pageIndex * 2;
const backPageIndex = pageIndex * 2 + 1;
// 确保页面数组有足够长度
while (result.length <= backPageIndex) {
result.push({
pageIndex: result.length,
cards: [],
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
frameBounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
});
}
const frontPage = result[frontPageIndex];
const backPage = result[backPageIndex];
// 计算当前正面页的卡牌范围
const startCardIndex = pageIndex * cardsPerPage;
const endCardIndex = Math.min(startCardIndex + cardsPerPage, totalCards);
for (let i = startCardIndex; i < endCardIndex; i++) {
// 正面:正常顺序排列
const indexInPage = i - startCardIndex;
const row = Math.floor(indexInPage / cardsPerRow);
const col = indexInPage % cardsPerRow;
// 双面打印时,所有正面页都在奇数物理页上,所以都应用偏移
const pageOffsetX = frontOddPageOffsetX();
const pageOffsetY = frontOddPageOffsetY();
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.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);
// 背面:逆转顺序排列(长边方向)
// 对于竖向打印,长边是垂直方向,所以逆转行
// 对于横向打印,长边是水平方向,所以逆转列
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.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);
}
}
} else {
// 单面打印模式:原有逻辑
let currentPage: PageData = { let currentPage: PageData = {
pageIndex: 0, pageIndex: 0,
cards: [], cards: [],
@ -76,13 +144,13 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
} }
const isOddPage = pageIndex % 2 === 0; const isOddPage = pageIndex % 2 === 0;
const pageOffsetX = isOddPage ? oddPageOffsetX() : 0; const pageOffsetX = isOddPage ? frontOddPageOffsetX() : 0;
const pageOffsetY = isOddPage ? oddPageOffsetY() : 0; const pageOffsetY = isOddPage ? frontOddPageOffsetY() : 0;
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 }); 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.maxX, cardX + cardWidth);
@ -92,16 +160,22 @@ export function usePageLayout(store: DeckStore): UsePageLayoutReturn {
if (currentPage.cards.length > 0) { if (currentPage.cards.length > 0) {
result.push(currentPage); result.push(currentPage);
} }
}
return result.map(page => ({ return result.map(page => {
const offsetX = doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetX() : 0;
const offsetY = doubleSided() && page.pageIndex % 2 === 0 ? frontOddPageOffsetY() : 0;
return {
...page, ...page,
frameBounds: { frameBounds: {
minX: baseOffsetX + (page.pageIndex % 2 === 0 ? oddPageOffsetX() : 0), minX: baseOffsetX + offsetX,
minY: baseOffsetY + (page.pageIndex % 2 === 0 ? oddPageOffsetY() : 0), minY: baseOffsetY + offsetY,
maxX: baseOffsetX + maxGridWidth + (page.pageIndex % 2 === 0 ? oddPageOffsetX() : 0), maxX: baseOffsetX + maxGridWidth + offsetX,
maxY: baseOffsetY + maxGridHeight + (page.pageIndex % 2 === 0 ? oddPageOffsetY() : 0) maxY: baseOffsetY + maxGridHeight + offsetY
} }
})); };
});
}); });
const cropMarks = createMemo<CropMarkData[]>(() => { const cropMarks = createMemo<CropMarkData[]>(() => {

View File

@ -17,6 +17,7 @@ interface DeckProps {
bleed?: number | string; bleed?: number | string;
padding?: number | string; padding?: number | string;
layers?: string; layers?: string;
backLayers?: string;
fixed?: boolean | string; fixed?: boolean | string;
} }
@ -30,6 +31,7 @@ customElement<DeckProps>('md-deck', {
bleed: 1, bleed: 1,
padding: 2, padding: 2,
layers: '', layers: '',
backLayers: '',
fixed: false fixed: false
}, (props, { element }) => { }, (props, { element }) => {
noShadowDOM(); noShadowDOM();
@ -50,7 +52,7 @@ customElement<DeckProps>('md-deck', {
const resolvedSrc = resolvePath(articlePath, csvPath); const resolvedSrc = resolvePath(articlePath, csvPath);
// 创建 store 并加载数据 // 创建 store 并加载数据
const store = createDeckStore(resolvedSrc, (props.layers as string) || ''); const store = createDeckStore(resolvedSrc);
// 解析 size 属性(支持旧格式 "54x86" 和新格式) // 解析 size 属性(支持旧格式 "54x86" 和新格式)
if (props.size && props.size.includes('x')) { if (props.size && props.size.includes('x')) {
@ -86,7 +88,12 @@ customElement<DeckProps>('md-deck', {
} }
// 加载 CSV 数据 // 加载 CSV 数据
store.actions.loadCardsFromPath(resolvedSrc, csvPath, (props.layers as string) || ''); store.actions.loadCardsFromPath(
resolvedSrc,
csvPath,
(props.layers as string) || '',
(props.backLayers as string) || ''
);
// 清理函数 // 清理函数
onCleanup(() => { onCleanup(() => {

View File

@ -2,6 +2,8 @@ export interface CardData {
[key: string]: string; [key: string]: string;
} }
export type CardSide = 'front' | 'back';
export interface Layer { export interface Layer {
prop: string; prop: string;
x1: number; x1: number;