feat: md-deck
This commit is contained in:
parent
ca1801e1a7
commit
907cabe9a0
|
|
@ -3,6 +3,7 @@ import './dice';
|
||||||
import './table';
|
import './table';
|
||||||
import './md-link';
|
import './md-link';
|
||||||
import './md-pins';
|
import './md-pins';
|
||||||
|
import './md-deck';
|
||||||
|
|
||||||
// 导出组件
|
// 导出组件
|
||||||
export { Article } from './Article';
|
export { Article } from './Article';
|
||||||
|
|
|
||||||
|
|
@ -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
39
todo.md
|
|
@ -1,10 +1,35 @@
|
||||||
# todo
|
# todo
|
||||||
|
|
||||||
## md-pin-editor
|
## md-pins
|
||||||
|
|
||||||
- [ ] 类似md-pin,寻找最近一张图片。
|
- [x] 类似 md-pin,寻找最近一张图片。
|
||||||
- [ ] 在图片上显示透明遮罩,覆盖整个图片。
|
- [x] 在图片上显示透明遮罩,覆盖整个图片。
|
||||||
- [ ] 点击遮罩添加一个pin,位置在点击的位置。
|
- [x] 点击遮罩添加一个 pin,位置在点击的位置。
|
||||||
- [ ] 再次点击pin会删除pin。
|
- [x] 再次点击 pin 会删除 pin。
|
||||||
- [ ] 点击遮罩hud的复制按钮,可以将所有pin复制为md-pin文本,以回车换行连接。
|
- [x] 点击遮罩 hud 的复制按钮,可以将所有 pin 复制为 md-pin 文本,以回车换行连接。
|
||||||
- [ ] 所有pin按照A B C ... Z AA AB ... 的顺序显示标签。
|
- [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 组件
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue