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 { Component, createEffect, createMemo, createSignal } from "solid-js";
|
||||||
import { useLocation } from "@solidjs/router";
|
import { useLocation } from "@solidjs/router";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
import { useJournalStream } from "./components/stores/journalStream";
|
||||||
import { addRevealedClasses } from "./components/stores/reveal";
|
|
||||||
import { useJournalCompletions } from "./components/journal/completions";
|
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
import "./components";
|
import "./components";
|
||||||
|
|
@ -12,6 +10,7 @@ import {
|
||||||
DesktopSidebar,
|
DesktopSidebar,
|
||||||
DocDialog,
|
DocDialog,
|
||||||
DataSourceDialog,
|
DataSourceDialog,
|
||||||
|
RevealManager,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
import { JournalPanel } from "./components/journal";
|
import { JournalPanel } from "./components/journal";
|
||||||
|
|
@ -19,7 +18,6 @@ import { JournalPanel } from "./components/journal";
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
const comp = useJournalCompletions();
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||||
|
|
@ -126,10 +124,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, location.pathname, comp.data)
|
<RevealManager />
|
||||||
}
|
</Article>
|
||||||
/>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
ParentProps,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
createEffect,
|
createEffect,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
Show,
|
Show,
|
||||||
createResource,
|
createResource,
|
||||||
createMemo,
|
createMemo,
|
||||||
|
createSignal,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { parseMarkdown } from "../markdown";
|
import { parseMarkdown } from "../markdown";
|
||||||
import { extractSection } from "../data-loader";
|
import { extractSection } from "../data-loader";
|
||||||
|
|
@ -21,7 +25,19 @@ 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;
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: {
|
async function fetchArticleContent(params: {
|
||||||
|
|
@ -59,13 +75,13 @@ function scrollToHash(hash: string) {
|
||||||
* Article 组件
|
* Article 组件
|
||||||
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
|
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
|
||||||
*/
|
*/
|
||||||
export const Article: Component<ArticleProps> = (props) => {
|
export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [content, { refetch }] = createResource(
|
const [content, { refetch }] = createResource(
|
||||||
() => ({ src: props.src, section: props.section }),
|
() => ({ src: props.src, section: props.section }),
|
||||||
fetchArticleContent,
|
fetchArticleContent,
|
||||||
);
|
);
|
||||||
let innerDiv!: HTMLDivElement;
|
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
|
||||||
|
|
||||||
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
||||||
const iconPrefix = createMemo(() => {
|
const iconPrefix = createMemo(() => {
|
||||||
|
|
@ -82,8 +98,6 @@ export const Article: Component<ArticleProps> = (props) => {
|
||||||
|
|
||||||
// 内容渲染后检查 hash 并滚动
|
// 内容渲染后检查 hash 并滚动
|
||||||
scrollToHash(location.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>
|
<div class="text-red-500">加载失败:{content.error?.message}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!content.loading && !content.error && content()}>
|
<Show when={!content.loading && !content.error && content()}>
|
||||||
|
<ArticleDomCtx.Provider value={contentDom}>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
ref={innerDiv}
|
ref={setContentDom}
|
||||||
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
innerHTML={parseMarkdown(content()!, iconPrefix())}
|
||||||
/>
|
/>
|
||||||
|
{props.children}
|
||||||
|
</ArticleDomCtx.Provider>
|
||||||
</Show>
|
</Show>
|
||||||
</article>
|
</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 { Article } from "./Article";
|
||||||
export type { ArticleProps } from "./Article";
|
export type { ArticleProps } from "./Article";
|
||||||
|
export { RevealManager } from "./RevealManager";
|
||||||
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
||||||
export type { SidebarProps } from "./Sidebar";
|
export type { SidebarProps } from "./Sidebar";
|
||||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,13 @@ export function setLinkPrefill(text: string | null) {
|
||||||
setActionPrefill(text ? { command: "/link", text } : 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
|
// addRevealedClasses — GM injects buttons; non-GM applies revealed/concealed
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -95,10 +102,19 @@ export function addRevealedClasses(
|
||||||
completions: CompletionsForInject = { sparkTables: [] },
|
completions: CompletionsForInject = { sparkTables: [] },
|
||||||
) {
|
) {
|
||||||
const state = journalStreamState;
|
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);
|
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 ----
|
// ---- GM mode: inject action buttons on headings and spark tables ----
|
||||||
if (state.myRole === "gm") {
|
if (state.myRole === "gm") {
|
||||||
injectActionButtons(root, normalized, completions);
|
injectActionButtons(root, normalized, completions);
|
||||||
|
|
@ -114,6 +130,28 @@ export function addRevealedClasses(
|
||||||
applyClasses(root, revealedSet);
|
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)
|
// Completions payload type (mirrors the completions module shape)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -144,8 +182,11 @@ function injectActionButtons(
|
||||||
const headingText = el.id || el.textContent?.trim() || "";
|
const headingText = el.id || el.textContent?.trim() || "";
|
||||||
if (headingText) {
|
if (headingText) {
|
||||||
const btn = createLinkButton(normalizedPath, headingText);
|
const btn = createLinkButton(normalizedPath, headingText);
|
||||||
|
btn.setAttribute(DATA_BUTTON, "");
|
||||||
el.insertBefore(btn, el.firstChild);
|
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);
|
for (const child of el.children) walk(child);
|
||||||
|
|
@ -211,8 +252,10 @@ function injectSparkButtons(
|
||||||
|
|
||||||
// Inject the spark button into the <md-table> wrapper
|
// Inject the spark button into the <md-table> wrapper
|
||||||
const btn = createSparkButton(match.slug);
|
const btn = createSparkButton(match.slug);
|
||||||
|
btn.setAttribute(DATA_BUTTON, "");
|
||||||
const wrapper = el.parentElement;
|
const wrapper = el.parentElement;
|
||||||
if (wrapper) {
|
if (wrapper) {
|
||||||
|
wrapper.setAttribute(DATA_INJECTED, "");
|
||||||
wrapper.style.position = "relative";
|
wrapper.style.position = "relative";
|
||||||
wrapper.classList.add("group");
|
wrapper.classList.add("group");
|
||||||
wrapper.insertBefore(btn, wrapper.firstChild);
|
wrapper.insertBefore(btn, wrapper.firstChild);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue