diff --git a/common/commandArguments.ts b/common/commandArguments.ts new file mode 100644 index 0000000..ec6ab86 --- /dev/null +++ b/common/commandArguments.ts @@ -0,0 +1,220 @@ +import type { + CommandArgumentListFieldSchema, + CommandArgumentListValue, + CommandArgumentScalarValue, + CommandArguments, + ListCommandArgumentSchema, + RegisteredSessionCommand, + ScalarCommandArgumentSchema, +} from "@/common/sessionCommandBase"; +import { isListArgumentSchema } from "@/common/sessionCommandBase"; + +export type ScalarFormValues = Record; +export type ListFormValues = Record[]>; + +export type CommandArgumentFormState = { + scalar: ScalarFormValues; + list: ListFormValues; +}; + +export type CommandArgumentFormErrors = Record; + +function createEmptyListItem( + schema: ListCommandArgumentSchema +): Record { + return Object.fromEntries(schema.fields.map((field) => [field.name, ""])); +} + +export function createInitialFormState( + command: RegisteredSessionCommand +): CommandArgumentFormState { + const scalar: ScalarFormValues = {}; + const list: ListFormValues = {}; + + for (const argument of command.arguments) { + if (isListArgumentSchema(argument)) { + const minItems = argument.minItems ?? (argument.required === false ? 0 : 1); + list[argument.name] = Array.from({ length: minItems }, () => + createEmptyListItem(argument) + ); + continue; + } + scalar[argument.name] = ""; + } + + return { scalar, list }; +} + +function validateScalarField( + schema: ScalarCommandArgumentSchema, + rawValue: string +): string | null { + const trimmedValue = rawValue.trim(); + + if (schema.required && trimmedValue === "") return "Pflichtfeld"; + if (!schema.required && trimmedValue === "") return null; + + if (schema.type === "integer" || schema.type === "number") { + const value = Number(trimmedValue); + if (!Number.isFinite(value)) return "Bitte eine Zahl eingeben"; + if (schema.type === "integer" && !Number.isInteger(value)) { + return "Bitte eine ganze Zahl eingeben"; + } + if (schema.min != null && value < schema.min) { + return `Mindestens ${schema.min}`; + } + if (schema.max != null && value > schema.max) { + return `Maximal ${schema.max}`; + } + } + + if (schema.type === "date" && Number.isNaN(Date.parse(trimmedValue))) { + return "Bitte ein gültiges Datum eingeben"; + } + + if (schema.type === "time" && !/^\d{2}:\d{2}$/.test(trimmedValue)) { + return "Bitte eine gültige Uhrzeit eingeben"; + } + + return null; +} + +function validateListField( + field: CommandArgumentListFieldSchema, + rawValue: string +): string | null { + const trimmedValue = rawValue.trim(); + + if (field.required && trimmedValue === "") return "Pflichtfeld"; + if (!field.required && trimmedValue === "") return null; + + if (field.type === "date" && Number.isNaN(Date.parse(trimmedValue))) { + return "Bitte ein gültiges Datum eingeben"; + } + + if (field.type === "time" && !/^\d{2}:\d{2}$/.test(trimmedValue)) { + return "Bitte eine gültige Uhrzeit eingeben"; + } + + return null; +} + +function listErrorKey( + argumentName: string, + itemIndex: number, + fieldName: string +): string { + return `${argumentName}.${itemIndex}.${fieldName}`; +} + +export function validateCommandArguments( + command: RegisteredSessionCommand, + state: CommandArgumentFormState +): CommandArgumentFormErrors { + const errors: CommandArgumentFormErrors = {}; + + for (const argument of command.arguments) { + if (isListArgumentSchema(argument)) { + const items = state.list[argument.name] ?? []; + const minItems = argument.minItems ?? (argument.required === false ? 0 : 1); + + if (argument.required && items.length === 0) { + errors[argument.name] = "Mindestens ein Eintrag erforderlich"; + } + + if (items.length < minItems) { + errors[argument.name] = `Mindestens ${minItems} Einträge erforderlich`; + } + + if (argument.maxItems != null && items.length > argument.maxItems) { + errors[argument.name] = `Maximal ${argument.maxItems} Einträge erlaubt`; + } + + items.forEach((item, itemIndex) => { + for (const field of argument.fields) { + const error = validateListField(field, item[field.name] ?? ""); + if (error) { + errors[listErrorKey(argument.name, itemIndex, field.name)] = error; + } + } + }); + continue; + } + + const error = validateScalarField(argument, state.scalar[argument.name] ?? ""); + if (error) errors[argument.name] = error; + } + + return errors; +} + +function parseScalarField( + schema: ScalarCommandArgumentSchema, + rawValue: string +): CommandArgumentScalarValue { + const trimmedValue = rawValue.trim(); + + if (schema.type === "string" || schema.type === "date" || schema.type === "time") { + return trimmedValue; + } + if (schema.type === "boolean") return trimmedValue === "true"; + + const value = Number(trimmedValue); + if (schema.type === "integer") return Math.trunc(value); + return value; +} + +function parseListField( + field: CommandArgumentListFieldSchema, + rawValue: string +): CommandArgumentScalarValue { + return rawValue.trim(); +} + +export function parseCommandArguments( + command: RegisteredSessionCommand, + state: CommandArgumentFormState +): CommandArguments { + const args: CommandArguments = {}; + + for (const argument of command.arguments) { + if (isListArgumentSchema(argument)) { + const items = state.list[argument.name] ?? []; + args[argument.name] = items.map((item) => + Object.fromEntries( + argument.fields.map((field) => [ + field.name, + parseListField(field, item[field.name] ?? ""), + ]) + ) + ) as CommandArgumentListValue; + continue; + } + + args[argument.name] = parseScalarField( + argument, + state.scalar[argument.name] ?? "" + ); + } + + return args; +} + +export function getScalarInputType( + schema: ScalarCommandArgumentSchema +): "text" | "number" | "date" | "time" { + if (schema.type === "integer" || schema.type === "number") return "number"; + if (schema.type === "date") return "date"; + if (schema.type === "time") return "time"; + return "text"; +} + +export function getListFieldInputType( + field: CommandArgumentListFieldSchema +): "text" | "date" | "time" { + if (field.type === "date") return "date"; + if (field.type === "time") return "time"; + return "text"; +} + +export { listErrorKey }; diff --git a/common/commands/date.ts b/common/commands/date.ts new file mode 100644 index 0000000..8253fa7 --- /dev/null +++ b/common/commands/date.ts @@ -0,0 +1,18 @@ +import { + asRegisteredSessionCommand, + defineSessionCommand, +} from "@/common/sessionCommandBase"; + +type NoArgs = Record; + +export default asRegisteredSessionCommand( + defineSessionCommand({ + name: "date", + description: "Aktuelles Datum einfügen", + arguments: [], + execute: () => { + const dateStr = new Date().toISOString().split("T")[0]; + return { text: dateStr, data: { type: "date", date: dateStr }, shouldReplaceCommand: true }; + }, + }) +); diff --git a/common/commands/fuckyou.ts b/common/commands/fuckyou.ts new file mode 100644 index 0000000..d52b217 --- /dev/null +++ b/common/commands/fuckyou.ts @@ -0,0 +1,17 @@ +import { + asRegisteredSessionCommand, + defineSessionCommand, +} from "@/common/sessionCommandBase"; + +type NoArgs = Record; + +export default asRegisteredSessionCommand( + defineSessionCommand({ + name: "fy", + description: "fy", + arguments: [], + execute: () => { + return { text: "༼ つ ◕_◕ ༽つᶠᶸᶜᵏᵧₒᵤ!", data: {}, shouldReplaceCommand: true }; + }, + }) +); diff --git a/common/commands/index.ts b/common/commands/index.ts new file mode 100644 index 0000000..55fcac5 --- /dev/null +++ b/common/commands/index.ts @@ -0,0 +1,18 @@ +import voteCommand from "./vote"; +import dateCommand from "./date"; +import fuckyou from "./fuckyou"; +import tableflipp from "./tableflipp"; +import tableunflipp from "./tableunflipp"; +import timeCommand from "./time"; +import todolistCommand from "./todolist"; +import type { RegisteredSessionCommand } from "@/common/sessionCommandBase"; + +export const sessionCommands: RegisteredSessionCommand[] = [ + voteCommand, + dateCommand, + fuckyou, + tableflipp, + tableunflipp, + timeCommand, + todolistCommand +]; diff --git a/common/commands/tableflipp.ts b/common/commands/tableflipp.ts new file mode 100644 index 0000000..5f3a557 --- /dev/null +++ b/common/commands/tableflipp.ts @@ -0,0 +1,17 @@ +import { + asRegisteredSessionCommand, + defineSessionCommand, +} from "@/common/sessionCommandBase"; + +type NoArgs = Record; + +export default asRegisteredSessionCommand( + defineSessionCommand({ + name: "tableflipp", + description: "Table Flipp", + arguments: [], + execute: () => { + return { text: "(╯°□°)╯︵ ┻━┻", data: {}, shouldReplaceCommand: true }; + }, + }) +); diff --git a/common/commands/tableunflipp.ts b/common/commands/tableunflipp.ts new file mode 100644 index 0000000..388bfc3 --- /dev/null +++ b/common/commands/tableunflipp.ts @@ -0,0 +1,17 @@ +import { + asRegisteredSessionCommand, + defineSessionCommand, +} from "@/common/sessionCommandBase"; + +type NoArgs = Record; + +export default asRegisteredSessionCommand( + defineSessionCommand({ + name: "tableunflipp", + description: "Table Unflipp", + arguments: [], + execute: () => { + return { text: "┬─┬ ノ( ゜- ゜)ノ", data: {}, shouldReplaceCommand: true }; + }, + }) +); diff --git a/common/commands/time.ts b/common/commands/time.ts new file mode 100644 index 0000000..485e78c --- /dev/null +++ b/common/commands/time.ts @@ -0,0 +1,19 @@ +import { + asRegisteredSessionCommand, + defineSessionCommand, +} from "@/common/sessionCommandBase"; + +type NoArgs = Record; + +export default asRegisteredSessionCommand( + defineSessionCommand({ + name: "time", + description: "Aktuelle Uhrzeit einfügen", + arguments: [], + execute: () => { + const now = new Date(); + const timeStr = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + return { text: timeStr, data: { type: "time", time: timeStr }, shouldReplaceCommand: true }; + }, + }) +); diff --git a/common/commands/todolist.ts b/common/commands/todolist.ts new file mode 100644 index 0000000..4f888d0 --- /dev/null +++ b/common/commands/todolist.ts @@ -0,0 +1,66 @@ +import { + asRegisteredSessionCommand, + defineSessionCommand, +} from "@/common/sessionCommandBase"; + +type TodoListItem = { + topic: string; + deadline: string; +}; + +type TodoListArgs = { + title: string; + items: TodoListItem[]; +}; + +export default asRegisteredSessionCommand( + defineSessionCommand({ + name: "todolist", + description: "Todo-Liste mit Themen und Fristen einfügen", + arguments: [ + { + name: "title", + label: "Titel", + type: "string", + required: true, + placeholder: "Todo list", + }, + { + name: "items", + label: "Aufgaben", + type: "list", + required: true, + minItems: 1, + itemLabel: "Aufgabe", + addLabel: "Aufgabe hinzufügen", + fields: [ + { + name: "topic", + label: "Thema", + type: "string", + required: true, + placeholder: "Beschreibung", + }, + { + name: "deadline", + label: "Frist", + type: "date", + required: false, + }, + ], + }, + ], + execute: (args) => { + const lines = args.items.map((item) => { + const deadline = item.deadline ? ` (bis ${item.deadline})` : ""; + return `- [ ] ${item.topic}${deadline}`; + }); + + return { + text: `${args.title}\n${lines.join("\n")}`, + data: { type: "todolist", title: args.title, items: args.items }, + shouldReplaceCommand: true, + }; + }, + }) +); diff --git a/common/commands/vote.ts b/common/commands/vote.ts new file mode 100644 index 0000000..866d30b --- /dev/null +++ b/common/commands/vote.ts @@ -0,0 +1,27 @@ +import { + asRegisteredSessionCommand, + defineSessionCommand, +} from "@/common/sessionCommandBase"; + +type VoteArgs = { + zustimmung: number; + ablehnung: number; + enthaltung: number; +}; + +export default asRegisteredSessionCommand( + defineSessionCommand({ + name: "vote", + description: "Abstimmung mit Zustimmung, Ablehnung und Enthaltung einfügen", + arguments: [ + { name: "zustimmung", label: "Zustimmung", type: "integer", required: true, min: 0, placeholder: "0" }, + { name: "ablehnung", label: "Ablehnung", type: "integer", required: true, min: 0, placeholder: "0" }, + { name: "enthaltung", label: "Enthaltung", type: "integer", required: true, min: 0, placeholder: "0" }, + ], + execute: (args) => ({ + text: `(${args.zustimmung}/${args.ablehnung}/${args.enthaltung}) (dafür/dagegen/enthalten)`, + data: { type: "vote", ...args }, + shouldReplaceCommand: true, + }), + }) +); diff --git a/common/sessionCommandBase.ts b/common/sessionCommandBase.ts new file mode 100644 index 0000000..b13804f --- /dev/null +++ b/common/sessionCommandBase.ts @@ -0,0 +1,122 @@ +export type CommandScalarArgumentType = + | "integer" + | "number" + | "string" + | "boolean" + | "date" + | "time"; + +export type CommandArgumentListFieldType = "string" | "date" | "time"; + +export type CommandArgumentListFieldSchema = { + name: string; + label: string; + type: CommandArgumentListFieldType; + required?: boolean; + placeholder?: string; +}; + +export type CommandArgumentListValue = Record[]; + +export type CommandArgumentScalarValue = string | number | boolean; + +export type CommandArgumentValue = + | CommandArgumentScalarValue + | CommandArgumentListValue; + +export type CommandArguments = Record; + +type CommandArgumentSchemaBase = { + name: string; + label: string; + description?: string; + required?: boolean; +}; + +export type ScalarCommandArgumentSchema = CommandArgumentSchemaBase & { + type: CommandScalarArgumentType; + min?: number; + max?: number; + placeholder?: string; +}; + +export type ListCommandArgumentSchema = CommandArgumentSchemaBase & { + type: "list"; + itemLabel?: string; + addLabel?: string; + minItems?: number; + maxItems?: number; + fields: CommandArgumentListFieldSchema[]; +}; + +export type CommandArgumentSchema = + | ScalarCommandArgumentSchema + | ListCommandArgumentSchema; + +export type CommandExecutionContext = { + sourceText: string; + triggerIndex: number; + cursorIndex: number; +}; + +export type CommandExecutionResult = { + text: string; + data?: unknown; + shouldReplaceCommand?: boolean; +}; + +export type SessionCommand = { + name: string; + description: string; + arguments: CommandArgumentSchema[]; + execute: ( + args: TArgs, + context: CommandExecutionContext + ) => CommandExecutionResult | Promise; +}; + +export type RegisteredSessionCommand = Omit< + SessionCommand, + "execute" +> & { + execute: ( + args: CommandArguments, + context: CommandExecutionContext + ) => CommandExecutionResult | Promise; +}; + +export const defineSessionCommand = ( + command: SessionCommand +) => command; + +export const asRegisteredSessionCommand = ( + command: SessionCommand +) => command as RegisteredSessionCommand; + +export function isListArgumentSchema( + schema: CommandArgumentSchema +): schema is ListCommandArgumentSchema { + return schema.type === "list"; +} + +export function commandRequiresArguments( + command: Pick +): boolean { + return command.arguments.length > 0; +} + +export function insertAtCommandRange( + text: string, + triggerIndex: number, + cursorIndex: number, + insert: string +): { nextText: string; nextCursor: number } { + const before = text.slice(0, triggerIndex); + const after = text.slice(cursorIndex); + const needsSpace = after.length > 0 && !/^\s/.test(after); + const spacer = needsSpace ? " " : ""; + return { + nextText: `${before}${insert}${spacer}${after}`, + nextCursor: before.length + insert.length + spacer.length, + }; +} diff --git a/common/sessionCommands.ts b/common/sessionCommands.ts new file mode 100644 index 0000000..8ca9b6e --- /dev/null +++ b/common/sessionCommands.ts @@ -0,0 +1,85 @@ +import { + type CommandArguments, + type CommandArgumentListFieldSchema, + type CommandArgumentListFieldType, + type CommandArgumentListValue, + type CommandArgumentScalarValue, + type CommandArgumentValue, + type CommandExecutionResult, + type RegisteredSessionCommand, + type SessionCommand, + type CommandArgumentSchema, + type CommandScalarArgumentType, + type CommandExecutionContext, + type ListCommandArgumentSchema, + type ScalarCommandArgumentSchema, + commandRequiresArguments, + defineSessionCommand, + insertAtCommandRange, + isListArgumentSchema, +} from "@/common/sessionCommandBase"; +import { sessionCommands } from "./commands"; + +export type { + CommandArguments, + CommandArgumentListFieldSchema, + CommandArgumentListFieldType, + CommandArgumentListValue, + CommandArgumentScalarValue, + CommandArgumentValue, + CommandExecutionResult, + RegisteredSessionCommand, + SessionCommand, + CommandArgumentSchema, + CommandScalarArgumentType, + CommandExecutionContext, + ListCommandArgumentSchema, + ScalarCommandArgumentSchema, +}; +export { + commandRequiresArguments, + defineSessionCommand, + insertAtCommandRange, + isListArgumentSchema, +}; + +export class SessionCommandRegistry { + private commands = new Map(); + + register(command: SessionCommand) { + const key = command.name.toLowerCase(); + if (this.commands.has(key)) { + throw new Error(`Session command "${command.name}" is already registered.`); + } + this.commands.set(key, command as unknown as RegisteredSessionCommand); + return this; + } + + list() { + return Array.from(this.commands.values()).sort((a, b) => + a.name.localeCompare(b.name) + ); + } + + find(name: string) { + return this.commands.get(name.toLowerCase()) ?? null; + } + + filter(query: string) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return this.list(); + return this.list().filter( + (command) => + command.name.toLowerCase().includes(normalizedQuery) || + command.description.toLowerCase().includes(normalizedQuery) + ); + } +} + +export const sessionCommandRegistry = sessionCommands.reduce( + (registry, command) => { + registry.register(command); + return registry; + }, + new SessionCommandRegistry() +); diff --git a/components/CommandArgumentForm.tsx b/components/CommandArgumentForm.tsx new file mode 100644 index 0000000..aa084fe --- /dev/null +++ b/components/CommandArgumentForm.tsx @@ -0,0 +1,201 @@ +import React, { FormEvent, useCallback, useMemo, useState } from "react"; +import { + createInitialFormState, + getScalarInputType, + parseCommandArguments, + validateCommandArguments, + type CommandArgumentFormErrors, +} from "@/common/commandArguments"; +import { isListArgumentSchema } from "@/common/sessionCommandBase"; +import type { + CommandArguments, + RegisteredSessionCommand, + ScalarCommandArgumentSchema, +} from "@/common/sessionCommands"; +import { CommandArgumentListInput } from "@/components/CommandArgumentListInput"; + +type Props = { + command: RegisteredSessionCommand; + onSubmit: (args: CommandArguments) => void; + onCancel: () => void; +}; + +const fieldClassName = + "w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-border dark:bg-zinc-950 dark:text-foreground dark:placeholder:text-muted-foreground dark:focus:border-primary dark:focus:ring-indigo-950"; + +export function CommandArgumentForm({ command, onSubmit, onCancel }: Props) { + const initialState = useMemo(() => createInitialFormState(command), [command]); + const [scalarValues, setScalarValues] = useState(initialState.scalar); + const [listValues, setListValues] = useState(initialState.list); + const [errors, setErrors] = useState({}); + + const focusFirstField = useCallback( + (element: HTMLInputElement | HTMLSelectElement | null) => { + element?.focus(); + }, + [] + ); + + const submit = (event: FormEvent) => { + event.preventDefault(); + + const state = { scalar: scalarValues, list: listValues }; + const nextErrors = validateCommandArguments(command, state); + + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + return; + } + + onSubmit(parseCommandArguments(command, state)); + }; + + const firstFocusableArgumentName = command.arguments[0]?.name ?? null; + + return ( +
+
+

+ /{command.name} +

+

+ {command.description} +

+
+ +
+ {command.arguments.map((argument) => { + if (isListArgumentSchema(argument)) { + const shouldFocus = argument.name === firstFocusableArgumentName; + + return ( +
+ + {argument.label} + + {argument.description && ( + + {argument.description} + + )} + { + setListValues((current) => ({ + ...current, + [argument.name]: items, + })); + setErrors((current) => { + const next = { ...current }; + delete next[argument.name]; + items.forEach((_, itemIndex) => { + for (const field of argument.fields) { + delete next[`${argument.name}.${itemIndex}.${field.name}`]; + } + }); + return next; + }); + }} + firstFieldRef={ + shouldFocus + ? (element) => focusFirstField(element) + : undefined + } + /> +
+ ); + } + + const error = errors[argument.name]; + const shouldFocus = argument.name === firstFocusableArgumentName; + + return ( + { + setScalarValues((current) => ({ + ...current, + [argument.name]: value, + })); + setErrors((current) => ({ ...current, [argument.name]: "" })); + }} + /> + ); + })} +
+ +
+ + +
+
+ ); +} + +function ScalarField({ + schema, + value, + error, + inputRef, + onChange, +}: { + schema: ScalarCommandArgumentSchema; + value: string; + error?: string; + inputRef?: (element: HTMLInputElement | HTMLSelectElement | null) => void; + onChange: (value: string) => void; +}) { + return ( + + ); +} diff --git a/components/CommandArgumentListInput.tsx b/components/CommandArgumentListInput.tsx new file mode 100644 index 0000000..4785281 --- /dev/null +++ b/components/CommandArgumentListInput.tsx @@ -0,0 +1,151 @@ +import { Plus, Trash2 } from "lucide-react"; +import type { + CommandArgumentListFieldSchema, + ListCommandArgumentSchema, +} from "@/common/sessionCommandBase"; +import { + getListFieldInputType, + listErrorKey, + type CommandArgumentFormErrors, +} from "@/common/commandArguments"; + +const fieldClassName = + "w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-border dark:bg-zinc-950 dark:text-foreground dark:placeholder:text-muted-foreground dark:focus:border-primary dark:focus:ring-indigo-950"; + +type Props = { + schema: ListCommandArgumentSchema; + items: Record[]; + errors: CommandArgumentFormErrors; + onChange: (items: Record[]) => void; + firstFieldRef?: (element: HTMLInputElement | null) => void; +}; + +export function CommandArgumentListInput({ + schema, + items, + errors, + onChange, + firstFieldRef, +}: Props) { + const itemLabel = schema.itemLabel ?? "Eintrag"; + const addLabel = schema.addLabel ?? "Eintrag hinzufügen"; + const canAdd = + schema.maxItems == null || items.length < schema.maxItems; + const minItems = schema.minItems ?? (schema.required === false ? 0 : 1); + const canRemove = items.length > minItems; + + const updateItem = ( + itemIndex: number, + fieldName: string, + value: string + ) => { + onChange( + items.map((item, index) => + index === itemIndex ? { ...item, [fieldName]: value } : item + ) + ); + }; + + const addItem = () => { + if (!canAdd) return; + onChange([ + ...items, + Object.fromEntries(schema.fields.map((field) => [field.name, ""])), + ]); + }; + + const removeItem = (itemIndex: number) => { + if (!canRemove) return; + onChange(items.filter((_, index) => index !== itemIndex)); + }; + + return ( +
+ {errors[schema.name] && ( + {errors[schema.name]} + )} + + {items.map((item, itemIndex) => ( +
+
+ + {itemLabel} {itemIndex + 1} + + {canRemove && ( + + )} +
+ +
+ {schema.fields.map((field, fieldIndex) => ( + updateItem(itemIndex, field.name, value)} + /> + ))} +
+
+ ))} + + {canAdd && ( + + )} +
+ ); +} + +function ListField({ + field, + value, + error, + inputRef, + onChange, +}: { + field: CommandArgumentListFieldSchema; + value: string; + error?: string; + inputRef?: (element: HTMLInputElement | null) => void; + onChange: (value: string) => void; +}) { + return ( + + ); +} diff --git a/components/CommandSuggestions.tsx b/components/CommandSuggestions.tsx new file mode 100644 index 0000000..34154f8 --- /dev/null +++ b/components/CommandSuggestions.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { Terminal } from "lucide-react"; +import type { RegisteredSessionCommand } from "@/common/sessionCommands"; + +type Props = { + isOpen: boolean; + commands: RegisteredSessionCommand[]; + activeIndex?: number; + onPick: (command: RegisteredSessionCommand) => void; + position?: "absolute" | "fixed"; + style?: React.CSSProperties; + className?: string; + containerRef?: React.RefObject; +}; + +export function CommandSuggestions({ + isOpen, + commands, + activeIndex = 0, + onPick, + position = "absolute", + style, + className, + containerRef, +}: Props) { + if (!isOpen || commands.length === 0) return null; + + return ( +
+
+ {commands.map((command, index) => { + const isActive = index === activeIndex; + return ( + + ); + })} +
+
+ ); +} diff --git a/components/SortableSessionItem.tsx b/components/SortableSessionItem.tsx index cf69940..a9bf0c6 100644 --- a/components/SortableSessionItem.tsx +++ b/components/SortableSessionItem.tsx @@ -3,7 +3,17 @@ import { GripVertical, Trash2, Plus } from "lucide-react"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Member, SessionItem } from "@/common/types"; +import { + CommandArguments, + commandRequiresArguments, + insertAtCommandRange, + RegisteredSessionCommand, + sessionCommandRegistry, +} from "@/common/sessionCommands"; +import { CommandArgumentForm } from "@/components/CommandArgumentForm"; +import { CommandSuggestions } from "@/components/CommandSuggestions"; import { MemberSuggestions } from "@/components/MemberSuggestions"; +import { Modal } from "@/components/Modal"; import { useSuggestionNavigation } from "@/hooks/useSuggestionNavigation"; interface SortableSessionItemProps { @@ -29,6 +39,16 @@ type MentionState = { cursorIndex: number | null; }; +type CommandState = { + isOpen: boolean; + target: MentionTarget | null; + query: string; + anchor: { top: number; left: number; width: number } | null; + triggerIndex: number | null; + cursorIndex: number | null; + selectedCommand: RegisteredSessionCommand | null; +}; + export const SortableSessionItem = ({ item, memberSuggestions, @@ -57,6 +77,7 @@ export const SortableSessionItem = ({ const topicRef = useRef(null); const pointRefs = useRef<(HTMLTextAreaElement | null)[]>([]); const dropdownRef = useRef(null); + const commandDropdownRef = useRef(null); const pendingSelectionRef = useRef<{ target: MentionTarget; pos: number; @@ -72,6 +93,17 @@ export const SortableSessionItem = ({ }); const [mentionMatches, setMentionMatches] = useState([]); + const [command, setCommand] = useState({ + isOpen: false, + target: null, + query: "", + anchor: null, + triggerIndex: null, + cursorIndex: null, + selectedCommand: null, + }); + + const commandMatches = sessionCommandRegistry.filter(command.query); const getTargetEl = (target: MentionTarget | null) => { if (!target) return null; @@ -91,6 +123,22 @@ export const SortableSessionItem = ({ })); }, []); + const closeCommand = useCallback(() => { + setCommand((current) => ({ + ...current, + isOpen: false, + target: null, + query: "", + anchor: null, + triggerIndex: null, + cursorIndex: null, + })); + }, []); + + const closeCommandForm = useCallback(() => { + setCommand((current) => ({ ...current, selectedCommand: null })); + }, []); + const findMentionContext = (text: string, cursorIndex: number) => { const uptoCursor = text.slice(0, cursorIndex); const at = uptoCursor.lastIndexOf("@"); @@ -102,6 +150,17 @@ export const SortableSessionItem = ({ return { triggerIndex: at, query }; }; + const findCommandContext = (text: string, cursorIndex: number) => { + const uptoCursor = text.slice(0, cursorIndex); + const slash = uptoCursor.lastIndexOf("/"); + if (slash < 0) return null; + const prev = slash === 0 ? "" : uptoCursor[slash - 1]; + if (prev && !/\s|[([{"'`]/.test(prev)) return null; + const query = uptoCursor.slice(slash + 1); + if (/\s/.test(query)) return null; + return { triggerIndex: slash, query }; + }; + const updateMentionFromInput = ( target: MentionTarget, el: HTMLInputElement | HTMLTextAreaElement | null, @@ -128,6 +187,33 @@ export const SortableSessionItem = ({ })); }; + const updateCommandFromInput = ( + target: MentionTarget, + el: HTMLInputElement | HTMLTextAreaElement | null, + text: string + ) => { + if (!el) return closeCommand(); + const cursorIndex = el.selectionStart ?? text.length; + const ctx = findCommandContext(text, cursorIndex); + if (!ctx) return closeCommand(); + + const rect = el.getBoundingClientRect(); + setCommand((current) => ({ + ...current, + isOpen: true, + target, + query: ctx.query, + anchor: { + top: rect.bottom + 6, + left: rect.left, + width: rect.width, + }, + triggerIndex: ctx.triggerIndex, + cursorIndex, + })); + closeMention(); + }; + const applyMention = useCallback((memberName: string) => { if (!mention.isOpen || !mention.target) return; const target = mention.target; @@ -178,14 +264,108 @@ export const SortableSessionItem = ({ onClose: closeMention, }); + const commandRef = useRef(command); + commandRef.current = command; + + const resetCommandState = useCallback(() => { + setCommand({ + isOpen: false, + target: null, + query: "", + anchor: null, + triggerIndex: null, + cursorIndex: null, + selectedCommand: null, + }); + }, []); + + const executeCommand = useCallback( + async ( + selectedCommand: RegisteredSessionCommand, + args: CommandArguments, + execution: Pick + ) => { + const { target, triggerIndex, cursorIndex } = execution; + if (!target || triggerIndex == null || cursorIndex == null) return; + + const text = + target.type === "topic" + ? item.topic + : item.points[target.idx] ?? ""; + + const result = await selectedCommand.execute(args, { + sourceText: text, + triggerIndex, + cursorIndex, + }); + const { nextText, nextCursor } = insertAtCommandRange( + text, + triggerIndex, + cursorIndex, + result.text + ); + + if (target.type === "topic") { + updateTopicTitle(item.id, nextText); + } else { + updatePoint(item.id, target.idx, nextText); + } + + pendingSelectionRef.current = { target, pos: nextCursor }; + resetCommandState(); + }, + [item.id, item.points, item.topic, resetCommandState, updatePoint, updateTopicTitle] + ); + + const pickCommand = useCallback( + (nextCommand: RegisteredSessionCommand) => { + const current = commandRef.current; + if (!commandRequiresArguments(nextCommand)) { + setCommand((state) => ({ ...state, isOpen: false })); + void executeCommand(nextCommand, {}, current); + return; + } + setCommand({ + ...current, + isOpen: false, + selectedCommand: nextCommand, + }); + }, + [executeCommand] + ); + + const applyCommand = useCallback( + async (args: CommandArguments) => { + if (!command.selectedCommand) return; + await executeCommand(command.selectedCommand, args, command); + }, + [command, executeCommand] + ); + + const { + activeIndex: commandActiveIndex, + onKeyDown: onCommandKeyDown, + setActiveIndex: setCommandActiveIndex, + } = useSuggestionNavigation({ + isOpen: command.isOpen, + matches: commandMatches, + onPick: pickCommand, + onClose: closeCommand, + }); + const onEditorKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (onCommandKeyDown(e)) return; if (onMentionKeyDown(e)) return; e.stopPropagation(); }, - [onMentionKeyDown] + [onCommandKeyDown, onMentionKeyDown] ); + useEffect(() => { + setCommandActiveIndex(0); + }, [command.query, setCommandActiveIndex]); + useLayoutEffect(() => { const pending = pendingSelectionRef.current; if (!pending) return; @@ -212,6 +392,19 @@ export const SortableSessionItem = ({ return () => window.removeEventListener("mousedown", onMouseDown); }, [mention.isOpen, mention.target, closeMention]); + useEffect(() => { + if (!command.isOpen) return; + const onMouseDown = (ev: MouseEvent) => { + const target = ev.target as Node | null; + const el = getTargetEl(command.target); + if (commandDropdownRef.current?.contains(target as Node)) return; + if (el?.contains(target as Node)) return; + closeCommand(); + }; + window.addEventListener("mousedown", onMouseDown); + return () => window.removeEventListener("mousedown", onMouseDown); + }, [command.isOpen, command.target, closeCommand]); + return (
+ + + {command.selectedCommand && commandRequiresArguments(command.selectedCommand) && ( + + + + )} + {/* Topic Header */}
{/* Drag Handle */} @@ -267,6 +495,7 @@ export const SortableSessionItem = ({ onChange={(e) => { updateTopicTitle(item.id, e.target.value); updateMentionFromInput({ type: "topic" }, e.currentTarget, e.target.value); + updateCommandFromInput({ type: "topic" }, e.currentTarget, e.target.value); }} className="flex-1 bg-transparent text-lg font-semibold text-slate-800 dark:text-foreground placeholder-slate-400 dark:placeholder-muted-foreground outline-none focus:underline decoration-indigo-300 dark:decoration-indigo-700 underline-offset-4" placeholder="Thema Titel..." @@ -299,6 +528,11 @@ export const SortableSessionItem = ({ e.currentTarget, e.target.value ); + updateCommandFromInput( + { type: "point", idx }, + e.currentTarget, + e.target.value + ); }} className="flex-1 bg-transparent resize-none border-b border-transparent focus:border-indigo-200 dark:focus:border-primary outline-none py-1 text-slate-600 dark:text-foreground leading-relaxed" rows={ diff --git a/hooks/useSuggestionNavigation.ts b/hooks/useSuggestionNavigation.ts index 38ca180..d73605c 100644 --- a/hooks/useSuggestionNavigation.ts +++ b/hooks/useSuggestionNavigation.ts @@ -1,19 +1,18 @@ import { useCallback, useMemo, useState } from "react"; -import type { Member } from "@/common/types"; -type Options = { +type Options = { isOpen: boolean; - matches: Member[]; - onPick: (member: Member) => void; + matches: T[]; + onPick: (item: T) => void; onClose?: () => void; }; -export function useSuggestionNavigation({ +export function useSuggestionNavigation({ isOpen, matches, onPick, onClose, -}: Options) { +}: Options) { const [activeIndex, setActiveIndex] = useState(0); const clampedActiveIndex = useMemo(() => {