From 44e4e15f3fc8d711042ee467fc3a01da94e5467f Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 9 Jul 2026 09:31:19 +0800 Subject: [PATCH] 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. --- .../journal/CompletionsDropdown.tsx | 114 ++++ src/components/journal/JournalInput.tsx | 585 +++--------------- src/components/journal/command-completions.ts | 151 +++++ src/components/journal/command-parser.ts | 83 +++ 4 files changed, 445 insertions(+), 488 deletions(-) create mode 100644 src/components/journal/CompletionsDropdown.tsx create mode 100644 src/components/journal/command-completions.ts create mode 100644 src/components/journal/command-parser.ts diff --git a/src/components/journal/CompletionsDropdown.tsx b/src/components/journal/CompletionsDropdown.tsx new file mode 100644 index 0000000..ba7c39a --- /dev/null +++ b/src/components/journal/CompletionsDropdown.tsx @@ -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 = ( + 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 ( + + + {(props.state as any).message || "无法加载补全数据"} + + } + > +
+ 正在加载补全数据… +
+
+ } + > + 0}> +
+ + {(item, idx) => ( +
props.onSelect(item)} + onMouseEnter={() => + item.kind !== "no-results" && props.onHover(idx()) + } + > + — + } + > + + {item.kind === "command" ? "cmd" : "val"} + + + {item.label} +
+ )} +
+
+
+
+ + ); +}; diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index 34cbf65..d477244 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -5,127 +5,21 @@ * - Plain text → "chat" type * - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM) * - `/link path#section` → "link" type - * - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json - * in CLI mode, or client-side scan of the in-memory file index in dev mode) + * - `/stat set key=value` → "stat" type + * - `/` alone opens completions dropdown */ -import { - Component, - createSignal, - createEffect, - onMount, - onCleanup, - Show, - For, - Switch, - Match, -} from "solid-js"; +import { Component, createSignal, createEffect, onMount, Show } from "solid-js"; import { sendMessage, useJournalStream } from "../stores/journalStream"; import { actionPrefill, setActionPrefill } from "../stores/reveal"; import { useJournalCompletions, ensureCompletions } from "./completions"; import { resolveRollPayload } from "./types/roll"; import { resolveSparkPayload } from "./types/spark"; import { resolveStatRoll, canModifyStat } from "./stat-helpers"; - -// ---- Helpers ---- - -interface CompletionItem { - label: string; - kind: "command" | "value" | "no-results"; - insertText: string; -} - -interface ParsedInput { - type: "chat" | "roll" | "spark" | "link" | "stat"; - payload: Record; - 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 } }; -} +import { parseInput } from "./command-parser"; +import type { CompletionItem } from "./command-parser"; +import { buildCompletions } from "./command-completions"; +import { CompletionsDropdown } from "./CompletionsDropdown"; // ---- Component ---- @@ -144,14 +38,13 @@ export const JournalInput: Component = () => { const [selectedIdx, setSelectedIdx] = createSignal(0); let textareaRef!: HTMLTextAreaElement; - let completionsRef!: HTMLDivElement; // Ensure completions are loading on mount onMount(() => { 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(() => { const action = actionPrefill(); if (action) { @@ -161,6 +54,14 @@ export const JournalInput: Component = () => { } }); + // ---- Derived ---- + + const completions = () => + buildCompletions(text().trim(), { + data: comp.data, + isGm: isGm(), + }); + // ---- Send ---- async function handleSend() { @@ -170,12 +71,8 @@ export const JournalInput: Component = () => { // Observers: everything is plain chat if (isObserver()) { const result = sendMessage("chat", { text: raw }); - if (!result.success) { - setError(result.error); - } else { - setText(""); - } - textareaRef?.focus(); + const r = unwrap(result); + finish(r.ok, r.err); return; } @@ -191,63 +88,16 @@ export const JournalInput: Component = () => { if (isPlayer()) { if (parsed.type === "chat") { const result = sendMessage("chat", { text: raw }); - if (!result.success) { - setError(result.error); - } else { - setText(""); - } - textareaRef?.focus(); + const r = unwrap(result); + finish(r.ok, r.err); return; } if (parsed.type === "stat") { - const p = parsed.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, - }); - 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(); + handleStat(parsed.payload); return; } - // Players can't use other commands setError("玩家只能发送聊天消息或使用 /stat 命令"); return; } @@ -255,260 +105,99 @@ export const JournalInput: Component = () => { // GM: all commands setSending(true); - // 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(); + const r = unwrap(result); + finish(r.ok, r.err); return; } - // 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; + const r = unwrap(result); + finish(r.ok, r.err); } catch (e) { setError(e instanceof Error ? e.message : "种子表掷骰失败"); setSending(false); - return; } + return; } - // GM stat: roll resolves client-side, others go straight through if (parsed.type === "stat") { - const p = parsed.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, - }); - 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(""); - } + handleStat(parsed.payload); setSending(false); - textareaRef?.focus(); return; } const result = sendMessage(parsed.type, parsed.payload); - if (!result.success) { - setError(result.error); - } else { - setText(""); + const r = unwrap(result); + finish(r.ok, r.err); + } + + /** Handle a /stat command payload (set/del/roll) */ + function handleStat(payload: Record) { + const p = payload as { action?: string; key?: string; value?: string }; + + if (p.action === "roll" && p.key) { + const resolved = resolveStatRoll(p.key, comp.data.stats, stream.stats); + if (resolved.error) { + setError(resolved.error); + } else { + const result = sendMessage("stat", { + action: "set", + key: p.key, + value: resolved.value, + }); + const r = unwrap(result); + finish(r.ok, r.err); + } + return; } + if (!p.action || !p.key) { + setError("无效的 stat 命令"); + return; + } + + if (!canModifyStat(stream.myRole, stream.myName, p.key)) { + setError(`无权修改属性: ${p.key}`); + return; + } + + const result = sendMessage("stat", payload); + const r = unwrap(result); + finish(r.ok, r.err); + } + + /** Clear text + error on success, or set error on failure. */ + function finish(success: boolean, err?: string) { + if (success) { + setText(""); + } else if (err) { + setError(err); + } setSending(false); textareaRef?.focus(); } - // ---- Completions ---- - // ---- 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" }); - }); - - // ---- 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 []; + /** Unwrap a sendMessage result into (success, error?) for finish(). */ + function unwrap( + r: { success: true; msg: R } | { success: false; error: string }, + ) { + return r.success + ? ({ ok: true, err: undefined } as const) + : ({ ok: false, err: r.error } as const); } - function currentCompletions(): CompletionItem[] { - return buildCompletions(); - } + // ---- Completions ---- function acceptCompletion(item: CompletionItem) { if (item.kind === "no-results") return; @@ -518,7 +207,7 @@ export const JournalInput: Component = () => { } function selectCompletion(dir: "up" | "down") { - const comps = currentCompletions(); + const comps = completions(); if (comps.length === 0) return; setSelectedIdx((prev) => { if (dir === "down") return (prev + 1) % comps.length; @@ -526,14 +215,17 @@ export const JournalInput: Component = () => { }); } + function openCompletions() { + setShowCompletions(true); + setSelectedIdx(0); + } + // ---- Keyboard ---- function handleKeyDown(e: KeyboardEvent) { - const comps = currentCompletions(); + const comps = completions(); 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 (open) { e.preventDefault(); @@ -542,12 +234,10 @@ export const JournalInput: Component = () => { } return; } - // If not open and starts with /, open completions (GM or /stat for players) const raw = text(); if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) { e.preventDefault(); - setShowCompletions(true); - setSelectedIdx(0); + openCompletions(); return; } return; @@ -575,7 +265,6 @@ export const JournalInput: Component = () => { } } - // Send if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); @@ -587,7 +276,6 @@ export const JournalInput: Component = () => { const raw = input.value; setText(raw); - // Completions visibility — keep open while user types any / (GM or /stat for players) if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) { setShowCompletions(true); setSelectedIdx(0); @@ -600,98 +288,20 @@ export const JournalInput: Component = () => { textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px"; } - // ---- Click outside ---- - - 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 ( -
- - {(item, idx) => ( -
acceptCompletion(item)} - onMouseEnter={() => - item.kind !== "no-results" && setSelectedIdx(idx()) - } - > - —} - > - - {item.kind === "command" ? "cmd" : "val"} - - - {item.label} -
- )} -
-
- ); - } + // ---- Render ---- return (
- {/* Completions loading / error / empty teaser */} - - - -
- 正在加载补全数据… -
-
- -
- {(comp.state as any).message || "无法加载补全数据"} -
-
- - {renderCompletionsDropdown()} - -
-
+ setSelectedIdx(idx)} + onClose={() => setShowCompletions(false)} + state={comp.state} + /> - {/* Textarea + actions */}
{ bg-transparent min-h-[60px]" /> - {/* Bottom row: error + buttons */}

{error()}

diff --git a/src/components/journal/command-completions.ts b/src/components/journal/command-completions.ts new file mode 100644 index 0000000..4b79cc9 --- /dev/null +++ b/src/components/journal/command-completions.ts @@ -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 []; +} diff --git a/src/components/journal/command-parser.ts b/src/components/journal/command-parser.ts new file mode 100644 index 0000000..7748c06 --- /dev/null +++ b/src/components/journal/command-parser.ts @@ -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; + 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 } }; +}