feat: md-deck

This commit is contained in:
hypercross 2026-02-27 00:52:29 +08:00
parent ca1801e1a7
commit 907cabe9a0
3 changed files with 271 additions and 7 deletions

View File

@ -3,6 +3,7 @@ import './dice';
import './table';
import './md-link';
import './md-pins';
import './md-deck';
// 导出组件
export { Article } from './Article';

238
src/components/md-deck.tsx Normal file
View File

@ -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<string, CardData[]>();
customElement('md-deck', {
size: '54x86',
grid: '5x8',
bleed: '1',
padding: '2',
layers: ''
}, (props, { element }) => {
noShadowDOM();
const [cards, setCards] = createSignal<CardData[]>([]);
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<CardData[]> => {
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<typeof dimensions>) => {
// 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 (
<div class="md-deck">
{/* Tab 选择器 */}
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
<div ref={tabsContainer} class="flex gap-1 overflow-x-auto flex-1 min-w-0 flex-wrap">
<For each={cards()}>
{(card, index) => (
<button
onClick={() => setActiveTab(index())}
class={`font-medium transition-colors flex-shrink-0 min-w-[1.6em] cursor-pointer px-2 py-1 rounded ${
activeTab() === index()
? 'bg-blue-100 text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
>
{card.label || card.name || `Card ${index() + 1}`}
</button>
)}
</For>
</div>
</div>
{/* 卡牌预览 */}
<Show when={!csvData.loading && cards().length > 0}>
<div class="flex justify-center">
<Show when={activeTab() < cards().length}>
{(() => {
const currentCard = cards()[activeTab()];
const dims = dimensions();
return (
<div
class="relative bg-white border border-gray-300 shadow-lg"
style={{
width: `${dims.cardWidth}mm`,
height: `${dims.cardHeight}mm`
}}
>
{/* 网格区域容器 */}
<div
class="absolute"
style={{
left: `${dims.gridOriginX}mm`,
top: `${dims.gridOriginY}mm`,
width: `${dims.gridAreaWidth}mm`,
height: `${dims.gridAreaHeight}mm`
}}
>
{/* 渲染每个 layer */}
<For each={layers()}>
{(layer) => {
const style = getLayerStyle(layer, dims);
return (
<div
class="absolute flex items-center justify-center text-center prose prose-sm"
style={style}
innerHTML={renderLayer(layer, currentCard)}
/>
);
}}
</For>
</div>
</div>
);
})()}
</Show>
</div>
</Show>
</div>
);
});

39
todo.md
View File

@ -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 组件