From aa326f38a0ec0e3e6228b3cf1850fe339bb31f01 Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 7 Jul 2026 22:19:04 +0800 Subject: [PATCH] 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. --- src/App.tsx | 11 +++----- src/components/Article.tsx | 37 ++++++++++++++++++------- src/components/RevealManager.tsx | 40 +++++++++++++++++++++++++++ src/components/index.ts | 1 + src/components/stores/reveal.ts | 47 ++++++++++++++++++++++++++++++-- 5 files changed, 117 insertions(+), 19 deletions(-) create mode 100644 src/components/RevealManager.tsx diff --git a/src/App.tsx b/src/App.tsx index ea6bac2..e448854 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => {
- addRevealedClasses(dom, location.pathname, comp.data) - } - /> + > + +
setIsDocOpen(false)} /> diff --git a/src/components/Article.tsx b/src/components/Article.tsx index 9d69b45..80cb251 100644 --- a/src/components/Article.tsx +++ b/src/components/Article.tsx @@ -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
"); + return ctx; } async function fetchArticleContent(params: { @@ -59,13 +75,13 @@ function scrollToHash(hash: string) { * Article 组件 * 用于将特定 src 位置的 md 文件显示为 markdown 文章 */ -export const Article: Component = (props) => { +export const Article: Component = (props) => { const location = useLocation(); const [content, { refetch }] = createResource( () => ({ src: props.src, section: props.section }), fetchArticleContent, ); - let innerDiv!: HTMLDivElement; + const [contentDom, setContentDom] = createSignal(); // 解析 iconPath,默认为 "./assets",空字符串表示禁用 const iconPrefix = createMemo(() => { @@ -82,8 +98,6 @@ export const Article: Component = (props) => { // 内容渲染后检查 hash 并滚动 scrollToHash(location.hash); - - props.onDom?.(innerDiv); } }); @@ -103,11 +117,14 @@ export const Article: Component = (props) => {
加载失败:{content.error?.message}
-
+ +
+ {props.children} +
); diff --git a/src/components/RevealManager.tsx b/src/components/RevealManager.tsx new file mode 100644 index 0000000..919ba40 --- /dev/null +++ b/src/components/RevealManager.tsx @@ -0,0 +1,40 @@ +/** + * RevealManager — mounts as a child of
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; diff --git a/src/components/index.ts b/src/components/index.ts index da79502..ba34e4d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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"; diff --git a/src/components/stores/reveal.ts b/src/components/stores/reveal.ts index 3ab805a..4219cd4 100644 --- a/src/components/stores/reveal.ts +++ b/src/components/stores/reveal.ts @@ -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 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);