ttrpg-tools/src/components/table.tsx

222 lines
6.9 KiB
TypeScript
Raw Normal View History

2026-02-26 00:47:26 +08:00
import { customElement, noShadowDOM } from 'solid-element';
2026-02-26 14:51:26 +08:00
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
2026-02-26 00:49:20 +08:00
import { parse } from 'csv-parse/browser/esm/sync';
2026-02-26 08:26:44 +08:00
import { marked } from '../markdown';
2026-02-26 15:40:58 +08:00
import { resolvePath } from '../utils/path';
2026-02-26 00:17:23 +08:00
interface TableRow {
label: string;
body: string;
2026-02-26 10:10:05 +08:00
group?: string;
2026-02-26 00:17:23 +08:00
[key: string]: string;
}
2026-02-26 14:51:26 +08:00
// 全局缓存已加载的 CSV 内容
const csvCache = new Map<string, TableRow[]>();
2026-02-26 01:07:48 +08:00
customElement('md-table', { roll: false, remix: false }, (props, { element }) => {
2026-02-26 00:47:26 +08:00
noShadowDOM();
2026-02-26 00:17:23 +08:00
const [rows, setRows] = createSignal<TableRow[]>([]);
const [activeTab, setActiveTab] = createSignal(0);
2026-02-26 10:10:05 +08:00
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
2026-02-26 08:26:44 +08:00
const [bodyHtml, setBodyHtml] = createSignal('');
2026-02-26 17:29:39 +08:00
let tabsContainer: HTMLDivElement | undefined;
2026-02-26 00:17:23 +08:00
2026-02-26 01:07:48 +08:00
// 从 element 的 textContent 获取 CSV 路径
const src = element?.textContent?.trim() || '';
2026-02-26 10:10:05 +08:00
2026-02-26 09:47:21 +08:00
// 隐藏原始文本内容
if (element) {
element.textContent = '';
}
2026-02-26 01:21:53 +08:00
2026-02-26 01:15:40 +08:00
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
2026-02-26 01:07:48 +08:00
const articleEl = element?.closest('article[data-src]');
const articlePath = articleEl?.getAttribute('data-src') || '';
2026-02-26 01:21:53 +08:00
2026-02-26 15:40:58 +08:00
// 解析相对路径
2026-02-26 01:15:40 +08:00
const resolvedSrc = resolvePath(articlePath, src);
2026-02-26 01:07:48 +08:00
2026-02-26 14:51:26 +08:00
// 加载 CSV 文件的函数
const loadCSV = async (path: string): Promise<TableRow[]> => {
// 先从缓存获取
if (csvCache.has(path)) {
return csvCache.get(path)!;
}
const response = await fetch(path);
const content = await response.text();
2026-02-26 00:17:23 +08:00
const records = parse(content, {
columns: true,
2026-02-26 01:07:48 +08:00
comment: '#',
trim: true,
skipEmptyLines: true
2026-02-26 00:17:23 +08:00
});
2026-02-26 14:51:26 +08:00
const result = records as TableRow[];
// 缓存结果
csvCache.set(path, result);
return result;
2026-02-26 00:17:23 +08:00
};
2026-02-26 14:51:26 +08:00
// 使用 createResource 加载 CSV自动响应路径变化并避免重复加载
const [csvData] = createResource(() => resolvedSrc, loadCSV);
2026-02-26 00:17:23 +08:00
2026-02-26 14:51:26 +08:00
// 当数据加载完成后更新 rows
createEffect(() => {
const data = csvData();
if (data) {
setRows(data);
}
});
2026-02-26 00:17:23 +08:00
2026-02-26 10:10:05 +08:00
// 检测是否有 group 列
const hasGroup = createMemo(() => {
const allRows = rows();
return allRows.length > 0 && 'group' in allRows[0];
});
// 获取所有分组
const groups = createMemo(() => {
if (!hasGroup()) return [];
const allRows = rows();
const groupSet = new Set<string>();
for (const row of allRows) {
if (row.group) {
groupSet.add(row.group);
}
}
return Array.from(groupSet).sort();
});
// 根据当前选中的分组过滤行
const filteredRows = createMemo(() => {
const allRows = rows();
const group = activeGroup();
if (!group) return allRows;
return allRows.filter(row => row.group === group);
});
2026-02-26 08:26:44 +08:00
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
2026-02-26 00:17:23 +08:00
const processBody = (body: string, currentRow: TableRow): string => {
2026-02-26 08:26:44 +08:00
let processedBody = body;
2026-02-26 10:10:05 +08:00
2026-02-26 00:17:23 +08:00
if (!props.remix) {
// 不启用 remix 时,只替换当前行的引用
2026-02-26 08:26:44 +08:00
processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => currentRow[key] || '');
2026-02-26 00:17:23 +08:00
} else {
// 启用 remix 时,每次引用使用随机行的内容
2026-02-26 08:26:44 +08:00
processedBody = body.replace(/\{\{(\w+)\}\}/g, (_, key) => {
2026-02-26 00:17:23 +08:00
const randomRow = rows()[Math.floor(Math.random() * rows().length)];
return randomRow?.[key] || '';
});
}
2026-02-26 10:10:05 +08:00
2026-02-26 08:26:44 +08:00
// 使用 marked 解析 markdown
return marked.parse(processedBody) as string;
2026-02-26 00:17:23 +08:00
};
2026-02-26 01:21:53 +08:00
// 更新 body 内容
const updateBodyContent = () => {
2026-02-26 10:10:05 +08:00
const filtered = filteredRows();
2026-02-26 14:51:26 +08:00
if (!csvData.loading && filtered.length > 0) {
2026-02-26 10:10:05 +08:00
const index = Math.min(activeTab(), filtered.length - 1);
const currentRow = filtered[index];
2026-02-26 08:26:44 +08:00
setBodyHtml(processBody(currentRow.body, currentRow));
2026-02-26 01:21:53 +08:00
}
};
2026-02-26 10:10:05 +08:00
// 监听 activeTab 和 activeGroup 变化并更新内容
2026-02-26 01:21:53 +08:00
createEffect(() => {
activeTab();
2026-02-26 10:10:05 +08:00
activeGroup();
2026-02-26 01:21:53 +08:00
updateBodyContent();
});
2026-02-26 10:10:05 +08:00
// 切换分组时重置 tab 索引
const handleGroupChange = (group: string | null) => {
setActiveGroup(group);
setActiveTab(0);
};
2026-02-26 01:21:53 +08:00
// 随机切换 tab
const handleRoll = () => {
2026-02-26 10:10:05 +08:00
const randomIndex = Math.floor(Math.random() * filteredRows().length);
2026-02-26 01:21:53 +08:00
setActiveTab(randomIndex);
2026-02-26 17:29:39 +08:00
// 滚动到可视区域
setTimeout(() => {
const activeButton = tabsContainer?.querySelector('.border-blue-600');
activeButton?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}, 50);
2026-02-26 01:21:53 +08:00
};
2026-02-26 00:17:23 +08:00
return (
<div class="ttrpg-table">
2026-02-26 01:21:53 +08:00
<div class="flex items-center gap-2 border-b border-gray-200">
2026-02-26 17:24:25 +08:00
<div class="flex flex-col overflow-x-auto">
2026-02-26 10:10:05 +08:00
{/* 分组 tabs */}
<Show when={hasGroup()}>
<div class="flex gap-2 border-b border-gray-100 pb-2">
2026-02-26 01:21:53 +08:00
<button
2026-02-26 10:10:05 +08:00
onClick={() => handleGroupChange(null)}
2026-02-26 08:26:44 +08:00
class={`font-medium transition-colors ${
2026-02-26 10:10:05 +08:00
activeGroup() === null
2026-02-26 01:21:53 +08:00
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
2026-02-26 10:10:05 +08:00
</button>
<For each={groups()}>
{(group) => (
<button
onClick={() => handleGroupChange(group)}
class={`font-medium transition-colors ${
activeGroup() === group
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{group}
</button>
)}
</For>
</div>
</Show>
{/* 内容 tabs */}
2026-02-26 17:24:25 +08:00
<div class="flex items-center gap-2">
2026-02-26 10:10:05 +08:00
<Show when={props.roll}>
<button
onClick={handleRoll}
2026-02-26 17:24:25 +08:00
class="text-gray-500 hover:text-gray-700 flex-shrink-0"
2026-02-26 10:10:05 +08:00
title="随机切换"
>
🎲
2026-02-26 01:21:53 +08:00
</button>
2026-02-26 10:10:05 +08:00
</Show>
2026-02-26 17:29:39 +08:00
<div ref={tabsContainer} class="flex gap-2 overflow-x-auto flex-1 min-w-0">
2026-02-26 17:24:25 +08:00
<For each={filteredRows()}>
{(row, index) => (
<button
onClick={() => setActiveTab(index())}
class={`font-medium transition-colors flex-shrink-0 ${
activeTab() === index()
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{row.label}
</button>
)}
</For>
</div>
2026-02-26 10:10:05 +08:00
</div>
2026-02-26 01:21:53 +08:00
</div>
2026-02-26 00:17:23 +08:00
</div>
2026-02-26 08:26:44 +08:00
<div class="p-1 prose max-w-none">
2026-02-26 14:51:26 +08:00
<Show when={!csvData.loading && filteredRows().length > 0}>
2026-02-26 08:26:44 +08:00
<div innerHTML={bodyHtml()} />
2026-02-26 00:17:23 +08:00
</Show>
</div>
</div>
);
2026-02-26 00:47:26 +08:00
});