diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx new file mode 100644 index 0000000..8d06409 --- /dev/null +++ b/src/components/journal/JournalInput.tsx @@ -0,0 +1,307 @@ +/** + * 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 */} +
+