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:
parent
e801ac9b5f
commit
56af7dea42
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
Loading…
Reference in New Issue