diff --git a/src/components/RevealManager.tsx b/src/components/RevealManager.tsx index 919ba40..06170f4 100644 --- a/src/components/RevealManager.tsx +++ b/src/components/RevealManager.tsx @@ -2,31 +2,92 @@ * 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, 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 { useArticleDom } from "./Article"; import { journalStreamState } from "./stores/journalStream"; import { useJournalCompletions } from "./journal/completions"; -import { addRevealedClasses, cleanupInjections } from "./stores/reveal"; +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; - // 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); + addRevealedClasses(dom, location.pathname); }); onCleanup(() => { @@ -34,7 +95,107 @@ export const RevealManager: Component = () => { 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 ( + + {(btn) => ( + +