ttrpg-tools/src/components/journal/JournalInput.tsx

352 lines
9.8 KiB
TypeScript

/**
* 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<string | null>(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<string, unknown>) {
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>(
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 (
<div class="border-t border-gray-200 bg-white relative">
<CompletionsDropdown
show={showCompletions()}
items={completions()}
selectedIdx={selectedIdx()}
onSelect={acceptCompletion}
onHover={(idx) => setSelectedIdx(idx)}
onClose={() => setShowCompletions(false)}
state={comp.state}
/>
<div class="flex flex-col">
<Show
when={!isObserver()}
fallback={
<div class="px-3 py-4 text-xs text-gray-400 italic text-center">
</div>
}
>
<textarea
ref={textareaRef}
id="journal-input-textarea"
value={text()}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder={
isGm()
? "输入消息,或使用 /roll、/spark、/link、/stat 命令..."
: "输入消息,或使用 /stat 命令..."
}
rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
text-gray-800 placeholder-gray-400 focus:outline-none
bg-transparent min-h-[60px]"
/>
<div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}>
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
</Show>
<div class="flex items-center gap-1 ml-auto">
<button
onClick={handleSend}
disabled={sending() || !text().trim()}
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
transition-colors"
>
</button>
</div>
</div>
</Show>
</div>
</div>
);
};