diff --git a/src/components/journal/types/link.tsx b/src/components/journal/types/link.tsx index 682e6a2..826eca4 100644 --- a/src/components/journal/types/link.tsx +++ b/src/components/journal/types/link.tsx @@ -83,7 +83,16 @@ registerMessageType({ 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(); + } }), ); }, diff --git a/src/components/stores/journalStream.ts b/src/components/stores/journalStream.ts index 4152dd8..2c99058 100644 --- a/src/components/stores/journalStream.ts +++ b/src/components/stores/journalStream.ts @@ -29,10 +29,12 @@ export interface JournalStreamState { /** Last sequence number per sender */ senderSeq: Record; /** - * 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; + revealedPaths: Record>; /** MQTT connection status */ connected: boolean; /** Granular connection state for UI indicators */ @@ -101,7 +103,7 @@ const [state, setState] = createStore({ sessionName: null, messages: [], senderSeq: {}, - revealedPaths: new Set(), + revealedPaths: {}, connected: false, connectionStatus: "disconnected", connectionError: null, @@ -604,6 +606,32 @@ 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; +} + +/** 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/data-loader/toc.ts b/src/data-loader/toc.ts index 117f20e..29616e6 100644 --- a/src/data-loader/toc.ts +++ b/src/data-loader/toc.ts @@ -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;