feat: shape for cards

This commit is contained in:
hyper 2026-03-14 15:48:55 +08:00
parent 984b8aa1c8
commit 107e6fd6a2
7 changed files with 142 additions and 31 deletions

View File

@ -1,6 +1,7 @@
import { Show, For, createMemo } from 'solid-js'; import { Show, For, createMemo } from 'solid-js';
import { useCardSelection } from './hooks/useCardSelection'; import { useCardSelection } from './hooks/useCardSelection';
import { getSelectionBoxStyle } from './hooks/useCardSelection'; import { getSelectionBoxStyle } from './hooks/useCardSelection';
import { getShapeClipPath } from './hooks/shape-styles';
import { CardLayer } from './CardLayer'; import { CardLayer } from './CardLayer';
import type { DeckStore } from './hooks/deckStore'; import type { DeckStore } from './hooks/deckStore';
@ -18,6 +19,7 @@ export function CardPreview(props: CardPreviewProps) {
const selectionStyle = createMemo(() => const selectionStyle = createMemo(() =>
getSelectionBoxStyle(store.state.selectStart, store.state.selectEnd, store.state.dimensions) getSelectionBoxStyle(store.state.selectStart, store.state.selectEnd, store.state.dimensions)
); );
const shapeClipPath = createMemo(() => getShapeClipPath(store.state.shape));
const selection = useCardSelection(store); const selection = useCardSelection(store);
@ -32,7 +34,8 @@ export function CardPreview(props: CardPreviewProps) {
classList={{ 'select-none': store.state.isEditing }} classList={{ 'select-none': store.state.isEditing }}
style={{ style={{
width: `${store.state.dimensions?.cardWidth}mm`, width: `${store.state.dimensions?.cardWidth}mm`,
height: `${store.state.dimensions?.cardHeight}mm` height: `${store.state.dimensions?.cardHeight}mm`,
'clip-path': shapeClipPath() !== 'none' ? shapeClipPath() : undefined
}} }}
onMouseDown={(e) => selection.onMouseDown(e, cardRef!)} onMouseDown={(e) => selection.onMouseDown(e, cardRef!)}
onMouseMove={(e) => selection.onMouseMove(e, cardRef!)} onMouseMove={(e) => selection.onMouseMove(e, cardRef!)}

View File

@ -1,7 +1,8 @@
import { For } from 'solid-js'; import { For, Show } from 'solid-js';
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 { 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';
@ -125,35 +126,46 @@ export function PrintPreview(props: PrintPreviewProps) {
</For> </For>
<For each={page.cards}> <For each={page.cards}>
{(card) => ( {(card) => {
<g class="card-group"> const cardWidth = store.state.dimensions?.cardWidth || 56;
<foreignObject const cardHeight = 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(clipPathId, cardWidth, cardHeight, store.state.shape);
width={`${store.state.dimensions?.cardWidth || 56}mm`}
height={`${store.state.dimensions?.cardHeight || 88}mm`} return (
> <g class="card-group">
<div class="w-full h-full bg-white" {...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}> <Show when={shapeClipPath}>
<div <defs>{shapeClipPath}</defs>
class="absolute" </Show>
style={{ <foreignObject
position: 'absolute', x={`${card.x}mm`}
left: `${store.state.dimensions?.gridOriginX}mm`, y={`${card.y}mm`}
top: `${store.state.dimensions?.gridOriginY}mm`, width={`${cardWidth}mm`}
width: `${store.state.dimensions?.gridAreaWidth}mm`, height={`${cardHeight}mm`}
height: `${store.state.dimensions?.gridAreaHeight}mm` clip-path={shapeClipPath ? `url(#${clipPathId})` : undefined}
}} >
> <div class="w-full h-full bg-white" {...({ xmlns: 'http://www.w3.org/1999/xhtml' } as any)}>
<CardLayer <div
store={store} class="absolute"
cardData={card.data} style={{
side={card.side || 'front'} position: 'absolute',
/> left: `${store.state.dimensions?.gridOriginX}mm`,
top: `${store.state.dimensions?.gridOriginY}mm`,
width: `${store.state.dimensions?.gridAreaWidth}mm`,
height: `${store.state.dimensions?.gridAreaHeight}mm`
}}
>
<CardLayer
store={store}
cardData={card.data}
side={card.side || 'front'}
/>
</div>
</div> </div>
</div> </foreignObject>
</foreignObject> </g>
</g> );
)} }}
</For> </For>
</svg> </svg>
); );

View File

@ -1,4 +1,5 @@
import type { DeckStore } from '../hooks/deckStore'; import type { DeckStore } from '../hooks/deckStore';
import type { CardShape } from '../types';
export interface PropertiesEditorPanelProps { export interface PropertiesEditorPanelProps {
store: DeckStore; store: DeckStore;
@ -100,6 +101,27 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
/> />
</div> </div>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"></label>
<div class="grid grid-cols-2 gap-2">
{(['rectangle', 'circle', 'triangle', 'hexagon'] as CardShape[]).map((shape) => (
<button
onClick={() => store.actions.setShape(shape)}
class={`px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.shape === shape
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{shape === 'rectangle' && '矩形'}
{shape === 'circle' && '圆形'}
{shape === 'triangle' && '三角形'}
{shape === 'hexagon' && '六边形'}
</button>
))}
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -2,7 +2,7 @@
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, initLayerConfigsForSide } from './layer-parser'; import { initLayerConfigs, formatLayers, initLayerConfigsForSide } from './layer-parser';
import type { CardData, LayerConfig, Dimensions, CardSide } from '../types'; import type { CardData, LayerConfig, Dimensions, CardSide, CardShape } from '../types';
/** /**
* *
@ -24,6 +24,7 @@ export interface DeckState {
gridH: number; gridH: number;
bleed: number; bleed: number;
padding: number; padding: number;
shape: CardShape;
fixed: boolean; fixed: boolean;
src: string; src: string;
rawSrc: string; // 原始 CSV 路径(用于生成代码时保持相对路径) rawSrc: string; // 原始 CSV 路径(用于生成代码时保持相对路径)
@ -75,6 +76,7 @@ export interface DeckActions {
setGridH: (grid: number) => void; setGridH: (grid: number) => void;
setBleed: (bleed: number) => void; setBleed: (bleed: number) => void;
setPadding: (padding: number) => void; setPadding: (padding: number) => void;
setShape: (shape: CardShape) => void;
// 数据设置 // 数据设置
setCards: (cards: CSV<CardData>) => void; setCards: (cards: CSV<CardData>) => void;
@ -144,6 +146,7 @@ export function createDeckStore(
gridH: DECK_DEFAULTS.GRID_H, gridH: DECK_DEFAULTS.GRID_H,
bleed: DECK_DEFAULTS.BLEED, bleed: DECK_DEFAULTS.BLEED,
padding: DECK_DEFAULTS.PADDING, padding: DECK_DEFAULTS.PADDING,
shape: 'rectangle',
fixed: false, fixed: false,
src: initialSrc, src: initialSrc,
rawSrc: initialSrc, rawSrc: initialSrc,
@ -206,6 +209,9 @@ export function createDeckStore(
setState({ padding }); setState({ padding });
updateDimensions(); updateDimensions();
}; };
const setShape = (shape: CardShape) => {
setState({ shape });
};
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 }); const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
const setActiveTab = (index: number) => setState({ activeTab: index }); const setActiveTab = (index: number) => setState({ activeTab: index });
@ -313,6 +319,9 @@ export function createDeckStore(
if (state.padding !== DECK_DEFAULTS.PADDING) { if (state.padding !== DECK_DEFAULTS.PADDING) {
parts.push(`padding="${state.padding}" `); parts.push(`padding="${state.padding}" `);
} }
if (state.shape !== 'rectangle') {
parts.push(`shape="${state.shape}" `);
}
parts.push(`layers="${frontLayersStr}" `); parts.push(`layers="${frontLayersStr}" `);
if (backLayersString) { if (backLayersString) {
@ -368,6 +377,7 @@ export function createDeckStore(
setGridH, setGridH,
setBleed, setBleed,
setPadding, setPadding,
setShape,
setCards, setCards,
setActiveTab, setActiveTab,
updateCardData, updateCardData,

View File

@ -0,0 +1,56 @@
import type { CardShape } from '../types';
/**
* CSS clip-path
*/
export function getShapeClipPath(shape: CardShape): string {
switch (shape) {
case 'circle':
return 'circle(50% at 50% 50%)';
case 'triangle':
return 'polygon(50% 0%, 0% 100%, 100% 100%)';
case 'hexagon':
return 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)';
case 'rectangle':
default:
return 'none';
}
}
/**
* SVG clipPath
* @param id clipPath ID
* @param width
* @param height
* @param shape
*/
export function getShapeSvgClipPath(
id: string,
width: number,
height: number,
shape: CardShape
): string {
const halfW = width / 2;
const halfH = height / 2;
switch (shape) {
case 'circle':
return `
<clipPath id="${id}">
<circle cx="${halfW}" cy="${halfH}" r="${halfW}" />
</clipPath>`;
case 'triangle':
return `
<clipPath id="${id}">
<polygon points="${halfW},0 0,${height} ${width},${height}" />
</clipPath>`;
case 'hexagon':
return `
<clipPath id="${id}">
<polygon points="${halfW},0 ${width},${height * 0.25} ${width},${height * 0.75} ${halfW},${height} 0,${height * 0.75} 0,${height * 0.25}" />
</clipPath>`;
case 'rectangle':
default:
return '';
}
}

View File

@ -2,6 +2,7 @@ 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 { DeckHeader } from './DeckHeader'; import { DeckHeader } from './DeckHeader';
import { DeckContent } from './DeckContent'; import { DeckContent } from './DeckContent';
import { PrintPreview } from './PrintPreview'; import { PrintPreview } from './PrintPreview';
@ -16,6 +17,7 @@ interface DeckProps {
gridH?: number; gridH?: number;
bleed?: number | string; bleed?: number | string;
padding?: number | string; padding?: number | string;
shape?: CardShape;
layers?: string; layers?: string;
backLayers?: string; backLayers?: string;
fixed?: boolean | string; fixed?: boolean | string;
@ -30,6 +32,7 @@ customElement<DeckProps>('md-deck', {
gridH: 8, gridH: 8,
bleed: 1, bleed: 1,
padding: 2, padding: 2,
shape: 'rectangle',
layers: '', layers: '',
backLayers: '', backLayers: '',
fixed: false fixed: false
@ -87,6 +90,9 @@ customElement<DeckProps>('md-deck', {
store.actions.setPadding(props.padding ?? 2); store.actions.setPadding(props.padding ?? 2);
} }
// 设置形状
store.actions.setShape(props.shape ?? 'rectangle');
// 加载 CSV 数据 // 加载 CSV 数据
store.actions.loadCardsFromPath( store.actions.loadCardsFromPath(
resolvedSrc, resolvedSrc,

View File

@ -4,6 +4,8 @@ export interface CardData {
export type CardSide = 'front' | 'back'; export type CardSide = 'front' | 'back';
export type CardShape = 'rectangle' | 'circle' | 'triangle' | 'hexagon';
export interface Layer { export interface Layer {
prop: string; prop: string;
x1: number; x1: number;