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 => (
+ executeMacro(macro.steps)}
+ />
+ ))}
+
+
+
+ );
+}
\ 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 && (
+
+
+
+
+
+
+ Delete
+
+ )}
+
+
+
+
+
+
+ Modifiers:
+
+
+ {Object.entries(groupedModifiers).map(([group, mods]) => (
+
+
+ {group}
+
+
+ {mods.map(option => (
+
+ {
+ const modifiersArray = ensureArray(step.modifiers);
+ const newModifiers = e.target.checked
+ ? [...modifiersArray, option.value]
+ : modifiersArray.filter(m => m !== option.value);
+ onModifierChange(newModifiers);
+ }}
+ />
+ {option.label.split(' ')[1] || option.label}
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ Keys:
+
+
+
+ {ensureArray(step.keys).map((key, keyIndex) => (
+
+ {key}
+ {
+ const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
+ onKeySelect({ value: null, keys: newKeys });
+ }}
+ >
+ ×
+
+
+ ))}
+
+
+
= MAX_KEYS_PER_STEP}
+ />
+
+ {ensureArray(step.keys).length >= MAX_KEYS_PER_STEP && (
+
+ (max keys reached)
+
+ )}
+
+
+
+
+
+ Step Duration:
+
+
+
+
+
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))}
+ >
+ {PRESET_DELAYS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+// 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 && (
+
+ )}
+
+
+
+
+ = 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 && (
+
setShowAddMacro(true)}
+ disabled={isMaxMacrosReached}
+ />
+ )}
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {showAddMacro && (
+
+
+
Add New Macro
+
+
+
+
+
+
+ Steps:
+
+
+ {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}
+ />
+ ))}
+
+
+ {
+ if (isMaxStepsReachedForNewMacro) {
+ showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+ return;
+ }
+
+ setNewMacro(prev => ({
+ ...prev,
+ steps: [
+ ...(prev.steps || []),
+ { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+ ],
+ }));
+ clearErrors();
+ }}
+ disabled={isMaxStepsReachedForNewMacro}
+ >
+
+ Add Step {isMaxStepsReachedForNewMacro && `(${MAX_STEPS_PER_MACRO} max)`}
+
+
+
+
+ {showClearConfirm ? (
+
+
+ Cancel changes?
+
+ {
+ resetNewMacro();
+ setShowAddMacro(false);
+ setShowClearConfirm(false);
+ }}
+ />
+ setShowClearConfirm(false)}
+ />
+
+ ) : (
+
{
+ if (newMacro.name || newMacro.description || newMacro.steps?.some(s => s.keys?.length || s.modifiers?.length)) {
+ setShowClearConfirm(true);
+ } else {
+ resetNewMacro();
+ setShowAddMacro(false);
+ }
+ }}
+ />
+ )}
+
+
+
+
+
+ )}
+ {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 ? (
+
+
+
+
+
+
+ Steps:
+
+
+ {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}
+ />
+ ))}
+
+
+ = MAX_STEPS_PER_MACRO
+ ? 'bg-slate-100 text-slate-400 cursor-not-allowed dark:bg-slate-800 dark:text-slate-500'
+ : 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700'
+ }`}
+ onClick={() => {
+ if (editingMacro.steps.length >= MAX_STEPS_PER_MACRO) {
+ showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
+ return;
+ }
+
+ setEditingMacro({
+ ...editingMacro,
+ steps: [
+ ...editingMacro.steps,
+ { keys: [], modifiers: [], delay: DEFAULT_DELAY }
+ ],
+ });
+ clearErrors();
+ }}
+ disabled={editingMacro.steps.length >= MAX_STEPS_PER_MACRO}
+ >
+
+ Add Step {editingMacro.steps.length >= MAX_STEPS_PER_MACRO && `(${MAX_STEPS_PER_MACRO} max)`}
+
+
+
+
+
+ {
+ setEditingMacro(null);
+ setErrors({});
+ }}
+ />
+
+
+
+
+ ) : (
+
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?
+
+ {
+ handleDeleteMacro(macro.id);
+ }}
+ />
+ setMacroToDelete(null)}
+ />
+
+ ) : (
+ <>
+
handleEditMacro(macro)}
+ title="Edit"
+ >
+
+
+
handleDuplicateMacro(macro)}
+ title="Duplicate"
+ >
+
+
+
+
+
+
setMacroToDelete(macro.id)}
+ title="Delete"
+ >
+
+
+ >
+ )}
+
+
+ )
+ )}
+
+ )}
+
+ )}
+
+
+ );
+}
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
+
+
+