diff --git a/src/App.tsx b/src/App.tsx index 6b6c419..7df0131 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,7 @@ import { Component, createEffect, createMemo, createSignal } from "solid-js"; import { useLocation } from "@solidjs/router"; -import { - addRevealedClasses, - useJournalStream, -} from "./components/stores/journalStream"; +import { useJournalStream } from "./components/stores/journalStream"; +import { addRevealedClasses } from "./components/stores/reveal"; // 导入组件以注册自定义元素 import "./components"; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 39c58a1..5e9ee00 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,8 +2,7 @@ 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/journalStream"; -import { createEffect } from "solid-js"; +import { isPathRevealed } from "./stores/reveal"; export interface SidebarProps { isOpen: boolean; diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index bc37075..2864a10 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -21,6 +21,7 @@ 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"; @@ -88,6 +89,16 @@ 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() { diff --git a/src/components/stores/journalStream.ts b/src/components/stores/journalStream.ts index c4716f0..edebd61 100644 --- a/src/components/stores/journalStream.ts +++ b/src/components/stores/journalStream.ts @@ -15,6 +15,16 @@ 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 @@ -61,32 +71,6 @@ export interface SessionManifest { sessions: Record; } -// --------------------------------------------------------------------------- -// 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 // --------------------------------------------------------------------------- @@ -132,9 +116,7 @@ export { sessionList as sessions }; */ export function setMyName(name: string): void { setState("myName", name); - if (typeof localStorage !== "undefined") { - localStorage.setItem(LS_PLAYER_NAME, name); - } + saveName(name); syncUrlParam("player", name); } @@ -144,9 +126,7 @@ export function setMyName(name: string): void { */ export function setMyRole(role: "gm" | "player" | "observer"): void { setState("myRole", role); - if (typeof localStorage !== "undefined") { - localStorage.setItem(LS_PLAYER_ROLE, role); - } + saveRole(role); } /** @@ -155,10 +135,8 @@ 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(); @@ -170,41 +148,6 @@ 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; @@ -307,10 +250,8 @@ export async function connectStream( setState("brokerUrl", brokerUrl); // Persist connection info for next time - if (typeof localStorage !== "undefined") { - localStorage.setItem(LS_BROKER_URL, brokerUrl); - localStorage.setItem(LS_LAST_SESSION, sessionId); - } + saveBrokerUrl(brokerUrl); + saveSessionId(sessionId); client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => { if (err) console.error("[stream] stream sub err:", err); @@ -606,129 +547,6 @@ export function visibleMessages(): StreamMessage[] { return state.messages.filter((m) => !m.reverted); } -/** - * 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 { - 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; -} - -/** - * 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) { - if (!state.connected) return; - if (state.myRole === "gm") return; - - const normalized = normalizePath(path); - const revealed = state.revealedPaths[normalized]; - if (!revealed) return; - - const HEADING_TAGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]); - - // ---- Pass 1: collect heading hierarchy ---- - interface HeadingEntry { - el: Element; - level: number; - text: string; - /** Texts of all ancestor headings (shallowest first) */ - parents: string[]; - } - const headings: HeadingEntry[] = []; - const cur: Record = {}; - - 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); - - // ---- Cascade: directly revealed headings propagate ---- - // 1. directly revealed headings - const revealedSet = new Set(revealed); - - // 2. Downward: all children of directly revealed headings - // let changed = true; - // while (changed) { - // changed = false; - // for (const h of headings) { - // if (revealedSet.has(h.text)) continue; - // if (h.parents.some((p) => revealedSet.has(p))) { - // revealedSet.add(h.text); - // changed = true; - // } - // } - // } - - // 3. Upward: all parents of directly revealed headings - for (const h of headings) { - if (revealed.has(h.text)) { - for (const p of h.parents) revealedSet.add(p); - } - } - - // ---- Pass 2: apply classes using the cascaded set ---- - 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); -} - -/** Normalize a path for lookup: strip .md, leading ./ or / */ -function normalizePath(p: string): string { - return p.replace(/^\.?\//, "").replace(/\.md$/, ""); -} - export function useJournalStream() { return state; } diff --git a/src/components/stores/persistence.ts b/src/components/stores/persistence.ts new file mode 100644 index 0000000..46060a9 --- /dev/null +++ b/src/components/stores/persistence.ts @@ -0,0 +1,102 @@ +/** + * 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"), + }; +} diff --git a/src/components/stores/reveal.ts b/src/components/stores/reveal.ts new file mode 100644 index 0000000..08955a9 --- /dev/null +++ b/src/components/stores/reveal.ts @@ -0,0 +1,200 @@ +/** + * 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(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 = + '' + + '' + + '' + + ""; + +// ---- Non-GM helpers ---- + +function collectHeadings(root: Element): HeadingEntry[] { + const headings: HeadingEntry[] = []; + const cur: Record = {}; + + 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, +): Set { + const set = new Set(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) { + 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); +}