mirror of https://github.com/jetkvm/kvm.git
235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|