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 <Article
class="prose text-black prose-sm max-w-full flex-1" class="prose text-black prose-sm max-w-full flex-1"
src={currentPath()} src={currentPath()}
onDom={(dom) => onDom={(dom) => addRevealedClasses(dom, location.pathname)}
addRevealedClasses(dom, stream.revealedPaths[currentPath()])
}
/> />
</main> </main>
</div> </div>

View File

@ -61,9 +61,6 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
if (node.children?.some((child) => !isHeadingHidden(child))) return false; if (node.children?.some((child) => !isHeadingHidden(child))) return false;
return true; return true;
}; };
createEffect(() => {
console.log(currentFileHeadings(), revealedHeadings());
});
return ( return (
<div class="flex flex-col h-full"> <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. * Walk every element in `root` and tag it as revealed or concealed.
* *
* Headings (h1h6) update a current-heading tracker. When a higher-level * Two-pass approach:
* heading is encountered (e.g. h2 after h1), all sub-headings at deeper * 1. Collect all headings with their ancestor chain.
* levels are cleared. Each non-heading element receives a `revealed` or * 2. Cascade revealed status upward if a heading is revealed, all its
* `concealed` CSS class depending on whether any of its current heading * parent headings are marked revealed as well.
* ancestors is present in the `revealed` set. * 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( export function addRevealedClasses(root: HTMLDivElement, path: string) {
root: HTMLDivElement, if (!state.connected) return;
revealed: Set<string>, if (state.myRole === "gm") return;
) {
const normalized = normalizePath(path);
const revealed = state.revealedPaths[normalized];
if (!revealed) return; 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 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(); const tag = el.tagName.toUpperCase();
if (HEADING_TAGS.has(tag)) { if (HEADING_TAGS.has(tag)) {
const level = Number(tag.charAt(1)); // 1-6 const level = Number(tag.charAt(1));
const text = el.textContent?.trim() ?? ""; const text = el.id || el.textContent?.trim() || "";
const parents: string[] = [];
// Update current heading at this level for (let l = 1; l < level; l++) {
if (cur[l]) parents.push(cur[l]);
}
headings.push({ el, level, text, parents });
cur[level] = text; cur[level] = text;
// Clear all deeper heading levels
for (let l = level + 1; l <= 6; l++) delete cur[l]; 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) // ---- Cascade: if a heading is revealed, its parents are too ----
for (const child of root.children) { const revealedSet = new Set<string>();
walk(child); 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 / */ /** Normalize a path for lookup: strip .md, leading ./ or / */

View File

@ -138,3 +138,14 @@ icon.big .icon-label-stroke {
.col-5 { .col-5 {
@apply lg:flex-5; @apply lg:flex-5;
} }
/* concealed / revealed */
.concealed {
opacity: 0;
}
.revealed,
.concealed .revealed {
opacity: unset;
}