feat: implement command autocomplete for journal input

Refactor journal input command parsing into a dedicated module and
add a `CompletionsDropdown` component to provide real-time suggestions
for commands (/roll, /spark, /link, /stat) and their arguments.
This commit is contained in:
hypercross 2026-07-09 09:31:19 +08:00
parent 9747409a1f
commit 44e4e15f3f
4 changed files with 445 additions and 488 deletions

View File

@ -0,0 +1,114 @@
/**
* CompletionsDropdown autocomplete popover for journal command input.
*/
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
import type { CompletionItem } from "./command-parser";
import type { CompletionsState } from "./completions";
export interface CompletionsDropdownProps {
show: boolean;
items: CompletionItem[];
selectedIdx: number;
onSelect: (item: CompletionItem) => void;
onHover: (idx: number) => void;
onClose: () => void;
state: CompletionsState;
}
export const CompletionsDropdown: Component<CompletionsDropdownProps> = (
props,
) => {
let containerRef!: HTMLDivElement;
// Scroll selected into view
createEffect(() => {
const idx = props.selectedIdx;
if (!props.show || !containerRef) return;
const el = containerRef.querySelector(`[data-comp-idx="${idx}"]`);
if (el) el.scrollIntoView({ block: "nearest" });
});
// Click outside
onMount(() => {
const handler = (e: MouseEvent) => {
if (containerRef && !containerRef.contains(e.target as Node)) {
props.onClose();
}
};
document.addEventListener("mousedown", handler);
onCleanup(() => document.removeEventListener("mousedown", handler));
});
return (
<Show when={props.show}>
<Show
when={props.state.status === "loaded" || props.state.status === "empty"}
fallback={
<Show
when={props.state.status === "loading"}
fallback={
<div
ref={containerRef}
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"
>
{(props.state as any).message || "无法加载补全数据"}
</div>
}
>
<div
ref={containerRef}
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"
>
</div>
</Show>
}
>
<Show when={props.items.length > 0}>
<div
ref={containerRef}
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={props.items}>
{(item, idx) => (
<div
data-comp-idx={idx()}
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() === props.selectedIdx
? "bg-blue-50 text-blue-700"
: "hover:bg-gray-100 text-gray-700"
}`}
onClick={() => props.onSelect(item)}
onMouseEnter={() =>
item.kind !== "no-results" && props.onHover(idx())
}
>
<Show
when={item.kind !== "no-results"}
fallback={
<span class="text-xs text-gray-300 shrink-0"></span>
}
>
<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>
</Show>
<span class="font-mono truncate flex-1">{item.label}</span>
</div>
)}
</For>
</div>
</Show>
</Show>
</Show>
);
};

View File

@ -5,127 +5,21 @@
* - Plain text "chat" type * - Plain text "chat" type
* - `/roll 3d6kh1` "roll" type (result resolved client-side by GM) * - `/roll 3d6kh1` "roll" type (result resolved client-side by GM)
* - `/link path#section` "link" type * - `/link path#section` "link" type
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json * - `/stat set key=value` "stat" type
* in CLI mode, or client-side scan of the in-memory file index in dev mode) * - `/` alone opens completions dropdown
*/ */
import { import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
Component,
createSignal,
createEffect,
onMount,
onCleanup,
Show,
For,
Switch,
Match,
} from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream"; import { sendMessage, useJournalStream } from "../stores/journalStream";
import { actionPrefill, setActionPrefill } from "../stores/reveal"; import { actionPrefill, setActionPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions"; import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll"; import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark"; import { resolveSparkPayload } from "./types/spark";
import { resolveStatRoll, canModifyStat } from "./stat-helpers"; import { resolveStatRoll, canModifyStat } from "./stat-helpers";
import { parseInput } from "./command-parser";
// ---- Helpers ---- import type { CompletionItem } from "./command-parser";
import { buildCompletions } from "./command-completions";
interface CompletionItem { import { CompletionsDropdown } from "./CompletionsDropdown";
label: string;
kind: "command" | "value" | "no-results";
insertText: string;
}
interface ParsedInput {
type: "chat" | "roll" | "spark" | "link" | "stat";
payload: Record<string, unknown>;
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 ---- // ---- Component ----
@ -144,14 +38,13 @@ export const JournalInput: Component = () => {
const [selectedIdx, setSelectedIdx] = createSignal(0); const [selectedIdx, setSelectedIdx] = createSignal(0);
let textareaRef!: HTMLTextAreaElement; let textareaRef!: HTMLTextAreaElement;
let completionsRef!: HTMLDivElement;
// Ensure completions are loading on mount // Ensure completions are loading on mount
onMount(() => { onMount(() => {
void ensureCompletions(); void ensureCompletions();
}); });
// Listen for action prefill requests from article buttons (heading /link, spark table roll, etc.) // Listen for action prefill requests from article buttons
createEffect(() => { createEffect(() => {
const action = actionPrefill(); const action = actionPrefill();
if (action) { if (action) {
@ -161,6 +54,14 @@ export const JournalInput: Component = () => {
} }
}); });
// ---- Derived ----
const completions = () =>
buildCompletions(text().trim(), {
data: comp.data,
isGm: isGm(),
});
// ---- Send ---- // ---- Send ----
async function handleSend() { async function handleSend() {
@ -170,12 +71,8 @@ export const JournalInput: Component = () => {
// Observers: everything is plain chat // Observers: everything is plain chat
if (isObserver()) { if (isObserver()) {
const result = sendMessage("chat", { text: raw }); const result = sendMessage("chat", { text: raw });
if (!result.success) { const r = unwrap(result);
setError(result.error); finish(r.ok, r.err);
} else {
setText("");
}
textareaRef?.focus();
return; return;
} }
@ -191,63 +88,16 @@ export const JournalInput: Component = () => {
if (isPlayer()) { if (isPlayer()) {
if (parsed.type === "chat") { if (parsed.type === "chat") {
const result = sendMessage("chat", { text: raw }); const result = sendMessage("chat", { text: raw });
if (!result.success) { const r = unwrap(result);
setError(result.error); finish(r.ok, r.err);
} else {
setText("");
}
textareaRef?.focus();
return; return;
} }
if (parsed.type === "stat") { if (parsed.type === "stat") {
const p = parsed.payload as { handleStat(parsed.payload);
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; return;
} }
// Players can't use other commands
setError("玩家只能发送聊天消息或使用 /stat 命令"); setError("玩家只能发送聊天消息或使用 /stat 命令");
return; return;
} }
@ -255,260 +105,99 @@ export const JournalInput: Component = () => {
// GM: all commands // GM: all commands
setSending(true); setSending(true);
// GM roll: resolve the dice result locally
if (parsed.type === "roll") { if (parsed.type === "roll") {
const p = resolveRollPayload( const p = resolveRollPayload(
parsed.payload as { notation: string; label?: string }, parsed.payload as { notation: string; label?: string },
); );
const result = sendMessage("roll", p); const result = sendMessage("roll", p);
if (!result.success) { const r = unwrap(result);
setError(result.error); finish(r.ok, r.err);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
return; return;
} }
// GM spark: resolve the spark table roll locally
if (parsed.type === "spark") { if (parsed.type === "spark") {
try { try {
const key = (parsed.payload as { key: string }).key; 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 match = comp.data.sparkTables.find((s) => s.slug === key);
const filePath = match?.filePath ?? ""; const filePath = match?.filePath ?? "";
const p = await resolveSparkPayload({ key, filePath }); const p = await resolveSparkPayload({ key, filePath });
const result = sendMessage("spark", p); const result = sendMessage("spark", p);
if (!result.success) { const r = unwrap(result);
setError(result.error); finish(r.ok, r.err);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
return;
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "种子表掷骰失败"); setError(e instanceof Error ? e.message : "种子表掷骰失败");
setSending(false); setSending(false);
return;
} }
return;
} }
// GM stat: roll resolves client-side, others go straight through
if (parsed.type === "stat") { if (parsed.type === "stat") {
const p = parsed.payload as { handleStat(parsed.payload);
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); setSending(false);
textareaRef?.focus();
return; return;
} }
const result = sendMessage(parsed.type, parsed.payload); const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) { const r = unwrap(result);
setError(result.error); finish(r.ok, r.err);
} else { }
setText("");
/** 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); setSending(false);
textareaRef?.focus(); textareaRef?.focus();
} }
// ---- Completions ---- /** Unwrap a sendMessage result into (success, error?) for finish(). */
// ---- Scroll selected completion into view ---- function unwrap<R>(
r: { success: true; msg: R } | { success: false; error: string },
createEffect(() => { ) {
const idx = selectedIdx(); return r.success
if (!showCompletions() || !completionsRef) return; ? ({ ok: true, err: undefined } as const)
const el = completionsRef.querySelector(`[data-comp-idx="${idx}"]`); : ({ ok: false, err: r.error } as const);
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[] { // ---- Completions ----
return buildCompletions();
}
function acceptCompletion(item: CompletionItem) { function acceptCompletion(item: CompletionItem) {
if (item.kind === "no-results") return; if (item.kind === "no-results") return;
@ -518,7 +207,7 @@ export const JournalInput: Component = () => {
} }
function selectCompletion(dir: "up" | "down") { function selectCompletion(dir: "up" | "down") {
const comps = currentCompletions(); const comps = completions();
if (comps.length === 0) return; if (comps.length === 0) return;
setSelectedIdx((prev) => { setSelectedIdx((prev) => {
if (dir === "down") return (prev + 1) % comps.length; if (dir === "down") return (prev + 1) % comps.length;
@ -526,14 +215,17 @@ export const JournalInput: Component = () => {
}); });
} }
function openCompletions() {
setShowCompletions(true);
setSelectedIdx(0);
}
// ---- Keyboard ---- // ---- Keyboard ----
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
const comps = currentCompletions(); const comps = completions();
const open = showCompletions(); 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 (e.key === "Tab") {
if (open) { if (open) {
e.preventDefault(); e.preventDefault();
@ -542,12 +234,10 @@ export const JournalInput: Component = () => {
} }
return; return;
} }
// If not open and starts with /, open completions (GM or /stat for players)
const raw = text(); const raw = text();
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) { if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
e.preventDefault(); e.preventDefault();
setShowCompletions(true); openCompletions();
setSelectedIdx(0);
return; return;
} }
return; return;
@ -575,7 +265,6 @@ export const JournalInput: Component = () => {
} }
} }
// Send
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
@ -587,7 +276,6 @@ export const JournalInput: Component = () => {
const raw = input.value; const raw = input.value;
setText(raw); setText(raw);
// Completions visibility — keep open while user types any / (GM or /stat for players)
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) { if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
setShowCompletions(true); setShowCompletions(true);
setSelectedIdx(0); setSelectedIdx(0);
@ -600,98 +288,20 @@ export const JournalInput: Component = () => {
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px"; textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
} }
// ---- Click outside ---- // ---- Render ----
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 (
<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
data-comp-idx={idx()}
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()
? "bg-blue-50 text-blue-700"
: "hover:bg-gray-100 text-gray-700"
}`}
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>}
>
<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>
</Show>
<span class="font-mono truncate flex-1">{item.label}</span>
</div>
)}
</For>
</div>
);
}
return ( return (
<div class="border-t border-gray-200 bg-white relative"> <div class="border-t border-gray-200 bg-white relative">
{/* Completions loading / error / empty teaser */} <CompletionsDropdown
<Show when={showCompletions()}> show={showCompletions()}
<Switch> items={completions()}
<Match when={comp.state.status === "loading"}> selectedIdx={selectedIdx()}
<div onSelect={acceptCompletion}
ref={completionsRef} onHover={(idx) => setSelectedIdx(idx)}
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" onClose={() => setShowCompletions(false)}
> state={comp.state}
/>
</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"
>
{(comp.state as any).message || "无法加载补全数据"}
</div>
</Match>
<Match
when={
comp.state.status === "empty" || comp.state.status === "loaded"
}
>
{renderCompletionsDropdown()}
</Match>
</Switch>
</Show>
{/* Textarea + actions */}
<div class="flex flex-col"> <div class="flex flex-col">
<Show <Show
when={!isObserver()} when={!isObserver()}
@ -718,7 +328,6 @@ export const JournalInput: Component = () => {
bg-transparent min-h-[60px]" bg-transparent min-h-[60px]"
/> />
{/* Bottom row: error + buttons */}
<div class="flex items-center justify-between px-2 pb-2"> <div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}> <Show when={error()}>
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p> <p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>

View File

@ -0,0 +1,151 @@
/**
* Command completions build the dropdown items for journal input
* autocomplete based on the current raw text and completions data.
*
* Pure function with no component dependencies.
*/
import type { CompletionItem } from "./command-parser";
import type { JournalCompletions } from "./completions";
export interface CompletionsContext {
data: JournalCompletions;
isGm: boolean;
}
export function buildCompletions(
raw: string,
ctx: CompletionsContext,
): CompletionItem[] {
const { data, isGm } = ctx;
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
if (raw.startsWith("/") && !raw.includes(" ")) {
const prefix = raw.toLowerCase();
let matches = commands.filter((c) =>
c.label.toLowerCase().startsWith(prefix),
);
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();
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} `,
}));
}
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}=`,
}));
}
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 [];
}

View File

@ -0,0 +1,83 @@
/**
* Command parser parse a raw input string into a typed ParsedInput.
*
* Pure function with no component dependencies, so it can live in its own
* module and keep JournalInput small.
*/
export interface CompletionItem {
label: string;
kind: "command" | "value" | "no-results";
insertText: string;
}
export interface ParsedInput {
type: "chat" | "roll" | "spark" | "link" | "stat";
payload: Record<string, unknown>;
error?: string;
}
export 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)" };
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 } };
}
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 } };
}
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" };
}
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/stat") {
return { type: "chat", payload: {}, error: "请补全命令" };
}
return { type: "chat", payload: { text: raw } };
}