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:
hypercross 2026-07-07 23:14:04 +08:00
parent aa326f38a0
commit 4eaf6aab16
3 changed files with 191 additions and 180 deletions

View File

@ -2,31 +2,92 @@
* RevealManager mounts as a child of <Article> and reactively applies * RevealManager mounts as a child of <Article> and reactively applies
* reveal classes (non-GM) or injects action buttons (GM) whenever the * reveal classes (non-GM) or injects action buttons (GM) whenever the
* content DOM, stream connection state, role, or completions change. * content DOM, stream connection state, role, or completions change.
*
* All action buttons are proper Solid components rendered via Portal.
* Nothing is injected into the custom element DOM listeners are attached
* via addEventListener with proper cleanup.
*/ */
import { Component, createEffect, onCleanup } from "solid-js"; import {
Component,
createEffect,
createSignal,
onCleanup,
Show,
} from "solid-js";
import { Portal } from "solid-js/web";
import { useLocation } from "@solidjs/router"; import { useLocation } from "@solidjs/router";
import { useArticleDom } from "./Article"; import { useArticleDom } from "./Article";
import { journalStreamState } from "./stores/journalStream"; import { journalStreamState } from "./stores/journalStream";
import { useJournalCompletions } from "./journal/completions"; import { useJournalCompletions } from "./journal/completions";
import { addRevealedClasses, cleanupInjections } from "./stores/reveal"; import {
addRevealedClasses,
cleanupInjections,
setActionPrefill,
normalizePath,
} from "./stores/reveal";
// ---------------------------------------------------------------------------
// SVG icons
// ---------------------------------------------------------------------------
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>";
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>";
// ---------------------------------------------------------------------------
// Floating button state
// ---------------------------------------------------------------------------
interface FloatingButton {
rect: DOMRect;
action: () => void;
title: string;
svg: string;
color: string;
}
const [floating, setFloating] = createSignal<FloatingButton | null>(null);
let hideTimer: ReturnType<typeof setTimeout> | undefined;
function show(btn: FloatingButton) {
clearTimeout(hideTimer);
setFloating(btn);
}
function hide() {
hideTimer = setTimeout(() => setFloating(null), 200);
}
// ---------------------------------------------------------------------------
// RevealManager
// ---------------------------------------------------------------------------
export const RevealManager: Component = () => { export const RevealManager: Component = () => {
const contentDom = useArticleDom(); const contentDom = useArticleDom();
const location = useLocation(); const location = useLocation();
const comp = useJournalCompletions(); const comp = useJournalCompletions();
// ---- Reveal / conceal classes ----
createEffect(() => { createEffect(() => {
const dom = contentDom(); const dom = contentDom();
if (!dom) return; if (!dom) return;
// Access stream state reactively so the effect re-runs whenever
// connection status or role changes.
const state = journalStreamState; const state = journalStreamState;
void state.connected; void state.connected;
void state.myRole; void state.myRole;
addRevealedClasses(dom, location.pathname, comp.data); addRevealedClasses(dom, location.pathname);
}); });
onCleanup(() => { onCleanup(() => {
@ -34,7 +95,107 @@ export const RevealManager: Component = () => {
if (dom) cleanupInjections(dom); if (dom) cleanupInjections(dom);
}); });
return null; // ---- GM hover listeners ----
createEffect(() => {
const dom = contentDom();
const state = journalStreamState;
if (!dom || state.myRole !== "gm") return;
const normalized = normalizePath(location.pathname);
const data = comp.data;
// Spark tables on this page — matched by suffix on column slug.
// filePath in completions may have a leading slash; normalize both sides.
const pageSparkSlugs = data.sparkTables
.filter((st) => st.filePath.replace(/^\//, "") === normalized)
.map((st) => st.slug);
// Single delegated handler for both link and spark
const onMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Spark table
const sparkTable = target.closest("md-table[data-spark]");
if (sparkTable) {
const colSlug = sparkTable.getAttribute("data-spark");
const combinedSlug = colSlug
? pageSparkSlugs.find((s) => s.endsWith(`-${colSlug}`))
: undefined;
console.log(data.sparkTables, normalized, pageSparkSlugs, colSlug);
if (combinedSlug) {
show({
rect: sparkTable.getBoundingClientRect(),
action: () =>
setActionPrefill({
command: "/spark",
text: `/spark ${combinedSlug}`,
}),
title: "Roll spark table",
svg: SPARK_SVG,
color: "text-purple-400 hover:text-purple-600 hover:bg-purple-50",
});
return;
}
}
// Heading
const heading = target.closest("h1, h2, h3, h4, h5, h6");
if (heading) {
const text = heading.id || heading.textContent?.trim() || "";
if (text) {
show({
rect: heading.getBoundingClientRect(),
action: () =>
setActionPrefill({
command: "/link",
text: `/link ${normalized}#${text}`,
}),
title: "Send /link to stream",
svg: LINK_SVG,
color: "text-blue-500 hover:bg-blue-50",
});
}
}
};
const onMouseOut = (e: MouseEvent) => {
// Hide only if the mouse actually left the content area
const related = e.relatedTarget as HTMLElement | null;
if (!dom.contains(related)) hide();
};
dom.addEventListener("mouseover", onMouseOver);
dom.addEventListener("mouseout", onMouseOut);
onCleanup(() => {
dom.removeEventListener("mouseover", onMouseOver);
dom.removeEventListener("mouseout", onMouseOut);
});
});
return (
<Show when={floating()}>
{(btn) => (
<Portal>
<button
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
style={{
top: `${btn().rect.top + window.scrollY - 6}px`,
left: `${btn().rect.left + window.scrollX - 20}px`,
}}
title={btn().title}
innerHTML={btn().svg}
onClick={(e) => {
e.stopPropagation();
btn().action();
}}
onMouseEnter={() => clearTimeout(hideTimer)}
onMouseLeave={hide}
/>
</Portal>
)}
</Show>
);
}; };
export default RevealManager; export default RevealManager;

View File

@ -25,6 +25,14 @@ function fileNameFromPath(path: string): string {
return parts[parts.length - 1] || path; return parts[parts.length - 1] || path;
} }
/** Encode a path, preserving / separators but encoding each segment */
function encodePath(path: string): string {
return path
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
}
/** Section slug → human-readable title */ /** Section slug → human-readable title */
function slugToTitle(slug: string): string { function slugToTitle(slug: string): string {
return slug return slug
@ -37,8 +45,8 @@ const RevealLink: Component<LinkPayload> = (p) => {
const navigate = useNavigate(); const navigate = useNavigate();
const target = () => { const target = () => {
let t = p.path; let t = encodePath(p.path);
if (p.section) t += `#${p.section}`; if (p.section) t += `#${encodeURIComponent(p.section)}`;
return t; return t;
}; };

View File

@ -1,8 +1,10 @@
/** /**
* DOM reveal logic applies revealed/concealed classes to article headings * 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`. * 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"; import { createSignal } from "solid-js";
@ -67,14 +69,7 @@ export function setLinkPrefill(text: string | null) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DOM markers for injected artifacts (used by cleanup) --------------__________ // addRevealedClasses — non-GM only: applies revealed/concealed classes
// ---------------------------------------------------------------------------
const DATA_BUTTON = "data-reveal-button";
const DATA_INJECTED = "data-reveal-injected";
// ---------------------------------------------------------------------------
// addRevealedClasses — GM injects buttons; non-GM applies revealed/concealed
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface HeadingEntry { interface HeadingEntry {
@ -96,33 +91,25 @@ interface HeadingEntry {
* every element (headings included) based on whether any current * every element (headings included) based on whether any current
* heading ancestor is in the cascaded revealed set. * heading ancestor is in the cascaded revealed set.
*/ */
export function addRevealedClasses( export function addRevealedClasses(root: HTMLDivElement, path: string) {
root: HTMLDivElement,
path: string,
completions: CompletionsForInject = { sparkTables: [] },
) {
const state = journalStreamState; const state = journalStreamState;
if (!state.connected) { 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); cleanupInjections(root);
return; return;
} }
const normalized = normalizePath(path); const normalized = normalizePath(path);
const revealed = state.revealedPaths[normalized];
// Always clean up previous injections before applying new ones. // Always clean up previous classes before applying new ones
// This handles role changes, reconnects, and navigation without
// leaving stale buttons or classes behind.
cleanupInjections(root); 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; if (!revealed) return;
const headings = collectHeadings(root); 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 { 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 root
.querySelectorAll(".revealed, .concealed") .querySelectorAll(".revealed, .concealed")
.forEach((el) => el.classList.remove("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 ---- // ---- Non-GM helpers ----
function collectHeadings(root: Element): HeadingEntry[] { function collectHeadings(root: Element): HeadingEntry[] {