2026-07-06 16:57:36 +08:00
|
|
|
/**
|
|
|
|
|
* JournalInput — chat-style textarea with command dispatch and autocomplete.
|
|
|
|
|
*
|
|
|
|
|
* - Enter sends, Shift+Enter inserts newline
|
2026-07-06 18:06:32 +08:00
|
|
|
* - Plain text → "chat" type
|
|
|
|
|
* - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM)
|
|
|
|
|
* - `/link path#section` → "link" type
|
2026-07-06 17:13:11 +08:00
|
|
|
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json
|
|
|
|
|
* in CLI mode, or client-side scan of the in-memory file index in dev mode)
|
2026-07-06 16:57:36 +08:00
|
|
|
*/
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
import {
|
|
|
|
|
Component,
|
|
|
|
|
createSignal,
|
2026-07-06 17:20:20 +08:00
|
|
|
createEffect,
|
2026-07-06 17:13:11 +08:00
|
|
|
onMount,
|
|
|
|
|
onCleanup,
|
|
|
|
|
Show,
|
|
|
|
|
For,
|
|
|
|
|
Switch,
|
|
|
|
|
Match,
|
|
|
|
|
} from "solid-js";
|
2026-07-06 17:26:37 +08:00
|
|
|
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
2026-07-07 21:38:43 +08:00
|
|
|
import { actionPrefill, setActionPrefill } from "../stores/reveal";
|
2026-07-06 17:52:56 +08:00
|
|
|
import { useJournalCompletions, ensureCompletions } from "./completions";
|
2026-07-06 18:06:32 +08:00
|
|
|
import { resolveRollPayload } from "./types/roll";
|
2026-07-07 19:02:17 +08:00
|
|
|
import { resolveSparkPayload } from "./types/spark";
|
2026-07-06 17:13:11 +08:00
|
|
|
|
|
|
|
|
// ---- Helpers ----
|
2026-07-06 16:57:36 +08:00
|
|
|
|
|
|
|
|
interface CompletionItem {
|
|
|
|
|
label: string;
|
2026-07-06 17:13:11 +08:00
|
|
|
kind: "command" | "value" | "no-results";
|
2026-07-06 16:57:36 +08:00
|
|
|
insertText: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ParsedInput {
|
2026-07-07 19:02:17 +08:00
|
|
|
type: "chat" | "roll" | "spark" | "link";
|
2026-07-06 16:57:36 +08:00
|
|
|
payload: Record<string, unknown>;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
function parseInput(raw: string): ParsedInput {
|
|
|
|
|
if (raw.startsWith("/roll ")) {
|
|
|
|
|
const notation = raw.slice("/roll ".length).trim();
|
|
|
|
|
if (!notation)
|
2026-07-07 23:50:39 +08:00
|
|
|
return { type: "roll", payload: {}, error: "需要骰子表达式" };
|
2026-07-06 18:06:32 +08:00
|
|
|
return { type: "roll", payload: { notation, label: notation } };
|
2026-07-06 17:13:11 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-07 19:02:17 +08:00
|
|
|
if (raw.startsWith("/spark ")) {
|
|
|
|
|
const key = raw.slice("/spark ".length).trim();
|
2026-07-07 23:50:39 +08:00
|
|
|
if (!key) return { type: "spark", payload: {}, error: "需要种子表键名" };
|
2026-07-07 19:02:17 +08:00
|
|
|
return { type: "spark", payload: { key } };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
if (raw.startsWith("/link ")) {
|
|
|
|
|
const arg = raw.slice("/link ".length).trim();
|
2026-07-07 23:50:39 +08:00
|
|
|
if (!arg) return { type: "link", payload: {}, error: "需要路径" };
|
2026-07-06 17:13:11 +08:00
|
|
|
const hashIdx = arg.indexOf("#");
|
|
|
|
|
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
|
|
|
|
|
const section =
|
|
|
|
|
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
|
2026-07-06 18:06:32 +08:00
|
|
|
return { type: "link", payload: { path, section } };
|
2026-07-06 17:13:11 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-07 19:02:17 +08:00
|
|
|
// /roll, /spark, or /link with no space — need to complete, don't send
|
|
|
|
|
if (raw === "/roll" || raw === "/spark" || raw === "/link") {
|
2026-07-07 23:50:39 +08:00
|
|
|
return { type: "chat", payload: {}, error: "请补全命令" };
|
2026-07-06 17:13:11 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 18:06:32 +08:00
|
|
|
return { type: "chat", payload: { text: raw } };
|
2026-07-06 17:13:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Component ----
|
|
|
|
|
|
2026-07-06 16:57:36 +08:00
|
|
|
export const JournalInput: Component = () => {
|
|
|
|
|
const stream = useJournalStream();
|
2026-07-06 17:13:11 +08:00
|
|
|
const comp = useJournalCompletions();
|
2026-07-06 16:57:36 +08:00
|
|
|
|
2026-07-06 17:52:56 +08:00
|
|
|
const isObserver = () => stream.myRole === "observer";
|
|
|
|
|
const isPlayer = () => stream.myRole === "player";
|
|
|
|
|
const isGm = () => stream.myRole === "gm";
|
|
|
|
|
|
2026-07-06 16:57:36 +08:00
|
|
|
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;
|
|
|
|
|
let completionsRef!: HTMLDivElement;
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// Ensure completions are loading on mount
|
|
|
|
|
onMount(() => {
|
|
|
|
|
void ensureCompletions();
|
|
|
|
|
});
|
2026-07-06 16:57:36 +08:00
|
|
|
|
2026-07-07 21:38:43 +08:00
|
|
|
// Listen for action prefill requests from article buttons (heading /link, spark table roll, etc.)
|
2026-07-07 18:10:43 +08:00
|
|
|
createEffect(() => {
|
2026-07-07 21:38:43 +08:00
|
|
|
const action = actionPrefill();
|
|
|
|
|
if (action) {
|
|
|
|
|
setText(`${action.command} ${action.text}`);
|
|
|
|
|
setActionPrefill(null);
|
2026-07-07 18:10:43 +08:00
|
|
|
textareaRef?.focus();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// ---- Send ----
|
2026-07-06 16:57:36 +08:00
|
|
|
|
2026-07-07 19:02:17 +08:00
|
|
|
async function handleSend() {
|
2026-07-06 16:57:36 +08:00
|
|
|
const raw = text().trim();
|
|
|
|
|
if (!raw) return;
|
|
|
|
|
|
2026-07-06 18:06:32 +08:00
|
|
|
// Players / observers: everything is plain chat
|
2026-07-06 17:52:56 +08:00
|
|
|
if (isPlayer() || isObserver()) {
|
2026-07-06 18:06:32 +08:00
|
|
|
const result = sendMessage("chat", { text: raw });
|
2026-07-06 17:52:56 +08:00
|
|
|
if (!result.success) {
|
|
|
|
|
setError(result.error);
|
|
|
|
|
} else {
|
|
|
|
|
setText("");
|
|
|
|
|
}
|
|
|
|
|
textareaRef?.focus();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 16:57:36 +08:00
|
|
|
const parsed = parseInput(raw);
|
|
|
|
|
if (parsed.error) {
|
|
|
|
|
setError(parsed.error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setError(null);
|
|
|
|
|
setSending(true);
|
|
|
|
|
|
2026-07-06 18:06:32 +08:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 19:02:17 +08:00
|
|
|
// 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) {
|
2026-07-07 23:50:39 +08:00
|
|
|
setError(e instanceof Error ? e.message : "种子表掷骰失败");
|
2026-07-07 19:02:17 +08:00
|
|
|
setSending(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 16:57:36 +08:00
|
|
|
const result = sendMessage(parsed.type, parsed.payload);
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
setError(result.error);
|
|
|
|
|
} else {
|
|
|
|
|
setText("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSending(false);
|
|
|
|
|
textareaRef?.focus();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:26:37 +08:00
|
|
|
// ---- Completions ----
|
2026-07-06 17:20:20 +08:00
|
|
|
// ---- 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" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// ---- Completions ----
|
2026-07-06 16:57:36 +08:00
|
|
|
|
|
|
|
|
function buildCompletions(): CompletionItem[] {
|
|
|
|
|
const raw = text().trim();
|
2026-07-06 17:52:56 +08:00
|
|
|
// Only GM gets completions
|
|
|
|
|
if (!isGm()) return [];
|
2026-07-06 17:13:11 +08:00
|
|
|
const data = comp.data;
|
2026-07-06 17:20:20 +08:00
|
|
|
const commands = [
|
|
|
|
|
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
2026-07-07 19:02:17 +08:00
|
|
|
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
|
2026-07-06 17:20:20 +08:00
|
|
|
{ label: "/link", kind: "command" as const, insertText: "/link " },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Show commands when user types / or starts typing a command name
|
|
|
|
|
if (raw.startsWith("/") && !raw.includes(" ")) {
|
|
|
|
|
const prefix = raw.toLowerCase();
|
|
|
|
|
const matches = commands.filter((c) =>
|
|
|
|
|
c.label.toLowerCase().startsWith(prefix),
|
|
|
|
|
);
|
|
|
|
|
return matches.length > 0
|
|
|
|
|
? matches
|
2026-07-07 23:50:39 +08:00
|
|
|
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// After /roll — show dice suggestions
|
|
|
|
|
if (raw.startsWith("/roll ")) {
|
|
|
|
|
const prefix = raw.slice("/roll ".length).toLowerCase();
|
2026-07-06 17:13:11 +08:00
|
|
|
const matches = data.dice
|
2026-07-06 16:57:36 +08:00
|
|
|
.filter(
|
|
|
|
|
(d) =>
|
|
|
|
|
d.notation.toLowerCase().includes(prefix) ||
|
|
|
|
|
d.label.toLowerCase().includes(prefix),
|
|
|
|
|
)
|
2026-07-06 17:13:11 +08:00
|
|
|
.slice(0, 8);
|
|
|
|
|
if (matches.length === 0) {
|
2026-07-07 23:50:39 +08:00
|
|
|
return [{ label: "未找到骰子", kind: "no-results", insertText: "" }];
|
2026-07-06 17:13:11 +08:00
|
|
|
}
|
|
|
|
|
return matches.map((d) => ({
|
|
|
|
|
label: d.notation,
|
|
|
|
|
kind: "value" as const,
|
|
|
|
|
insertText: "/roll " + d.notation,
|
|
|
|
|
}));
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-07 19:02:17 +08:00
|
|
|
// 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 [
|
|
|
|
|
{
|
2026-07-07 23:50:39 +08:00
|
|
|
label: "未找到种子表",
|
2026-07-07 19:02:17 +08:00
|
|
|
kind: "no-results",
|
|
|
|
|
insertText: "",
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
return matches.map((s) => ({
|
2026-07-07 23:48:04 +08:00
|
|
|
label: `${s.slug} (${s.notation})`,
|
2026-07-07 19:02:17 +08:00
|
|
|
kind: "value" as const,
|
|
|
|
|
insertText: `/spark ${s.slug}`,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 16:57:36 +08:00
|
|
|
// After /link — show article and heading suggestions
|
|
|
|
|
if (raw.startsWith("/link ")) {
|
|
|
|
|
const prefix = raw.slice("/link ".length).toLowerCase();
|
2026-07-06 17:13:11 +08:00
|
|
|
const matches = data.links
|
2026-07-06 16:57:36 +08:00
|
|
|
.filter(
|
|
|
|
|
(l) =>
|
|
|
|
|
l.path.toLowerCase().includes(prefix) ||
|
|
|
|
|
l.label.toLowerCase().includes(prefix),
|
|
|
|
|
)
|
2026-07-06 17:13:11 +08:00
|
|
|
.slice(0, 8);
|
|
|
|
|
if (matches.length === 0) {
|
2026-07-07 23:50:39 +08:00
|
|
|
return [{ label: "未找到链接", kind: "no-results", insertText: "" }];
|
2026-07-06 17:13:11 +08:00
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function currentCompletions(): CompletionItem[] {
|
|
|
|
|
return buildCompletions();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function acceptCompletion(item: CompletionItem) {
|
2026-07-06 17:13:11 +08:00
|
|
|
if (item.kind === "no-results") return;
|
2026-07-06 16:57:36 +08:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// ---- Keyboard ----
|
2026-07-06 16:57:36 +08:00
|
|
|
|
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
|
|
|
const comps = currentCompletions();
|
|
|
|
|
const open = showCompletions();
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-07-06 17:52:56 +08:00
|
|
|
// If not open and starts with /, open completions (GM only)
|
2026-07-06 17:13:11 +08:00
|
|
|
const raw = text();
|
2026-07-06 17:52:56 +08:00
|
|
|
if (isGm() && raw.startsWith("/")) {
|
2026-07-06 17:13:11 +08:00
|
|
|
e.preventDefault();
|
|
|
|
|
setShowCompletions(true);
|
|
|
|
|
setSelectedIdx(0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 16:57:36 +08:00
|
|
|
if (open && comps.length > 0) {
|
2026-07-06 17:13:11 +08:00
|
|
|
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") {
|
2026-07-06 16:57:36 +08:00
|
|
|
e.preventDefault();
|
|
|
|
|
acceptCompletion(comps[selectedIdx()]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-06 17:13:11 +08:00
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
setShowCompletions(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-06 16:57:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send
|
|
|
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleSend();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
function handleInput(e: InputEvent) {
|
|
|
|
|
const input = e.currentTarget as HTMLTextAreaElement;
|
|
|
|
|
const raw = input.value;
|
|
|
|
|
setText(raw);
|
|
|
|
|
|
2026-07-06 17:52:56 +08:00
|
|
|
// Completions visibility — keep open while user types any / (GM only)
|
|
|
|
|
if (isGm() && raw.startsWith("/")) {
|
2026-07-06 16:57:36 +08:00
|
|
|
setShowCompletions(true);
|
|
|
|
|
setSelectedIdx(0);
|
|
|
|
|
} else {
|
|
|
|
|
setShowCompletions(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-resize
|
|
|
|
|
textareaRef.style.height = "auto";
|
|
|
|
|
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// ---- Click outside ----
|
2026-07-06 16:57:36 +08:00
|
|
|
|
|
|
|
|
onMount(() => {
|
|
|
|
|
const handler = (e: MouseEvent) => {
|
|
|
|
|
if (completionsRef && !completionsRef.contains(e.target as Node)) {
|
|
|
|
|
setShowCompletions(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener("mousedown", handler);
|
|
|
|
|
onCleanup(() => document.removeEventListener("mousedown", handler));
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 17:13:11 +08:00
|
|
|
// Completions placeholder — shown in the dropdown area when no matches
|
|
|
|
|
function renderCompletionsDropdown() {
|
|
|
|
|
const comps = currentCompletions();
|
|
|
|
|
if (comps.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={completionsRef}
|
|
|
|
|
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
|
|
|
|
|
>
|
|
|
|
|
<For each={comps}>
|
|
|
|
|
{(item, idx) => (
|
|
|
|
|
<div
|
2026-07-06 17:20:20 +08:00
|
|
|
data-comp-idx={idx()}
|
2026-07-06 17:13:11 +08:00
|
|
|
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
|
|
|
|
|
item.kind === "no-results"
|
|
|
|
|
? "text-gray-400 italic cursor-default"
|
|
|
|
|
: idx() === selectedIdx()
|
2026-07-06 16:57:36 +08:00
|
|
|
? "bg-blue-50 text-blue-700"
|
|
|
|
|
: "hover:bg-gray-100 text-gray-700"
|
2026-07-06 17:13:11 +08:00
|
|
|
}`}
|
|
|
|
|
onClick={() => acceptCompletion(item)}
|
|
|
|
|
onMouseEnter={() =>
|
|
|
|
|
item.kind !== "no-results" && setSelectedIdx(idx())
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<Show
|
|
|
|
|
when={item.kind !== "no-results"}
|
|
|
|
|
fallback={<span class="text-xs text-gray-300 shrink-0">—</span>}
|
2026-07-06 16:57:36 +08:00
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
class={`text-xs px-1 rounded shrink-0 ${
|
|
|
|
|
item.kind === "command"
|
|
|
|
|
? "bg-purple-100 text-purple-700"
|
|
|
|
|
: "bg-gray-100 text-gray-500"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{item.kind === "command" ? "cmd" : "val"}
|
|
|
|
|
</span>
|
2026-07-06 17:13:11 +08:00
|
|
|
</Show>
|
|
|
|
|
<span class="font-mono truncate flex-1">{item.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</For>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div class="border-t border-gray-200 bg-white relative">
|
|
|
|
|
{/* Completions loading / error / empty teaser */}
|
|
|
|
|
<Show when={showCompletions()}>
|
|
|
|
|
<Switch>
|
|
|
|
|
<Match when={comp.state.status === "loading"}>
|
|
|
|
|
<div
|
|
|
|
|
ref={completionsRef}
|
|
|
|
|
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
|
|
|
|
|
>
|
2026-07-07 23:50:39 +08:00
|
|
|
正在加载补全数据…
|
2026-07-06 17:13:11 +08:00
|
|
|
</div>
|
|
|
|
|
</Match>
|
|
|
|
|
<Match when={comp.state.status === "error"}>
|
|
|
|
|
<div
|
|
|
|
|
ref={completionsRef}
|
|
|
|
|
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
|
|
|
|
|
>
|
2026-07-07 23:50:39 +08:00
|
|
|
{(comp.state as any).message || "无法加载补全数据"}
|
2026-07-06 17:13:11 +08:00
|
|
|
</div>
|
|
|
|
|
</Match>
|
|
|
|
|
<Match
|
|
|
|
|
when={
|
|
|
|
|
comp.state.status === "empty" || comp.state.status === "loaded"
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{renderCompletionsDropdown()}
|
|
|
|
|
</Match>
|
|
|
|
|
</Switch>
|
2026-07-06 16:57:36 +08:00
|
|
|
</Show>
|
|
|
|
|
|
|
|
|
|
{/* Textarea + actions */}
|
|
|
|
|
<div class="flex flex-col">
|
2026-07-06 17:52:56 +08:00
|
|
|
<Show
|
|
|
|
|
when={!isObserver()}
|
|
|
|
|
fallback={
|
|
|
|
|
<div class="px-3 py-4 text-xs text-gray-400 italic text-center">
|
2026-07-07 23:50:39 +08:00
|
|
|
你正在观察模式。在另一台设备上打开此面板以主持人或玩家身份加入。
|
2026-07-06 17:52:56 +08:00
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<textarea
|
|
|
|
|
ref={textareaRef}
|
|
|
|
|
value={text()}
|
|
|
|
|
onInput={handleInput}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
placeholder={
|
|
|
|
|
isGm()
|
2026-07-07 23:50:39 +08:00
|
|
|
? "输入消息,或使用 /roll、/spark、/link 命令..."
|
|
|
|
|
: "输入消息..."
|
2026-07-06 17:52:56 +08:00
|
|
|
}
|
|
|
|
|
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]"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Bottom row: error + buttons */}
|
|
|
|
|
<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
|
2026-07-06 16:57:36 +08:00
|
|
|
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
|
|
|
|
|
transition-colors"
|
2026-07-06 17:52:56 +08:00
|
|
|
>
|
2026-07-07 23:50:39 +08:00
|
|
|
发送
|
2026-07-06 17:52:56 +08:00
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-07-06 16:57:36 +08:00
|
|
|
</div>
|
2026-07-06 17:52:56 +08:00
|
|
|
</Show>
|
2026-07-06 16:57:36 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|