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.
This commit is contained in:
hypercross 2026-07-07 07:44:57 +08:00
parent 7cfc0fd4f3
commit c5e1167beb
3 changed files with 92 additions and 16 deletions

View File

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

View File

@ -29,10 +29,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 */
@ -101,7 +103,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,
@ -604,6 +606,32 @@ export function visibleMessages(): StreamMessage[] {
return state.messages.filter((m) => !m.reverted); 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() { export function useJournalStream() {
return state; return state;
} }

View File

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