ttrpg-tools/src/components/md-table.tsx

190 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { customElement, noShadowDOM } from 'solid-element';
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
import { marked } from '../markdown';
import { resolvePath } from './utils/path';
import {loadCSV, CSV, processVariables} from './utils/csv-loader';
export interface TableProps {
roll?: boolean;
remix?: boolean;
}
interface TableRow {
label: string;
body: string;
[key: string]: string;
}
customElement('md-table', { roll: false, remix: false }, (props, { element }) => {
noShadowDOM();
const [rows, setRows] = createSignal<CSV<TableRow>>([]);
const [activeTab, setActiveTab] = createSignal(0);
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
const [bodyHtml, setBodyHtml] = createSignal('');
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);
// 使用 createResource 加载 CSV自动响应路径变化并避免重复加载
const [csvData] = createResource(() => resolvedSrc, loadCSV);
// 当数据加载完成后更新 rows
createEffect(() => {
const data = csvData();
if (data) {
setRows(data as any[]);
}
});
// 检测是否有 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);
});
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
const processBody = (body: string, currentRow: TableRow): string => {
// 使用 marked 解析 markdown
return marked.parse(processVariables(body, currentRow, rows(), filteredRows(), props.remix)) as string;
};
// 更新 body 内容
const updateBodyContent = () => {
const filtered = filteredRows();
if (!csvData.loading && filtered.length > 0) {
const index = Math.min(activeTab(), filtered.length - 1);
const currentRow = filtered[index];
setBodyHtml(processBody(currentRow.body, currentRow));
}
};
// 监听 activeTab 和 activeGroup 变化并更新内容
createEffect(() => {
activeTab();
activeGroup();
updateBodyContent();
});
// 切换分组时重置 tab 索引
const handleGroupChange = (group: string | null) => {
setActiveGroup(group);
setActiveTab(0);
};
// 随机切换 tab
const handleRoll = () => {
const randomIndex = Math.floor(Math.random() * filteredRows().length);
setActiveTab(randomIndex);
// 滚动到可视区域
setTimeout(() => {
const activeButton = tabsContainer?.querySelector('.border-blue-600');
activeButton?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}, 50);
};
return (
<div class="ttrpg-table">
<div class="flex items-center gap-2 border-b border-gray-200">
<div class="flex flex-col overflow-x-auto">
{/* 分组 tabs */}
<Show when={hasGroup()}>
<div class="flex gap-2 border-b border-gray-100 pb-2">
<button
onClick={() => handleGroupChange(null)}
class={`font-medium transition-colors ${
activeGroup() === null
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</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 */}
<div class="flex items-center gap-2">
<Show when={props.roll}>
<button
onClick={handleRoll}
class="text-gray-500 hover:text-gray-700 flex-shrink-0 cursor-pointer"
title="随机切换"
>
🎲
</button>
</Show>
<div ref={tabsContainer} class="flex gap-1 overflow-x-auto flex-1 min-w-0">
<For each={filteredRows()}>
{(row, index) => (
<button
onClick={() => setActiveTab(index())}
class={`font-medium transition-colors flex-shrink-0 min-w-[1.6em] cursor-pointer ${
activeTab() === index()
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{row.label}
</button>
)}
</For>
</div>
</div>
</div>
</div>
<div class="p-4 prose max-w-none">
<Show when={!csvData.loading && filteredRows().length > 0}>
<div innerHTML={bodyHtml()} />
</Show>
</div>
</div>
);
});