feat: nsew layers

This commit is contained in:
hyper 2026-02-27 22:56:06 +08:00
parent c204dc695c
commit 5bc77a2291
7 changed files with 167 additions and 60 deletions

View File

@ -29,29 +29,32 @@ function renderLayerContent(layer: { prop: string }, cardData: CardData): string
export function CardLayer(props: CardLayerProps) { export function CardLayer(props: CardLayerProps) {
return ( return (
<For each={props.layers}> <For each={props.layers}>
{(layer) => ( {(layer) => {
<> const layerStyle = getLayerStyle(layer, props.dimensions);
<article return (
class="absolute flex items-center justify-center text-center prose prose-sm" <>
style={{ <article
...getLayerStyle(layer, props.dimensions), class="absolute flex items-center justify-center text-center prose prose-sm"
'font-size': `${props.dimensions.fontSize}mm`
}}
innerHTML={renderLayerContent(layer, props.cardData)}
/>
{props.showBounds && (
<div
class="absolute border-2 border-blue-500/50 pointer-events-none select-none"
style={{ style={{
left: `${(layer.x1 - 1) * props.dimensions.cellWidth}mm`, ...layerStyle,
top: `${(layer.y1 - 1) * props.dimensions.cellHeight}mm`, 'font-size': `${props.dimensions.fontSize}mm`
width: `${(layer.x2 - layer.x1 + 1) * props.dimensions.cellWidth}mm`,
height: `${(layer.y2 - layer.y1 + 1) * props.dimensions.cellHeight}mm`
}} }}
innerHTML={renderLayerContent(layer, props.cardData)}
/> />
)} {props.showBounds && (
</> <div
)} class="absolute border-2 border-blue-500/50 pointer-events-none select-none"
style={{
left: `${(layer.x1 - 1) * props.dimensions.cellWidth}mm`,
top: `${(layer.y1 - 1) * props.dimensions.cellHeight}mm`,
width: `${(layer.x2 - layer.x1 + 1) * props.dimensions.cellWidth}mm`,
height: `${(layer.y2 - layer.y1 + 1) * props.dimensions.cellHeight}mm`
}}
/>
)}
</>
);
}}
</For> </For>
); );
} }

View File

@ -5,12 +5,26 @@ export interface LayerEditorPanelProps {
store: DeckStore; store: DeckStore;
} }
const ORIENTATION_OPTIONS = [
{ value: 'n', label: '↑ 北' },
{ value: 'e', label: '→ 东' },
{ value: 's', label: '↓ 南' },
{ value: 'w', label: '← 西' }
] as const;
/** /**
* *
*/ */
export function LayerEditorPanel(props: LayerEditorPanelProps) { export function LayerEditorPanel(props: LayerEditorPanelProps) {
const { store } = props; const { store } = props;
const updateLayerOrientation = (layerProp: string, orientation: 'n' | 's' | 'e' | 'w') => {
const layer = store.state.layerConfigs.find(l => l.prop === layerProp);
if (layer) {
store.actions.updateLayerConfig(layerProp, { ...layer, orientation });
}
};
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"></h3>
@ -18,24 +32,39 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
<div class="space-y-2"> <div class="space-y-2">
<For each={store.state.layerConfigs}> <For each={store.state.layerConfigs}>
{(layer) => ( {(layer) => (
<div class="flex items-center gap-2"> <div class="flex flex-col gap-1 p-2 bg-gray-50 rounded">
<input <div class="flex items-center gap-2">
type="checkbox" <input
checked={layer.visible} type="checkbox"
onChange={() => store.actions.toggleLayerVisible(layer.prop)} checked={layer.visible}
class="cursor-pointer" onChange={() => store.actions.toggleLayerVisible(layer.prop)}
/> class="cursor-pointer"
<span class="text-sm flex-1">{layer.prop}</span> />
<button <span class="text-sm flex-1">{layer.prop}</span>
onClick={() => store.actions.setEditingLayer(store.state.editingLayer === layer.prop ? null : layer.prop)} </div>
class={`text-xs px-2 py-0.5 rounded cursor-pointer ${ <div class="flex items-center gap-2">
store.state.editingLayer === layer.prop <select
? 'bg-blue-500 text-white' value={layer.orientation || 'n'}
: 'bg-gray-200 text-gray-700 hover:bg-gray-300' onChange={(e) => updateLayerOrientation(layer.prop, e.target.value as 'n' | 's' | 'e' | 'w')}
}`} class="text-xs px-2 py-1 rounded border border-gray-300 bg-white cursor-pointer"
> >
{store.state.editingLayer === layer.prop ? '✓ 框选' : '编辑'} <For each={ORIENTATION_OPTIONS}>
</button> {(opt) => (
<option value={opt.value}>{opt.label}</option>
)}
</For>
</select>
<button
onClick={() => store.actions.setEditingLayer(store.state.editingLayer === layer.prop ? null : layer.prop)}
class={`text-xs px-2 py-1 rounded cursor-pointer ${
store.state.editingLayer === layer.prop
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{store.state.editingLayer === layer.prop ? '✓ 框选' : '框选'}
</button>
</div>
</div> </div>
)} )}
</For> </For>

View File

@ -28,6 +28,7 @@ export interface DeckState {
fontSize: number; fontSize: number;
fixed: boolean; fixed: boolean;
src: string; src: string;
rawSrc: string; // 原始 CSV 路径(用于生成代码时保持相对路径)
// 解析后的尺寸 // 解析后的尺寸
dimensions: Dimensions | null; dimensions: Dimensions | null;
@ -97,7 +98,7 @@ export interface DeckActions {
cancelSelection: () => void; cancelSelection: () => void;
// 数据加载 // 数据加载
loadCardsFromPath: (path: string, layersStr?: string) => Promise<void>; loadCardsFromPath: (path: string, rawSrc: string, layersStr?: string) => Promise<void>;
setError: (error: string | null) => void; setError: (error: string | null) => void;
clearError: () => void; clearError: () => void;
@ -140,6 +141,7 @@ export function createDeckStore(
fontSize: DECK_DEFAULTS.FONT_SIZE, fontSize: DECK_DEFAULTS.FONT_SIZE,
fixed: false, fixed: false,
src: initialSrc, src: initialSrc,
rawSrc: initialSrc,
dimensions: null, dimensions: null,
cards: [], cards: [],
activeTab: 0, activeTab: 0,
@ -237,13 +239,13 @@ export function createDeckStore(
}; };
// 加载卡牌数据(核心逻辑) // 加载卡牌数据(核心逻辑)
const loadCardsFromPath = async (path: string, layersStr: string = '') => { const loadCardsFromPath = async (path: string, rawSrc: string, layersStr: string = '') => {
if (!path) { if (!path) {
setState({ error: '未指定 CSV 文件路径' }); setState({ error: '未指定 CSV 文件路径' });
return; return;
} }
setState({ isLoading: true, error: null, src: path }); setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
try { try {
const data = await loadCSV(path); const data = await loadCSV(path);
@ -277,9 +279,9 @@ export function createDeckStore(
const generateCode = () => { const generateCode = () => {
const layersStr = state.layerConfigs const layersStr = state.layerConfigs
.filter(l => l.visible) .filter(l => l.visible)
.map(l => `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`) .map(l => `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}${l.orientation && l.orientation !== 'n' ? `${l.orientation}` : ''}`)
.join(' '); .join(' ');
return `:md-deck[${state.src}]{size="${state.sizeW}x${state.sizeH}" grid="${state.gridW}x${state.gridH}" bleed="${state.bleed}" padding="${state.padding}" font-size="${state.fontSize}" layers="${layersStr}"}`; return `:md-deck[${state.rawSrc || state.src}]{size="${state.sizeW}x${state.sizeH}" grid="${state.gridW}x${state.gridH}" bleed="${state.bleed}" padding="${state.padding}" font-size="${state.fontSize}" layers="${layersStr}"}`;
}; };
const copyCode = async () => { const copyCode = async () => {

View File

@ -47,20 +47,89 @@ export function calculateDimensions(options: DimensionOptions): Dimensions {
/** /**
* layer mm * layer mm
* orientation
*
* orientation
* - n (0°):
* - e (90°): 90°
* - s (180°): 180°
* - w (270°): 270°
*/ */
export function getLayerStyle( export function getLayerStyle(
layer: { x1: number; y1: number; x2: number; y2: number }, layer: { x1: number; y1: number; x2: number; y2: number; orientation?: 'n' | 's' | 'e' | 'w' },
dims: Dimensions dims: Dimensions
): { left: string; top: string; width: string; height: string } { ): {
const left = (layer.x1 - 1) * dims.cellWidth; left: string;
const top = (layer.y1 - 1) * dims.cellHeight; top: string;
const width = (layer.x2 - layer.x1 + 1) * dims.cellWidth; width: string;
const height = (layer.y2 - layer.y1 + 1) * dims.cellHeight; height: string;
transform?: string;
'transform-origin'?: string;
} {
const cellWidth = dims.cellWidth;
const cellHeight = dims.cellHeight;
// 计算原始矩形的边界(相对于网格区域起点)
const baseLeft = (layer.x1 - 1) * cellWidth;
const baseTop = (layer.y1 - 1) * cellHeight;
const baseWidth = (layer.x2 - layer.x1 + 1) * cellWidth;
const baseHeight = (layer.y2 - layer.y1 + 1) * cellHeight;
const orientation = layer.orientation ?? 'n';
// 根据方向决定旋转角度
// n: 0°, e: 90°, s: 180°, w: 270°
const rotationMap: Record<'n' | 's' | 'e' | 'w', number> = {
n: 0,
e: 90,
s: 180,
w: 270
};
const rotation = rotationMap[orientation];
// 对于 e/w 方向,需要交换宽高
if (orientation === 'e' || orientation === 'w') {
// 计算旋转后的位置,使得元素覆盖相同的区域
// 旋转 90°/270°后原来的宽度变成高度高度变成宽度
// 需要调整 left/top 使得旋转后的元素中心与原始矩形中心重合
// 元素尺寸交换
const width = baseHeight;
const height = baseWidth;
// 计算新起点,使得旋转后覆盖相同区域
// 旋转中心设为元素中心,旋转后元素覆盖的区域与原始区域相同
const left = baseLeft + (baseWidth - width) / 2;
const top = baseTop + (baseHeight - height) / 2;
return {
left: `${left}mm`,
top: `${top}mm`,
width: `${width}mm`,
height: `${height}mm`,
transform: `rotate(${rotation}deg)`,
'transform-origin': 'center center'
};
}
// n 或 s 方向,宽高不变
if (orientation === 's') {
return {
left: `${baseLeft}mm`,
top: `${baseTop}mm`,
width: `${baseWidth}mm`,
height: `${baseHeight}mm`,
transform: `rotate(180deg)`,
'transform-origin': 'center center'
};
}
// n 方向(默认)
return { return {
left: `${left}mm`, left: `${baseLeft}mm`,
top: `${top}mm`, top: `${baseTop}mm`,
width: `${width}mm`, width: `${baseWidth}mm`,
height: `${height}mm` height: `${baseHeight}mm`
}; };
} }

View File

@ -1,13 +1,13 @@
import type { Layer, LayerConfig } from '../types'; import type { Layer, LayerConfig } from '../types';
/** /**
* layers "body:1,7-5,8 title:1,1-5,1" * layers "body:1,7-5,8 title:1,1-5,1" "body:1,7-5,8,s title:1,1-5,1,e"
*/ */
export function parseLayers(layersStr: string): Layer[] { export function parseLayers(layersStr: string): Layer[] {
if (!layersStr) return []; if (!layersStr) return [];
const layers: Layer[] = []; const layers: Layer[] = [];
const regex = /(\w+):(\d+),(\d+)-(\d+),(\d+)/g; const regex = /(\w+):(\d+),(\d+)-(\d+),(\d+)([nsew])?/g;
let match; let match;
while ((match = regex.exec(layersStr)) !== null) { while ((match = regex.exec(layersStr)) !== null) {
@ -16,7 +16,8 @@ export function parseLayers(layersStr: string): Layer[] {
x1: parseInt(match[2]), x1: parseInt(match[2]),
y1: parseInt(match[3]), y1: parseInt(match[3]),
x2: parseInt(match[4]), x2: parseInt(match[4]),
y2: parseInt(match[5]) y2: parseInt(match[5]),
orientation: match[6] as 'n' | 's' | 'e' | 'w' | undefined
}); });
} }
@ -29,7 +30,7 @@ export function parseLayers(layersStr: string): Layer[] {
export function formatLayers(layers: LayerConfig[]): string { export function formatLayers(layers: LayerConfig[]): string {
return layers return layers
.filter(l => l.visible) .filter(l => l.visible)
.map(l => `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`) .map(l => `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}${l.orientation && l.orientation !== 'n' ? `${l.orientation}` : ''}`)
.join(' '); .join(' ');
} }
@ -42,7 +43,7 @@ export function initLayerConfigs(
): LayerConfig[] { ): LayerConfig[] {
const parsed = parseLayers(existingLayersStr); const parsed = parseLayers(existingLayersStr);
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 => {
const existing = parsed.find(l => l.prop === prop); const existing = parsed.find(l => l.prop === prop);
return { return {
@ -51,7 +52,8 @@ export function initLayerConfigs(
x1: existing?.x1 || 1, x1: existing?.x1 || 1,
y1: existing?.y1 || 1, y1: existing?.y1 || 1,
x2: existing?.x2 || 2, x2: existing?.x2 || 2,
y2: existing?.y2 || 2 y2: existing?.y2 || 2,
orientation: existing?.orientation
}; };
}); });
} }

View File

@ -94,7 +94,7 @@ customElement<DeckProps>('md-deck', {
} }
// 加载 CSV 数据 // 加载 CSV 数据
store.actions.loadCardsFromPath(resolvedSrc, (props.layers as string) || ''); store.actions.loadCardsFromPath(resolvedSrc, csvPath, (props.layers as string) || '');
// 清理函数 // 清理函数
onCleanup(() => { onCleanup(() => {

View File

@ -8,6 +8,7 @@ export interface Layer {
y1: number; y1: number;
x2: number; x2: number;
y2: number; y2: number;
orientation?: 'n' | 's' | 'e' | 'w';
} }
export interface LayerConfig { export interface LayerConfig {
@ -17,6 +18,7 @@ export interface LayerConfig {
y1: number; y1: number;
x2: number; x2: number;
y2: number; y2: number;
orientation?: 'n' | 's' | 'e' | 'w';
} }
export interface Dimensions { export interface Dimensions {