refactor: extract persistence and reveal logic into new stores
Move localStorage and URL parameter management from `journalStream` to a dedicated `persistence` store. Relocate DOM reveal and heading injection logic to a new `reveal` store to improve separation of concerns.
This commit is contained in:
parent
ef7295ff7a
commit
3690d13407
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -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<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);
|
||||
|
||||
// ---- Cascade: directly revealed headings propagate ----
|
||||
// 1. directly revealed headings
|
||||
const revealedSet = new Set<string>(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue