Compare commits
3 Commits
0886a406bb
...
53c8ce0677
| Author | SHA1 | Date |
|---|---|---|
|
|
53c8ce0677 | |
|
|
a05b0f4291 | |
|
|
d72d4e7bde |
44
src/App.tsx
44
src/App.tsx
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, createMemo, createSignal } from "solid-js";
|
||||
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
|
||||
// 导入组件以注册自定义元素
|
||||
|
|
@ -8,12 +8,38 @@ import {
|
|||
MobileSidebar,
|
||||
DesktopSidebar,
|
||||
DocDialog,
|
||||
DataSourceDialog,
|
||||
} from "./components";
|
||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||
|
||||
const App: Component = () => {
|
||||
const location = useLocation();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = 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(() => {
|
||||
// 根据路由加载对应的 markdown 文件
|
||||
|
|
@ -29,11 +55,13 @@ const App: Component = () => {
|
|||
return (
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
{/* 桌面端固定侧边栏 */}
|
||||
<DesktopSidebar />
|
||||
<DesktopSidebar fileTree={fileTree()} pathHeadings={pathHeadings()} />
|
||||
{/* 移动端抽屉式侧边栏 */}
|
||||
<MobileSidebar
|
||||
isOpen={isSidebarOpen()}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
fileTree={fileTree()}
|
||||
pathHeadings={pathHeadings()}
|
||||
/>
|
||||
<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">
|
||||
|
|
@ -47,6 +75,13 @@ const App: Component = () => {
|
|||
</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
||||
<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
|
||||
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"
|
||||
|
|
@ -63,6 +98,11 @@ const App: Component = () => {
|
|||
</main>
|
||||
</div>
|
||||
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
||||
<DataSourceDialog
|
||||
isOpen={isDataSourceOpen()}
|
||||
onClose={() => setIsDataSourceOpen(false)}
|
||||
onSourceChanged={handleSourceChanged}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -150,19 +150,12 @@ const DocContent: Component<{ entry: DocEntry }> = (props) => {
|
|||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { FileTreeNode, HeadingNode } from "./FileTree";
|
|||
export interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
fileTree?: FileNode[];
|
||||
pathHeadings?: Record<string, TocNode[]>;
|
||||
}
|
||||
|
||||
interface SidebarContentProps {
|
||||
|
|
@ -25,7 +27,9 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||
// 响应式获取当前文件的标题列表
|
||||
const currentFileHeadings = createMemo(() => {
|
||||
const pathname = decodeURIComponent(location.pathname);
|
||||
return props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || [];
|
||||
return (
|
||||
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -68,11 +72,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||
本页
|
||||
</h3>
|
||||
{currentFileHeadings().map((node) => (
|
||||
<HeadingNode
|
||||
node={node}
|
||||
basePath={location.pathname}
|
||||
depth={0}
|
||||
/>
|
||||
<HeadingNode node={node} basePath={location.pathname} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -85,14 +85,20 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||
*/
|
||||
export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||
const location = useLocation();
|
||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({});
|
||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
|
||||
Record<string, TocNode[]>
|
||||
>({});
|
||||
|
||||
// 加载目录数据
|
||||
const fileTree = () => props.fileTree ?? selfFileTree();
|
||||
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
|
||||
|
||||
// 加载目录数据 (only if props not provided)
|
||||
onMount(async () => {
|
||||
if (props.fileTree && props.pathHeadings) return;
|
||||
const toc = await generateToc();
|
||||
setFileTree(toc.fileTree);
|
||||
setPathHeadings(toc.pathHeadings);
|
||||
setSelfFileTree(toc.fileTree);
|
||||
setSelfPathHeadings(toc.pathHeadings);
|
||||
});
|
||||
|
||||
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 [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({});
|
||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
|
||||
Record<string, TocNode[]>
|
||||
>({});
|
||||
|
||||
const fileTree = () => props.fileTree ?? selfFileTree();
|
||||
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
|
||||
|
||||
// 加载目录数据
|
||||
onMount(async () => {
|
||||
if (props.fileTree && props.pathHeadings) return;
|
||||
const toc = await generateToc();
|
||||
setFileTree(toc.fileTree);
|
||||
setPathHeadings(toc.pathHeadings);
|
||||
setSelfFileTree(toc.fileTree);
|
||||
setSelfPathHeadings(toc.pathHeadings);
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import yaml from "js-yaml";
|
||||
|
||||
export interface DocEntry {
|
||||
tag: string;
|
||||
icon: string;
|
||||
|
|
@ -5,350 +7,79 @@ export interface DocEntry {
|
|||
description: string;
|
||||
syntax: string;
|
||||
props: { name: string; type: string; default?: string; desc: string }[];
|
||||
examples: string;
|
||||
usage: string;
|
||||
/** Markdown body (everything after frontmatter ---) */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const docEntries: DocEntry[] = [
|
||||
{
|
||||
tag: "md-dice",
|
||||
icon: "🎲",
|
||||
title: "骰子组件",
|
||||
description: "点击文字执行掷骰,可用于属性检定、伤害投掷等场景。",
|
||||
syntax: ':md-dice[2d6+d8]{key="attack"}',
|
||||
props: [
|
||||
{
|
||||
name: "key",
|
||||
type: "string",
|
||||
desc: "URL 参数标识,结果记录到 ?dice-key=15",
|
||||
},
|
||||
],
|
||||
examples: `**攻击检定:** :md-dice[1d20+5]{key="attack"}
|
||||
/** Splits frontmatter and markdown body from a raw .md string. */
|
||||
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return yaml.load(match[1]) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
**伤害掷骰:** :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),
|
||||
};
|
||||
}
|
||||
|
||||
点击掷骰文字执行投掷,再次点击重置为公式。`,
|
||||
usage: "用于技能检定、攻击命中、伤害计算等需要随机数的场景。",
|
||||
},
|
||||
{
|
||||
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}} 引用同行其他列" },
|
||||
],
|
||||
examples: `**基础表格:**
|
||||
\`\`\`markdown
|
||||
:md-table[./npcs.csv]
|
||||
\`\`\`
|
||||
// Static imports – each .md file is asset/source so imports are strings.
|
||||
// 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";
|
||||
import mdLinkRaw from "../doc-entries/md-link.md";
|
||||
import mdPinsRaw from "../doc-entries/md-pins.md";
|
||||
import mdFontRaw from "../doc-entries/md-font.md";
|
||||
import mdBgRaw from "../doc-entries/md-bg.md";
|
||||
import mdBorderRaw from "../doc-entries/md-border.md";
|
||||
import mdEmbedRaw from "../doc-entries/md-embed.md";
|
||||
import mdDeckRaw from "../doc-entries/md-deck.md";
|
||||
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
|
||||
import mdTokenRaw from "../doc-entries/md-token.md";
|
||||
import mdTokenViewerRaw from "../doc-entries/md-token-viewer.md";
|
||||
import mdCommanderRaw from "../doc-entries/md-commander.md";
|
||||
|
||||
**带随机抽取:**
|
||||
\`\`\`markdown
|
||||
:md-table[./encounters.csv]{roll=true}
|
||||
\`\`\`
|
||||
|
||||
**随机 + 变量引用:**
|
||||
\`\`\`markdown
|
||||
:md-table[./quests.csv]{roll=true remix=true}
|
||||
\`\`\`
|
||||
|
||||
CSV 要求包含 label, body 列,可选 group 列分组。支持 YAML front matter 继承行属性。`,
|
||||
usage: "用于 NPC 列表、遭遇表、宝物生成、随机事件等结构化数据。",
|
||||
},
|
||||
{
|
||||
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 颜色值(hex、rgb、颜色名等)。`,
|
||||
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、状态等游戏信息。",
|
||||
},
|
||||
const rawDocuments: string[] = [
|
||||
mdDiceRaw,
|
||||
mdTableRaw,
|
||||
mdLinkRaw,
|
||||
mdPinsRaw,
|
||||
mdFontRaw,
|
||||
mdBgRaw,
|
||||
mdBorderRaw,
|
||||
mdEmbedRaw,
|
||||
mdDeckRaw,
|
||||
mdYarnRaw,
|
||||
mdTokenRaw,
|
||||
mdTokenViewerRaw,
|
||||
mdCommanderRaw,
|
||||
];
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export type { SidebarProps } from "./Sidebar";
|
|||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||
export { default as DocDialog } from "./DocDialog";
|
||||
export type { DocDialogProps } from "./DocDialog";
|
||||
export { default as DataSourceDialog } from "./DataSourceDialog";
|
||||
export type { DataSourceDialogProps } from "./DataSourceDialog";
|
||||
|
||||
// 导出数据类型
|
||||
export type { DiceProps } from "./md-dice";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
||||
let fileIndex: FileIndex | 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> {
|
||||
if (indexLoadPromise) return indexLoadPromise;
|
||||
|
||||
indexLoadPromise = (async () => {
|
||||
// 尝试 CLI 环境:从 /__CONTENT_INDEX.json 加载
|
||||
// 策略 1: CLI 环境 — 从 /__CONTENT_INDEX.json 加载
|
||||
try {
|
||||
const response = await fetch("/__CONTENT_INDEX.json");
|
||||
if (response.ok) {
|
||||
const index = await response.json();
|
||||
fileIndex = { ...fileIndex, ...index };
|
||||
activeSource = "cli";
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// CLI 索引不可用时尝试 dev 环境
|
||||
// CLI 索引不可用时尝试下一个策略
|
||||
}
|
||||
|
||||
// Dev 环境:使用 import.meta.webpackContext + raw loader 加载 .md, .csv, .yarn 文件
|
||||
// 策略 2: Dev 环境 — webpackContext + raw loader
|
||||
try {
|
||||
const context = import.meta.webpackContext("../../content", {
|
||||
recursive: true,
|
||||
|
|
@ -37,21 +53,143 @@ function ensureIndexLoaded(): Promise<void> {
|
|||
const keys = context.keys();
|
||||
const index: FileIndex = {};
|
||||
for (const key of keys) {
|
||||
// context 返回的是模块,需要访问其 default 导出(raw-loader 处理后的内容)
|
||||
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);
|
||||
index[normalizedPath] = content;
|
||||
}
|
||||
fileIndex = { ...fileIndex, ...index };
|
||||
activeSource = "webpack";
|
||||
} catch (e) {
|
||||
// webpackContext 不可用时忽略
|
||||
}
|
||||
|
||||
// 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
|
||||
if (!activeSource) {
|
||||
try {
|
||||
const restored = await restoreSavedHandle();
|
||||
if (restored) {
|
||||
activeSource = "folder";
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// 无法恢复,继续 — 用户需要手动选择文件夹
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从索引获取文件内容
|
||||
*/
|
||||
|
|
@ -74,8 +212,8 @@ export async function getPathsByExtension(ext: string): Promise<string[]> {
|
|||
await ensureIndexLoaded();
|
||||
if (!fileIndex) return [];
|
||||
const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`;
|
||||
return Object.keys(fileIndex).filter(path =>
|
||||
path.toLowerCase().endsWith(normalizedExt.toLowerCase())
|
||||
return Object.keys(fileIndex).filter((path) =>
|
||||
path.toLowerCase().endsWith(normalizedExt.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -85,4 +223,6 @@ export async function getPathsByExtension(ext: string): Promise<string[]> {
|
|||
export function clearIndex(): void {
|
||||
fileIndex = null;
|
||||
indexLoadPromise = null;
|
||||
activeSource = null;
|
||||
activeDirHandle = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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、颜色名等)。
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于营造场景氛围,如地牢、森林、城镇等不同环境的背景,或纯色区分不同主题。
|
||||
|
|
@ -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`
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于装饰文档卡片,配合背景和字体营造特定风格。
|
||||
|
|
@ -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、状态等游戏信息。
|
||||
|
|
@ -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 卡等可打印的卡牌排版。
|
||||
|
|
@ -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"}
|
||||
|
||||
点击掷骰文字执行投掷,再次点击重置为公式。
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于技能检定、攻击命中、伤害计算等需要随机数的场景。
|
||||
|
|
@ -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]
|
||||
|
||||
嵌入后内容直接在当前位置显示,包括其中的表格、骰子等组件也会正常渲染。
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于在文档中引用通用规则、重复使用的数据表格等。
|
||||
|
|
@ -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"}
|
||||
|
||||
字体和颜色将应用到整个文档卡片。
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于切换文档的显示字体和文字颜色,适配不同风格需求。
|
||||
|
|
@ -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]
|
||||
|
||||
点击链接即可内联展开目标内容,再次点击折叠。
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于引用规则书章节、怪物数据、快速预览等场景。
|
||||
|
|
@ -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 模式下点击图片添加标记,点击标记删除。
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于战斗地图标注、地牢房间标记、任务地点指示等。
|
||||
|
|
@ -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 列表、遭遇表、宝物生成、随机事件等结构化数据。
|
||||
|
|
@ -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 打印前的代币模型预览。
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
tag: md-token
|
||||
icon: 🪙
|
||||
title: 代币组件
|
||||
description: 展示游戏代币或棋子的图片。
|
||||
syntax: ':md-token[./token.png]'
|
||||
props: []
|
||||
---
|
||||
|
||||
**展示代币:**
|
||||
:md-token[./goblin.png]
|
||||
|
||||
代币图片自动渲染为圆形,适合战棋或卡牌中的棋子展示。
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于展示怪物代币、角色棋子、道具图标等。
|
||||
|
|
@ -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 是用于游戏对话系统的格式,支持选项、条件分支和变量。
|
||||
|
||||
## 使用场景
|
||||
|
||||
用于互动故事、分支对话、冒险剧本等。
|
||||
|
|
@ -1,5 +1,40 @@
|
|||
/// <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 {
|
||||
(path: string): { default?: string } | string;
|
||||
keys(): string[];
|
||||
|
|
@ -11,6 +46,11 @@ interface ImportMeta {
|
|||
options: {
|
||||
recursive?: boolean;
|
||||
regExp?: RegExp;
|
||||
}
|
||||
},
|
||||
): WebpackContext;
|
||||
}
|
||||
|
||||
declare module "*.md" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue