Compare commits

..

8 Commits

Author SHA1 Message Date
hypercross cd5741692a refactor: alignment icons 2026-03-31 22:32:53 +08:00
hypercross d8058fd224 refactor: hide original frame 2026-03-31 22:32:53 +08:00
hypercross 49fca8c18f fix: rotation 2026-03-31 22:32:53 +08:00
hypercross ceb2da8b1a refactor: layer transform controls 2026-03-31 22:32:53 +08:00
hypercross 56cabea109 refactor: layer editing ui 2026-03-31 22:32:53 +08:00
hypercross 831955e16e fix: csv loading 2026-03-31 22:32:52 +08:00
hypercross ea57cf8d2b refactor: layer alignment 2026-03-31 22:32:52 +08:00
hypercross 273f949839 refactor: details 2026-03-31 22:32:52 +08:00
14 changed files with 693 additions and 285 deletions

View File

@ -104,8 +104,8 @@
```
**功能:**
- 点击骰子图标执行掷骰
- 点击文本重置为公式
- 点击文本执行掷骰
- 点击骰子图标重置为公式
- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
**属性:**

View File

@ -1,15 +1,17 @@
import {createMemo, For} from 'solid-js';
import {parseMarkdown} from '../../markdown';
import { createMemo, For, Show } from 'solid-js';
import { parseMarkdown } from '../../markdown';
import { getLayerStyle } from './hooks/dimensions';
import type { CardData, CardSide } from './types';
import {DeckStore} from "./hooks/deckStore";
import {processVariables} from "../utils/csv-loader";
import {resolvePath} from "../utils/path";
import type { CardData, CardSide, LayerConfig } from './types';
import { DeckStore } from "./hooks/deckStore";
import { processVariables } from "../utils/csv-loader";
import { resolvePath } from "../utils/path";
import type { LayerInteractionHandlers } from './hooks/useLayerInteraction';
export interface CardLayerProps {
cardData: CardData;
store: DeckStore;
side?: CardSide;
interaction?: LayerInteractionHandlers;
}
export function CardLayer(props: CardLayerProps) {
@ -20,36 +22,160 @@ export function CardLayer(props: CardLayerProps) {
: props.store.state.backLayerConfigs.filter((l) => l.visible)
);
const dimensions = () => props.store.state.dimensions!;
const showBounds = () => props.store.state.isEditing;
const selectedLayer = () => props.store.state.selectedLayer;
const draggingState = () => props.store.state.draggingState;
function renderLayerContent(content: string) {
const iconPath = resolvePath(props.store.state.cards.sourcePath, props.cardData.iconPath ?? "./assets");
return parseMarkdown(processVariables(content, props.cardData, props.store.state.cards), iconPath) as string;
}
const getAlignStyle = (align?: 'l' | 'c' | 'r') => {
if (align === 'l') return 'left';
if (align === 'r') return 'right';
return 'center';
};
const isLayerSelected = (layer: LayerConfig) => selectedLayer() === layer.prop;
const getFrameBounds = (layer: LayerConfig) => {
const dims = dimensions();
const left = (layer.x1 - 1) * dims.cellWidth;
const top = (layer.y1 - 1) * dims.cellHeight;
const width = (layer.x2 - layer.x1 + 1) * dims.cellWidth;
const height = (layer.y2 - layer.y1 + 1) * dims.cellHeight;
return { left, top, width, height };
};
const handleLayerClick = (layerProp: string, e: MouseEvent) => {
if (props.interaction) {
props.interaction.onLayerClick(layerProp, e);
}
};
const handleFrameMouseDown = (
action: 'drag' | 'resize-corner' | 'resize-edge',
anchor?: 'nw' | 'ne' | 'sw' | 'se',
edge?: 'n' | 's' | 'e' | 'w',
e?: MouseEvent
) => {
if (props.interaction) {
props.interaction.onFrameMouseDown(action, anchor, edge, e);
}
};
return (
<For each={layers()}>
{(layer) => {
const bounds = () => getFrameBounds(layer);
const isSelected = () => isLayerSelected(layer);
return (
<>
<article
class="absolute flex flex-col items-center justify-center text-center prose prose-sm"
<article
class="absolute flex flex-col items-stretch justify-center prose prose-sm cursor-pointer"
classList={{
'ring-2 ring-blue-500 ring-offset-1': isSelected() && !draggingState()
}}
style={{
...getLayerStyle(layer, dimensions()),
'font-size': `${layer.fontSize || 3}mm`
'font-size': `${layer.fontSize || 3}mm`,
'text-align': getAlignStyle(layer.align)
}}
innerHTML={renderLayerContent(props.cardData[layer.prop])}
onClick={(e) => handleLayerClick(layer.prop, e)}
/>
{showBounds() && (
<Show when={isSelected()}>
<div
class="absolute border-2 border-blue-500/50 pointer-events-none select-none"
class="absolute border-2 border-blue-500 pointer-events-none z-10"
style={{
left: `${(layer.x1 - 1) * dimensions().cellWidth}mm`,
top: `${(layer.y1 - 1) * dimensions().cellHeight}mm`,
width: `${(layer.x2 - layer.x1 + 1) * dimensions().cellWidth}mm`,
height: `${(layer.y2 - layer.y1 + 1) * dimensions().cellHeight}mm`
left: `${bounds().left}mm`,
top: `${bounds().top}mm`,
width: `${bounds().width}mm`,
height: `${bounds().height}mm`
}}
/>
)}
<div
class="absolute pointer-events-auto z-10"
style={{
left: `${bounds().left}mm`,
top: `${bounds().top}mm`,
width: `${bounds().width}mm`,
height: `${bounds().height}mm`
}}
onMouseDown={(e) => handleFrameMouseDown('drag', undefined, undefined, e)}
/>
<Show when={!draggingState() || draggingState()?.action !== 'drag'}>
<div
class="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize pointer-events-auto z-20"
style={{
left: `${bounds().left - 2}mm`,
top: `${bounds().top - 2}mm`
}}
onMouseDown={(e) => handleFrameMouseDown('resize-corner', 'nw', undefined, e)}
/>
<div
class="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize pointer-events-auto z-20"
style={{
left: `${bounds().left + bounds().width - 2}mm`,
top: `${bounds().top - 2}mm`
}}
onMouseDown={(e) => handleFrameMouseDown('resize-corner', 'ne', undefined, e)}
/>
<div
class="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize pointer-events-auto z-20"
style={{
left: `${bounds().left - 2}mm`,
top: `${bounds().top + bounds().height - 2}mm`
}}
onMouseDown={(e) => handleFrameMouseDown('resize-corner', 'sw', undefined, e)}
/>
<div
class="absolute w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize pointer-events-auto z-20"
style={{
left: `${bounds().left + bounds().width - 2}mm`,
top: `${bounds().top + bounds().height - 2}mm`
}}
onMouseDown={(e) => handleFrameMouseDown('resize-corner', 'se', undefined, e)}
/>
<div
class="absolute h-2 cursor-ns-resize pointer-events-auto z-10"
style={{
left: `${bounds().left}mm`,
top: `${bounds().top - 1}mm`,
width: `${bounds().width}mm`
}}
onMouseDown={(e) => handleFrameMouseDown('resize-edge', undefined, 'n', e)}
/>
<div
class="absolute h-2 cursor-ns-resize pointer-events-auto z-10"
style={{
left: `${bounds().left}mm`,
top: `${bounds().top + bounds().height - 1}mm`,
width: `${bounds().width}mm`
}}
onMouseDown={(e) => handleFrameMouseDown('resize-edge', undefined, 's', e)}
/>
<div
class="absolute w-2 cursor-ew-resize pointer-events-auto z-10"
style={{
left: `${bounds().left - 1}mm`,
top: `${bounds().top}mm`,
height: `${bounds().height}mm`
}}
onMouseDown={(e) => handleFrameMouseDown('resize-edge', undefined, 'w', e)}
/>
<div
class="absolute w-2 cursor-ew-resize pointer-events-auto z-10"
style={{
left: `${bounds().left + bounds().width - 1}mm`,
top: `${bounds().top}mm`,
height: `${bounds().height}mm`
}}
onMouseDown={(e) => handleFrameMouseDown('resize-edge', undefined, 'e', e)}
/>
</Show>
</Show>
</>
);
}}

View File

@ -1,58 +1,67 @@
import { Show, For, createMemo } from 'solid-js';
import { useCardSelection } from './hooks/useCardSelection';
import { getSelectionBoxStyle } from './hooks/useCardSelection';
import { Show, For, createMemo, onCleanup, onMount } from 'solid-js';
import { getShapeClipPath } from './hooks/shape-styles';
import { CardLayer } from './CardLayer';
import type { DeckStore } from './hooks/deckStore';
import { useLayerInteraction } from './hooks/useLayerInteraction';
export interface CardPreviewProps {
store: DeckStore;
}
/**
*
*/
export function CardPreview(props: CardPreviewProps) {
const { store } = props;
const currentCard = createMemo(() => store.state.cards[store.state.activeTab]);
const selectionStyle = createMemo(() =>
getSelectionBoxStyle(store.state.selectStart, store.state.selectEnd, store.state.dimensions)
);
const shapeClipPath = createMemo(() => {
const dims = store.state.dimensions;
if (!dims) return 'none';
return getShapeClipPath(store.state.shape, dims.cardWidth, dims.cardHeight, store.state.cornerRadius);
});
const selection = useCardSelection(store);
const interaction = useLayerInteraction(store);
let cardRef: HTMLDivElement | undefined;
const handleGlobalMouseMove = (e: MouseEvent) => {
if (cardRef && store.state.draggingState) {
interaction.onCardMouseMove(e, cardRef);
}
};
const handleGlobalMouseUp = () => {
if (store.state.draggingState) {
interaction.onCardMouseUp();
}
};
onMount(() => {
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
});
onCleanup(() => {
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
});
return (
<div class="flex justify-center drop-shadow">
<Show when={store.state.activeTab < store.state.cards.length}>
<div
ref={cardRef}
class="relative bg-white border border-gray-300 overflow-hidden"
classList={{ 'select-none': store.state.isEditing }}
classList={{ 'select-none': !!store.state.draggingState }}
style={{
width: `${store.state.dimensions?.cardWidth}mm`,
height: `${store.state.dimensions?.cardHeight}mm`,
'clip-path': shapeClipPath() !== 'none' ? shapeClipPath() : undefined
}}
onMouseDown={(e) => selection.onMouseDown(e, cardRef!)}
onMouseMove={(e) => selection.onMouseMove(e, cardRef!)}
onMouseUp={selection.onMouseUp}
onMouseLeave={selection.onMouseLeave}
onClick={(e) => {
if (!store.state.draggingState && cardRef) {
interaction.onCardClick(e, cardRef);
}
}}
>
<Show when={store.state.isSelecting && selectionStyle()}>
<div
class="absolute bg-blue-500/30 border-2 border-blue-500 pointer-events-none"
style={selectionStyle()!}
/>
</Show>
<div
class="absolute"
style={{
@ -87,6 +96,7 @@ export function CardPreview(props: CardPreviewProps) {
cardData={currentCard()}
store={store}
side={store.state.activeSide}
interaction={interaction}
/>
</div>
</div>

View File

@ -1,5 +1,8 @@
import { For } from 'solid-js';
import { For, createSignal, onCleanup, onMount } from 'solid-js';
import type { DeckStore } from '../hooks/deckStore';
import alignLeftIcon from './icons/align-left.png';
import alignCenterIcon from './icons/align-center.png';
import alignRightIcon from './icons/align-right.png';
export interface LayerEditorPanelProps {
store: DeckStore;
@ -12,13 +15,38 @@ const ORIENTATION_OPTIONS = [
{ value: 'w', label: '← 西' }
] as const;
/**
*
*/
export function LayerEditorPanel(props: LayerEditorPanelProps) {
const { store } = props;
const ALIGN_OPTIONS = [
{ value: '', label: '默认', icon: alignCenterIcon },
{ value: 'l', label: '左对齐', icon: alignLeftIcon },
{ value: 'c', label: '居中', icon: alignCenterIcon },
{ value: 'r', label: '右对齐', icon: alignRightIcon }
] as const;
const FONT_PRESETS = [3, 5, 8, 12] as const;
function OrientationIcon(value: string): string {
switch (value) {
case 'n': return '↑';
case 'e': return '→';
case 's': return '↓';
case 'w': return '←';
default: return '↑';
}
}
function AlignIconSrc(value: string): string {
switch (value) {
case 'l': return alignLeftIcon;
case 'r': return alignRightIcon;
default: return alignCenterIcon;
}
}
function LayerEditorPanel(props: LayerEditorPanelProps) {
const { store } = props;
const [openDropdown, setOpenDropdown] = createSignal<string | null>(null);
let dropdownRef: HTMLDivElement | undefined;
// 根据当前激活的面获取图层配置
const currentLayerConfigs = () =>
store.state.activeSide === 'front'
? store.state.frontLayerConfigs
@ -29,6 +57,7 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
? store.actions.updateFrontLayerConfig
: store.actions.updateBackLayerConfig;
updateFn(layerProp, { orientation });
setOpenDropdown(null);
};
const updateLayerFontSize = (layerProp: string, fontSize?: number) => {
@ -38,6 +67,14 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
updateFn(layerProp, { fontSize });
};
const updateLayerAlign = (layerProp: string, align?: 'l' | 'c' | 'r') => {
const updateFn = store.state.activeSide === 'front'
? store.actions.updateFrontLayerConfig
: store.actions.updateBackLayerConfig;
updateFn(layerProp, { align });
setOpenDropdown(null);
};
const toggleLayerVisible = (layerProp: string) => {
const toggleFn = store.state.activeSide === 'front'
? store.actions.toggleFrontLayerVisible
@ -45,73 +82,181 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
toggleFn(layerProp);
};
const setEditingLayer = (layerProp: string) => {
store.actions.setEditingLayer(
store.state.editingLayer === layerProp ? null : layerProp
const selectLayer = (layerProp: string) => {
store.actions.setSelectedLayer(
store.state.selectedLayer === layerProp ? null : layerProp
);
};
const handleDropdownClick = (e: MouseEvent) => {
e.stopPropagation();
};
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setOpenDropdown(null);
}
};
onMount(() => {
document.addEventListener('click', handleClickOutside);
});
onCleanup(() => {
document.removeEventListener('click', handleClickOutside);
});
const layerCount = () => currentLayerConfigs().length;
return (
<div class="w-64 flex-shrink-0">
<h3 class="font-bold mb-2 mt-0">
({store.state.activeSide === 'front' ? '正面' : '背面'})
</h3>
<div class="space-y-2">
<div ref={dropdownRef}>
<For each={currentLayerConfigs()}>
{(layer) => (
<div class="flex flex-row flex-wrap gap-1 p-2 bg-gray-50 rounded">
<div class="flex items-center gap-2">
<input
type="checkbox"
checked={layer.visible}
onChange={() => toggleLayerVisible(layer.prop)}
class="cursor-pointer"
/>
<span class="text-sm flex-1">{layer.prop}</span>
</div>
{layer.visible && (
<>
<button
onClick={() => setEditingLayer(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'
}`}
{(layer, index) => (
<div
class={`flex items-center gap-1 py-1.5 px-1 ${
index() < layerCount() - 1 ? 'border-b border-gray-200' : ''
} ${
store.state.selectedLayer === layer.prop ? 'bg-blue-50' : ''
}`}
>
<input
type="checkbox"
checked={layer.visible}
onChange={() => toggleLayerVisible(layer.prop)}
class="cursor-pointer"
/>
<span
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600"
onClick={() => selectLayer(layer.prop)}
>
{layer.prop}
</span>
<div class="relative">
<button
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown() === `orient-${layer.prop}` ? null : `orient-${layer.prop}`);
}}
class={`w-7 h-7 text-sm rounded cursor-pointer flex items-center justify-center bg-gray-200 text-gray-700 hover:bg-gray-300 ${
!layer.visible ? 'invisible pointer-events-none' : ''
}`}
title="方向"
>
{OrientationIcon(layer.orientation || 'n')}
</button>
{openDropdown() === `orient-${layer.prop}` && (
<div
class="absolute top-full right-0 mt-1 bg-white border border-gray-300 rounded shadow-lg z-10"
onClick={handleDropdownClick}
>
{store.state.editingLayer === layer.prop ? '✓ 框选' : '框选'}
</button>
<div class="flex items-center gap-2">
<select
value={layer.orientation || 'n'}
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"
>
<For each={ORIENTATION_OPTIONS}>
{(opt) => (
<option value={opt.value}>{opt.label}</option>
<For each={ORIENTATION_OPTIONS}>
{(opt) => (
<button
onClick={() => updateLayerOrientation(layer.prop, opt.value)}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
>
{opt.label}
</button>
)}
</For>
</div>
)}
</div>
<div class="relative">
<button
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown() === `align-${layer.prop}` ? null : `align-${layer.prop}`);
}}
class={`w-7 h-7 rounded cursor-pointer flex items-center justify-center bg-gray-200 hover:bg-gray-300 ${
!layer.visible ? 'invisible pointer-events-none' : ''
}`}
title="对齐"
>
<img src={AlignIconSrc(layer.align || '')} alt="align" class="w-5 h-5 not-prose" />
</button>
{openDropdown() === `align-${layer.prop}` && (
<div
class="absolute top-full right-0 mt-1 bg-white border border-gray-300 rounded shadow-lg z-10"
onClick={handleDropdownClick}
>
<For each={ALIGN_OPTIONS}>
{(opt) => (
<button
onClick={() => updateLayerAlign(layer.prop, opt.value as 'l' | 'c' | 'r' | undefined || undefined)}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
>
<img src={opt.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
</button>
)}
</For>
</div>
)}
</div>
<div class="relative">
<button
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown() === `font-${layer.prop}` ? null : `font-${layer.prop}`);
}}
class={`w-7 h-7 text-xs rounded cursor-pointer flex items-center justify-center bg-gray-200 text-gray-700 hover:bg-gray-300 ${
!layer.visible ? 'invisible pointer-events-none' : ''
}`}
title="字体大小 (mm)"
>
{layer.fontSize ?? 3}
</button>
{openDropdown() === `font-${layer.prop}` && (
<div
class="absolute top-full right-0 mt-1 bg-white border border-gray-300 rounded shadow-lg z-10 p-2"
onClick={handleDropdownClick}
>
<div class="flex items-center gap-1 mb-2">
<input
type="number"
value={layer.fontSize ?? 3}
onChange={(e) => {
const value = e.target.value;
updateLayerFontSize(layer.prop, value ? Number(value) : undefined);
}}
class="w-14 text-xs px-1.5 py-1 rounded border border-gray-300"
step="0.1"
min="0.1"
/>
<span class="text-xs text-gray-500">mm</span>
</div>
<div class="flex gap-1">
<For each={FONT_PRESETS}>
{(preset) => (
<button
onClick={() => updateLayerFontSize(layer.prop, preset)}
class={`px-2 py-1 text-xs rounded cursor-pointer ${
(layer.fontSize ?? 3) === preset
? 'bg-blue-500 text-white'
: 'bg-gray-100 hover:bg-gray-200 text-gray-700'
}`}
>
{preset}
</button>
)}
</For>
</select>
</div>
<button
onClick={() => updateLayerFontSize(layer.prop, undefined)}
class="mt-2 w-full text-xs text-gray-500 hover:text-gray-700 cursor-pointer"
>
</button>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600">/mm</label>
<input
type="number"
value={layer.fontSize || ''}
placeholder="默认"
onChange={(e) => {
const value = e.target.value;
updateLayerFontSize(layer.prop, value ? Number(value) : undefined);
}}
class="w-16 text-xs px-2 py-1 rounded border border-gray-300 bg-white"
step="0.1"
min="0.1"
/>
</div>
</>
)}
)}
</div>
</div>
)}
</For>
@ -129,3 +274,5 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
</div>
);
}
export { LayerEditorPanel };

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

View File

@ -1,12 +1,9 @@
import { createStore } from 'solid-js/store';
import { createStore } from 'solid-js/store';
import { calculateDimensions } from './dimensions';
import { loadCSV, CSV } from '../../utils/csv-loader';
import { initLayerConfigs, formatLayers, initLayerConfigsForSide } from './layer-parser';
import type { CardData, LayerConfig, Dimensions, CardSide, CardShape } from '../types';
/**
*
*/
export const DECK_DEFAULTS = {
SIZE_W: 54,
SIZE_H: 86,
@ -17,8 +14,17 @@ export const DECK_DEFAULTS = {
CORNER_RADIUS: 3
} as const;
export interface DraggingState {
layer: string;
action: 'drag' | 'resize-corner' | 'resize-edge';
anchor?: 'nw' | 'ne' | 'sw' | 'se';
edge?: 'n' | 's' | 'e' | 'w';
startX: number;
startY: number;
startGrid: { x1: number; y1: number; x2: number; y2: number };
}
export interface DeckState {
// 基本属性
sizeW: number;
sizeH: number;
gridW: number;
@ -29,41 +35,33 @@ export interface DeckState {
shape: CardShape;
fixed: boolean;
src: string;
rawSrc: string; // 原始 CSV 路径(用于生成代码时保持相对路径)
rawSrc: string;
// 解析后的尺寸
dimensions: Dimensions | null;
// 卡牌数据
cards: CSV<CardData>;
activeTab: number;
// 图层配置
frontLayerConfigs: LayerConfig[];
backLayerConfigs: LayerConfig[];
// 编辑状态
isEditing: boolean;
editingLayer: string | null;
selectedLayer: string | null;
activeSide: CardSide;
// 框选状态
isSelecting: boolean;
selectStart: { x: number; y: number } | null;
selectEnd: { x: number; y: number } | null;
// 加载状态
isLoading: boolean;
draggingState: DraggingState | null;
// 错误状态
isLoading: boolean;
error: string | null;
// 导出状态
isExporting: boolean;
exportProgress: number; // 0-100
exportProgress: number;
exportError: string | null;
// 打印设置
printOrientation: 'portrait' | 'landscape';
printFrontOddPageOffsetX: number;
printFrontOddPageOffsetY: number;
@ -71,7 +69,6 @@ export interface DeckState {
}
export interface DeckActions {
// 基本属性设置
setSizeW: (size: number) => void;
setSizeH: (size: number) => void;
setGridW: (grid: number) => void;
@ -81,50 +78,45 @@ export interface DeckActions {
setCornerRadius: (cornerRadius: number) => void;
setShape: (shape: CardShape) => void;
// 数据设置
setCards: (cards: CSV<CardData>) => void;
setActiveTab: (index: number) => void;
updateCardData: (index: number, key: string, value: string) => void;
// 图层操作 - 正面
setFrontLayerConfigs: (configs: LayerConfig[]) => void;
updateFrontLayerConfig: (prop: string, updates: Partial<LayerConfig>) => void;
toggleFrontLayerVisible: (prop: string) => void;
// 图层操作 - 背面
setBackLayerConfigs: (configs: LayerConfig[]) => void;
updateBackLayerConfig: (prop: string, updates: Partial<LayerConfig>) => void;
toggleBackLayerVisible: (prop: string) => void;
// 编辑状态
setIsEditing: (editing: boolean) => void;
setEditingLayer: (layer: string | null) => void;
updateLayerPosition: (x1: number, y1: number, x2: number, y2: number) => void;
setSelectedLayer: (layer: string | null) => void;
setActiveSide: (side: CardSide) => void;
// 框选操作
setIsSelecting: (selecting: boolean) => void;
setSelectStart: (pos: { x: number; y: number } | null) => void;
setSelectEnd: (pos: { x: number; y: number } | null) => void;
cancelSelection: () => void;
// 数据加载
setDraggingState: (state: DraggingState | null) => void;
moveLayer: (layerProp: string, dxGrid: number, dyGrid: number, startGrid?: { x1: number; y1: number; x2: number; y2: number }) => void;
resizeLayerCorner: (layerProp: string, anchor: 'nw' | 'ne' | 'sw' | 'se', dxGrid: number, dyGrid: number, startGrid: { x1: number; y1: number; x2: number; y2: number }) => void;
resizeLayerEdge: (layerProp: string, edge: 'n' | 's' | 'e' | 'w', delta: number, startGrid: { x1: number; y1: number; x2: number; y2: number }) => void;
loadCardsFromPath: (path: string, rawSrc: string, layersStr?: string, backLayersStr?: string) => Promise<void>;
setError: (error: string | null) => void;
clearError: () => void;
// 生成代码
generateCode: (backLayersStr?: string) => string;
copyCode: (backLayersStr?: string) => Promise<void>;
// 导出操作
setExporting: (exporting: boolean) => void;
exportDeck: () => void;
setExportProgress: (progress: number) => void;
setExportError: (error: string | null) => void;
clearExportError: () => void;
// 打印设置
setPrintOrientation: (orientation: 'portrait' | 'landscape') => void;
setPrintFrontOddPageOffsetX: (offset: number) => void;
setPrintFrontOddPageOffsetY: (offset: number) => void;
@ -136,9 +128,6 @@ export interface DeckStore {
actions: DeckActions;
}
/**
* deck store
*/
export function createDeckStore(
initialSrc: string = '',
): DeckStore {
@ -160,11 +149,12 @@ export function createDeckStore(
frontLayerConfigs: [],
backLayerConfigs: [],
isEditing: false,
editingLayer: null,
selectedLayer: null,
activeSide: 'front',
isSelecting: false,
selectStart: null,
selectEnd: null,
draggingState: null,
isLoading: false,
error: null,
isExporting: false,
@ -176,7 +166,6 @@ export function createDeckStore(
printDoubleSided: false
});
// 更新尺寸并重新计算 dimensions
const updateDimensions = () => {
const dims = calculateDimensions({
sizeW: state.sizeW,
@ -226,7 +215,6 @@ export function createDeckStore(
setState('cards', index, key, value);
};
// 正面图层操作
const setFrontLayerConfigs = (configs: LayerConfig[]) => setState({ frontLayerConfigs: configs });
const updateFrontLayerConfig = (prop: string, updates: Partial<LayerConfig>) => {
setState('frontLayerConfigs', (prev) => prev.map((config) => config.prop === prop ? { ...config, ...updates } : config));
@ -237,7 +225,6 @@ export function createDeckStore(
));
};
// 背面图层操作
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));
@ -249,21 +236,9 @@ export function createDeckStore(
};
const setIsEditing = (editing: boolean) => setState({ isEditing: editing });
const setEditingLayer = (layer: string | null) => setState({ editingLayer: layer });
const setSelectedLayer = (layer: string | null) => setState({ selectedLayer: layer });
const setActiveSide = (side: CardSide) => setState({ activeSide: side });
const updateLayerPosition = (x1: number, y1: number, x2: number, y2: number) => {
const layer = state.editingLayer;
if (!layer) return;
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
));
setState({ editingLayer: null });
};
const setIsSelecting = (selecting: boolean) => setState({ isSelecting: selecting });
const setSelectStart = (pos: { x: number; y: number } | null) => setState({ selectStart: pos });
const setSelectEnd = (pos: { x: number; y: number } | null) => setState({ selectEnd: pos });
@ -271,7 +246,68 @@ export function createDeckStore(
setState({ isSelecting: false, selectStart: null, selectEnd: null });
};
// 加载卡牌数据(核心逻辑)
const setDraggingState = (draggingState: DraggingState | null) => setState({ draggingState });
const getLayerConfig = (layerProp: string): LayerConfig | undefined => {
const configs = state.activeSide === 'front' ? state.frontLayerConfigs : state.backLayerConfigs;
return configs.find(c => c.prop === layerProp);
};
const updateLayerConfig = (layerProp: string, updates: Partial<LayerConfig>) => {
if (state.activeSide === 'front') {
updateFrontLayerConfig(layerProp, updates);
} else {
updateBackLayerConfig(layerProp, updates);
}
};
const moveLayer = (layerProp: string, dxGrid: number, dyGrid: number, startGrid?: { x1: number; y1: number; x2: number; y2: number }) => {
const layer = getLayerConfig(layerProp);
if (!layer) return;
const grid = startGrid ?? { x1: layer.x1, y1: layer.y1, x2: layer.x2, y2: layer.y2 };
updateLayerConfig(layerProp, {
x1: grid.x1 + dxGrid,
x2: grid.x2 + dxGrid,
y1: grid.y1 + dyGrid,
y2: grid.y2 + dyGrid
});
};
const resizeLayerCorner = (layerProp: string, anchor: 'nw' | 'ne' | 'sw' | 'se', dxGrid: number, dyGrid: number, startGrid: { x1: number; y1: number; x2: number; y2: number }) => {
const layer = getLayerConfig(layerProp);
if (!layer) return;
const updates: Partial<LayerConfig> = {};
if (anchor === 'nw') {
updates.x1 = Math.min(startGrid.x2, startGrid.x1 + dxGrid);
updates.y1 = Math.min(startGrid.y2, startGrid.y1 + dyGrid);
} else if (anchor === 'ne') {
updates.x2 = Math.max(startGrid.x1, startGrid.x2 + dxGrid);
updates.y1 = Math.min(startGrid.y2, startGrid.y1 + dyGrid);
} else if (anchor === 'sw') {
updates.x1 = Math.min(startGrid.x2, startGrid.x1 + dxGrid);
updates.y2 = Math.max(startGrid.y1, startGrid.y2 + dyGrid);
} else if (anchor === 'se') {
updates.x2 = Math.max(startGrid.x1, startGrid.x2 + dxGrid);
updates.y2 = Math.max(startGrid.y1, startGrid.y2 + dyGrid);
}
updateLayerConfig(layerProp, updates);
};
const resizeLayerEdge = (layerProp: string, edge: 'n' | 's' | 'e' | 'w', delta: number, startGrid: { x1: number; y1: number; x2: number; y2: number }) => {
const layer = getLayerConfig(layerProp);
if (!layer) return;
const updates: Partial<LayerConfig> = {};
if (edge === 'n') updates.y1 = Math.min(startGrid.y2, Math.max(1, startGrid.y1 + delta));
if (edge === 's') updates.y2 = Math.max(startGrid.y1, Math.min(state.gridH, startGrid.y2 + delta));
if (edge === 'w') updates.x1 = Math.min(startGrid.x2, Math.max(1, startGrid.x1 + delta));
if (edge === 'e') updates.x2 = Math.max(startGrid.x1, Math.min(state.gridW, startGrid.x2 + delta));
updateLayerConfig(layerProp, updates);
};
const loadCardsFromPath = async (path: string, rawSrc: string, layersStr: string = '', backLayersStr: string = '') => {
if (!path) {
setState({ error: '未指定 CSV 文件路径' });
@ -319,7 +355,6 @@ export function createDeckStore(
`grid="${state.gridW}x${state.gridH}" `
];
// 仅在非默认值时添加 bleed 和 padding
if (state.bleed !== DECK_DEFAULTS.BLEED) {
parts.push(`bleed="${state.bleed}" `);
}
@ -396,13 +431,16 @@ export function createDeckStore(
updateBackLayerConfig,
toggleBackLayerVisible,
setIsEditing,
setEditingLayer,
updateLayerPosition,
setSelectedLayer,
setActiveSide,
setIsSelecting,
setSelectStart,
setSelectEnd,
cancelSelection,
setDraggingState,
moveLayer,
resizeLayerCorner,
resizeLayerEdge,
loadCardsFromPath,
setError,
clearError,

View File

@ -3,15 +3,15 @@ import {CSV} from "../../utils/csv-loader";
/**
* layers
* body:1,7-5,8 title:1,1-4,1f6.6s
* f[fontSize]
* body:1,7-5,8 title:1,1-4,1f6.6sl
* f[fontSize] l/c/r
*/
export function parseLayers(layersStr: string): Layer[] {
if (!layersStr) return [];
const layers: Layer[] = [];
// 匹配prop:x1,y1-x2,y2[ffontSize][direction]
const regex = /(\w+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?/g;
// 匹配prop:x1,y1-x2,y2[ffontSize][direction][align]
const regex = /(\w+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g;
let match;
while ((match = regex.exec(layersStr)) !== null) {
@ -22,7 +22,8 @@ export function parseLayers(layersStr: string): Layer[] {
x2: parseInt(match[4]),
y2: parseInt(match[5]),
fontSize: match[6] ? parseFloat(match[6]) : undefined,
orientation: match[7] as 'n' | 's' | 'e' | 'w' | undefined
orientation: match[7] as 'n' | 's' | 'e' | 'w' | undefined,
align: match[8] as 'l' | 'c' | 'r' | undefined
});
}
@ -43,6 +44,9 @@ export function formatLayers(layers: LayerConfig[]): string {
if (l.orientation && l.orientation !== 'n') {
str += l.orientation;
}
if (l.align) {
str += l.align;
}
return str;
})
.join(' ');
@ -68,7 +72,8 @@ export function initLayerConfigsForSide(
x2: existing?.x2 || 2,
y2: existing?.y2 || 2,
orientation: existing?.orientation,
fontSize: existing?.fontSize
fontSize: existing?.fontSize,
align: existing?.align
};
});
}

View File

@ -1,92 +0,0 @@
import type { DeckStore } from './deckStore';
/**
* deckStore
* hook
*/
export function useCardSelection(store: DeckStore) {
const calculateGridCoords = (e: MouseEvent, cardEl: HTMLElement, dimensions: DeckStore['state']['dimensions']) => {
if (!dimensions) return { gridX: 1, gridY: 1 };
const rect = cardEl.getBoundingClientRect();
const offsetX = (e.clientX - rect.left) / rect.width * dimensions.cardWidth;
const offsetY = (e.clientY - rect.top) / rect.height * dimensions.cardHeight;
const gridX = Math.max(1, Math.floor((offsetX - dimensions.gridOriginX) / dimensions.cellWidth) + 1);
const gridY = Math.max(1, Math.floor((offsetY - dimensions.gridOriginY) / dimensions.cellHeight) + 1);
return {
gridX: Math.max(1, Math.min(dimensions.gridW, gridX)),
gridY: Math.max(1, Math.min(dimensions.gridH, gridY))
};
};
const handleMouseDown = (e: MouseEvent, cardEl: HTMLElement) => {
if (!store.state.isEditing || !store.state.editingLayer) return;
const { gridX, gridY } = calculateGridCoords(e, cardEl, store.state.dimensions);
store.actions.setSelectStart({ x: gridX, y: gridY });
store.actions.setSelectEnd({ x: gridX, y: gridY });
store.actions.setIsSelecting(true);
};
const handleMouseMove = (e: MouseEvent, cardEl: HTMLElement) => {
if (!store.state.isSelecting) return;
const { gridX, gridY } = calculateGridCoords(e, cardEl, store.state.dimensions);
store.actions.setSelectEnd({ x: gridX, y: gridY });
};
const handleMouseUp = () => {
if (!store.state.isSelecting || !store.state.editingLayer) return;
const start = store.state.selectStart!;
const end = store.state.selectEnd!;
const x1 = Math.min(start.x, end.x);
const y1 = Math.min(start.y, end.y);
const x2 = Math.max(start.x, end.x);
const y2 = Math.max(start.y, end.y);
store.actions.updateLayerPosition(x1, y1, x2, y2);
store.actions.cancelSelection();
};
return {
onMouseDown: handleMouseDown,
onMouseMove: handleMouseMove,
onMouseUp: handleMouseUp,
onMouseLeave: handleMouseUp
};
}
/**
*
*/
export function getSelectionBoxStyle(
selectStart: { x: number; y: number } | null,
selectEnd: { x: number; y: number } | null,
dims: { gridOriginX: number; gridOriginY: number; cellWidth: number; cellHeight: number } | null
): { left: string; top: string; width: string; height: string } | null {
if (!selectStart || !selectEnd || !dims) return null;
const x1 = Math.min(selectStart.x, selectEnd.x);
const y1 = Math.min(selectStart.y, selectEnd.y);
const x2 = Math.max(selectStart.x, selectEnd.x);
const y2 = Math.max(selectStart.y, selectEnd.y);
const left = dims.gridOriginX + (x1 - 1) * dims.cellWidth;
const top = dims.gridOriginY + (y1 - 1) * dims.cellHeight;
const width = (x2 - x1 + 1) * dims.cellWidth;
const height = (y2 - y1 + 1) * dims.cellHeight;
return {
left: `${left}mm`,
top: `${top}mm`,
width: `${width}mm`,
height: `${height}mm`
};
}

View File

@ -0,0 +1,193 @@
import { createMemo } from 'solid-js';
import type { DeckStore, DraggingState } from './deckStore';
import type { LayerConfig } from '../types';
export interface LayerClickInfo {
layerProp: string;
gridX: number;
gridY: number;
}
export interface LayerInteractionHandlers {
onLayerClick: (layerProp: string, e: MouseEvent) => void;
onFrameMouseDown: (action: 'drag' | 'resize-corner' | 'resize-edge', anchor?: 'nw' | 'ne' | 'sw' | 'se', edge?: 'n' | 's' | 'e' | 'w', e?: MouseEvent) => void;
onCardMouseMove: (e: MouseEvent, cardEl: HTMLElement) => void;
onCardMouseUp: () => void;
onCardClick: (e: MouseEvent, cardEl: HTMLElement) => void;
getOverlappingLayers: (gridX: number, gridY: number) => LayerConfig[];
}
export function useLayerInteraction(store: DeckStore): LayerInteractionHandlers {
let clickStack: { gridX: number; gridY: number; layers: string[]; currentIndex: number } | null = null;
const currentLayerConfigs = () =>
store.state.activeSide === 'front'
? store.state.frontLayerConfigs.filter(l => l.visible)
: store.state.backLayerConfigs.filter(l => l.visible);
const calculateGridCoords = (e: MouseEvent, cardEl: HTMLElement) => {
const dims = store.state.dimensions;
if (!dims) return { gridX: 1, gridY: 1 };
const rect = cardEl.getBoundingClientRect();
const mmToPxRatio = rect.width / dims.cardWidth;
const offsetX = (e.clientX - rect.left) / mmToPxRatio;
const offsetY = (e.clientY - rect.top) / mmToPxRatio;
const gridX = Math.floor((offsetX - dims.gridOriginX) / dims.cellWidth) + 1;
const gridY = Math.floor((offsetY - dims.gridOriginY) / dims.cellHeight) + 1;
return {
gridX: Math.max(1, Math.min(dims.gridW, gridX)),
gridY: Math.max(1, Math.min(dims.gridH, gridY))
};
};
const isPointInLayer = (gridX: number, gridY: number, layer: LayerConfig): boolean => {
return gridX >= layer.x1 && gridX <= layer.x2 && gridY >= layer.y1 && gridY <= layer.y2;
};
const getOverlappingLayers = (gridX: number, gridY: number): LayerConfig[] => {
return currentLayerConfigs().filter(layer => isPointInLayer(gridX, gridY, layer));
};
const handleLayerClick = (layerProp: string, e: MouseEvent) => {
e.stopPropagation();
const currentlySelected = store.state.selectedLayer;
if (currentlySelected === layerProp) {
store.actions.setSelectedLayer(null);
} else {
store.actions.setSelectedLayer(layerProp);
}
clickStack = null;
};
const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => {
if (store.state.draggingState) return;
const { gridX, gridY } = calculateGridCoords(e, cardEl);
const overlapping = getOverlappingLayers(gridX, gridY);
if (overlapping.length === 0) {
store.actions.setSelectedLayer(null);
clickStack = null;
return;
}
const overlappingProps = overlapping.map(l => l.prop);
if (clickStack && clickStack.gridX === gridX && clickStack.gridY === gridY) {
clickStack.currentIndex = (clickStack.currentIndex + 1) % overlappingProps.length;
const nextLayer = overlappingProps[clickStack.currentIndex];
store.actions.setSelectedLayer(nextLayer);
} else {
clickStack = {
gridX,
gridY,
layers: overlappingProps,
currentIndex: 0
};
store.actions.setSelectedLayer(overlappingProps[0]);
}
};
const handleFrameMouseDown = (
action: 'drag' | 'resize-corner' | 'resize-edge',
anchor?: 'nw' | 'ne' | 'sw' | 'se',
edge?: 'n' | 's' | 'e' | 'w',
e?: MouseEvent
) => {
if (!store.state.selectedLayer) return;
if (e) e.stopPropagation();
const layer = currentLayerConfigs().find(l => l.prop === store.state.selectedLayer);
if (!layer) return;
const startX = e?.clientX ?? 0;
const startY = e?.clientY ?? 0;
const draggingState: DraggingState = {
layer: store.state.selectedLayer,
action,
anchor,
edge,
startX,
startY,
startGrid: {
x1: layer.x1,
y1: layer.y1,
x2: layer.x2,
y2: layer.y2
}
};
store.actions.setDraggingState(draggingState);
};
const handleCardMouseMove = (e: MouseEvent, cardEl: HTMLElement) => {
const dragging = store.state.draggingState;
if (!dragging) return;
const dims = store.state.dimensions;
if (!dims) return;
const rect = cardEl.getBoundingClientRect();
const mmToPxRatio = rect.width / dims.cardWidth;
const deltaX = e.clientX - dragging.startX;
const deltaY = e.clientY - dragging.startY;
const deltaMmX = deltaX / mmToPxRatio;
const deltaMmY = deltaY / mmToPxRatio;
const deltaGridX = deltaMmX / dims.cellWidth;
const deltaGridY = deltaMmY / dims.cellHeight;
const startGrid = dragging.startGrid;
if (dragging.action === 'drag') {
store.actions.moveLayer(dragging.layer, Math.round(deltaGridX), Math.round(deltaGridY), startGrid);
} else if (dragging.action === 'resize-corner' && dragging.anchor) {
store.actions.resizeLayerCorner(dragging.layer, dragging.anchor, Math.round(deltaGridX), Math.round(deltaGridY), startGrid);
} else if (dragging.action === 'resize-edge' && dragging.edge) {
const delta = dragging.edge === 'n' || dragging.edge === 's' ? Math.round(deltaGridY) : Math.round(deltaGridX);
store.actions.resizeLayerEdge(dragging.layer, dragging.edge, delta, startGrid);
}
};
const handleCardMouseUp = () => {
if (store.state.draggingState) {
const layer = store.state.draggingState.layer;
const dragging = store.state.draggingState;
store.actions.setDraggingState(null);
const newLayerConfig = currentLayerConfigs().find(l => l.prop === layer);
if (newLayerConfig && dragging.startGrid) {
store.actions.setDraggingState({
...dragging,
startGrid: {
x1: newLayerConfig.x1,
y1: newLayerConfig.y1,
x2: newLayerConfig.x2,
y2: newLayerConfig.y2
}
});
store.actions.setDraggingState(null);
}
}
};
return {
onLayerClick: handleLayerClick,
onFrameMouseDown: handleFrameMouseDown,
onCardMouseMove: handleCardMouseMove,
onCardMouseUp: handleCardMouseUp,
onCardClick: handleCardClick,
getOverlappingLayers
};
}

View File

@ -14,6 +14,7 @@ export interface Layer {
y2: number;
orientation?: 'n' | 's' | 'e' | 'w';
fontSize?: number;
align?: 'l' | 'c' | 'r';
}
export interface LayerConfig {
@ -25,6 +26,7 @@ export interface LayerConfig {
y2: number;
orientation?: 'n' | 's' | 'e' | 'w';
fontSize?: number;
align?: 'l' | 'c' | 'r';
}
export interface Dimensions {

View File

@ -82,7 +82,8 @@ export function parseCSVString<T = Record<string, string>>(csvString: string, so
columns: true,
comment: '#',
trim: true,
skipEmptyLines: true
skipEmptyLines: true,
bom: true
});
const result = records as Record<string, string>[];
@ -112,28 +113,7 @@ export async function loadCSV<T = Record<string, string>>(pathOrContent: string)
// 从索引获取文件内容
const content = await getIndexedData(pathOrContent);
// 解析 front matter
const { frontmatter, remainingContent } = parseFrontMatter(content);
const records = parse(remainingContent, {
columns: true,
comment: '#',
trim: true,
skipEmptyLines: true
});
const result = records as Record<string, string>[];
// 添加 front matter 到结果中
const csvResult = result as CSV<T>;
if (frontmatter) {
csvResult.frontmatter = frontmatter;
for(const each of result){
Object.assign(each, frontmatter);
}
}
csvResult.sourcePath = pathOrContent;
return csvResult;
return parseCSVString<T>(content, pathOrContent);
}
type JSONData = JSONArray | JSONObject | string | number | boolean | null;

View File

@ -4,8 +4,8 @@
/* icon */
icon, pull{
@apply inline-block;
width: 2em;
height: 2em;
width: 1.5em;
height: 1.28em;
vertical-align: text-bottom;
--icon-src: '';
background-image: var(--icon-src);
@ -13,10 +13,9 @@ icon, pull{
background-position: center;
background-repeat: no-repeat;
}
icon.mini{
width: 1.5em;
height: 1.28em;
vertical-align: text-bottom;
icon.big{
width: 2em;
height: 2em;
}
pull{
margin-right: -.5em;