diff --git a/ui/src/components/Combobox.tsx b/ui/src/components/Combobox.tsx index f1342d4..383d88a 100644 --- a/ui/src/components/Combobox.tsx +++ b/ui/src/components/Combobox.tsx @@ -1,9 +1,7 @@ import { useRef } from "react"; import clsx from "clsx"; - -import { Combobox as HeadlessCombobox, ComboboxProps as HeadlessComboboxProps, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; +import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; import { cva } from "@/cva.config"; - import Card from "./Card"; export interface ComboboxOption { @@ -16,15 +14,15 @@ const sizes = { SM: "h-[32px] pl-3 pr-8 text-[13px]", MD: "h-[40px] pl-4 pr-10 text-sm", LG: "h-[48px] pl-4 pr-10 px-5 text-base", -}; +} as const; const comboboxVariants = cva({ variants: { size: sizes }, }); -interface ComboboxProps extends HeadlessComboboxProps> { +type BaseProps = React.ComponentProps; + +interface ComboboxProps extends Omit { displayValue: (option: ComboboxOption) => string; onInputChange: (option: string) => void; options: () => ComboboxOption[]; @@ -34,7 +32,7 @@ interface ComboboxProps extends HeadlessComboboxProps({ +export function Combobox({ onInputChange, displayValue, options, @@ -45,11 +43,11 @@ export function Combobox({ onChange, disabledMessage = "Input disabled", ...otherProps -}: ComboboxProps) { +}: ComboboxProps) { const inputRef = useRef(null); const classes = comboboxVariants({ size }); - const handleChange = (value: T) => { + const handleChange = (value: unknown) => { if (onChange) { onChange(value); inputRef.current?.blur(); @@ -57,14 +55,13 @@ export function Combobox({ }; return ( - > - immediate + - {() => ( + {() => ( <> - + ({ onChange={(event) => onInputChange(event.target.value)} disabled={disabled} /> - - - {options().length > 0 && ( + + + {options().length > 0 && ( - {options().map((option) => ( + {options().map((option) => ( ({ "dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200" )} > - {option.label} + {option.label} - ))} + ))} - )} - - {options().length === 0 && inputRef.current?.value && ( + )} + + {options().length === 0 && inputRef.current?.value && (
{emptyMessage}
- )} + )} - )} + )}
); } \ No newline at end of file diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx index a0a7275..107d8de 100644 --- a/ui/src/components/MacroBar.tsx +++ b/ui/src/components/MacroBar.tsx @@ -12,7 +12,6 @@ export default function MacroBar() { const { executeMacro } = useKeyboard(); const [send] = useJsonRpc(); - // Set up sendFn and initialize macros if needed useEffect(() => { setSendFn(send); diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx new file mode 100644 index 0000000..c482f7b --- /dev/null +++ b/ui/src/components/MacroForm.tsx @@ -0,0 +1,362 @@ +import { useState } from "react"; + +import { LuPlus, LuInfo } from "react-icons/lu"; + +import { KeySequence } from "@/hooks/stores"; +import { Button } from "@/components/Button"; +import { InputFieldWithLabel, FieldError } from "@/components/InputField"; +import Fieldset from "@/components/Fieldset"; +import { MacroStepCard } from "@/components/MacroStepCard"; +import Modal from "@/components/Modal"; +import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros"; + +interface ValidationErrors { + name?: string; + steps?: Record; +} + +interface MacroFormProps { + initialData: Partial; + onSubmit: (macro: Partial) => Promise; + onCancel: () => void; + isSubmitting?: boolean; + submitText?: string; + showCancelConfirm?: boolean; + onCancelConfirm?: () => void; + showDelete?: boolean; + onDelete?: () => void; + isDeleting?: boolean; +} + +export function MacroForm({ + initialData, + onSubmit, + onCancel, + isSubmitting = false, + submitText = "Save Macro", + showCancelConfirm = false, + onCancelConfirm, + showDelete = false, + onDelete, + isDeleting = false +}: MacroFormProps) { + const [macro, setMacro] = useState>(initialData); + const [keyQueries, setKeyQueries] = useState>({}); + const [errors, setErrors] = useState({}); + const [errorMessage, setErrorMessage] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const showTemporaryError = (message: string) => { + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 3000); + }; + + const validateForm = (): boolean => { + const newErrors: ValidationErrors = {}; + + // Name validation + if (!macro.name?.trim()) { + newErrors.name = "Name is required"; + } else if (macro.name.trim().length > 50) { + newErrors.name = "Name must be less than 50 characters"; + } + + if (!macro.steps?.length) { + newErrors.steps = { 0: { keys: "At least one step is required" } }; + } else { + const hasKeyOrModifier = macro.steps.some(step => + (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0 + ); + + if (!hasKeyOrModifier) { + newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } }; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validateForm()) { + showTemporaryError("Please fix the validation errors"); + return; + } + + try { + await onSubmit(macro); + } catch (error) { + if (error instanceof Error) { + showTemporaryError(error.message); + } else { + showTemporaryError("An error occurred while saving"); + } + } + }; + + const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => { + const newSteps = [...(macro.steps || [])]; + if (!newSteps[stepIndex]) return; + + if (option.keys) { + newSteps[stepIndex].keys = option.keys; + } else if (option.value) { + if (!newSteps[stepIndex].keys) { + newSteps[stepIndex].keys = []; + } + const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : []; + if (keysArray.length >= MAX_KEYS_PER_STEP) { + showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`); + return; + } + newSteps[stepIndex].keys = [...keysArray, option.value]; + } + setMacro({ ...macro, steps: newSteps }); + + if (errors.steps?.[stepIndex]?.keys) { + const newErrors = { ...errors }; + delete newErrors.steps?.[stepIndex].keys; + if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) { + delete newErrors.steps?.[stepIndex]; + } + if (Object.keys(newErrors.steps || {}).length === 0) { + delete newErrors.steps; + } + setErrors(newErrors); + } + }; + + const handleKeyQueryChange = (stepIndex: number, query: string) => { + setKeyQueries(prev => ({ ...prev, [stepIndex]: query })); + }; + + const handleModifierChange = (stepIndex: number, modifiers: string[]) => { + const newSteps = [...(macro.steps || [])]; + newSteps[stepIndex].modifiers = modifiers; + setMacro({ ...macro, steps: newSteps }); + + // Clear step errors when modifiers are added + if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) { + const newErrors = { ...errors }; + delete newErrors.steps?.[stepIndex].keys; + if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) { + delete newErrors.steps?.[stepIndex]; + } + if (Object.keys(newErrors.steps || {}).length === 0) { + delete newErrors.steps; + } + setErrors(newErrors); + } + }; + + const handleDelayChange = (stepIndex: number, delay: number) => { + const newSteps = [...(macro.steps || [])]; + newSteps[stepIndex].delay = delay; + setMacro({ ...macro, steps: newSteps }); + }; + + const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => { + const newSteps = [...(macro.steps || [])]; + const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1; + [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]]; + setMacro({ ...macro, steps: newSteps }); + }; + + const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO; + + return ( + <> +
+
+ { + setMacro(prev => ({ ...prev, name: e.target.value })); + if (errors.name) { + const newErrors = { ...errors }; + delete newErrors.name; + setErrors(newErrors); + } + }} + /> +
+ +
+
+
+ +
+ +
+

Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of {MAX_STEPS_PER_MACRO} steps per macro.

+
+
+
+ + {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps + +
+ {errors.steps && errors.steps[0]?.keys && ( +
+ +
+ )} +
+
+ {(macro.steps || []).map((step, stepIndex) => ( + 1 ? () => { + const newSteps = [...(macro.steps || [])]; + newSteps.splice(stepIndex, 1); + setMacro(prev => ({ ...prev, steps: newSteps })); + } : undefined} + onMoveUp={() => handleStepMove(stepIndex, 'up')} + onMoveDown={() => handleStepMove(stepIndex, 'down')} + onKeySelect={(option) => handleKeySelect(stepIndex, option)} + onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)} + keyQuery={keyQueries[stepIndex] || ''} + onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)} + onDelayChange={(delay) => handleDelayChange(stepIndex, delay)} + isLastStep={stepIndex === (macro.steps?.length || 0) - 1} + /> + ))} +
+
+ +
+
+ + {errorMessage && ( +
+ +
+ )} + +
+ {showCancelConfirm ? ( +
+ + Cancel changes? + +
+ ) : ( + <> +
+
+ {showDelete && ( +
+
+
+ + setShowDeleteConfirm(false)} + > +
+
+
+
+

+ Delete Macro +

+
+ Are you sure you want to delete this macro? This action cannot be undone. +
+
+ +
+
+
+
+
+
+ + ); +} \ No newline at end of file diff --git a/ui/src/components/MacroStepCard.tsx b/ui/src/components/MacroStepCard.tsx new file mode 100644 index 0000000..1bd6329 --- /dev/null +++ b/ui/src/components/MacroStepCard.tsx @@ -0,0 +1,246 @@ +import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu"; + +import { Button } from "@/components/Button"; +import { Combobox } from "@/components/Combobox"; +import { SelectMenuBasic } from "@/components/SelectMenuBasic"; +import Card from "@/components/Card"; +import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings"; +import { MAX_KEYS_PER_STEP } from "@/constants/macros"; + +// Filter out modifier keys since they're handled in the modifiers section +const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta']; + +const keyOptions = Object.keys(keys) + .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix))) + .map(key => ({ + value: key, + label: keyDisplayMap[key] || key, + })); + +const modifierOptions = Object.keys(modifiers).map(modifier => ({ + value: modifier, + label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"), +})); + +const groupedModifiers: Record = { + Control: modifierOptions.filter(mod => mod.value.startsWith('Control')), + Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')), + Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')), + Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')), +}; + +const PRESET_DELAYS = [ + { value: "50", label: "50ms" }, + { value: "100", label: "100ms" }, + { value: "200", label: "200ms" }, + { value: "300", label: "300ms" }, + { value: "500", label: "500ms" }, + { value: "750", label: "750ms" }, + { value: "1000", label: "1000ms" }, + { value: "1500", label: "1500ms" }, + { value: "2000", label: "2000ms" }, +]; + +interface MacroStep { + keys: string[]; + modifiers: string[]; + delay: number; +} + +interface MacroStepCardProps { + step: MacroStep; + stepIndex: number; + onDelete?: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + onKeySelect: (option: { value: string | null; keys?: string[] }) => void; + onKeyQueryChange: (query: string) => void; + keyQuery: string; + onModifierChange: (modifiers: string[]) => void; + onDelayChange: (delay: number) => void; + isLastStep: boolean; +} + +const ensureArray = (arr: T[] | null | undefined): T[] => { + return Array.isArray(arr) ? arr : []; +}; + +export function MacroStepCard({ + step, + stepIndex, + onDelete, + onMoveUp, + onMoveDown, + onKeySelect, + onKeyQueryChange, + keyQuery, + onModifierChange, + onDelayChange, + isLastStep +}: MacroStepCardProps) { + const getFilteredKeys = () => { + const selectedKeys = ensureArray(step.keys); + const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value)); + + if (keyQuery === '') { + return availableKeys; + } else { + return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase())); + } + }; + + return ( + +
+
+
+
+ + {stepIndex + 1} + +
+ +
+ {onDelete && ( +
+
+ +
+
+ +
+ {Object.entries(groupedModifiers).map(([group, mods]) => ( +
+ + {group} + +
+ {mods.map(option => ( + + ))} +
+
+ ))} +
+
+ +
+
+ +
+ +
+

You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.

+
+
+
+
+ {ensureArray(step.keys).map((key, keyIndex) => ( + + + {keyDisplayMap[key] || key} + +
+
+ onKeySelect(value)} + displayValue={() => keyQuery} + onInputChange={onKeyQueryChange} + options={getFilteredKeys} + disabledMessage="Max keys reached" + size="SM" + immediate + disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP} + placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."} + emptyMessage="No matching keys found" + /> +
+
+ +
+
+ +
+ +
+

The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.

+
+
+
+
+ onDelayChange(parseInt(e.target.value, 10))} + options={PRESET_DELAYS} + /> +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/constants/macros.ts b/ui/src/constants/macros.ts new file mode 100644 index 0000000..853cfe9 --- /dev/null +++ b/ui/src/constants/macros.ts @@ -0,0 +1,5 @@ +export const DEFAULT_DELAY = 50; +export const MAX_STEPS_PER_MACRO = 10; +export const MAX_KEYS_PER_STEP = 10; +export const MAX_TOTAL_MACROS = 25; +export const COPY_SUFFIX = "(copy)"; \ No newline at end of file diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 05bfdf0..0fa4121 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros"; // Define the JsonRpc types for better type checking interface JsonRpcResponse { @@ -671,7 +672,6 @@ export interface KeySequenceStep { export interface KeySequence { id: string; name: string; - description?: string; steps: KeySequenceStep[]; sortOrder?: number; } @@ -686,9 +686,9 @@ export interface MacrosState { setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void; } -const MAX_STEPS_PER_MACRO = 10; -const MAX_TOTAL_MACROS = 25; -const MAX_KEYS_PER_STEP = 10; +export const generateMacroId = () => { + return Math.random().toString(36).substring(2, 9); +}; export const useMacrosStore = create((set, get) => ({ macros: [], diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 4b29129..e09a2a9 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -40,10 +40,12 @@ import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access. import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware"; import SettingsVideoRoute from "./routes/devices.$id.settings.video"; import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance"; -import SettingsMacrosRoute from "./routes/devices.$id.settings.macros"; import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index"; import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update"; import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth"; +import SettingsMacrosRoute from "./routes/devices.$id.settings.macros"; +import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add"; +import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit"; export const isOnDevice = import.meta.env.MODE === "device"; export const isInCloud = !isOnDevice; @@ -178,7 +180,20 @@ if (isOnDevice) { }, { path: "macros", - element: , + children: [ + { + index: true, + element: , + }, + { + path: "add", + element: , + }, + { + path: ":macroId/edit", + element: , + }, + ], }, ], }, @@ -290,7 +305,20 @@ if (isOnDevice) { }, { path: "macros", - element: , + children: [ + { + index: true, + element: , + }, + { + path: "add", + element: , + }, + { + path: ":macroId/edit", + element: , + }, + ], }, ], }, diff --git a/ui/src/routes/devices.$id.settings.macros.add.tsx b/ui/src/routes/devices.$id.settings.macros.add.tsx new file mode 100644 index 0000000..1b3ce30 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.macros.add.tsx @@ -0,0 +1,63 @@ +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; + +import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { MacroForm } from "@/components/MacroForm"; +import { DEFAULT_DELAY } from "@/constants/macros"; +import notifications from "@/notifications"; + +export default function SettingsMacrosAddRoute() { + const { macros, saveMacros } = useMacrosStore(); + const [isSaving, setIsSaving] = useState(false); + const navigate = useNavigate(); + + const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { + return macros.map((macro, index) => ({ + ...macro, + sortOrder: index + 1, + })); + }; + + const handleAddMacro = async (macro: Partial) => { + setIsSaving(true); + try { + const newMacro: KeySequence = { + id: generateMacroId(), + name: macro.name!.trim(), + steps: macro.steps || [], + sortOrder: macros.length + 1, + }; + + await saveMacros(normalizeSortOrders([...macros, newMacro])); + notifications.success(`Macro "${newMacro.name}" created successfully`); + navigate("../"); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to create macro: ${error.message}`); + } else { + notifications.error("Failed to create macro"); + } + } finally { + setIsSaving(false); + } + }; + + return ( +
+ + navigate("../")} + isSubmitting={isSaving} + /> +
+ ); +} \ No newline at end of file diff --git a/ui/src/routes/devices.$id.settings.macros.edit.tsx b/ui/src/routes/devices.$id.settings.macros.edit.tsx new file mode 100644 index 0000000..6d92420 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.macros.edit.tsx @@ -0,0 +1,108 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { useState, useEffect } from "react"; + +import { KeySequence, useMacrosStore } from "@/hooks/stores"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { MacroForm } from "@/components/MacroForm"; +import notifications from "@/notifications"; + +const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { + return macros.map((macro, index) => ({ + ...macro, + sortOrder: index + 1, + })); +}; + +export default function SettingsMacrosEditRoute() { + const { macros, saveMacros } = useMacrosStore(); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const navigate = useNavigate(); + const { macroId } = useParams<{ macroId: string }>(); + const [macro, setMacro] = useState(null); + + useEffect(() => { + const foundMacro = macros.find(m => m.id === macroId); + if (foundMacro) { + setMacro({ + ...foundMacro, + steps: foundMacro.steps.map(step => ({ + ...step, + keys: Array.isArray(step.keys) ? step.keys : [], + modifiers: Array.isArray(step.modifiers) ? step.modifiers : [], + delay: typeof step.delay === 'number' ? step.delay : 0 + })) + }); + } else { + navigate("../"); + } + }, [macroId, macros, navigate]); + + const handleUpdateMacro = async (updatedMacro: Partial) => { + if (!macro) return; + + setIsUpdating(true); + try { + const newMacros = macros.map(m => + m.id === macro.id ? { + ...macro, + name: updatedMacro.name!.trim(), + steps: updatedMacro.steps || [], + } : m + ); + + await saveMacros(normalizeSortOrders(newMacros)); + notifications.success(`Macro "${updatedMacro.name}" updated successfully`); + navigate("../"); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to update macro: ${error.message}`); + } else { + notifications.error("Failed to update macro"); + } + } finally { + setIsUpdating(false); + } + }; + + const handleDeleteMacro = async () => { + if (!macro) return; + + setIsDeleting(true); + try { + const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id)); + await saveMacros(updatedMacros); + notifications.success(`Macro "${macro.name}" deleted successfully`); + navigate("../macros"); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to delete macro: ${error.message}`); + } else { + notifications.error("Failed to delete macro"); + } + } finally { + setIsDeleting(false); + } + }; + + if (!macro) return null; + + return ( +
+ + navigate("../")} + isSubmitting={isUpdating} + submitText="Save Changes" + showDelete + onDelete={handleDeleteMacro} + isDeleting={isDeleting} + /> +
+ ); +} \ No newline at end of file diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx index 6dc4a93..c8213d1 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -1,90 +1,17 @@ -import { useState, useEffect, useCallback, Fragment } from "react"; -import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; +import { useEffect, Fragment, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { LuPenLine, LuLoader, LuCopy, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; -import { KeySequence, useMacrosStore } from "@/hooks/stores"; +import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { Button } from "@/components/Button"; -import Checkbox from "@/components/Checkbox"; -import { keys, modifiers, keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings"; -import { useJsonRpc } from "@/hooks/useJsonRpc"; +import EmptyCard from "@/components/EmptyCard"; +import { SortableList } from "@/components/SortableList"; +import { MAX_TOTAL_MACROS, COPY_SUFFIX } from "@/constants/macros"; +import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings"; import notifications from "@/notifications"; import { SettingsItem } from "@/routes/devices.$id.settings"; -import { InputFieldWithLabel, FieldError } from "@/components/InputField"; -import Fieldset from "@/components/Fieldset"; -import { SelectMenuBasic } from "@/components/SelectMenuBasic"; -import EmptyCard from "@/components/EmptyCard"; -import { Combobox } from "@/components/Combobox"; -import { CardHeader } from "@/components/CardHeader"; -import Card from "@/components/Card"; -import { SortableList } from "@/components/SortableList"; -const DEFAULT_DELAY = 50; - -interface MacroStep { - keys: string[]; - modifiers: string[]; - delay: number; -} - -interface KeyOption { - value: string; - label: string; -} - -interface KeyOptionData { - value: string | null; - keys?: string[]; - label?: string; -} - -const generateId = () => { - return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; -}; - -// Filter out modifier keys since they're handled in the modifiers section -const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta']; - -const keyOptions = Object.keys(keys) - .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix))) - .map(key => ({ - value: key, - label: keyDisplayMap[key] || key, - })); - -const modifierOptions = Object.keys(modifiers).map(modifier => ({ - value: modifier, - label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"), -})); - -const groupedModifiers = { - Control: modifierOptions.filter(mod => mod.value.startsWith('Control')), - Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')), - Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')), - Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')), -}; - - -const PRESET_DELAYS = [ - { value: "50", label: "50ms" }, - { value: "100", label: "100ms" }, - { value: "200", label: "200ms" }, - { value: "300", label: "300ms" }, - { value: "500", label: "500ms" }, - { value: "750", label: "750ms" }, - { value: "1000", label: "1000ms" }, - { value: "1500", label: "1500ms" }, - { value: "2000", label: "2000ms" }, -]; - -const MAX_STEPS_PER_MACRO = 10; -const MAX_TOTAL_MACROS = 25; -const MAX_KEYS_PER_STEP = 10; - -const ensureArray = (arr: T[] | null | undefined): T[] => { - return Array.isArray(arr) ? arr : []; -}; - -// Helper function to normalize sort orders, ensuring they start at 1 and have no gaps const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { return macros.map((macro, index) => ({ ...macro, @@ -92,805 +19,93 @@ const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { })); }; -interface MacroStepCardProps { - step: MacroStep; - stepIndex: number; - onDelete?: () => void; - onMoveUp?: () => void; - onMoveDown?: () => void; - isDesktop: boolean; - onKeySelect: (option: KeyOptionData) => void; - onKeyQueryChange: (query: string) => void; - keyQuery: string; - getFilteredKeys: () => KeyOption[]; - onModifierChange: (modifiers: string[]) => void; - onDelayChange: (delay: number) => void; - isLastStep: boolean; -} - -function MacroStepCard({ - step, - stepIndex, - onDelete, - onMoveUp, - onMoveDown, - onKeySelect, - onKeyQueryChange, - keyQuery, - getFilteredKeys, - onModifierChange, - onDelayChange, - isLastStep -}: MacroStepCardProps) { - return ( -
-
-
-
-
- - {stepIndex + 1} - -
- -
- {onDelete && ( -
-
- -
-
- -
- {Object.entries(groupedModifiers).map(([group, mods]) => ( -
- - {group} - -
- {mods.map(option => ( - - ))} -
-
- ))} -
-
- -
- - -
- {ensureArray(step.keys).map((key, keyIndex) => ( - - - {keyDisplayMap[key] || key} - -
-
- - onChange={(value: KeyOption) => onKeySelect(value)} - displayValue={() => keyQuery} - onInputChange={onKeyQueryChange} - options={getFilteredKeys} - disabledMessage="Max keys reached" - size="SM" - disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP} - placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."} - emptyMessage="No matching keys found" - /> -
-
- -
-
- -
- -
-

The time to wait after pressing the keys in this step before moving to the next step. This helps ensure reliable key presses when automating keyboard input.

-
-
-
-
- onDelayChange(parseInt(e.target.value, 10))} - options={PRESET_DELAYS} - /> -
-
-
-
- ); -} - -// Helper to update step keys used by both new and edit flows -const updateStepKeys = ( - steps: MacroStep[], - stepIndex: number, - keyOption: { value: string | null; keys?: string[] }, - showTemporaryError: (msg: string) => void -) => { - const newSteps = [...steps]; - - // Check if the step at stepIndex exists - if (!newSteps[stepIndex]) { - console.error(`Step at index ${stepIndex} does not exist`); - return steps; // Return original steps to avoid mutation - } - - if (keyOption.keys) { - newSteps[stepIndex].keys = keyOption.keys; - } else if (keyOption.value) { - // Initialize keys array if it doesn't exist - if (!newSteps[stepIndex].keys) { - newSteps[stepIndex].keys = []; - } - const keysArray = ensureArray(newSteps[stepIndex].keys); - if (keysArray.length >= MAX_KEYS_PER_STEP) { - showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`); - return newSteps; - } - newSteps[stepIndex].keys = [...keysArray, keyOption.value]; - } - return newSteps; -}; - -interface StepError { - keys?: string; - modifiers?: string; - delay?: string; -} - -interface ValidationErrors { - name?: string; - description?: string; - steps?: Record; -} - export default function SettingsMacrosRoute() { - const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore(); - const [editingMacro, setEditingMacro] = useState(null); - const [newMacro, setNewMacro] = useState>({ - name: "", - description: "", - steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }], - }); - const [macroToDelete, setMacroToDelete] = useState(null); + const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore(); + const navigate = useNavigate(); - const [keyQueries, setKeyQueries] = useState>({}); - const [editKeyQueries, setEditKeyQueries] = useState>({}); - - const [errorMessage, setErrorMessage] = useState(null); - const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768); - - const [send] = useJsonRpc(); + const isMaxMacrosReached = useMemo(() => + macros.length >= MAX_TOTAL_MACROS, + [macros.length] + ); - const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS; - const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO; - - const showTemporaryError = useCallback((message: string) => { - setErrorMessage(message); - setTimeout(() => setErrorMessage(null), 3000); - }, []); - - // Helper for both new and edit key select - const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => { - if (isEditing && editingMacro) { - const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError); - setEditingMacro({ ...editingMacro, steps: updatedSteps }); - } else { - const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError); - setNewMacro({ ...newMacro, steps: updatedSteps }); - } - }; - - const handleKeySelect = (stepIndex: number, option: KeyOptionData) => { - handleKeySelectUpdate(stepIndex, option, false); - }; - - const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => { - handleKeySelectUpdate(stepIndex, option, true); - }; - - const handleKeyQueryChange = (stepIndex: number, query: string) => { - setKeyQueries(prev => ({ ...prev, [stepIndex]: query })); - }; - - const handleEditKeyQueryChange = (stepIndex: number, query: string) => { - setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query })); - }; - - const getFilteredKeys = (stepIndex: number, isEditing = false) => { - const query = isEditing - ? (editKeyQueries[stepIndex] || '') - : (keyQueries[stepIndex] || ''); - - const currentStep = isEditing - ? editingMacro?.steps[stepIndex] - : newMacro.steps?.[stepIndex]; - - const selectedKeys = ensureArray(currentStep?.keys); - - const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value)); - - if (query === '') { - return availableKeys; - } else { - return availableKeys.filter(option => option.label.toLowerCase().includes(query.toLowerCase())); - } - }; - useEffect(() => { - setSendFn(send); if (!initialized) { loadMacros(); } - }, [initialized, loadMacros, setSendFn, send]); - - const [errors, setErrors] = useState({}); - - const clearErrors = useCallback(() => { - setErrors({}); - }, []); - - const validateMacro = (macro: Partial): ValidationErrors => { - const errors: ValidationErrors = {}; - - // Name validation - if (!macro.name?.trim()) { - errors.name = "Name is required"; - } else if (macro.name.trim().length > 50) { - errors.name = "Name must be less than 50 characters"; - } - - // Description validation (optional) - if (macro.description && macro.description.trim().length > 200) { - errors.description = "Description must be less than 200 characters"; - } - - // Steps validation - if (!macro.steps?.length) { - errors.steps = { 0: { keys: "At least one step is required" } }; - return errors; - } - - // Check if at least one step has keys or modifiers - const hasKeyOrModifier = macro.steps.some(step => - (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0 - ); - - if (!hasKeyOrModifier) { - errors.steps = { 0: { keys: "At least one step must have keys or modifiers" } }; - return errors; - } - - const stepErrors: Record = {}; - - macro.steps.forEach((step, index) => { - const stepError: StepError = {}; - - // Keys validation (only if keys are present) - if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) { - stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`; - } - - // Delay validation - if (typeof step.delay !== 'number' || step.delay < 0) { - stepError.delay = "Invalid delay value"; - } - - if (Object.keys(stepError).length > 0) { - stepErrors[index] = stepError; - } - }); - - if (Object.keys(stepErrors).length > 0) { - errors.steps = stepErrors; - } - - return errors; - }; - - const resetNewMacro = () => { - setNewMacro({ - name: "", - description: "", - steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }], - }); - setKeyQueries({}); - setErrors({}); - }; - - const [isSaving, setIsSaving] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - const handleAddMacro = useCallback(async () => { - if (isMaxMacrosReached) { - showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); - return; - } - - const validationErrors = validateMacro(newMacro); - if (Object.keys(validationErrors).length > 0) { - setErrors(validationErrors); - return; - } - - setIsSaving(true); - try { - const macro: KeySequence = { - id: generateId(), - name: newMacro.name!.trim(), - description: newMacro.description?.trim() || "", - steps: newMacro.steps || [], - sortOrder: macros.length + 1, - }; - - await saveMacros(normalizeSortOrders([...macros, macro])); - resetNewMacro(); - setShowAddMacro(false); - notifications.success(`Macro "${macro.name}" created successfully`); - } catch (error) { - if (error instanceof Error) { - notifications.error(`Failed to create macro: ${error.message}`); - showTemporaryError(error.message); - } else { - notifications.error("Failed to create macro"); - showTemporaryError("Failed to save macro"); - } - } finally { - setIsSaving(false); - } - }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]); - - const handleEditMacro = (macro: KeySequence) => { - setEditingMacro({ - ...macro, - description: macro.description || "", - steps: macro.steps.map(step => ({ - ...step, - keys: ensureArray(step.keys), - modifiers: ensureArray(step.modifiers), - delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY - })) - }); - clearErrors(); - setEditKeyQueries({}); - }; - - const handleDeleteMacro = async (id: string) => { - const macroToBeDeleted = macros.find(m => m.id === id); - if (!macroToBeDeleted) return; - - setIsDeleting(true); - try { - const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id)); - await saveMacros(updatedMacros); - if (editingMacro?.id === id) { - setEditingMacro(null); - } - setMacroToDelete(null); - notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`); - } catch (error) { - if (error instanceof Error) { - notifications.error(`Failed to delete macro: ${error.message}`); - showTemporaryError(error.message); - } else { - notifications.error("Failed to delete macro"); - showTemporaryError("Failed to delete macro"); - } - } finally { - setIsDeleting(false); - } - }; + }, [initialized, loadMacros]); const handleDuplicateMacro = async (macro: KeySequence) => { if (isMaxMacrosReached) { - showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); + notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); return; } const newMacroCopy: KeySequence = { ...JSON.parse(JSON.stringify(macro)), - id: generateId(), - name: `${macro.name} (copy)`, + id: generateMacroId(), + name: `${macro.name} ${COPY_SUFFIX}`, sortOrder: macros.length + 1, }; - newMacroCopy.steps = newMacroCopy.steps.map(step => ({ - ...step, - keys: ensureArray(step.keys), - modifiers: ensureArray(step.modifiers), - delay: step.delay || DEFAULT_DELAY - })); - try { await saveMacros(normalizeSortOrders([...macros, newMacroCopy])); notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`); - } catch (error) { + } catch (error: unknown) { if (error instanceof Error) { notifications.error(`Failed to duplicate macro: ${error.message}`); - showTemporaryError(error.message); } else { notifications.error("Failed to duplicate macro"); - showTemporaryError("Failed to duplicate macro"); } } }; - const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => { - const newSteps = [...steps]; - const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1; - [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]]; - return newSteps; - }; - - useEffect(() => { - const handleResize = () => { - setIsDesktop(window.innerWidth >= 768); - }; - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - const [showClearConfirm, setShowClearConfirm] = useState(false); - const [showAddMacro, setShowAddMacro] = useState(false); - - const handleUpdateMacro = useCallback(async () => { - if (!editingMacro) return; - - const validationErrors = validateMacro(editingMacro); - if (Object.keys(validationErrors).length > 0) { - setErrors(validationErrors); - return; - } - - setIsUpdating(true); - try { - const newMacros = macros.map(m => - m.id === editingMacro.id ? { - ...editingMacro, - name: editingMacro.name.trim(), - description: editingMacro.description?.trim() || "", - } : m - ); - - await saveMacros(normalizeSortOrders(newMacros)); - setEditingMacro(null); - clearErrors(); - notifications.success(`Macro "${editingMacro.name}" updated successfully`); - } catch (error) { - if (error instanceof Error) { - notifications.error(`Failed to update macro: ${error.message}`); - showTemporaryError(error.message); - } else { - notifications.error("Failed to update macro"); - showTemporaryError("Failed to update macro"); - } - } finally { - setIsUpdating(false); - } - }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && editingMacro) { - setEditingMacro(null); - setErrors({}); - } - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - if (editingMacro) { - handleUpdateMacro(); - } else if (!isMaxMacrosReached) { - handleAddMacro(); - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]); - - const handleModifierChange = (stepIndex: number, modifiers: string[]) => { - if (editingMacro) { - const newSteps = [...editingMacro.steps]; - newSteps[stepIndex].modifiers = modifiers; - setEditingMacro({ ...editingMacro, steps: newSteps }); - } else { - const newSteps = [...(newMacro.steps || [])]; - newSteps[stepIndex].modifiers = modifiers; - setNewMacro({ ...newMacro, steps: newSteps }); - } - }; - - const handleDelayChange = (stepIndex: number, delay: number) => { - if (editingMacro) { - const newSteps = [...editingMacro.steps]; - newSteps[stepIndex].delay = delay; - setEditingMacro({ ...editingMacro, steps: newSteps }); - } else { - const newSteps = [...(newMacro.steps || [])]; - newSteps[stepIndex].delay = delay; - setNewMacro({ ...newMacro, steps: newSteps }); - } - }; - - const ErrorMessage = ({ error }: { error?: string }) => { - if (!error) return null; - return ( - - ); - }; return (
- - {macros.length > 0 && ( -
- -
- {!showAddMacro && ( -
-
-
- )} - - {errorMessage && ()} - - {loading && ( -
- -
- )} -
- {showAddMacro && ( - - +
+ +
+
-
-
- -
- {showClearConfirm ? ( -
- - Cancel changes? - -
- ) : ( -
-
- )} -
-
-
- )} - {macros.length === 0 && !showAddMacro && ( +
+ {loading ? ( + + } + /> + ) : macros.length === 0 ? ( setShowAddMacro(true)} + onClick={() => navigate("add")} disabled={isMaxMacrosReached} /> } /> - )} - {macros.length > 0 && ( + ) : ( keyFn={(macro) => macro.id} items={macros} @@ -900,248 +115,88 @@ export default function SettingsMacrosRoute() { try { await saveMacros(updatedMacros); notifications.success("Macro order updated successfully"); - } catch (error) { + } catch (error: unknown) { if (error instanceof Error) { notifications.error(`Failed to reorder macros: ${error.message}`); - showTemporaryError(error.message); } else { notifications.error("Failed to reorder macros"); - showTemporaryError("Failed to save reordered macros"); } } }} - disabled={!!editingMacro} variant="list" size="XS" handlePosition="left" > {(macro) => ( - editingMacro && editingMacro.id === macro.id ? ( - - -
- { - setEditingMacro({ ...editingMacro, name: e.target.value }); - if (errors.name) { - const newErrors = { ...errors }; - delete newErrors.name; - setErrors(newErrors); - } - }} - /> -
- -
-
- - - {editingMacro.steps.length}/{MAX_STEPS_PER_MACRO} steps - -
- {errors.steps && errors.steps[0]?.keys && ( -
- -
- )} -
- You can add up to {MAX_STEPS_PER_MACRO} steps per macro -
-
-
- {editingMacro.steps.map((step, stepIndex) => ( - 1 ? () => { - const newSteps = [...editingMacro.steps]; - newSteps.splice(stepIndex, 1); - setEditingMacro({ ...editingMacro, steps: newSteps }); - } : undefined} - onMoveUp={() => { - const newSteps = handleStepMove(stepIndex, 'up', editingMacro.steps); - setEditingMacro({ ...editingMacro, steps: newSteps }); - }} - onMoveDown={() => { - const newSteps = handleStepMove(stepIndex, 'down', editingMacro.steps); - setEditingMacro({ ...editingMacro, steps: newSteps }); - }} - isDesktop={isDesktop} - onKeySelect={(option) => handleEditKeySelect(stepIndex, option)} - onKeyQueryChange={(query) => handleEditKeyQueryChange(stepIndex, query)} - keyQuery={editKeyQueries[stepIndex] || ''} - getFilteredKeys={() => getFilteredKeys(stepIndex, true)} - onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)} - onDelayChange={(delay) => handleDelayChange(stepIndex, delay)} - isLastStep={stepIndex === editingMacro.steps.length - 1} - /> - ))} -
-
- -
-
+
+
+

+ {macro.name} +

+

+ + {macro.steps.map((step, stepIndex) => { + const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; -

-
-
-
-
- - ) : ( -
-
-

- {macro.name} -

-

- - {macro.steps.map((step, stepIndex) => { - const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; - - return ( - - - - {(ensureArray(step.modifiers).length > 0 || ensureArray(step.keys).length > 0) ? ( - <> - {ensureArray(step.modifiers).map((modifier, idx) => ( - - - {modifierDisplayMap[modifier] || modifier} - - {idx < ensureArray(step.modifiers).length - 1 && ( - + - )} - - ))} - - {ensureArray(step.modifiers).length > 0 && ensureArray(step.keys).length > 0 && ( - + - )} - - {ensureArray(step.keys).map((key, idx) => ( - - - {keyDisplayMap[key] || key} - - {idx < ensureArray(step.keys).length - 1 && ( - + - )} - - ))} - - ) : ( - Delay only - )} - ({step.delay}ms) - + return ( + + + + {(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? ( + <> + {Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => ( + + + {modifierDisplayMap[modifier] || modifier} + + {idx < step.modifiers.length - 1 && ( + + + )} + + ))} + + {Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && ( + + + )} + + {Array.isArray(step.keys) && step.keys.map((key, idx) => ( + + + {keyDisplayMap[key] || key} + + {idx < step.keys.length - 1 && ( + + + )} + + ))} + + ) : ( + Delay only + )} + ({step.delay}ms) - ); - })} - -

-
- -
- {macroToDelete === macro.id ? ( -
- - Delete macro? - -
-
-
- ) : ( - <> -
+ + ); + })} + +

- ) + +
+
+
)} )}