From 2ba8e1981b5e80bfe1c8cf51f7a63fd5f8578ddf Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:05:11 +1000 Subject: [PATCH] add ui keyboard macros settings and macro bar --- ui/src/components/MacroBar.tsx | 47 + ui/src/components/WebRTCVideo.tsx | 4 +- ui/src/hooks/stores.ts | 153 ++ ui/src/hooks/useKeyboard.ts | 26 +- ui/src/index.css | 129 ++ ui/src/main.tsx | 9 + ui/src/routes/devices.$id.settings.macros.tsx | 1401 +++++++++++++++++ ui/src/routes/devices.$id.settings.tsx | 12 + 8 files changed, 1779 insertions(+), 2 deletions(-) create mode 100644 ui/src/components/MacroBar.tsx create mode 100644 ui/src/routes/devices.$id.settings.macros.tsx diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx new file mode 100644 index 0000000..5f513b7 --- /dev/null +++ b/ui/src/components/MacroBar.tsx @@ -0,0 +1,47 @@ +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(); + + // Set up sendFn and initialize macros if needed + 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/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 911c5ea..f169553 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"; @@ -526,7 +527,7 @@ export default function WebRTCVideo() { return (
-
+
@@ -535,6 +536,7 @@ export default function WebRTCVideo() { }) } /> +
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index f30c28c..871e0a5 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -1,6 +1,18 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +// Define the JsonRpc types for better type checking +interface JsonRpcResponse { + jsonrpc: string; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; + id: number | string | null; +} + // Utility function to append stats to a Map const appendStatToMap = ( stat: T, @@ -649,3 +661,144 @@ 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; + description?: 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; +} + +const MAX_STEPS_PER_MACRO = 10; +const MAX_TOTAL_MACROS = 25; +const MAX_KEYS_PER_STEP = 10; + +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."); + return; + } + + 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 + })); + + set({ macros: macrosWithSortOrder }); + + await new Promise((resolve, reject) => { + sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => { + if (response.error) { + console.error("Error saving macros:", response.error); + reject(new Error(response.error.message)); + return; + } + + resolve(); + }); + }); + } catch (error) { + console.error("Failed to save macros:", error); + get().loadMacros(); + } 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/index.css b/ui/src/index.css index 5052657..c41f5d3 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -201,3 +201,132 @@ video::-webkit-media-controls { .hide-scrollbar::-webkit-scrollbar { display: none; } + +/* Macro Component Styles */ +@keyframes macroFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes macroScaleIn { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.macro-animate-fadeIn { animation: macroFadeIn 0.2s ease-out; } +.macro-animate-scaleIn { animation: macroScaleIn 0.2s ease-out; } + +/* Base Macro Element Styles */ +.macro-sortable, .macro-step, [data-macro-item] { + transition: box-shadow 0.15s ease-out, background-color 0.2s ease-out, transform 0.1s, border-color 0.2s; + position: relative; + touch-action: none; +} + +.macro-sortable.dragging, .macro-step.dragging, [data-macro-item].dragging { + z-index: 10; + opacity: 0.8; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + @apply bg-blue-500/10; +} + +.macro-sortable.drop-target, .macro-step.drop-target, [data-macro-item].drop-target { + @apply border-2 border-dashed border-blue-500 bg-blue-500/5; +} + +.macro-sortable.ghost { + position: static; + opacity: 0.3; + pointer-events: none; + background-color: transparent; + border: 2px dashed rgb(148 163 184); + transform: none; +} + +/* Drag Handle Styles */ +.drag-handle, .macro-sortable-handle { + cursor: grab; + touch-action: none; + @apply flex items-center p-1 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300; +} + +.drag-handle:active, .macro-sortable-handle:active { + cursor: grabbing; +} + +@media (hover: none) { + .macro-sortable, .macro-step, [data-macro-item] { + user-select: none; + } +} + +/* Macro Form Elements */ +.macro-input { + @apply w-full rounded-md border border-slate-300 bg-white p-1.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-white; +} + +.macro-input-error { + @apply border-red-500 dark:border-red-500; +} + +.macro-select, .macro-delay-select { + @apply w-full rounded-md border border-slate-300 bg-slate-50 p-2 text-sm shadow-sm + focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 + dark:border-slate-600 dark:bg-slate-800 dark:text-white; +} + +/* Macro Card Elements */ +.macro-card, .macro-step-card { + @apply rounded-md border border-slate-300 bg-white p-4 shadow-sm + dark:border-slate-600 dark:bg-slate-800 + transition-colors duration-200; +} + +.macro-step-card:hover { + @apply border-blue-300 dark:border-blue-700; +} + +/* Badge & Step Number Styles */ +.macro-key-badge, .macro-step-number { + @apply inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200; +} + +/* Section Styling Utilities */ +.macro-section { + @apply space-y-4 mt-2; +} + +.macro-section-header { + @apply flex items-center justify-between; +} + +.macro-section-title { + @apply text-sm font-medium text-slate-700 dark:text-slate-300; +} + +.macro-section-subtitle { + @apply text-xs text-slate-500 dark:text-slate-400; +} + +/* Error Styles */ +.macro-error { + @apply mb-4 rounded-md bg-red-50 p-3 dark:bg-red-900/30; +} + +.macro-error-icon { + @apply h-5 w-5 text-red-400 dark:text-red-300; +} + +.macro-error-text { + @apply text-sm font-medium text-red-800 dark:text-red-200; +} + +/* Container Styles */ +.macro-modifiers-container { + @apply flex flex-wrap gap-2; +} + +.macro-modifier-group { + @apply inline-flex flex-col rounded-md border border-slate-200 p-2 dark:border-slate-700; + min-width: fit-content; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 066ee57..4b29129 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -40,6 +40,7 @@ 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"; @@ -175,6 +176,10 @@ if (isOnDevice) { path: "appearance", element: , }, + { + path: "macros", + element: , + }, ], }, ], @@ -283,6 +288,10 @@ if (isOnDevice) { path: "appearance", element: , }, + { + path: "macros", + element: , + }, ], }, ], 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..50db6b0 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -0,0 +1,1401 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { LuPlus, LuTrash, LuSave, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo } from "react-icons/lu"; +import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; + +import { KeySequence, useMacrosStore } from "../hooks/stores"; +import { SettingsPageHeader } from "../components/SettingsPageheader"; +import { Button } from "../components/Button"; +import { keys, modifiers } from "../keyboardMappings"; +import { useJsonRpc } from "../hooks/useJsonRpc"; + +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)}`; +}; + +const keyOptions = Object.keys(keys).map(key => ({ + value: key, + label: 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')), +}; + +interface KeyComboboxProps { + stepIndex: number; + step: MacroStep; + onSelect: (option: KeyOptionData) => void; + query: string; + onQueryChange: (query: string) => void; + getFilteredOptions: () => KeyOption[]; + disabled?: boolean; +} + +function KeyCombobox({ + onSelect, + query, + onQueryChange, + getFilteredOptions, + disabled = false, +}: KeyComboboxProps) { + const inputRef = useRef(null); + + return ( +
+ + {() => ( + <> +
+ query} + onChange={(event) => onQueryChange(event.target.value)} + disabled={disabled} + /> +
+ + + {getFilteredOptions().map((option) => ( + + {option.label} + + ))} + {getFilteredOptions().length === 0 && ( +
+ No matching keys found +
+ )} +
+ + )} +
+
+ ); +} + +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) => ( + + {key} + + + ))} +
+ + = MAX_KEYS_PER_STEP} + /> + + {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && ( + + (max keys reached) + + )} +
+ +
+
+ +
+ +
+

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.

+
+
+
+
+ +
+
+
+
+ ); +} + +// 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]; + if (keyOption.keys) { + newSteps[stepIndex].keys = keyOption.keys; + } else if (keyOption.value) { + 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; +}; + +const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => { + const [touchStartY, setTouchStartY] = useState(null); + const [touchedIndex, setTouchedIndex] = useState(null); + + const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => { + const touch = e.touches[0]; + setTouchStartY(touch.clientY); + setTouchedIndex(index); + + const element = e.currentTarget as HTMLElement; + const rect = element.getBoundingClientRect(); + + // Create ghost element + const ghost = element.cloneNode(true) as HTMLElement; + ghost.id = 'ghost-macro'; + ghost.className = 'macro-sortable ghost'; + ghost.style.height = `${rect.height}px`; + element.parentNode?.insertBefore(ghost, element); + + // Set up dragged element + element.style.position = 'fixed'; + element.style.left = `${rect.left}px`; + element.style.top = `${rect.top}px`; + element.style.width = `${rect.width}px`; + element.style.zIndex = '50'; + }, []); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (touchStartY === null || touchedIndex === null) return; + + const touch = e.touches[0]; + const deltaY = touch.clientY - touchStartY; + const element = e.currentTarget as HTMLElement; + + // Smooth movement of dragged element + element.style.transform = `translateY(${deltaY}px)`; + + const macroElements = document.querySelectorAll('[data-macro-item]'); + const draggedRect = element.getBoundingClientRect(); + const draggedMiddle = draggedRect.top + draggedRect.height / 2; + + macroElements.forEach((el, i) => { + if (i === touchedIndex) return; + + const rect = el.getBoundingClientRect(); + const elementMiddle = rect.top + rect.height / 2; + const distance = Math.abs(draggedMiddle - elementMiddle); + + if (distance < rect.height) { + const direction = draggedMiddle > elementMiddle ? -1 : 1; + (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`; + (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; + } else { + (el as HTMLElement).style.transform = ''; + (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; + } + }); + }, [touchStartY, touchedIndex]); + + const handleTouchEnd = useCallback(async (e: React.TouchEvent) => { + if (touchedIndex === null) return; + + const element = e.currentTarget as HTMLElement; + const touch = e.changedTouches[0]; + + // Remove ghost element + const ghost = document.getElementById('ghost-macro'); + ghost?.parentNode?.removeChild(ghost); + + // Reset dragged element styles + element.style.position = ''; + element.style.left = ''; + element.style.top = ''; + element.style.width = ''; + element.style.zIndex = ''; + element.style.transform = ''; + element.style.boxShadow = ''; + element.style.transition = ''; + + const macroElements = document.querySelectorAll('[data-macro-item]'); + let targetIndex = touchedIndex; + + // Find the closest element to the final touch position + const finalY = touch.clientY; + let closestDistance = Infinity; + + macroElements.forEach((el, i) => { + if (i === touchedIndex) return; + + const rect = el.getBoundingClientRect(); + const distance = Math.abs(finalY - (rect.top + rect.height / 2)); + + if (distance < closestDistance) { + closestDistance = distance; + targetIndex = i; + } + + // Reset other elements + (el as HTMLElement).style.transform = ''; + (el as HTMLElement).style.transition = ''; + }); + + if (targetIndex !== touchedIndex && closestDistance < 50) { + const itemsCopy = [...items]; + const [draggedItem] = itemsCopy.splice(touchedIndex, 1); + itemsCopy.splice(targetIndex, 0, draggedItem); + onSort(itemsCopy); + } + + setTouchStartY(null); + setTouchedIndex(null); + }, [touchedIndex, items, onSort]); + + return { handleTouchStart, handleTouchMove, handleTouchEnd }; +}; + +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 [isDragging, setIsDragging] = useState(false); + const dragItem = useRef(null); + const dragOverItem = useRef(null); + + 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] || ''); + + if (query === '') { + return keyOptions; + } else { + return keyOptions.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); + } catch (error) { + if (error instanceof Error) { + showTemporaryError(error.message); + } else { + showTemporaryError("Failed to save macro"); + } + } finally { + setIsSaving(false); + } + }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]); + + const handleDragStart = (index: number) => { + dragItem.current = index; + setIsDragging(true); + + const allItems = document.querySelectorAll('[data-macro-item]'); + const draggedElement = allItems[index]; + if (draggedElement) { + draggedElement.classList.add('dragging'); + } + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + dragOverItem.current = index; + + const allItems = document.querySelectorAll('[data-macro-item]'); + allItems.forEach(el => el.classList.remove('drop-target')); + + const targetElement = allItems[index]; + if (targetElement) { + targetElement.classList.add('drop-target'); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + if (dragItem.current === null || dragOverItem.current === null) return; + + const macroCopy = [...macros]; + const draggedItem = macroCopy.splice(dragItem.current, 1)[0]; + macroCopy.splice(dragOverItem.current, 0, draggedItem); + const updatedMacros = normalizeSortOrders(macroCopy); + await saveMacros(updatedMacros); + + const allItems = document.querySelectorAll('[data-macro-item]'); + allItems.forEach(el => { + el.classList.remove('drop-target'); + el.classList.remove('dragging'); + }); + + dragItem.current = null; + dragOverItem.current = null; + setIsDragging(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(); + } catch (error) { + if (error instanceof Error) { + showTemporaryError(error.message); + } else { + showTemporaryError("Failed to update macro"); + } + } finally { + setIsUpdating(false); + } + }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]); + + 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) => { + setIsDeleting(true); + try { + const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id)); + await saveMacros(updatedMacros); + if (editingMacro?.id === id) { + setEditingMacro(null); + } + } catch (error) { + if (error instanceof Error) { + showTemporaryError(error.message); + } else { + showTemporaryError("Failed to delete macro"); + } + } finally { + setIsDeleting(false); + } + }; + + const handleDuplicateMacro = (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 { + saveMacros(normalizeSortOrders([...macros, newMacroCopy])); + } catch (error) { + if (error instanceof Error) { + showTemporaryError(error.message); + } else { + 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 { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort( + macros, + async (newMacros) => { + const updatedMacros = normalizeSortOrders(newMacros); + await saveMacros(updatedMacros); + } + ); + + const [showClearConfirm, setShowClearConfirm] = useState(false); + const [showAddMacro, setShowAddMacro] = useState(false); + + 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 ( +

+ {error} +

+ ); + }; + + return ( +
+ + + {errorMessage && ( +
+
+
+ +
+
+

{errorMessage}

+
+
+
+ )} + +
+
+
+ = MAX_TOTAL_MACROS ? "font-semibold text-amber-600 dark:text-amber-400" : ""}> + Macros: {macros.length}/{MAX_TOTAL_MACROS} + + {macros.length >= MAX_TOTAL_MACROS && ( + (maximum reached) + )} +
+ {!showAddMacro && ( +
+
+ + {loading && ( +
+ +
+ )} +
+ {showAddMacro && ( +
+
+

Add New Macro

+
+
+
+ { + setNewMacro(prev => ({ ...prev, name: e.target.value })); + if (errors.name) { + const newErrors = { ...errors }; + delete newErrors.name; + setErrors(newErrors); + } + }} + placeholder="Macro Name" + /> + +
+
+ { + setNewMacro(prev => ({ ...prev, description: e.target.value })); + if (errors.description) { + const newErrors = { ...errors }; + delete newErrors.description; + setErrors(newErrors); + } + }} + placeholder="Description (optional)" + /> + +
+
+ +
+
+ + + {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 && ( +
+

Saved Macros

+ + {macros.length === 0 ? ( +

+ No macros created yet. Add your first macro above. +

+ ) : ( +
+ {macros.map((macro, index) => + editingMacro && editingMacro.id === macro.id ? ( +
+
+
+ { + setEditingMacro({ ...editingMacro, name: e.target.value }); + if (errors.name) { + const newErrors = { ...errors }; + delete newErrors.name; + setErrors(newErrors); + } + }} + placeholder="Macro Name" + /> + +
+
+ { + setEditingMacro({ ...editingMacro, description: e.target.value }); + if (errors.description) { + const newErrors = { ...errors }; + delete newErrors.description; + setErrors(newErrors); + } + }} + placeholder="Description (optional)" + /> + +
+
+ +
+
+ + + {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} + /> + ))} + +
+ +
+
+ +
+
+
+
+ ) : ( +
handleDragStart(index)} + onDragOver={e => handleDragOver(e, index)} + onDragEnd={() => { + const allItems = document.querySelectorAll('[data-macro-item]'); + allItems.forEach(el => { + el.classList.remove('drop-target'); + el.classList.remove('dragging'); + }); + setIsDragging(false); + }} + onDrop={handleDrop} + onTouchStart={(e) => handleTouchStart(e, index)} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + className={`macro-sortable flex items-center justify-between rounded-md border border-slate-200 p-2 dark:border-slate-700 ${ + isDragging && dragItem.current === index + ? "bg-blue-50 dark:bg-blue-900/20" + : "bg-white dark:bg-slate-800" + }`} + > +
+ +
+ +
+

+ {macro.name} +

+ {macro.description && ( +

+ {macro.description} +

+ )} +

+ + {macro.steps.slice(0, 3).map((step, stepIndex) => { + const modifiersText = ensureArray(step.modifiers).length > 0 + ? ensureArray(step.modifiers).map(m => m.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1")).join(' + ') + : ''; + + const keysText = ensureArray(step.keys).length > 0 ? ensureArray(step.keys).join(' + ') : ''; + const combinedText = (modifiersText || keysText) + ? [modifiersText, keysText].filter(Boolean).join(' + ') + : 'Delay only'; + + return ( + + {stepIndex > 0 && } + + {combinedText} + ({step.delay}ms) + + + ); + })} + {macro.steps.length > 3 && ( + + + {macro.steps.length - 3} more steps + + )} + +

+
+ +
+ {macroToDelete === macro.id ? ( +
+ + Delete macro? + +
+ ) : ( + <> + + + + + )} +
+
+ ) + )} +
+ )} +
+ )} +
+
+ ); +} 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

+
+
+