/** * 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 { actionPrefill, setActionPrefill } from "../stores/reveal"; import { useJournalCompletions, ensureCompletions } from "./completions"; import { resolveRollPayload } from "./types/roll"; import { resolveSparkPayload } from "./types/spark"; import { resolveStatRoll, canModifyStat } from "./stat-helpers"; // ---- Helpers ---- interface CompletionItem { label: string; kind: "command" | "value" | "no-results"; insertText: string; } interface ParsedInput { type: "chat" | "roll" | "spark" | "link" | "stat"; 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: "需要骰子表达式" }; 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: "需要种子表键名" }; return { type: "spark", payload: { key } }; } if (raw.startsWith("/link ")) { const arg = raw.slice("/link ".length).trim(); if (!arg) return { type: "link", payload: {}, error: "需要路径" }; 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 } }; } if (raw.startsWith("/stat ")) { const arg = raw.slice("/stat ".length).trim(); if (!arg) return { type: "stat", payload: {}, error: "需要 stat 命令 (set/del/roll)", }; // /stat set key=value if (arg.startsWith("set ")) { const rest = arg.slice("set ".length).trim(); const eqIdx = rest.indexOf("="); if (eqIdx === -1) return { type: "stat", payload: {}, error: "格式: /stat set key=value", }; const key = rest.slice(0, eqIdx).trim(); const value = rest.slice(eqIdx + 1).trim(); if (!key || !value) return { type: "stat", payload: {}, error: "key 和 value 不能为空" }; return { type: "stat", payload: { action: "set", key, value } }; } // /stat del key if (arg.startsWith("del ")) { const key = arg.slice("del ".length).trim(); if (!key) return { type: "stat", payload: {}, error: "格式: /stat del key" }; return { type: "stat", payload: { action: "del", key } }; } // /stat roll key if (arg.startsWith("roll ")) { const key = arg.slice("roll ".length).trim(); if (!key) return { type: "stat", payload: {}, error: "格式: /stat roll key" }; return { type: "stat", payload: { action: "roll", key } }; } return { type: "stat", payload: {}, error: "未知 stat 子命令: set/del/roll", }; } // /roll, /spark, /link, or /stat with no space — need to complete, don't send if ( raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/stat" ) { return { type: "chat", payload: {}, error: "请补全命令" }; } 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 action prefill requests from article buttons (heading /link, spark table roll, etc.) createEffect(() => { const action = actionPrefill(); if (action) { setText(`${action.command} ${action.text}`); setActionPrefill(null); textareaRef?.focus(); } }); // ---- Send ---- async function handleSend() { const raw = text().trim(); if (!raw) return; // Observers: everything is plain chat if (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); // Players: chat + stat commands only if (isPlayer()) { if (parsed.type === "chat") { const result = sendMessage("chat", { text: raw }); if (!result.success) { setError(result.error); } else { setText(""); } textareaRef?.focus(); return; } if (parsed.type === "stat") { const p = parsed.payload as { action?: string; key?: string; value?: string; }; if (p.action === "roll" && p.key) { const resolved = resolveStatRoll( p.key, comp.data.stats, stream.stats, ); if (resolved.error) { setError(resolved.error); } else { const result = sendMessage("stat", { action: "set", key: p.key, value: resolved.value, }); if (!result.success) { setError(result.error); } else { setText(""); } } textareaRef?.focus(); return; } if (!p.action || !p.key) { setError("无效的 stat 命令"); return; } if (!canModifyStat(stream.myRole, stream.myName, p.key)) { setError(`无权修改属性: ${p.key}`); return; } const result = sendMessage("stat", parsed.payload); if (!result.success) { setError(result.error); } else { setText(""); } textareaRef?.focus(); return; } // Players can't use other commands setError("玩家只能发送聊天消息或使用 /stat 命令"); return; } // GM: all commands 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 : "种子表掷骰失败"); setSending(false); return; } } // GM stat: roll resolves client-side, others go straight through if (parsed.type === "stat") { const p = parsed.payload as { action?: string; key?: string; value?: string; }; if (p.action === "roll" && p.key) { const resolved = resolveStatRoll(p.key, comp.data.stats, stream.stats); if (resolved.error) { setError(resolved.error); } else { const result = sendMessage("stat", { action: "set", key: p.key, value: resolved.value, }); if (!result.success) { setError(result.error); } else { setText(""); } } setSending(false); textareaRef?.focus(); return; } const result = sendMessage("stat", parsed.payload); if (!result.success) { setError(result.error); } else { setText(""); } setSending(false); textareaRef?.focus(); 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(); 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 " }, { label: "/stat", kind: "command" as const, insertText: "/stat " }, ]; // Show commands when user types / or starts typing a command name (GM only, except /stat) if (raw.startsWith("/") && !raw.includes(" ")) { const prefix = raw.toLowerCase(); let matches = commands.filter((c) => c.label.toLowerCase().startsWith(prefix), ); // Non-GM only gets /stat if (!isGm()) { matches = matches.filter((c) => c.label === "/stat"); } return matches.length > 0 ? matches : [{ label: "未知命令", 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: "未找到骰子", 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: "未找到种子表", kind: "no-results", insertText: "", }, ]; } return matches.map((s) => ({ label: `${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: "未找到链接", 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, }; }); } // After /stat — show subcommands or stat keys if (raw.startsWith("/stat ")) { const rest = raw.slice("/stat ".length).trim(); // Show subcommands: set, del, roll if (!rest || rest.length < 3) { const subs = ["set", "del", "roll"]; const matches = subs.filter((s) => s.startsWith(rest.toLowerCase())); if (matches.length === 0) return [ { label: "set/del/roll", kind: "no-results", insertText: "" }, ]; return matches.map((s) => ({ label: `/stat ${s}`, kind: "value" as const, insertText: `/stat ${s} `, })); } // After /stat set — show stat keys if (rest.startsWith("set ")) { const prefix = rest.slice("set ".length).toLowerCase(); const matches = data.stats .filter((s) => s.key.toLowerCase().includes(prefix)) .slice(0, 8); if (matches.length === 0) return [{ label: "未找到属性", kind: "no-results", insertText: "" }]; return matches.map((s) => ({ label: `${s.key} (${s.label})`, kind: "value" as const, insertText: `/stat set ${s.key}=`, })); } // After /stat del or /stat roll — show stat keys if (rest.startsWith("del ") || rest.startsWith("roll ")) { const [cmd, ...restParts] = rest.split(" "); const prefix = restParts.join(" ").toLowerCase(); const matches = data.stats .filter((s) => s.key.toLowerCase().startsWith(prefix)) .slice(0, 8); if (matches.length === 0) return [{ label: "未找到属性", kind: "no-results", insertText: "" }]; return matches.map((s) => ({ label: `${s.key} (${s.label})`, kind: "value" as const, insertText: `/stat ${cmd} ${s.key}`, })); } } 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 or /stat for players) const raw = text(); if ((isGm() || raw.startsWith("/stat")) && 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 or /stat for players) if ((isGm() || raw.startsWith("/stat")) && 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 */}
正在加载补全数据…
{(comp.state as any).message || "无法加载补全数据"}
{renderCompletionsDropdown()}
{/* Textarea + actions */}
你正在观察模式。在另一台设备上打开此面板以主持人或玩家身份加入。
} >