Compare commits

..

6 Commits

Author SHA1 Message Date
hypercross 3690d13407 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.
2026-07-07 18:10:43 +08:00
hypercross ef7295ff7a fix(sidebar): add correct node identity to heading set 2026-07-07 13:26:35 +08:00
hypercross 574d17f201 refactor: improve heading revelation logic
Rewrite `addRevealedClasses` to use a two-pass approach that correctly
cascades revealed status from child headings to parent headings. This
ensures that if a sub-heading is revealed, its parent headings are
also treated as revealed, maintaining proper visibility hierarchy.
2026-07-07 12:56:28 +08:00
hypercross ced9a4f8f2 feat: implement content reveal mechanism via DOM traversal
Add `addRevealedClasses` to apply `revealed` or `concealed` CSS classes
to article elements based on the current heading hierarchy and
revealed paths. Update `Article` to expose its DOM element via an
`onDom` callback.
2026-07-07 10:56:19 +08:00
hypercross adf28a7cf1 feat: implement visibility logic for sidebar nodes
Add support for hiding file tree nodes and headings based on path
revelation status. This allows the sidebar to dynamically show only
the relevant parts of the file structure and table of contents.
2026-07-07 09:27:04 +08:00
hypercross c5e1167beb feat: implement granular section visibility control
Introduce the ability to reveal specific sections of a document
rather than entire files. This includes:

- Updating `revealedPaths` to map normalized file paths to sets of
  revealed section slugs.
- Adding `isPathRevealed` to handle visibility logic for connected
  clients and GMs.
- Enhancing `extractHeadings` to calculate `startLine` and `endLine`
  for each TOC node to support precise section identification.
2026-07-07 07:44:57 +08:00
11 changed files with 437 additions and 93 deletions

View File

@ -1,6 +1,7 @@
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";
@ -123,6 +124,7 @@ 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,6 +21,7 @@ export interface ArticleProps {
onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制
scrollToHash?: boolean; // 是否自动滚动到 hash
onDom?: (dom: HTMLDivElement) => void;
}
async function fetchArticleContent(params: {
@ -64,6 +65,7 @@ export const Article: Component<ArticleProps> = (props) => {
() => ({ src: props.src, section: props.section }),
fetchArticleContent,
);
let innerDiv!: HTMLDivElement;
// 解析 iconPath默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => {
@ -80,6 +82,8 @@ export const Article: Component<ArticleProps> = (props) => {
// 内容渲染后检查 hash 并滚动
scrollToHash(location.hash);
props.onDom?.(innerDiv);
}
});
@ -101,6 +105,7 @@ 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,6 +20,7 @@ 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;
@ -41,7 +42,7 @@ export const FileTreeNode: Component<{
const indent = props.depth * 12;
return (
<div>
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<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"
@ -66,6 +67,7 @@ export const FileTreeNode: Component<{
pathHeadings={props.pathHeadings}
depth={props.depth + 1}
onClose={props.onClose}
isHidden={props.isHidden}
/>
))}
</div>
@ -81,6 +83,7 @@ export const HeadingNode: Component<{
node: TocNode;
basePath: string;
depth: number;
isHidden?: (node: TocNode) => boolean;
}> = (props) => {
const navigate = useNavigate();
const anchor = props.node.id || "";
@ -112,7 +115,7 @@ export const HeadingNode: Component<{
const indent = props.depth * 12;
return (
<div>
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<div class="flex flex-row">
<span
class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
@ -140,6 +143,7 @@ export const HeadingNode: Component<{
node={child}
basePath={props.basePath}
depth={props.depth + 1}
isHidden={props.isHidden}
/>
))}
</div>

View File

@ -2,6 +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/reveal";
export interface SidebarProps {
isOpen: boolean;
@ -33,6 +34,17 @@ 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">
@ -72,6 +84,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
pathHeadings={props.pathHeadings}
depth={0}
onClose={props.onClose}
isHidden={isFileHidden}
/>
))}
</div>
@ -83,7 +96,12 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
</h3>
{currentFileHeadings().map((node) => (
<HeadingNode node={node} basePath={location.pathname} depth={0} />
<HeadingNode
node={node}
basePath={location.pathname}
depth={0}
isHidden={isHeadingHidden}
/>
))}
</div>
</Show>

View File

@ -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() {

View File

@ -83,7 +83,16 @@ registerMessageType<LinkPayload>({
reducer: (p) => {
journalSetState(
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();
}
}),
);
},

View File

@ -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
@ -29,10 +39,12 @@ export interface JournalStreamState {
/** Last sequence number per sender */
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.
*/
revealedPaths: Set<string>;
revealedPaths: Record<string, Set<string>>;
/** MQTT connection status */
connected: boolean;
/** Granular connection state for UI indicators */
@ -59,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
// ---------------------------------------------------------------------------
@ -101,7 +87,7 @@ const [state, setState] = createStore<JournalStreamState>({
sessionName: null,
messages: [],
senderSeq: {},
revealedPaths: new Set(),
revealedPaths: {},
connected: false,
connectionStatus: "disconnected",
connectionError: null,
@ -130,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);
}
@ -142,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);
}
/**
@ -153,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();
@ -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()
let _mqttClient: import("mqtt").MqttClient | null = null;
let _mqttConnected = false;
@ -305,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);

View File

@ -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"),
};
}

View File

@ -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);
}

View File

@ -8,6 +8,10 @@ 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[];
}
@ -21,26 +25,58 @@ 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;
for (const line of lines) {
const match = line.trim().match(/^(#{1,6})\s+(.+)$/);
// 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+(.+)$/);
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());
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();
}
@ -52,7 +88,13 @@ export function extractHeadings(content: string): TocNode[] {
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;
@ -136,7 +178,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);
@ -149,10 +191,7 @@ 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,3 +138,14 @@ icon.big .icon-label-stroke {
.col-5 {
@apply lg:flex-5;
}
/* concealed / revealed */
.concealed {
opacity: 0;
}
.revealed,
.concealed .revealed {
opacity: unset;
}