diff --git a/config.go b/config.go index 642f113..1c1b98d 100644 --- a/config.go +++ b/config.go @@ -14,6 +14,64 @@ type WakeOnLanDevice struct { MacAddress string `json:"macAddress"` } +// Constants for keyboard macro limits +const ( + MaxMacrosPerDevice = 25 + MaxStepsPerMacro = 10 + MaxKeysPerStep = 10 + MinStepDelay = 50 + MaxStepDelay = 2000 +) + +type KeyboardMacroStep struct { + Keys []string `json:"keys"` + Modifiers []string `json:"modifiers"` + Delay int `json:"delay"` +} + +func (s *KeyboardMacroStep) Validate() error { + if len(s.Keys) > MaxKeysPerStep { + return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep) + } + + if s.Delay < MinStepDelay { + s.Delay = MinStepDelay + } else if s.Delay > MaxStepDelay { + s.Delay = MaxStepDelay + } + + return nil +} + +type KeyboardMacro struct { + ID string `json:"id"` + Name string `json:"name"` + Steps []KeyboardMacroStep `json:"steps"` + SortOrder int `json:"sortOrder,omitempty"` +} + +func (m *KeyboardMacro) Validate() error { + if m.Name == "" { + return fmt.Errorf("macro name cannot be empty") + } + + if len(m.Steps) == 0 { + return fmt.Errorf("macro must have at least one step") + } + + if len(m.Steps) > MaxStepsPerMacro { + return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro) + } + + for i := range m.Steps { + if err := m.Steps[i].Validate(); err != nil { + return fmt.Errorf("invalid step %d: %w", i+1, err) + } + } + + return nil +} + type Config struct { CloudURL string `json:"cloud_url"` CloudAppURL string `json:"cloud_app_url"` @@ -26,6 +84,7 @@ type Config struct { LocalAuthToken string `json:"local_auth_token"` LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` EdidString string `json:"hdmi_edid_string"` ActiveExtension string `json:"active_extension"` DisplayMaxBrightness int `json:"display_max_brightness"` @@ -43,6 +102,7 @@ var defaultConfig = &Config{ CloudAppURL: "https://app.jetkvm.com", AutoUpdateEnabled: true, // Set a default value ActiveExtension: "", + KeyboardMacros: []KeyboardMacro{}, DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes diff --git a/jsonrpc.go b/jsonrpc.go index 9ce1f1b..e5deb49 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -797,6 +797,99 @@ func rpcSetScrollSensitivity(sensitivity string) error { return nil } +func getKeyboardMacros() (interface{}, error) { + macros := make([]KeyboardMacro, len(config.KeyboardMacros)) + copy(macros, config.KeyboardMacros) + + return macros, nil +} + +type KeyboardMacrosParams struct { + Macros []interface{} `json:"macros"` +} + +func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { + if params.Macros == nil { + return nil, fmt.Errorf("missing or invalid macros parameter") + } + + newMacros := make([]KeyboardMacro, 0, len(params.Macros)) + + for i, item := range params.Macros { + macroMap, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid macro at index %d", i) + } + + id, _ := macroMap["id"].(string) + if id == "" { + id = fmt.Sprintf("macro-%d", time.Now().UnixNano()) + } + + name, _ := macroMap["name"].(string) + + sortOrder := i + 1 + if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok { + sortOrder = int(sortOrderFloat) + } + + steps := []KeyboardMacroStep{} + if stepsArray, ok := macroMap["steps"].([]interface{}); ok { + for _, stepItem := range stepsArray { + stepMap, ok := stepItem.(map[string]interface{}) + if !ok { + continue + } + + step := KeyboardMacroStep{} + + if keysArray, ok := stepMap["keys"].([]interface{}); ok { + for _, k := range keysArray { + if keyStr, ok := k.(string); ok { + step.Keys = append(step.Keys, keyStr) + } + } + } + + if modsArray, ok := stepMap["modifiers"].([]interface{}); ok { + for _, m := range modsArray { + if modStr, ok := m.(string); ok { + step.Modifiers = append(step.Modifiers, modStr) + } + } + } + + if delay, ok := stepMap["delay"].(float64); ok { + step.Delay = int(delay) + } + + steps = append(steps, step) + } + } + + macro := KeyboardMacro{ + ID: id, + Name: name, + Steps: steps, + SortOrder: sortOrder, + } + + if err := macro.Validate(); err != nil { + return nil, fmt.Errorf("invalid macro at index %d: %w", i, err) + } + + newMacros = append(newMacros, macro) + } + + config.KeyboardMacros = newMacros + + if err := SaveConfig(); err != nil { + return nil, err + } + + return nil, nil +} + var rpcHandlers = map[string]RPCHandler{ "ping": {Func: rpcPing}, "getDeviceID": {Func: rpcGetDeviceID}, @@ -862,4 +955,6 @@ var rpcHandlers = map[string]RPCHandler{ "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, "getScrollSensitivity": {Func: rpcGetScrollSensitivity}, "setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}}, + "getKeyboardMacros": {Func: getKeyboardMacros}, + "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, } diff --git a/ui/src/components/Checkbox.tsx b/ui/src/components/Checkbox.tsx index 261a425..e3237b1 100644 --- a/ui/src/components/Checkbox.tsx +++ b/ui/src/components/Checkbox.tsx @@ -37,11 +37,11 @@ type CheckBoxProps = { } & Omit; const Checkbox = forwardRef(function Checkbox( - { size = "MD", ...props }, + { size = "MD", className, ...props }, ref, ) { const classes = checkboxVariants({ size }); - return ; + return ; }); Checkbox.displayName = "Checkbox"; diff --git a/ui/src/components/Combobox.tsx b/ui/src/components/Combobox.tsx new file mode 100644 index 0000000..8055043 --- /dev/null +++ b/ui/src/components/Combobox.tsx @@ -0,0 +1,119 @@ +import { useRef } from "react"; +import clsx from "clsx"; +import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; +import { cva } from "@/cva.config"; +import Card from "./Card"; + +export interface ComboboxOption { + value: string; + label: string; +} + +const sizes = { + XS: "h-[24.5px] pl-3 pr-8 text-xs", + 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 }, +}); + +type BaseProps = React.ComponentProps; + +interface ComboboxProps extends Omit { + displayValue: (option: ComboboxOption) => string; + onInputChange: (option: string) => void; + options: () => ComboboxOption[]; + placeholder?: string; + emptyMessage?: string; + size?: keyof typeof sizes; + disabledMessage?: string; +} + +export function Combobox({ + onInputChange, + displayValue, + options, + disabled = false, + placeholder = "Search...", + emptyMessage = "No results found", + size = "MD", + onChange, + disabledMessage = "Input disabled", + ...otherProps +}: ComboboxProps) { + const inputRef = useRef(null); + const classes = comboboxVariants({ size }); + + return ( + + {() => ( + <> + + onInputChange(event.target.value)} + disabled={disabled} + /> + + + {options().length > 0 && ( + + {options().map((option) => ( + + {option.label} + + ))} + + )} + + {options().length === 0 && inputRef.current?.value && ( +
+
+ {emptyMessage} +
+
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..57391e2 --- /dev/null +++ b/ui/src/components/ConfirmDialog.tsx @@ -0,0 +1,106 @@ +import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; +import { cx } from "@/cva.config"; +import { Button } from "@/components/Button"; +import Modal from "@/components/Modal"; + +type Variant = "danger" | "success" | "warning" | "info"; + +interface ConfirmDialogProps { + open: boolean; + onClose: () => void; + title: string; + description: string; + variant?: Variant; + confirmText?: string; + cancelText?: string | null; + onConfirm: () => void; + isConfirming?: boolean; +} + +const variantConfig = { + danger: { + icon: ExclamationTriangleIcon, + iconClass: "text-red-600", + iconBgClass: "bg-red-100", + buttonTheme: "danger", + }, + success: { + icon: CheckCircleIcon, + iconClass: "text-green-600", + iconBgClass: "bg-green-100", + buttonTheme: "primary", + }, + warning: { + icon: ExclamationTriangleIcon, + iconClass: "text-yellow-600", + iconBgClass: "bg-yellow-100", + buttonTheme: "lightDanger", + }, + info: { + icon: InformationCircleIcon, + iconClass: "text-blue-600", + iconBgClass: "bg-blue-100", + buttonTheme: "primary", + }, +} as Record; + +export function ConfirmDialog({ + open, + onClose, + title, + description, + variant = "info", + confirmText = "Confirm", + cancelText = "Cancel", + onConfirm, + isConfirming = false, +}: ConfirmDialogProps) { + const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant]; + + return ( + +
+
+
+
+
+
+
+

+ {title} +

+
+ {description} +
+
+
+ +
+ {cancelText && ( +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/FieldLabel.tsx b/ui/src/components/FieldLabel.tsx index 42e6ede..f9065a1 100644 --- a/ui/src/components/FieldLabel.tsx +++ b/ui/src/components/FieldLabel.tsx @@ -49,4 +49,4 @@ export default function FieldLabel({ } else { return <>; } -} +} \ No newline at end of file diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx new file mode 100644 index 0000000..066c21f --- /dev/null +++ b/ui/src/components/MacroBar.tsx @@ -0,0 +1,48 @@ +import { useEffect } from "react"; +import { LuCommand } from "react-icons/lu"; + +import { Button } from "@components/Button"; +import Container from "@components/Container"; +import { useMacrosStore } from "@/hooks/stores"; +import useKeyboard from "@/hooks/useKeyboard"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; + +export default function MacroBar() { + const { macros, initialized, loadMacros, setSendFn } = useMacrosStore(); + const { executeMacro } = useKeyboard(); + const [send] = useJsonRpc(); + + useEffect(() => { + setSendFn(send); + + if (!initialized) { + loadMacros(); + } + }, [initialized, loadMacros, setSendFn, send]); + + if (macros.length === 0) { + return null; + } + + return ( + +
+
+ +
+
+ {macros.map(macro => ( +
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx new file mode 100644 index 0000000..135817c --- /dev/null +++ b/ui/src/components/MacroForm.tsx @@ -0,0 +1,271 @@ +import { useState } from "react"; + +import { LuPlus } 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 { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros"; +import FieldLabel from "@/components/FieldLabel"; + +interface ValidationErrors { + name?: string; + steps?: Record; +} + +interface MacroFormProps { + initialData: Partial; + onSubmit: (macro: Partial) => Promise; + onCancel: () => void; + isSubmitting?: boolean; + submitText?: string; +} + +export function MacroForm({ + initialData, + onSubmit, + onCancel, + isSubmitting = false, + submitText = "Save Macro", +}: MacroFormProps) { + const [macro, setMacro] = useState>(initialData); + const [keyQueries, setKeyQueries] = useState>({}); + const [errors, setErrors] = useState({}); + const [errorMessage, setErrorMessage] = useState(null); + + 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); + } + }} + /> +
+ +
+
+
+ +
+ + {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 && ( +
+ +
+ )} + +
+
+
+
+ + ); +} \ 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..8642c28 --- /dev/null +++ b/ui/src/components/MacroStepCard.tsx @@ -0,0 +1,235 @@ +import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } 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, DEFAULT_DELAY } from "@/constants/macros"; +import FieldLabel from "@/components/FieldLabel"; + +// 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 basePresetDelays = [ + { 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 PRESET_DELAYS = basePresetDelays.map(delay => { + if (parseInt(delay.value, 10) === DEFAULT_DELAY) { + return { ...delay, label: "Default" }; + } + return delay; +}); + +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 => ( +
+
+ ))} +
+
+ +
+
+ +
+ {ensureArray(step.keys) && step.keys.length > 0 && ( +
+ {step.keys.map((key, keyIndex) => ( + + + {keyDisplayMap[key] || key} + +
+ )} +
+ { + onKeySelect(value); + onKeyQueryChange(''); + }} + 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" + /> +
+
+ +
+
+ +
+
+ onDelayChange(parseInt(e.target.value, 10))} + options={PRESET_DELAYS} + /> +
+
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index ec5906c..09a94a6 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -11,7 +11,7 @@ import "react-simple-keyboard/build/css/index.css"; import { useHidStore, useUiStore } from "@/hooks/stores"; import { cx } from "@/cva.config"; -import { keys, modifiers } from "@/keyboardMappings"; +import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings"; import useKeyboard from "@/hooks/useKeyboard"; import DetachIconRaw from "@/assets/detach-icon.svg"; import AttachIconRaw from "@/assets/attach-icon.svg"; @@ -260,136 +260,7 @@ function KeyboardWrapper() { buttons: "CtrlAltDelete AltMetaEscape", }, ]} - display={{ - CtrlAltDelete: "Ctrl + Alt + Delete", - AltMetaEscape: "Alt + Meta + Escape", - Escape: "esc", - Tab: "tab", - Backspace: "backspace", - "(Backspace)": "backspace", - Enter: "enter", - CapsLock: "caps lock", - ShiftLeft: "shift", - ShiftRight: "shift", - ControlLeft: "ctrl", - AltLeft: "alt", - AltRight: "alt", - MetaLeft: "meta", - MetaRight: "meta", - KeyQ: "q", - KeyW: "w", - KeyE: "e", - KeyR: "r", - KeyT: "t", - KeyY: "y", - KeyU: "u", - KeyI: "i", - KeyO: "o", - KeyP: "p", - KeyA: "a", - KeyS: "s", - KeyD: "d", - KeyF: "f", - KeyG: "g", - KeyH: "h", - KeyJ: "j", - KeyK: "k", - KeyL: "l", - KeyZ: "z", - KeyX: "x", - KeyC: "c", - KeyV: "v", - KeyB: "b", - KeyN: "n", - KeyM: "m", - - "(KeyQ)": "Q", - "(KeyW)": "W", - "(KeyE)": "E", - "(KeyR)": "R", - "(KeyT)": "T", - "(KeyY)": "Y", - "(KeyU)": "U", - "(KeyI)": "I", - "(KeyO)": "O", - "(KeyP)": "P", - "(KeyA)": "A", - "(KeyS)": "S", - "(KeyD)": "D", - "(KeyF)": "F", - "(KeyG)": "G", - "(KeyH)": "H", - "(KeyJ)": "J", - "(KeyK)": "K", - "(KeyL)": "L", - "(KeyZ)": "Z", - "(KeyX)": "X", - "(KeyC)": "C", - "(KeyV)": "V", - "(KeyB)": "B", - "(KeyN)": "N", - "(KeyM)": "M", - Digit1: "1", - Digit2: "2", - Digit3: "3", - Digit4: "4", - Digit5: "5", - Digit6: "6", - Digit7: "7", - Digit8: "8", - Digit9: "9", - Digit0: "0", - - "(Digit1)": "!", - "(Digit2)": "@", - "(Digit3)": "#", - "(Digit4)": "$", - "(Digit5)": "%", - "(Digit6)": "^", - "(Digit7)": "&", - "(Digit8)": "*", - "(Digit9)": "(", - "(Digit0)": ")", - Minus: "-", - "(Minus)": "_", - - Equal: "=", - "(Equal)": "+", - BracketLeft: "[", - BracketRight: "]", - "(BracketLeft)": "{", - "(BracketRight)": "}", - Backslash: "\\", - "(Backslash)": "|", - - Semicolon: ";", - "(Semicolon)": ":", - Quote: "'", - "(Quote)": '"', - Comma: ",", - "(Comma)": "<", - Period: ".", - "(Period)": ">", - Slash: "/", - "(Slash)": "?", - Space: " ", - Backquote: "`", - "(Backquote)": "~", - IntlBackslash: "\\", - - F1: "F1", - F2: "F2", - F3: "F3", - F4: "F4", - F5: "F5", - F6: "F6", - F7: "F7", - F8: "F8", - F9: "F9", - F10: "F10", - F11: "F11", - F12: "F12", - }} + display={keyDisplayMap} layout={{ default: [ "CtrlAltDelete AltMetaEscape", diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 970867a..be69899 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -13,6 +13,7 @@ import { useResizeObserver } from "@/hooks/useResizeObserver"; import { cx } from "@/cva.config"; import VirtualKeyboard from "@components/VirtualKeyboard"; import Actionbar from "@components/ActionBar"; +import MacroBar from "@/components/MacroBar"; import InfoBar from "@components/InfoBar"; import useKeyboard from "@/hooks/useKeyboard"; import { useJsonRpc } from "@/hooks/useJsonRpc"; @@ -553,16 +554,19 @@ export default function WebRTCVideo() { return (
-
-
- - videoElm.current?.requestFullscreen({ - navigationUI: "show", - }) - } - /> -
+
+
+
+ + videoElm.current?.requestFullscreen({ + navigationUI: "show", + }) + } + /> + +
+
( @@ -649,3 +662,146 @@ export const useDeviceStore = create(set => ({ setAppVersion: version => set({ appVersion: version }), setSystemVersion: version => set({ systemVersion: version }), })); + +export interface KeySequenceStep { + keys: string[]; + modifiers: string[]; + delay: number; +} + +export interface KeySequence { + id: string; + name: string; + steps: KeySequenceStep[]; + sortOrder?: number; +} + +export interface MacrosState { + macros: KeySequence[]; + loading: boolean; + initialized: boolean; + loadMacros: () => Promise; + saveMacros: (macros: KeySequence[]) => Promise; + sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void) | null; + setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void; +} + +export const generateMacroId = () => { + return Math.random().toString(36).substring(2, 9); +}; + +export const useMacrosStore = create((set, get) => ({ + macros: [], + loading: false, + initialized: false, + sendFn: null, + + setSendFn: (sendFn) => { + set({ sendFn }); + }, + + loadMacros: async () => { + if (get().initialized) return; + + const { sendFn } = get(); + if (!sendFn) { + console.warn("JSON-RPC send function not available."); + return; + } + + set({ loading: true }); + + try { + await new Promise((resolve, reject) => { + sendFn("getKeyboardMacros", {}, (response) => { + if (response.error) { + console.error("Error loading macros:", response.error); + reject(new Error(response.error.message)); + return; + } + + const macros = (response.result as KeySequence[]) || []; + + const sortedMacros = [...macros].sort((a, b) => { + if (a.sortOrder !== undefined && b.sortOrder !== undefined) { + return a.sortOrder - b.sortOrder; + } + if (a.sortOrder !== undefined) return -1; + if (b.sortOrder !== undefined) return 1; + return 0; + }); + + set({ + macros: sortedMacros, + initialized: true + }); + + resolve(); + }); + }); + } catch (error) { + console.error("Failed to load macros:", error); + } finally { + set({ loading: false }); + } + }, + + saveMacros: async (macros: KeySequence[]) => { + const { sendFn } = get(); + if (!sendFn) { + console.warn("JSON-RPC send function not available."); + throw new Error("JSON-RPC send function not available"); + } + + if (macros.length > MAX_TOTAL_MACROS) { + console.error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`); + throw new Error(`Cannot save: exceeded maximum of ${MAX_TOTAL_MACROS} macros`); + } + + for (const macro of macros) { + if (macro.steps.length > MAX_STEPS_PER_MACRO) { + console.error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`); + throw new Error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`); + } + + for (let i = 0; i < macro.steps.length; i++) { + const step = macro.steps[i]; + if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) { + console.error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); + throw new Error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); + } + } + } + + set({ loading: true }); + + try { + const macrosWithSortOrder = macros.map((macro, index) => ({ + ...macro, + sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index + })); + + const response = await new Promise((resolve) => { + sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => { + resolve(response); + }); + }); + + if (response.error) { + console.error("Error saving macros:", response.error); + const errorMessage = typeof response.error.data === 'string' + ? response.error.data + : response.error.message || "Failed to save macros"; + throw new Error(errorMessage); + } + + // Only update the store if the request was successful + set({ macros: macrosWithSortOrder }); + } catch (error) { + console.error("Failed to save macros:", error); + throw error; + } finally { + set({ loading: false }); + } + } +})); \ No newline at end of file diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index 137fc8b..0ce1eef 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { useHidStore, useRTCStore } from "@/hooks/stores"; import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { keys, modifiers } from "@/keyboardMappings"; export default function useKeyboard() { const [send] = useJsonRpc(); @@ -28,5 +29,28 @@ export default function useKeyboard() { sendKeyboardEvent([], []); }, [sendKeyboardEvent]); - return { sendKeyboardEvent, resetKeyboardState }; + const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { + for (const [index, step] of steps.entries()) { + const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || []; + const modifierValues = step.modifiers?.map(mod => modifiers[mod]).filter(Boolean) || []; + + // If the step has keys and/or modifiers, press them and hold for the delay + if (keyValues.length > 0 || modifierValues.length > 0) { + sendKeyboardEvent(keyValues, modifierValues); + await new Promise(resolve => setTimeout(resolve, step.delay || 50)); + + resetKeyboardState(); + } else { + // This is a delay-only step, just wait for the delay amount + await new Promise(resolve => setTimeout(resolve, step.delay || 50)); + } + + // Add a small pause between steps if not the last step + if (index < steps.length - 1) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + }; + + return { sendKeyboardEvent, resetKeyboardState, executeMacro }; } diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts index ffc781c..347939a 100644 --- a/ui/src/keyboardMappings.ts +++ b/ui/src/keyboardMappings.ts @@ -212,3 +212,80 @@ export const modifiers = { MetaLeft: 0x08, MetaRight: 0x80, } as Record; + +export const modifierDisplayMap: Record = { + ControlLeft: "Left Ctrl", + ControlRight: "Right Ctrl", + ShiftLeft: "Left Shift", + ShiftRight: "Right Shift", + AltLeft: "Left Alt", + AltRight: "Right Alt", + MetaLeft: "Left Meta", + MetaRight: "Right Meta", +} as Record; + +export const keyDisplayMap: Record = { + CtrlAltDelete: "Ctrl + Alt + Delete", + AltMetaEscape: "Alt + Meta + Escape", + Escape: "esc", + Tab: "tab", + Backspace: "backspace", + Enter: "enter", + CapsLock: "caps lock", + ShiftLeft: "shift", + ShiftRight: "shift", + ControlLeft: "ctrl", + AltLeft: "alt", + AltRight: "alt", + MetaLeft: "meta", + MetaRight: "meta", + Space: " ", + Home: "home", + PageUp: "pageup", + Delete: "delete", + End: "end", + PageDown: "pagedown", + ArrowLeft: "←", + ArrowRight: "→", + ArrowUp: "↑", + ArrowDown: "↓", + + // Letters + KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e", + KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j", + KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o", + KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t", + KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y", + KeyZ: "z", + + // Numbers + Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5", + Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0", + + // Symbols + Minus: "-", + Equal: "=", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Slash: "/", + Backquote: "`", + IntlBackslash: "\\", + + // Function keys + F1: "F1", F2: "F2", F3: "F3", F4: "F4", + F5: "F5", F6: "F6", F7: "F7", F8: "F8", + F9: "F9", F10: "F10", F11: "F11", F12: "F12", + + // Numpad + Numpad0: "Num 0", Numpad1: "Num 1", Numpad2: "Num 2", + Numpad3: "Num 3", Numpad4: "Num 4", Numpad5: "Num 5", + Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8", + Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -", + NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .", + NumpadEnter: "Num Enter" +}; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 066ee57..e09a2a9 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -43,6 +43,9 @@ import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance"; 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; @@ -175,6 +178,23 @@ if (isOnDevice) { path: "appearance", element: , }, + { + path: "macros", + children: [ + { + index: true, + element: , + }, + { + path: "add", + element: , + }, + { + path: ":macroId/edit", + element: , + }, + ], + }, ], }, ], @@ -283,6 +303,23 @@ if (isOnDevice) { path: "appearance", element: , }, + { + path: "macros", + 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..336fe85 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.macros.edit.tsx @@ -0,0 +1,134 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { useState, useEffect } from "react"; +import { LuTrash2 } from "react-icons/lu"; + +import { KeySequence, useMacrosStore } from "@/hooks/stores"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { MacroForm } from "@/components/MacroForm"; +import notifications from "@/notifications"; +import { Button } from "@/components/Button"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; + +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); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + 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" + /> + + setShowDeleteConfirm(false)} + title="Delete Macro" + description="Are you sure you want to delete this macro? This action cannot be undone." + variant="danger" + confirmText={isDeleting ? "Deleting" : "Delete"} + onConfirm={() => { + handleDeleteMacro(); + setShowDeleteConfirm(false); + }} + isConfirming={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 new file mode 100644 index 0000000..f809f57 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -0,0 +1,306 @@ +import { useEffect, Fragment, useMemo, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu"; + +import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { Button } from "@/components/Button"; +import EmptyCard from "@/components/EmptyCard"; +import Card from "@/components/Card"; +import { MAX_TOTAL_MACROS, COPY_SUFFIX, DEFAULT_DELAY } from "@/constants/macros"; +import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings"; +import notifications from "@/notifications"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import LoadingSpinner from "@/components/LoadingSpinner"; + +const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { + return macros.map((macro, index) => ({ + ...macro, + sortOrder: index + 1, + })); +}; + +export default function SettingsMacrosRoute() { + const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore(); + const navigate = useNavigate(); + const [actionLoadingId, setActionLoadingId] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [macroToDelete, setMacroToDelete] = useState(null); + + const isMaxMacrosReached = useMemo(() => + macros.length >= MAX_TOTAL_MACROS, + [macros.length] + ); + + useEffect(() => { + if (!initialized) { + loadMacros(); + } + }, [initialized, loadMacros]); + + const handleDuplicateMacro = useCallback(async (macro: KeySequence) => { + if (!macro?.id || !macro?.name) { + notifications.error("Invalid macro data"); + return; + } + + if (isMaxMacrosReached) { + notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); + return; + } + + setActionLoadingId(macro.id); + + const newMacroCopy: KeySequence = { + ...JSON.parse(JSON.stringify(macro)), + id: generateMacroId(), + name: `${macro.name} ${COPY_SUFFIX}`, + sortOrder: macros.length + 1, + }; + + try { + await saveMacros(normalizeSortOrders([...macros, newMacroCopy])); + notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to duplicate macro: ${error.message}`); + } else { + notifications.error("Failed to duplicate macro"); + } + } finally { + setActionLoadingId(null); + } + }, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]); + + const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => { + if (!Array.isArray(macros) || macros.length === 0) { + notifications.error("No macros available"); + return; + } + + const newIndex = direction === 'up' ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= macros.length) return; + + setActionLoadingId(macroId); + + try { + const newMacros = [...macros]; + [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]]; + const updatedMacros = normalizeSortOrders(newMacros); + + await saveMacros(updatedMacros); + notifications.success("Macro order updated successfully"); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to reorder macros: ${error.message}`); + } else { + notifications.error("Failed to reorder macros"); + } + } finally { + setActionLoadingId(null); + } + }, [macros, saveMacros, setActionLoadingId]); + + const handleDeleteMacro = useCallback(async () => { + if (!macroToDelete?.id) return; + + setActionLoadingId(macroToDelete.id); + try { + const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id)); + await saveMacros(updatedMacros); + notifications.success(`Macro "${macroToDelete.name}" deleted successfully`); + setShowDeleteConfirm(false); + setMacroToDelete(null); + } catch (error: unknown) { + if (error instanceof Error) { + notifications.error(`Failed to delete macro: ${error.message}`); + } else { + notifications.error("Failed to delete macro"); + } + } finally { + setActionLoadingId(null); + } + }, [macroToDelete, macros, saveMacros]); + + const MacroList = useMemo(() => ( +
+ {macros.map((macro, index) => ( + +
+
+
+ +
+

+ {macro.name} +

+

+ + {macro.steps.map((step, stepIndex) => { + const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; + + 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 !== DEFAULT_DELAY && ( + ({step.delay}ms) + )} + + + ); + })} + +

+
+ +
+
+
+
+ ))} + + { + setShowDeleteConfirm(false); + setMacroToDelete(null); + }} + title="Delete Macro" + description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`} + variant="danger" + confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"} + onConfirm={handleDeleteMacro} + isConfirming={actionLoadingId === macroToDelete?.id} + /> +
+ ), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]); + + return ( +
+
+ + { macros.length > 0 && ( +
+
+ )} +
+ +
+ {loading && macros.length === 0 ? ( + + +
+ } + /> + ) : macros.length === 0 ? ( + navigate("add")} + disabled={isMaxMacrosReached} + aria-label="Add new macro" + /> + } + /> + ) : MacroList} +
+
+ ); +} diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 4742445..db7d6b0 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -8,6 +8,7 @@ import { LuWrench, LuArrowLeft, LuPalette, + LuCommand, } from "react-icons/lu"; import React, { useEffect, useRef, useState } from "react"; @@ -195,6 +196,17 @@ export default function SettingsRoute() {
+
+ (isActive ? "active" : "")} + > +
+ +

Keyboard Macros

+
+
+