feat: implement content reveal mechanism via DOM traversal
Add `addRevealedClasses` to apply `revealed` or `concealed` CSS classes to article elements based on the current heading hierarchy and revealed paths. Update `Article` to expose its DOM element via an `onDom` callback.
This commit is contained in:
parent
adf28a7cf1
commit
ced9a4f8f2
|
|
@ -1,6 +1,9 @@
|
||||||
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
import { Component, createEffect, createMemo, createSignal } from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
import {
|
||||||
|
addRevealedClasses,
|
||||||
|
useJournalStream,
|
||||||
|
} from "./components/stores/journalStream";
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
import "./components";
|
import "./components";
|
||||||
|
|
@ -123,6 +126,9 @@ 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) =>
|
||||||
|
addRevealedClasses(dom, stream.revealedPaths[currentPath()])
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface ArticleProps {
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
class?: string; // 额外的 class 用于样式控制
|
class?: string; // 额外的 class 用于样式控制
|
||||||
scrollToHash?: boolean; // 是否自动滚动到 hash
|
scrollToHash?: boolean; // 是否自动滚动到 hash
|
||||||
|
onDom?: (dom: HTMLDivElement) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchArticleContent(params: {
|
async function fetchArticleContent(params: {
|
||||||
|
|
@ -64,6 +65,7 @@ export const Article: Component<ArticleProps> = (props) => {
|
||||||
() => ({ src: props.src, section: props.section }),
|
() => ({ src: props.src, section: props.section }),
|
||||||
fetchArticleContent,
|
fetchArticleContent,
|
||||||
);
|
);
|
||||||
|
let innerDiv!: HTMLDivElement;
|
||||||
|
|
||||||
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
||||||
const iconPrefix = createMemo(() => {
|
const iconPrefix = createMemo(() => {
|
||||||
|
|
@ -80,6 +82,8 @@ export const Article: Component<ArticleProps> = (props) => {
|
||||||
|
|
||||||
// 内容渲染后检查 hash 并滚动
|
// 内容渲染后检查 hash 并滚动
|
||||||
scrollToHash(location.hash);
|
scrollToHash(location.hash);
|
||||||
|
|
||||||
|
props.onDom?.(innerDiv);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -101,6 +105,7 @@ export const Article: Component<ArticleProps> = (props) => {
|
||||||
<Show when={!content.loading && !content.error && content()}>
|
<Show when={!content.loading && !content.error && content()}>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
|
ref={innerDiv}
|
||||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { FileTreeNode, HeadingNode } from "./FileTree";
|
import { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
import { isPathRevealed } from "./stores/journalStream";
|
import { isPathRevealed } from "./stores/journalStream";
|
||||||
|
import { createEffect } from "solid-js";
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -43,8 +44,8 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
}
|
}
|
||||||
for (const node of currentFileHeadings()) {
|
for (const node of currentFileHeadings()) {
|
||||||
traverse(node, (anode) => {
|
traverse(node, (anode) => {
|
||||||
if (isPathRevealed(pathname, anode.title)) {
|
if (isPathRevealed(pathname, anode.id ?? anode.title)) {
|
||||||
traverse(anode, (each) => set.add(each.title));
|
traverse(anode, (each) => set.add(anode.id ?? anode.title));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -56,10 +57,13 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const isHeadingHidden = (node: TocNode): boolean => {
|
const isHeadingHidden = (node: TocNode): boolean => {
|
||||||
if (revealedHeadings().has(node.title)) return false;
|
if (revealedHeadings().has(node.id ?? node.title)) return false;
|
||||||
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">
|
||||||
|
|
|
||||||
|
|
@ -627,6 +627,54 @@ export function isPathRevealed(path: string, section?: string): boolean {
|
||||||
return revealed.size > 0;
|
return revealed.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk every element in `root` and tag it as revealed or concealed.
|
||||||
|
*
|
||||||
|
* Headings (h1–h6) 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.
|
||||||
|
*/
|
||||||
|
export function addRevealedClasses(
|
||||||
|
root: HTMLDivElement,
|
||||||
|
revealed: Set<string>,
|
||||||
|
) {
|
||||||
|
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) => {
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start walk from root's children (skip the container itself)
|
||||||
|
for (const child of root.children) {
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Normalize a path for lookup: strip .md, leading ./ or / */
|
/** Normalize a path for lookup: strip .md, leading ./ or / */
|
||||||
function normalizePath(p: string): string {
|
function normalizePath(p: string): string {
|
||||||
return p.replace(/^\.?\//, "").replace(/\.md$/, "");
|
return p.replace(/^\.?\//, "").replace(/\.md$/, "");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue