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:
2026-07-07 22:19:04 +08:00
parent 1ef2560faa
commit aa326f38a0
5 changed files with 117 additions and 19 deletions
+45 -2
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);