split up macro routes

This commit is contained in:
Andrew Davis 2025-04-03 16:01:22 +10:00
parent 48d8523122
commit 7b8725892d
No known key found for this signature in database
GPG Key ID: 30AB5B89A109D044
10 changed files with 959 additions and 1096 deletions

View File

@ -1,9 +1,7 @@
import { useRef } from "react"; import { useRef } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
import { Combobox as HeadlessCombobox, ComboboxProps as HeadlessComboboxProps, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
import { cva } from "@/cva.config"; import { cva } from "@/cva.config";
import Card from "./Card"; import Card from "./Card";
export interface ComboboxOption { export interface ComboboxOption {
@ -16,15 +14,15 @@ const sizes = {
SM: "h-[32px] pl-3 pr-8 text-[13px]", SM: "h-[32px] pl-3 pr-8 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm", MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base", LG: "h-[48px] pl-4 pr-10 px-5 text-base",
}; } as const;
const comboboxVariants = cva({ const comboboxVariants = cva({
variants: { size: sizes }, variants: { size: sizes },
}); });
interface ComboboxProps<T> extends HeadlessComboboxProps<T, boolean, React.ExoticComponent<{ type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
children?: React.ReactNode;
}>> { interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
displayValue: (option: ComboboxOption) => string; displayValue: (option: ComboboxOption) => string;
onInputChange: (option: string) => void; onInputChange: (option: string) => void;
options: () => ComboboxOption[]; options: () => ComboboxOption[];
@ -34,7 +32,7 @@ interface ComboboxProps<T> extends HeadlessComboboxProps<T, boolean, React.Exoti
disabledMessage?: string; disabledMessage?: string;
} }
export function Combobox<T>({ export function Combobox({
onInputChange, onInputChange,
displayValue, displayValue,
options, options,
@ -45,11 +43,11 @@ export function Combobox<T>({
onChange, onChange,
disabledMessage = "Input disabled", disabledMessage = "Input disabled",
...otherProps ...otherProps
}: ComboboxProps<T>) { }: ComboboxProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const classes = comboboxVariants({ size }); const classes = comboboxVariants({ size });
const handleChange = (value: T) => { const handleChange = (value: unknown) => {
if (onChange) { if (onChange) {
onChange(value); onChange(value);
inputRef.current?.blur(); inputRef.current?.blur();
@ -57,14 +55,13 @@ export function Combobox<T>({
}; };
return ( return (
<HeadlessCombobox<T, boolean, React.ExoticComponent<{ children?: React.ReactNode;}>> <HeadlessCombobox
immediate
onChange={handleChange} onChange={handleChange}
{...otherProps} {...otherProps}
> >
{() => ( {() => (
<> <>
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30"> <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
<ComboboxInput <ComboboxInput
ref={inputRef} ref={inputRef}
className={clsx( className={clsx(
@ -90,11 +87,11 @@ export function Combobox<T>({
onChange={(event) => onInputChange(event.target.value)} onChange={(event) => onInputChange(event.target.value)}
disabled={disabled} disabled={disabled}
/> />
</Card> </Card>
{options().length > 0 && ( {options().length > 0 && (
<ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar"> <ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
{options().map((option) => ( {options().map((option) => (
<ComboboxOption <ComboboxOption
key={option.value} key={option.value}
value={option} value={option}
@ -109,21 +106,21 @@ export function Combobox<T>({
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200" "dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
)} )}
> >
{option.label} {option.label}
</ComboboxOption> </ComboboxOption>
))} ))}
</ComboboxOptions> </ComboboxOptions>
)} )}
{options().length === 0 && inputRef.current?.value && ( {options().length === 0 && inputRef.current?.value && (
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700"> <div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
<div className="text-slate-500 dark:text-slate-400"> <div className="text-slate-500 dark:text-slate-400">
{emptyMessage} {emptyMessage}
</div> </div>
</div> </div>
)} )}
</> </>
)} )}
</HeadlessCombobox> </HeadlessCombobox>
); );
} }

View File

@ -12,7 +12,6 @@ export default function MacroBar() {
const { executeMacro } = useKeyboard(); const { executeMacro } = useKeyboard();
const [send] = useJsonRpc(); const [send] = useJsonRpc();
// Set up sendFn and initialize macros if needed
useEffect(() => { useEffect(() => {
setSendFn(send); setSendFn(send);

View File

@ -0,0 +1,362 @@
import { useState } from "react";
import { LuPlus, LuInfo } from "react-icons/lu";
import { KeySequence } from "@/hooks/stores";
import { Button } from "@/components/Button";
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import Fieldset from "@/components/Fieldset";
import { MacroStepCard } from "@/components/MacroStepCard";
import Modal from "@/components/Modal";
import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
interface ValidationErrors {
name?: string;
steps?: Record<number, {
keys?: string;
modifiers?: string;
delay?: string;
}>;
}
interface MacroFormProps {
initialData: Partial<KeySequence>;
onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
onCancel: () => void;
isSubmitting?: boolean;
submitText?: string;
showCancelConfirm?: boolean;
onCancelConfirm?: () => void;
showDelete?: boolean;
onDelete?: () => void;
isDeleting?: boolean;
}
export function MacroForm({
initialData,
onSubmit,
onCancel,
isSubmitting = false,
submitText = "Save Macro",
showCancelConfirm = false,
onCancelConfirm,
showDelete = false,
onDelete,
isDeleting = false
}: MacroFormProps) {
const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
const [errors, setErrors] = useState<ValidationErrors>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const showTemporaryError = (message: string) => {
setErrorMessage(message);
setTimeout(() => setErrorMessage(null), 3000);
};
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {};
// Name validation
if (!macro.name?.trim()) {
newErrors.name = "Name is required";
} else if (macro.name.trim().length > 50) {
newErrors.name = "Name must be less than 50 characters";
}
if (!macro.steps?.length) {
newErrors.steps = { 0: { keys: "At least one step is required" } };
} else {
const hasKeyOrModifier = macro.steps.some(step =>
(step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
);
if (!hasKeyOrModifier) {
newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
showTemporaryError("Please fix the validation errors");
return;
}
try {
await onSubmit(macro);
} catch (error) {
if (error instanceof Error) {
showTemporaryError(error.message);
} else {
showTemporaryError("An error occurred while saving");
}
}
};
const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
const newSteps = [...(macro.steps || [])];
if (!newSteps[stepIndex]) return;
if (option.keys) {
newSteps[stepIndex].keys = option.keys;
} else if (option.value) {
if (!newSteps[stepIndex].keys) {
newSteps[stepIndex].keys = [];
}
const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
if (keysArray.length >= MAX_KEYS_PER_STEP) {
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
return;
}
newSteps[stepIndex].keys = [...keysArray, option.value];
}
setMacro({ ...macro, steps: newSteps });
if (errors.steps?.[stepIndex]?.keys) {
const newErrors = { ...errors };
delete newErrors.steps?.[stepIndex].keys;
if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
delete newErrors.steps?.[stepIndex];
}
if (Object.keys(newErrors.steps || {}).length === 0) {
delete newErrors.steps;
}
setErrors(newErrors);
}
};
const handleKeyQueryChange = (stepIndex: number, query: string) => {
setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
};
const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
const newSteps = [...(macro.steps || [])];
newSteps[stepIndex].modifiers = modifiers;
setMacro({ ...macro, steps: newSteps });
// Clear step errors when modifiers are added
if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
const newErrors = { ...errors };
delete newErrors.steps?.[stepIndex].keys;
if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
delete newErrors.steps?.[stepIndex];
}
if (Object.keys(newErrors.steps || {}).length === 0) {
delete newErrors.steps;
}
setErrors(newErrors);
}
};
const handleDelayChange = (stepIndex: number, delay: number) => {
const newSteps = [...(macro.steps || [])];
newSteps[stepIndex].delay = delay;
setMacro({ ...macro, steps: newSteps });
};
const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
const newSteps = [...(macro.steps || [])];
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
setMacro({ ...macro, steps: newSteps });
};
const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
return (
<>
<div className="space-y-4">
<Fieldset>
<InputFieldWithLabel
type="text"
label="Macro Name"
placeholder="Macro Name"
value={macro.name}
error={errors.name}
onChange={e => {
setMacro(prev => ({ ...prev, name: e.target.value }));
if (errors.name) {
const newErrors = { ...errors };
delete newErrors.name;
setErrors(newErrors);
}
}}
/>
</Fieldset>
<div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<label className="font-medium text-slate-700 dark:text-slate-200">
Steps
</label>
<div className="group relative cursor-pointer">
<LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
<div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
<p>Each step is a collection of keys and/or modifiers that will be executed in order. You can add up to a maximum of {MAX_STEPS_PER_MACRO} steps per macro.</p>
</div>
</div>
</div>
<span className="text-slate-500 dark:text-slate-400">
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
</span>
</div>
{errors.steps && errors.steps[0]?.keys && (
<div className="mt-2">
<FieldError error={errors.steps[0].keys} />
</div>
)}
<Fieldset>
<div className="mt-2 space-y-4">
{(macro.steps || []).map((step, stepIndex) => (
<MacroStepCard
key={stepIndex}
step={step}
stepIndex={stepIndex}
onDelete={macro.steps && macro.steps.length > 1 ? () => {
const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps }));
} : undefined}
onMoveUp={() => handleStepMove(stepIndex, 'up')}
onMoveDown={() => handleStepMove(stepIndex, 'down')}
onKeySelect={(option) => handleKeySelect(stepIndex, option)}
onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
keyQuery={keyQueries[stepIndex] || ''}
onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
/>
))}
</div>
</Fieldset>
<div className="mt-4">
<Button
size="MD"
theme="light"
fullWidth
LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
onClick={() => {
if (isMaxStepsReached) {
showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
return;
}
setMacro(prev => ({
...prev,
steps: [
...(prev.steps || []),
{ keys: [], modifiers: [], delay: DEFAULT_DELAY }
],
}));
setErrors({});
}}
disabled={isMaxStepsReached}
/>
</div>
{errorMessage && (
<div className="mt-4">
<FieldError error={errorMessage} />
</div>
)}
<div className="mt-6 flex items-center justify-between">
{showCancelConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
Cancel changes?
</span>
<Button
size="SM"
theme="danger"
text="Yes"
onClick={onCancelConfirm}
/>
<Button
size="SM"
theme="light"
text="No"
onClick={() => onCancel()}
/>
</div>
) : (
<>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text={isSubmitting ? "Saving..." : submitText}
onClick={handleSubmit}
disabled={isSubmitting}
/>
<Button
size="SM"
theme="light"
text="Cancel"
onClick={onCancel}
/>
</div>
{showDelete && (
<Button
size="SM"
theme="danger"
text={isDeleting ? "Deleting..." : "Delete Macro"}
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
/>
)}
</>
)}
</div>
</div>
</div>
<Modal
open={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
>
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
<div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto">
<div className="space-y-4">
<div className="space-y-0">
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
Delete Macro
</h2>
<div className="text-sm leading-snug text-slate-600 dark:text-slate-400">
Are you sure you want to delete this macro? This action cannot be undone.
</div>
</div>
<div className="flex justify-end gap-x-2">
<Button
size="SM"
theme="danger"
text={isDeleting ? "Deleting..." : "Delete"}
onClick={() => {
onDelete?.();
setShowDeleteConfirm(false);
}}
disabled={isDeleting}
/>
<Button
size="SM"
theme="light"
text="Cancel"
onClick={() => setShowDeleteConfirm(false)}
/>
</div>
</div>
</div>
</div>
</Modal>
</>
);
}

View File

@ -0,0 +1,246 @@
import { LuArrowUp, LuArrowDown, LuX, LuInfo } from "react-icons/lu";
import { Button } from "@/components/Button";
import { Combobox } from "@/components/Combobox";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import Card from "@/components/Card";
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
import { MAX_KEYS_PER_STEP } from "@/constants/macros";
// Filter out modifier keys since they're handled in the modifiers section
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
const keyOptions = Object.keys(keys)
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
.map(key => ({
value: key,
label: keyDisplayMap[key] || key,
}));
const modifierOptions = Object.keys(modifiers).map(modifier => ({
value: modifier,
label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
}));
const groupedModifiers: Record<string, typeof modifierOptions> = {
Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
};
const PRESET_DELAYS = [
{ value: "50", label: "50ms" },
{ value: "100", label: "100ms" },
{ value: "200", label: "200ms" },
{ value: "300", label: "300ms" },
{ value: "500", label: "500ms" },
{ value: "750", label: "750ms" },
{ value: "1000", label: "1000ms" },
{ value: "1500", label: "1500ms" },
{ value: "2000", label: "2000ms" },
];
interface MacroStep {
keys: string[];
modifiers: string[];
delay: number;
}
interface MacroStepCardProps {
step: MacroStep;
stepIndex: number;
onDelete?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
onKeyQueryChange: (query: string) => void;
keyQuery: string;
onModifierChange: (modifiers: string[]) => void;
onDelayChange: (delay: number) => void;
isLastStep: boolean;
}
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
return Array.isArray(arr) ? arr : [];
};
export function MacroStepCard({
step,
stepIndex,
onDelete,
onMoveUp,
onMoveDown,
onKeySelect,
onKeyQueryChange,
keyQuery,
onModifierChange,
onDelayChange,
isLastStep
}: MacroStepCardProps) {
const getFilteredKeys = () => {
const selectedKeys = ensureArray(step.keys);
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
if (keyQuery === '') {
return availableKeys;
} else {
return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
}
};
return (
<Card className="p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
<Button
size="XS"
theme="light"
onClick={onMoveUp}
disabled={stepIndex === 0}
LeadingIcon={LuArrowUp}
/>
<Button
size="XS"
theme="light"
onClick={onMoveDown}
disabled={isLastStep}
LeadingIcon={LuArrowDown}
/>
</div>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{stepIndex + 1}
</span>
</div>
<div className="flex items-center space-x-2">
{onDelete && (
<Button
size="XS"
theme="danger"
text="Delete"
onClick={onDelete}
/>
)}
</div>
</div>
<div className="space-y-4 mt-2">
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
Modifiers
</label>
<div className="inline-flex flex-wrap gap-3">
{Object.entries(groupedModifiers).map(([group, mods]) => (
<div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
<span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
{group}
</span>
<div className="flex flex-wrap gap-1">
{mods.map(option => (
<label
key={option.value}
className={`flex items-center px-2 py-1 rounded border cursor-pointer text-xs font-medium transition-colors ${
ensureArray(step.modifiers).includes(option.value)
? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-200'
: 'bg-slate-100 border-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700'
}`}
>
<input
type="checkbox"
className="sr-only"
checked={ensureArray(step.modifiers).includes(option.value)}
onChange={e => {
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}
</label>
))}
</div>
</div>
))}
</div>
</div>
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
Keys
</label>
<div className="group relative cursor-pointer">
<LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
<div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
<p>You can add up to a maximum of {MAX_KEYS_PER_STEP} keys to press per step.</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-1 pb-2">
{ensureArray(step.keys).map((key, keyIndex) => (
<span
key={keyIndex}
className="inline-flex items-center rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
>
<span className="px-1">
{keyDisplayMap[key] || key}
</span>
<Button
size="XS"
className=""
theme="blank"
onClick={() => {
const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
onKeySelect({ value: null, keys: newKeys });
}}
LeadingIcon={LuX}
/>
</span>
))}
</div>
<div className="relative w-full">
<Combobox
onChange={(value: { value: string; label: string }) => onKeySelect(value)}
displayValue={() => keyQuery}
onInputChange={onKeyQueryChange}
options={getFilteredKeys}
disabledMessage="Max keys reached"
size="SM"
immediate
disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
emptyMessage="No matching keys found"
/>
</div>
</div>
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
Step Duration
</label>
<div className="group relative cursor-pointer">
<LuInfo className="h-4 w-4 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-400" />
<div className="absolute left-1/2 top-full z-10 mt-1 hidden w-64 -translate-x-1/2 rounded-md bg-slate-800 px-3 py-2 text-xs text-white shadow-lg group-hover:block dark:bg-slate-700">
<p>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.</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<SelectMenuBasic
size="SM"
fullWidth
value={step.delay.toString()}
onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
options={PRESET_DELAYS}
/>
</div>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,5 @@
export const DEFAULT_DELAY = 50;
export const MAX_STEPS_PER_MACRO = 10;
export const MAX_KEYS_PER_STEP = 10;
export const MAX_TOTAL_MACROS = 25;
export const COPY_SUFFIX = "(copy)";

View File

@ -1,5 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros";
// Define the JsonRpc types for better type checking // Define the JsonRpc types for better type checking
interface JsonRpcResponse { interface JsonRpcResponse {
@ -671,7 +672,6 @@ export interface KeySequenceStep {
export interface KeySequence { export interface KeySequence {
id: string; id: string;
name: string; name: string;
description?: string;
steps: KeySequenceStep[]; steps: KeySequenceStep[];
sortOrder?: number; sortOrder?: number;
} }
@ -686,9 +686,9 @@ export interface MacrosState {
setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void; setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void;
} }
const MAX_STEPS_PER_MACRO = 10; export const generateMacroId = () => {
const MAX_TOTAL_MACROS = 25; return Math.random().toString(36).substring(2, 9);
const MAX_KEYS_PER_STEP = 10; };
export const useMacrosStore = create<MacrosState>((set, get) => ({ export const useMacrosStore = create<MacrosState>((set, get) => ({
macros: [], macros: [],

View File

@ -40,10 +40,12 @@ import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access.
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware"; import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
import SettingsVideoRoute from "./routes/devices.$id.settings.video"; import SettingsVideoRoute from "./routes/devices.$id.settings.video";
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance"; 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 * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update"; import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth"; import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
import SettingsMacrosRoute from "./routes/devices.$id.settings.macros";
import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add";
import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit";
export const isOnDevice = import.meta.env.MODE === "device"; export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice; export const isInCloud = !isOnDevice;
@ -178,7 +180,20 @@ if (isOnDevice) {
}, },
{ {
path: "macros", path: "macros",
element: <SettingsMacrosRoute />, children: [
{
index: true,
element: <SettingsMacrosRoute />,
},
{
path: "add",
element: <SettingsMacrosAddRoute />,
},
{
path: ":macroId/edit",
element: <SettingsMacrosEditRoute />,
},
],
}, },
], ],
}, },
@ -290,7 +305,20 @@ if (isOnDevice) {
}, },
{ {
path: "macros", path: "macros",
element: <SettingsMacrosRoute />, children: [
{
index: true,
element: <SettingsMacrosRoute />,
},
{
path: "add",
element: <SettingsMacrosAddRoute />,
},
{
path: ":macroId/edit",
element: <SettingsMacrosEditRoute />,
},
],
}, },
], ],
}, },

View File

@ -0,0 +1,63 @@
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { MacroForm } from "@/components/MacroForm";
import { DEFAULT_DELAY } from "@/constants/macros";
import notifications from "@/notifications";
export default function SettingsMacrosAddRoute() {
const { macros, saveMacros } = useMacrosStore();
const [isSaving, setIsSaving] = useState(false);
const navigate = useNavigate();
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
};
const handleAddMacro = async (macro: Partial<KeySequence>) => {
setIsSaving(true);
try {
const newMacro: KeySequence = {
id: generateMacroId(),
name: macro.name!.trim(),
steps: macro.steps || [],
sortOrder: macros.length + 1,
};
await saveMacros(normalizeSortOrders([...macros, newMacro]));
notifications.success(`Macro "${newMacro.name}" created successfully`);
navigate("../");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to create macro: ${error.message}`);
} else {
notifications.error("Failed to create macro");
}
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="Add New Macro"
description="Create a new keyboard macro"
/>
<MacroForm
initialData={{
name: "",
steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
}}
onSubmit={handleAddMacro}
onCancel={() => navigate("../")}
isSubmitting={isSaving}
/>
</div>
);
}

View File

@ -0,0 +1,108 @@
import { useNavigate, useParams } from "react-router-dom";
import { useState, useEffect } from "react";
import { KeySequence, useMacrosStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { MacroForm } from "@/components/MacroForm";
import notifications from "@/notifications";
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
};
export default function SettingsMacrosEditRoute() {
const { macros, saveMacros } = useMacrosStore();
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const navigate = useNavigate();
const { macroId } = useParams<{ macroId: string }>();
const [macro, setMacro] = useState<KeySequence | null>(null);
useEffect(() => {
const foundMacro = macros.find(m => m.id === macroId);
if (foundMacro) {
setMacro({
...foundMacro,
steps: foundMacro.steps.map(step => ({
...step,
keys: Array.isArray(step.keys) ? step.keys : [],
modifiers: Array.isArray(step.modifiers) ? step.modifiers : [],
delay: typeof step.delay === 'number' ? step.delay : 0
}))
});
} else {
navigate("../");
}
}, [macroId, macros, navigate]);
const handleUpdateMacro = async (updatedMacro: Partial<KeySequence>) => {
if (!macro) return;
setIsUpdating(true);
try {
const newMacros = macros.map(m =>
m.id === macro.id ? {
...macro,
name: updatedMacro.name!.trim(),
steps: updatedMacro.steps || [],
} : m
);
await saveMacros(normalizeSortOrders(newMacros));
notifications.success(`Macro "${updatedMacro.name}" updated successfully`);
navigate("../");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to update macro: ${error.message}`);
} else {
notifications.error("Failed to update macro");
}
} finally {
setIsUpdating(false);
}
};
const handleDeleteMacro = async () => {
if (!macro) return;
setIsDeleting(true);
try {
const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id));
await saveMacros(updatedMacros);
notifications.success(`Macro "${macro.name}" deleted successfully`);
navigate("../macros");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to delete macro: ${error.message}`);
} else {
notifications.error("Failed to delete macro");
}
} finally {
setIsDeleting(false);
}
};
if (!macro) return null;
return (
<div className="space-y-4">
<SettingsPageHeader
title="Edit Macro"
description="Modify your keyboard macro"
/>
<MacroForm
initialData={macro}
onSubmit={handleUpdateMacro}
onCancel={() => navigate("../")}
isSubmitting={isUpdating}
submitText="Save Changes"
showDelete
onDelete={handleDeleteMacro}
isDeleting={isDeleting}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff