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:
parent
7cfc0fd4f3
commit
c5e1167beb
|
|
@ -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();
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue