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:
parent
9747409a1f
commit
44e4e15f3f
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,53 +105,47 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "stat") {
|
||||||
|
handleStat(parsed.payload);
|
||||||
|
setSending(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GM stat: roll resolves client-side, others go straight through
|
const result = sendMessage(parsed.type, parsed.payload);
|
||||||
if (parsed.type === "stat") {
|
const r = unwrap(result);
|
||||||
const p = parsed.payload as {
|
finish(r.ok, r.err);
|
||||||
action?: string;
|
}
|
||||||
key?: string;
|
|
||||||
value?: string;
|
/** 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) {
|
if (p.action === "roll" && p.key) {
|
||||||
const resolved = resolveStatRoll(p.key, comp.data.stats, stream.stats);
|
const resolved = resolveStatRoll(p.key, comp.data.stats, stream.stats);
|
||||||
if (resolved.error) {
|
if (resolved.error) {
|
||||||
|
|
@ -312,204 +156,49 @@ export const JournalInput: Component = () => {
|
||||||
key: p.key,
|
key: p.key,
|
||||||
value: resolved.value,
|
value: resolved.value,
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
const r = unwrap(result);
|
||||||
setError(result.error);
|
finish(r.ok, r.err);
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
setSending(false);
|
|
||||||
textareaRef?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = sendMessage("stat", parsed.payload);
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error);
|
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
|
||||||
setSending(false);
|
|
||||||
textareaRef?.focus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = sendMessage(parsed.type, parsed.payload);
|
if (!p.action || !p.key) {
|
||||||
if (!result.success) {
|
setError("无效的 stat 命令");
|
||||||
setError(result.error);
|
return;
|
||||||
} else {
|
|
||||||
setText("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ----
|
// ---- 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[] {
|
|
||||||
return buildCompletions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptCompletion(item: CompletionItem) {
|
function acceptCompletion(item: CompletionItem) {
|
||||||
if (item.kind === "no-results") return;
|
if (item.kind === "no-results") return;
|
||||||
setText(item.insertText);
|
setText(item.insertText);
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
|
@ -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 } };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue