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`) - `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
**属性:** **属性:**

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) {
@ -20,36 +22,160 @@ export function CardLayer(props: CardLayerProps) {
: props.store.state.backLayerConfigs.filter((l) => l.visible) : props.store.state.backLayerConfigs.filter((l) => l.visible)
); );
const dimensions = () => props.store.state.dimensions!; const dimensions = () => props.store.state.dimensions!;
const showBounds = () => props.store.state.isEditing; const 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");
return parseMarkdown(processVariables(content, props.cardData, props.store.state.cards), iconPath) as string; 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 ( 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-center justify-center text-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)
}} }}
innerHTML={renderLayerContent(props.cardData[layer.prop])} innerHTML={renderLayerContent(props.cardData[layer.prop])}
onClick={(e) => handleLayerClick(layer.prop, e)}
/> />
{showBounds() && ( <Show when={isSelected()}>
<div <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={{ style={{
left: `${(layer.x1 - 1) * dimensions().cellWidth}mm`, left: `${bounds().left}mm`,
top: `${(layer.y1 - 1) * dimensions().cellHeight}mm`, top: `${bounds().top}mm`,
width: `${(layer.x2 - layer.x1 + 1) * dimensions().cellWidth}mm`, width: `${bounds().width}mm`,
height: `${(layer.y2 - layer.y1 + 1) * dimensions().cellHeight}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 { 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,6 +96,7 @@ 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>

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 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 { export interface LayerEditorPanelProps {
store: DeckStore; store: DeckStore;
@ -12,13 +15,38 @@ const ORIENTATION_OPTIONS = [
{ value: 'w', label: '← 西' } { value: 'w', label: '← 西' }
] as const; ] as const;
/** const ALIGN_OPTIONS = [
* { value: '', label: '默认', icon: alignCenterIcon },
*/ { value: 'l', label: '左对齐', icon: alignLeftIcon },
export function LayerEditorPanel(props: LayerEditorPanelProps) { { value: 'c', label: '居中', icon: alignCenterIcon },
const { store } = props; { 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 = () => const currentLayerConfigs = () =>
store.state.activeSide === 'front' store.state.activeSide === 'front'
? store.state.frontLayerConfigs ? store.state.frontLayerConfigs
@ -29,6 +57,7 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
? store.actions.updateFrontLayerConfig ? store.actions.updateFrontLayerConfig
: store.actions.updateBackLayerConfig; : store.actions.updateBackLayerConfig;
updateFn(layerProp, { orientation }); updateFn(layerProp, { orientation });
setOpenDropdown(null);
}; };
const updateLayerFontSize = (layerProp: string, fontSize?: number) => { const updateLayerFontSize = (layerProp: string, fontSize?: number) => {
@ -38,6 +67,14 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
updateFn(layerProp, { fontSize }); 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 toggleLayerVisible = (layerProp: string) => {
const toggleFn = store.state.activeSide === 'front' const toggleFn = store.state.activeSide === 'front'
? store.actions.toggleFrontLayerVisible ? store.actions.toggleFrontLayerVisible
@ -45,73 +82,181 @@ export 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
); );
}; };
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 ( return (
<div class="w-64 flex-shrink-0"> <div class="w-64 flex-shrink-0">
<h3 class="font-bold mb-2 mt-0"> <h3 class="font-bold mb-2 mt-0">
({store.state.activeSide === 'front' ? '正面' : '背面'}) ({store.state.activeSide === 'front' ? '正面' : '背面'})
</h3> </h3>
<div class="space-y-2"> <div ref={dropdownRef}>
<For each={currentLayerConfigs()}> <For each={currentLayerConfigs()}>
{(layer) => ( {(layer, index) => (
<div class="flex flex-row flex-wrap gap-1 p-2 bg-gray-50 rounded"> <div
<div class="flex items-center gap-2"> 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 <input
type="checkbox" type="checkbox"
checked={layer.visible} checked={layer.visible}
onChange={() => toggleLayerVisible(layer.prop)} onChange={() => toggleLayerVisible(layer.prop)}
class="cursor-pointer" class="cursor-pointer"
/> />
<span class="text-sm flex-1">{layer.prop}</span> <span
</div> class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600"
{layer.visible && ( onClick={() => selectLayer(layer.prop)}
<>
<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'
}`}
> >
{store.state.editingLayer === 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> </button>
<div class="flex items-center gap-2"> {openDropdown() === `orient-${layer.prop}` && (
<select <div
value={layer.orientation || 'n'} class="absolute top-full right-0 mt-1 bg-white border border-gray-300 rounded shadow-lg z-10"
onChange={(e) => updateLayerOrientation(layer.prop, e.target.value as 'n' | 's' | 'e' | 'w')} onClick={handleDropdownClick}
class="text-xs px-2 py-1 rounded border border-gray-300 bg-white cursor-pointer"
> >
<For each={ORIENTATION_OPTIONS}> <For each={ORIENTATION_OPTIONS}>
{(opt) => ( {(opt) => (
<option value={opt.value}>{opt.label}</option> <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> </For>
</select>
</div> </div>
<div class="flex items-center gap-2"> )}
<label class="text-xs text-gray-600">/mm</label> </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 <input
type="number" type="number"
value={layer.fontSize || ''} value={layer.fontSize ?? 3}
placeholder="默认"
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
updateLayerFontSize(layer.prop, value ? Number(value) : undefined); updateLayerFontSize(layer.prop, value ? Number(value) : undefined);
}} }}
class="w-16 text-xs px-2 py-1 rounded border border-gray-300 bg-white" class="w-14 text-xs px-1.5 py-1 rounded border border-gray-300"
step="0.1" step="0.1"
min="0.1" min="0.1"
/> />
<span class="text-xs text-gray-500">mm</span>
</div> </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>
</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>
</div> </div>
)} )}
</For> </For>
@ -129,3 +274,5 @@ export function LayerEditorPanel(props: LayerEditorPanelProps) {
</div> </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 { 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,21 +236,9 @@ 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 });
const setSelectEnd = (pos: { x: number; y: number } | null) => setState({ selectEnd: 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 }); 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 = '') => { const loadCardsFromPath = async (path: string, rawSrc: string, layersStr: string = '', backLayersStr: string = '') => {
if (!path) { if (!path) {
setState({ error: '未指定 CSV 文件路径' }); setState({ error: '未指定 CSV 文件路径' });
@ -319,7 +355,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 +431,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,

View File

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

View File

@ -82,7 +82,8 @@ export function parseCSVString<T = Record<string, string>>(csvString: string, so
columns: true, columns: true,
comment: '#', comment: '#',
trim: true, trim: true,
skipEmptyLines: true skipEmptyLines: true,
bom: true
}); });
const result = records as Record<string, string>[]; 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); const content = await getIndexedData(pathOrContent);
return parseCSVString<T>(content, 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;
} }
type JSONData = JSONArray | JSONObject | string | number | boolean | null; type JSONData = JSONArray | JSONObject | string | number | boolean | null;

View File

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