ttrpg-tools/src/components/md-commander/hooks/useCommander.ts

443 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createSignal } from "solid-js";
import {
MdCommanderCommand,
CommanderEntry,
CompletionItem,
} from "../types";
import { rollSimple } from "./useDiceRoller";
// ==================== 默认命令 ====================
export const defaultCommands: Record<string, MdCommanderCommand> = {
help: {
command: "help",
description: "显示帮助信息或特定命令的帮助",
parameters: [
{
name: "cmd",
description: "要查询的命令名",
type: "enum",
values: [], // 运行时填充
required: false,
},
],
handler: (args) => {
const cmdName = args.params.cmd;
if (cmdName) {
return {
message: `命令:${cmdName}\n描述${defaultCommands[cmdName]?.description || "无描述"}`,
type: "info",
};
}
const cmdList = Object.keys(defaultCommands).join(", ");
return {
message: `可用命令:${cmdList}`,
type: "info",
};
},
},
clear: {
command: "clear",
description: "清空命令历史",
handler: () => ({
message: "命令历史已清空",
type: "success",
}),
},
roll: {
command: "roll",
description: "掷骰子 - 支持骰池、修饰符和组合",
parameters: [
{
name: "formula",
description: "骰子公式,如 3d6+{d4,d8}kh2-5",
type: "string",
required: true,
},
],
handler: (args) => {
const formula = args.params.formula || "1d6";
const result = rollSimple(formula);
return {
message: result.text,
isHtml: result.isHtml,
type: result.text.startsWith("错误") ? "error" : "success",
};
},
},
};
// ==================== 工具函数 ====================
export function parseInput(input: string, commands?: Record<string, MdCommanderCommand>): {
command?: string;
params: Record<string, string>;
options: Record<string, string>;
incompleteParam?: { index: number; value: string };
} {
const result: {
command?: string;
params: Record<string, string>;
options: Record<string, string>;
incompleteParam?: { index: number; value: string };
} = {
params: {},
options: {},
};
const trimmed = input.trim();
if (!trimmed) return result;
const parts = trimmed.split(/\s+/);
let i = 0;
// 获取命令
if (parts[0] && !parts[0].startsWith("-")) {
result.command = parts[0];
i = 1;
}
// 获取命令的参数定义
const cmd = result.command ? commands?.[result.command] : undefined;
const paramDefs = cmd?.parameters || [];
let paramIndex = 0;
// 解析参数和选项
while (i < parts.length) {
const part = parts[i];
if (part.startsWith("--")) {
// 选项 --key=value 或 --key value
const eqIndex = part.indexOf("=");
if (eqIndex !== -1) {
const key = part.slice(2, eqIndex);
const value = part.slice(eqIndex + 1);
result.options[key] = value;
} else {
const key = part.slice(2);
if (i + 1 < parts.length && !parts[i + 1].startsWith("-")) {
result.options[key] = parts[i + 1];
i++;
} else {
// 未完成的选项
}
}
} else if (part.startsWith("-") && part.length === 2) {
// 短选项 -k value
const key = part.slice(1);
if (i + 1 < parts.length && !parts[i + 1].startsWith("-")) {
result.options[key] = parts[i + 1];
i++;
}
} else {
// 位置参数
if (paramIndex < paramDefs.length) {
const paramDef = paramDefs[paramIndex];
result.params[paramDef.name] = part;
paramIndex++;
}
}
i++;
}
// 检查是否有未完成的位置参数
if (paramIndex < paramDefs.length) {
const lastPart = parts[parts.length - 1];
if (lastPart && !lastPart.startsWith("-")) {
result.incompleteParam = {
index: paramIndex,
value: lastPart,
};
}
}
return result;
}
export function getCompletions(
input: string,
commands: Record<string, MdCommanderCommand>,
): CompletionItem[] {
const trimmed = input.trim();
if (!trimmed || /^\s*$/.test(trimmed)) {
return Object.values(commands).map((cmd) => ({
label: cmd.command,
type: "command",
description: cmd.description,
insertText: cmd.command,
}));
}
const parsed = parseInput(trimmed, commands);
if (!parsed.command || !commands[parsed.command]) {
const commandCompletions = Object.values(commands)
.filter((cmd) => cmd.command.startsWith(parsed.command || ""))
.map((cmd) => ({
label: cmd.command,
type: "command" as "command",
description: cmd.description,
insertText: cmd.command,
}));
if (commandCompletions.length > 0) {
return commandCompletions;
}
}
const cmd = commands[parsed.command!];
if (!cmd) return [];
// 检查是否需要参数补全
const paramDefs = cmd.parameters || [];
const usedParams = Object.keys(parsed.params);
// 如果还有未填的参数,提供参数值补全
if (paramDefs.length > usedParams.length) {
const paramDef = paramDefs[usedParams.length];
if (paramDef.type === "enum" && paramDef.values) {
const currentValue = parsed.incompleteParam?.value || "";
return paramDef.values
.filter((v) => v.startsWith(currentValue))
.map((v) => ({
label: v,
type: "value",
description: paramDef.description,
insertText: v,
}));
}
// 其他类型的参数,显示提示
if (parsed.incompleteParam) {
return [{
label: `<${paramDef.name}>`,
type: "value",
description: `${paramDef.type}${paramDef.required !== false ? " (必填)" : ""}: ${paramDef.description || ""}`,
insertText: "",
}];
}
}
// 选项补全
if (!cmd.options) return [];
const usedOptions = Object.keys(parsed.options);
return Object.values(cmd.options)
.filter((opt) => !usedOptions.includes(opt.option))
.map((opt) => ({
label: `--${opt.option}`,
type: "option",
description: opt.description,
insertText: `--${opt.option}=${opt.type === "boolean" ? "" : ""}`,
}));
}
export function getResultClass(
type?: "success" | "error" | "warning" | "info",
): string {
switch (type) {
case "success":
return "text-green-600";
case "error":
return "text-red-600";
case "warning":
return "text-yellow-600";
case "info":
default:
return "text-blue-600";
}
}
// ==================== Commander Hook ====================
export interface UseCommanderReturn {
inputValue: () => string;
entries: () => CommanderEntry[];
showCompletions: () => boolean;
completions: () => CompletionItem[];
selectedCompletion: () => number;
isFocused: () => boolean;
setInputValue: (v: string) => void;
setEntries: (updater: (prev: CommanderEntry[]) => CommanderEntry[]) => void;
setShowCompletions: (v: boolean) => void;
setSelectedCompletion: (v: number | ((prev: number) => number)) => void;
setIsFocused: (v: boolean) => void;
handleCommand: () => void;
updateCompletions: () => void;
acceptCompletion: () => void;
commands: Record<string, MdCommanderCommand>;
historyIndex: () => number;
setHistoryIndex: (v: number) => void;
commandHistory: () => string[];
navigateHistory: (direction: 'up' | 'down') => void;
}
export function useCommander(
customCommands?: Record<string, MdCommanderCommand>,
): UseCommanderReturn {
const [inputValue, setInputValue] = createSignal("");
const [entries, setEntries] = createSignal<CommanderEntry[]>([]);
const [showCompletions, setShowCompletions] = createSignal(false);
const [completions, setCompletions] = createSignal<CompletionItem[]>([]);
const [selectedCompletion, setSelectedCompletion] = createSignal(0);
const [isFocused, setIsFocused] = createSignal(false);
// 命令历史
const [commandHistory, setCommandHistory] = createSignal<string[]>([]);
const [historyIndex, setHistoryIndex] = createSignal(-1);
const commands = { ...defaultCommands, ...customCommands };
// 更新 help 命令的参数值
if (commands.help?.parameters?.[0]) {
commands.help.parameters[0].values = Object.keys(commands).filter(
(k) => k !== "help",
);
}
const handleCommand = () => {
const input = inputValue().trim();
if (!input) return;
const parsed = parseInput(input, commands);
const commandName = parsed.command;
const cmd = commands[commandName!];
let result: { message: string; type?: "success" | "error" | "warning" | "info"; isHtml?: boolean };
if (!cmd) {
result = { message: `未知命令:${commandName}`, type: "error" };
} else if (cmd.handler) {
try {
result = cmd.handler({ params: parsed.params, options: parsed.options });
} catch (e) {
result = {
message: `执行错误:${e instanceof Error ? e.message : String(e)}`,
type: "error",
};
}
} else {
result = { message: `命令 ${commandName} 已执行(无处理器)`, type: "info" };
}
const newEntry: CommanderEntry = {
id: Date.now().toString() + Math.random().toString(36).slice(2),
command: input,
args: parsed.options,
result,
timestamp: new Date(),
};
setEntries((prev) => [...prev, newEntry]);
// 添加到命令历史
setCommandHistory((prev) => [...prev, input]);
setHistoryIndex(-1); // 重置历史索引
setInputValue("");
setShowCompletions(false);
if (commandName === "clear") {
setEntries([]);
}
};
const updateCompletions = () => {
const input = inputValue();
const comps = getCompletions(input, commands);
setCompletions(comps);
setShowCompletions(comps.length > 0 && isFocused());
setSelectedCompletion(0);
};
const acceptCompletion = () => {
const idx = selectedCompletion();
const comp = completions()[idx];
if (!comp) return;
const input = inputValue();
const parsed = parseInput(input, commands);
let newValue: string;
if (comp.type === "command") {
newValue = comp.insertText + " ";
} else if (comp.type === "option") {
const base = parsed.command || "";
const existingOptions = Object.entries(parsed.options)
.map(([k, v]) => `--${k}=${v}`)
.join(" ");
newValue = `${base} ${existingOptions}${existingOptions ? " " : ""}${comp.insertText}`;
} else if (comp.type === "value") {
// 参数值补全
const cmd = parsed.command ? commands[parsed.command] : null;
const paramDefs = cmd?.parameters || [];
const usedParams = Object.keys(parsed.params);
if (paramDefs.length > usedParams.length) {
// 当前参数的补全
const base = parsed.command || "";
const existingParams = Object.values(parsed.params).join(" ");
const existingOptions = Object.entries(parsed.options)
.map(([k, v]) => `--${k}=${v}`)
.join(" ");
newValue = `${base} ${existingParams}${existingParams ? " " : ""}${comp.insertText}${existingOptions ? " " + existingOptions : ""}`;
} else {
newValue = input;
}
} else {
newValue = input;
}
setInputValue(newValue.trim());
setShowCompletions(false);
};
const navigateHistory = (direction: 'up' | 'down') => {
const history = commandHistory();
if (history.length === 0) return;
let newIndex = historyIndex();
if (direction === 'up') {
// 向上浏览历史(更早的命令)
if (newIndex < history.length - 1) {
newIndex++;
}
} else {
// 向下浏览历史(更新的命令)
if (newIndex > 0) {
newIndex--;
} else {
// 回到当前输入
setInputValue("");
setHistoryIndex(-1);
return;
}
}
setHistoryIndex(newIndex);
// 从历史末尾获取命令(最新的在前)
setInputValue(history[history.length - 1 - newIndex]);
};
return {
inputValue,
entries,
showCompletions,
completions,
selectedCompletion,
isFocused,
setInputValue,
setEntries,
setShowCompletions,
setSelectedCompletion,
setIsFocused,
handleCommand,
updateCompletions,
acceptCompletion,
commands,
historyIndex,
setHistoryIndex,
commandHistory,
navigateHistory,
};
}