/** * JournalInput — chat-style textarea with command dispatch and autocomplete. * * - Enter sends, Shift+Enter inserts newline * - Plain text → "chat" type * - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM) * - `/link path#section` → "link" type * - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json * in CLI mode, or client-side scan of the in-memory file index in dev mode) */ import { Component, createSignal, createEffect, onMount, onCleanup, Show, For, Switch, Match, } from "solid-js"; import { sendMessage, useJournalStream } from "../stores/journalStream"; import { linkPrefill, setLinkPrefill } from "../stores/reveal"; import { useJournalCompletions, ensureCompletions } from "./completions"; import { resolveRollPayload } from "./types/roll"; import { resolveSparkPayload } from "./types/spark"; // ---- Helpers ---- interface CompletionItem { label: string; kind: "command" | "value" | "no-results"; insertText: string; } interface ParsedInput { type: "chat" | "roll" | "spark" | "link"; payload: Record; error?: string; } function parseInput(raw: string): ParsedInput { if (raw.startsWith("/roll ")) { const notation = raw.slice("/roll ".length).trim(); if (!notation) return { type: "roll", payload: {}, error: "Dice notation required" }; return { type: "roll", payload: { notation, label: notation } }; } if (raw.startsWith("/spark ")) { const key = raw.slice("/spark ".length).trim(); if (!key) return { type: "spark", payload: {}, error: "Spark table key required" }; return { type: "spark", payload: { key } }; } if (raw.startsWith("/link ")) { const arg = raw.slice("/link ".length).trim(); if (!arg) return { type: "link", payload: {}, error: "Path required" }; const hashIdx = arg.indexOf("#"); const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx); const section = hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined; return { type: "link", payload: { path, section } }; } // /roll, /spark, or /link with no space — need to complete, don't send if (raw === "/roll" || raw === "/spark" || raw === "/link") { return { type: "chat", payload: {}, error: "Complete the command" }; } return { type: "chat", payload: { text: raw } }; } // ---- Component ---- export const JournalInput: Component = () => { const stream = useJournalStream(); const comp = useJournalCompletions(); const isObserver = () => stream.myRole === "observer"; const isPlayer = () => stream.myRole === "player"; const isGm = () => stream.myRole === "gm"; const [text, setText] = createSignal(""); const [error, setError] = createSignal(null); const [sending, setSending] = createSignal(false); const [showCompletions, setShowCompletions] = createSignal(false); const [selectedIdx, setSelectedIdx] = createSignal(0); let textareaRef!: HTMLTextAreaElement; let completionsRef!: HTMLDivElement; // Ensure completions are loading on mount onMount(() => { void ensureCompletions(); }); // Listen for /link prefill requests from article headings createEffect(() => { const prefilled = linkPrefill(); if (prefilled) { setText(prefilled); setLinkPrefill(null); textareaRef?.focus(); } }); // ---- Send ---- async function handleSend() { const raw = text().trim(); if (!raw) return; // Players / observers: everything is plain chat if (isPlayer() || isObserver()) { const result = sendMessage("chat", { text: raw }); if (!result.success) { setError(result.error); } else { setText(""); } textareaRef?.focus(); return; } const parsed = parseInput(raw); if (parsed.error) { setError(parsed.error); return; } setError(null); setSending(true); // GM roll: resolve the dice result locally if (parsed.type === "roll") { const p = resolveRollPayload( parsed.payload as { notation: string; label?: string }, ); const result = sendMessage("roll", p); if (!result.success) { setError(result.error); } else { setText(""); } setSending(false); textareaRef?.focus(); return; } // GM spark: resolve the spark table roll locally if (parsed.type === "spark") { try { const key = (parsed.payload as { key: string }).key; // Look up filePath from completions data const match = comp.data.sparkTables.find((s) => s.slug === key); const filePath = match?.filePath ?? ""; const p = await resolveSparkPayload({ key, filePath }); const result = sendMessage("spark", p); if (!result.success) { setError(result.error); } else { setText(""); } setSending(false); textareaRef?.focus(); return; } catch (e) { setError(e instanceof Error ? e.message : "Failed to roll spark table"); setSending(false); return; } } const result = sendMessage(parsed.type, parsed.payload); if (!result.success) { setError(result.error); } else { setText(""); } setSending(false); textareaRef?.focus(); } // ---- Completions ---- // ---- Scroll selected completion into view ---- createEffect(() => { const idx = selectedIdx(); if (!showCompletions() || !completionsRef) return; const el = completionsRef.querySelector(`[data-comp-idx="${idx}"]`); if (el) el.scrollIntoView({ block: "nearest" }); }); // ---- Completions ---- function buildCompletions(): CompletionItem[] { const raw = text().trim(); // Only GM gets completions if (!isGm()) return []; const data = comp.data; const commands = [ { label: "/roll", kind: "command" as const, insertText: "/roll " }, { label: "/spark", kind: "command" as const, insertText: "/spark " }, { label: "/link", kind: "command" as const, insertText: "/link " }, ]; // Show commands when user types / or starts typing a command name if (raw.startsWith("/") && !raw.includes(" ")) { const prefix = raw.toLowerCase(); const matches = commands.filter((c) => c.label.toLowerCase().startsWith(prefix), ); return matches.length > 0 ? matches : [{ label: "Unknown command", kind: "no-results", insertText: "" }]; } // After /roll — show dice suggestions if (raw.startsWith("/roll ")) { const prefix = raw.slice("/roll ".length).toLowerCase(); const matches = data.dice .filter( (d) => d.notation.toLowerCase().includes(prefix) || d.label.toLowerCase().includes(prefix), ) .slice(0, 8); if (matches.length === 0) { return [{ label: "No dice found", kind: "no-results", insertText: "" }]; } return matches.map((d) => ({ label: d.notation, kind: "value" as const, insertText: "/roll " + d.notation, })); } // After /spark — show spark table suggestions if (raw.startsWith("/spark ")) { const prefix = raw.slice("/spark ".length).toLowerCase(); const matches = data.sparkTables .filter( (s) => s.slug.toLowerCase().includes(prefix) || s.label.toLowerCase().includes(prefix), ) .slice(0, 8); if (matches.length === 0) { return [ { label: "No spark tables found", kind: "no-results", insertText: "", }, ]; } return matches.map((s) => ({ label: `${s.filePath} § ${s.slug} (${s.notation})`, kind: "value" as const, insertText: `/spark ${s.slug}`, })); } // After /link — show article and heading suggestions if (raw.startsWith("/link ")) { const prefix = raw.slice("/link ".length).toLowerCase(); const matches = data.links .filter( (l) => l.path.toLowerCase().includes(prefix) || l.label.toLowerCase().includes(prefix), ) .slice(0, 8); if (matches.length === 0) { return [ { label: "No links found", kind: "no-results", insertText: "" }, ]; } return matches.map((l) => { const insert = l.section ? `/link ${l.path}#${l.section}` : `/link ${l.path}`; return { label: l.label, kind: "value" as const, insertText: insert, }; }); } return []; } function currentCompletions(): CompletionItem[] { return buildCompletions(); } function acceptCompletion(item: CompletionItem) { if (item.kind === "no-results") return; setText(item.insertText); setShowCompletions(false); textareaRef?.focus(); } function selectCompletion(dir: "up" | "down") { const comps = currentCompletions(); if (comps.length === 0) return; setSelectedIdx((prev) => { if (dir === "down") return (prev + 1) % comps.length; return (prev - 1 + comps.length) % comps.length; }); } // ---- Keyboard ---- function handleKeyDown(e: KeyboardEvent) { const comps = currentCompletions(); const open = showCompletions(); // When opening completions with Tab, if the text matches a command prefix // and there are options, accept the first. Otherwise just show. if (e.key === "Tab") { if (open) { e.preventDefault(); if (comps.length > 0 && comps[selectedIdx()].kind !== "no-results") { acceptCompletion(comps[selectedIdx()]); } return; } // If not open and starts with /, open completions (GM only) const raw = text(); if (isGm() && raw.startsWith("/")) { e.preventDefault(); setShowCompletions(true); setSelectedIdx(0); return; } return; } if (open && comps.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); selectCompletion("down"); return; } if (e.key === "ArrowUp") { e.preventDefault(); selectCompletion("up"); return; } if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") { e.preventDefault(); acceptCompletion(comps[selectedIdx()]); return; } if (e.key === "Escape") { setShowCompletions(false); return; } } // Send if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } } function handleInput(e: InputEvent) { const input = e.currentTarget as HTMLTextAreaElement; const raw = input.value; setText(raw); // Completions visibility — keep open while user types any / (GM only) if (isGm() && raw.startsWith("/")) { setShowCompletions(true); setSelectedIdx(0); } else { setShowCompletions(false); } // Auto-resize textareaRef.style.height = "auto"; textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px"; } // ---- Click outside ---- onMount(() => { const handler = (e: MouseEvent) => { if (completionsRef && !completionsRef.contains(e.target as Node)) { setShowCompletions(false); } }; document.addEventListener("mousedown", handler); onCleanup(() => document.removeEventListener("mousedown", handler)); }); // Completions placeholder — shown in the dropdown area when no matches function renderCompletionsDropdown() { const comps = currentCompletions(); if (comps.length === 0) return null; return (
{(item, idx) => (
acceptCompletion(item)} onMouseEnter={() => item.kind !== "no-results" && setSelectedIdx(idx()) } > —} > {item.kind === "command" ? "cmd" : "val"} {item.label}
)}
); } return (
{/* Completions loading / error / empty teaser */}
Loading completions…
{(comp.state as any).message || "Failed to load completions"}
{renderCompletionsDropdown()}
{/* Textarea + actions */}
You are observing. Open this panel on another device to join as GM or player.
} >