mirror of https://github.com/jetkvm/kvm.git
1152 lines
44 KiB
TypeScript
1152 lines
44 KiB
TypeScript
import { useState, useEffect, useCallback, Fragment } from "react";
|
|
import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu";
|
|
|
|
import { KeySequence, useMacrosStore } from "@/hooks/stores";
|
|
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
|
import { Button } from "@/components/Button";
|
|
import Checkbox from "@/components/Checkbox";
|
|
import { keys, modifiers, keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
|
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
|
import notifications from "@/notifications";
|
|
import { SettingsItem } from "@/routes/devices.$id.settings";
|
|
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
|
import Fieldset from "@/components/Fieldset";
|
|
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
|
import EmptyCard from "@/components/EmptyCard";
|
|
import { Combobox } from "@/components/Combobox";
|
|
import { CardHeader } from "@/components/CardHeader";
|
|
import Card from "@/components/Card";
|
|
import { SortableList } from "@/components/SortableList";
|
|
|
|
const DEFAULT_DELAY = 50;
|
|
|
|
interface MacroStep {
|
|
keys: string[];
|
|
modifiers: string[];
|
|
delay: number;
|
|
}
|
|
|
|
interface KeyOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
interface KeyOptionData {
|
|
value: string | null;
|
|
keys?: string[];
|
|
label?: string;
|
|
}
|
|
|
|
const generateId = () => {
|
|
return `macro-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
};
|
|
|
|
// Filter out modifier keys since they're handled in the modifiers section
|
|
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
|
|
|
|
const keyOptions = Object.keys(keys)
|
|
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
|
|
.map(key => ({
|
|
value: key,
|
|
label: keyDisplayMap[key] || key,
|
|
}));
|
|
|
|
const modifierOptions = Object.keys(modifiers).map(modifier => ({
|
|
value: modifier,
|
|
label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
|
|
}));
|
|
|
|
const groupedModifiers = {
|
|
Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
|
|
Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
|
|
Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
|
|
Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
|
|
};
|
|
|
|
|
|
const PRESET_DELAYS = [
|
|
{ value: "50", label: "50ms" },
|
|
{ value: "100", label: "100ms" },
|
|
{ value: "200", label: "200ms" },
|
|
{ value: "300", label: "300ms" },
|
|
{ value: "500", label: "500ms" },
|
|
{ value: "750", label: "750ms" },
|
|
{ value: "1000", label: "1000ms" },
|
|
{ value: "1500", label: "1500ms" },
|
|
{ value: "2000", label: "2000ms" },
|
|
];
|
|
|
|
const MAX_STEPS_PER_MACRO = 10;
|
|
const MAX_TOTAL_MACROS = 25;
|
|
const MAX_KEYS_PER_STEP = 10;
|
|
|
|
const ensureArray = <T,>(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 (
|
|
<div className="macro-step-card rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-4 shadow-sm">
|
|
<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="macro-step-number 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}
|
|
LeadingIcon={LuTrash}
|
|
/>
|
|
)}
|
|
</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="macro-modifiers-container 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'
|
|
}`}
|
|
>
|
|
<Checkbox
|
|
className="sr-only"
|
|
size="SM"
|
|
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">
|
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
Keys:
|
|
</label>
|
|
|
|
<div className="macro-key-group 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<KeyOption>
|
|
onChange={(value: KeyOption) => onKeySelect(value)}
|
|
displayValue={() => keyQuery}
|
|
onInputChange={onKeyQueryChange}
|
|
options={getFilteredKeys}
|
|
disabledMessage="Max keys reached"
|
|
size="SM"
|
|
disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
|
|
placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
|
|
emptyMessage="No matching keys found"
|
|
/>
|
|
</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">
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Helper to update step keys used by both new and edit flows
|
|
const updateStepKeys = (
|
|
steps: MacroStep[],
|
|
stepIndex: number,
|
|
keyOption: { value: string | null; keys?: string[] },
|
|
showTemporaryError: (msg: string) => void
|
|
) => {
|
|
const newSteps = [...steps];
|
|
|
|
// Check if the step at stepIndex exists
|
|
if (!newSteps[stepIndex]) {
|
|
console.error(`Step at index ${stepIndex} does not exist`);
|
|
return steps; // Return original steps to avoid mutation
|
|
}
|
|
|
|
if (keyOption.keys) {
|
|
newSteps[stepIndex].keys = keyOption.keys;
|
|
} else if (keyOption.value) {
|
|
// Initialize keys array if it doesn't exist
|
|
if (!newSteps[stepIndex].keys) {
|
|
newSteps[stepIndex].keys = [];
|
|
}
|
|
const keysArray = ensureArray(newSteps[stepIndex].keys);
|
|
if (keysArray.length >= MAX_KEYS_PER_STEP) {
|
|
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
|
|
return newSteps;
|
|
}
|
|
newSteps[stepIndex].keys = [...keysArray, keyOption.value];
|
|
}
|
|
return newSteps;
|
|
};
|
|
|
|
interface StepError {
|
|
keys?: string;
|
|
modifiers?: string;
|
|
delay?: string;
|
|
}
|
|
|
|
interface ValidationErrors {
|
|
name?: string;
|
|
description?: string;
|
|
steps?: Record<number, StepError>;
|
|
}
|
|
|
|
export default function SettingsMacrosRoute() {
|
|
const { macros, loading, initialized, loadMacros, saveMacros, setSendFn } = useMacrosStore();
|
|
const [editingMacro, setEditingMacro] = useState<KeySequence | null>(null);
|
|
const [newMacro, setNewMacro] = useState<Partial<KeySequence>>({
|
|
name: "",
|
|
description: "",
|
|
steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
|
|
});
|
|
const [macroToDelete, setMacroToDelete] = useState<string | null>(null);
|
|
|
|
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
|
|
const [editKeyQueries, setEditKeyQueries] = useState<Record<number, string>>({});
|
|
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 768);
|
|
|
|
const [send] = useJsonRpc();
|
|
|
|
const isMaxMacrosReached = macros.length >= MAX_TOTAL_MACROS;
|
|
const isMaxStepsReachedForNewMacro = (newMacro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
|
|
|
|
const showTemporaryError = useCallback((message: string) => {
|
|
setErrorMessage(message);
|
|
setTimeout(() => setErrorMessage(null), 3000);
|
|
}, []);
|
|
|
|
// Helper for both new and edit key select
|
|
const handleKeySelectUpdate = (stepIndex: number, option: KeyOptionData, isEditing = false) => {
|
|
if (isEditing && editingMacro) {
|
|
const updatedSteps = updateStepKeys(editingMacro.steps, stepIndex, option, showTemporaryError);
|
|
setEditingMacro({ ...editingMacro, steps: updatedSteps });
|
|
} else {
|
|
const updatedSteps = updateStepKeys(newMacro.steps || [], stepIndex, option, showTemporaryError);
|
|
setNewMacro({ ...newMacro, steps: updatedSteps });
|
|
}
|
|
};
|
|
|
|
const handleKeySelect = (stepIndex: number, option: KeyOptionData) => {
|
|
handleKeySelectUpdate(stepIndex, option, false);
|
|
};
|
|
|
|
const handleEditKeySelect = (stepIndex: number, option: KeyOptionData) => {
|
|
handleKeySelectUpdate(stepIndex, option, true);
|
|
};
|
|
|
|
const handleKeyQueryChange = (stepIndex: number, query: string) => {
|
|
setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
|
|
};
|
|
|
|
const handleEditKeyQueryChange = (stepIndex: number, query: string) => {
|
|
setEditKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
|
|
};
|
|
|
|
const getFilteredKeys = (stepIndex: number, isEditing = false) => {
|
|
const query = isEditing
|
|
? (editKeyQueries[stepIndex] || '')
|
|
: (keyQueries[stepIndex] || '');
|
|
|
|
const currentStep = isEditing
|
|
? editingMacro?.steps[stepIndex]
|
|
: newMacro.steps?.[stepIndex];
|
|
|
|
const selectedKeys = ensureArray(currentStep?.keys);
|
|
|
|
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
|
|
|
|
if (query === '') {
|
|
return availableKeys;
|
|
} else {
|
|
return availableKeys.filter(option => option.label.toLowerCase().includes(query.toLowerCase()));
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
setSendFn(send);
|
|
if (!initialized) {
|
|
loadMacros();
|
|
}
|
|
}, [initialized, loadMacros, setSendFn, send]);
|
|
|
|
const [errors, setErrors] = useState<ValidationErrors>({});
|
|
|
|
const clearErrors = useCallback(() => {
|
|
setErrors({});
|
|
}, []);
|
|
|
|
const validateMacro = (macro: Partial<KeySequence>): 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<number, StepError> = {};
|
|
|
|
macro.steps.forEach((step, index) => {
|
|
const stepError: StepError = {};
|
|
|
|
// Keys validation (only if keys are present)
|
|
if (step.keys?.length && step.keys.length > MAX_KEYS_PER_STEP) {
|
|
stepError.keys = `Maximum ${MAX_KEYS_PER_STEP} keys allowed`;
|
|
}
|
|
|
|
// Delay validation
|
|
if (typeof step.delay !== 'number' || step.delay < 0) {
|
|
stepError.delay = "Invalid delay value";
|
|
}
|
|
|
|
if (Object.keys(stepError).length > 0) {
|
|
stepErrors[index] = stepError;
|
|
}
|
|
});
|
|
|
|
if (Object.keys(stepErrors).length > 0) {
|
|
errors.steps = stepErrors;
|
|
}
|
|
|
|
return errors;
|
|
};
|
|
|
|
const resetNewMacro = () => {
|
|
setNewMacro({
|
|
name: "",
|
|
description: "",
|
|
steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
|
|
});
|
|
setKeyQueries({});
|
|
setErrors({});
|
|
};
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isUpdating, setIsUpdating] = useState(false);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
const handleAddMacro = useCallback(async () => {
|
|
if (isMaxMacrosReached) {
|
|
showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
|
|
return;
|
|
}
|
|
|
|
const validationErrors = validateMacro(newMacro);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
setErrors(validationErrors);
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
const macro: KeySequence = {
|
|
id: generateId(),
|
|
name: newMacro.name!.trim(),
|
|
description: newMacro.description?.trim() || "",
|
|
steps: newMacro.steps || [],
|
|
sortOrder: macros.length + 1,
|
|
};
|
|
|
|
await saveMacros(normalizeSortOrders([...macros, macro]));
|
|
resetNewMacro();
|
|
setShowAddMacro(false);
|
|
notifications.success(`Macro "${macro.name}" created successfully`);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
notifications.error(`Failed to create macro: ${error.message}`);
|
|
showTemporaryError(error.message);
|
|
} else {
|
|
notifications.error("Failed to create macro");
|
|
showTemporaryError("Failed to save macro");
|
|
}
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]);
|
|
|
|
const handleEditMacro = (macro: KeySequence) => {
|
|
setEditingMacro({
|
|
...macro,
|
|
description: macro.description || "",
|
|
steps: macro.steps.map(step => ({
|
|
...step,
|
|
keys: ensureArray(step.keys),
|
|
modifiers: ensureArray(step.modifiers),
|
|
delay: typeof step.delay === 'number' ? step.delay : DEFAULT_DELAY
|
|
}))
|
|
});
|
|
clearErrors();
|
|
setEditKeyQueries({});
|
|
};
|
|
|
|
const handleDeleteMacro = async (id: string) => {
|
|
const macroToBeDeleted = macros.find(m => m.id === id);
|
|
if (!macroToBeDeleted) return;
|
|
|
|
setIsDeleting(true);
|
|
try {
|
|
const updatedMacros = normalizeSortOrders(macros.filter(macro => macro.id !== id));
|
|
await saveMacros(updatedMacros);
|
|
if (editingMacro?.id === id) {
|
|
setEditingMacro(null);
|
|
}
|
|
setMacroToDelete(null);
|
|
notifications.success(`Macro "${macroToBeDeleted.name}" deleted successfully`);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
notifications.error(`Failed to delete macro: ${error.message}`);
|
|
showTemporaryError(error.message);
|
|
} else {
|
|
notifications.error("Failed to delete macro");
|
|
showTemporaryError("Failed to delete macro");
|
|
}
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleDuplicateMacro = async (macro: KeySequence) => {
|
|
if (isMaxMacrosReached) {
|
|
showTemporaryError(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
|
|
return;
|
|
}
|
|
|
|
const newMacroCopy: KeySequence = {
|
|
...JSON.parse(JSON.stringify(macro)),
|
|
id: generateId(),
|
|
name: `${macro.name} (copy)`,
|
|
sortOrder: macros.length + 1,
|
|
};
|
|
|
|
newMacroCopy.steps = newMacroCopy.steps.map(step => ({
|
|
...step,
|
|
keys: ensureArray(step.keys),
|
|
modifiers: ensureArray(step.modifiers),
|
|
delay: step.delay || DEFAULT_DELAY
|
|
}));
|
|
|
|
try {
|
|
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
|
|
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
notifications.error(`Failed to duplicate macro: ${error.message}`);
|
|
showTemporaryError(error.message);
|
|
} else {
|
|
notifications.error("Failed to duplicate macro");
|
|
showTemporaryError("Failed to duplicate macro");
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleStepMove = (stepIndex: number, direction: 'up' | 'down', steps: MacroStep[]) => {
|
|
const newSteps = [...steps];
|
|
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
|
|
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
|
|
return newSteps;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
setIsDesktop(window.innerWidth >= 768);
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
return () => window.removeEventListener('resize', handleResize);
|
|
}, []);
|
|
|
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
|
const [showAddMacro, setShowAddMacro] = useState(false);
|
|
|
|
const handleUpdateMacro = useCallback(async () => {
|
|
if (!editingMacro) return;
|
|
|
|
const validationErrors = validateMacro(editingMacro);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
setErrors(validationErrors);
|
|
return;
|
|
}
|
|
|
|
setIsUpdating(true);
|
|
try {
|
|
const newMacros = macros.map(m =>
|
|
m.id === editingMacro.id ? {
|
|
...editingMacro,
|
|
name: editingMacro.name.trim(),
|
|
description: editingMacro.description?.trim() || "",
|
|
} : m
|
|
);
|
|
|
|
await saveMacros(normalizeSortOrders(newMacros));
|
|
setEditingMacro(null);
|
|
clearErrors();
|
|
notifications.success(`Macro "${editingMacro.name}" updated successfully`);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
notifications.error(`Failed to update macro: ${error.message}`);
|
|
showTemporaryError(error.message);
|
|
} else {
|
|
notifications.error("Failed to update macro");
|
|
showTemporaryError("Failed to update macro");
|
|
}
|
|
} finally {
|
|
setIsUpdating(false);
|
|
}
|
|
}, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && editingMacro) {
|
|
setEditingMacro(null);
|
|
setErrors({});
|
|
}
|
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
if (editingMacro) {
|
|
handleUpdateMacro();
|
|
} else if (!isMaxMacrosReached) {
|
|
handleAddMacro();
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [editingMacro, isMaxMacrosReached, handleAddMacro, handleUpdateMacro]);
|
|
|
|
const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
|
|
if (editingMacro) {
|
|
const newSteps = [...editingMacro.steps];
|
|
newSteps[stepIndex].modifiers = modifiers;
|
|
setEditingMacro({ ...editingMacro, steps: newSteps });
|
|
} else {
|
|
const newSteps = [...(newMacro.steps || [])];
|
|
newSteps[stepIndex].modifiers = modifiers;
|
|
setNewMacro({ ...newMacro, steps: newSteps });
|
|
}
|
|
};
|
|
|
|
const handleDelayChange = (stepIndex: number, delay: number) => {
|
|
if (editingMacro) {
|
|
const newSteps = [...editingMacro.steps];
|
|
newSteps[stepIndex].delay = delay;
|
|
setEditingMacro({ ...editingMacro, steps: newSteps });
|
|
} else {
|
|
const newSteps = [...(newMacro.steps || [])];
|
|
newSteps[stepIndex].delay = delay;
|
|
setNewMacro({ ...newMacro, steps: newSteps });
|
|
}
|
|
};
|
|
|
|
const ErrorMessage = ({ error }: { error?: string }) => {
|
|
if (!error) return null;
|
|
return (
|
|
<FieldError error={error} />
|
|
);
|
|
};
|
|
return (
|
|
<div className="space-y-4">
|
|
<SettingsPageHeader
|
|
title="Keyboard Macros"
|
|
description="Create and manage keyboard macros for quick actions"
|
|
/>
|
|
{macros.length > 0 && (
|
|
<div className="flex items-center justify-between mb-4">
|
|
<SettingsItem
|
|
title="Macros"
|
|
description={`${macros.length}/${MAX_TOTAL_MACROS}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{!showAddMacro && (
|
|
<Button
|
|
size="SM"
|
|
theme="primary"
|
|
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
|
|
onClick={() => setShowAddMacro(true)}
|
|
disabled={isMaxMacrosReached}
|
|
/>
|
|
)}
|
|
</div>
|
|
</SettingsItem>
|
|
</div>
|
|
)}
|
|
|
|
{errorMessage && (<FieldError error={errorMessage} />)}
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center p-8">
|
|
<LuLoader className="h-6 w-6 animate-spin text-blue-500" />
|
|
</div>
|
|
)}
|
|
<div className={`space-y-4 ${loading ? 'hidden' : ''}`}>
|
|
{showAddMacro && (
|
|
<Card className="p-3">
|
|
<CardHeader
|
|
headline="Add New Macro"
|
|
/>
|
|
<Fieldset className="mt-4">
|
|
<InputFieldWithLabel
|
|
type="text"
|
|
label="Macro Name"
|
|
placeholder="Macro Name"
|
|
value={newMacro.name}
|
|
error={errors.name}
|
|
onChange={e => {
|
|
setNewMacro(prev => ({ ...prev, name: e.target.value }));
|
|
if (errors.name) {
|
|
const newErrors = { ...errors };
|
|
delete newErrors.name;
|
|
setErrors(newErrors);
|
|
}
|
|
}}
|
|
/>
|
|
</Fieldset>
|
|
|
|
<div className="mt-4">
|
|
<div className="macro-section-header">
|
|
<label className="macro-section-title">
|
|
Steps:
|
|
</label>
|
|
<span className="macro-section-subtitle">
|
|
{newMacro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
|
|
</span>
|
|
</div>
|
|
{errors.steps && errors.steps[0]?.keys && (
|
|
<div className="mt-2">
|
|
<ErrorMessage error={errors.steps[0].keys} />
|
|
</div>
|
|
)}
|
|
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
|
You can add up to {MAX_STEPS_PER_MACRO} steps per macro
|
|
</div>
|
|
<Fieldset>
|
|
<div className="mt-2 space-y-4">
|
|
{(newMacro.steps || []).map((step, stepIndex) => (
|
|
<MacroStepCard
|
|
key={stepIndex}
|
|
step={step}
|
|
stepIndex={stepIndex}
|
|
onDelete={newMacro.steps && newMacro.steps.length > 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}
|
|
/>
|
|
))}
|
|
</div>
|
|
</Fieldset>
|
|
|
|
<div className="mt-4 border-t border-slate-200 pt-4 dark:border-slate-700">
|
|
<Button
|
|
size="MD"
|
|
theme="light"
|
|
fullWidth
|
|
LeadingIcon={LuPlus}
|
|
text={`Add Step ${isMaxStepsReachedForNewMacro ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
|
|
onClick={() => {
|
|
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}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-6 flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-700">
|
|
{showClearConfirm ? (
|
|
<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={() => {
|
|
resetNewMacro();
|
|
setShowAddMacro(false);
|
|
setShowClearConfirm(false);
|
|
}}
|
|
/>
|
|
<Button
|
|
size="SM"
|
|
theme="light"
|
|
text="No"
|
|
onClick={() => setShowClearConfirm(false)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-x-2">
|
|
<Button
|
|
size="SM"
|
|
theme="primary"
|
|
text={isSaving ? "Saving..." : "Save Macro"}
|
|
onClick={handleAddMacro}
|
|
disabled={isSaving}
|
|
/>
|
|
<Button
|
|
size="SM"
|
|
theme="light"
|
|
text="Cancel"
|
|
onClick={() => {
|
|
if (newMacro.name || newMacro.description || newMacro.steps?.some(s => s.keys?.length || s.modifiers?.length)) {
|
|
setShowClearConfirm(true);
|
|
} else {
|
|
resetNewMacro();
|
|
setShowAddMacro(false);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
{macros.length === 0 && !showAddMacro && (
|
|
<EmptyCard
|
|
headline="No macros created yet"
|
|
BtnElm={
|
|
<Button
|
|
size="SM"
|
|
theme="primary"
|
|
text="Add New Macro"
|
|
onClick={() => setShowAddMacro(true)}
|
|
disabled={isMaxMacrosReached}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
{macros.length > 0 && (
|
|
<SortableList<KeySequence>
|
|
keyFn={(macro) => macro.id}
|
|
items={macros}
|
|
itemClassName="rounded-md border border-slate-200 p-2 dark:border-slate-700 bg-white dark:bg-slate-800"
|
|
onSort={async (newMacros) => {
|
|
const updatedMacros = normalizeSortOrders(newMacros);
|
|
try {
|
|
await saveMacros(updatedMacros);
|
|
notifications.success("Macro order updated successfully");
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
notifications.error(`Failed to reorder macros: ${error.message}`);
|
|
showTemporaryError(error.message);
|
|
} else {
|
|
notifications.error("Failed to reorder macros");
|
|
showTemporaryError("Failed to save reordered macros");
|
|
}
|
|
}
|
|
}}
|
|
disabled={!!editingMacro}
|
|
variant="list"
|
|
size="XS"
|
|
handlePosition="left"
|
|
>
|
|
{(macro) => (
|
|
editingMacro && editingMacro.id === macro.id ? (
|
|
<Card className="border-blue-300 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20">
|
|
<CardHeader
|
|
headline="Edit Macro"
|
|
/>
|
|
<Fieldset className="mt-4">
|
|
<InputFieldWithLabel
|
|
type="text"
|
|
label="Macro Name"
|
|
placeholder="Macro Name"
|
|
value={editingMacro.name}
|
|
error={errors.name}
|
|
onChange={e => {
|
|
setEditingMacro({ ...editingMacro, name: e.target.value });
|
|
if (errors.name) {
|
|
const newErrors = { ...errors };
|
|
delete newErrors.name;
|
|
setErrors(newErrors);
|
|
}
|
|
}}
|
|
/>
|
|
</Fieldset>
|
|
|
|
<div className="mt-4">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
Steps:
|
|
</label>
|
|
<span className="text-sm text-slate-500 dark:text-slate-400">
|
|
{editingMacro.steps.length}/{MAX_STEPS_PER_MACRO} steps
|
|
</span>
|
|
</div>
|
|
{errors.steps && errors.steps[0]?.keys && (
|
|
<div className="mt-2">
|
|
<ErrorMessage error={errors.steps[0].keys} />
|
|
</div>
|
|
)}
|
|
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
|
You can add up to {MAX_STEPS_PER_MACRO} steps per macro
|
|
</div>
|
|
<Fieldset>
|
|
<div className="mt-2 space-y-4">
|
|
{editingMacro.steps.map((step, stepIndex) => (
|
|
<MacroStepCard
|
|
key={stepIndex}
|
|
step={step}
|
|
stepIndex={stepIndex}
|
|
onDelete={editingMacro.steps.length > 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}
|
|
/>
|
|
))}
|
|
</div>
|
|
</Fieldset>
|
|
|
|
<div className="mt-4 border-t border-slate-200 pt-4 dark:border-slate-700">
|
|
<Button
|
|
size="MD"
|
|
theme="light"
|
|
fullWidth
|
|
LeadingIcon={LuPlus}
|
|
text={`Add Step ${editingMacro.steps.length >= MAX_STEPS_PER_MACRO ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
|
|
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}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-700">
|
|
<div className="flex gap-x-2">
|
|
<Button
|
|
size="SM"
|
|
theme="primary"
|
|
text={isUpdating ? "Saving..." : "Save Changes"}
|
|
onClick={handleUpdateMacro}
|
|
disabled={isUpdating}
|
|
/>
|
|
<Button
|
|
size="SM"
|
|
theme="light"
|
|
text="Cancel"
|
|
onClick={() => {
|
|
setEditingMacro(null);
|
|
setErrors({});
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
) : (
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
|
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
|
|
{macro.name}
|
|
</h3>
|
|
<p className="mt-1 ml-2 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
|
|
<span className="flex flex-col items-start gap-1">
|
|
{macro.steps.map((step, stepIndex) => {
|
|
const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
|
|
|
|
return (
|
|
<span key={stepIndex} className="inline-flex items-center">
|
|
<StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
|
|
<span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
|
|
{(ensureArray(step.modifiers).length > 0 || ensureArray(step.keys).length > 0) ? (
|
|
<>
|
|
{ensureArray(step.modifiers).map((modifier, idx) => (
|
|
<Fragment key={`mod-${idx}`}>
|
|
<span className="font-medium text-slate-600 dark:text-slate-200">
|
|
{modifierDisplayMap[modifier] || modifier}
|
|
</span>
|
|
{idx < ensureArray(step.modifiers).length - 1 && (
|
|
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
|
|
{ensureArray(step.modifiers).length > 0 && ensureArray(step.keys).length > 0 && (
|
|
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
|
)}
|
|
|
|
{ensureArray(step.keys).map((key, idx) => (
|
|
<Fragment key={`key-${idx}`}>
|
|
<span className="font-medium text-blue-600 dark:text-blue-200">
|
|
{keyDisplayMap[key] || key}
|
|
</span>
|
|
{idx < ensureArray(step.keys).length - 1 && (
|
|
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</>
|
|
) : (
|
|
<span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
|
|
)}
|
|
<span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
|
|
</span>
|
|
</span>
|
|
);
|
|
})}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 ml-4">
|
|
{macroToDelete === macro.id ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
|
Delete macro?
|
|
</span>
|
|
<div className="flex items-center gap-x-2">
|
|
<Button
|
|
size="XS"
|
|
theme="danger"
|
|
text="Yes"
|
|
onClick={() => {
|
|
handleDeleteMacro(macro.id);
|
|
}}
|
|
disabled={isDeleting}
|
|
/>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
text="No"
|
|
onClick={() => setMacroToDelete(null)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
LeadingIcon={LuPenLine}
|
|
onClick={() => handleEditMacro(macro)}
|
|
/>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
LeadingIcon={LuCopy}
|
|
onClick={() => handleDuplicateMacro(macro)}
|
|
/>
|
|
<Button
|
|
size="XS"
|
|
theme="light"
|
|
LeadingIcon={LuTrash}
|
|
onClick={() => setMacroToDelete(macro.id)}
|
|
className="text-red-500 dark:text-red-400"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
)}
|
|
</SortableList>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|