diff --git a/src/App.tsx b/src/App.tsx index 7df0131..ea6bac2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = () => {
addRevealedClasses(dom, location.pathname)} + onDom={(dom) => + addRevealedClasses(dom, location.pathname, comp.data) + } /> diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index a9d56b4..73057ab 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -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} diff --git a/src/components/stores/reveal.ts b/src/components/stores/reveal.ts index 08955a9..cb02dc6 100644 --- a/src/components/stores/reveal.ts +++ b/src/components/stores/reveal.ts @@ -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(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(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 = '' + ""; +// ---- 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 = + '' + + '' + + ""; + // ---- Non-GM helpers ---- function collectHeadings(root: Element): HeadingEntry[] {