/** * 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 * - `/stat set key=value` → "stat" type * - `/` alone opens completions dropdown */ import { Component, createSignal, createEffect, onMount, Show } 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"; import { parseInput } from "./command-parser"; import type { CompletionItem } from "./command-parser"; import { buildCompletions } from "./command-completions"; import { CompletionsDropdown } from "./CompletionsDropdown"; // ---- 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; // Ensure completions are loading on mount onMount(() => { void ensureCompletions(); }); // Listen for action prefill requests from article buttons createEffect(() => { const action = actionPrefill(); if (action) { setText(`${action.command} ${action.text}`); setActionPrefill(null); textareaRef?.focus(); } }); // ---- Derived ---- const completions = () => buildCompletions(text().trim(), { data: comp.data, isGm: isGm(), }); // ---- Send ---- async function handleSend() { const raw = text().trim(); if (!raw) return; // Observers: everything is plain chat if (isObserver()) { const result = sendMessage("chat", { text: raw }); const r = unwrap(result); finish(r.ok, r.err); 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 }); const r = unwrap(result); finish(r.ok, r.err); return; } if (parsed.type === "stat") { handleStat(parsed.payload); return; } setError("玩家只能发送聊天消息或使用 /stat 命令"); return; } // GM: all commands setSending(true); if (parsed.type === "roll") { const p = resolveRollPayload( parsed.payload as { notation: string; label?: string }, ); const result = sendMessage("roll", p); const r = unwrap(result); finish(r.ok, r.err); return; } if (parsed.type === "spark") { try { const key = (parsed.payload as { key: string }).key; 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); const r = unwrap(result); finish(r.ok, r.err); } catch (e) { setError(e instanceof Error ? e.message : "种子表掷骰失败"); setSending(false); } return; } if (parsed.type === "stat") { handleStat(parsed.payload); setSending(false); return; } const result = sendMessage(parsed.type, parsed.payload); const r = unwrap(result); finish(r.ok, r.err); } /** Handle a /stat command payload (set/del/roll) */ function handleStat(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); if (resolved.error) { setError(resolved.error); } else { const result = sendMessage("stat", { action: "set", key: p.key, value: resolved.value, }); const r = unwrap(result); finish(r.ok, r.err); } 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", payload); const r = unwrap(result); finish(r.ok, r.err); } /** Clear text + error on success, or set error on failure. */ function finish(success: boolean, err?: string) { if (success) { setText(""); } else if (err) { setError(err); } setSending(false); textareaRef?.focus(); } /** Unwrap a sendMessage result into (success, error?) for finish(). */ 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); } // ---- Completions ---- function acceptCompletion(item: CompletionItem) { if (item.kind === "no-results") return; setText(item.insertText); setShowCompletions(false); textareaRef?.focus(); } function selectCompletion(dir: "up" | "down") { const comps = completions(); if (comps.length === 0) return; setSelectedIdx((prev) => { if (dir === "down") return (prev + 1) % comps.length; return (prev - 1 + comps.length) % comps.length; }); } function openCompletions() { setShowCompletions(true); setSelectedIdx(0); } // ---- Keyboard ---- function handleKeyDown(e: KeyboardEvent) { const comps = completions(); const open = showCompletions(); if (e.key === "Tab") { if (open) { e.preventDefault(); if (comps.length > 0 && comps[selectedIdx()].kind !== "no-results") { acceptCompletion(comps[selectedIdx()]); } return; } const raw = text(); if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) { e.preventDefault(); openCompletions(); 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; } } 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); 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"; } // ---- Render ---- return (
setSelectedIdx(idx)} onClose={() => setShowCompletions(false)} state={comp.state} />
你正在观察模式。在另一台设备上打开此面板以主持人或玩家身份加入。
} >