diff --git a/config.go b/config.go index c83ccfc7..f89ff936 100644 --- a/config.go +++ b/config.go @@ -24,7 +24,7 @@ const ( MaxMacrosPerDevice = 25 MaxStepsPerMacro = 10 MaxKeysPerStep = 10 - MinStepDelay = 50 + MinStepDelay = 10 MaxStepDelay = 2000 ) @@ -32,6 +32,10 @@ type KeyboardMacroStep struct { Keys []string `json:"keys"` Modifiers []string `json:"modifiers"` 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 { diff --git a/jsonrpc.go b/jsonrpc.go index 0ff44a78..301bf029 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1038,6 +1038,15 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) { 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) } } diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx index 1aafe9c9..c5be524e 100644 --- a/ui/src/components/MacroForm.tsx +++ b/ui/src/components/MacroForm.tsx @@ -66,7 +66,7 @@ export function MacroForm({ 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, + step => (step.text && step.text.length > 0) || (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0, ); if (!hasKeyOrModifier) { @@ -163,6 +163,40 @@ export function MacroForm({ 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 newSteps = [...(macro.steps || [])]; const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1; @@ -213,31 +247,46 @@ export function MacroForm({
{(macro.steps || []).map((step, stepIndex) => ( - 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} - keyboard={selectedKeyboard} - /> +
+ 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} + keyboard={selectedKeyboard} + onStepTypeChange={type => handleStepTypeChange(stepIndex, type)} + onTextChange={text => handleTextChange(stepIndex, text)} + /> + {stepIndex < (macro.steps?.length || 0) - 1 && ( +
+
+ )} +
))}
diff --git a/ui/src/components/MacroStepCard.tsx b/ui/src/components/MacroStepCard.tsx index c795520d..5c9b0d9a 100644 --- a/ui/src/components/MacroStepCard.tsx +++ b/ui/src/components/MacroStepCard.tsx @@ -38,16 +38,22 @@ const basePresetDelays = [ ]; const PRESET_DELAYS = basePresetDelays.map(delay => { - if (parseInt(delay.value, 10) === DEFAULT_DELAY) { - return { ...delay, label: "Default" }; - } + if (parseInt(delay.value, 10) === DEFAULT_DELAY) return { ...delay, label: "Default" }; return delay; }); +const TEXT_EXTRA_DELAYS = [ + { value: "10", label: "10ms" }, + { value: "20", label: "20ms" }, + { value: "30", label: "30ms" }, +]; + interface MacroStep { keys: string[]; modifiers: string[]; delay: number; + text?: string; + wait?: boolean; } interface MacroStepCardProps { @@ -62,7 +68,9 @@ interface MacroStepCardProps { onModifierChange: (modifiers: string[]) => void; onDelayChange: (delay: number) => void; isLastStep: boolean; - keyboard: KeyboardLayout + keyboard: KeyboardLayout; + onStepTypeChange: (type: "keys" | "text" | "wait") => void; + onTextChange: (text: string) => void; } const ensureArray = (arr: T[] | null | undefined): T[] => { @@ -81,7 +89,9 @@ export function MacroStepCard({ onModifierChange, onDelayChange, isLastStep, - keyboard + keyboard, + onStepTypeChange, + onTextChange, }: MacroStepCardProps) { const { keyDisplayMap } = keyboard; @@ -106,6 +116,8 @@ export function MacroStepCard({ } }, [keyOptions, keyQuery, step.keys]); + const stepType: "keys" | "text" | "wait" = step.wait ? "wait" : (step.text !== undefined ? "text" : "keys"); + return (
@@ -146,6 +158,46 @@ export function MacroStepCard({
+
+ +
+
+
+ {stepType === "text" ? ( +
+ + onTextChange(e.target.value)} + placeholder="Enter text..." + /> +
+ ) : stepType === "wait" ? ( +
+ +

This step waits for the configured duration, no keys are sent.

+
+ ) : (
@@ -176,7 +228,8 @@ export function MacroStepCard({ ))}
- + )} + {stepType === "keys" && (
@@ -223,10 +276,10 @@ export function MacroStepCard({ />
- + )}
- +
onDelayChange(parseInt(e.target.value, 10))} - options={PRESET_DELAYS} + options={stepType === 'text' ? [...TEXT_EXTRA_DELAYS, ...PRESET_DELAYS] : PRESET_DELAYS} />
+
); diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e..967f569a 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -763,6 +763,8 @@ export interface KeySequenceStep { keys: string[]; modifiers: string[]; 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 { diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index 8d101b3b..5d90798e 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -16,6 +16,7 @@ import { import { useHidRpc } from "@/hooks/useHidRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; +import useKeyboardLayout from "@/hooks/useKeyboardLayout"; const MACRO_RESET_KEYBOARD_STATE = { keys: new Array(hidKeyBufferSize).fill(0), @@ -27,6 +28,8 @@ export interface MacroStep { keys: string[] | null; modifiers: string[] | null; delay: number; + text?: string | undefined; + wait?: boolean | undefined; } export type MacroSteps = MacroStep[]; @@ -34,6 +37,7 @@ export type MacroSteps = MacroStep[]; const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); export default function useKeyboard() { + const { selectedKeyboard } = useKeyboardLayout(); const { send } = useJsonRpc(); const { rpcDataChannel } = useRTCStore(); 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. // 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. + 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 ( - steps: MacroSteps, + stepsIn: MacroSteps, ) => { + const steps = expandTextSteps(stepsIn); const macro: KeyboardMacroStep[] = []; for (const [_, step] of steps.entries()) { @@ -297,16 +330,22 @@ export default function useKeyboard() { .reduce((acc, val) => acc + val, 0); - // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierMask > 0) { + if (step.wait) { + // 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({ ...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]); - const executeMacroClientSide = useCallback(async (steps: MacroSteps) => { + }, [sendKeyboardMacroEventHidRpc, expandTextSteps]); + const executeMacroClientSide = useCallback(async (stepsIn: MacroSteps) => { + const steps = expandTextSteps(stepsIn); const promises: (() => Promise)[] = []; const ac = new AbortController(); @@ -318,11 +357,14 @@ export default function useKeyboard() { .map(mod => modifiers[mod]) .reduce((acc, val) => acc + val, 0); - // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierMask > 0) { + if (step.wait) { + promises.push(() => sleep(step.delay || 100)); + } else if (keyValues.length > 0 || modifierMask > 0) { promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac)); promises.push(() => resetKeyboardState()); promises.push(() => sleep(step.delay || 100)); + } else { + promises.push(() => sleep(step.delay || 100)); } } @@ -354,7 +396,7 @@ export default function useKeyboard() { reject(error); }); }); - }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]); + }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController, expandTextSteps]); const executeMacro = useCallback(async (steps: MacroSteps) => { if (rpcHidReady) { return executeMacroRemote(steps); diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx index 94fded36..39e57cdd 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -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 { LuPenLine, @@ -9,6 +9,7 @@ import { LuArrowDown, LuTrash2, LuCommand, + LuDownload, } from "react-icons/lu"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; @@ -22,13 +23,32 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; import LoadingSpinner from "@/components/LoadingSpinner"; import useKeyboardLayout from "@/hooks/useKeyboardLayout"; -const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { - return macros.map((macro, index) => ({ - ...macro, - sortOrder: index + 1, - })); +const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => macros.map((m, i) => ({ ...m, sortOrder: i + 1 })); + +const pad2 = (n: number) => String(n).padStart(2, "0"); + +const buildMacroDownloadFilename = (macro: KeySequence) => { + const safeName = (macro.name || macro.id).replace(/[^a-z0-9-_]+/gi, "-").toLowerCase(); + const now = new Date(); + const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`; + return `jetkvm-macro-${safeName}-${ts}.json`; }; +const sanitizeImportedStep = (raw: any) => ({ + keys: Array.isArray(raw?.keys) ? raw.keys.filter((k: any) => typeof k === "string") : [], + modifiers: Array.isArray(raw?.modifiers) ? raw.modifiers.filter((m: any) => typeof m === "string") : [], + delay: typeof raw?.delay === "number" ? raw.delay : DEFAULT_DELAY, + text: typeof raw?.text === "string" ? raw.text : undefined, + wait: typeof raw?.wait === "boolean" ? raw.wait : false, +}); + +const sanitizeImportedMacro = (raw: any, sortOrder: number): KeySequence => ({ + id: generateMacroId(), + name: (typeof raw?.name === "string" && raw.name.trim() ? raw.name : "Imported Macro").slice(0, 50), + steps: Array.isArray(raw?.steps) ? raw.steps.map(sanitizeImportedStep) : [], + sortOrder, +}); + export default function SettingsMacrosRoute() { const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore(); const navigate = useNavigate(); @@ -36,6 +56,7 @@ export default function SettingsMacrosRoute() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [macroToDelete, setMacroToDelete] = useState(null); const { selectedKeyboard } = useKeyboardLayout(); + const fileInputRef = useRef(null); const isMaxMacrosReached = useMemo( () => macros.length >= MAX_TOTAL_MACROS, @@ -48,74 +69,52 @@ export default function SettingsMacrosRoute() { } }, [initialized, loadMacros]); - const handleDuplicateMacro = useCallback( - async (macro: KeySequence) => { - if (!macro?.id || !macro?.name) { - notifications.error("Invalid macro data"); - return; - } + 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 (e: any) { + notifications.error(`Failed to duplicate macro: ${e?.message || 'error'}`); + } finally { + setActionLoadingId(null); + } + }, [macros, saveMacros, isMaxMacrosReached]); - 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 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 (e: any) { + notifications.error(`Failed to reorder macros: ${e?.message || 'error'}`); + } finally { + setActionLoadingId(null); + } + }, [macros, saveMacros]); const handleDeleteMacro = useCallback(async () => { if (!macroToDelete?.id) return; @@ -140,6 +139,17 @@ export default function SettingsMacrosRoute() { } }, [macroToDelete, macros, saveMacros]); + const handleDownloadMacro = useCallback((macro: KeySequence) => { + 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"); + a.href = url; + a.download = buildMacroDownloadFilename(macro); + a.click(); + URL.revokeObjectURL(url); + }, []); + const MacroList = useMemo( () => (
@@ -178,9 +188,12 @@ export default function SettingsMacrosRoute() { - {(Array.isArray(step.modifiers) && - step.modifiers.length > 0) || - (Array.isArray(step.keys) && step.keys.length > 0) ? ( + {step.text && step.text.length > 0 ? ( + Text: "{step.text}" + ) : step.wait ? ( + Wait + ) : (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) => ( @@ -224,7 +237,7 @@ export default function SettingsMacrosRoute() { ) : ( - Delay only + Pause only )} {step.delay !== DEFAULT_DELAY && ( @@ -261,6 +274,7 @@ export default function SettingsMacrosRoute() { disabled={actionLoadingId === macro.id} aria-label={`Duplicate macro ${macro.name}`} /> +
), - [ - macros, - showDeleteConfirm, - macroToDelete?.name, - macroToDelete?.id, - actionLoadingId, - handleDeleteMacro, - handleMoveMacro, - selectedKeyboard.modifierDisplayMap, - selectedKeyboard.keyDisplayMap, - handleDuplicateMacro, - navigate - ], + [macros, showDeleteConfirm, macroToDelete?.name, macroToDelete?.id, actionLoadingId, handleDeleteMacro, handleMoveMacro, selectedKeyboard.modifierDisplayMap, selectedKeyboard.keyDisplayMap, handleDuplicateMacro, navigate, handleDownloadMacro], ); return ( @@ -312,18 +314,57 @@ export default function SettingsMacrosRoute() { title="Keyboard Macros" description={`Combine keystrokes into a single action for faster workflows.`} /> - {macros.length > 0 && ( -
+
+
- )} +