Files
ttrpg-tools/src/components/md-table.tsx
T
hypercross 40b6fbe157 feat(ui): replace unicode icons with solid-icons
Swap emoji and unicode glyphs for Tabler components so icons inherit
currentColor and font-size and render consistently across platforms.

- add src/components/doc-icons.ts to resolve doc-entry frontmatter icon
  keys to components; frontmatter now stores semantic keys
- replace the align-*.png assets with TbOutlineAlign* icons
- render RevealManager action SVGs as components instead of injected
  innerHTML strings
- carry completion icons via an optional CompletionItem.icon field
2026-09-13 14:01:03 +08:00

248 lines
7.9 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 { TbOutlineDice5 } from "solid-icons/tb";
import { parseMarkdown } from "../markdown";
import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
import { resolveContentRef } from "./utils/resolve-content";
import { parseSparkTableCsv } from "./utils/spark-table";
import {
areAllLabelsNumeric,
weightedRandomIndex,
} from "./utils/weighted-random";;
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>>(
[] as unknown as 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 路径或 inline CSV 数据
const rawContent = element?.textContent?.trim() || "";
// 隐藏原始文本内容
if (element) {
element.textContent = "";
}
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest("article[data-src]");
const articlePath = articleEl?.getAttribute("data-src") || "";
// 解析引用:inline CSV 直接使用,否则通过 registry 解析(含内联内容 id)
const [csvData] = createResource(
() => ({ ref: rawContent, docPath: articlePath }),
async ({ ref, docPath }) => {
const content = await resolveContentRef(ref, docPath);
if (content === null) {
throw new Error(`Failed to resolve table content: "${ref}"`);
}
return content;
},
);
// 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
// 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
createEffect(() => {
const content = csvData();
if (!content) return;
setRows(parseCSVString(content) as unknown as CSV<TableRow>);
if (element) {
const meta = parseSparkTableCsv(content);
if (meta) {
element.setAttribute("data-spark", meta.slug);
} else {
element.removeAttribute("data-spark");
}
}
});
// 检测是否有 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 => {
// 使用 parseMarkdown 统一入口(设置图标 base path 等)
return parseMarkdown(
processVariables(body, currentRow, rows(), filteredRows(), props.remix),
articlePath,
);
};
// 更新 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 filtered = filteredRows();
if (filtered.length === 0) return;
// 检查所有 label 是否都是整数/整数范围格式
const labels = filtered.map((row) => row.label);
if (areAllLabelsNumeric(labels)) {
// 使用加权随机
const randomIndex = weightedRandomIndex(labels);
if (randomIndex !== -1) {
setActiveTab(randomIndex);
}
} else {
// 使用简单随机
const randomIndex = Math.floor(Math.random() * filtered.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 shrink-0 cursor-pointer"
title="随机切换"
>
<TbOutlineDice5 class="inline-block align-[-0.125em]" />
</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 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>
);
},
);