diff --git a/src/components/index.ts b/src/components/index.ts index 053c55b..7b5d4db 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,6 +3,7 @@ import './dice'; import './table'; import './md-link'; import './md-pins'; +import './md-deck'; // 导出组件 export { Article } from './Article'; diff --git a/src/components/md-deck.tsx b/src/components/md-deck.tsx new file mode 100644 index 0000000..b3d14a5 --- /dev/null +++ b/src/components/md-deck.tsx @@ -0,0 +1,238 @@ +import { customElement, noShadowDOM } from 'solid-element'; +import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js'; +import { parse } from 'csv-parse/browser/esm/sync'; +import { marked } from '../markdown'; +import { resolvePath } from '../utils/path'; + +interface CardData { + [key: string]: string; +} + +interface Layer { + prop: string; + 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; +} + +// 全局缓存已加载的 CSV 内容 +const csvCache = new Map(); + +customElement('md-deck', { + size: '54x86', + grid: '5x8', + bleed: '1', + padding: '2', + layers: '' +}, (props, { element }) => { + noShadowDOM(); + + const [cards, setCards] = createSignal([]); + const [activeTab, setActiveTab] = createSignal(0); + let tabsContainer: HTMLDivElement | undefined; + + // 从 element 的 textContent 获取 CSV 路径 + const src = element?.textContent?.trim() || ''; + + // 隐藏原始文本内容 + if (element) { + element.textContent = ''; + } + + // 从父节点 article 的 data-src 获取当前 markdown 文件完整路径 + const articleEl = element?.closest('article[data-src]'); + const articlePath = articleEl?.getAttribute('data-src') || ''; + + // 解析相对路径 + 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; + }; + + const [csvData] = createResource(() => resolvedSrc, loadCSV); + + createEffect(() => { + const data = csvData(); + if (data) { + setCards(data); + } + }); + + // 解析尺寸 + const dimensions = createMemo(() => { + const [width, height] = (props.size as string).split('x').map(Number); + const [bleedW, bleedH] = (props.bleed as string).includes('x') + ? (props.bleed as string).split('x').map(Number) + : [Number(props.bleed), Number(props.bleed)]; + const [padW, padH] = (props.padding as string).includes('x') + ? (props.padding as string).split('x').map(Number) + : [Number(props.padding), Number(props.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] = (props.grid as string).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 + }; + }); + + // 解析 layers + const layers = createMemo(() => parseLayers(props.layers as string)); + + // 渲染 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) => { + // layer 坐标是网格坐标(1-based) + // 计算相对于网格区域起点的偏移(mm) + const left = (layer.x1 - 1) * dims.cellWidth; + const top = (layer.y1 - 1) * dims.cellHeight; + // 计算尺寸(mm) + 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` + }; + }; + + return ( +
+ {/* Tab 选择器 */} +
+
+ + {(card, index) => ( + + )} + +
+
+ + {/* 卡牌预览 */} + 0}> +
+ + {(() => { + const currentCard = cards()[activeTab()]; + const dims = dimensions(); + + return ( +
+ {/* 网格区域容器 */} +
+ {/* 渲染每个 layer */} + + {(layer) => { + const style = getLayerStyle(layer, dims); + return ( +
+ ); + }} + +
+
+ ); + })()} + +
+
+
+ ); +}); diff --git a/todo.md b/todo.md index 99a96c0..1eccfec 100644 --- a/todo.md +++ b/todo.md @@ -1,10 +1,35 @@ # todo -## md-pin-editor +## md-pins -- [ ] 类似md-pin,寻找最近一张图片。 -- [ ] 在图片上显示透明遮罩,覆盖整个图片。 -- [ ] 点击遮罩添加一个pin,位置在点击的位置。 -- [ ] 再次点击pin会删除pin。 -- [ ] 点击遮罩hud的复制按钮,可以将所有pin复制为md-pin文本,以回车换行连接。 -- [ ] 所有pin按照A B C ... Z AA AB ... 的顺序显示标签。 +- [x] 类似 md-pin,寻找最近一张图片。 +- [x] 在图片上显示透明遮罩,覆盖整个图片。 +- [x] 点击遮罩添加一个 pin,位置在点击的位置。 +- [x] 再次点击 pin 会删除 pin。 +- [x] 点击遮罩 hud 的复制按钮,可以将所有 pin 复制为 md-pin 文本,以回车换行连接。 +- [x] 所有 pin 按照 A B C ... Z AA AB ... 的顺序显示标签。 +- [x] 重命名为 md-pins,退休 md-pin。 + +## md-deck + +定义一个排版模板,填充来自 csv 的数据并生成预览。 + +### 语法 + +- size: 长宽尺寸,以 mm 为单位。 +- grid: 排版网格。 +- bleed: 出血,如 54x86 外加 1mm 出血之后实际尺寸为 56x88。 +- padding: 网格外边距。54x86 外加 2mm padding 之后实际网格区域为 50x82。 +- layers: 排版层,由一个列表构成。每个元素以 `prop:topleft-bottomright` 格式定义。 + +``` +:md-deck[./card.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="body:1,7-5,8 title:1,1-5,1"} +``` + +### 显示 + +首先对 csv 数据中的每个条目,显示一个 tab。放在一个 flex wrap 的标签框里。 + +然后显示卡牌本身。对每个排版层,将 csv 数据中对应字段作为 markdown 显示在对应位置并居中。 + +- [x] 创建 md-deck 组件