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:
+14
-172
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user