ttrpg-tools/src/components/RevealManager.tsx

201 lines
6.2 KiB
TypeScript

/**
* RevealManager — mounts as a child of <Article> 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 =
'<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 = () => {
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 (only when connected as GM) ----
createEffect(() => {
const dom = contentDom();
const state = journalStreamState;
if (!dom || state.myRole !== "gm" || !state.connected) return;
const normalized = normalizePath(decodeURIComponent(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;
if (combinedSlug) {
show({
rect: sparkTable.getBoundingClientRect(),
action: () =>
setActionPrefill({
command: "/spark",
text: 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: `${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;