Compare commits
6 Commits
7cfc0fd4f3
...
3690d13407
| Author | SHA1 | Date |
|---|---|---|
|
|
3690d13407 | |
|
|
ef7295ff7a | |
|
|
574d17f201 | |
|
|
ced9a4f8f2 | |
|
|
adf28a7cf1 | |
|
|
c5e1167beb |
|
|
@ -1,6 +1,7 @@
|
||||||
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
import { useJournalStream } from "./components/stores/journalStream";
|
||||||
|
import { addRevealedClasses } from "./components/stores/reveal";
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
import "./components";
|
import "./components";
|
||||||
|
|
@ -123,6 +124,7 @@ const App: Component = () => {
|
||||||
<Article
|
<Article
|
||||||
class="prose text-black prose-sm max-w-full flex-1"
|
class="prose text-black prose-sm max-w-full flex-1"
|
||||||
src={currentPath()}
|
src={currentPath()}
|
||||||
|
onDom={(dom) => addRevealedClasses(dom, location.pathname)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface ArticleProps {
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
class?: string; // 额外的 class 用于样式控制
|
class?: string; // 额外的 class 用于样式控制
|
||||||
scrollToHash?: boolean; // 是否自动滚动到 hash
|
scrollToHash?: boolean; // 是否自动滚动到 hash
|
||||||
|
onDom?: (dom: HTMLDivElement) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchArticleContent(params: {
|
async function fetchArticleContent(params: {
|
||||||
|
|
@ -64,6 +65,7 @@ export const Article: Component<ArticleProps> = (props) => {
|
||||||
() => ({ src: props.src, section: props.section }),
|
() => ({ src: props.src, section: props.section }),
|
||||||
fetchArticleContent,
|
fetchArticleContent,
|
||||||
);
|
);
|
||||||
|
let innerDiv!: HTMLDivElement;
|
||||||
|
|
||||||
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
||||||
const iconPrefix = createMemo(() => {
|
const iconPrefix = createMemo(() => {
|
||||||
|
|
@ -80,6 +82,8 @@ export const Article: Component<ArticleProps> = (props) => {
|
||||||
|
|
||||||
// 内容渲染后检查 hash 并滚动
|
// 内容渲染后检查 hash 并滚动
|
||||||
scrollToHash(location.hash);
|
scrollToHash(location.hash);
|
||||||
|
|
||||||
|
props.onDom?.(innerDiv);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -101,6 +105,7 @@ export const Article: Component<ArticleProps> = (props) => {
|
||||||
<Show when={!content.loading && !content.error && content()}>
|
<Show when={!content.loading && !content.error && content()}>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
|
ref={innerDiv}
|
||||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export const FileTreeNode: Component<{
|
||||||
pathHeadings: Record<string, TocNode[]>;
|
pathHeadings: Record<string, TocNode[]>;
|
||||||
depth: number;
|
depth: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
isHidden?: (node: FileNode) => boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isDir = !!props.node.children;
|
const isDir = !!props.node.children;
|
||||||
|
|
@ -41,7 +42,7 @@ export const FileTreeNode: Component<{
|
||||||
const indent = props.depth * 12;
|
const indent = props.depth * 12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 ${
|
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"
|
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||||
|
|
@ -66,6 +67,7 @@ export const FileTreeNode: Component<{
|
||||||
pathHeadings={props.pathHeadings}
|
pathHeadings={props.pathHeadings}
|
||||||
depth={props.depth + 1}
|
depth={props.depth + 1}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
isHidden={props.isHidden}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,6 +83,7 @@ export const HeadingNode: Component<{
|
||||||
node: TocNode;
|
node: TocNode;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
|
isHidden?: (node: TocNode) => boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const anchor = props.node.id || "";
|
const anchor = props.node.id || "";
|
||||||
|
|
@ -112,7 +115,7 @@ export const HeadingNode: Component<{
|
||||||
const indent = props.depth * 12;
|
const indent = props.depth * 12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
|
||||||
<div class="flex flex-row">
|
<div class="flex flex-row">
|
||||||
<span
|
<span
|
||||||
class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
|
class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
|
||||||
|
|
@ -140,6 +143,7 @@ export const HeadingNode: Component<{
|
||||||
node={child}
|
node={child}
|
||||||
basePath={props.basePath}
|
basePath={props.basePath}
|
||||||
depth={props.depth + 1}
|
depth={props.depth + 1}
|
||||||
|
isHidden={props.isHidden}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
|
||||||
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { FileTreeNode, HeadingNode } from "./FileTree";
|
import { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
|
import { isPathRevealed } from "./stores/reveal";
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -33,6 +34,17 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
|
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 (
|
return (
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
|
|
@ -72,6 +84,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
pathHeadings={props.pathHeadings}
|
pathHeadings={props.pathHeadings}
|
||||||
depth={0}
|
depth={0}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
|
isHidden={isFileHidden}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -83,7 +96,12 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
本页
|
本页
|
||||||
</h3>
|
</h3>
|
||||||
{currentFileHeadings().map((node) => (
|
{currentFileHeadings().map((node) => (
|
||||||
<HeadingNode node={node} basePath={location.pathname} depth={0} />
|
<HeadingNode
|
||||||
|
node={node}
|
||||||
|
basePath={location.pathname}
|
||||||
|
depth={0}
|
||||||
|
isHidden={isHeadingHidden}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
Match,
|
Match,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||||
|
import { linkPrefill, setLinkPrefill } from "../stores/reveal";
|
||||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
|
|
||||||
|
|
@ -88,6 +89,16 @@ export const JournalInput: Component = () => {
|
||||||
void ensureCompletions();
|
void ensureCompletions();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for /link prefill requests from article headings
|
||||||
|
createEffect(() => {
|
||||||
|
const prefilled = linkPrefill();
|
||||||
|
if (prefilled) {
|
||||||
|
setText(prefilled);
|
||||||
|
setLinkPrefill(null);
|
||||||
|
textareaRef?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Send ----
|
// ---- Send ----
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,16 @@ registerMessageType<LinkPayload>({
|
||||||
reducer: (p) => {
|
reducer: (p) => {
|
||||||
journalSetState(
|
journalSetState(
|
||||||
produce((s) => {
|
produce((s) => {
|
||||||
s.revealedPaths.add(p.path);
|
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();
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,16 @@ import { createStore, produce } from "solid-js/store";
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import type { StreamMessage } from "../journal/registry";
|
import type { StreamMessage } from "../journal/registry";
|
||||||
import { getMessageType, validatePayload } from "../journal/registry";
|
import { getMessageType, validatePayload } from "../journal/registry";
|
||||||
|
import {
|
||||||
|
loadPersisted,
|
||||||
|
saveName,
|
||||||
|
saveRole,
|
||||||
|
saveSessionId,
|
||||||
|
saveBrokerUrl,
|
||||||
|
syncUrlParam,
|
||||||
|
removeUrlParam,
|
||||||
|
readUrlParams,
|
||||||
|
} from "./persistence";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -29,10 +39,12 @@ export interface JournalStreamState {
|
||||||
/** Last sequence number per sender */
|
/** Last sequence number per sender */
|
||||||
senderSeq: Record<string, number>;
|
senderSeq: Record<string, number>;
|
||||||
/**
|
/**
|
||||||
* Paths revealed by link messages.
|
* 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.
|
||||||
* Populated during hydration and live receipt via the type's reducer.
|
* Populated during hydration and live receipt via the type's reducer.
|
||||||
*/
|
*/
|
||||||
revealedPaths: Set<string>;
|
revealedPaths: Record<string, Set<string>>;
|
||||||
/** MQTT connection status */
|
/** MQTT connection status */
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
/** Granular connection state for UI indicators */
|
/** Granular connection state for UI indicators */
|
||||||
|
|
@ -59,32 +71,6 @@ export interface SessionManifest {
|
||||||
sessions: Record<string, SessionMeta>;
|
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
|
// Store
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -101,7 +87,7 @@ const [state, setState] = createStore<JournalStreamState>({
|
||||||
sessionName: null,
|
sessionName: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
senderSeq: {},
|
senderSeq: {},
|
||||||
revealedPaths: new Set(),
|
revealedPaths: {},
|
||||||
connected: false,
|
connected: false,
|
||||||
connectionStatus: "disconnected",
|
connectionStatus: "disconnected",
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
|
|
@ -130,9 +116,7 @@ export { sessionList as sessions };
|
||||||
*/
|
*/
|
||||||
export function setMyName(name: string): void {
|
export function setMyName(name: string): void {
|
||||||
setState("myName", name);
|
setState("myName", name);
|
||||||
if (typeof localStorage !== "undefined") {
|
saveName(name);
|
||||||
localStorage.setItem(LS_PLAYER_NAME, name);
|
|
||||||
}
|
|
||||||
syncUrlParam("player", name);
|
syncUrlParam("player", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,9 +126,7 @@ export function setMyName(name: string): void {
|
||||||
*/
|
*/
|
||||||
export function setMyRole(role: "gm" | "player" | "observer"): void {
|
export function setMyRole(role: "gm" | "player" | "observer"): void {
|
||||||
setState("myRole", role);
|
setState("myRole", role);
|
||||||
if (typeof localStorage !== "undefined") {
|
saveRole(role);
|
||||||
localStorage.setItem(LS_PLAYER_ROLE, role);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -153,10 +135,8 @@ export function setMyRole(role: "gm" | "player" | "observer"): void {
|
||||||
*/
|
*/
|
||||||
export function setSessionId(id: string | null): void {
|
export function setSessionId(id: string | null): void {
|
||||||
setState("sessionId", id);
|
setState("sessionId", id);
|
||||||
if (typeof localStorage !== "undefined" && id) {
|
|
||||||
localStorage.setItem(LS_LAST_SESSION, id);
|
|
||||||
}
|
|
||||||
if (id) {
|
if (id) {
|
||||||
|
saveSessionId(id);
|
||||||
syncUrlParam("session", id);
|
syncUrlParam("session", id);
|
||||||
// Resolve session name from current manifest
|
// Resolve session name from current manifest
|
||||||
const manifest = sessionList();
|
const manifest = sessionList();
|
||||||
|
|
@ -168,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()
|
// Will hold the MQTT client instance after connect()
|
||||||
let _mqttClient: import("mqtt").MqttClient | null = null;
|
let _mqttClient: import("mqtt").MqttClient | null = null;
|
||||||
let _mqttConnected = false;
|
let _mqttConnected = false;
|
||||||
|
|
@ -305,10 +250,8 @@ export async function connectStream(
|
||||||
setState("brokerUrl", brokerUrl);
|
setState("brokerUrl", brokerUrl);
|
||||||
|
|
||||||
// Persist connection info for next time
|
// Persist connection info for next time
|
||||||
if (typeof localStorage !== "undefined") {
|
saveBrokerUrl(brokerUrl);
|
||||||
localStorage.setItem(LS_BROKER_URL, brokerUrl);
|
saveSessionId(sessionId);
|
||||||
localStorage.setItem(LS_LAST_SESSION, sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
|
||||||
if (err) console.error("[stream] stream sub err:", err);
|
if (err) console.error("[stream] stream sub err:", err);
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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<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);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,10 @@ export interface TocNode {
|
||||||
id?: string;
|
id?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
level: number;
|
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[];
|
children?: TocNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,26 +25,58 @@ export interface FileNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 markdown 内容提取标题结构
|
* 从 markdown 内容提取标题结构(含行号范围)
|
||||||
*/
|
*/
|
||||||
export function extractHeadings(content: string): TocNode[] {
|
export function extractHeadings(content: string): TocNode[] {
|
||||||
const headings: TocNode[] = [];
|
const headings: TocNode[] = [];
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
const stack: { node: TocNode; level: number }[] = [];
|
const stack: { node: TocNode; level: number }[] = [];
|
||||||
const slugger = new Slugger();
|
const slugger = new Slugger();
|
||||||
|
const totalLines = lines.length;
|
||||||
|
|
||||||
for (const line of lines) {
|
// First pass: collect heading positions
|
||||||
const match = line.trim().match(/^(#{1,6})\s+(.+)$/);
|
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+(.+)$/);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
|
|
||||||
const level = match[1].length;
|
const level = match[1].length;
|
||||||
const title = match[2].trim();
|
const title = match[2].trim();
|
||||||
// 使用 github-slugger 生成 ID,与 marked-gfm-heading-id 保持一致
|
// 使用 github-slugger 生成 ID,与 marked-gfm-heading-id 保持一致
|
||||||
const id = slugger.slug(title.toLowerCase());
|
const id = slugger.slug(title.toLowerCase());
|
||||||
const node: TocNode = { title, id, level };
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
// 找到合适的父节点
|
// 找到合适的父节点
|
||||||
while (stack.length > 0 && stack[stack.length - 1].level >= 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;
|
||||||
stack.pop();
|
stack.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +88,13 @@ export function extractHeadings(content: string): TocNode[] {
|
||||||
parent.children.push(node);
|
parent.children.push(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.push({ node, level });
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
return headings;
|
return headings;
|
||||||
|
|
@ -136,7 +178,7 @@ export function extractSection(content: string, sectionTitle: string): string {
|
||||||
// 匹配标题(支持 1-6 级标题)
|
// 匹配标题(支持 1-6 级标题)
|
||||||
const sectionRegex = new RegExp(
|
const sectionRegex = new RegExp(
|
||||||
`^(#{1,6})\\s*${escapeRegExp(sectionTitle)}\\s*$`,
|
`^(#{1,6})\\s*${escapeRegExp(sectionTitle)}\\s*$`,
|
||||||
"im"
|
"im",
|
||||||
);
|
);
|
||||||
|
|
||||||
const match = content.match(sectionRegex);
|
const match = content.match(sectionRegex);
|
||||||
|
|
@ -149,10 +191,7 @@ export function extractSection(content: string, sectionTitle: string): string {
|
||||||
const startIndex = match.index!;
|
const startIndex = match.index!;
|
||||||
|
|
||||||
// 查找下一个同级或更高级别的标题
|
// 查找下一个同级或更高级别的标题
|
||||||
const nextHeaderRegex = new RegExp(
|
const nextHeaderRegex = new RegExp(`^#{1,${headerLevel}}\\s+.+$`, "gm");
|
||||||
`^#{1,${headerLevel}}\\s+.+$`,
|
|
||||||
"gm"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 从标题后开始搜索
|
// 从标题后开始搜索
|
||||||
nextHeaderRegex.lastIndex = startIndex + match[0].length;
|
nextHeaderRegex.lastIndex = startIndex + match[0].length;
|
||||||
|
|
|
||||||
|
|
@ -138,3 +138,14 @@ icon.big .icon-label-stroke {
|
||||||
.col-5 {
|
.col-5 {
|
||||||
@apply lg:flex-5;
|
@apply lg:flex-5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* concealed / revealed */
|
||||||
|
|
||||||
|
.concealed {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revealed,
|
||||||
|
.concealed .revealed {
|
||||||
|
opacity: unset;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue