diff --git a/src/App.tsx b/src/App.tsx index f25fcc8..0b6028a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { DataSourceDialog, RevealManager, ReactiveStatManager, + CommandLinkManager, } from "./components"; import { generateToc, type FileNode, type TocNode } from "./data-loader"; import { JournalPanel } from "./components/journal"; @@ -158,6 +159,7 @@ const App: Component = () => { > + diff --git a/src/components/CommandLinkManager.tsx b/src/components/CommandLinkManager.tsx new file mode 100644 index 0000000..4861f03 --- /dev/null +++ b/src/components/CommandLinkManager.tsx @@ -0,0 +1,224 @@ +/** + * CommandLinkManager — mounts as a child of
and intercepts + * clicks on command-like links (>roll, >spark, >link, >stat) to send + * them as journal stream messages instead of navigating. + * + * Uses capture-phase event delegation so it fires before the Article + * component's own link interception (which would otherwise navigate). + */ + +import { Component, onCleanup } from "solid-js"; +import { useArticleDom } from "./Article"; +import { useJournalStream, sendMessage } from "./stores/journalStream"; +import { useJournalCompletions } from "./journal/completions"; +import { parseInput } from "./journal/command-parser"; +import { resolveRollPayload } from "./journal/types/roll"; +import { resolveSparkPayload } from "./journal/types/spark"; +import { + resolveStatRoll, + resolveTemplateSet, + canModifyStat, + fullKey, + findStatDef, +} from "./journal/stat-helpers"; + +/** Commands that get intercepted as stream messages. */ +const COMMAND_PREFIXES = [">roll", ">spark", ">link", ">stat"]; + +/** + * Extract the command text from an href. + * Percent-encoded spaces (%20) are decoded automatically by decodeURIComponent. + */ +function hrefToCommand(href: string): string | null { + // Exclude external URLs and pure anchors + if (/^(https?:|\/\/)/i.test(href)) return null; + if (href === "#" || href.startsWith("#!")) return null; + + // Decode percent-encoding (%20 → space, etc.) + let cleaned = decodeURIComponent(href); + + // Strip trailing .md extension (markdown links may include it) + if (cleaned.endsWith(".md")) { + cleaned = cleaned.slice(0, -3); + } + + // Only consider links starting with a command prefix + if (!COMMAND_PREFIXES.some((p) => cleaned.startsWith(p))) return null; + + return cleaned.trim(); +} + +// We need unwrap helper for sendMessage results +function unwrap(r: { success: true; msg: R } | { success: false; error: string }) { + return r.success + ? ({ ok: true, err: undefined } as const) + : ({ ok: false, err: r.error } as const); +} + +export const CommandLinkManager: Component = () => { + const contentDom = useArticleDom(); + const stream = useJournalStream(); + const comp = useJournalCompletions(); + + const isObserver = () => stream.myRole === "observer"; + const isPlayer = () => stream.myRole === "player"; + + // ---- Click interceptor (capture phase) ---- + + const onClick = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement).closest("a[href]"); + if (!anchor) return; + + const href = anchor.getAttribute("href"); + if (!href) return; + + const command = hrefToCommand(href); + if (!command) return; + + // This is a command link — intercept it + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + dispatchCommand(command); + }; + + const dom = contentDom(); + if (dom) { + // Use capture phase to beat the Article component's bubble-phase handler + dom.addEventListener("click", onClick, true); + } + + onCleanup(() => { + const d = contentDom(); + if (d) d.removeEventListener("click", onClick, true); + }); + + // ---- Command dispatch ---- + + function dispatchCommand(raw: string) { + // Convert > prefix to / so parseInput understands it + const command = raw.startsWith(">") ? "/" + raw.slice(1) : raw; + + // Observers: everything goes as chat + if (isObserver()) { + sendMessage("chat", { text: command }); + return; + } + + const parsed = parseInput(command); + if (parsed.error) return; + + // Players: chat + stat commands only + if (isPlayer()) { + if (parsed.type === "chat") { + sendMessage("chat", { text: raw }); + return; + } + if (parsed.type === "stat") { + dispatchStat(parsed.payload as Record); + return; + } + return; + } + + // GM: all commands + if (parsed.type === "roll") { + const p = resolveRollPayload( + parsed.payload as { notation: string; label?: string }, + ); + sendMessage("roll", p); + } else if (parsed.type === "spark") { + void (async () => { + try { + const key = (parsed.payload as { key: string }).key; + const match = comp.data.sparkTables.find((s) => s.slug === key); + const csvPath = match?.csvPath ?? ""; + const remix = match?.remix ?? false; + const p = await resolveSparkPayload({ key, csvPath, remix }); + sendMessage("spark", p); + } catch { + // silently ignore spark table errors from links + } + })(); + } else if (parsed.type === "stat") { + dispatchStat(parsed.payload as Record); + } else { + sendMessage(parsed.type, parsed.payload); + } + } + + /** Resolve bare key -> full key (copied from JournalInput for standalone use). */ + function resolveKey( + inputKey: string, + statDefs: typeof comp.data.stats, + playerName: string, + ): string { + if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) return inputKey; + const def = statDefs.find((d) => d.key === inputKey); + if (def) return fullKey(def, playerName); + return inputKey; + } + + function dispatchStat(payload: Record) { + const p = payload as { action?: string; key?: string; value?: string }; + + if (p.action === "roll" && p.key) { + const resolved = resolveStatRoll( + p.key, + comp.data.stats, + stream.stats, + stream.myName, + comp.data.statTemplates, + ); + if (resolved.error) return; + + const result = sendMessage("stat", { + action: "set", + key: resolved.fullKey, + value: resolved.value, + }); + if (!unwrap(result).ok) return; + + if (resolved.modifiers) { + for (const [mk, mv] of Object.entries(resolved.modifiers)) { + sendMessage("stat", { action: "set", key: mk, value: mv }); + } + } + return; + } + + if (!p.action || !p.key) return; + + const fk = resolveKey(p.key, comp.data.stats, stream.myName); + + if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) { + return; + } + + sendMessage("stat", { action: p.action, key: fk, value: p.value }); + + if (p.action === "set" && p.value) { + const def = findStatDef(fk, comp.data.stats, stream.myName); + if (def) { + const modifiers = resolveTemplateSet( + def, + p.value, + comp.data.stats, + stream.stats, + stream.myName, + comp.data.statTemplates, + ); + if (modifiers) { + for (const [mk, mv] of Object.entries(modifiers)) { + sendMessage("stat", { action: "set", key: mk, value: mv }); + } + } + } + } + } + + return null; // renders nothing — purely event-based +}; + +export default CommandLinkManager; diff --git a/src/components/index.ts b/src/components/index.ts index b455d86..ce2f4e3 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -15,6 +15,7 @@ import "./md-yarn-spinner"; export { Article } from "./Article"; export type { ArticleProps } from "./Article"; export { RevealManager } from "./RevealManager"; +export { CommandLinkManager } from "./CommandLinkManager"; export { ReactiveStatManager } from "./journal/ReactiveStatManager"; export { MobileSidebar, DesktopSidebar } from "./Sidebar"; export type { SidebarProps } from "./Sidebar";