refactor: introduce RevealManager and Article DOM context

Decouple the reveal logic from the `Article` component by introducing
a `RevealManager` component and an `ArticleDomCtx`. This allows the
reveal logic to reactively manage DOM injections and classes without
polluting the `Article` component's props or lifecycle.

Additionally, implements a robust `cleanupInjections` mechanism to
ensure that injected buttons and styling artifacts are properly removed
during navigation, role changes, or disconnections.
This commit is contained in:
hypercross 2026-07-07 22:19:04 +08:00
parent 1ef2560faa
commit aa326f38a0
5 changed files with 117 additions and 19 deletions

View File

@ -1,8 +1,6 @@
import { Component, createEffect, createMemo, createSignal } from "solid-js";
import { useLocation } from "@solidjs/router";
import { useJournalStream } from "./components/stores/journalStream";
import { addRevealedClasses } from "./components/stores/reveal";
import { useJournalCompletions } from "./components/journal/completions";
// 导入组件以注册自定义元素
import "./components";
@ -12,6 +10,7 @@ import {
DesktopSidebar,
DocDialog,
DataSourceDialog,
RevealManager,
} from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal";
@ -19,7 +18,6 @@ import { JournalPanel } from "./components/journal";
const App: Component = () => {
const location = useLocation();
const stream = useJournalStream();
const comp = useJournalCompletions();
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
const [isDocOpen, setIsDocOpen] = createSignal(false);
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
@ -126,10 +124,9 @@ const App: Component = () => {
<Article
class="prose text-black prose-sm max-w-full flex-1"
src={currentPath()}
onDom={(dom) =>
addRevealedClasses(dom, location.pathname, comp.data)
}
/>
>
<RevealManager />
</Article>
</main>
</div>
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />

View File

@ -1,10 +1,14 @@
import {
Component,
ParentProps,
createContext,
useContext,
createEffect,
onCleanup,
Show,
createResource,
createMemo,
createSignal,
} from "solid-js";
import { parseMarkdown } from "../markdown";
import { extractSection } from "../data-loader";
@ -21,7 +25,19 @@ export interface ArticleProps {
onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制
scrollToHash?: boolean; // 是否自动滚动到 hash
onDom?: (dom: HTMLDivElement) => void;
}
// ---------------------------------------------------------------------------
// Article DOM context lets child components react to the content container
// ---------------------------------------------------------------------------
const ArticleDomCtx = createContext<() => HTMLDivElement | undefined>();
/** Access the article's content DOM element from a child component. */
export function useArticleDom(): () => HTMLDivElement | undefined {
const ctx = useContext(ArticleDomCtx);
if (!ctx) throw new Error("useArticleDom must be used inside an <Article>");
return ctx;
}
async function fetchArticleContent(params: {
@ -59,13 +75,13 @@ function scrollToHash(hash: string) {
* Article
* src md markdown
*/
export const Article: Component<ArticleProps> = (props) => {
export const Article: Component<ArticleProps & ParentProps> = (props) => {
const location = useLocation();
const [content, { refetch }] = createResource(
() => ({ src: props.src, section: props.section }),
fetchArticleContent,
);
let innerDiv!: HTMLDivElement;
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
// 解析 iconPath默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => {
@ -82,8 +98,6 @@ export const Article: Component<ArticleProps> = (props) => {
// 内容渲染后检查 hash 并滚动
scrollToHash(location.hash);
props.onDom?.(innerDiv);
}
});
@ -103,11 +117,14 @@ export const Article: Component<ArticleProps> = (props) => {
<div class="text-red-500">{content.error?.message}</div>
</Show>
<Show when={!content.loading && !content.error && content()}>
<ArticleDomCtx.Provider value={contentDom}>
<div
class="relative"
ref={innerDiv}
ref={setContentDom}
innerHTML={parseMarkdown(content()!, iconPrefix())}
/>
{props.children}
</ArticleDomCtx.Provider>
</Show>
</article>
);

View File

@ -0,0 +1,40 @@
/**
* RevealManager mounts as a child of <Article> and reactively applies
* reveal classes (non-GM) or injects action buttons (GM) whenever the
* content DOM, stream connection state, role, or completions change.
*/
import { Component, createEffect, onCleanup } from "solid-js";
import { useLocation } from "@solidjs/router";
import { useArticleDom } from "./Article";
import { journalStreamState } from "./stores/journalStream";
import { useJournalCompletions } from "./journal/completions";
import { addRevealedClasses, cleanupInjections } from "./stores/reveal";
export const RevealManager: Component = () => {
const contentDom = useArticleDom();
const location = useLocation();
const comp = useJournalCompletions();
createEffect(() => {
const dom = contentDom();
if (!dom) return;
// Access stream state reactively so the effect re-runs whenever
// connection status or role changes.
const state = journalStreamState;
void state.connected;
void state.myRole;
addRevealedClasses(dom, location.pathname, comp.data);
});
onCleanup(() => {
const dom = contentDom();
if (dom) cleanupInjections(dom);
});
return null;
};
export default RevealManager;

View File

@ -16,6 +16,7 @@ import "./md-token-viewer";
// 导出组件
export { Article } from "./Article";
export type { ArticleProps } from "./Article";
export { RevealManager } from "./RevealManager";
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
export type { SidebarProps } from "./Sidebar";
export { FileTreeNode, HeadingNode } from "./FileTree";

View File

@ -66,6 +66,13 @@ export function setLinkPrefill(text: string | null) {
setActionPrefill(text ? { command: "/link", text } : null);
}
// ---------------------------------------------------------------------------
// DOM markers for injected artifacts (used by cleanup) --------------__________
// ---------------------------------------------------------------------------
const DATA_BUTTON = "data-reveal-button";
const DATA_INJECTED = "data-reveal-injected";
// ---------------------------------------------------------------------------
// addRevealedClasses — GM injects buttons; non-GM applies revealed/concealed
// ---------------------------------------------------------------------------
@ -95,10 +102,19 @@ export function addRevealedClasses(
completions: CompletionsForInject = { sparkTables: [] },
) {
const state = journalStreamState;
if (!state.connected) return;
if (!state.connected) {
// Disconnected — scrub all artifacts so the page looks clean
cleanupInjections(root);
return;
}
const normalized = normalizePath(path);
// Always clean up previous injections before applying new ones.
// This handles role changes, reconnects, and navigation without
// leaving stale buttons or classes behind.
cleanupInjections(root);
// ---- GM mode: inject action buttons on headings and spark tables ----
if (state.myRole === "gm") {
injectActionButtons(root, normalized, completions);
@ -114,6 +130,28 @@ export function addRevealedClasses(
applyClasses(root, revealedSet);
}
// ---------------------------------------------------------------------------
// Cleanup — strip all previously injected artifacts
// ---------------------------------------------------------------------------
export function cleanupInjections(root: Element): void {
// Remove injected buttons
root.querySelectorAll(`[${DATA_BUTTON}]`).forEach((el) => el.remove());
// Clean up injected classes and inline styles on headings / wrappers
root.querySelectorAll(`[${DATA_INJECTED}]`).forEach((el) => {
const htmlEl = el as HTMLElement;
htmlEl.classList.remove("group", "flex", "items-center");
htmlEl.style.position = "";
htmlEl.removeAttribute(DATA_INJECTED);
});
// Remove revealed / concealed classes from all elements
root
.querySelectorAll(".revealed, .concealed")
.forEach((el) => el.classList.remove("revealed", "concealed"));
}
// ---------------------------------------------------------------------------
// Completions payload type (mirrors the completions module shape)
// ---------------------------------------------------------------------------
@ -144,8 +182,11 @@ function injectActionButtons(
const headingText = el.id || el.textContent?.trim() || "";
if (headingText) {
const btn = createLinkButton(normalizedPath, headingText);
btn.setAttribute(DATA_BUTTON, "");
el.insertBefore(btn, el.firstChild);
(el as HTMLElement).classList.add("group", "flex", "items-center");
const htmlEl = el as HTMLElement;
htmlEl.setAttribute(DATA_INJECTED, "");
htmlEl.classList.add("group", "flex", "items-center");
}
}
for (const child of el.children) walk(child);
@ -211,8 +252,10 @@ function injectSparkButtons(
// Inject the spark button into the <md-table> wrapper
const btn = createSparkButton(match.slug);
btn.setAttribute(DATA_BUTTON, "");
const wrapper = el.parentElement;
if (wrapper) {
wrapper.setAttribute(DATA_INJECTED, "");
wrapper.style.position = "relative";
wrapper.classList.add("group");
wrapper.insertBefore(btn, wrapper.firstChild);