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 08:26:44 +08:00
|
|
|
|
import { marked } from '../markdown';
|
2026-02-27 12:34:55 +08:00
|
|
|
|
import { resolvePath } from '../utils';
|
2026-02-27 12:38:09 +08:00
|
|
|
|
import { loadCSV } from './utils/csv-loader';
|
2026-02-27 12:34:55 +08:00
|
|
|
|
|
|
|
|
|
|
export interface TableProps {
|
|
|
|
|
|
roll?: boolean;
|
|
|
|
|
|
remix?: boolean;
|
|
|
|
|
|
}
|
2026-02-26 00:17:23 +08:00
|
|
|
|
|
|
|
|
|
|
interface TableRow {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
body: string;
|
|
|
|
|
|
[key: string]: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// 使用 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 23:12:26 +08:00
|
|
|
|
class="text-gray-500 hover:text-gray-700 flex-shrink-0 cursor-pointer"
|
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:51:49 +08:00
|
|
|
|
<div ref={tabsContainer} class="flex gap-1 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())}
|
2026-02-26 23:12:26 +08:00
|
|
|
|
class={`font-medium transition-colors flex-shrink-0 min-w-[1.6em] cursor-pointer ${
|
2026-02-26 17:24:25 +08:00
|
|
|
|
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
|
|
|
|
});
|