mirror of https://github.com/jetkvm/kvm.git
Add keyboard macros (#305)
* add jsonrpc keyboard macro get/set * add ui keyboard macros settings and macro bar * use notifications component and handle jsonrpc errors * cleanup settings menu * return error rather than truncate steps in validation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(ui): add className prop to Checkbox component to allow custom styling * use existing components and CTA * extract display key mappings * create generic combobox component * remove macro description * cleanup styles and macro list * create sortable list component * split up macro routes * remove sortable list and simplify * cleanup macrobar * use and add info to fieldlabel * add useCallback optimizations * add confirm dialog component * cleanup delete buttons * revert info on field label * cleanup combobox focus * cleanup icons * set default label for delay --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
76efa56083
commit
8f6e64fd9c
60
config.go
60
config.go
|
@ -14,6 +14,64 @@ type WakeOnLanDevice struct {
|
|||
MacAddress string `json:"macAddress"`
|
||||
}
|
||||
|
||||
// Constants for keyboard macro limits
|
||||
const (
|
||||
MaxMacrosPerDevice = 25
|
||||
MaxStepsPerMacro = 10
|
||||
MaxKeysPerStep = 10
|
||||
MinStepDelay = 50
|
||||
MaxStepDelay = 2000
|
||||
)
|
||||
|
||||
type KeyboardMacroStep struct {
|
||||
Keys []string `json:"keys"`
|
||||
Modifiers []string `json:"modifiers"`
|
||||
Delay int `json:"delay"`
|
||||
}
|
||||
|
||||
func (s *KeyboardMacroStep) Validate() error {
|
||||
if len(s.Keys) > MaxKeysPerStep {
|
||||
return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep)
|
||||
}
|
||||
|
||||
if s.Delay < MinStepDelay {
|
||||
s.Delay = MinStepDelay
|
||||
} else if s.Delay > MaxStepDelay {
|
||||
s.Delay = MaxStepDelay
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type KeyboardMacro struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Steps []KeyboardMacroStep `json:"steps"`
|
||||
SortOrder int `json:"sortOrder,omitempty"`
|
||||
}
|
||||
|
||||
func (m *KeyboardMacro) Validate() error {
|
||||
if m.Name == "" {
|
||||
return fmt.Errorf("macro name cannot be empty")
|
||||
}
|
||||
|
||||
if len(m.Steps) == 0 {
|
||||
return fmt.Errorf("macro must have at least one step")
|
||||
}
|
||||
|
||||
if len(m.Steps) > MaxStepsPerMacro {
|
||||
return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro)
|
||||
}
|
||||
|
||||
for i := range m.Steps {
|
||||
if err := m.Steps[i].Validate(); err != nil {
|
||||
return fmt.Errorf("invalid step %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
CloudURL string `json:"cloud_url"`
|
||||
CloudAppURL string `json:"cloud_app_url"`
|
||||
|
@ -26,6 +84,7 @@ type Config struct {
|
|||
LocalAuthToken string `json:"local_auth_token"`
|
||||
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||
EdidString string `json:"hdmi_edid_string"`
|
||||
ActiveExtension string `json:"active_extension"`
|
||||
DisplayMaxBrightness int `json:"display_max_brightness"`
|
||||
|
@ -43,6 +102,7 @@ var defaultConfig = &Config{
|
|||
CloudAppURL: "https://app.jetkvm.com",
|
||||
AutoUpdateEnabled: true, // Set a default value
|
||||
ActiveExtension: "",
|
||||
KeyboardMacros: []KeyboardMacro{},
|
||||
DisplayMaxBrightness: 64,
|
||||
DisplayDimAfterSec: 120, // 2 minutes
|
||||
DisplayOffAfterSec: 1800, // 30 minutes
|
||||
|
|
95
jsonrpc.go
95
jsonrpc.go
|
@ -797,6 +797,99 @@ func rpcSetScrollSensitivity(sensitivity string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func getKeyboardMacros() (interface{}, error) {
|
||||
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
|
||||
copy(macros, config.KeyboardMacros)
|
||||
|
||||
return macros, nil
|
||||
}
|
||||
|
||||
type KeyboardMacrosParams struct {
|
||||
Macros []interface{} `json:"macros"`
|
||||
}
|
||||
|
||||
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
||||
if params.Macros == nil {
|
||||
return nil, fmt.Errorf("missing or invalid macros parameter")
|
||||
}
|
||||
|
||||
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
|
||||
|
||||
for i, item := range params.Macros {
|
||||
macroMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid macro at index %d", i)
|
||||
}
|
||||
|
||||
id, _ := macroMap["id"].(string)
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("macro-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
name, _ := macroMap["name"].(string)
|
||||
|
||||
sortOrder := i + 1
|
||||
if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok {
|
||||
sortOrder = int(sortOrderFloat)
|
||||
}
|
||||
|
||||
steps := []KeyboardMacroStep{}
|
||||
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
|
||||
for _, stepItem := range stepsArray {
|
||||
stepMap, ok := stepItem.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
step := KeyboardMacroStep{}
|
||||
|
||||
if keysArray, ok := stepMap["keys"].([]interface{}); ok {
|
||||
for _, k := range keysArray {
|
||||
if keyStr, ok := k.(string); ok {
|
||||
step.Keys = append(step.Keys, keyStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
|
||||
for _, m := range modsArray {
|
||||
if modStr, ok := m.(string); ok {
|
||||
step.Modifiers = append(step.Modifiers, modStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if delay, ok := stepMap["delay"].(float64); ok {
|
||||
step.Delay = int(delay)
|
||||
}
|
||||
|
||||
steps = append(steps, step)
|
||||
}
|
||||
}
|
||||
|
||||
macro := KeyboardMacro{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Steps: steps,
|
||||
SortOrder: sortOrder,
|
||||
}
|
||||
|
||||
if err := macro.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid macro at index %d: %w", i, err)
|
||||
}
|
||||
|
||||
newMacros = append(newMacros, macro)
|
||||
}
|
||||
|
||||
config.KeyboardMacros = newMacros
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rpcHandlers = map[string]RPCHandler{
|
||||
"ping": {Func: rpcPing},
|
||||
"getDeviceID": {Func: rpcGetDeviceID},
|
||||
|
@ -862,4 +955,6 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
||||
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
|
||||
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
|
||||
"getKeyboardMacros": {Func: getKeyboardMacros},
|
||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||
}
|
||||
|
|
|
@ -37,11 +37,11 @@ type CheckBoxProps = {
|
|||
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
|
||||
|
||||
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
|
||||
{ size = "MD", ...props },
|
||||
{ size = "MD", className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const classes = checkboxVariants({ size });
|
||||
return <input ref={ref} {...props} type="checkbox" className={classes} />;
|
||||
return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />;
|
||||
});
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
import { useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
|
||||
import { cva } from "@/cva.config";
|
||||
import Card from "./Card";
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
||||
SM: "h-[32px] pl-3 pr-8 text-[13px]",
|
||||
MD: "h-[40px] pl-4 pr-10 text-sm",
|
||||
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
|
||||
} as const;
|
||||
|
||||
const comboboxVariants = cva({
|
||||
variants: { size: sizes },
|
||||
});
|
||||
|
||||
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
|
||||
|
||||
interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
|
||||
displayValue: (option: ComboboxOption) => string;
|
||||
onInputChange: (option: string) => void;
|
||||
options: () => ComboboxOption[];
|
||||
placeholder?: string;
|
||||
emptyMessage?: string;
|
||||
size?: keyof typeof sizes;
|
||||
disabledMessage?: string;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
onInputChange,
|
||||
displayValue,
|
||||
options,
|
||||
disabled = false,
|
||||
placeholder = "Search...",
|
||||
emptyMessage = "No results found",
|
||||
size = "MD",
|
||||
onChange,
|
||||
disabledMessage = "Input disabled",
|
||||
...otherProps
|
||||
}: ComboboxProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const classes = comboboxVariants({ size });
|
||||
|
||||
return (
|
||||
<HeadlessCombobox
|
||||
onChange={onChange}
|
||||
{...otherProps}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
|
||||
<ComboboxInput
|
||||
ref={inputRef}
|
||||
className={clsx(
|
||||
classes,
|
||||
|
||||
// General styling
|
||||
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
||||
|
||||
// Hover
|
||||
"hover:bg-blue-50/80 active:bg-blue-100/60",
|
||||
|
||||
// Dark mode
|
||||
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
|
||||
|
||||
// Focus
|
||||
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
|
||||
|
||||
// Disabled
|
||||
disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
|
||||
)}
|
||||
placeholder={disabled ? disabledMessage : placeholder}
|
||||
displayValue={displayValue}
|
||||
onChange={(event) => onInputChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{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">
|
||||
{options().map((option) => (
|
||||
<ComboboxOption
|
||||
key={option.value}
|
||||
value={option}
|
||||
className={clsx(
|
||||
// General styling
|
||||
"cursor-default select-none py-2 px-4",
|
||||
|
||||
// Hover and active states
|
||||
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
|
||||
|
||||
// Dark mode
|
||||
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</ComboboxOptions>
|
||||
)}
|
||||
|
||||
{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="text-slate-500 dark:text-slate-400">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HeadlessCombobox>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { cx } from "@/cva.config";
|
||||
import { Button } from "@/components/Button";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
type Variant = "danger" | "success" | "warning" | "info";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
variant?: Variant;
|
||||
confirmText?: string;
|
||||
cancelText?: string | null;
|
||||
onConfirm: () => void;
|
||||
isConfirming?: boolean;
|
||||
}
|
||||
|
||||
const variantConfig = {
|
||||
danger: {
|
||||
icon: ExclamationTriangleIcon,
|
||||
iconClass: "text-red-600",
|
||||
iconBgClass: "bg-red-100",
|
||||
buttonTheme: "danger",
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircleIcon,
|
||||
iconClass: "text-green-600",
|
||||
iconBgClass: "bg-green-100",
|
||||
buttonTheme: "primary",
|
||||
},
|
||||
warning: {
|
||||
icon: ExclamationTriangleIcon,
|
||||
iconClass: "text-yellow-600",
|
||||
iconBgClass: "bg-yellow-100",
|
||||
buttonTheme: "lightDanger",
|
||||
},
|
||||
info: {
|
||||
icon: InformationCircleIcon,
|
||||
iconClass: "text-blue-600",
|
||||
iconBgClass: "bg-blue-100",
|
||||
buttonTheme: "primary",
|
||||
},
|
||||
} as Record<Variant, {
|
||||
icon: React.ElementType;
|
||||
iconClass: string;
|
||||
iconBgClass: string;
|
||||
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
|
||||
}>;
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
variant = "info",
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
onConfirm,
|
||||
isConfirming = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<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="sm:flex sm:items-start">
|
||||
<div className={cx("mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10", iconBgClass)}>
|
||||
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-x-2">
|
||||
{cancelText && (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text={cancelText}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="SM"
|
||||
theme={buttonTheme}
|
||||
text={isConfirming ? `${confirmText}...` : confirmText}
|
||||
onClick={onConfirm}
|
||||
disabled={isConfirming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
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();
|
||||
|
||||
useEffect(() => {
|
||||
setSendFn(send);
|
||||
|
||||
if (!initialized) {
|
||||
loadMacros();
|
||||
}
|
||||
}, [initialized, loadMacros, setSendFn, send]);
|
||||
|
||||
if (macros.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="bg-white dark:bg-slate-900 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
|
||||
<div className="flex items-center gap-x-2 py-1.5">
|
||||
<div className="absolute -ml-5">
|
||||
<LuCommand className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{macros.map(macro => (
|
||||
<Button
|
||||
key={macro.id}
|
||||
aria-label={macro.name}
|
||||
size="XS"
|
||||
theme="light"
|
||||
text={macro.name}
|
||||
onClick={() => executeMacro(macro.steps)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { LuPlus } 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 { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function MacroForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting = false,
|
||||
submitText = "Save Macro",
|
||||
}: 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 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">
|
||||
<FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} />
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,235 @@
|
|||
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } 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, DEFAULT_DELAY } from "@/constants/macros";
|
||||
import FieldLabel from "@/components/FieldLabel";
|
||||
|
||||
// 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 basePresetDelays = [
|
||||
{ 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 PRESET_DELAYS = basePresetDelays.map(delay => {
|
||||
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
|
||||
return { ...delay, label: "Default" };
|
||||
}
|
||||
return delay;
|
||||
});
|
||||
|
||||
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">
|
||||
<span className="flex h-6 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">
|
||||
<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>
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
className="text-red-500 dark:text-red-400"
|
||||
text="Delete"
|
||||
LeadingIcon={LuTrash2}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mt-2">
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<FieldLabel label="Modifiers" />
|
||||
<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-4 pt-1">
|
||||
{mods.map(option => (
|
||||
<Button
|
||||
key={option.value}
|
||||
size="XS"
|
||||
theme={ensureArray(step.modifiers).includes(option.value) ? "primary" : "light"}
|
||||
text={option.label.split(' ')[1] || option.label}
|
||||
onClick={() => {
|
||||
const modifiersArray = ensureArray(step.modifiers);
|
||||
const isSelected = modifiersArray.includes(option.value);
|
||||
const newModifiers = isSelected
|
||||
? modifiersArray.filter(m => m !== option.value)
|
||||
: [...modifiersArray, option.value];
|
||||
onModifierChange(newModifiers);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
|
||||
</div>
|
||||
{ensureArray(step.keys) && step.keys.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pb-2">
|
||||
{step.keys.map((key, keyIndex) => (
|
||||
<span
|
||||
key={keyIndex}
|
||||
className="inline-flex items-center py-0.5 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);
|
||||
onKeyQueryChange('');
|
||||
}}
|
||||
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">
|
||||
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
|
||||
</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>
|
||||
);
|
||||
}
|
|
@ -11,7 +11,7 @@ import "react-simple-keyboard/build/css/index.css";
|
|||
|
||||
import { useHidStore, useUiStore } from "@/hooks/stores";
|
||||
import { cx } from "@/cva.config";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
import DetachIconRaw from "@/assets/detach-icon.svg";
|
||||
import AttachIconRaw from "@/assets/attach-icon.svg";
|
||||
|
@ -260,136 +260,7 @@ function KeyboardWrapper() {
|
|||
buttons: "CtrlAltDelete AltMetaEscape",
|
||||
},
|
||||
]}
|
||||
display={{
|
||||
CtrlAltDelete: "Ctrl + Alt + Delete",
|
||||
AltMetaEscape: "Alt + Meta + Escape",
|
||||
Escape: "esc",
|
||||
Tab: "tab",
|
||||
Backspace: "backspace",
|
||||
"(Backspace)": "backspace",
|
||||
Enter: "enter",
|
||||
CapsLock: "caps lock",
|
||||
ShiftLeft: "shift",
|
||||
ShiftRight: "shift",
|
||||
ControlLeft: "ctrl",
|
||||
AltLeft: "alt",
|
||||
AltRight: "alt",
|
||||
MetaLeft: "meta",
|
||||
MetaRight: "meta",
|
||||
KeyQ: "q",
|
||||
KeyW: "w",
|
||||
KeyE: "e",
|
||||
KeyR: "r",
|
||||
KeyT: "t",
|
||||
KeyY: "y",
|
||||
KeyU: "u",
|
||||
KeyI: "i",
|
||||
KeyO: "o",
|
||||
KeyP: "p",
|
||||
KeyA: "a",
|
||||
KeyS: "s",
|
||||
KeyD: "d",
|
||||
KeyF: "f",
|
||||
KeyG: "g",
|
||||
KeyH: "h",
|
||||
KeyJ: "j",
|
||||
KeyK: "k",
|
||||
KeyL: "l",
|
||||
KeyZ: "z",
|
||||
KeyX: "x",
|
||||
KeyC: "c",
|
||||
KeyV: "v",
|
||||
KeyB: "b",
|
||||
KeyN: "n",
|
||||
KeyM: "m",
|
||||
|
||||
"(KeyQ)": "Q",
|
||||
"(KeyW)": "W",
|
||||
"(KeyE)": "E",
|
||||
"(KeyR)": "R",
|
||||
"(KeyT)": "T",
|
||||
"(KeyY)": "Y",
|
||||
"(KeyU)": "U",
|
||||
"(KeyI)": "I",
|
||||
"(KeyO)": "O",
|
||||
"(KeyP)": "P",
|
||||
"(KeyA)": "A",
|
||||
"(KeyS)": "S",
|
||||
"(KeyD)": "D",
|
||||
"(KeyF)": "F",
|
||||
"(KeyG)": "G",
|
||||
"(KeyH)": "H",
|
||||
"(KeyJ)": "J",
|
||||
"(KeyK)": "K",
|
||||
"(KeyL)": "L",
|
||||
"(KeyZ)": "Z",
|
||||
"(KeyX)": "X",
|
||||
"(KeyC)": "C",
|
||||
"(KeyV)": "V",
|
||||
"(KeyB)": "B",
|
||||
"(KeyN)": "N",
|
||||
"(KeyM)": "M",
|
||||
Digit1: "1",
|
||||
Digit2: "2",
|
||||
Digit3: "3",
|
||||
Digit4: "4",
|
||||
Digit5: "5",
|
||||
Digit6: "6",
|
||||
Digit7: "7",
|
||||
Digit8: "8",
|
||||
Digit9: "9",
|
||||
Digit0: "0",
|
||||
|
||||
"(Digit1)": "!",
|
||||
"(Digit2)": "@",
|
||||
"(Digit3)": "#",
|
||||
"(Digit4)": "$",
|
||||
"(Digit5)": "%",
|
||||
"(Digit6)": "^",
|
||||
"(Digit7)": "&",
|
||||
"(Digit8)": "*",
|
||||
"(Digit9)": "(",
|
||||
"(Digit0)": ")",
|
||||
Minus: "-",
|
||||
"(Minus)": "_",
|
||||
|
||||
Equal: "=",
|
||||
"(Equal)": "+",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
"(BracketLeft)": "{",
|
||||
"(BracketRight)": "}",
|
||||
Backslash: "\\",
|
||||
"(Backslash)": "|",
|
||||
|
||||
Semicolon: ";",
|
||||
"(Semicolon)": ":",
|
||||
Quote: "'",
|
||||
"(Quote)": '"',
|
||||
Comma: ",",
|
||||
"(Comma)": "<",
|
||||
Period: ".",
|
||||
"(Period)": ">",
|
||||
Slash: "/",
|
||||
"(Slash)": "?",
|
||||
Space: " ",
|
||||
Backquote: "`",
|
||||
"(Backquote)": "~",
|
||||
IntlBackslash: "\\",
|
||||
|
||||
F1: "F1",
|
||||
F2: "F2",
|
||||
F3: "F3",
|
||||
F4: "F4",
|
||||
F5: "F5",
|
||||
F6: "F6",
|
||||
F7: "F7",
|
||||
F8: "F8",
|
||||
F9: "F9",
|
||||
F10: "F10",
|
||||
F11: "F11",
|
||||
F12: "F12",
|
||||
}}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
default: [
|
||||
"CtrlAltDelete AltMetaEscape",
|
||||
|
|
|
@ -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";
|
||||
|
@ -553,16 +554,19 @@ export default function WebRTCVideo() {
|
|||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-layout">
|
||||
<div className="min-h-[39.5px]">
|
||||
<fieldset disabled={peerConnection?.connectionState !== "connected"}>
|
||||
<Actionbar
|
||||
requestFullscreen={async () =>
|
||||
videoElm.current?.requestFullscreen({
|
||||
navigationUI: "show",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="min-h-[39.5px] flex flex-col">
|
||||
<div className="flex flex-col">
|
||||
<fieldset disabled={peerConnection?.connectionState !== "connected"} className="contents">
|
||||
<Actionbar
|
||||
requestFullscreen={async () =>
|
||||
videoElm.current?.requestFullscreen({
|
||||
navigationUI: "show",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<MacroBar />
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
@ -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)";
|
|
@ -1,5 +1,18 @@
|
|||
import { create } from "zustand";
|
||||
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
|
||||
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 = <T extends { timestamp: number }>(
|
||||
|
@ -649,3 +662,146 @@ export const useDeviceStore = create<DeviceState>(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;
|
||||
steps: KeySequenceStep[];
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface MacrosState {
|
||||
macros: KeySequence[];
|
||||
loading: boolean;
|
||||
initialized: boolean;
|
||||
loadMacros: () => Promise<void>;
|
||||
saveMacros: (macros: KeySequence[]) => Promise<void>;
|
||||
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;
|
||||
}
|
||||
|
||||
export const generateMacroId = () => {
|
||||
return Math.random().toString(36).substring(2, 9);
|
||||
};
|
||||
|
||||
export const useMacrosStore = create<MacrosState>((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<void>((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.");
|
||||
throw new Error("JSON-RPC send function not available");
|
||||
}
|
||||
|
||||
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
|
||||
}));
|
||||
|
||||
const response = await new Promise<JsonRpcResponse>((resolve) => {
|
||||
sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => {
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
console.error("Error saving macros:", response.error);
|
||||
const errorMessage = typeof response.error.data === 'string'
|
||||
? response.error.data
|
||||
: response.error.message || "Failed to save macros";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Only update the store if the request was successful
|
||||
set({ macros: macrosWithSortOrder });
|
||||
} catch (error) {
|
||||
console.error("Failed to save macros:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -212,3 +212,80 @@ export const modifiers = {
|
|||
MetaLeft: 0x08,
|
||||
MetaRight: 0x80,
|
||||
} as Record<string, number>;
|
||||
|
||||
export const modifierDisplayMap: Record<string, string> = {
|
||||
ControlLeft: "Left Ctrl",
|
||||
ControlRight: "Right Ctrl",
|
||||
ShiftLeft: "Left Shift",
|
||||
ShiftRight: "Right Shift",
|
||||
AltLeft: "Left Alt",
|
||||
AltRight: "Right Alt",
|
||||
MetaLeft: "Left Meta",
|
||||
MetaRight: "Right Meta",
|
||||
} as Record<string, string>;
|
||||
|
||||
export const keyDisplayMap: Record<string, string> = {
|
||||
CtrlAltDelete: "Ctrl + Alt + Delete",
|
||||
AltMetaEscape: "Alt + Meta + Escape",
|
||||
Escape: "esc",
|
||||
Tab: "tab",
|
||||
Backspace: "backspace",
|
||||
Enter: "enter",
|
||||
CapsLock: "caps lock",
|
||||
ShiftLeft: "shift",
|
||||
ShiftRight: "shift",
|
||||
ControlLeft: "ctrl",
|
||||
AltLeft: "alt",
|
||||
AltRight: "alt",
|
||||
MetaLeft: "meta",
|
||||
MetaRight: "meta",
|
||||
Space: " ",
|
||||
Home: "home",
|
||||
PageUp: "pageup",
|
||||
Delete: "delete",
|
||||
End: "end",
|
||||
PageDown: "pagedown",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
|
||||
// Letters
|
||||
KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e",
|
||||
KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j",
|
||||
KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o",
|
||||
KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t",
|
||||
KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y",
|
||||
KeyZ: "z",
|
||||
|
||||
// Numbers
|
||||
Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5",
|
||||
Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0",
|
||||
|
||||
// Symbols
|
||||
Minus: "-",
|
||||
Equal: "=",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
Backslash: "\\",
|
||||
Semicolon: ";",
|
||||
Quote: "'",
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
Slash: "/",
|
||||
Backquote: "`",
|
||||
IntlBackslash: "\\",
|
||||
|
||||
// Function keys
|
||||
F1: "F1", F2: "F2", F3: "F3", F4: "F4",
|
||||
F5: "F5", F6: "F6", F7: "F7", F8: "F8",
|
||||
F9: "F9", F10: "F10", F11: "F11", F12: "F12",
|
||||
|
||||
// Numpad
|
||||
Numpad0: "Num 0", Numpad1: "Num 1", Numpad2: "Num 2",
|
||||
Numpad3: "Num 3", Numpad4: "Num 4", Numpad5: "Num 5",
|
||||
Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8",
|
||||
Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -",
|
||||
NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .",
|
||||
NumpadEnter: "Num Enter"
|
||||
};
|
||||
|
|
|
@ -43,6 +43,9 @@ import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
|
|||
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";
|
||||
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 isInCloud = !isOnDevice;
|
||||
|
@ -175,6 +178,23 @@ if (isOnDevice) {
|
|||
path: "appearance",
|
||||
element: <SettingsAppearanceRoute />,
|
||||
},
|
||||
{
|
||||
path: "macros",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsMacrosRoute />,
|
||||
},
|
||||
{
|
||||
path: "add",
|
||||
element: <SettingsMacrosAddRoute />,
|
||||
},
|
||||
{
|
||||
path: ":macroId/edit",
|
||||
element: <SettingsMacrosEditRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
@ -283,6 +303,23 @@ if (isOnDevice) {
|
|||
path: "appearance",
|
||||
element: <SettingsAppearanceRoute />,
|
||||
},
|
||||
{
|
||||
path: "macros",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsMacrosRoute />,
|
||||
},
|
||||
{
|
||||
path: "add",
|
||||
element: <SettingsMacrosAddRoute />,
|
||||
},
|
||||
{
|
||||
path: ":macroId/edit",
|
||||
element: <SettingsMacrosEditRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
|
||||
import { KeySequence, useMacrosStore } from "@/hooks/stores";
|
||||
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
||||
import { MacroForm } from "@/components/MacroForm";
|
||||
import notifications from "@/notifications";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
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);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
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">
|
||||
<div className="flex items-center justify-between">
|
||||
<SettingsPageHeader
|
||||
title="Edit Macro"
|
||||
description="Modify your keyboard macro"
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Delete Macro"
|
||||
className="text-red-500 dark:text-red-400"
|
||||
LeadingIcon={LuTrash2}
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
<MacroForm
|
||||
initialData={macro}
|
||||
onSubmit={handleUpdateMacro}
|
||||
onCancel={() => navigate("../")}
|
||||
isSubmitting={isUpdating}
|
||||
submitText="Save Changes"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title="Delete Macro"
|
||||
description="Are you sure you want to delete this macro? This action cannot be undone."
|
||||
variant="danger"
|
||||
confirmText={isDeleting ? "Deleting" : "Delete"}
|
||||
onConfirm={() => {
|
||||
handleDeleteMacro();
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
isConfirming={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,306 @@
|
|||
import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu";
|
||||
|
||||
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
|
||||
import { SettingsPageHeader } from "@/components/SettingsPageheader";
|
||||
import { Button } from "@/components/Button";
|
||||
import EmptyCard from "@/components/EmptyCard";
|
||||
import Card from "@/components/Card";
|
||||
import { MAX_TOTAL_MACROS, COPY_SUFFIX, DEFAULT_DELAY } from "@/constants/macros";
|
||||
import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
|
||||
import notifications from "@/notifications";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
|
||||
return macros.map((macro, index) => ({
|
||||
...macro,
|
||||
sortOrder: index + 1,
|
||||
}));
|
||||
};
|
||||
|
||||
export default function SettingsMacrosRoute() {
|
||||
const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
|
||||
const navigate = useNavigate();
|
||||
const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
|
||||
|
||||
const isMaxMacrosReached = useMemo(() =>
|
||||
macros.length >= MAX_TOTAL_MACROS,
|
||||
[macros.length]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) {
|
||||
loadMacros();
|
||||
}
|
||||
}, [initialized, loadMacros]);
|
||||
|
||||
const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
|
||||
if (!macro?.id || !macro?.name) {
|
||||
notifications.error("Invalid macro data");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMaxMacrosReached) {
|
||||
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoadingId(macro.id);
|
||||
|
||||
const newMacroCopy: KeySequence = {
|
||||
...JSON.parse(JSON.stringify(macro)),
|
||||
id: generateMacroId(),
|
||||
name: `${macro.name} ${COPY_SUFFIX}`,
|
||||
sortOrder: macros.length + 1,
|
||||
};
|
||||
|
||||
try {
|
||||
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
|
||||
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
notifications.error(`Failed to duplicate macro: ${error.message}`);
|
||||
} else {
|
||||
notifications.error("Failed to duplicate macro");
|
||||
}
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
}, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]);
|
||||
|
||||
const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => {
|
||||
if (!Array.isArray(macros) || macros.length === 0) {
|
||||
notifications.error("No macros available");
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= macros.length) return;
|
||||
|
||||
setActionLoadingId(macroId);
|
||||
|
||||
try {
|
||||
const newMacros = [...macros];
|
||||
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
|
||||
const updatedMacros = normalizeSortOrders(newMacros);
|
||||
|
||||
await saveMacros(updatedMacros);
|
||||
notifications.success("Macro order updated successfully");
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
notifications.error(`Failed to reorder macros: ${error.message}`);
|
||||
} else {
|
||||
notifications.error("Failed to reorder macros");
|
||||
}
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
}, [macros, saveMacros, setActionLoadingId]);
|
||||
|
||||
const handleDeleteMacro = useCallback(async () => {
|
||||
if (!macroToDelete?.id) return;
|
||||
|
||||
setActionLoadingId(macroToDelete.id);
|
||||
try {
|
||||
const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id));
|
||||
await saveMacros(updatedMacros);
|
||||
notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
|
||||
setShowDeleteConfirm(false);
|
||||
setMacroToDelete(null);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
notifications.error(`Failed to delete macro: ${error.message}`);
|
||||
} else {
|
||||
notifications.error("Failed to delete macro");
|
||||
}
|
||||
} finally {
|
||||
setActionLoadingId(null);
|
||||
}
|
||||
}, [macroToDelete, macros, saveMacros]);
|
||||
|
||||
const MacroList = useMemo(() => (
|
||||
<div className="space-y-2">
|
||||
{macros.map((macro, index) => (
|
||||
<Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
onClick={() => handleMoveMacro(index, 'up', macro.id)}
|
||||
disabled={index === 0 || actionLoadingId === macro.id}
|
||||
LeadingIcon={LuArrowUp}
|
||||
aria-label={`Move ${macro.name} up`}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
onClick={() => handleMoveMacro(index, 'down', macro.id)}
|
||||
disabled={index === macros.length - 1 || actionLoadingId === macro.id}
|
||||
LeadingIcon={LuArrowDown}
|
||||
aria-label={`Move ${macro.name} down`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
|
||||
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
|
||||
{macro.name}
|
||||
</h3>
|
||||
<p className="mt-1 ml-4 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">
|
||||
{(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? (
|
||||
<>
|
||||
{Array.isArray(step.modifiers) && 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 < step.modifiers.length - 1 && (
|
||||
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
|
||||
<span className="text-slate-400 dark:text-slate-600"> + </span>
|
||||
)}
|
||||
|
||||
{Array.isArray(step.keys) && step.keys.map((key, idx) => (
|
||||
<Fragment key={`key-${idx}`}>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
||||
{keyDisplayMap[key] || key}
|
||||
</span>
|
||||
{idx < 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>
|
||||
)}
|
||||
{step.delay !== DEFAULT_DELAY && (
|
||||
<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">
|
||||
<Button
|
||||
size="XS"
|
||||
className="text-red-500 dark:text-red-400"
|
||||
theme="light"
|
||||
LeadingIcon={LuTrash2}
|
||||
onClick={() => {
|
||||
setMacroToDelete(macro);
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Delete macro ${macro.name}`}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
LeadingIcon={LuCopy}
|
||||
onClick={() => handleDuplicateMacro(macro)}
|
||||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Duplicate macro ${macro.name}`}
|
||||
/>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
LeadingIcon={LuPenLine}
|
||||
text="Edit"
|
||||
onClick={() => navigate(`${macro.id}/edit`)}
|
||||
disabled={actionLoadingId === macro.id}
|
||||
aria-label={`Edit macro ${macro.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setMacroToDelete(null);
|
||||
}}
|
||||
title="Delete Macro"
|
||||
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
|
||||
variant="danger"
|
||||
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
|
||||
onConfirm={handleDeleteMacro}
|
||||
isConfirming={actionLoadingId === macroToDelete?.id}
|
||||
/>
|
||||
</div>
|
||||
), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SettingsPageHeader
|
||||
title="Keyboard Macros"
|
||||
description={`Combine keystrokes into a single action for faster workflows.`}
|
||||
/>
|
||||
{ macros.length > 0 && (
|
||||
<div className="flex items-center pl-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
|
||||
onClick={() => navigate("add")}
|
||||
disabled={isMaxMacrosReached}
|
||||
aria-label="Add new macro"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading && macros.length === 0 ? (
|
||||
<EmptyCard
|
||||
IconElm={LuCommand}
|
||||
headline="Loading macros..."
|
||||
BtnElm={
|
||||
<div className="my-2 flex flex-col items-center space-y-2 text-center">
|
||||
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : macros.length === 0 ? (
|
||||
<EmptyCard
|
||||
IconElm={LuCommand}
|
||||
headline="Create Your First Macro"
|
||||
BtnElm={
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Add New Macro"
|
||||
onClick={() => navigate("add")}
|
||||
disabled={isMaxMacrosReached}
|
||||
aria-label="Add new macro"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : MacroList}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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() {
|
|||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="macros"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuCommand className="h-4 w-4 shrink-0" />
|
||||
<h1>Keyboard Macros</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="advanced"
|
||||
|
|
Loading…
Reference in New Issue