"); + 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} +
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);