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
248 lines
7.9 KiB
TypeScript
248 lines
7.9 KiB
TypeScript
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>
|
||
);
|
||
},
|
||
);
|