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"`
|
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 {
|
type Config struct {
|
||||||
CloudURL string `json:"cloud_url"`
|
CloudURL string `json:"cloud_url"`
|
||||||
CloudAppURL string `json:"cloud_app_url"`
|
CloudAppURL string `json:"cloud_app_url"`
|
||||||
|
@ -26,6 +84,7 @@ type Config struct {
|
||||||
LocalAuthToken string `json:"local_auth_token"`
|
LocalAuthToken string `json:"local_auth_token"`
|
||||||
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
||||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
||||||
|
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||||
EdidString string `json:"hdmi_edid_string"`
|
EdidString string `json:"hdmi_edid_string"`
|
||||||
ActiveExtension string `json:"active_extension"`
|
ActiveExtension string `json:"active_extension"`
|
||||||
DisplayMaxBrightness int `json:"display_max_brightness"`
|
DisplayMaxBrightness int `json:"display_max_brightness"`
|
||||||
|
@ -43,6 +102,7 @@ var defaultConfig = &Config{
|
||||||
CloudAppURL: "https://app.jetkvm.com",
|
CloudAppURL: "https://app.jetkvm.com",
|
||||||
AutoUpdateEnabled: true, // Set a default value
|
AutoUpdateEnabled: true, // Set a default value
|
||||||
ActiveExtension: "",
|
ActiveExtension: "",
|
||||||
|
KeyboardMacros: []KeyboardMacro{},
|
||||||
DisplayMaxBrightness: 64,
|
DisplayMaxBrightness: 64,
|
||||||
DisplayDimAfterSec: 120, // 2 minutes
|
DisplayDimAfterSec: 120, // 2 minutes
|
||||||
DisplayOffAfterSec: 1800, // 30 minutes
|
DisplayOffAfterSec: 1800, // 30 minutes
|
||||||
|
|
95
jsonrpc.go
95
jsonrpc.go
|
@ -797,6 +797,99 @@ func rpcSetScrollSensitivity(sensitivity string) error {
|
||||||
return nil
|
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{
|
var rpcHandlers = map[string]RPCHandler{
|
||||||
"ping": {Func: rpcPing},
|
"ping": {Func: rpcPing},
|
||||||
"getDeviceID": {Func: rpcGetDeviceID},
|
"getDeviceID": {Func: rpcGetDeviceID},
|
||||||
|
@ -862,4 +955,6 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
|
||||||
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
|
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
|
||||||
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
|
"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">;
|
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
|
||||||
|
|
||||||
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
|
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
|
||||||
{ size = "MD", ...props },
|
{ size = "MD", className, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const classes = checkboxVariants({ size });
|
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";
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -49,4 +49,4 @@ export default function FieldLabel({
|
||||||
} else {
|
} else {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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 { useHidStore, useUiStore } from "@/hooks/stores";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import DetachIconRaw from "@/assets/detach-icon.svg";
|
import DetachIconRaw from "@/assets/detach-icon.svg";
|
||||||
import AttachIconRaw from "@/assets/attach-icon.svg";
|
import AttachIconRaw from "@/assets/attach-icon.svg";
|
||||||
|
@ -260,136 +260,7 @@ function KeyboardWrapper() {
|
||||||
buttons: "CtrlAltDelete AltMetaEscape",
|
buttons: "CtrlAltDelete AltMetaEscape",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
display={{
|
display={keyDisplayMap}
|
||||||
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",
|
|
||||||
}}
|
|
||||||
layout={{
|
layout={{
|
||||||
default: [
|
default: [
|
||||||
"CtrlAltDelete AltMetaEscape",
|
"CtrlAltDelete AltMetaEscape",
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { useResizeObserver } from "@/hooks/useResizeObserver";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import VirtualKeyboard from "@components/VirtualKeyboard";
|
import VirtualKeyboard from "@components/VirtualKeyboard";
|
||||||
import Actionbar from "@components/ActionBar";
|
import Actionbar from "@components/ActionBar";
|
||||||
|
import MacroBar from "@/components/MacroBar";
|
||||||
import InfoBar from "@components/InfoBar";
|
import InfoBar from "@components/InfoBar";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
@ -553,16 +554,19 @@ export default function WebRTCVideo() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full w-full grid-rows-layout">
|
<div className="grid h-full w-full grid-rows-layout">
|
||||||
<div className="min-h-[39.5px]">
|
<div className="min-h-[39.5px] flex flex-col">
|
||||||
<fieldset disabled={peerConnection?.connectionState !== "connected"}>
|
<div className="flex flex-col">
|
||||||
<Actionbar
|
<fieldset disabled={peerConnection?.connectionState !== "connected"} className="contents">
|
||||||
requestFullscreen={async () =>
|
<Actionbar
|
||||||
videoElm.current?.requestFullscreen({
|
requestFullscreen={async () =>
|
||||||
navigationUI: "show",
|
videoElm.current?.requestFullscreen({
|
||||||
})
|
navigationUI: "show",
|
||||||
}
|
})
|
||||||
/>
|
}
|
||||||
</fieldset>
|
/>
|
||||||
|
<MacroBar />
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
</div>
|
</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 { create } from "zustand";
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros";
|
||||||
|
|
||||||
|
// Define the JsonRpc types for better type checking
|
||||||
|
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
|
// Utility function to append stats to a Map
|
||||||
const appendStatToMap = <T extends { timestamp: number }>(
|
const appendStatToMap = <T extends { timestamp: number }>(
|
||||||
|
@ -649,3 +662,146 @@ export const useDeviceStore = create<DeviceState>(set => ({
|
||||||
setAppVersion: version => set({ appVersion: version }),
|
setAppVersion: version => set({ appVersion: version }),
|
||||||
setSystemVersion: version => set({ systemVersion: 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 { useHidStore, useRTCStore } from "@/hooks/stores";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
|
||||||
export default function useKeyboard() {
|
export default function useKeyboard() {
|
||||||
const [send] = useJsonRpc();
|
const [send] = useJsonRpc();
|
||||||
|
@ -28,5 +29,28 @@ export default function useKeyboard() {
|
||||||
sendKeyboardEvent([], []);
|
sendKeyboardEvent([], []);
|
||||||
}, [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,
|
MetaLeft: 0x08,
|
||||||
MetaRight: 0x80,
|
MetaRight: 0x80,
|
||||||
} as Record<string, number>;
|
} 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 * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
|
||||||
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
|
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
|
||||||
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
|
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
|
||||||
|
import SettingsMacrosRoute from "./routes/devices.$id.settings.macros";
|
||||||
|
import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add";
|
||||||
|
import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit";
|
||||||
|
|
||||||
export const isOnDevice = import.meta.env.MODE === "device";
|
export const isOnDevice = import.meta.env.MODE === "device";
|
||||||
export const isInCloud = !isOnDevice;
|
export const isInCloud = !isOnDevice;
|
||||||
|
@ -175,6 +178,23 @@ if (isOnDevice) {
|
||||||
path: "appearance",
|
path: "appearance",
|
||||||
element: <SettingsAppearanceRoute />,
|
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",
|
path: "appearance",
|
||||||
element: <SettingsAppearanceRoute />,
|
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,
|
LuWrench,
|
||||||
LuArrowLeft,
|
LuArrowLeft,
|
||||||
LuPalette,
|
LuPalette,
|
||||||
|
LuCommand,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
@ -195,6 +196,17 @@ export default function SettingsRoute() {
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</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">
|
<div className="shrink-0">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="advanced"
|
to="advanced"
|
||||||
|
|
Loading…
Reference in New Issue