mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-06-02 11:31:21 +02:00
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:
committed by
Siyuan Miao
parent
76efa56083
commit
8f6e64fd9c
@@ -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";
|
||||
|
||||
|
||||
119
ui/src/components/Combobox.tsx
Normal file
119
ui/src/components/Combobox.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
106
ui/src/components/ConfirmDialog.tsx
Normal file
106
ui/src/components/ConfirmDialog.tsx
Normal file
@@ -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 {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
ui/src/components/MacroBar.tsx
Normal file
48
ui/src/components/MacroBar.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
271
ui/src/components/MacroForm.tsx
Normal file
271
ui/src/components/MacroForm.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
235
ui/src/components/MacroStepCard.tsx
Normal file
235
ui/src/components/MacroStepCard.tsx
Normal file
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user