refactor: move GM action buttons to Solid components

Migrate GM action button injection from imperative DOM manipulation to
a reactive Solid component (`RevealManager`) using `Portal`. This
improves cleanup reliability and ensures buttons are proper Solid
components rather than raw HTML strings injected into the DOM.

Also adds URI encoding for link paths and simplifies the reveal
store logic.
This commit is contained in:
2026-07-07 23:14:04 +08:00
parent aa326f38a0
commit 4eaf6aab16
3 changed files with 191 additions and 180 deletions
+14 -172
View File
@@ -1,8 +1,10 @@
/**
* DOM reveal logic — applies revealed/concealed classes to article headings
* for non-GM clients, and injects hover-action buttons for GM.
* for non-GM clients.
*
* All state is read from the journal stream store via `journalStreamState`.
*
* Action buttons (link, spark) are handled by the RevealManager Solid component.
*/
import { createSignal } from "solid-js";
@@ -67,14 +69,7 @@ export function setLinkPrefill(text: string | 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 — non-GM only: applies revealed/concealed classes
// ---------------------------------------------------------------------------
interface HeadingEntry {
@@ -96,33 +91,25 @@ interface HeadingEntry {
* every element (headings included) based on whether any current
* heading ancestor is in the cascaded revealed set.
*/
export function addRevealedClasses(
root: HTMLDivElement,
path: string,
completions: CompletionsForInject = { sparkTables: [] },
) {
export function addRevealedClasses(root: HTMLDivElement, path: string) {
const state = journalStreamState;
if (!state.connected) {
// Disconnected — scrub all artifacts so the page looks clean
cleanupInjections(root);
return;
}
// GM sees everything — no classes needed
if (state.myRole === "gm") {
cleanupInjections(root);
return;
}
const normalized = normalizePath(path);
const revealed = state.revealedPaths[normalized];
// Always clean up previous injections before applying new ones.
// This handles role changes, reconnects, and navigation without
// leaving stale buttons or classes behind.
// Always clean up previous classes before applying new ones
cleanupInjections(root);
// ---- GM mode: inject action buttons on headings and spark tables ----
if (state.myRole === "gm") {
injectActionButtons(root, normalized, completions);
return;
}
// ---- Non-GM mode: apply revealed/concealed classes ----
const revealed = state.revealedPaths[normalized];
if (!revealed) return;
const headings = collectHeadings(root);
@@ -131,160 +118,15 @@ export function addRevealedClasses(
}
// ---------------------------------------------------------------------------
// Cleanup — strip all previously injected artifacts
// Cleanup — strip previously applied revealed/concealed classes
// ---------------------------------------------------------------------------
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)
// ---------------------------------------------------------------------------
export interface SparkTableCompletion {
label: string;
notation: string;
slug: string;
filePath: string;
headers: string[];
}
export interface CompletionsForInject {
sparkTables: SparkTableCompletion[];
}
// ---- GM helpers ----
function injectActionButtons(
root: Element,
normalizedPath: string,
completions: CompletionsForInject,
) {
// 1. Inject link buttons on headings
const walk = (el: Element) => {
const tag = el.tagName.toUpperCase();
if (HEADING_TAGS.has(tag)) {
const headingText = el.id || el.textContent?.trim() || "";
if (headingText) {
const btn = createLinkButton(normalizedPath, headingText);
btn.setAttribute(DATA_BUTTON, "");
el.insertBefore(btn, el.firstChild);
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 root.children) walk(child);
// 2. Inject spark buttons on spark tables
injectSparkButtons(root, normalizedPath, completions);
}
// ---- Link button (headings) ----
function createLinkButton(path: string, headingId: string): HTMLButtonElement {
const btn = document.createElement("button");
btn.className =
"inline-flex items-center justify-center w-5 h-5 mr-1 -ml-6 " +
"text-gray-300 hover:text-blue-500 hover:bg-blue-50 rounded " +
"transition-colors align-middle opacity-0 group-hover:opacity-100 focus:opacity-100";
btn.title = "Send /link to stream";
btn.innerHTML = LINK_SVG;
btn.addEventListener("click", (e) => {
e.stopPropagation();
setActionPrefill({ command: "/link", text: `/link ${path}#${headingId}` });
});
return btn;
}
const LINK_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>' +
'<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>' +
"</svg>";
// ---- Spark button (tables) ----
function injectSparkButtons(
root: Element,
normalizedPath: string,
completions: CompletionsForInject,
) {
// Only consider spark tables whose filePath matches the current page
const pageTables = completions.sparkTables.filter(
(st) => st.filePath === normalizedPath,
);
if (pageTables.length === 0) return;
// Spark tables are rendered as <md-table data-spark="columnSlug">
const sparkTables = root.querySelectorAll("md-table[data-spark]");
sparkTables.forEach((el) => {
const colSlug = el.getAttribute("data-spark");
if (!colSlug) return;
// Find the matching completions entry by column slug
const match = pageTables.find((st) => {
// st.slug is the combined slug (pageName-columnSlug);
// colSlug is just the column part. Match by checking if
// the combined slug ends with the column slug.
return st.slug.endsWith(`-${colSlug}`);
});
if (!match) return;
// 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);
}
});
}
function createSparkButton(combinedSlug: string): HTMLButtonElement {
const btn = document.createElement("button");
btn.className =
"absolute top-0.5 right-0.5 z-10 inline-flex items-center justify-center " +
"w-6 h-6 text-purple-400 hover:text-purple-600 hover:bg-purple-50 rounded " +
"transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100";
btn.title = "Roll spark table";
btn.innerHTML = SPARK_SVG;
btn.addEventListener("click", (e) => {
e.stopPropagation();
setActionPrefill({ command: "/spark", text: `/spark ${combinedSlug}` });
});
return btn;
}
const SPARK_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M12 2l1.5 6.5L18 7l-4.5 4.5L16 16l-4-2.5L8 16l1.5-4.5L5 7l4.5-.5z"/>' +
"</svg>";
// ---- Non-GM helpers ----
function collectHeadings(root: Element): HeadingEntry[] {