diff --git a/src/components/index.ts b/src/components/index.ts index 8dcbdf6..33c03dc 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ import './md-link'; import './md-pins'; import './md-bg'; import './md-deck'; +import './md-commander/index'; // 导出组件 export { Article } from './Article'; @@ -17,3 +18,11 @@ export { FileTreeNode, HeadingNode } from './FileTree'; export type { DiceProps } from './md-dice'; export type { TableProps } from './md-table'; export type { BgProps } from './md-bg'; +export type { + MdCommanderProps, + MdCommanderCommand, + MdCommanderOption, + MdCommanderOptionType, + CommanderEntry, + CompletionItem, +} from './md-commander'; diff --git a/src/components/md-commander/CommanderEntries.tsx b/src/components/md-commander/CommanderEntries.tsx new file mode 100644 index 0000000..b7ca10e --- /dev/null +++ b/src/components/md-commander/CommanderEntries.tsx @@ -0,0 +1,38 @@ +import { type Component, For, Show } from "solid-js"; +import type { CommanderEntry } from "./types"; +import { getResultClass } from "./hooks"; + +export interface CommanderEntriesProps { + entries: () => CommanderEntry[]; +} + +export const CommanderEntries: Component = (props) => { + return ( +
+ 0} + fallback={ +
暂无命令执行记录
+ } + > + + {(entry) => ( +
+
+ {entry.command} + + {entry.timestamp.toLocaleTimeString()} + +
+
+ {entry.result.message} +
+
+ )} +
+
+
+ ); +}; diff --git a/src/components/md-commander/CommanderInput.tsx b/src/components/md-commander/CommanderInput.tsx new file mode 100644 index 0000000..e11bb1c --- /dev/null +++ b/src/components/md-commander/CommanderInput.tsx @@ -0,0 +1,78 @@ +import { type Component, Show } from "solid-js"; +import type { CompletionItem } from "./types"; + +export interface CommanderInputProps { + placeholder?: string; + inputValue: () => string; + onInput: (e: Event) => void; + onKeyDown: (e: KeyboardEvent) => void; + onFocus: () => void; + onBlur: () => void; + onSubmit: () => void; + showCompletions: () => boolean; + completions: () => CompletionItem[]; + selectedCompletion: () => number; + onSelectCompletion: (idx: number) => void; + onAcceptCompletion: () => void; +} + +export const CommanderInput: Component = (props) => { + return ( +
+ + + + + {/* 自动补全下拉框 */} + 0}> +
+ {props.completions().map((comp, idx) => ( +
{ + props.onSelectCompletion(idx); + props.onAcceptCompletion(); + }} + > +
+ + {comp.type} + + {comp.label} +
+ + {comp.description} + +
+ ))} +
+
+
+ ); +}; diff --git a/src/components/md-commander/hooks/index.ts b/src/components/md-commander/hooks/index.ts new file mode 100644 index 0000000..0ccec30 --- /dev/null +++ b/src/components/md-commander/hooks/index.ts @@ -0,0 +1,2 @@ +export { useCommander, defaultCommands, parseInput, getCompletions, getResultClass } from './useCommander'; +export type { UseCommanderReturn } from './useCommander'; diff --git a/src/components/md-commander/hooks/useCommander.ts b/src/components/md-commander/hooks/useCommander.ts new file mode 100644 index 0000000..cd6d95c --- /dev/null +++ b/src/components/md-commander/hooks/useCommander.ts @@ -0,0 +1,342 @@ +import { createSignal, createMemo } from "solid-js"; +import { + MdCommanderCommand, + CommanderEntry, + CompletionItem, +} from "../types"; + +// ==================== 默认命令 ==================== + +export const defaultCommands: Record = { + help: { + command: "help", + description: "显示帮助信息或特定命令的帮助", + options: { + cmd: { + option: "cmd", + description: "要查询的命令名", + type: "enum", + values: [], // 运行时填充 + }, + }, + handler: (args) => { + if (args.cmd) { + return { + message: `命令:${args.cmd}\n描述:${defaultCommands[args.cmd]?.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: "掷骰子", + options: { + formula: { + option: "formula", + description: "骰子公式,如 2d6+3", + type: "string", + required: true, + }, + }, + handler: (args) => { + const formula = args.formula || "1d6"; + const match = formula.match(/^(\d+)?d(\d+)([+-]\d+)?$/i); + if (!match) { + return { message: `无效的骰子公式:${formula}`, type: "error" }; + } + const count = parseInt(match[1] || "1"); + const sides = parseInt(match[2]); + const modifier = parseInt(match[3] || "0"); + const rolls: number[] = []; + for (let i = 0; i < count; i++) { + rolls.push(Math.floor(Math.random() * sides) + 1); + } + const total = rolls.reduce((a, b) => a + b, 0) + modifier; + return { + message: `${formula} = [${rolls.join(", ")}]${modifier !== 0 ? (modifier > 0 ? `+${modifier}` : modifier) : ""} = ${total}`, + type: "success", + }; + }, + }, +}; + +// ==================== 工具函数 ==================== + +export function parseInput(input: string): { + command?: string; + options: Record; + incompleteOption?: string; +} { + const result: { + command?: string; + options: Record; + incompleteOption?: string; + } = { + 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; + } + + while (i < parts.length) { + const part = parts[i]; + if (part.startsWith("--")) { + 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 { + result.incompleteOption = key; + } + } + } + i++; + } + + return result; +} + +export function getCompletions( + input: string, + commands: Record, +): 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); + + 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", + description: cmd.description, + insertText: cmd.command, + })); + if (commandCompletions.length > 0) { + return commandCompletions; + } + } + + const cmd = commands[parsed.command!]; + if (!cmd || !cmd.options) return []; + + if (parsed.incompleteOption) { + const option = cmd.options[parsed.incompleteOption]; + if (option?.type === "enum" && option.values) { + return option.values + .filter((v) => v.startsWith(parsed.incompleteOption)) + .map((v) => ({ + label: v, + type: "value", + insertText: v, + })); + } + } + + 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; +} + +export function useCommander( + customCommands?: Record, +): UseCommanderReturn { + const [inputValue, setInputValue] = createSignal(""); + const [entries, setEntries] = createSignal([]); + const [showCompletions, setShowCompletions] = createSignal(false); + const [completions, setCompletions] = createSignal([]); + const [selectedCompletion, setSelectedCompletion] = createSignal(0); + const [isFocused, setIsFocused] = createSignal(false); + + const commands = { ...defaultCommands, ...customCommands }; + + // 更新 help 命令的选项值 + if (commands.help?.options?.cmd) { + commands.help.options.cmd.values = Object.keys(commands).filter( + (k) => k !== "help", + ); + } + + const handleCommand = () => { + const input = inputValue().trim(); + if (!input) return; + + const parsed = parseInput(input); + const commandName = parsed.command; + const cmd = commands[commandName!]; + + let result: { message: string; type?: "success" | "error" | "warning" | "info" }; + + if (!cmd) { + result = { message: `未知命令:${commandName}`, type: "error" }; + } else if (cmd.handler) { + try { + result = cmd.handler(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]); + 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); + + 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 { + const optKey = parsed.incompleteOption; + if (optKey) { + const base = parsed.command || ""; + const otherOptions = Object.entries(parsed.options) + .filter(([k]) => k !== optKey) + .map(([k, v]) => `--${k}=${v}`) + .join(" "); + newValue = `${base} --${optKey}=${comp.insertText}${otherOptions ? " " + otherOptions : ""}`; + } else { + newValue = input; + } + } + + setInputValue(newValue.trim()); + setShowCompletions(false); + }; + + return { + inputValue, + entries, + showCompletions, + completions, + selectedCompletion, + isFocused, + setInputValue, + setEntries, + setShowCompletions, + setSelectedCompletion, + setIsFocused, + handleCommand, + updateCompletions, + acceptCompletion, + commands, + }; +} diff --git a/src/components/md-commander/index.tsx b/src/components/md-commander/index.tsx new file mode 100644 index 0000000..c68dfbb --- /dev/null +++ b/src/components/md-commander/index.tsx @@ -0,0 +1,108 @@ +import { customElement, noShadowDOM } from "solid-element"; +import { onMount, onCleanup } from "solid-js"; +import { useCommander } from "./hooks"; +import { CommanderInput } from "./CommanderInput"; +import { CommanderEntries } from "./CommanderEntries"; +import type { MdCommanderProps } from "./types"; + +export { CommanderInput } from './CommanderInput'; +export type { CommanderInputProps } from './CommanderInput'; +export { CommanderEntries } from './CommanderEntries'; +export type { CommanderEntriesProps } from './CommanderEntries'; +export type { + MdCommanderProps, + MdCommanderCommand, + MdCommanderOption, + MdCommanderOptionType, + CommanderEntry, + CompletionItem, +} from './types'; + +customElement( + "md-commander", + { placeholder: "", class: "", height: "" }, + (props, { element }) => { + noShadowDOM(); + + const commander = useCommander(props.commands); + + const handleKeyDown = (e: KeyboardEvent) => { + if (commander.showCompletions() && commander.completions().length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + commander.setSelectedCompletion( + (prev) => (prev + 1) % commander.completions().length, + ); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + commander.setSelectedCompletion( + (prev) => (prev - 1 + commander.completions().length) % commander.completions().length, + ); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + commander.acceptCompletion(); + return; + } + if (e.key === "Escape") { + commander.setShowCompletions(false); + return; + } + } + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + commander.handleCommand(); + } + }; + + const heightStyle = () => props.height || "400px"; + + onMount(() => { + const handleClickOutside = (e: MouseEvent) => { + if (!element?.contains(e.target as Node)) { + commander.setShowCompletions(false); + } + }; + document.addEventListener("click", handleClickOutside); + onCleanup(() => document.removeEventListener("click", handleClickOutside)); + }); + + return ( +
+ {/* 命令输入框 */} +
+ { + commander.setInputValue((e.target as HTMLInputElement).value); + commander.updateCompletions(); + }} + onKeyDown={handleKeyDown} + onFocus={() => { + commander.setIsFocused(true); + commander.updateCompletions(); + }} + onBlur={() => commander.setIsFocused(false)} + onSubmit={commander.handleCommand} + showCompletions={commander.showCompletions} + completions={commander.completions} + selectedCompletion={commander.selectedCompletion} + onSelectCompletion={commander.setSelectedCompletion} + onAcceptCompletion={commander.acceptCompletion} + /> +
+ + {/* 命令执行结果 */} + +
+ ); + }, +); diff --git a/src/components/md-commander/types.ts b/src/components/md-commander/types.ts new file mode 100644 index 0000000..df4fddc --- /dev/null +++ b/src/components/md-commander/types.ts @@ -0,0 +1,55 @@ +import type { Component } from "solid-js"; + +export interface MdCommanderProps { + placeholder?: string; + class?: string; + height?: string; + commands?: Record; +} + +export interface MdCommanderCommand { + command: string; + description?: string; + options?: Record; + handler?: (args: Record) => { + message: string; + type?: "success" | "error" | "info" | "warning"; + }; +} + +export type MdCommanderOptionType = + | "string" + | "number" + | "enum" + | "tag" + | "boolean"; + +export interface MdCommanderOption { + option: string; + description?: string; + type: MdCommanderOptionType; + required?: boolean; + default?: any; + min?: number; + max?: number; + values?: string[]; + EntryComponent?: Component; +} + +export interface CommanderEntry { + id: string; + command: string; + args: Record; + result: { + message: string; + type?: "success" | "error" | "warning" | "info"; + }; + timestamp: Date; +} + +export interface CompletionItem { + label: string; + type: "command" | "option" | "value"; + description?: string; + insertText: string; +}