/** * JournalInput — chat-style textarea with command dispatch and autocomplete. * * - Enter sends, Shift+Enter inserts newline * - Plain text → "narrative" type * - `/roll 3d6kh1` → "roll.request" type * - `/link path#section` → "article.reveal" type * - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json) */ import { Component, createSignal, onMount, onCleanup, Show, For } from "solid-js"; import { sendMessage, revertLatest, canRevert, useJournalStream } from "../stores/journalStream"; import { useJournalCompletions, type DiceCompletion, type LinkCompletion } from "./completions"; interface CompletionItem { label: string; /** "command" | "value" — used for styling and insertion */ kind: "command" | "value"; insertText: string; } interface ParsedInput { type: "narrative" | "roll.request" | "article.reveal"; payload: Record; error?: string; } export const JournalInput: Component = () => { const stream = useJournalStream(); const completions = useJournalCompletions(); 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; // ---------- Parsing ---------- function parseInput(raw: string): ParsedInput { if (raw.startsWith("/roll ")) { const notation = raw.slice("/roll ".length).trim(); if (!notation) return { type: "roll.request", payload: {}, error: "Dice notation required" }; return { type: "roll.request", payload: { notation, label: notation } }; } if (raw.startsWith("/link ")) { const arg = raw.slice("/link ".length).trim(); if (!arg) return { type: "article.reveal", payload: {}, error: "Path required" }; const [path, section] = parseLinkArg(arg); return { type: "article.reveal", payload: { path, section: section ?? undefined } }; } // /roll or /link with no space — show completions, don't send if (raw === "/roll" || raw === "/link") { return { type: "narrative", payload: {}, error: "Complete the command" }; } return { type: "narrative", payload: { text: raw } }; } /** Parse "path#section" or just "path" */ function parseLinkArg(arg: string): [string, string | null] { const hashIdx = arg.indexOf("#"); if (hashIdx === -1) return [arg, null]; return [arg.slice(0, hashIdx), arg.slice(hashIdx + 1) || null]; } // ---------- Send ---------- function handleSend() { const raw = text().trim(); if (!raw) return; const parsed = parseInput(raw); if (parsed.error) { setError(parsed.error); return; } setError(null); setSending(true); const result = sendMessage(parsed.type, parsed.payload); if (!result.success) { setError(result.error); } else { setText(""); } setSending(false); textareaRef?.focus(); } function handleRevert() { revertLatest(); } // ---------- Completions ---------- function buildCompletions(): CompletionItem[] { const raw = text().trim(); // Show commands when user types / if (raw === "" || raw === "/") { return [ { label: "/roll", kind: "command", insertText: "/roll " }, { label: "/link", kind: "command", insertText: "/link " }, ]; } // After /roll — show dice suggestions if (raw.startsWith("/roll ")) { const prefix = raw.slice("/roll ".length).toLowerCase(); return completions.dice .filter( (d) => d.notation.toLowerCase().includes(prefix) || d.label.toLowerCase().includes(prefix), ) .slice(0, 8) .map((d) => ({ label: d.notation, kind: "value" as const, insertText: "/roll " + d.notation, })); } // After /link — show article and heading suggestions if (raw.startsWith("/link ")) { const prefix = raw.slice("/link ".length).toLowerCase(); return completions.links .filter( (l) => l.path.toLowerCase().includes(prefix) || l.label.toLowerCase().includes(prefix), ) .slice(0, 8) .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) { 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(); // Completions: navigate & accept 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 === "Tab" || e.key === "Enter") { 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() { const raw = text(); // Show completions when user types / at the start if (raw === "/" || raw.startsWith("/roll ") || raw.startsWith("/link ")) { 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)); }); const showRevert = () => canRevert() && stream.myName === "gm"; return (
{/* Completions dropdown — positioned above the textarea */} 0}>
{(item, idx) => (
acceptCompletion(item)} onMouseEnter={() => setSelectedIdx(idx())} > {item.kind === "command" ? "cmd" : "val"} {item.label}
)}
{/* Textarea + actions */}