/** * RevealManager — mounts as a child of
and reactively applies * reveal classes (non-GM) or injects action buttons (GM) whenever the * 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, createSignal, onCleanup, Show, } from "solid-js"; import { Portal } from "solid-js/web"; import { useLocation } from "@solidjs/router"; import { useArticleDom } from "./Article"; import { journalStreamState } from "./stores/journalStream"; import { useJournalCompletions } from "./journal/completions"; import { addRevealedClasses, cleanupInjections, setActionPrefill, normalizePath, } from "./stores/reveal"; // --------------------------------------------------------------------------- // SVG icons // --------------------------------------------------------------------------- const LINK_SVG = '' + '' + '' + ""; const SPARK_SVG = '' + '' + ""; // --------------------------------------------------------------------------- // Floating button state // --------------------------------------------------------------------------- interface FloatingButton { rect: DOMRect; action: () => void; title: string; svg: string; color: string; } const [floating, setFloating] = createSignal(null); let hideTimer: ReturnType | undefined; function show(btn: FloatingButton) { clearTimeout(hideTimer); setFloating(btn); } function hide() { hideTimer = setTimeout(() => setFloating(null), 200); } // --------------------------------------------------------------------------- // RevealManager // --------------------------------------------------------------------------- export const RevealManager: Component = () => { const contentDom = useArticleDom(); const location = useLocation(); const comp = useJournalCompletions(); // ---- Reveal / conceal classes ---- createEffect(() => { const dom = contentDom(); if (!dom) return; const state = journalStreamState; void state.connected; void state.myRole; addRevealedClasses(dom, location.pathname); }); onCleanup(() => { const dom = contentDom(); if (dom) cleanupInjections(dom); }); // ---- 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 ( {(btn) => (