Compare commits

..

No commits in common. "3690d13407b46956bbd31450c5a3f2bc25406772" and "7cfc0fd4f3ea5097d98c751e06732edc64da4098" have entirely different histories.

11 changed files with 93 additions and 437 deletions

View File

@ -1,7 +1,6 @@
import { Component, createEffect, createMemo, createSignal } from "solid-js";
import { useLocation } from "@solidjs/router";
import { useJournalStream } from "./components/stores/journalStream";
import { addRevealedClasses } from "./components/stores/reveal";
// 导入组件以注册自定义元素
import "./components";
@ -124,7 +123,6 @@ const App: Component = () => {
<Article
class="prose text-black prose-sm max-w-full flex-1"
src={currentPath()}
onDom={(dom) => addRevealedClasses(dom, location.pathname)}
/>
</main>
</div>

View File

@ -21,7 +21,6 @@ export interface ArticleProps {
onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制
scrollToHash?: boolean; // 是否自动滚动到 hash
onDom?: (dom: HTMLDivElement) => void;
}
async function fetchArticleContent(params: {
@ -65,7 +64,6 @@ export const Article: Component<ArticleProps> = (props) => {
() => ({ src: props.src, section: props.section }),
fetchArticleContent,
);
let innerDiv!: HTMLDivElement;
// 解析 iconPath默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => {
@ -82,8 +80,6 @@ export const Article: Component<ArticleProps> = (props) => {
// 内容渲染后检查 hash 并滚动
scrollToHash(location.hash);
props.onDom?.(innerDiv);
}
});
@ -105,7 +101,6 @@ export const Article: Component<ArticleProps> = (props) => {
<Show when={!content.loading && !content.error && content()}>
<div
class="relative"
ref={innerDiv}
innerHTML={parseMarkdown(content()!, iconPrefix())}
/>
</Show>

View File

@ -20,7 +20,6 @@ export const FileTreeNode: Component<{
pathHeadings: Record<string, TocNode[]>;
depth: number;
onClose: () => void;
isHidden?: (node: FileNode) => boolean;
}> = (props) => {
const navigate = useNavigate();
const isDir = !!props.node.children;
@ -42,7 +41,7 @@ export const FileTreeNode: Component<{
const indent = props.depth * 12;
return (
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<div>
<div
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
@ -67,7 +66,6 @@ export const FileTreeNode: Component<{
pathHeadings={props.pathHeadings}
depth={props.depth + 1}
onClose={props.onClose}
isHidden={props.isHidden}
/>
))}
</div>
@ -83,7 +81,6 @@ export const HeadingNode: Component<{
node: TocNode;
basePath: string;
depth: number;
isHidden?: (node: TocNode) => boolean;
}> = (props) => {
const navigate = useNavigate();
const anchor = props.node.id || "";
@ -115,7 +112,7 @@ export const HeadingNode: Component<{
const indent = props.depth * 12;
return (
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<div>
<div class="flex flex-row">
<span
class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
@ -143,7 +140,6 @@ export const HeadingNode: Component<{
node={child}
basePath={props.basePath}
depth={props.depth + 1}
isHidden={props.isHidden}
/>
))}
</div>

View File

@ -2,7 +2,6 @@ import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
import { generateToc, type FileNode, type TocNode } from "../data-loader";
import { useLocation } from "@solidjs/router";
import { FileTreeNode, HeadingNode } from "./FileTree";
import { isPathRevealed } from "./stores/reveal";
export interface SidebarProps {
isOpen: boolean;
@ -34,17 +33,6 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
);
});
const isFileHidden = (node: FileNode): boolean => {
if (isPathRevealed(node.path)) return false;
if (node.children?.some((child) => !isFileHidden(child))) return false;
return true;
};
const isHeadingHidden = (node: TocNode): boolean => {
const pathname = decodeURIComponent(location.pathname);
if (isPathRevealed(pathname, node.id ?? node.title)) return false;
if (node.children?.some((child) => !isHeadingHidden(child))) return false;
return true;
};
return (
<div class="flex flex-col h-full">
@ -84,7 +72,6 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
pathHeadings={props.pathHeadings}
depth={0}
onClose={props.onClose}
isHidden={isFileHidden}
/>
))}
</div>
@ -96,12 +83,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
</h3>
{currentFileHeadings().map((node) => (
<HeadingNode
node={node}
basePath={location.pathname}
depth={0}
isHidden={isHeadingHidden}
/>
<HeadingNode node={node} basePath={location.pathname} depth={0} />
))}
</div>
</Show>

View File

@ -21,7 +21,6 @@ import {
Match,
} from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import { linkPrefill, setLinkPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
@ -89,16 +88,6 @@ export const JournalInput: Component = () => {
void ensureCompletions();
});
// Listen for /link prefill requests from article headings
createEffect(() => {
const prefilled = linkPrefill();
if (prefilled) {
setText(prefilled);
setLinkPrefill(null);
textareaRef?.focus();
}
});
// ---- Send ----
function handleSend() {

View File

@ -83,16 +83,7 @@ registerMessageType<LinkPayload>({
reducer: (p) => {
journalSetState(
produce((s) => {
const key = p.path.replace(/^\.?\//, "").replace(/\.md$/, "");
if (!s.revealedPaths[key]) {
s.revealedPaths[key] = new Set();
}
if (p.section) {
s.revealedPaths[key].add(p.section);
} else {
// No section — reveal the whole article
s.revealedPaths[key].clear();
}
s.revealedPaths.add(p.path);
}),
);
},

View File

@ -15,16 +15,6 @@ import { createStore, produce } from "solid-js/store";
import { createSignal } from "solid-js";
import type { StreamMessage } from "../journal/registry";
import { getMessageType, validatePayload } from "../journal/registry";
import {
loadPersisted,
saveName,
saveRole,
saveSessionId,
saveBrokerUrl,
syncUrlParam,
removeUrlParam,
readUrlParams,
} from "./persistence";
// ---------------------------------------------------------------------------
// Types
@ -39,12 +29,10 @@ export interface JournalStreamState {
/** Last sequence number per sender */
senderSeq: Record<string, number>;
/**
* Paths and sections revealed by link messages.
* Key: normalized path (no .md). Value: set of revealed section slugs.
* An empty set means the whole article is revealed.
* Paths revealed by link messages.
* Populated during hydration and live receipt via the type's reducer.
*/
revealedPaths: Record<string, Set<string>>;
revealedPaths: Set<string>;
/** MQTT connection status */
connected: boolean;
/** Granular connection state for UI indicators */
@ -71,6 +59,32 @@ export interface SessionManifest {
sessions: Record<string, SessionMeta>;
}
// ---------------------------------------------------------------------------
// localStorage keys
// ---------------------------------------------------------------------------
const LS_PLAYER_NAME = "ttrpg.playerName";
const LS_BROKER_URL = "ttrpg.brokerUrl";
const LS_LAST_SESSION = "ttrpg.lastSessionId";
const LS_PLAYER_ROLE = "ttrpg.playerRole";
function loadPersisted(): {
myName: string;
brokerUrl: string | null;
lastSessionId: string | null;
myRole: string;
} {
if (typeof localStorage === "undefined") {
return { myName: "gm", brokerUrl: null, lastSessionId: null, myRole: "gm" };
}
return {
myName: localStorage.getItem(LS_PLAYER_NAME) || "gm",
brokerUrl: localStorage.getItem(LS_BROKER_URL),
lastSessionId: localStorage.getItem(LS_LAST_SESSION),
myRole: localStorage.getItem(LS_PLAYER_ROLE) || "gm",
};
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
@ -87,7 +101,7 @@ const [state, setState] = createStore<JournalStreamState>({
sessionName: null,
messages: [],
senderSeq: {},
revealedPaths: {},
revealedPaths: new Set(),
connected: false,
connectionStatus: "disconnected",
connectionError: null,
@ -116,7 +130,9 @@ export { sessionList as sessions };
*/
export function setMyName(name: string): void {
setState("myName", name);
saveName(name);
if (typeof localStorage !== "undefined") {
localStorage.setItem(LS_PLAYER_NAME, name);
}
syncUrlParam("player", name);
}
@ -126,7 +142,9 @@ export function setMyName(name: string): void {
*/
export function setMyRole(role: "gm" | "player" | "observer"): void {
setState("myRole", role);
saveRole(role);
if (typeof localStorage !== "undefined") {
localStorage.setItem(LS_PLAYER_ROLE, role);
}
}
/**
@ -135,8 +153,10 @@ export function setMyRole(role: "gm" | "player" | "observer"): void {
*/
export function setSessionId(id: string | null): void {
setState("sessionId", id);
if (typeof localStorage !== "undefined" && id) {
localStorage.setItem(LS_LAST_SESSION, id);
}
if (id) {
saveSessionId(id);
syncUrlParam("session", id);
// Resolve session name from current manifest
const manifest = sessionList();
@ -148,6 +168,41 @@ export function setSessionId(id: string | null): void {
}
}
// ---------------------------------------------------------------------------
// URL param sync (session & player for bookmarkable links)
// ---------------------------------------------------------------------------
function syncUrlParam(key: string, value: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(key, value);
window.history.replaceState(null, "", url.toString());
}
function removeUrlParam(key: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState(null, "", url.toString());
}
/**
* Read initial session/player from URL search params on store init.
* Called once at module load time.
*/
function readUrlParams(): {
sessionId: string | null;
playerName: string | null;
} {
if (typeof window === "undefined")
return { sessionId: null, playerName: null };
const params = new URL(window.location.href).searchParams;
return {
sessionId: params.get("session"),
playerName: params.get("player"),
};
}
// Will hold the MQTT client instance after connect()
let _mqttClient: import("mqtt").MqttClient | null = null;
let _mqttConnected = false;
@ -250,8 +305,10 @@ export async function connectStream(
setState("brokerUrl", brokerUrl);
// Persist connection info for next time
saveBrokerUrl(brokerUrl);
saveSessionId(sessionId);
if (typeof localStorage !== "undefined") {
localStorage.setItem(LS_BROKER_URL, brokerUrl);
localStorage.setItem(LS_LAST_SESSION, sessionId);
}
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[stream] stream sub err:", err);

View File

@ -1,102 +0,0 @@
/**
* Journal Stream client-side persistence (localStorage + URL params).
*
* Survives page reloads so the user doesn't have to re-enter their name,
* role, session, or broker URL on every visit. URL search params take
* precedence over localStorage for bookmarkable invite links.
*/
// ---------------------------------------------------------------------------
// localStorage keys
// ---------------------------------------------------------------------------
const LS_PLAYER_NAME = "ttrpg.playerName";
const LS_BROKER_URL = "ttrpg.brokerUrl";
const LS_LAST_SESSION = "ttrpg.lastSessionId";
const LS_PLAYER_ROLE = "ttrpg.playerRole";
// ---------------------------------------------------------------------------
// Safe localStorage helpers
// ---------------------------------------------------------------------------
function hasStorage(): boolean {
return typeof localStorage !== "undefined";
}
// ---------------------------------------------------------------------------
// Load
// ---------------------------------------------------------------------------
export interface PersistedState {
myName: string;
brokerUrl: string | null;
lastSessionId: string | null;
myRole: string;
}
export function loadPersisted(): PersistedState {
if (!hasStorage()) {
return { myName: "gm", brokerUrl: null, lastSessionId: null, myRole: "gm" };
}
return {
myName: localStorage.getItem(LS_PLAYER_NAME) || "gm",
brokerUrl: localStorage.getItem(LS_BROKER_URL),
lastSessionId: localStorage.getItem(LS_LAST_SESSION),
myRole: localStorage.getItem(LS_PLAYER_ROLE) || "gm",
};
}
// ---------------------------------------------------------------------------
// Save
// ---------------------------------------------------------------------------
export function saveName(name: string): void {
if (hasStorage()) localStorage.setItem(LS_PLAYER_NAME, name);
}
export function saveRole(role: string): void {
if (hasStorage()) localStorage.setItem(LS_PLAYER_ROLE, role);
}
export function saveSessionId(id: string): void {
if (hasStorage()) localStorage.setItem(LS_LAST_SESSION, id);
}
export function saveBrokerUrl(url: string): void {
if (hasStorage()) localStorage.setItem(LS_BROKER_URL, url);
}
// ---------------------------------------------------------------------------
// URL params (bookmarkable/persistable "session" and "player" params)
// ---------------------------------------------------------------------------
export function syncUrlParam(key: string, value: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set(key, value);
window.history.replaceState(null, "", url.toString());
}
export function removeUrlParam(key: string): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState(null, "", url.toString());
}
/**
* Read initial session/player from URL search params on store init.
* Called once at module load time.
*/
export function readUrlParams(): {
sessionId: string | null;
playerName: string | null;
} {
if (typeof window === "undefined")
return { sessionId: null, playerName: null };
const params = new URL(window.location.href).searchParams;
return {
sessionId: params.get("session"),
playerName: params.get("player"),
};
}

View File

@ -1,200 +0,0 @@
/**
* DOM reveal logic applies revealed/concealed classes to article headings
* for non-GM clients, and injects "send to stream" link buttons for GM.
*
* All state is read from the journal stream store via `journalStreamState`.
*/
import { createSignal } from "solid-js";
import { journalStreamState } from "./journalStream";
const HEADING_TAGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
/** Normalize a path for lookup: strip .md, leading ./ or / */
export function normalizePath(p: string): string {
return p.replace(/^\.?\//, "").replace(/\.md$/i, "");
}
// ---------------------------------------------------------------------------
// isPathRevealed
// ---------------------------------------------------------------------------
/**
* Check whether a path (and optionally section) is revealed.
* GM and disconnected clients see everything.
* Non-GM connected clients only see explicitly revealed content.
*/
export function isPathRevealed(path: string, section?: string): boolean {
const state = journalStreamState;
if (!state.connected) return true;
if (state.myRole === "gm") return true;
const normalized = normalizePath(path);
const revealed = state.revealedPaths[normalized];
if (!revealed) return false;
// Whole article revealed (empty set)
if (revealed.size === 0) return true;
// Section-specific check
if (section) return revealed.has(section);
// No section asked — at least some sections are revealed, article is partially visible
return revealed.size > 0;
}
// ---------------------------------------------------------------------------
// linkPrefill signal — bridges article heading buttons → JournalInput
// ---------------------------------------------------------------------------
/** Shared signal: when set, JournalInput prefills the textarea with this value. */
export const [linkPrefill, setLinkPrefill] = createSignal<string | null>(null);
// ---------------------------------------------------------------------------
// addRevealedClasses — GM injects buttons; non-GM applies revealed/concealed
// ---------------------------------------------------------------------------
interface HeadingEntry {
el: Element;
level: number;
text: string;
/** Texts of all ancestor headings (shallowest first) */
parents: string[];
}
/**
* Walk every element in `root` and tag it as revealed or concealed.
*
* Two-pass approach:
* 1. Collect all headings with their ancestor chain.
* 2. Cascade revealed status upward if a heading is revealed, all its
* parent headings are marked revealed as well.
* 3. Walk the tree again, applying `revealed` or `concealed` classes to
* every element (headings included) based on whether any current
* heading ancestor is in the cascaded revealed set.
*/
export function addRevealedClasses(root: HTMLDivElement, path: string) {
const state = journalStreamState;
if (!state.connected) return;
const normalized = normalizePath(path);
// ---- GM mode: inject "send to stream" buttons on headings ----
if (state.myRole === "gm") {
injectSendButtons(root, normalized);
return;
}
// ---- Non-GM mode: apply revealed/concealed classes ----
const revealed = state.revealedPaths[normalized];
if (!revealed) return;
const headings = collectHeadings(root);
const revealedSet = cascadeRevealed(headings, revealed);
applyClasses(root, revealedSet);
}
// ---- GM helpers ----
function injectSendButtons(root: Element, normalizedPath: string) {
const walk = (el: Element) => {
const tag = el.tagName.toUpperCase();
if (HEADING_TAGS.has(tag)) {
const headingText = el.id || el.textContent?.trim() || "";
if (headingText) {
const btn = createSendButton(normalizedPath, headingText);
el.insertBefore(btn, el.firstChild);
(el as HTMLElement).classList.add("group", "flex", "items-center");
}
}
for (const child of el.children) walk(child);
};
for (const child of root.children) walk(child);
}
function createSendButton(path: string, headingId: string): HTMLButtonElement {
const btn = document.createElement("button");
btn.className =
"inline-flex items-center justify-center w-5 h-5 mr-1 -ml-6 " +
"text-gray-300 hover:text-blue-500 hover:bg-blue-50 rounded " +
"transition-colors align-middle opacity-0 group-hover:opacity-100 focus:opacity-100";
btn.title = "Send /link to stream";
btn.innerHTML = LINK_SVG;
btn.addEventListener("click", (e) => {
e.stopPropagation();
setLinkPrefill(`/link ${path}#${headingId}`);
});
return btn;
}
const LINK_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>' +
'<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>' +
"</svg>";
// ---- Non-GM helpers ----
function collectHeadings(root: Element): HeadingEntry[] {
const headings: HeadingEntry[] = [];
const cur: Record<number, string> = {};
const collect = (el: Element) => {
const tag = el.tagName.toUpperCase();
if (HEADING_TAGS.has(tag)) {
const level = Number(tag.charAt(1));
const text = el.id || el.textContent?.trim() || "";
const parents: string[] = [];
for (let l = 1; l < level; l++) {
if (cur[l]) parents.push(cur[l]);
}
headings.push({ el, level, text, parents });
cur[level] = text;
for (let l = level + 1; l <= 6; l++) delete cur[l];
}
for (const child of el.children) collect(child);
};
for (const child of root.children) collect(child);
return headings;
}
function cascadeRevealed(
headings: HeadingEntry[],
revealed: Set<string>,
): Set<string> {
const set = new Set<string>(revealed);
// Upward: all parents of directly revealed headings
for (const h of headings) {
if (revealed.has(h.text)) {
for (const p of h.parents) set.add(p);
}
}
return set;
}
function applyClasses(root: Element, revealedSet: Set<string>) {
const stack: string[] = [];
const apply = (el: Element) => {
let pushed = false;
for (const child of el.children) {
const tag = child.tagName.toUpperCase();
if (HEADING_TAGS.has(tag)) {
if (pushed) stack.pop();
const text = child.id || child.textContent?.trim() || "";
stack.push(text);
pushed = true;
}
const current = stack.length > 0 ? stack[stack.length - 1] : null;
const isRevealed = current !== null && revealedSet.has(current);
child.classList.add(isRevealed ? "revealed" : "concealed");
apply(child);
}
if (pushed) stack.pop();
};
apply(root);
}

View File

@ -8,10 +8,6 @@ export interface TocNode {
id?: string;
path?: string;
level: number;
/** 1-based line index where the heading starts */
startLine: number;
/** 1-based line index where this heading's section ends (exclusive) */
endLine: number;
children?: TocNode[];
}
@ -25,58 +21,26 @@ export interface FileNode {
}
/**
* markdown
* markdown
*/
export function extractHeadings(content: string): TocNode[] {
const headings: TocNode[] = [];
const lines = content.split("\n");
const stack: { node: TocNode; level: number }[] = [];
const slugger = new Slugger();
const totalLines = lines.length;
// First pass: collect heading positions
interface HeadingPos {
lineIndex: number; // 0-based line index
level: number;
title: string;
id: string;
}
const positions: HeadingPos[] = [];
for (let i = 0; i < totalLines; i++) {
const match = lines[i].trim().match(/^(#{1,6})\s+(.+)$/);
for (const line of lines) {
const match = line.trim().match(/^(#{1,6})\s+(.+)$/);
if (!match) continue;
const level = match[1].length;
const title = match[2].trim();
// 使用 github-slugger 生成 ID与 marked-gfm-heading-id 保持一致
const id = slugger.slug(title.toLowerCase());
positions.push({ lineIndex: i, level, title, id });
}
// Compute endLine for each heading: endLine is the 1-based line index of
// the next heading of same or higher level (or EOF+1).
for (let pi = 0; pi < positions.length; pi++) {
const pos = positions[pi];
let endLine = totalLines + 1; // default: end of file (1-based, exclusive)
for (let pj = pi + 1; pj < positions.length; pj++) {
if (positions[pj].level <= pos.level) {
endLine = positions[pj].lineIndex + 1; // 1-based
break;
}
}
const node: TocNode = {
title: pos.title,
id: pos.id,
level: pos.level,
startLine: pos.lineIndex + 1,
endLine,
};
const node: TocNode = { title, id, level };
// 找到合适的父节点
while (stack.length > 0 && stack[stack.length - 1].level >= node.level) {
// Close out the current parent's endLine at this heading's start
stack[stack.length - 1].node.endLine = node.startLine;
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
@ -88,13 +52,7 @@ export function extractHeadings(content: string): TocNode[] {
parent.children.push(node);
}
stack.push({ node, level: node.level });
}
// Close remaining stack entries at EOF
while (stack.length > 0) {
stack[stack.length - 1].node.endLine = totalLines + 1;
stack.pop();
stack.push({ node, level });
}
return headings;
@ -178,7 +136,7 @@ export function extractSection(content: string, sectionTitle: string): string {
// 匹配标题(支持 1-6 级标题)
const sectionRegex = new RegExp(
`^(#{1,6})\\s*${escapeRegExp(sectionTitle)}\\s*$`,
"im",
"im"
);
const match = content.match(sectionRegex);
@ -191,7 +149,10 @@ export function extractSection(content: string, sectionTitle: string): string {
const startIndex = match.index!;
// 查找下一个同级或更高级别的标题
const nextHeaderRegex = new RegExp(`^#{1,${headerLevel}}\\s+.+$`, "gm");
const nextHeaderRegex = new RegExp(
`^#{1,${headerLevel}}\\s+.+$`,
"gm"
);
// 从标题后开始搜索
nextHeaderRegex.lastIndex = startIndex + match[0].length;

View File

@ -138,14 +138,3 @@ icon.big .icon-label-stroke {
.col-5 {
@apply lg:flex-5;
}
/* concealed / revealed */
.concealed {
opacity: 0;
}
.revealed,
.concealed .revealed {
opacity: unset;
}