mirror of https://github.com/jetkvm/kvm.git
feat(macros): add text/wait step types and improve delays
Lower minimum step delay to 10ms to allow finer-grained macro timing. Introduce optional "text" and "wait" fields on macro steps (Go and TypeScript types, JSON-RPC parsing) so steps can either type text using the selected keyboard layout or act as explicit wait-only pauses. Implement client-side expansion of text steps into per-character key events (handling shift, AltRight, dead/accent keys and trailing space) and wire expansion into both remote and client-side macro execution. Adjust macro execution logic to treat wait steps as no-op delays and ensure key press followed by explicit release delay is sent for typed keys. These changes enable richer macro semantics (text composition and explicit waits) and more responsive timing control.
This commit is contained in:
parent
83caa8f82d
commit
9f27a5d5c3
|
@ -23,7 +23,7 @@ const (
|
||||||
MaxMacrosPerDevice = 25
|
MaxMacrosPerDevice = 25
|
||||||
MaxStepsPerMacro = 10
|
MaxStepsPerMacro = 10
|
||||||
MaxKeysPerStep = 10
|
MaxKeysPerStep = 10
|
||||||
MinStepDelay = 50
|
MinStepDelay = 10
|
||||||
MaxStepDelay = 2000
|
MaxStepDelay = 2000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -31,6 +31,10 @@ type KeyboardMacroStep struct {
|
||||||
Keys []string `json:"keys"`
|
Keys []string `json:"keys"`
|
||||||
Modifiers []string `json:"modifiers"`
|
Modifiers []string `json:"modifiers"`
|
||||||
Delay int `json:"delay"`
|
Delay int `json:"delay"`
|
||||||
|
// Optional: when set, this step types the given text using the configured keyboard layout.
|
||||||
|
// The delay value is treated as the per-character delay.
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Wait bool `json:"wait,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *KeyboardMacroStep) Validate() error {
|
func (s *KeyboardMacroStep) Validate() error {
|
||||||
|
|
|
@ -1024,6 +1024,15 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
|
||||||
step.Delay = int(delay)
|
step.Delay = int(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional text field for advanced steps
|
||||||
|
if txt, ok := stepMap["text"].(string); ok {
|
||||||
|
step.Text = txt
|
||||||
|
}
|
||||||
|
|
||||||
|
if wv, ok := stepMap["wait"].(bool); ok {
|
||||||
|
step.Wait = wv
|
||||||
|
}
|
||||||
|
|
||||||
steps = append(steps, step)
|
steps = append(steps, step)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,7 +66,7 @@ export function MacroForm({
|
||||||
newErrors.steps = { 0: { keys: "At least one step is required" } };
|
newErrors.steps = { 0: { keys: "At least one step is required" } };
|
||||||
} else {
|
} else {
|
||||||
const hasKeyOrModifier = macro.steps.some(
|
const hasKeyOrModifier = macro.steps.some(
|
||||||
step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
|
step => (step.text && step.text.length > 0) || (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasKeyOrModifier) {
|
if (!hasKeyOrModifier) {
|
||||||
|
@ -163,6 +163,40 @@ export function MacroForm({
|
||||||
setMacro({ ...macro, steps: newSteps });
|
setMacro({ ...macro, steps: newSteps });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStepTypeChange = (stepIndex: number, type: "keys" | "text" | "wait") => {
|
||||||
|
const newSteps = [...(macro.steps || [])];
|
||||||
|
const prev = newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY };
|
||||||
|
if (type === "text") {
|
||||||
|
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, text: prev.text || "" } as any;
|
||||||
|
} else if (type === "wait") {
|
||||||
|
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, wait: true } as any;
|
||||||
|
} else {
|
||||||
|
// switch back to keys; drop text
|
||||||
|
const { text, wait, ...rest } = prev as any;
|
||||||
|
newSteps[stepIndex] = { ...rest } as any;
|
||||||
|
}
|
||||||
|
setMacro({ ...macro, steps: newSteps });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextChange = (stepIndex: number, text: string) => {
|
||||||
|
const newSteps = [...(macro.steps || [])];
|
||||||
|
// Ensure this step is of text type
|
||||||
|
newSteps[stepIndex] = { ...(newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY }), text } as any;
|
||||||
|
setMacro({ ...macro, steps: newSteps });
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertStepAfter = (index: number) => {
|
||||||
|
if (isMaxStepsReached) {
|
||||||
|
showTemporaryError(
|
||||||
|
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newSteps = [...(macro.steps || [])];
|
||||||
|
newSteps.splice(index + 1, 0, { keys: [], modifiers: [], delay: DEFAULT_DELAY });
|
||||||
|
setMacro(prev => ({ ...prev, steps: newSteps }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
|
const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
|
||||||
const newSteps = [...(macro.steps || [])];
|
const newSteps = [...(macro.steps || [])];
|
||||||
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
|
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
|
||||||
|
@ -213,8 +247,8 @@ export function MacroForm({
|
||||||
<Fieldset>
|
<Fieldset>
|
||||||
<div className="mt-2 space-y-4">
|
<div className="mt-2 space-y-4">
|
||||||
{(macro.steps || []).map((step, stepIndex) => (
|
{(macro.steps || []).map((step, stepIndex) => (
|
||||||
|
<div key={stepIndex} className="space-y-3">
|
||||||
<MacroStepCard
|
<MacroStepCard
|
||||||
key={stepIndex}
|
|
||||||
step={step}
|
step={step}
|
||||||
stepIndex={stepIndex}
|
stepIndex={stepIndex}
|
||||||
onDelete={
|
onDelete={
|
||||||
|
@ -237,7 +271,22 @@ export function MacroForm({
|
||||||
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
|
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
|
||||||
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
|
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
|
||||||
keyboard={selectedKeyboard}
|
keyboard={selectedKeyboard}
|
||||||
|
onStepTypeChange={type => handleStepTypeChange(stepIndex, type)}
|
||||||
|
onTextChange={text => handleTextChange(stepIndex, text)}
|
||||||
/>
|
/>
|
||||||
|
{stepIndex < (macro.steps?.length || 0) - 1 && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
LeadingIcon={LuPlus}
|
||||||
|
text="Insert step here"
|
||||||
|
onClick={() => insertStepAfter(stepIndex)}
|
||||||
|
disabled={isMaxStepsReached}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
|
@ -38,16 +38,22 @@ const basePresetDelays = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRESET_DELAYS = basePresetDelays.map(delay => {
|
const PRESET_DELAYS = basePresetDelays.map(delay => {
|
||||||
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
|
if (parseInt(delay.value, 10) === DEFAULT_DELAY) return { ...delay, label: "Default" };
|
||||||
return { ...delay, label: "Default" };
|
|
||||||
}
|
|
||||||
return delay;
|
return delay;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const TEXT_EXTRA_DELAYS = [
|
||||||
|
{ value: "10", label: "10ms" },
|
||||||
|
{ value: "20", label: "20ms" },
|
||||||
|
{ value: "30", label: "30ms" },
|
||||||
|
];
|
||||||
|
|
||||||
interface MacroStep {
|
interface MacroStep {
|
||||||
keys: string[];
|
keys: string[];
|
||||||
modifiers: string[];
|
modifiers: string[];
|
||||||
delay: number;
|
delay: number;
|
||||||
|
text?: string;
|
||||||
|
wait?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MacroStepCardProps {
|
interface MacroStepCardProps {
|
||||||
|
@ -62,7 +68,9 @@ interface MacroStepCardProps {
|
||||||
onModifierChange: (modifiers: string[]) => void;
|
onModifierChange: (modifiers: string[]) => void;
|
||||||
onDelayChange: (delay: number) => void;
|
onDelayChange: (delay: number) => void;
|
||||||
isLastStep: boolean;
|
isLastStep: boolean;
|
||||||
keyboard: KeyboardLayout
|
keyboard: KeyboardLayout;
|
||||||
|
onStepTypeChange: (type: "keys" | "text" | "wait") => void;
|
||||||
|
onTextChange: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
|
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
|
||||||
|
@ -81,7 +89,9 @@ export function MacroStepCard({
|
||||||
onModifierChange,
|
onModifierChange,
|
||||||
onDelayChange,
|
onDelayChange,
|
||||||
isLastStep,
|
isLastStep,
|
||||||
keyboard
|
keyboard,
|
||||||
|
onStepTypeChange,
|
||||||
|
onTextChange,
|
||||||
}: MacroStepCardProps) {
|
}: MacroStepCardProps) {
|
||||||
const { keyDisplayMap } = keyboard;
|
const { keyDisplayMap } = keyboard;
|
||||||
|
|
||||||
|
@ -106,6 +116,8 @@ export function MacroStepCard({
|
||||||
}
|
}
|
||||||
}, [keyOptions, keyQuery, step.keys]);
|
}, [keyOptions, keyQuery, step.keys]);
|
||||||
|
|
||||||
|
const stepType: "keys" | "text" | "wait" = step.wait ? "wait" : (step.text !== undefined ? "text" : "keys");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
@ -146,6 +158,46 @@ export function MacroStepCard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mt-2">
|
<div className="space-y-4 mt-2">
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
<FieldLabel label="Step Type" />
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme={stepType === "keys" ? "primary" : "light"}
|
||||||
|
text="Keys/Modifiers"
|
||||||
|
onClick={() => onStepTypeChange("keys")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme={stepType === "text" ? "primary" : "light"}
|
||||||
|
text="Text"
|
||||||
|
onClick={() => onStepTypeChange("text")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme={stepType === "wait" ? "primary" : "light"}
|
||||||
|
text="Wait"
|
||||||
|
onClick={() => onStepTypeChange("wait")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stepType === "text" ? (
|
||||||
|
<div className="w-full flex flex-col gap-1">
|
||||||
|
<FieldLabel label="Text to type" description="Will be typed with this step's delay per character" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full rounded-md border border-slate-200 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-800"
|
||||||
|
value={step.text || ""}
|
||||||
|
onChange={e => onTextChange(e.target.value)}
|
||||||
|
placeholder="Enter text..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : stepType === "wait" ? (
|
||||||
|
<div className="w-full flex flex-col gap-1">
|
||||||
|
<FieldLabel label="Wait" description="Pause execution for the specified duration." />
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">This step waits for the configured duration, no keys are sent.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
<FieldLabel label="Modifiers" />
|
<FieldLabel label="Modifiers" />
|
||||||
<div className="inline-flex flex-wrap gap-3">
|
<div className="inline-flex flex-wrap gap-3">
|
||||||
|
@ -176,7 +228,8 @@ export function MacroStepCard({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{stepType === "keys" && (
|
||||||
<div className="w-full flex flex-col gap-1">
|
<div className="w-full flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
|
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
|
||||||
|
@ -223,10 +276,10 @@ export function MacroStepCard({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="w-full flex flex-col gap-1">
|
<div className="w-full flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
|
<FieldLabel label="Step Duration" description={stepType === "text" ? "Delay per character when typing text" : stepType === "wait" ? "How long to pause before the next step" : "Time to wait before executing the next step."} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<SelectMenuBasic
|
<SelectMenuBasic
|
||||||
|
@ -234,10 +287,11 @@ export function MacroStepCard({
|
||||||
fullWidth
|
fullWidth
|
||||||
value={step.delay.toString()}
|
value={step.delay.toString()}
|
||||||
onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
|
onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
|
||||||
options={PRESET_DELAYS}
|
options={stepType === 'text' ? [...TEXT_EXTRA_DELAYS, ...PRESET_DELAYS] : PRESET_DELAYS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
@ -763,6 +763,8 @@ export interface KeySequenceStep {
|
||||||
keys: string[];
|
keys: string[];
|
||||||
modifiers: string[];
|
modifiers: string[];
|
||||||
delay: number;
|
delay: number;
|
||||||
|
text?: string; // optional: when set, type this text with per-character delay
|
||||||
|
wait?: boolean; // optional: when true, this is a pure wait step (pause for delay ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeySequence {
|
export interface KeySequence {
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import { useHidRpc } from "@/hooks/useHidRpc";
|
import { useHidRpc } from "@/hooks/useHidRpc";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
|
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
|
||||||
|
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||||
|
|
||||||
const MACRO_RESET_KEYBOARD_STATE = {
|
const MACRO_RESET_KEYBOARD_STATE = {
|
||||||
keys: new Array(hidKeyBufferSize).fill(0),
|
keys: new Array(hidKeyBufferSize).fill(0),
|
||||||
|
@ -27,6 +28,8 @@ export interface MacroStep {
|
||||||
keys: string[] | null;
|
keys: string[] | null;
|
||||||
modifiers: string[] | null;
|
modifiers: string[] | null;
|
||||||
delay: number;
|
delay: number;
|
||||||
|
text?: string | undefined;
|
||||||
|
wait?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MacroSteps = MacroStep[];
|
export type MacroSteps = MacroStep[];
|
||||||
|
@ -34,6 +37,7 @@ export type MacroSteps = MacroStep[];
|
||||||
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
export default function useKeyboard() {
|
export default function useKeyboard() {
|
||||||
|
const { selectedKeyboard } = useKeyboardLayout();
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const { rpcDataChannel } = useRTCStore();
|
const { rpcDataChannel } = useRTCStore();
|
||||||
const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } =
|
const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } =
|
||||||
|
@ -284,9 +288,38 @@ export default function useKeyboard() {
|
||||||
// After the delay, the keys and modifiers are released and the next step is executed.
|
// After the delay, the keys and modifiers are released and the next step is executed.
|
||||||
// If a step has no keys or modifiers, it is treated as a delay-only step.
|
// If a step has no keys or modifiers, it is treated as a delay-only step.
|
||||||
// A small pause is added between steps to ensure that the device can process the events.
|
// A small pause is added between steps to ensure that the device can process the events.
|
||||||
|
const expandTextSteps = useCallback((steps: MacroSteps): MacroSteps => {
|
||||||
|
const expanded: MacroSteps = [];
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.text && step.text.length > 0 && selectedKeyboard) {
|
||||||
|
for (const char of step.text) {
|
||||||
|
const keyprops = selectedKeyboard.chars[char];
|
||||||
|
if (!keyprops) continue;
|
||||||
|
const { key, shift, altRight, deadKey, accentKey } = keyprops;
|
||||||
|
if (!key) continue;
|
||||||
|
if (accentKey) {
|
||||||
|
const accentModifiers: string[] = [];
|
||||||
|
if (accentKey.shift) accentModifiers.push("ShiftLeft");
|
||||||
|
if (accentKey.altRight) accentModifiers.push("AltRight");
|
||||||
|
expanded.push({ keys: [String(accentKey.key)], modifiers: accentModifiers, delay: step.delay });
|
||||||
|
}
|
||||||
|
const mods: string[] = [];
|
||||||
|
if (shift) mods.push("ShiftLeft");
|
||||||
|
if (altRight) mods.push("AltRight");
|
||||||
|
expanded.push({ keys: [String(key)], modifiers: mods, delay: step.delay });
|
||||||
|
if (deadKey) expanded.push({ keys: ["Space"], modifiers: null, delay: step.delay });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expanded.push(step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expanded;
|
||||||
|
}, [selectedKeyboard]);
|
||||||
|
|
||||||
const executeMacroRemote = useCallback(async (
|
const executeMacroRemote = useCallback(async (
|
||||||
steps: MacroSteps,
|
stepsIn: MacroSteps,
|
||||||
) => {
|
) => {
|
||||||
|
const steps = expandTextSteps(stepsIn);
|
||||||
const macro: KeyboardMacroStep[] = [];
|
const macro: KeyboardMacroStep[] = [];
|
||||||
|
|
||||||
for (const [_, step] of steps.entries()) {
|
for (const [_, step] of steps.entries()) {
|
||||||
|
@ -297,16 +330,22 @@ export default function useKeyboard() {
|
||||||
|
|
||||||
.reduce((acc, val) => acc + val, 0);
|
.reduce((acc, val) => acc + val, 0);
|
||||||
|
|
||||||
// If the step has keys and/or modifiers, press them and hold for the delay
|
if (step.wait) {
|
||||||
if (keyValues.length > 0 || modifierMask > 0) {
|
// pure wait: send a no-op clear state with desired delay
|
||||||
|
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
|
||||||
|
} else if (keyValues.length > 0 || modifierMask > 0) {
|
||||||
macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 });
|
macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 });
|
||||||
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
|
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
|
||||||
|
} else {
|
||||||
|
// empty step (pause only)
|
||||||
|
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendKeyboardMacroEventHidRpc(macro);
|
sendKeyboardMacroEventHidRpc(macro);
|
||||||
}, [sendKeyboardMacroEventHidRpc]);
|
}, [sendKeyboardMacroEventHidRpc, expandTextSteps]);
|
||||||
const executeMacroClientSide = useCallback(async (steps: MacroSteps) => {
|
const executeMacroClientSide = useCallback(async (stepsIn: MacroSteps) => {
|
||||||
|
const steps = expandTextSteps(stepsIn);
|
||||||
const promises: (() => Promise<void>)[] = [];
|
const promises: (() => Promise<void>)[] = [];
|
||||||
|
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
@ -318,11 +357,14 @@ export default function useKeyboard() {
|
||||||
.map(mod => modifiers[mod])
|
.map(mod => modifiers[mod])
|
||||||
.reduce((acc, val) => acc + val, 0);
|
.reduce((acc, val) => acc + val, 0);
|
||||||
|
|
||||||
// If the step has keys and/or modifiers, press them and hold for the delay
|
if (step.wait) {
|
||||||
if (keyValues.length > 0 || modifierMask > 0) {
|
promises.push(() => sleep(step.delay || 100));
|
||||||
|
} else if (keyValues.length > 0 || modifierMask > 0) {
|
||||||
promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac));
|
promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac));
|
||||||
promises.push(() => resetKeyboardState());
|
promises.push(() => resetKeyboardState());
|
||||||
promises.push(() => sleep(step.delay || 100));
|
promises.push(() => sleep(step.delay || 100));
|
||||||
|
} else {
|
||||||
|
promises.push(() => sleep(step.delay || 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -354,7 +396,7 @@ export default function useKeyboard() {
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]);
|
}, [sendKeystrokeLegacy, resetKeyboardState, setAbortController, expandTextSteps]);
|
||||||
const executeMacro = useCallback(async (steps: MacroSteps) => {
|
const executeMacro = useCallback(async (steps: MacroSteps) => {
|
||||||
if (rpcHidReady) {
|
if (rpcHidReady) {
|
||||||
return executeMacroRemote(steps);
|
return executeMacroRemote(steps);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
|
import { useEffect, Fragment, useMemo, useState, useCallback, useRef } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import {
|
import {
|
||||||
LuPenLine,
|
LuPenLine,
|
||||||
|
@ -9,6 +9,7 @@ import {
|
||||||
LuArrowDown,
|
LuArrowDown,
|
||||||
LuTrash2,
|
LuTrash2,
|
||||||
LuCommand,
|
LuCommand,
|
||||||
|
LuDownload,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
|
|
||||||
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
|
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
|
||||||
|
@ -36,6 +37,7 @@ export default function SettingsMacrosRoute() {
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
|
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
|
||||||
const { selectedKeyboard } = useKeyboardLayout();
|
const { selectedKeyboard } = useKeyboardLayout();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const isMaxMacrosReached = useMemo(
|
const isMaxMacrosReached = useMemo(
|
||||||
() => macros.length >= MAX_TOTAL_MACROS,
|
() => macros.length >= MAX_TOTAL_MACROS,
|
||||||
|
@ -48,74 +50,52 @@ export default function SettingsMacrosRoute() {
|
||||||
}
|
}
|
||||||
}, [initialized, loadMacros]);
|
}, [initialized, loadMacros]);
|
||||||
|
|
||||||
const handleDuplicateMacro = useCallback(
|
const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
|
||||||
async (macro: KeySequence) => {
|
|
||||||
if (!macro?.id || !macro?.name) {
|
if (!macro?.id || !macro?.name) {
|
||||||
notifications.error("Invalid macro data");
|
notifications.error("Invalid macro data");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMaxMacrosReached) {
|
if (isMaxMacrosReached) {
|
||||||
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
|
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActionLoadingId(macro.id);
|
setActionLoadingId(macro.id);
|
||||||
|
|
||||||
const newMacroCopy: KeySequence = {
|
const newMacroCopy: KeySequence = {
|
||||||
...JSON.parse(JSON.stringify(macro)),
|
...JSON.parse(JSON.stringify(macro)),
|
||||||
id: generateMacroId(),
|
id: generateMacroId(),
|
||||||
name: `${macro.name} ${COPY_SUFFIX}`,
|
name: `${macro.name} ${COPY_SUFFIX}`,
|
||||||
sortOrder: macros.length + 1,
|
sortOrder: macros.length + 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
|
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
|
||||||
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
|
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
|
||||||
} catch (error: unknown) {
|
} catch (e: any) {
|
||||||
if (error instanceof Error) {
|
notifications.error(`Failed to duplicate macro: ${e?.message || 'error'}`);
|
||||||
notifications.error(`Failed to duplicate macro: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
notifications.error("Failed to duplicate macro");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoadingId(null);
|
setActionLoadingId(null);
|
||||||
}
|
}
|
||||||
},
|
}, [macros, saveMacros, isMaxMacrosReached]);
|
||||||
[isMaxMacrosReached, macros, saveMacros, setActionLoadingId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMoveMacro = useCallback(
|
const handleMoveMacro = useCallback(async (index: number, direction: "up" | "down", macroId: string) => {
|
||||||
async (index: number, direction: "up" | "down", macroId: string) => {
|
|
||||||
if (!Array.isArray(macros) || macros.length === 0) {
|
if (!Array.isArray(macros) || macros.length === 0) {
|
||||||
notifications.error("No macros available");
|
notifications.error("No macros available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||||
if (newIndex < 0 || newIndex >= macros.length) return;
|
if (newIndex < 0 || newIndex >= macros.length) return;
|
||||||
|
|
||||||
setActionLoadingId(macroId);
|
setActionLoadingId(macroId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newMacros = [...macros];
|
const newMacros = [...macros];
|
||||||
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
|
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
|
||||||
const updatedMacros = normalizeSortOrders(newMacros);
|
const updatedMacros = normalizeSortOrders(newMacros);
|
||||||
|
|
||||||
await saveMacros(updatedMacros);
|
await saveMacros(updatedMacros);
|
||||||
notifications.success("Macro order updated successfully");
|
notifications.success("Macro order updated successfully");
|
||||||
} catch (error: unknown) {
|
} catch (e: any) {
|
||||||
if (error instanceof Error) {
|
notifications.error(`Failed to reorder macros: ${e?.message || 'error'}`);
|
||||||
notifications.error(`Failed to reorder macros: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
notifications.error("Failed to reorder macros");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoadingId(null);
|
setActionLoadingId(null);
|
||||||
}
|
}
|
||||||
},
|
}, [macros, saveMacros]);
|
||||||
[macros, saveMacros, setActionLoadingId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteMacro = useCallback(async () => {
|
const handleDeleteMacro = useCallback(async () => {
|
||||||
if (!macroToDelete?.id) return;
|
if (!macroToDelete?.id) return;
|
||||||
|
@ -178,8 +158,11 @@ export default function SettingsMacrosRoute() {
|
||||||
<span key={stepIndex} className="inline-flex items-center">
|
<span key={stepIndex} className="inline-flex items-center">
|
||||||
<StepIcon className="mr-1 h-3 w-3 shrink-0 text-slate-400 dark:text-slate-500" />
|
<StepIcon className="mr-1 h-3 w-3 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||||
<span className="rounded-md border border-slate-200/50 bg-slate-50 px-2 py-0.5 dark:border-slate-700/50 dark:bg-slate-800">
|
<span className="rounded-md border border-slate-200/50 bg-slate-50 px-2 py-0.5 dark:border-slate-700/50 dark:bg-slate-800">
|
||||||
{(Array.isArray(step.modifiers) &&
|
{step.text && step.text.length > 0 ? (
|
||||||
step.modifiers.length > 0) ||
|
<span className="font-medium text-emerald-700 dark:text-emerald-300">Text: "{step.text}"</span>
|
||||||
|
) : step.wait ? (
|
||||||
|
<span className="font-medium text-amber-600 dark:text-amber-300">Wait</span>
|
||||||
|
) : (Array.isArray(step.modifiers) && step.modifiers.length > 0) ||
|
||||||
(Array.isArray(step.keys) && step.keys.length > 0) ? (
|
(Array.isArray(step.keys) && step.keys.length > 0) ? (
|
||||||
<>
|
<>
|
||||||
{Array.isArray(step.modifiers) &&
|
{Array.isArray(step.modifiers) &&
|
||||||
|
@ -224,7 +207,7 @@ export default function SettingsMacrosRoute() {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-medium text-slate-500 dark:text-slate-400">
|
<span className="font-medium text-slate-500 dark:text-slate-400">
|
||||||
Delay only
|
Pause only
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{step.delay !== DEFAULT_DELAY && (
|
{step.delay !== DEFAULT_DELAY && (
|
||||||
|
@ -261,6 +244,27 @@ export default function SettingsMacrosRoute() {
|
||||||
disabled={actionLoadingId === macro.id}
|
disabled={actionLoadingId === macro.id}
|
||||||
aria-label={`Duplicate macro ${macro.name}`}
|
aria-label={`Duplicate macro ${macro.name}`}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
LeadingIcon={LuDownload}
|
||||||
|
onClick={() => {
|
||||||
|
const data = JSON.stringify(macro, null, 2);
|
||||||
|
const blob = new Blob([data], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
const safeName = macro.name.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase();
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||||
|
a.href = url;
|
||||||
|
a.download = `jetkvm-macro-${safeName || macro.id}-${ts}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
aria-label={`Download macro ${macro.name}`}
|
||||||
|
disabled={actionLoadingId === macro.id}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
|
@ -312,7 +316,6 @@ export default function SettingsMacrosRoute() {
|
||||||
title="Keyboard Macros"
|
title="Keyboard Macros"
|
||||||
description={`Combine keystrokes into a single action for faster workflows.`}
|
description={`Combine keystrokes into a single action for faster workflows.`}
|
||||||
/>
|
/>
|
||||||
{macros.length > 0 && (
|
|
||||||
<div className="flex items-center pl-2">
|
<div className="flex items-center pl-2">
|
||||||
<Button
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
|
@ -322,8 +325,66 @@ export default function SettingsMacrosRoute() {
|
||||||
disabled={isMaxMacrosReached}
|
disabled={isMaxMacrosReached}
|
||||||
aria-label="Add new macro"
|
aria-label="Add new macro"
|
||||||
/>
|
/>
|
||||||
|
<div className="ml-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={async e => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
let working = [...macros];
|
||||||
|
const imported: string[] = [];
|
||||||
|
let errors = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const f of Array.from(files)) {
|
||||||
|
if (working.length >= MAX_TOTAL_MACROS) { skipped++; continue; }
|
||||||
|
try {
|
||||||
|
const raw = await f.text();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const candidates = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (working.length >= MAX_TOTAL_MACROS) { skipped += (candidates.length); break; }
|
||||||
|
if (!c || typeof c !== 'object') { errors++; continue; }
|
||||||
|
const sanitized: KeySequence = {
|
||||||
|
id: generateMacroId(),
|
||||||
|
name: (c.name || 'Imported Macro').slice(0,50),
|
||||||
|
steps: Array.isArray(c.steps) ? c.steps.map((s:any) => ({
|
||||||
|
keys: Array.isArray(s.keys) ? s.keys : [],
|
||||||
|
modifiers: Array.isArray(s.modifiers) ? s.modifiers : [],
|
||||||
|
delay: typeof s.delay === 'number' ? s.delay : DEFAULT_DELAY,
|
||||||
|
text: typeof s.text === 'string' ? s.text : undefined,
|
||||||
|
wait: typeof s.wait === 'boolean' ? s.wait : false,
|
||||||
|
})) : [],
|
||||||
|
sortOrder: working.length + 1,
|
||||||
|
};
|
||||||
|
working.push(sanitized);
|
||||||
|
imported.push(sanitized.name);
|
||||||
|
}
|
||||||
|
} catch { errors++; }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (imported.length) {
|
||||||
|
await saveMacros(normalizeSortOrders(working));
|
||||||
|
notifications.success(`Imported ${imported.length} macro${imported.length===1?'':'s'}`);
|
||||||
|
}
|
||||||
|
if (errors) notifications.error(`${errors} file${errors===1?'':'s'} failed`);
|
||||||
|
if (skipped) notifications.error(`${skipped} macro${skipped===1?'':'s'} skipped (limit ${MAX_TOTAL_MACROS})`);
|
||||||
|
} finally {
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="light"
|
||||||
|
text="Import Macro"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
Loading…
Reference in New Issue