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 { useLocation } from "@solidjs/router";
|
||||||
import { useJournalStream } from "./components/stores/journalStream";
|
import { useJournalStream } from "./components/stores/journalStream";
|
||||||
import { addRevealedClasses } from "./components/stores/reveal";
|
import { addRevealedClasses } from "./components/stores/reveal";
|
||||||
|
import { useJournalCompletions } from "./components/journal/completions";
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
import "./components";
|
import "./components";
|
||||||
|
|
@ -18,6 +19,7 @@ import { JournalPanel } from "./components/journal";
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
const comp = useJournalCompletions();
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||||
|
|
@ -124,7 +126,9 @@ const App: Component = () => {
|
||||||
<Article
|
<Article
|
||||||
class="prose text-black prose-sm max-w-full flex-1"
|
class="prose text-black prose-sm max-w-full flex-1"
|
||||||
src={currentPath()}
|
src={currentPath()}
|
||||||
onDom={(dom) => addRevealedClasses(dom, location.pathname)}
|
onDom={(dom) =>
|
||||||
|
addRevealedClasses(dom, location.pathname, comp.data)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
Match,
|
Match,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||||
import { linkPrefill, setLinkPrefill } from "../stores/reveal";
|
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
||||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
import { resolveSparkPayload } from "./types/spark";
|
||||||
|
|
@ -97,12 +97,12 @@ export const JournalInput: Component = () => {
|
||||||
void ensureCompletions();
|
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(() => {
|
createEffect(() => {
|
||||||
const prefilled = linkPrefill();
|
const action = actionPrefill();
|
||||||
if (prefilled) {
|
if (action) {
|
||||||
setText(prefilled);
|
setText(`${action.command} ${action.text}`);
|
||||||
setLinkPrefill(null);
|
setActionPrefill(null);
|
||||||
textareaRef?.focus();
|
textareaRef?.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -498,7 +498,7 @@ export const JournalInput: Component = () => {
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
isGm()
|
isGm()
|
||||||
? "Type a message, or /roll or /link..."
|
? "Type a message, or /roll, /spark, or /link..."
|
||||||
: "Type a message..."
|
: "Type a message..."
|
||||||
}
|
}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* DOM reveal logic — applies revealed/concealed classes to article headings
|
* 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`.
|
* 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. */
|
/** A prefill action: what command + text to place in the journal input. */
|
||||||
export const [linkPrefill, setLinkPrefill] = createSignal<string | null>(null);
|
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
|
// addRevealedClasses — GM injects buttons; non-GM applies revealed/concealed
|
||||||
|
|
@ -71,15 +89,19 @@ interface HeadingEntry {
|
||||||
* every element (headings included) based on whether any current
|
* every element (headings included) based on whether any current
|
||||||
* heading ancestor is in the cascaded revealed set.
|
* 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;
|
const state = journalStreamState;
|
||||||
if (!state.connected) return;
|
if (!state.connected) return;
|
||||||
|
|
||||||
const normalized = normalizePath(path);
|
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") {
|
if (state.myRole === "gm") {
|
||||||
injectSendButtons(root, normalized);
|
injectActionButtons(root, normalized, completions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,15 +114,36 @@ export function addRevealedClasses(root: HTMLDivElement, path: string) {
|
||||||
applyClasses(root, revealedSet);
|
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 ----
|
// ---- 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 walk = (el: Element) => {
|
||||||
const tag = el.tagName.toUpperCase();
|
const tag = el.tagName.toUpperCase();
|
||||||
if (HEADING_TAGS.has(tag)) {
|
if (HEADING_TAGS.has(tag)) {
|
||||||
const headingText = el.id || el.textContent?.trim() || "";
|
const headingText = el.id || el.textContent?.trim() || "";
|
||||||
if (headingText) {
|
if (headingText) {
|
||||||
const btn = createSendButton(normalizedPath, headingText);
|
const btn = createLinkButton(normalizedPath, headingText);
|
||||||
el.insertBefore(btn, el.firstChild);
|
el.insertBefore(btn, el.firstChild);
|
||||||
(el as HTMLElement).classList.add("group", "flex", "items-center");
|
(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 el.children) walk(child);
|
||||||
};
|
};
|
||||||
for (const child of root.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");
|
const btn = document.createElement("button");
|
||||||
btn.className =
|
btn.className =
|
||||||
"inline-flex items-center justify-center w-5 h-5 mr-1 -ml-6 " +
|
"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.innerHTML = LINK_SVG;
|
||||||
btn.addEventListener("click", (e) => {
|
btn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setLinkPrefill(`/link ${path}#${headingId}`);
|
setActionPrefill({ command: "/link", text: `/link ${path}#${headingId}` });
|
||||||
});
|
});
|
||||||
return btn;
|
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"/>' +
|
'<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>";
|
"</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 ----
|
// ---- Non-GM helpers ----
|
||||||
|
|
||||||
function collectHeadings(root: Element): HeadingEntry[] {
|
function collectHeadings(root: Element): HeadingEntry[] {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue