201 lines
6.2 KiB
TypeScript
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;
|