From 4eaf6aab161ad358134b94eaff0df32738d25c3b Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 7 Jul 2026 23:14:04 +0800 Subject: [PATCH] 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. --- src/components/RevealManager.tsx | 173 +++++++++++++++++++++++- src/components/journal/types/link.tsx | 12 +- src/components/stores/reveal.ts | 186 ++------------------------ 3 files changed, 191 insertions(+), 180 deletions(-) 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) => ( + +