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.
This commit is contained in:
hypercross 2026-07-07 12:56:28 +08:00
parent ced9a4f8f2
commit 574d17f201
4 changed files with 83 additions and 43 deletions

View File

@ -126,9 +126,7 @@ const App: Component = () => {
<Article
class="prose text-black prose-sm max-w-full flex-1"
src={currentPath()}
onDom={(dom) =>
addRevealedClasses(dom, stream.revealedPaths[currentPath()])
}
onDom={(dom) => addRevealedClasses(dom, location.pathname)}
/>
</main>
</div>

View File

@ -61,9 +61,6 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
if (node.children?.some((child) => !isHeadingHidden(child))) return false;
return true;
};
createEffect(() => {
console.log(currentFileHeadings(), revealedHeadings());
});
return (
<div class="flex flex-col h-full">

View File

@ -630,49 +630,83 @@ export function isPathRevealed(path: string, section?: string): boolean {
/**
* Walk every element in `root` and tag it as revealed or concealed.
*
* Headings (h1h6) update a current-heading tracker. When a higher-level
* heading is encountered (e.g. h2 after h1), all sub-headings at deeper
* levels are cleared. Each non-heading element receives a `revealed` or
* `concealed` CSS class depending on whether any of its current heading
* ancestors is present in the `revealed` set.
* 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,
revealed: Set<string>,
) {
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;
// Current heading text for each level (h1..h6, index 1..6)
const cur: Record<number, string> = {};
const HEADING_TAGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
const walk = (el: Element) => {
// ---- 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)); // 1-6
const text = el.textContent?.trim() ?? "";
// Update current heading at this level
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;
// Clear all deeper heading levels
for (let l = level + 1; l <= 6; l++) delete cur[l];
} else {
// Non-heading: check if any current heading ancestor is revealed
const isRevealed = Object.values(cur).some((h) => revealed.has(h));
el.classList.add(isRevealed ? "revealed" : "concealed");
}
// Recurse into children
for (const child of el.children) {
walk(child);
}
for (const child of el.children) collect(child);
};
for (const child of root.children) collect(child);
// Start walk from root's children (skip the container itself)
for (const child of root.children) {
walk(child);
// ---- Cascade: if a heading is revealed, its parents are too ----
const revealedSet = new Set<string>();
for (const h of headings) {
if (revealed.has(h.text) || revealedSet.has(h.text)) {
revealedSet.add(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 / */

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