feat: add spark table roll buttons for GMs

Introduces the ability for GMs to inject spark roll buttons into
tables that match existing spark completions.

- Replaces `linkPrefill` with a more flexible `actionPrefill` system
  to support multiple command types (e.g., `/link`, `/spark`).
- Implements `injectSparkButtons` to identify spark tables in the
  DOM and insert action buttons.
- Updates `JournalInput` to handle structured prefill actions.
This commit is contained in:
hypercross 2026-07-07 21:38:43 +08:00
parent e801ac9b5f
commit 56af7dea42
3 changed files with 150 additions and 19 deletions

View File

@ -2,6 +2,7 @@ import { Component, createEffect, createMemo, createSignal } from "solid-js";
import { useLocation } from "@solidjs/router";
import { useJournalStream } from "./components/stores/journalStream";
import { addRevealedClasses } from "./components/stores/reveal";
import { useJournalCompletions } from "./components/journal/completions";
// 导入组件以注册自定义元素
import "./components";
@ -18,6 +19,7 @@ import { JournalPanel } from "./components/journal";
const App: Component = () => {
const location = useLocation();
const stream = useJournalStream();
const comp = useJournalCompletions();
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
const [isDocOpen, setIsDocOpen] = createSignal(false);
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
@ -124,7 +126,9 @@ const App: Component = () => {
<Article
class="prose text-black prose-sm max-w-full flex-1"
src={currentPath()}
onDom={(dom) => addRevealedClasses(dom, location.pathname)}
onDom={(dom) =>
addRevealedClasses(dom, location.pathname, comp.data)
}
/>
</main>
</div>

View File

@ -21,7 +21,7 @@ import {
Match,
} from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import { linkPrefill, setLinkPrefill } from "../stores/reveal";
import { actionPrefill, setActionPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
@ -97,12 +97,12 @@ export const JournalInput: Component = () => {
void ensureCompletions();
});
// Listen for /link prefill requests from article headings
// Listen for action prefill requests from article buttons (heading /link, spark table roll, etc.)
createEffect(() => {
const prefilled = linkPrefill();
if (prefilled) {
setText(prefilled);
setLinkPrefill(null);
const action = actionPrefill();
if (action) {
setText(`${action.command} ${action.text}`);
setActionPrefill(null);
textareaRef?.focus();
}
});
@ -498,7 +498,7 @@ export const JournalInput: Component = () => {
onKeyDown={handleKeyDown}
placeholder={
isGm()
? "Type a message, or /roll or /link..."
? "Type a message, or /roll, /spark, or /link..."
: "Type a message..."
}
rows={2}

View File

@ -1,6 +1,6 @@
/**
* DOM reveal logic applies revealed/concealed classes to article headings
* for non-GM clients, and injects "send to stream" link buttons for GM.
* for non-GM clients, and injects hover-action buttons for GM.
*
* All state is read from the journal stream store via `journalStreamState`.
*/
@ -42,11 +42,29 @@ export function isPathRevealed(path: string, section?: string): boolean {
}
// ---------------------------------------------------------------------------
// linkPrefill signal — bridges article heading buttons → JournalInput
// actionPrefill signal — bridges article action buttons → JournalInput
// ---------------------------------------------------------------------------
/** Shared signal: when set, JournalInput prefills the textarea with this value. */
export const [linkPrefill, setLinkPrefill] = createSignal<string | null>(null);
/** A prefill action: what command + text to place in the journal input. */
export interface PrefillAction {
command: string;
text: string;
}
/** Shared signal: when set, JournalInput prefills the textarea. */
export const [actionPrefill, setActionPrefill] =
createSignal<PrefillAction | null>(null);
// Keep the old signal for backward compat; it's a convenience wrapper.
/** @deprecated Use actionPrefill instead. */
export const linkPrefill = () => {
const a = actionPrefill();
return a?.command === "/link" ? a.text : null;
};
/** @deprecated Use setActionPrefill instead. */
export function setLinkPrefill(text: string | null) {
setActionPrefill(text ? { command: "/link", text } : null);
}
// ---------------------------------------------------------------------------
// addRevealedClasses — GM injects buttons; non-GM applies revealed/concealed
@ -71,15 +89,19 @@ 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) {
export function addRevealedClasses(
root: HTMLDivElement,
path: string,
completions: CompletionsForInject = { sparkTables: [] },
) {
const state = journalStreamState;
if (!state.connected) return;
const normalized = normalizePath(path);
// ---- GM mode: inject "send to stream" buttons on headings ----
// ---- GM mode: inject action buttons on headings and spark tables ----
if (state.myRole === "gm") {
injectSendButtons(root, normalized);
injectActionButtons(root, normalized, completions);
return;
}
@ -92,15 +114,36 @@ export function addRevealedClasses(root: HTMLDivElement, path: string) {
applyClasses(root, revealedSet);
}
// ---------------------------------------------------------------------------
// 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 injectSendButtons(root: Element, normalizedPath: string) {
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 = createSendButton(normalizedPath, headingText);
const btn = createLinkButton(normalizedPath, headingText);
el.insertBefore(btn, el.firstChild);
(el as HTMLElement).classList.add("group", "flex", "items-center");
}
@ -108,9 +151,14 @@ function injectSendButtons(root: Element, normalizedPath: string) {
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);
}
function createSendButton(path: string, headingId: string): HTMLButtonElement {
// ---- 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 " +
@ -120,7 +168,7 @@ function createSendButton(path: string, headingId: string): HTMLButtonElement {
btn.innerHTML = LINK_SVG;
btn.addEventListener("click", (e) => {
e.stopPropagation();
setLinkPrefill(`/link ${path}#${headingId}`);
setActionPrefill({ command: "/link", text: `/link ${path}#${headingId}` });
});
return btn;
}
@ -133,6 +181,85 @@ const LINK_SVG =
'<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) ----
const DICE_HEADER_RE = /^d\d+$/i;
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;
const tables = root.querySelectorAll("table");
tables.forEach((table) => {
// Find the first header row — check thead first, then first tr
const thead = table.querySelector("thead");
const firstRow =
thead?.rows[0] ??
table.querySelector("tbody tr:first-child, tr:first-child");
if (!firstRow || !(firstRow instanceof HTMLTableRowElement)) return;
const firstCell = firstRow.cells[0];
if (!firstCell) return;
const firstHeader = firstCell.textContent?.trim() ?? "";
if (!DICE_HEADER_RE.test(firstHeader)) return;
// Collect data headers from the remaining cells
const dataHeaders: string[] = [];
for (let i = 1; i < firstRow.cells.length; i++) {
const text = firstRow.cells[i].textContent?.trim() ?? "";
if (text) dataHeaders.push(text);
}
// Match against completions: find the spark table with matching
// notation and data headers (order matters)
const match = pageTables.find(
(st) =>
st.notation.toLowerCase() === firstHeader.toLowerCase() &&
st.headers.length === dataHeaders.length &&
st.headers.every((h, i) => h === dataHeaders[i]),
);
if (!match) return;
// Inject the spark button
const btn = createSparkButton(match.slug);
const wrapper = table.parentElement;
if (wrapper) {
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[] {