Compare commits

..

3 Commits

Author SHA1 Message Date
hyper 53c8ce0677 feat(viewer): Add data source dialog
Introduce a dialog for switching between built-in content and a
user-selected local folder. Persist the folder handle in IndexedDB
so the selection survives page refreshes. Scan the folder for .md,
.yarn, and .csv files to build the file index.

Update App and Sidebar to pass file tree and heading data, and
add TypeScript declarations for the File System Access API.
2026-06-30 19:20:08 +08:00
hyper a05b0f4291 refactor(doc-data): Use static imports for doc entries 2026-06-30 18:54:34 +08:00
hyper d72d4e7bde feat(docs): add component documentation entries and refactor dialog
rendering
2026-06-30 18:47:14 +08:00
22 changed files with 944 additions and 379 deletions

View File

@ -1,4 +1,4 @@
import { Component, createMemo, createSignal } from "solid-js"; import { Component, createEffect, createMemo, createSignal } from "solid-js";
import { useLocation } from "@solidjs/router"; import { useLocation } from "@solidjs/router";
// 导入组件以注册自定义元素 // 导入组件以注册自定义元素
@ -8,12 +8,38 @@ import {
MobileSidebar, MobileSidebar,
DesktopSidebar, DesktopSidebar,
DocDialog, DocDialog,
DataSourceDialog,
} from "./components"; } from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader";
const App: Component = () => { const App: Component = () => {
const location = useLocation(); const location = useLocation();
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false); const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
const [isDocOpen, setIsDocOpen] = createSignal(false); const [isDocOpen, setIsDocOpen] = createSignal(false);
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
// Sidebar and TOC data — reload when source changes
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
const [pathHeadings, setPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
const [tocKey, setTocKey] = createSignal(0);
const loadToc = async () => {
const toc = await generateToc();
setFileTree(toc.fileTree);
setPathHeadings(toc.pathHeadings);
};
// Load TOC on mount and when source changes
createEffect(() => {
void tocKey();
void loadToc();
});
const handleSourceChanged = () => {
setTocKey((k) => k + 1);
};
const currentPath = createMemo(() => { const currentPath = createMemo(() => {
// 根据路由加载对应的 markdown 文件 // 根据路由加载对应的 markdown 文件
@ -29,11 +55,13 @@ const App: Component = () => {
return ( return (
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
{/* 桌面端固定侧边栏 */} {/* 桌面端固定侧边栏 */}
<DesktopSidebar /> <DesktopSidebar fileTree={fileTree()} pathHeadings={pathHeadings()} />
{/* 移动端抽屉式侧边栏 */} {/* 移动端抽屉式侧边栏 */}
<MobileSidebar <MobileSidebar
isOpen={isSidebarOpen()} isOpen={isSidebarOpen()}
onClose={() => setIsSidebarOpen(false)} onClose={() => setIsSidebarOpen(false)}
fileTree={fileTree()}
pathHeadings={pathHeadings()}
/> />
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30"> <header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4"> <div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
@ -47,6 +75,13 @@ const App: Component = () => {
</button> </button>
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1> <h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
<div class="flex-1" /> <div class="flex-1" />
<button
onClick={() => setIsDataSourceOpen(true)}
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
title="Content Source"
>
📂
</button>
<button <button
onClick={() => setIsDocOpen(true)} onClick={() => setIsDocOpen(true)}
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium" class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
@ -63,6 +98,11 @@ const App: Component = () => {
</main> </main>
</div> </div>
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} /> <DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
<DataSourceDialog
isOpen={isDataSourceOpen()}
onClose={() => setIsDataSourceOpen(false)}
onSourceChanged={handleSourceChanged}
/>
</div> </div>
); );
}; };

View File

@ -0,0 +1,159 @@
import { Component, createSignal, Show } from "solid-js";
import {
loadFromUserFolder,
switchToBuiltInSource,
getActiveSource,
clearIndex,
} from "../data-loader/file-index";
export interface DataSourceDialogProps {
isOpen: boolean;
onClose: () => void;
onSourceChanged: () => void;
}
/**
* Dialog for choosing how to source content files.
* Supports:
* - Built-in content (CLI / webpack bundled files)
* - Local folder (via File System Access API)
*/
export const DataSourceDialog: Component<DataSourceDialogProps> = (props) => {
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [folderPath, setFolderPath] = createSignal<string | null>(null);
const currentSource = () => getActiveSource();
const handleFolderPick = async () => {
setLoading(true);
setError(null);
const paths = await loadFromUserFolder();
if (paths === null) {
// User cancelled or API not supported — don't show error for cancellation
setLoading(false);
return;
}
if (paths.length === 0) {
setError(
"No supported files (.md, .yarn, .csv) found in the selected folder.",
);
setLoading(false);
return;
}
setFolderPath(`${paths.length} files loaded`);
setLoading(false);
props.onSourceChanged();
props.onClose();
};
const handleBuiltIn = async () => {
setLoading(true);
setError(null);
await switchToBuiltInSource();
// Force re-index from built-in sources
clearIndex();
setLoading(false);
props.onSourceChanged();
props.onClose();
};
return (
<Show when={props.isOpen}>
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"
onClick={props.onClose}
>
<div
class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"
onClick={(e) => e.stopPropagation()}
>
<h2 class="text-lg font-bold text-gray-900 mb-4">Content Source</h2>
<div class="space-y-3">
{/* Built-in option */}
<button
onClick={handleBuiltIn}
disabled={loading()}
class="w-full text-left p-4 rounded-lg border transition-colors"
classList={{
"border-blue-500 bg-blue-50":
currentSource() === "cli" || currentSource() === "webpack",
"border-gray-200 hover:border-gray-300":
currentSource() !== "cli" && currentSource() !== "webpack",
"opacity-50 cursor-not-allowed": loading(),
}}
>
<div class="font-medium text-gray-900">Built-in Content</div>
<div class="text-sm text-gray-500 mt-1">
Use the bundled content files shipped with the app.
</div>
<Show
when={
currentSource() === "cli" || currentSource() === "webpack"
}
>
<div class="text-xs text-blue-600 mt-1 font-medium">Active</div>
</Show>
</button>
{/* Folder picker option */}
<button
onClick={handleFolderPick}
disabled={
loading() ||
typeof window === "undefined" ||
!("showDirectoryPicker" in window)
}
class="w-full text-left p-4 rounded-lg border transition-colors"
classList={{
"border-blue-500 bg-blue-50": currentSource() === "folder",
"border-gray-200 hover:border-gray-300":
currentSource() !== "folder",
"opacity-50 cursor-not-allowed":
loading() || !("showDirectoryPicker" in window),
}}
>
<div class="font-medium text-gray-900">Local Folder</div>
<div class="text-sm text-gray-500 mt-1">
Pick a folder on your computer. Supports .md, .yarn, and .csv
files.
{"showDirectoryPicker" in window
? " Will be remembered across sessions."
: " (Not supported in this browser)"}
</div>
<Show when={currentSource() === "folder"}>
<div class="text-xs text-blue-600 mt-1 font-medium">
Active{folderPath() ? `${folderPath()}` : ""}
</div>
</Show>
</button>
</div>
<Show when={error()}>
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{error()}
</div>
</Show>
<div class="mt-6 flex justify-end">
<button
onClick={props.onClose}
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 rounded hover:bg-gray-100"
>
Close
</button>
</div>
</div>
</div>
</Show>
);
};
export default DataSourceDialog;

View File

@ -150,19 +150,12 @@ const DocContent: Component<{ entry: DocEntry }> = (props) => {
</div> </div>
</Show> </Show>
<div class="mb-6"> <div class="prose-sm max-w-none">
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2"></h3> <h3 class="text-sm font-semibold text-gray-500 uppercase mb-2"></h3>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm leading-relaxed whitespace-pre-wrap font-mono text-gray-800"> <div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm leading-relaxed whitespace-pre-wrap font-mono text-gray-800">
{e.examples} {e.body}
</div> </div>
</div> </div>
<div>
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">
使
</h3>
<p class="text-sm text-gray-600">{e.usage}</p>
</div>
</div> </div>
); );
}; };

View File

@ -6,6 +6,8 @@ import { FileTreeNode, HeadingNode } from "./FileTree";
export interface SidebarProps { export interface SidebarProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>;
} }
interface SidebarContentProps { interface SidebarContentProps {
@ -25,7 +27,9 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
// 响应式获取当前文件的标题列表 // 响应式获取当前文件的标题列表
const currentFileHeadings = createMemo(() => { const currentFileHeadings = createMemo(() => {
const pathname = decodeURIComponent(location.pathname); const pathname = decodeURIComponent(location.pathname);
return props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []; return (
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
);
}); });
return ( return (
@ -68,11 +72,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
</h3> </h3>
{currentFileHeadings().map((node) => ( {currentFileHeadings().map((node) => (
<HeadingNode <HeadingNode node={node} basePath={location.pathname} depth={0} />
node={node}
basePath={location.pathname}
depth={0}
/>
))} ))}
</div> </div>
</Show> </Show>
@ -85,14 +85,20 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
*/ */
export const MobileSidebar: Component<SidebarProps> = (props) => { export const MobileSidebar: Component<SidebarProps> = (props) => {
const location = useLocation(); const location = useLocation();
const [fileTree, setFileTree] = createSignal<FileNode[]>([]); const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({}); const [selfPathHeadings, setSelfPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
// 加载目录数据 const fileTree = () => props.fileTree ?? selfFileTree();
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
// 加载目录数据 (only if props not provided)
onMount(async () => { onMount(async () => {
if (props.fileTree && props.pathHeadings) return;
const toc = await generateToc(); const toc = await generateToc();
setFileTree(toc.fileTree); setSelfFileTree(toc.fileTree);
setPathHeadings(toc.pathHeadings); setSelfPathHeadings(toc.pathHeadings);
}); });
return ( return (
@ -124,16 +130,24 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
/** /**
* *
*/ */
export const DesktopSidebar: Component<{}> = () => { export const DesktopSidebar: Component<{
fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>;
}> = (props) => {
const location = useLocation(); const location = useLocation();
const [fileTree, setFileTree] = createSignal<FileNode[]>([]); const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({}); const [selfPathHeadings, setSelfPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
const fileTree = () => props.fileTree ?? selfFileTree();
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
// 加载目录数据
onMount(async () => { onMount(async () => {
if (props.fileTree && props.pathHeadings) return;
const toc = await generateToc(); const toc = await generateToc();
setFileTree(toc.fileTree); setSelfFileTree(toc.fileTree);
setPathHeadings(toc.pathHeadings); setSelfPathHeadings(toc.pathHeadings);
}); });
return ( return (

View File

@ -1,3 +1,5 @@
import yaml from "js-yaml";
export interface DocEntry { export interface DocEntry {
tag: string; tag: string;
icon: string; icon: string;
@ -5,350 +7,79 @@ export interface DocEntry {
description: string; description: string;
syntax: string; syntax: string;
props: { name: string; type: string; default?: string; desc: string }[]; props: { name: string; type: string; default?: string; desc: string }[];
examples: string; /** Markdown body (everything after frontmatter ---) */
usage: string; body: string;
} }
export const docEntries: DocEntry[] = [ /** Splits frontmatter and markdown body from a raw .md string. */
{ function parseFrontmatter(raw: string): Record<string, unknown> | null {
tag: "md-dice", const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
icon: "🎲", if (!match) return null;
title: "骰子组件", try {
description: "点击文字执行掷骰,可用于属性检定、伤害投掷等场景。", return yaml.load(match[1]) as Record<string, unknown>;
syntax: ':md-dice[2d6+d8]{key="attack"}', } catch {
props: [ return null;
{ }
name: "key", }
type: "string",
desc: "URL 参数标识,结果记录到 ?dice-key=15",
},
],
examples: `**攻击检定:** :md-dice[1d20+5]{key="attack"}
**** :md-dice[2d6+3]{key="damage"} function getBody(raw: string): string {
const match = raw.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return match ? match[1] : raw;
}
**** :md-dice[2d20k1+5]{key="advantage"} function parseEntry(raw: string): DocEntry | null {
const fm = parseFrontmatter(raw);
if (!fm) return null;
return {
tag: fm.tag as string,
icon: fm.icon as string,
title: fm.title as string,
description: fm.description as string,
syntax: fm.syntax as string,
props: (fm.props as DocEntry["props"]) ?? [],
body: getBody(raw),
};
}
`, // Static imports each .md file is asset/source so imports are strings.
usage: "用于技能检定、攻击命中、伤害计算等需要随机数的场景。", // Add a new import here when creating a new doc entry.
}, import mdDiceRaw from "../doc-entries/md-dice.md";
{ import mdTableRaw from "../doc-entries/md-table.md";
tag: "md-table", import mdLinkRaw from "../doc-entries/md-link.md";
icon: "📊", import mdPinsRaw from "../doc-entries/md-pins.md";
title: "表格组件", import mdFontRaw from "../doc-entries/md-font.md";
description: import mdBgRaw from "../doc-entries/md-bg.md";
"将 CSV 数据转换为可切换标签页的表格,支持随机抽取和变量引用。", import mdBorderRaw from "../doc-entries/md-border.md";
syntax: ":md-table[./data.csv]{roll=true remix=true}", import mdEmbedRaw from "../doc-entries/md-embed.md";
props: [ import mdDeckRaw from "../doc-entries/md-deck.md";
{ name: "roll", type: "boolean", desc: "显示随机切换按钮" }, import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
{ name: "remix", type: "boolean", desc: "支持 {{prop}} 引用同行其他列" }, import mdTokenRaw from "../doc-entries/md-token.md";
], import mdTokenViewerRaw from "../doc-entries/md-token-viewer.md";
examples: `**基础表格:** import mdCommanderRaw from "../doc-entries/md-commander.md";
\`\`\`markdown
:md-table[./npcs.csv]
\`\`\`
**** const rawDocuments: string[] = [
\`\`\`markdown mdDiceRaw,
:md-table[./encounters.csv]{roll=true} mdTableRaw,
\`\`\` mdLinkRaw,
mdPinsRaw,
** + ** mdFontRaw,
\`\`\`markdown mdBgRaw,
:md-table[./quests.csv]{roll=true remix=true} mdBorderRaw,
\`\`\` mdEmbedRaw,
mdDeckRaw,
CSV label, body group YAML front matter `, mdYarnRaw,
usage: "用于 NPC 列表、遭遇表、宝物生成、随机事件等结构化数据。", mdTokenRaw,
}, mdTokenViewerRaw,
{ mdCommanderRaw,
tag: "md-link",
icon: "🔗",
title: "链接组件",
description: "点击链接在当前页面内展开显示目标文章内容,支持章节定位。",
syntax: ":md-link[./rules.md#combat]",
props: [],
examples: `**展开完整文档:**
:md-link[./rules.md]
****
:md-link[./rules.md#combat]
`,
usage: "用于引用规则书章节、怪物数据、快速预览等场景。",
},
{
tag: "md-pins",
icon: "📍",
title: "标记组件",
description:
"在地图或图片上添加可编辑或固定的位置标记,支持字母或数字标签。",
syntax: ':md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed}',
props: [
{
name: "pins",
type: "string",
default: '""',
desc: '标记列表,格式 "A:x,y B:x,y"',
},
{
name: "fixed",
type: "boolean",
default: "false",
desc: "固定模式(只读不可编辑)",
},
{
name: "labelStart",
type: "string",
default: '"A"',
desc: "标签起始值,支持字母或数字",
},
],
examples: `**固定标记(只读):**
:md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30" fixed}
****
:md-pins[./images/blank-map.png]
****
:md-pins[./images/dungeon.png]{labelStart="1"}
fixed `,
usage: "用于战斗地图标注、地牢房间标记、任务地点指示等。",
},
{
tag: "md-font",
icon: "🔤",
title: "字体组件",
description:
"设置整个文档的字体和文字颜色,支持 Google Fonts、emfont 和系统本地字体三种来源。",
syntax:
':md-font[Noto Sans SC]{source="google" weight="400" color="#ff00dd"}',
props: [
{
name: "source",
type: "google | emfont | local",
default: "local",
desc: "字体来源",
},
{ name: "weight", type: "string", default: '"400"', desc: "字体粗细" },
{
name: "color",
type: "string",
desc: "文字颜色CSS 颜色值,如 #ff00dd、rgb(255,0,0)",
},
],
examples: `**Google 字体:**
:md-font[Noto Sans SC]{source="google"}
**Emfont **
:md-font[Source Han Serif]{source="emfont" weight="700"}
****
:md-font[STSong]
****
:md-font[Noto Sans SC]{source="google" color="#1a1a2e"}
:md-font[Noto Sans SC]{source="google" weight="700" color="crimson"}
`,
usage: "用于切换文档的显示字体和文字颜色,适配不同风格需求。",
},
{
tag: "md-bg",
icon: "🖼️",
title: "背景组件",
description: "设置背景图片或纯色作为文章卡片背景,支持多种适配方式。",
syntax: ":md-bg[#ff00dd]",
props: [
{
name: "fit",
type: "cover | contain | fill | none | scale-down",
default: "cover",
desc: "背景适配方式(仅图片时生效)",
},
],
examples: `**设置背景图:**
:md-bg[./images/dungeon-bg.jpg]{fit="cover"}
****
:md-bg[#2a1a3a]
:md-bg[rgb(200,50,50)]
:md-bg[#ff00dd]
CSS hexrgb`,
usage:
"用于营造场景氛围,如地牢、森林、城镇等不同环境的背景,或纯色区分不同主题。",
},
{
tag: "md-font",
icon: "🔤",
title: "字体组件",
description:
"设置整个文档的字体,支持 Google Fonts、emfont 和系统本地字体三种来源。",
syntax: ':md-font[Noto Sans SC]{source="google" weight="400"}',
props: [
{
name: "source",
type: "google | emfont | local",
default: "local",
desc: "字体来源",
},
{ name: "weight", type: "string", default: '"400"', desc: "字体粗细" },
],
examples: `**Google 字体:**
:md-font[Noto Sans SC]{source="google"}
**Emfont **
:md-font[Source Han Serif]{source="emfont" weight="700"}
****
:md-font[STSong]
`,
usage: "用于切换文档的显示字体,适配不同风格需求。",
},
{
tag: "md-border",
icon: "🖼️",
title: "边框组件",
description: "为文档卡片添加纯色或图片边框,支持指定边、样式和重复模式。",
syntax: ":md-border[#3b82f6]{.l .dashed}",
props: [
{
name: "width",
type: "string",
default: "纯色 .1mm,图片 2mm",
desc: "边框宽度",
},
{
name: "slice",
type: "string",
default: '"10"',
desc: "border-image-slice仅图片模式",
},
],
examples: `**纯色左边框:**
:md-border[#3b82f6]{.l}
**线**
:md-border[red]{.t .b .dashed width=".2mm"}
****
:md-border[./images/frame.png]{.all .round}
****
:md-border[./images/ornament.png]{.l .r .repeat}
class\`t\` \`b\` \`l\` \`r\` \`all\`(默认)\n样式 class\`solid\` \`dashed\` \`dotted\` \`double\`\n图片 class\`stretch\` \`repeat\` \`round\` \`space\``,
usage: "用于装饰文档卡片,配合背景和字体营造特定风格。",
},
{
tag: "md-embed",
icon: "📄",
title: "嵌入组件",
description: "将另一个 Markdown 文件的内容内联嵌入到当前文档中。",
syntax: ":md-embed[./rules.md#combat]",
props: [],
examples: `**嵌入完整文档:**
:md-embed[./rules.md]
****
:md-embed[./rules.md#combat]
`,
usage: "用于在文档中引用通用规则、重复使用的数据表格等。",
},
{
tag: "md-deck",
icon: "🃏",
title: "卡牌组件",
description: "将 CSV 数据渲染为卡牌布局,支持自定义网格和图层的排版。",
syntax:
':md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-8,8f3"}',
props: [
{ name: "grid", type: "string", desc: "卡牌布局,格式 行x列" },
{
name: "layers",
type: "string",
desc: "图层定义,格式 字段:行,列-列,字号",
},
],
examples: `**基础卡牌:**
:md-deck[./spells.csv]{grid="3x3"}
****
:md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-8,8f3"}
CSV label `,
usage: "用于法术卡、物品卡、NPC 卡等可打印的卡牌排版。",
},
{
tag: "md-yarn-spinner",
icon: "🧶",
title: "叙事线组件",
description: "展示 Yarn Spinner 格式的分支叙事结构,支持对话选择和分支。",
syntax: ":md-yarn-spinner[./story.yarn]",
props: [],
examples: `**加载叙事文件:**
:md-yarn-spinner[./story.yarn]
Yarn Spinner `,
usage: "用于互动故事、分支对话、冒险剧本等。",
},
{
tag: "md-token",
icon: "🪙",
title: "代币组件",
description: "展示游戏代币或棋子的图片。",
syntax: ":md-token[./token.png]",
props: [],
examples: `**展示代币:**
:md-token[./goblin.png]
`,
usage: "用于展示怪物代币、角色棋子、道具图标等。",
},
{
tag: "md-token-viewer",
icon: "🎨",
title: "代币预览组件",
description:
"使用 Three.js 在浏览器中 3D 渲染 3MF 格式的代币模型,支持旋转查看。",
syntax: ":md-token-viewer[./token.3mf]",
props: [],
examples: `**3D 预览模型:**
:md-token-viewer[./dragon.3mf]
`,
usage: "用于 3D 打印前的代币模型预览。",
},
{
tag: "md-commander",
icon: "📋",
title: "命令追踪器",
description: "支持命令历史和游戏状态追踪,使用类 Emmet 语法创建追踪项。",
syntax: ":md-commander",
props: [],
examples: `**追踪 NPC 血量和防御:**
\`\`\`
track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
\`\`\`
****
- \`#id\` 设置 ID
- \`.class\` 添加类别
- \`[attr=value]\` 设置属性
****
| | |
|---|---|
| x/y | |
| | |
| | |`,
usage: "用于追踪战斗中的 NPC 血量、AC、状态等游戏信息。",
},
]; ];
let _entries: DocEntry[] | null = null;
function loadDocEntries(): DocEntry[] {
if (_entries) return _entries;
_entries = rawDocuments.map(parseEntry).filter(Boolean) as DocEntry[];
_entries.sort((a, b) => a.tag.localeCompare(b.tag));
return _entries;
}
export const docEntries = loadDocEntries();

View File

@ -21,6 +21,8 @@ export type { SidebarProps } from "./Sidebar";
export { FileTreeNode, HeadingNode } from "./FileTree"; export { FileTreeNode, HeadingNode } from "./FileTree";
export { default as DocDialog } from "./DocDialog"; export { default as DocDialog } from "./DocDialog";
export type { DocDialogProps } from "./DocDialog"; export type { DocDialogProps } from "./DocDialog";
export { default as DataSourceDialog } from "./DataSourceDialog";
export type { DataSourceDialogProps } from "./DataSourceDialog";
// 导出数据类型 // 导出数据类型
export type { DiceProps } from "./md-dice"; export type { DiceProps } from "./md-dice";

View File

@ -0,0 +1,104 @@
/**
* IndexedDB helper for persisting FileSystemDirectoryHandle
* across page refreshes and browsing sessions.
*/
const DB_NAME = "ttrpg-file-index";
const DB_VERSION = 1;
const STORE_NAME = "handles";
const HANDLE_KEY = "rootDir";
let dbPromise: Promise<IDBDatabase> | null = null;
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
if (typeof indexedDB === "undefined") {
return reject(new Error("IndexedDB not available"));
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return dbPromise;
}
/**
* Persist a FileSystemDirectoryHandle to IndexedDB.
* Uses structured clone (natively supported by IndexedDB for FileSystemHandle).
*/
export async function saveHandle(handle: FileSystemDirectoryHandle): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const txn = db.transaction(STORE_NAME, "readwrite");
const store = txn.objectStore(STORE_NAME);
const req = store.put(handle, HANDLE_KEY);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
/**
* Try to load a previously persisted FileSystemDirectoryHandle.
* Returns null if none was saved, or if the stored handle cannot be used.
*/
export async function loadHandle(): Promise<FileSystemDirectoryHandle | null> {
try {
const db = await openDB();
return new Promise((resolve) => {
const txn = db.transaction(STORE_NAME, "readonly");
const store = txn.objectStore(STORE_NAME);
const req = store.get(HANDLE_KEY);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => resolve(null);
});
} catch {
return null;
}
}
/**
* Remove the persisted handle (e.g., user switches source).
*/
export async function removeHandle(): Promise<void> {
try {
const db = await openDB();
return new Promise((resolve) => {
const txn = db.transaction(STORE_NAME, "readwrite");
const store = txn.objectStore(STORE_NAME);
const req = store.delete(HANDLE_KEY);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
});
} catch {
// ignore
}
}
/**
* Check if a stored handle still has read permission,
* or request it with a user gesture.
*/
export async function ensurePermission(
handle: FileSystemDirectoryHandle,
): Promise<boolean> {
// FileSystemHandle.queryPermission not in all type defs
const queryOpts = { mode: "read" as const };
const current = await (handle as any).queryPermission?.(queryOpts);
if (current === "granted") return true;
const requested = await (handle as any).requestPermission?.(queryOpts);
return requested === "granted";
}

View File

@ -1,34 +1,50 @@
/** /**
* *
* *
* 1. CLI /__CONTENT_INDEX.json
* 2. Dev 使 webpackContext .md
* 3.
* - IndexedDB
*/ */
import {
saveHandle,
loadHandle,
removeHandle,
ensurePermission,
} from "./file-index-db";
type FileIndex = Record<string, string>; type FileIndex = Record<string, string>;
let fileIndex: FileIndex | null = null; let fileIndex: FileIndex | null = null;
let indexLoadPromise: Promise<void> | null = null; let indexLoadPromise: Promise<void> | null = null;
let activeSource: "cli" | "webpack" | "folder" | null = null;
/** Currently active directory handle (if folder source) */
let activeDirHandle: FileSystemDirectoryHandle | null = null;
/** /**
* *
* CLI JSON Dev 使 webpackContext .md * CLI JSON webpack context
*/ */
function ensureIndexLoaded(): Promise<void> { function ensureIndexLoaded(): Promise<void> {
if (indexLoadPromise) return indexLoadPromise; if (indexLoadPromise) return indexLoadPromise;
indexLoadPromise = (async () => { indexLoadPromise = (async () => {
// 尝试 CLI 环境:从 /__CONTENT_INDEX.json 加载 // 策略 1: CLI 环境 — 从 /__CONTENT_INDEX.json 加载
try { try {
const response = await fetch("/__CONTENT_INDEX.json"); const response = await fetch("/__CONTENT_INDEX.json");
if (response.ok) { if (response.ok) {
const index = await response.json(); const index = await response.json();
fileIndex = { ...fileIndex, ...index }; fileIndex = { ...fileIndex, ...index };
activeSource = "cli";
return; return;
} }
} catch (e) { } catch (e) {
// CLI 索引不可用时尝试 dev 环境 // CLI 索引不可用时尝试下一个策略
} }
// Dev 环境:使用 import.meta.webpackContext + raw loader 加载 .md, .csv, .yarn 文件 // 策略 2: Dev 环境 — webpackContext + raw loader
try { try {
const context = import.meta.webpackContext("../../content", { const context = import.meta.webpackContext("../../content", {
recursive: true, recursive: true,
@ -37,21 +53,143 @@ function ensureIndexLoaded(): Promise<void> {
const keys = context.keys(); const keys = context.keys();
const index: FileIndex = {}; const index: FileIndex = {};
for (const key of keys) { for (const key of keys) {
// context 返回的是模块,需要访问其 default 导出raw-loader 处理后的内容)
const module = context(key) as { default?: string } | string; const module = context(key) as { default?: string } | string;
const content = typeof module === "string" ? module : module.default ?? ""; const content =
typeof module === "string" ? module : (module.default ?? "");
const normalizedPath = "/content" + key.slice(1); const normalizedPath = "/content" + key.slice(1);
index[normalizedPath] = content; index[normalizedPath] = content;
} }
fileIndex = { ...fileIndex, ...index }; fileIndex = { ...fileIndex, ...index };
activeSource = "webpack";
} catch (e) { } catch (e) {
// webpackContext 不可用时忽略 // webpackContext 不可用时忽略
} }
// 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
if (!activeSource) {
try {
const restored = await restoreSavedHandle();
if (restored) {
activeSource = "folder";
return;
}
} catch (e) {
// 无法恢复,继续 — 用户需要手动选择文件夹
}
}
})(); })();
return indexLoadPromise; return indexLoadPromise;
} }
/**
*
* .md / .yarn / .csv
*/
async function scanDirectory(
handle: FileSystemDirectoryHandle,
prefix = "",
): Promise<FileIndex> {
const index: FileIndex = {};
const acceptedExt = /\.(md|yarn|csv)$/i;
for await (const [name, entry] of (handle as any).entries()) {
if (entry.kind === "directory") {
const sub = await scanDirectory(
entry as FileSystemDirectoryHandle,
prefix + name + "/",
);
Object.assign(index, sub);
} else if (entry.kind === "file" && acceptedExt.test(name)) {
const file = await (entry as FileSystemFileHandle).getFile();
const path = prefix + name;
index[path] = await file.text();
}
}
return index;
}
/**
* IndexedDB
*/
async function restoreSavedHandle(): Promise<boolean> {
if (typeof indexedDB === "undefined") return false;
const handle = await loadHandle();
if (!handle) return false;
const permitted = await ensurePermission(handle);
if (!permitted) return false;
try {
const index = await scanDirectory(handle);
fileIndex = { ...fileIndex, ...index };
activeDirHandle = handle;
// Refresh the promise so future calls use new index
indexLoadPromise = Promise.resolve();
return true;
} catch {
// handle is stale (folder moved/deleted), clear it
await removeHandle();
return false;
}
}
/**
* (Browser only)
* IndexedDB
*
* @returns null
*/
export async function loadFromUserFolder(): Promise<string[] | null> {
if (!("showDirectoryPicker" in window)) {
console.warn("showDirectoryPicker not supported in this browser");
return null;
}
try {
const handle = await window.showDirectoryPicker({ mode: "read" });
const index = await scanDirectory(handle);
// Replace the existing index entirely with the user's folder content
fileIndex = index;
activeDirHandle = handle;
activeSource = "folder";
// Reset the load promise so future ensureIndexLoaded() calls are no-ops
indexLoadPromise = Promise.resolve();
await saveHandle(handle);
return Object.keys(index);
} catch (err) {
// User cancelled or error
if ((err as DOMException)?.name !== "AbortError") {
console.error("Failed to load folder:", err);
}
return null;
}
}
/**
*
* 退 CLI/webpack
*/
export async function switchToBuiltInSource(): Promise<void> {
await removeHandle();
activeDirHandle = null;
fileIndex = null;
indexLoadPromise = null;
activeSource = null;
}
/**
*
*/
export function getActiveSource(): string | null {
return activeSource;
}
/** /**
* *
*/ */
@ -64,7 +202,7 @@ export async function getIndexedData(path: string): Promise<string> {
const content = await res.text(); const content = await res.text();
fileIndex = fileIndex || {}; fileIndex = fileIndex || {};
fileIndex[path] = content; fileIndex[path] = content;
return content; return content;
} }
/** /**
@ -74,8 +212,8 @@ export async function getPathsByExtension(ext: string): Promise<string[]> {
await ensureIndexLoaded(); await ensureIndexLoaded();
if (!fileIndex) return []; if (!fileIndex) return [];
const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`; const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`;
return Object.keys(fileIndex).filter(path => return Object.keys(fileIndex).filter((path) =>
path.toLowerCase().endsWith(normalizedExt.toLowerCase()) path.toLowerCase().endsWith(normalizedExt.toLowerCase()),
); );
} }
@ -85,4 +223,6 @@ export async function getPathsByExtension(ext: string): Promise<string[]> {
export function clearIndex(): void { export function clearIndex(): void {
fileIndex = null; fileIndex = null;
indexLoadPromise = null; indexLoadPromise = null;
activeSource = null;
activeDirHandle = null;
} }

28
src/doc-entries/md-bg.md Normal file
View File

@ -0,0 +1,28 @@
---
tag: md-bg
icon: 🖼️
title: 背景组件
description: 设置背景图片或纯色作为文章卡片背景,支持多种适配方式。
syntax: ':md-bg[#ff00dd]'
props:
- name: fit
type: cover | contain | fill | none | scale-down
default: cover
desc: 背景适配方式(仅图片时生效)
---
**设置背景图:**
:md-bg[./images/dungeon-bg.jpg]{fit="cover"}
**设置纯色背景:**
:md-bg[#2a1a3a]
:md-bg[rgb(200,50,50)]
:md-bg[#ff00dd]
支持图片路径或任意 CSS 颜色值hex、rgb、颜色名等
## 使用场景
用于营造场景氛围,如地牢、森林、城镇等不同环境的背景,或纯色区分不同主题。

View File

@ -0,0 +1,36 @@
---
tag: md-border
icon: 🖼️
title: 边框组件
description: 为文档卡片添加纯色或图片边框,支持指定边、样式和重复模式。
syntax: ':md-border[#3b82f6]{.l .dashed}'
props:
- name: width
type: string
default: 纯色 .2mm,图片 2mm
desc: 边框宽度
- name: slice
type: string
default: '"10"'
desc: border-image-slice仅图片模式
---
**纯色左边框:**
:md-border[#3b82f6]{.l}
**虚线上下边框:**
:md-border[red]{.t .b .dashed width=".2mm"}
**图片边框:**
:md-border[./images/frame.png]{.all .round}
**左右图片竖条:**
:md-border[./images/ornament.png]{.l .r .repeat}
边 class`t` `b` `l` `r` `all`(默认)
样式 class`solid` `dashed` `dotted` `double`
图片 class`stretch` `repeat` `round` `space`
## 使用场景
用于装饰文档卡片,配合背景和字体营造特定风格。

View File

@ -0,0 +1,29 @@
---
tag: md-commander
icon: 📋
title: 命令追踪器
description: 支持命令历史和游戏状态追踪,使用类 Emmet 语法创建追踪项。
syntax: ':md-commander'
props: []
---
**追踪 NPC 血量和防御:**
```
track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
```
**语法规则:**
- `#id` 设置 ID
- `.class` 添加类别
- `[attr=value]` 设置属性
**属性类型:**
| 格式 | 显示 |
|---|---|
| x/y | 进度条 |
| 整数 | 计数器 |
| 文本 | 文本字段 |
## 使用场景
用于追踪战斗中的 NPC 血量、AC、状态等游戏信息。

View File

@ -0,0 +1,26 @@
---
tag: md-deck
icon: 🃏
title: 卡牌组件
description: 将 CSV 数据渲染为卡牌布局,支持自定义网格和图层的排版。
syntax: ':md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-8,8f3"}'
props:
- name: grid
type: string
desc: 卡牌布局,格式 行x列
- name: layers
type: string
desc: 图层定义,格式 字段:行,列-列,字号
---
**基础卡牌:**
:md-deck[./spells.csv]{grid="3x3"}
**多层卡牌:**
:md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-8,8f3"}
CSV 包含 label 和显示字段列,通过图层定义控制各字段的位置和大小。
## 使用场景
用于法术卡、物品卡、NPC 卡等可打印的卡牌排版。

View File

@ -0,0 +1,23 @@
---
tag: md-dice
icon: 🎲
title: 骰子组件
description: 点击文字执行掷骰,可用于属性检定、伤害投掷等场景。
syntax: ':md-dice[2d6+d8]{key="attack"}'
props:
- name: key
type: string
desc: URL 参数标识,结果记录到 ?dice-key=15
---
**攻击检定:** :md-dice[1d20+5]{key="attack"}
**伤害掷骰:** :md-dice[2d6+3]{key="damage"}
**优势检定:** :md-dice[2d20k1+5]{key="advantage"}
点击掷骰文字执行投掷,再次点击重置为公式。
## 使用场景
用于技能检定、攻击命中、伤害计算等需要随机数的场景。

View File

@ -0,0 +1,20 @@
---
tag: md-embed
icon: 📄
title: 嵌入组件
description: 将另一个 Markdown 文件的内容内联嵌入到当前文档中。
syntax: ':md-embed[./rules.md#combat]'
props: []
---
**嵌入完整文档:**
:md-embed[./rules.md]
**嵌入指定章节:**
:md-embed[./rules.md#combat]
嵌入后内容直接在当前位置显示,包括其中的表格、骰子等组件也会正常渲染。
## 使用场景
用于在文档中引用通用规则、重复使用的数据表格等。

View File

@ -0,0 +1,39 @@
---
tag: md-font
icon: 🔤
title: 字体组件
description: 设置整个文档的字体和文字颜色,支持 Google Fonts、emfont 和系统本地字体三种来源。
syntax: ':md-font[Noto Sans SC]{source="google" weight="400" color="#ff00dd"}'
props:
- name: source
type: google | emfont | local
default: local
desc: 字体来源
- name: weight
type: string
default: '"400"'
desc: 字体粗细
- name: color
type: string
desc: 文字颜色CSS 颜色值,如 #ff00dd、rgb(255,0,0)
---
**Google 字体:**
:md-font[Noto Sans SC]{source="google"}
**Emfont 字体:**
:md-font[Source Han Serif]{source="emfont" weight="700"}
**本地字体(默认):**
:md-font[STSong]
**设置文字颜色:**
:md-font[Noto Sans SC]{source="google" color="#1a1a2e"}
:md-font[Noto Sans SC]{source="google" weight="700" color="crimson"}
字体和颜色将应用到整个文档卡片。
## 使用场景
用于切换文档的显示字体和文字颜色,适配不同风格需求。

View File

@ -0,0 +1,20 @@
---
tag: md-link
icon: 🔗
title: 链接组件
description: 点击链接在当前页面内展开显示目标文章内容,支持章节定位。
syntax: ':md-link[./rules.md#combat]'
props: []
---
**展开完整文档:**
:md-link[./rules.md]
**展开特定章节:**
:md-link[./rules.md#combat]
点击链接即可内联展开目标内容,再次点击折叠。
## 使用场景
用于引用规则书章节、怪物数据、快速预览等场景。

View File

@ -0,0 +1,35 @@
---
tag: md-pins
icon: 📍
title: 标记组件
description: 在地图或图片上添加可编辑或固定的位置标记,支持字母或数字标签。
syntax: ':md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed}'
props:
- name: pins
type: string
default: '""'
desc: 标记列表,格式 "A:x,y B:x,y"
- name: fixed
type: boolean
default: "false"
desc: 固定模式(只读不可编辑)
- name: labelStart
type: string
default: '"A"'
desc: 标签起始值,支持字母或数字
---
**固定标记(只读):**
:md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30" fixed}
**可编辑标记:**
:md-pins[./images/blank-map.png]
**数字标签:**
:md-pins[./images/dungeon.png]{labelStart="1"}
非 fixed 模式下点击图片添加标记,点击标记删除。
## 使用场景
用于战斗地图标注、地牢房间标记、任务地点指示等。

View File

@ -0,0 +1,35 @@
---
tag: md-table
icon: 📊
title: 表格组件
description: 将 CSV 数据转换为可切换标签页的表格,支持随机抽取和变量引用。
syntax: ':md-table[./data.csv]{roll=true remix=true}'
props:
- name: roll
type: boolean
desc: 显示随机切换按钮
- name: remix
type: boolean
desc: 支持 {{prop}} 引用同行其他列
---
**基础表格:**
```markdown
:md-table[./npcs.csv]
```
**带随机抽取:**
```markdown
:md-table[./encounters.csv]{roll=true}
```
**随机 + 变量引用:**
```markdown
:md-table[./quests.csv]{roll=true remix=true}
```
CSV 要求包含 label, body 列,可选 group 列分组。支持 YAML front matter 继承行属性。
## 使用场景
用于 NPC 列表、遭遇表、宝物生成、随机事件等结构化数据。

View File

@ -0,0 +1,17 @@
---
tag: md-token-viewer
icon: 🎨
title: 代币预览组件
description: 使用 Three.js 在浏览器中 3D 渲染 3MF 格式的代币模型,支持旋转查看。
syntax: ':md-token-viewer[./token.3mf]'
props: []
---
**3D 预览模型:**
:md-token-viewer[./dragon.3mf]
支持鼠标拖拽旋转和自动旋转,从多角度查看模型细节。
## 使用场景
用于 3D 打印前的代币模型预览。

View File

@ -0,0 +1,17 @@
---
tag: md-token
icon: 🪙
title: 代币组件
description: 展示游戏代币或棋子的图片。
syntax: ':md-token[./token.png]'
props: []
---
**展示代币:**
:md-token[./goblin.png]
代币图片自动渲染为圆形,适合战棋或卡牌中的棋子展示。
## 使用场景
用于展示怪物代币、角色棋子、道具图标等。

View File

@ -0,0 +1,17 @@
---
tag: md-yarn-spinner
icon: 🧶
title: 叙事线组件
description: 展示 Yarn Spinner 格式的分支叙事结构,支持对话选择和分支。
syntax: ':md-yarn-spinner[./story.yarn]'
props: []
---
**加载叙事文件:**
:md-yarn-spinner[./story.yarn]
Yarn Spinner 是用于游戏对话系统的格式,支持选项、条件分支和变量。
## 使用场景
用于互动故事、分支对话、冒险剧本等。

42
src/global.d.ts vendored
View File

@ -1,5 +1,40 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
// File System Access API (Chrome / Edge)
interface FileSystemHandlePermissionDescriptor {
mode?: "read" | "readwrite";
}
interface FileSystemDirectoryHandle {
kind: "directory";
name: string;
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
queryPermission?(
descriptor?: FileSystemHandlePermissionDescriptor,
): Promise<PermissionState>;
requestPermission?(
descriptor?: FileSystemHandlePermissionDescriptor,
): Promise<PermissionState>;
}
interface FileSystemFileHandle {
kind: "file";
name: string;
getFile(): Promise<File>;
}
type FileSystemHandle = FileSystemDirectoryHandle | FileSystemFileHandle;
interface DirectoryPickerOptions {
mode?: "read" | "readwrite";
}
interface Window {
showDirectoryPicker(
options?: DirectoryPickerOptions,
): Promise<FileSystemDirectoryHandle>;
}
interface WebpackContext { interface WebpackContext {
(path: string): { default?: string } | string; (path: string): { default?: string } | string;
keys(): string[]; keys(): string[];
@ -11,6 +46,11 @@ interface ImportMeta {
options: { options: {
recursive?: boolean; recursive?: boolean;
regExp?: RegExp; regExp?: RegExp;
} },
): WebpackContext; ): WebpackContext;
} }
declare module "*.md" {
const content: string;
export default content;
}