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.
This commit is contained in:
parent
aa326f38a0
commit
4eaf6aab16
|
|
@ -2,31 +2,92 @@
|
|||
* 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, 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 =
|
||||
'<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;
|
||||
|
||||
// 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 (
|
||||
<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;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ function fileNameFromPath(path: string): string {
|
|||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
/** Encode a path, preserving / separators but encoding each segment */
|
||||
function encodePath(path: string): string {
|
||||
return path
|
||||
.split("/")
|
||||
.map((seg) => encodeURIComponent(seg))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
/** Section slug → human-readable title */
|
||||
function slugToTitle(slug: string): string {
|
||||
return slug
|
||||
|
|
@ -37,8 +45,8 @@ const RevealLink: Component<LinkPayload> = (p) => {
|
|||
const navigate = useNavigate();
|
||||
|
||||
const target = () => {
|
||||
let t = p.path;
|
||||
if (p.section) t += `#${p.section}`;
|
||||
let t = encodePath(p.path);
|
||||
if (p.section) t += `#${encodeURIComponent(p.section)}`;
|
||||
return t;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
/**
|
||||
* DOM reveal logic — applies revealed/concealed classes to article headings
|
||||
* for non-GM clients, and injects hover-action buttons for GM.
|
||||
* for non-GM clients.
|
||||
*
|
||||
* All state is read from the journal stream store via `journalStreamState`.
|
||||
*
|
||||
* Action buttons (link, spark) are handled by the RevealManager Solid component.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
|
|
@ -67,14 +69,7 @@ export function setLinkPrefill(text: string | null) {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM markers for injected artifacts (used by cleanup) --------------__________
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DATA_BUTTON = "data-reveal-button";
|
||||
const DATA_INJECTED = "data-reveal-injected";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// addRevealedClasses — GM injects buttons; non-GM applies revealed/concealed
|
||||
// addRevealedClasses — non-GM only: applies revealed/concealed classes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HeadingEntry {
|
||||
|
|
@ -96,33 +91,25 @@ interface HeadingEntry {
|
|||
* every element (headings included) based on whether any current
|
||||
* heading ancestor is in the cascaded revealed set.
|
||||
*/
|
||||
export function addRevealedClasses(
|
||||
root: HTMLDivElement,
|
||||
path: string,
|
||||
completions: CompletionsForInject = { sparkTables: [] },
|
||||
) {
|
||||
export function addRevealedClasses(root: HTMLDivElement, path: string) {
|
||||
const state = journalStreamState;
|
||||
if (!state.connected) {
|
||||
// Disconnected — scrub all artifacts so the page looks clean
|
||||
cleanupInjections(root);
|
||||
return;
|
||||
}
|
||||
|
||||
// GM sees everything — no classes needed
|
||||
if (state.myRole === "gm") {
|
||||
cleanupInjections(root);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizePath(path);
|
||||
const revealed = state.revealedPaths[normalized];
|
||||
|
||||
// Always clean up previous injections before applying new ones.
|
||||
// This handles role changes, reconnects, and navigation without
|
||||
// leaving stale buttons or classes behind.
|
||||
// Always clean up previous classes before applying new ones
|
||||
cleanupInjections(root);
|
||||
|
||||
// ---- GM mode: inject action buttons on headings and spark tables ----
|
||||
if (state.myRole === "gm") {
|
||||
injectActionButtons(root, normalized, completions);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Non-GM mode: apply revealed/concealed classes ----
|
||||
const revealed = state.revealedPaths[normalized];
|
||||
if (!revealed) return;
|
||||
|
||||
const headings = collectHeadings(root);
|
||||
|
|
@ -131,160 +118,15 @@ export function addRevealedClasses(
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup — strip all previously injected artifacts
|
||||
// Cleanup — strip previously applied revealed/concealed classes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function cleanupInjections(root: Element): void {
|
||||
// Remove injected buttons
|
||||
root.querySelectorAll(`[${DATA_BUTTON}]`).forEach((el) => el.remove());
|
||||
|
||||
// Clean up injected classes and inline styles on headings / wrappers
|
||||
root.querySelectorAll(`[${DATA_INJECTED}]`).forEach((el) => {
|
||||
const htmlEl = el as HTMLElement;
|
||||
htmlEl.classList.remove("group", "flex", "items-center");
|
||||
htmlEl.style.position = "";
|
||||
htmlEl.removeAttribute(DATA_INJECTED);
|
||||
});
|
||||
|
||||
// Remove revealed / concealed classes from all elements
|
||||
root
|
||||
.querySelectorAll(".revealed, .concealed")
|
||||
.forEach((el) => el.classList.remove("revealed", "concealed"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Completions payload type (mirrors the completions module shape)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SparkTableCompletion {
|
||||
label: string;
|
||||
notation: string;
|
||||
slug: string;
|
||||
filePath: string;
|
||||
headers: string[];
|
||||
}
|
||||
|
||||
export interface CompletionsForInject {
|
||||
sparkTables: SparkTableCompletion[];
|
||||
}
|
||||
|
||||
// ---- GM helpers ----
|
||||
|
||||
function injectActionButtons(
|
||||
root: Element,
|
||||
normalizedPath: string,
|
||||
completions: CompletionsForInject,
|
||||
) {
|
||||
// 1. Inject link buttons on headings
|
||||
const walk = (el: Element) => {
|
||||
const tag = el.tagName.toUpperCase();
|
||||
if (HEADING_TAGS.has(tag)) {
|
||||
const headingText = el.id || el.textContent?.trim() || "";
|
||||
if (headingText) {
|
||||
const btn = createLinkButton(normalizedPath, headingText);
|
||||
btn.setAttribute(DATA_BUTTON, "");
|
||||
el.insertBefore(btn, el.firstChild);
|
||||
const htmlEl = el as HTMLElement;
|
||||
htmlEl.setAttribute(DATA_INJECTED, "");
|
||||
htmlEl.classList.add("group", "flex", "items-center");
|
||||
}
|
||||
}
|
||||
for (const child of el.children) walk(child);
|
||||
};
|
||||
for (const child of root.children) walk(child);
|
||||
|
||||
// 2. Inject spark buttons on spark tables
|
||||
injectSparkButtons(root, normalizedPath, completions);
|
||||
}
|
||||
|
||||
// ---- Link button (headings) ----
|
||||
|
||||
function createLinkButton(path: string, headingId: string): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.className =
|
||||
"inline-flex items-center justify-center w-5 h-5 mr-1 -ml-6 " +
|
||||
"text-gray-300 hover:text-blue-500 hover:bg-blue-50 rounded " +
|
||||
"transition-colors align-middle opacity-0 group-hover:opacity-100 focus:opacity-100";
|
||||
btn.title = "Send /link to stream";
|
||||
btn.innerHTML = LINK_SVG;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setActionPrefill({ command: "/link", text: `/link ${path}#${headingId}` });
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
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>";
|
||||
|
||||
// ---- Spark button (tables) ----
|
||||
|
||||
function injectSparkButtons(
|
||||
root: Element,
|
||||
normalizedPath: string,
|
||||
completions: CompletionsForInject,
|
||||
) {
|
||||
// Only consider spark tables whose filePath matches the current page
|
||||
const pageTables = completions.sparkTables.filter(
|
||||
(st) => st.filePath === normalizedPath,
|
||||
);
|
||||
if (pageTables.length === 0) return;
|
||||
|
||||
// Spark tables are rendered as <md-table data-spark="columnSlug">
|
||||
const sparkTables = root.querySelectorAll("md-table[data-spark]");
|
||||
sparkTables.forEach((el) => {
|
||||
const colSlug = el.getAttribute("data-spark");
|
||||
if (!colSlug) return;
|
||||
|
||||
// Find the matching completions entry by column slug
|
||||
const match = pageTables.find((st) => {
|
||||
// st.slug is the combined slug (pageName-columnSlug);
|
||||
// colSlug is just the column part. Match by checking if
|
||||
// the combined slug ends with the column slug.
|
||||
return st.slug.endsWith(`-${colSlug}`);
|
||||
});
|
||||
if (!match) return;
|
||||
|
||||
// Inject the spark button into the <md-table> wrapper
|
||||
const btn = createSparkButton(match.slug);
|
||||
btn.setAttribute(DATA_BUTTON, "");
|
||||
const wrapper = el.parentElement;
|
||||
if (wrapper) {
|
||||
wrapper.setAttribute(DATA_INJECTED, "");
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.classList.add("group");
|
||||
wrapper.insertBefore(btn, wrapper.firstChild);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createSparkButton(combinedSlug: string): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.className =
|
||||
"absolute top-0.5 right-0.5 z-10 inline-flex items-center justify-center " +
|
||||
"w-6 h-6 text-purple-400 hover:text-purple-600 hover:bg-purple-50 rounded " +
|
||||
"transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100";
|
||||
btn.title = "Roll spark table";
|
||||
btn.innerHTML = SPARK_SVG;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setActionPrefill({ command: "/spark", text: `/spark ${combinedSlug}` });
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
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>";
|
||||
|
||||
// ---- Non-GM helpers ----
|
||||
|
||||
function collectHeadings(root: Element): HeadingEntry[] {
|
||||
|
|
|
|||
Loading…
Reference in New Issue