refactor: layer transform controls

This commit is contained in:
hypercross 2026-03-30 12:01:08 +08:00
parent 56cabea109
commit ceb2da8b1a
6 changed files with 558 additions and 197 deletions

View File

@ -1,15 +1,17 @@
import {createMemo, For} from 'solid-js'; import { createMemo, For, Show } from 'solid-js';
import {parseMarkdown} from '../../markdown'; import { parseMarkdown } from '../../markdown';
import { getLayerStyle } from './hooks/dimensions'; import { getLayerStyle } from './hooks/dimensions';
import type { CardData, CardSide } from './types'; import type { CardData, CardSide, LayerConfig } 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";
import type { LayerInteractionHandlers } from './hooks/useLayerInteraction';
export interface CardLayerProps { export interface CardLayerProps {
cardData: CardData; cardData: CardData;
store: DeckStore; store: DeckStore;
side?: CardSide; side?: CardSide;
interaction?: LayerInteractionHandlers;
} }
export function CardLayer(props: CardLayerProps) { export function CardLayer(props: CardLayerProps) {
@ -21,6 +23,8 @@ export function CardLayer(props: CardLayerProps) {
); );
const dimensions = () => props.store.state.dimensions!; const dimensions = () => props.store.state.dimensions!;
const showBounds = () => props.store.state.isEditing; const showBounds = () => props.store.state.isEditing;
const selectedLayer = () => props.store.state.selectedLayer;
const draggingState = () => props.store.state.draggingState;
function renderLayerContent(content: string) { function renderLayerContent(content: string) {
const iconPath = resolvePath(props.store.state.cards.sourcePath, props.cardData.iconPath ?? "./assets"); const iconPath = resolvePath(props.store.state.cards.sourcePath, props.cardData.iconPath ?? "./assets");
@ -32,21 +36,67 @@ export function CardLayer(props: CardLayerProps) {
if (align === 'r') return 'right'; if (align === 'r') return 'right';
return 'center'; return 'center';
}; };
const isLayerSelected = (layer: LayerConfig) => selectedLayer() === layer.prop;
const getFrameBounds = (layer: LayerConfig) => {
const dims = dimensions();
const orientation = layer.orientation || 'n';
if (orientation === 'e' || orientation === 'w') {
const left = (layer.y1 - 1) * dims.cellWidth;
const top = (layer.x1 - 1) * dims.cellHeight;
const width = (layer.y2 - layer.y1 + 1) * dims.cellWidth;
const height = (layer.x2 - layer.x1 + 1) * dims.cellHeight;
return { left, top, width, height };
}
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 ( return (
<For each={layers()}> <For each={layers()}>
{(layer) => { {(layer) => {
const bounds = () => getFrameBounds(layer);
const isSelected = () => isLayerSelected(layer);
return ( return (
<> <>
<article <article
class="absolute flex flex-col items-stretch justify-center prose prose-sm" 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={{ style={{
...getLayerStyle(layer, dimensions()), ...getLayerStyle(layer, dimensions()),
'font-size': `${layer.fontSize || 3}mm`, 'font-size': `${layer.fontSize || 3}mm`,
'text-align': getAlignStyle(layer.align) 'text-align': getAlignStyle(layer.align)
}} }}
innerHTML={renderLayerContent(props.cardData[layer.prop])} innerHTML={renderLayerContent(props.cardData[layer.prop])}
onClick={(e) => handleLayerClick(layer.prop, e)}
/> />
{showBounds() && ( <Show when={showBounds() && !isSelected()}>
<div <div
class="absolute border-2 border-blue-500/50 pointer-events-none select-none" class="absolute border-2 border-blue-500/50 pointer-events-none select-none"
style={{ style={{
@ -56,10 +106,101 @@ export function CardLayer(props: CardLayerProps) {
height: `${(layer.y2 - layer.y1 + 1) * dimensions().cellHeight}mm` height: `${(layer.y2 - layer.y1 + 1) * dimensions().cellHeight}mm`
}} }}
/> />
)} </Show>
<Show when={isSelected()}>
<div
class="absolute border-2 border-blue-500 pointer-events-none"
style={{
left: `${bounds().left}mm`,
top: `${bounds().top}mm`,
width: `${bounds().width}mm`,
height: `${bounds().height}mm`
}}
/>
<div
class="absolute pointer-events-auto"
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-10"
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-10"
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-10"
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-10"
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"
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"
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"
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"
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>
</> </>
); );
}} }}
</For> </For>
); );
} }

View File

@ -1,58 +1,67 @@
import { Show, For, createMemo } from 'solid-js'; import { Show, For, createMemo, onCleanup, onMount } from 'solid-js';
import { useCardSelection } from './hooks/useCardSelection';
import { getSelectionBoxStyle } from './hooks/useCardSelection';
import { getShapeClipPath } from './hooks/shape-styles'; 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';
import { useLayerInteraction } from './hooks/useLayerInteraction';
export interface CardPreviewProps { export interface CardPreviewProps {
store: DeckStore; store: DeckStore;
} }
/**
*
*/
export function CardPreview(props: CardPreviewProps) { export function CardPreview(props: CardPreviewProps) {
const { store } = props; const { store } = props;
const currentCard = createMemo(() => store.state.cards[store.state.activeTab]); 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 shapeClipPath = createMemo(() => {
const dims = store.state.dimensions; const dims = store.state.dimensions;
if (!dims) return 'none'; if (!dims) return 'none';
return getShapeClipPath(store.state.shape, dims.cardWidth, dims.cardHeight, store.state.cornerRadius); return getShapeClipPath(store.state.shape, dims.cardWidth, dims.cardHeight, store.state.cornerRadius);
}); });
const selection = useCardSelection(store); const interaction = useLayerInteraction(store);
let cardRef: HTMLDivElement | undefined; 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 ( return (
<div class="flex justify-center drop-shadow"> <div class="flex justify-center drop-shadow">
<Show when={store.state.activeTab < store.state.cards.length}> <Show when={store.state.activeTab < store.state.cards.length}>
<div <div
ref={cardRef} ref={cardRef}
class="relative bg-white border border-gray-300 overflow-hidden" class="relative bg-white border border-gray-300 overflow-hidden"
classList={{ 'select-none': store.state.isEditing }} classList={{ 'select-none': !!store.state.draggingState }}
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 'clip-path': shapeClipPath() !== 'none' ? shapeClipPath() : undefined
}} }}
onMouseDown={(e) => selection.onMouseDown(e, cardRef!)} onClick={(e) => {
onMouseMove={(e) => selection.onMouseMove(e, cardRef!)} if (!store.state.draggingState && cardRef) {
onMouseUp={selection.onMouseUp} interaction.onCardClick(e, cardRef);
onMouseLeave={selection.onMouseLeave} }
}}
> >
<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 <div
class="absolute" class="absolute"
style={{ style={{
@ -87,10 +96,11 @@ export function CardPreview(props: CardPreviewProps) {
cardData={currentCard()} cardData={currentCard()}
store={store} store={store}
side={store.state.activeSide} side={store.state.activeSide}
interaction={interaction}
/> />
</div> </div>
</div> </div>
</Show> </Show>
</div> </div>
); );
} }

View File

@ -80,9 +80,9 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
toggleFn(layerProp); toggleFn(layerProp);
}; };
const setEditingLayer = (layerProp: string) => { const selectLayer = (layerProp: string) => {
store.actions.setEditingLayer( store.actions.setSelectedLayer(
store.state.editingLayer === layerProp ? null : layerProp store.state.selectedLayer === layerProp ? null : layerProp
); );
}; };
@ -118,6 +118,8 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
<div <div
class={`flex items-center gap-1 py-1.5 px-1 ${ class={`flex items-center gap-1 py-1.5 px-1 ${
index() < layerCount() - 1 ? 'border-b border-gray-200' : '' index() < layerCount() - 1 ? 'border-b border-gray-200' : ''
} ${
store.state.selectedLayer === layer.prop ? 'bg-blue-50' : ''
}`} }`}
> >
<input <input
@ -126,21 +128,12 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
onChange={() => toggleLayerVisible(layer.prop)} onChange={() => toggleLayerVisible(layer.prop)}
class="cursor-pointer" class="cursor-pointer"
/> />
<span class="text-sm flex-1 truncate">{layer.prop}</span> <span
<button class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600"
onClick={() => setEditingLayer(layer.prop)} onClick={() => selectLayer(layer.prop)}
class={`w-7 h-7 text-xs rounded cursor-pointer flex items-center justify-center ${
!layer.visible ? 'invisible pointer-events-none' : ''
} ${
store.state.editingLayer === layer.prop
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
title="框选"
> >
{store.state.editingLayer === layer.prop ? '✓' : '框'} {layer.prop}
</button> </span>
<div class="relative"> <div class="relative">
<button <button
onClick={(e) => { onClick={(e) => {

View File

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

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,211 @@
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 => {
const orientation = layer.orientation || 'n';
if (orientation === 'e' || orientation === 'w') {
const visualX1 = layer.y1;
const visualX2 = layer.y2;
const visualY1 = layer.x1;
const visualY2 = layer.x2;
return gridX >= visualX1 && gridX <= visualX2 && gridY >= visualY1 && gridY <= visualY2;
}
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 layer = currentLayerConfigs().find(l => l.prop === dragging.layer);
if (!layer) return;
const orientation = layer.orientation || 'n';
const startGrid = dragging.startGrid;
if (dragging.action === 'drag') {
const moveDx = Math.round(deltaGridX);
const moveDy = Math.round(deltaGridY);
store.actions.moveLayer(dragging.layer, moveDx, moveDy, 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 = orientation === 'e' || orientation === 'w'
? (dragging.edge === 'n' || dragging.edge === 's' ? Math.round(deltaGridX) : Math.round(deltaGridY))
: (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
};
}