diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..30a9902
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# Rider 忽略的文件
+/projectSettingsUpdater.xml
+/modules.xml
+/.idea.ttrpg-tools.iml
+/contentModel.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/components/card-preview.tsx b/src/components/card-preview.tsx
new file mode 100644
index 0000000..17c6a39
--- /dev/null
+++ b/src/components/card-preview.tsx
@@ -0,0 +1,114 @@
+import { Show, For } from 'solid-js';
+import { marked } from '../markdown';
+import type { CardData, LayerConfig, Dimensions } from './types';
+import { getLayerStyle } from './utils/dimensions';
+import { getSelectionBoxStyle } from './hooks/use-selection';
+
+export interface CardPreviewProps {
+ cards: CardData[];
+ activeTab: number;
+ layerConfigs: LayerConfig[];
+ dimensions: Dimensions;
+ isEditing: boolean;
+ isFixed: boolean;
+ editingLayer: string | null;
+ isSelecting: boolean;
+ selectStart: { x: number; y: number } | null;
+ selectEnd: { x: number; y: number } | null;
+ onMouseDown: (e: MouseEvent) => void;
+ onMouseMove: (e: MouseEvent) => void;
+ onMouseUp: () => void;
+ onMouseLeave: () => void;
+}
+
+/**
+ * 渲染 layer 内容
+ */
+function renderLayer(layer: { prop: string }, cardData: CardData): string {
+ const content = cardData[layer.prop] || '';
+ return marked.parse(content) as string;
+}
+
+export function CardPreview(props: CardPreviewProps) {
+ const currentCard = () => props.cards[props.activeTab];
+ const visibleLayers = () => props.layerConfigs.filter(l => l.visible);
+ const selectionStyle = () =>
+ getSelectionBoxStyle(props.selectStart, props.selectEnd, props.dimensions);
+
+ return (
+
+
+
+ {/* 框选遮罩 */}
+
+
+
+
+ {/* 网格区域容器 */}
+
+ {/* 编辑模式下的网格线 */}
+
+
+
+ {(_, i) => (
+
+ )}
+
+
+ {(_, i) => (
+
+ )}
+
+
+
+
+ {/* 渲染每个 layer */}
+
+ {(layer) => {
+ const style = getLayerStyle(layer, props.dimensions);
+ const isEditingThis = props.editingLayer === layer.prop;
+
+ return (
+
+ );
+ }}
+
+
+
+
+
+ );
+}
diff --git a/src/components/editor-panel.tsx b/src/components/editor-panel.tsx
new file mode 100644
index 0000000..8e55a07
--- /dev/null
+++ b/src/components/editor-panel.tsx
@@ -0,0 +1,139 @@
+import { Show, For } from 'solid-js';
+import type { CardData, LayerConfig } from './types';
+
+export interface DataEditorPanelProps {
+ cards: CardData[];
+ activeTab: number;
+ updateCardData: (key: string, value: string) => void;
+}
+
+export interface PropertiesEditorPanelProps {
+ localSize: string;
+ localGrid: string;
+ localBleed: string;
+ localPadding: string;
+ layerConfigs: LayerConfig[];
+ editingLayer: string | null;
+ onSizeChange: (value: string) => void;
+ onGridChange: (value: string) => void;
+ onBleedChange: (value: string) => void;
+ onPaddingChange: (value: string) => void;
+ onToggleLayerVisible: (prop: string) => void;
+ onStartEditingLayer: (prop: string) => void;
+ onCopyCode: () => void;
+}
+
+/**
+ * 左侧:CSV 数据编辑面板
+ */
+export function DataEditorPanel(props: DataEditorPanelProps) {
+ return (
+
+
卡牌数据
+
+
+ {(key) => (
+
+
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * 右侧:卡牌属性编辑面板
+ */
+export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
+ return (
+
+
卡牌属性
+
+
+
+
+ props.onSizeChange(e.target.value)}
+ />
+
+
+
+
+ props.onGridChange(e.target.value)}
+ />
+
+
+
+
+ props.onBleedChange(e.target.value)}
+ />
+
+
+
+
+ props.onPaddingChange(e.target.value)}
+ />
+
+
+
+
+
图层
+
+ {(layer) => (
+
+ props.onToggleLayerVisible(layer.prop)}
+ class="cursor-pointer"
+ />
+ {layer.prop}
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/hooks/use-layer-editor.ts b/src/components/hooks/use-layer-editor.ts
new file mode 100644
index 0000000..22210f5
--- /dev/null
+++ b/src/components/hooks/use-layer-editor.ts
@@ -0,0 +1,70 @@
+import { createSignal } from 'solid-js';
+import type { LayerConfig } from '../types';
+import { formatLayers } from '../utils/layer-parser';
+
+/**
+ * 图层编辑相关的状态和操作
+ */
+export function useLayerEditor(
+ props: any,
+ src: string,
+ localSize: () => string,
+ localGrid: () => string,
+ localBleed: () => string,
+ localPadding: () => string
+) {
+ const [isEditing, setIsEditing] = createSignal(false);
+ const [editingLayer, setEditingLayer] = createSignal(null);
+ const [layerConfigs, setLayerConfigs] = createSignal([]);
+
+ const toggleLayerVisible = (prop: string) => {
+ setLayerConfigs(configs => configs.map(c =>
+ c.prop === prop ? { ...c, visible: !c.visible } : c
+ ));
+ };
+
+ const startEditingLayer = (prop: string) => {
+ setEditingLayer(prop);
+ };
+
+ const updateLayerPosition = (x1: number, y1: number, x2: number, y2: number) => {
+ const layer = editingLayer();
+ if (!layer) return;
+
+ setLayerConfigs(configs => configs.map(c =>
+ c.prop === layer ? { ...c, x1, y1, x2, y2 } : c
+ ));
+ setEditingLayer(null);
+ };
+
+ const generateCode = () => {
+ const layersStr = formatLayers(layerConfigs());
+ return `:md-deck[${src}]{size="${localSize()}" grid="${localGrid()}" bleed="${localBleed()}" padding="${localPadding()}" layers="${layersStr}"}`;
+ };
+
+ const copyCode = () => {
+ const code = generateCode();
+ navigator.clipboard.writeText(code).then(() => {
+ alert('已复制到剪贴板!');
+ }).catch(err => {
+ console.error('复制失败:', err);
+ });
+ };
+
+ const isFixed = () => props.fixed === true || props.fixed === 'true';
+
+ return {
+ isEditing,
+ setIsEditing,
+ editingLayer,
+ setEditingLayer,
+ layerConfigs,
+ setLayerConfigs,
+ toggleLayerVisible,
+ startEditingLayer,
+ updateLayerPosition,
+ generateCode,
+ copyCode,
+ isFixed
+ };
+}
diff --git a/src/components/hooks/use-selection.ts b/src/components/hooks/use-selection.ts
new file mode 100644
index 0000000..2c3be09
--- /dev/null
+++ b/src/components/hooks/use-selection.ts
@@ -0,0 +1,109 @@
+import { createSignal } from 'solid-js';
+import type { Dimensions, LayerConfig } from '../types';
+
+/**
+ * 框选相关的状态和操作
+ */
+export function useSelection(
+ isEditing: () => boolean,
+ editingLayer: () => string | null,
+ dimensions: () => Dimensions
+) {
+ const [isSelecting, setIsSelecting] = createSignal(false);
+ const [selectStart, setSelectStart] = createSignal<{ x: number; y: number } | null>(null);
+ const [selectEnd, setSelectEnd] = createSignal<{ x: number; y: number } | null>(null);
+
+ const calculateGridCoords = (e: MouseEvent, cardEl: HTMLElement) => {
+ const rect = cardEl.getBoundingClientRect();
+ const dims = dimensions();
+
+ const offsetX = (e.clientX - rect.left) / rect.width * dims.cardWidth;
+ const offsetY = (e.clientY - rect.top) / rect.height * dims.cardHeight;
+
+ const gridX = Math.max(1, Math.floor((offsetX - dims.gridOriginX) / dims.cellWidth) + 1);
+ const gridY = Math.max(1, 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 handleMouseDown = (e: MouseEvent) => {
+ if (!isEditing() || !editingLayer()) return;
+
+ const cardEl = e.currentTarget as HTMLElement;
+ const { gridX, gridY } = calculateGridCoords(e, cardEl);
+
+ setSelectStart({ x: gridX, y: gridY });
+ setSelectEnd({ x: gridX, y: gridY });
+ setIsSelecting(true);
+ };
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isSelecting()) return;
+
+ const cardEl = e.currentTarget as HTMLElement;
+ const { gridX, gridY } = calculateGridCoords(e, cardEl);
+
+ setSelectEnd({ x: gridX, y: gridY });
+ };
+
+ const handleMouseUp = () => {
+ if (!isSelecting() || !editingLayer()) return;
+
+ const start = selectStart()!;
+ const end = 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);
+
+ return { x1, y1, x2, y2 };
+ };
+
+ const cancelSelection = () => {
+ setIsSelecting(false);
+ setSelectStart(null);
+ setSelectEnd(null);
+ };
+
+ return {
+ isSelecting,
+ selectStart,
+ selectEnd,
+ handleMouseDown,
+ handleMouseMove,
+ handleMouseUp,
+ cancelSelection
+ };
+}
+
+/**
+ * 计算框选区域的样式
+ */
+export function getSelectionBoxStyle(
+ selectStart: { x: number; y: number } | null,
+ selectEnd: { x: number; y: number } | null,
+ dims: Dimensions
+): { left: string; top: string; width: string; height: string } | null {
+ if (!selectStart || !selectEnd) 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`
+ };
+}
diff --git a/src/components/md-deck.tsx b/src/components/md-deck.tsx
index ad58e5b..ee59ce2 100644
--- a/src/components/md-deck.tsx
+++ b/src/components/md-deck.tsx
@@ -1,66 +1,19 @@
import { customElement, noShadowDOM } from 'solid-element';
-import { createSignal, For, Show, createEffect, createMemo, createResource, onMount } from 'solid-js';
-import { parse } from 'csv-parse/browser/esm/sync';
-import { marked } from '../markdown';
+import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
import { resolvePath } from '../utils/path';
+import type { CardData, Dimensions } from './types';
+import { loadCSV } from './utils/csv-loader';
+import { initLayerConfigs } from './utils/layer-parser';
+import { calculateDimensions } from './utils/dimensions';
+import { useSelection } from './hooks/use-selection';
+import { useLayerEditor } from './hooks/use-layer-editor';
+import { CardPreview } from './card-preview';
+import { DataEditorPanel, PropertiesEditorPanel } from './editor-panel';
-interface CardData {
- [key: string]: string;
-}
-
-interface Layer {
- prop: string;
- x1: number;
- y1: number;
- x2: number;
- y2: number;
-}
-
-interface LayerConfig {
- prop: string;
- visible: boolean;
- x1: number;
- y1: number;
- x2: number;
- y2: number;
-}
-
-// 解析 layers 字符串 "body:1,7-5,8 title:1,1-5,1"
-function parseLayers(layersStr: string): Layer[] {
- if (!layersStr) return [];
-
- const layers: Layer[] = [];
- const regex = /(\w+):(\d+),(\d+)-(\d+),(\d+)/g;
- let match;
-
- while ((match = regex.exec(layersStr)) !== null) {
- layers.push({
- prop: match[1],
- x1: parseInt(match[2]),
- y1: parseInt(match[3]),
- x2: parseInt(match[4]),
- y2: parseInt(match[5])
- });
- }
-
- return layers;
-}
-
-// 格式化 layers 为字符串
-function formatLayers(layers: LayerConfig[]): string {
- return layers
- .filter(l => l.visible)
- .map(l => `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`)
- .join(' ');
-}
-
-// 全局缓存已加载的 CSV 内容
-const csvCache = new Map();
-
-customElement('md-deck', {
- size: '54x86',
- grid: '5x8',
- bleed: '1',
+customElement('md-deck', {
+ size: '54x86',
+ grid: '5x8',
+ bleed: '1',
padding: '2',
layers: '',
fixed: false
@@ -71,22 +24,22 @@ customElement('md-deck', {
const [activeTab, setActiveTab] = createSignal(0);
let tabsContainer: HTMLDivElement | undefined;
- // 编辑器状态
- const [isEditing, setIsEditing] = createSignal(false);
- const [editingLayer, setEditingLayer] = createSignal(null);
- const [layerConfigs, setLayerConfigs] = createSignal([]);
-
- // 框选状态
- const [isSelecting, setIsSelecting] = createSignal(false);
- const [selectStart, setSelectStart] = createSignal<{ x: number; y: number } | null>(null);
- const [selectEnd, setSelectEnd] = createSignal<{ x: number; y: number } | null>(null);
-
// 本地编辑的属性
const [localSize, setLocalSize] = createSignal(props.size as string || '54x86');
const [localGrid, setLocalGrid] = createSignal(props.grid as string || '5x8');
const [localBleed, setLocalBleed] = createSignal(props.bleed as string || '1');
const [localPadding, setLocalPadding] = createSignal(props.padding as string || '2');
+ // 使用图层编辑器 hook
+ const layerEditor = useLayerEditor(
+ props,
+ '',
+ localSize,
+ localGrid,
+ localBleed,
+ localPadding
+ );
+
// 从 element 的 textContent 获取 CSV 路径
const src = element?.textContent?.trim() || '';
@@ -102,205 +55,51 @@ customElement('md-deck', {
// 解析相对路径
const resolvedSrc = resolvePath(articlePath, src);
- // 加载 CSV 文件的函数
- const loadCSV = async (path: string): Promise => {
- if (csvCache.has(path)) {
- return csvCache.get(path)!;
- }
- const response = await fetch(path);
- const content = await response.text();
- const records = parse(content, {
- columns: true,
- comment: '#',
- trim: true,
- skipEmptyLines: true
- });
- const result = records as CardData[];
- csvCache.set(path, result);
- return result;
- };
-
+ // 加载 CSV 文件
const [csvData] = createResource(() => resolvedSrc, loadCSV);
createEffect(() => {
const data = csvData();
if (data) {
setCards(data);
- // 初始化 layer configs
- const parsed = parseLayers(props.layers as string || '');
- const allProps = Object.keys(data[0] || {}).filter(k => k !== 'label');
- const configs: LayerConfig[] = allProps.map(prop => {
- const existing = parsed.find(l => l.prop === prop);
- return {
- prop,
- visible: !!existing,
- x1: existing?.x1 || 1,
- y1: existing?.y1 || 1,
- x2: existing?.x2 || 2,
- y2: existing?.y2 || 2
- };
- });
- setLayerConfigs(configs);
+ layerEditor.setLayerConfigs(initLayerConfigs(data, props.layers as string || ''));
}
});
- // 检查是否 fixed
- const isFixed = () => props.fixed === true || props.fixed === 'true';
-
- // 解析尺寸
- const dimensions = createMemo(() => {
- const [width, height] = localSize().split('x').map(Number);
- const [bleedW, bleedH] = localBleed().includes('x')
- ? localBleed().split('x').map(Number)
- : [Number(localBleed()), Number(localBleed())];
- const [padW, padH] = localPadding().includes('x')
- ? localPadding().split('x').map(Number)
- : [Number(localPadding()), Number(localPadding())];
-
- // 实际卡牌尺寸(含出血)
- const cardWidth = width + bleedW * 2;
- const cardHeight = height + bleedH * 2;
-
- // 网格区域尺寸(减去 padding)
- const gridAreaWidth = width - padW * 2;
- const gridAreaHeight = height - padH * 2;
-
- // 解析网格
- const [gridW, gridH] = localGrid().split('x').map(Number);
-
- // 每个网格单元的尺寸(mm)
- const cellWidth = gridAreaWidth / gridW;
- const cellHeight = gridAreaHeight / gridH;
-
- // 网格区域起点(相对于卡牌左上角,含 bleed 和 padding)
- const gridOriginX = bleedW + padW;
- const gridOriginY = bleedH + padH;
-
- return {
- cardWidth,
- cardHeight,
- gridAreaWidth,
- gridAreaHeight,
- cellWidth,
- cellHeight,
- gridW,
- gridH,
- gridOriginX,
- gridOriginY
- };
+ // 更新 src 到 layerEditor
+ createEffect(() => {
+ (layerEditor as any).src = src;
});
- // 渲染 layer 内容
- const renderLayer = (layer: Layer, cardData: CardData): string => {
- const content = cardData[layer.prop] || '';
- return marked.parse(content) as string;
- };
-
- // 计算 layer 位置样式(单位:mm)
- const getLayerStyle = (layer: Layer, dims: ReturnType) => {
- 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: `${left}mm`,
- top: `${top}mm`,
- width: `${width}mm`,
- height: `${height}mm`
- };
- };
-
- // 开始框选
- const handleCardMouseDown = (e: MouseEvent) => {
- if (!isEditing() || !editingLayer()) return;
-
- const cardEl = e.currentTarget as HTMLElement;
- const rect = cardEl.getBoundingClientRect();
- const dims = dimensions();
-
- // 计算相对于网格区域起点的坐标(网格单位)
- const offsetX = (e.clientX - rect.left) / rect.width * dims.cardWidth;
- const offsetY = (e.clientY - rect.top) / rect.height * dims.cardHeight;
-
- const gridX = Math.max(1, Math.floor((offsetX - dims.gridOriginX) / dims.cellWidth) + 1);
- const gridY = Math.max(1, Math.floor((offsetY - dims.gridOriginY) / dims.cellHeight) + 1);
-
- setSelectStart({ x: gridX, y: gridY });
- setSelectEnd({ x: gridX, y: gridY });
- setIsSelecting(true);
- };
-
- // 更新框选结束位置
- const handleCardMouseMove = (e: MouseEvent) => {
- if (!isSelecting()) return;
-
- const cardEl = e.currentTarget as HTMLElement;
- const rect = cardEl.getBoundingClientRect();
- const dims = dimensions();
-
- const offsetX = (e.clientX - rect.left) / rect.width * dims.cardWidth;
- const offsetY = (e.clientY - rect.top) / rect.height * dims.cardHeight;
-
- const gridX = Math.max(1, Math.min(dims.gridW, Math.floor((offsetX - dims.gridOriginX) / dims.cellWidth) + 1));
- const gridY = Math.max(1, Math.min(dims.gridH, Math.floor((offsetY - dims.gridOriginY) / dims.cellHeight) + 1));
-
- setSelectEnd({ x: gridX, y: gridY });
- };
-
- // 结束框选
- const handleCardMouseUp = () => {
- if (!isSelecting() || !editingLayer()) return;
-
- const start = selectStart()!;
- const end = 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);
-
- setLayerConfigs(configs => configs.map(c =>
- c.prop === editingLayer() ? { ...c, x1, y1, x2, y2 } : c
- ));
-
- setIsSelecting(false);
- setSelectStart(null);
- setSelectEnd(null);
- setEditingLayer(null);
- };
-
- // 切换图层可见性
- const toggleLayerVisible = (prop: string) => {
- setLayerConfigs(configs => configs.map(c =>
- c.prop === prop ? { ...c, visible: !c.visible } : c
- ));
- };
-
- // 开始编辑图层位置
- const startEditingLayer = (prop: string) => {
- setEditingLayer(prop);
- };
-
- // 生成 md-deck 代码
- const generateCode = () => {
- const layersStr = formatLayers(layerConfigs());
- return `:md-deck[${src}]{size="${localSize()}" grid="${localGrid()}" bleed="${localBleed()}" padding="${localPadding()}" layers="${layersStr}"}`;
- };
-
- // 复制代码
- const copyCode = () => {
- const code = generateCode();
- navigator.clipboard.writeText(code).then(() => {
- alert('已复制到剪贴板!');
- }).catch(err => {
- console.error('复制失败:', err);
+ // 解析尺寸
+ const dimensions = createMemo((): Dimensions => {
+ return calculateDimensions({
+ size: localSize(),
+ bleed: localBleed(),
+ padding: localPadding(),
+ grid: localGrid()
});
+ });
+
+ // 使用框选 hook
+ const selection = useSelection(
+ layerEditor.isEditing,
+ layerEditor.editingLayer,
+ dimensions
+ );
+
+ // 处理框选完成
+ const handleMouseUp = () => {
+ const result = selection.handleMouseUp();
+ if (result) {
+ layerEditor.updateLayerPosition(result.x1, result.y1, result.x2, result.y2);
+ selection.cancelSelection();
+ }
};
// 更新 CSV 数据
const updateCardData = (key: string, value: string) => {
- setCards(cards => cards.map((card, i) =>
+ setCards(cards => cards.map((card, i) =>
i === activeTab() ? { ...card, [key]: value } : card
));
};
@@ -308,40 +107,27 @@ customElement('md-deck', {
return (
{/* 左侧:CSV 数据编辑 */}
-
-
-
卡牌数据
-
-
- {(key) => (
-
-
-
- )}
-
-
-
+
+
- {/* 中间:卡牌预览 */}
+ {/* 中间:卡牌预览和控制 */}
{/* Tab 选择器 */}
@@ -363,195 +149,42 @@ customElement('md-deck', {
{/* 卡牌预览 */}
0}>
-
-
- {(() => {
- const currentCard = cards()[activeTab()];
- const dims = dimensions();
- const visibleLayers = layerConfigs().filter(l => l.visible);
-
- return (
-
- {/* 框选遮罩 */}
-
- {(() => {
- const start = selectStart()!;
- const end = 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);
-
- 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 (
-
- );
- })()}
-
-
- {/* 网格区域容器 */}
-
- {/* 编辑模式下的网格线 */}
-
-
-
- {(_, i) => (
-
- )}
-
-
- {(_, i) => (
-
- )}
-
-
-
-
- {/* 渲染每个 layer */}
-
- {(layer) => {
- const style = getLayerStyle(layer, dims);
- const isEditingThis = editingLayer() === layer.prop;
-
- return (
-
- );
- }}
-
-
-
- );
- })()}
-
-
+
{/* 右侧:属性编辑表单 */}
-
-
-
卡牌属性
-
-
-
-
- setLocalSize(e.target.value)}
- />
-
-
-
-
- setLocalGrid(e.target.value)}
- />
-
-
-
-
- setLocalBleed(e.target.value)}
- />
-
-
-
-
- setLocalPadding(e.target.value)}
- />
-
-
-
-
-
图层
-
- {(layer) => (
-
- toggleLayerVisible(layer.prop)}
- class="cursor-pointer"
- />
- {layer.prop}
-
-
- )}
-
-
-
-
-
-
-
+
+
);
diff --git a/src/components/types.ts b/src/components/types.ts
new file mode 100644
index 0000000..e7f3870
--- /dev/null
+++ b/src/components/types.ts
@@ -0,0 +1,39 @@
+export interface CardData {
+ [key: string]: string;
+}
+
+export interface Layer {
+ prop: string;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+}
+
+export interface LayerConfig {
+ prop: string;
+ visible: boolean;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+}
+
+export interface Dimensions {
+ cardWidth: number;
+ cardHeight: number;
+ gridAreaWidth: number;
+ gridAreaHeight: number;
+ cellWidth: number;
+ cellHeight: number;
+ gridW: number;
+ gridH: number;
+ gridOriginX: number;
+ gridOriginY: number;
+}
+
+export interface SelectionState {
+ isSelecting: boolean;
+ selectStart: { x: number; y: number } | null;
+ selectEnd: { x: number; y: number } | null;
+}
diff --git a/src/components/utils/csv-loader.ts b/src/components/utils/csv-loader.ts
new file mode 100644
index 0000000..7233952
--- /dev/null
+++ b/src/components/utils/csv-loader.ts
@@ -0,0 +1,29 @@
+import { parse } from 'csv-parse/browser/esm/sync';
+import type { CardData } from '../types';
+
+/**
+ * 全局缓存已加载的 CSV 内容
+ */
+const csvCache = new Map
();
+
+/**
+ * 加载 CSV 文件
+ */
+export async function loadCSV(path: string): Promise {
+ if (csvCache.has(path)) {
+ return csvCache.get(path)!;
+ }
+
+ const response = await fetch(path);
+ const content = await response.text();
+ const records = parse(content, {
+ columns: true,
+ comment: '#',
+ trim: true,
+ skipEmptyLines: true
+ });
+
+ const result = records as CardData[];
+ csvCache.set(path, result);
+ return result;
+}
diff --git a/src/components/utils/dimensions.ts b/src/components/utils/dimensions.ts
new file mode 100644
index 0000000..8098791
--- /dev/null
+++ b/src/components/utils/dimensions.ts
@@ -0,0 +1,73 @@
+import type { Dimensions } from '../types';
+
+export interface DimensionOptions {
+ size: string;
+ bleed: string;
+ padding: string;
+ grid: string;
+}
+
+/**
+ * 解析卡牌尺寸和网格配置
+ */
+export function calculateDimensions(options: DimensionOptions): Dimensions {
+ const [width, height] = options.size.split('x').map(Number);
+ const [bleedW, bleedH] = options.bleed.includes('x')
+ ? options.bleed.split('x').map(Number)
+ : [Number(options.bleed), Number(options.bleed)];
+ const [padW, padH] = options.padding.includes('x')
+ ? options.padding.split('x').map(Number)
+ : [Number(options.padding), Number(options.padding)];
+
+ // 实际卡牌尺寸(含出血)
+ const cardWidth = width + bleedW * 2;
+ const cardHeight = height + bleedH * 2;
+
+ // 网格区域尺寸(减去 padding)
+ const gridAreaWidth = width - padW * 2;
+ const gridAreaHeight = height - padH * 2;
+
+ // 解析网格
+ const [gridW, gridH] = options.grid.split('x').map(Number);
+
+ // 每个网格单元的尺寸(mm)
+ const cellWidth = gridAreaWidth / gridW;
+ const cellHeight = gridAreaHeight / gridH;
+
+ // 网格区域起点(相对于卡牌左上角,含 bleed 和 padding)
+ const gridOriginX = bleedW + padW;
+ const gridOriginY = bleedH + padH;
+
+ return {
+ cardWidth,
+ cardHeight,
+ gridAreaWidth,
+ gridAreaHeight,
+ cellWidth,
+ cellHeight,
+ gridW,
+ gridH,
+ gridOriginX,
+ gridOriginY
+ };
+}
+
+/**
+ * 计算 layer 位置样式(单位:mm)
+ */
+export function getLayerStyle(
+ layer: { x1: number; y1: number; x2: number; y2: number },
+ dims: Dimensions
+): { left: string; top: string; width: string; height: string } {
+ 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: `${left}mm`,
+ top: `${top}mm`,
+ width: `${width}mm`,
+ height: `${height}mm`
+ };
+}
diff --git a/src/components/utils/layer-parser.ts b/src/components/utils/layer-parser.ts
new file mode 100644
index 0000000..8b602df
--- /dev/null
+++ b/src/components/utils/layer-parser.ts
@@ -0,0 +1,57 @@
+import type { Layer, LayerConfig } from './types';
+
+/**
+ * 解析 layers 字符串 "body:1,7-5,8 title:1,1-5,1"
+ */
+export function parseLayers(layersStr: string): Layer[] {
+ if (!layersStr) return [];
+
+ const layers: Layer[] = [];
+ const regex = /(\w+):(\d+),(\d+)-(\d+),(\d+)/g;
+ let match;
+
+ while ((match = regex.exec(layersStr)) !== null) {
+ layers.push({
+ prop: match[1],
+ x1: parseInt(match[2]),
+ y1: parseInt(match[3]),
+ x2: parseInt(match[4]),
+ y2: parseInt(match[5])
+ });
+ }
+
+ return layers;
+}
+
+/**
+ * 格式化 layers 为字符串
+ */
+export function formatLayers(layers: LayerConfig[]): string {
+ return layers
+ .filter(l => l.visible)
+ .map(l => `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`)
+ .join(' ');
+}
+
+/**
+ * 初始化图层配置
+ */
+export function initLayerConfigs(
+ data: any[],
+ existingLayersStr: string
+): LayerConfig[] {
+ const parsed = parseLayers(existingLayersStr);
+ const allProps = Object.keys(data[0] || {}).filter(k => k !== 'label');
+
+ return allProps.map(prop => {
+ const existing = parsed.find(l => l.prop === prop);
+ return {
+ prop,
+ visible: !!existing,
+ x1: existing?.x1 || 1,
+ y1: existing?.y1 || 1,
+ x2: existing?.x2 || 2,
+ y2: existing?.y2 || 2
+ };
+ });
+}