428 lines
12 KiB
TypeScript
428 lines
12 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,
|
|
resolveTemplateSet,
|
|
canModifyStat,
|
|
fullKey,
|
|
findStatDef,
|
|
} 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,
|
|
stream.myName,
|
|
comp.data.statTemplates,
|
|
);
|
|
if (resolved.error) {
|
|
setError(resolved.error);
|
|
} else {
|
|
// Send the primary stat value
|
|
const result = sendMessage("stat", {
|
|
action: "set",
|
|
key: resolved.fullKey,
|
|
value: resolved.value,
|
|
});
|
|
const r = unwrap(result);
|
|
if (!r.ok) {
|
|
finish(false, r.err);
|
|
return;
|
|
}
|
|
|
|
// Send any modifier overrides from template
|
|
if (resolved.modifiers) {
|
|
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
|
|
sendMessage("stat", {
|
|
action: "set",
|
|
key: mk,
|
|
value: mv,
|
|
});
|
|
}
|
|
}
|
|
|
|
finish(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!p.action || !p.key) {
|
|
setError("无效的 stat 命令");
|
|
return;
|
|
}
|
|
|
|
// Resolve bare key to full key for set/del
|
|
const fk = resolveKey(p.key, comp.data.stats, stream.myName);
|
|
|
|
if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) {
|
|
setError(`无权修改属性: ${p.key}`);
|
|
return;
|
|
}
|
|
|
|
const result = sendMessage("stat", {
|
|
action: p.action,
|
|
key: fk,
|
|
value: p.value,
|
|
});
|
|
const r = unwrap(result);
|
|
if (!r.ok) {
|
|
finish(false, r.err);
|
|
return;
|
|
}
|
|
|
|
// If setting a template-type stat, apply its modifier overrides
|
|
if (p.action === "set" && p.value) {
|
|
const def = findStatDef(fk, comp.data.stats, stream.myName);
|
|
if (def) {
|
|
const modifiers = resolveTemplateSet(
|
|
def,
|
|
p.value,
|
|
comp.data.stats,
|
|
stream.stats,
|
|
stream.myName,
|
|
comp.data.statTemplates,
|
|
);
|
|
if (modifiers) {
|
|
for (const [mk, mv] of Object.entries(modifiers)) {
|
|
sendMessage("stat", { action: "set", key: mk, value: mv });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
finish(true);
|
|
}
|
|
|
|
/** Resolve a bare or full key to the actual runtime key. */
|
|
function resolveKey(
|
|
inputKey: string,
|
|
statDefs: typeof comp.data.stats,
|
|
playerName: string,
|
|
): string {
|
|
// Exact match
|
|
if (statDefs.some((d) => fullKey(d, playerName) === inputKey))
|
|
return inputKey;
|
|
// Bare key → full key
|
|
const def = statDefs.find((d) => d.key === inputKey);
|
|
if (def) return fullKey(def, playerName);
|
|
return inputKey;
|
|
}
|
|
|
|
/** 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>
|
|
);
|
|
};
|