kvm/ui/src/components/MacroStepCard.tsx

280 lines
9.3 KiB
TypeScript

import { useMemo } from "react";
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
import { Button } from "@components/Button";
import { Combobox, ComboboxOption } from "@components/Combobox";
import Card from "@components/Card";
import FieldLabel from "@components/FieldLabel";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import { KeyboardLayout } from "@/keyboardLayouts";
import { keys, modifiers } from "@/keyboardMappings";
import { m } from "@localizations/messages.js";
// Filter out modifier keys since they're handled in the modifiers section
const modifierKeyPrefixes = ["Alt", "Control", "Shift", "Meta"];
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")),
};
// not going to localize these since they're short time intervals
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 (Number.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;
keyboard: KeyboardLayout;
}
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
return Array.isArray(arr) ? arr : [];
};
const keyDisplay = (keyDisplayMap: Record<string, string>, key: string): string => {
return keyDisplayMap[key] || key
};
export function MacroStepCard({
step,
stepIndex,
onDelete,
onMoveUp,
onMoveDown,
onKeySelect,
onKeyQueryChange,
keyQuery,
onModifierChange,
onDelayChange,
isLastStep,
keyboard,
}: Readonly<MacroStepCardProps>) {
const { keyDisplayMap } = keyboard;
const keyOptions = useMemo(
() =>
Object.keys(keys)
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
.map(key => ({
value: key,
label: keyDisplay(keyDisplayMap, key),
})),
[keyDisplayMap],
);
const handleModifierToggle = (optionValue: string) => {
const modifiersArray = ensureArray(step.modifiers);
const isSelected = modifiersArray.includes(optionValue);
const newModifiers = isSelected
? modifiersArray.filter(m => m !== optionValue)
: [...modifiersArray, optionValue];
onModifierChange(newModifiers);
};
const filteredKeys = useMemo(() => {
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()),
);
}
}, [keyOptions, keyQuery, step.keys]);
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={m.delete()}
LeadingIcon={LuTrash2}
onClick={onDelete}
/>
)}
</div>
</div>
<div className="mt-2 space-y-4">
<div className="flex w-full flex-col gap-2">
<FieldLabel
label={m.macro_step_modifiers_label()}
description={m.macro_step_modifiers_description()}
/>
<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 p-2 dark:border-slate-700"
>
<span className="absolute -top-2.5 left-2 bg-white px-1 text-xs font-medium text-slate-500 dark:bg-slate-800 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={() => handleModifierToggle(option.value)}
/>
))}
</div>
</div>
))}
</div>
</div>
<div className="flex w-full flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel
label={m.macro_step_keys_label()}
description={m.macro_step_keys_description({ max: MAX_KEYS_PER_STEP })}
/>
</div>
{step.keys?.length > 0 && (
<div className="flex flex-wrap gap-1 pb-2">
{step.keys.map((key, keyIndex) => (
<span
key={`key-${keyIndex}`}
className="inline-flex items-center rounded-md bg-blue-100 px-1 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
>
<span className="px-1">{keyDisplay(keyDisplayMap, key)}</span>
<Button
size="XS"
className=""
theme="blank"
onClick={() => {
const newKeys = step.keys.filter(
(_, i) => i !== keyIndex,
);
onKeySelect({ value: null, keys: newKeys });
}}
LeadingIcon={LuX}
/>
</span>
))}
</div>
)}
<div className="relative w-full">
<Combobox
onChange={option => {
const selectedOption = option as ComboboxOption | null;
onKeySelect({ value: selectedOption?.value ?? null });
onKeyQueryChange("");
}}
displayValue={() => keyQuery}
onInputChange={onKeyQueryChange}
options={() => filteredKeys}
disabledMessage={m.macro_step_max_keys_reached({ max: MAX_KEYS_PER_STEP })}
size="SM"
immediate
disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
placeholder={
ensureArray(step.keys).length >= MAX_KEYS_PER_STEP
? m.macro_step_max_keys_reached()
: m.macro_step_search_for_key()
}
emptyMessage={m.macro_step_no_matching_keys_found()}
/>
</div>
</div>
<div className="flex w-full flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel
label={m.macro_step_duration_label()}
description={m.macro_step_duration_description()}
/>
</div>
<div className="flex items-center gap-3">
<SelectMenuBasic
size="SM"
fullWidth
value={step.delay.toString()}
onChange={e => onDelayChange(Number.parseInt(e.target.value, 10))}
options={PRESET_DELAYS}
/>
</div>
</div>
</div>
</Card>
);
}