import { useState, useEffect, useCallback, Fragment } from "react"; import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; import { KeySequence, useMacrosStore } 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 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, sortOrder: index + 1, })); }; 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 [keyQueries, setKeyQueries] = useState>({}); const [editKeyQueries, setEditKeyQueries] = useState>({}); const [errorMessage, setErrorMessage] = useState(null); const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768); const [send] = useJsonRpc(); 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); } }; const handleDuplicateMacro = async (macro: KeySequence) => { if (isMaxMacrosReached) { showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); return; } const newMacroCopy: KeySequence = { ...JSON.parse(JSON.stringify(macro)), id: generateId(), name: `${macro.name} (copy)`, 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) { 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 && (
{ setNewMacro(prev => ({ ...prev, name: e.target.value })); if (errors.name) { const newErrors = { ...errors }; delete newErrors.name; setErrors(newErrors); } }} />
{newMacro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
{errors.steps && errors.steps[0]?.keys && (
)}
You can add up to {MAX_STEPS_PER_MACRO} steps per macro
{(newMacro.steps || []).map((step, stepIndex) => ( 1 ? () => { const newSteps = [...(newMacro.steps || [])]; newSteps.splice(stepIndex, 1); setNewMacro(prev => ({ ...prev, steps: newSteps })); } : undefined} onMoveUp={() => { const newSteps = handleStepMove(stepIndex, 'up', newMacro.steps || []); setNewMacro(prev => ({ ...prev, steps: newSteps })); }} onMoveDown={() => { const newSteps = handleStepMove(stepIndex, 'down', newMacro.steps || []); setNewMacro(prev => ({ ...prev, steps: newSteps })); }} isDesktop={isDesktop} onKeySelect={(option) => handleKeySelect(stepIndex, option)} onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)} keyQuery={keyQueries[stepIndex] || ''} getFilteredKeys={() => getFilteredKeys(stepIndex)} onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)} onDelayChange={(delay) => handleDelayChange(stepIndex, delay)} isLastStep={stepIndex === (newMacro.steps?.length || 0) - 1} /> ))}
{showClearConfirm ? (
Cancel changes?
) : (
)}
)} {macros.length === 0 && !showAddMacro && ( setShowAddMacro(true)} disabled={isMaxMacrosReached} /> } /> )} {macros.length > 0 && ( keyFn={(macro) => macro.id} items={macros} itemClassName="rounded-md border border-slate-200 p-2 dark:border-slate-700 bg-white dark:bg-slate-800" onSort={async (newMacros) => { const updatedMacros = normalizeSortOrders(newMacros); try { await saveMacros(updatedMacros); notifications.success("Macro order updated successfully"); } catch (error) { 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; 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) ); })}

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