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:
parent
1ef2560faa
commit
aa326f38a0
11
src/App.tsx
11
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 = () => {
|
|||
<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)} />
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
<div
|
||||
class="relative"
|
||||
ref={innerDiv}
|
||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
||||
/>
|
||||
<ArticleDomCtx.Provider value={contentDom}>
|
||||
<div
|
||||
class="relative"
|
||||
ref={setContentDom}
|
||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
||||
/>
|
||||
{props.children}
|
||||
</ArticleDomCtx.Provider>
|
||||
</Show>
|
||||
</article>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue