feat: add Journal server documentation to DocDialog
Introduce a new documentation set for Journal server functionality, including a dropdown selector in the DocDialog to switch between "directives" and "journal" documentation modes.
This commit is contained in:
parent
1ef41f04fc
commit
c10b088fb1
|
|
@ -7,6 +7,10 @@ import {
|
||||||
onMount,
|
onMount,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { docEntries, type DocEntry } from "./doc-data";
|
import { docEntries, type DocEntry } from "./doc-data";
|
||||||
|
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
|
||||||
|
|
||||||
|
type DocSet = "directives" | "journal";
|
||||||
|
type AnyEntry = DocEntry | JournalDocEntry;
|
||||||
|
|
||||||
export interface DocDialogProps {
|
export interface DocDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -14,17 +18,42 @@ export interface DocDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DocDialog: Component<DocDialogProps> = (props) => {
|
const DocDialog: Component<DocDialogProps> = (props) => {
|
||||||
|
const [docSet, setDocSet] = createSignal<DocSet>("directives");
|
||||||
const [selectedTag, setSelectedTag] = createSignal(docEntries[0]?.tag ?? "");
|
const [selectedTag, setSelectedTag] = createSignal(docEntries[0]?.tag ?? "");
|
||||||
|
const [dropdownOpen, setDropdownOpen] = createSignal(false);
|
||||||
|
let dropdownRef!: HTMLDivElement;
|
||||||
|
|
||||||
|
const currentEntries = () =>
|
||||||
|
docSet() === "directives"
|
||||||
|
? (docEntries as AnyEntry[])
|
||||||
|
: (journalDocEntries as AnyEntry[]);
|
||||||
|
|
||||||
const selectedEntry = () =>
|
const selectedEntry = () =>
|
||||||
docEntries.find((e) => e.tag === selectedTag()) ?? docEntries[0];
|
currentEntries().find((e) => e.tag === selectedTag()) ??
|
||||||
|
currentEntries()[0];
|
||||||
|
|
||||||
|
const docSetLabel = () =>
|
||||||
|
docSet() === "directives" ? "指令组件文档" : "Journal 服务器文档";
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") props.onClose();
|
if (e.key === "Escape") props.onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const switchDocSet = (set: DocSet) => {
|
||||||
|
setDocSet(set);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
const entries = set === "directives" ? docEntries : journalDocEntries;
|
||||||
|
setSelectedTag(entries[0]?.tag ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
// Close dropdown on outside click
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|
@ -41,7 +70,47 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
||||||
>
|
>
|
||||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
|
<div class="bg-white rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
||||||
<h2 class="text-lg font-bold text-gray-900">指令组件文档</h2>
|
<div ref={dropdownRef} class="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setDropdownOpen((v) => !v)}
|
||||||
|
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{docSetLabel()}
|
||||||
|
<span class="text-gray-400 text-sm">▾</span>
|
||||||
|
</button>
|
||||||
|
<Show when={dropdownOpen()}>
|
||||||
|
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-50 py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => switchDocSet("directives")}
|
||||||
|
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||||
|
docSet() === "directives"
|
||||||
|
? "bg-blue-50 text-blue-700"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>📖</span>
|
||||||
|
<span>指令组件文档</span>
|
||||||
|
<Show when={docSet() === "directives"}>
|
||||||
|
<span class="ml-auto text-blue-500">✓</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => switchDocSet("journal")}
|
||||||
|
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||||
|
docSet() === "journal"
|
||||||
|
? "bg-blue-50 text-blue-700"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>📋</span>
|
||||||
|
<span>Journal 服务器文档</span>
|
||||||
|
<Show when={docSet() === "journal"}>
|
||||||
|
<span class="ml-auto text-blue-500">✓</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
||||||
|
|
@ -53,7 +122,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
||||||
|
|
||||||
<div class="flex flex-1 min-h-0">
|
<div class="flex flex-1 min-h-0">
|
||||||
<nav class="w-48 shrink-0 border-r border-gray-200 overflow-y-auto p-3 bg-gray-50">
|
<nav class="w-48 shrink-0 border-r border-gray-200 overflow-y-auto p-3 bg-gray-50">
|
||||||
<For each={docEntries}>
|
<For each={currentEntries()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedTag(entry.tag)}
|
onClick={() => setSelectedTag(entry.tag)}
|
||||||
|
|
@ -64,7 +133,12 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span class="mr-2">{entry.icon}</span>
|
<span class="mr-2">{entry.icon}</span>
|
||||||
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
|
<Show
|
||||||
|
when={docSet() === "directives"}
|
||||||
|
fallback={<span class="text-xs">{entry.title}</span>}
|
||||||
|
>
|
||||||
|
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
@ -83,7 +157,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Single entry documentation content */
|
/** Single entry documentation content */
|
||||||
const DocContent: Component<{ entry: DocEntry }> = (props) => {
|
const DocContent: Component<{ entry: AnyEntry }> = (props) => {
|
||||||
const e = props.entry;
|
const e = props.entry;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
export interface JournalDocEntry {
|
||||||
|
tag: string;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
syntax: string;
|
||||||
|
props: { name: string; type: string; default?: string; desc: string }[];
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||||
|
const normalized = raw.replace(/\r\n/g, "\n");
|
||||||
|
const match = normalized.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBody(raw: string): string {
|
||||||
|
const normalized = raw.replace(/\r\n/g, "\n");
|
||||||
|
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
||||||
|
return match ? match[1] : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntry(raw: string): JournalDocEntry | 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 JournalDocEntry["props"]) ?? [],
|
||||||
|
body: getBody(raw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import journalGmRaw from "../doc-entries/journal-gm.md";
|
||||||
|
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
||||||
|
|
||||||
|
const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw];
|
||||||
|
|
||||||
|
let _entries: JournalDocEntry[] | null = null;
|
||||||
|
|
||||||
|
function loadJournalDocEntries(): JournalDocEntry[] {
|
||||||
|
if (_entries) return _entries;
|
||||||
|
_entries = rawDocuments.map(parseEntry).filter(Boolean) as JournalDocEntry[];
|
||||||
|
return _entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const journalDocEntries = loadJournalDocEntries();
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
---
|
||||||
|
tag: journal-gm
|
||||||
|
icon: 🎭
|
||||||
|
title: 主持人指南
|
||||||
|
description: 作为 GM 创建会话、管理玩家、使用动态表单和消息类型。
|
||||||
|
syntax: '点击导航栏 📋 图标打开 Journal 面板'
|
||||||
|
props:
|
||||||
|
- name: 会话管理
|
||||||
|
type: —
|
||||||
|
desc: 创建、切换、删除会话;通过邀请链接添加玩家
|
||||||
|
- name: 消息类型
|
||||||
|
type: —
|
||||||
|
desc: 发送叙述、掷骰、表单等多种消息类型
|
||||||
|
- name: 动态表单
|
||||||
|
type: —
|
||||||
|
desc: 创建自定义表单收集玩家输入
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
|
||||||
|
2. 输入你的名字,选择"主持人"角色,点击"加入"
|
||||||
|
3. 连接成功后,你可以创建新会话或选择已有会话
|
||||||
|
|
||||||
|
## 会话管理
|
||||||
|
|
||||||
|
作为 GM,你可以管理多个会话:
|
||||||
|
|
||||||
|
- **创建会话**:点击标题下方的会话名称 → 下拉菜单 → "+ 新建会话"
|
||||||
|
- **切换会话**:点击会话名称,从下拉列表中选择
|
||||||
|
- **邀请玩家**:点击玩家标签旁的 "+ 邀请",输入玩家名字,复制链接发送给玩家
|
||||||
|
|
||||||
|
玩家通过邀请链接加入后会自动以玩家身份连接。
|
||||||
|
|
||||||
|
## 消息类型
|
||||||
|
|
||||||
|
Journal 支持多种消息类型,通过输入框上方的标签切换:
|
||||||
|
|
||||||
|
- **叙述**:普通的文字描述,用于推进剧情
|
||||||
|
- **掷骰**:发送掷骰结果,可附带公式
|
||||||
|
- **表单**:创建动态表单,收集玩家选择或输入
|
||||||
|
|
||||||
|
## 动态表单
|
||||||
|
|
||||||
|
表单消息允许你创建交互式问卷:
|
||||||
|
|
||||||
|
- 添加文本输入、选择框、复选框等字段
|
||||||
|
- 玩家提交后,GM 可以看到汇总结果
|
||||||
|
- 适用于投票、决策、角色创建等场景
|
||||||
|
|
||||||
|
## 撤回消息
|
||||||
|
|
||||||
|
GM 可以撤回自己发送的最新消息,点击消息旁的撤回按钮即可。
|
||||||
|
|
||||||
|
## 连接状态
|
||||||
|
|
||||||
|
- 🟢 绿色:已连接
|
||||||
|
- 🟡 黄色闪烁:连接中
|
||||||
|
- 🔴 红色:连接错误
|
||||||
|
- ⚪ 灰色:未连接
|
||||||
|
|
||||||
|
断开连接可点击标题栏的 ⏻ 按钮。
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
---
|
||||||
|
tag: journal-player
|
||||||
|
icon: 🎲
|
||||||
|
title: 玩家指南
|
||||||
|
description: 作为玩家加入 GM 的会话,查看消息、提交表单、参与互动。
|
||||||
|
syntax: '通过 GM 发送的邀请链接加入'
|
||||||
|
props:
|
||||||
|
- name: 加入方式
|
||||||
|
type: —
|
||||||
|
desc: 点击邀请链接自动加入,或手动输入名字和角色
|
||||||
|
- name: 查看消息
|
||||||
|
type: —
|
||||||
|
desc: 实时查看 GM 和其他玩家的消息
|
||||||
|
- name: 提交表单
|
||||||
|
type: —
|
||||||
|
desc: 填写 GM 发送的动态表单并提交
|
||||||
|
---
|
||||||
|
|
||||||
|
## 加入会话
|
||||||
|
|
||||||
|
有两种方式加入 GM 的会话:
|
||||||
|
|
||||||
|
### 通过邀请链接(推荐)
|
||||||
|
|
||||||
|
GM 会发送一个包含 `?session=xxx&player=你的名字&autojoin=1` 的链接。
|
||||||
|
点击链接即可自动加入,无需手动输入任何信息。
|
||||||
|
|
||||||
|
### 手动加入
|
||||||
|
|
||||||
|
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
|
||||||
|
2. 输入你的名字
|
||||||
|
3. 选择"玩家"角色
|
||||||
|
4. 点击"加入"
|
||||||
|
|
||||||
|
## 查看消息
|
||||||
|
|
||||||
|
连接成功后,你可以在面板中看到:
|
||||||
|
|
||||||
|
- GM 发送的叙述消息
|
||||||
|
- 掷骰结果
|
||||||
|
- 其他玩家的消息
|
||||||
|
- 动态表单
|
||||||
|
|
||||||
|
消息实时更新,无需刷新页面。
|
||||||
|
|
||||||
|
## 填写表单
|
||||||
|
|
||||||
|
当 GM 发送动态表单时:
|
||||||
|
|
||||||
|
1. 表单会显示在消息流中
|
||||||
|
2. 根据表单类型填写(文本、选择、复选框等)
|
||||||
|
3. 点击提交按钮发送你的回答
|
||||||
|
|
||||||
|
GM 可以看到所有玩家的提交结果。
|
||||||
|
|
||||||
|
## 角色标识
|
||||||
|
|
||||||
|
- 你的名字旁会显示蓝色"player"标签
|
||||||
|
- 观察者显示灰色"observer"标签
|
||||||
|
- GM 不显示角色标签
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 玩家无法创建或切换会话
|
||||||
|
- 玩家无法撤回消息
|
||||||
|
- 断开连接后重新加入会保留之前的消息记录
|
||||||
|
|
@ -28,7 +28,7 @@ function tableToCSV(headers: string[], rows: string[][]): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spark table: first column header is a pure dice formula (d6, d20, etc.) */
|
/** Spark table: first column header is a pure dice formula (d6, d20, etc.) */
|
||||||
const SPARK_DICE_RE = /^d\d+$/i;
|
const SPARK_DICE_RE = /^\d*d\d+$/i;
|
||||||
|
|
||||||
export default function markedTable(): MarkedExtension {
|
export default function markedTable(): MarkedExtension {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue