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:
Andrew Davis
2025-04-11 06:51:06 +10:00
committed by Siyuan Miao
parent 76efa56083
commit 8f6e64fd9c
20 changed files with 1768 additions and 145 deletions

View File

@@ -0,0 +1,63 @@
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { MacroForm } from "@/components/MacroForm";
import { DEFAULT_DELAY } from "@/constants/macros";
import notifications from "@/notifications";
export default function SettingsMacrosAddRoute() {
const { macros, saveMacros } = useMacrosStore();
const [isSaving, setIsSaving] = useState(false);
const navigate = useNavigate();
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
};
const handleAddMacro = async (macro: Partial<KeySequence>) => {
setIsSaving(true);
try {
const newMacro: KeySequence = {
id: generateMacroId(),
name: macro.name!.trim(),
steps: macro.steps || [],
sortOrder: macros.length + 1,
};
await saveMacros(normalizeSortOrders([...macros, newMacro]));
notifications.success(`Macro "${newMacro.name}" created successfully`);
navigate("../");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to create macro: ${error.message}`);
} else {
notifications.error("Failed to create macro");
}
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="Add New Macro"
description="Create a new keyboard macro"
/>
<MacroForm
initialData={{
name: "",
steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }],
}}
onSubmit={handleAddMacro}
onCancel={() => navigate("../")}
isSubmitting={isSaving}
/>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useNavigate, useParams } from "react-router-dom";
import { useState, useEffect } from "react";
import { LuTrash2 } from "react-icons/lu";
import { KeySequence, useMacrosStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { MacroForm } from "@/components/MacroForm";
import notifications from "@/notifications";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
};
export default function SettingsMacrosEditRoute() {
const { macros, saveMacros } = useMacrosStore();
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const navigate = useNavigate();
const { macroId } = useParams<{ macroId: string }>();
const [macro, setMacro] = useState<KeySequence | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
const foundMacro = macros.find(m => m.id === macroId);
if (foundMacro) {
setMacro({
...foundMacro,
steps: foundMacro.steps.map(step => ({
...step,
keys: Array.isArray(step.keys) ? step.keys : [],
modifiers: Array.isArray(step.modifiers) ? step.modifiers : [],
delay: typeof step.delay === 'number' ? step.delay : 0
}))
});
} else {
navigate("../");
}
}, [macroId, macros, navigate]);
const handleUpdateMacro = async (updatedMacro: Partial<KeySequence>) => {
if (!macro) return;
setIsUpdating(true);
try {
const newMacros = macros.map(m =>
m.id === macro.id ? {
...macro,
name: updatedMacro.name!.trim(),
steps: updatedMacro.steps || [],
} : m
);
await saveMacros(normalizeSortOrders(newMacros));
notifications.success(`Macro "${updatedMacro.name}" updated successfully`);
navigate("../");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to update macro: ${error.message}`);
} else {
notifications.error("Failed to update macro");
}
} finally {
setIsUpdating(false);
}
};
const handleDeleteMacro = async () => {
if (!macro) return;
setIsDeleting(true);
try {
const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macro.id));
await saveMacros(updatedMacros);
notifications.success(`Macro "${macro.name}" deleted successfully`);
navigate("../macros");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to delete macro: ${error.message}`);
} else {
notifications.error("Failed to delete macro");
}
} finally {
setIsDeleting(false);
}
};
if (!macro) return null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<SettingsPageHeader
title="Edit Macro"
description="Modify your keyboard macro"
/>
<Button
size="SM"
theme="light"
text="Delete Macro"
className="text-red-500 dark:text-red-400"
LeadingIcon={LuTrash2}
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
/>
</div>
<MacroForm
initialData={macro}
onSubmit={handleUpdateMacro}
onCancel={() => navigate("../")}
isSubmitting={isUpdating}
submitText="Save Changes"
/>
<ConfirmDialog
open={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title="Delete Macro"
description="Are you sure you want to delete this macro? This action cannot be undone."
variant="danger"
confirmText={isDeleting ? "Deleting" : "Delete"}
onConfirm={() => {
handleDeleteMacro();
setShowDeleteConfirm(false);
}}
isConfirming={isDeleting}
/>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { Button } from "@/components/Button";
import EmptyCard from "@/components/EmptyCard";
import Card from "@/components/Card";
import { MAX_TOTAL_MACROS, COPY_SUFFIX, DEFAULT_DELAY } from "@/constants/macros";
import { keyDisplayMap, modifierDisplayMap } from "@/keyboardMappings";
import notifications from "@/notifications";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import LoadingSpinner from "@/components/LoadingSpinner";
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
};
export default function SettingsMacrosRoute() {
const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
const navigate = useNavigate();
const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
const isMaxMacrosReached = useMemo(() =>
macros.length >= MAX_TOTAL_MACROS,
[macros.length]
);
useEffect(() => {
if (!initialized) {
loadMacros();
}
}, [initialized, loadMacros]);
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 (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 handleDeleteMacro = useCallback(async () => {
if (!macroToDelete?.id) return;
setActionLoadingId(macroToDelete.id);
try {
const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id));
await saveMacros(updatedMacros);
notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
setShowDeleteConfirm(false);
setMacroToDelete(null);
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to delete macro: ${error.message}`);
} else {
notifications.error("Failed to delete macro");
}
} finally {
setActionLoadingId(null);
}
}, [macroToDelete, macros, saveMacros]);
const MacroList = useMemo(() => (
<div className="space-y-2">
{macros.map((macro, index) => (
<Card key={macro.id} className="p-2 bg-white dark:bg-slate-800">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1 px-2">
<Button
size="XS"
theme="light"
onClick={() => handleMoveMacro(index, 'up', macro.id)}
disabled={index === 0 || actionLoadingId === macro.id}
LeadingIcon={LuArrowUp}
aria-label={`Move ${macro.name} up`}
/>
<Button
size="XS"
theme="light"
onClick={() => handleMoveMacro(index, 'down', macro.id)}
disabled={index === macros.length - 1 || actionLoadingId === macro.id}
LeadingIcon={LuArrowDown}
aria-label={`Move ${macro.name} down`}
/>
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center ml-2">
<h3 className="truncate text-sm font-semibold text-black dark:text-white">
{macro.name}
</h3>
<p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden">
<span className="flex flex-col items-start gap-1">
{macro.steps.map((step, stepIndex) => {
const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
return (
<span key={stepIndex} className="inline-flex items-center">
<StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" />
<span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50">
{(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) => (
<Fragment key={`mod-${idx}`}>
<span className="font-medium text-slate-600 dark:text-slate-200">
{modifierDisplayMap[modifier] || modifier}
</span>
{idx < step.modifiers.length - 1 && (
<span className="text-slate-400 dark:text-slate-600"> + </span>
)}
</Fragment>
))}
{Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && (
<span className="text-slate-400 dark:text-slate-600"> + </span>
)}
{Array.isArray(step.keys) && step.keys.map((key, idx) => (
<Fragment key={`key-${idx}`}>
<span className="font-medium text-blue-600 dark:text-blue-400">
{keyDisplayMap[key] || key}
</span>
{idx < step.keys.length - 1 && (
<span className="text-slate-400 dark:text-slate-600"> + </span>
)}
</Fragment>
))}
</>
) : (
<span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span>
)}
{step.delay !== DEFAULT_DELAY && (
<span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span>
)}
</span>
</span>
);
})}
</span>
</p>
</div>
<div className="flex items-center gap-1 ml-4">
<Button
size="XS"
className="text-red-500 dark:text-red-400"
theme="light"
LeadingIcon={LuTrash2}
onClick={() => {
setMacroToDelete(macro);
setShowDeleteConfirm(true);
}}
disabled={actionLoadingId === macro.id}
aria-label={`Delete macro ${macro.name}`}
/>
<Button
size="XS"
theme="light"
LeadingIcon={LuCopy}
onClick={() => handleDuplicateMacro(macro)}
disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`}
/>
<Button
size="XS"
theme="light"
LeadingIcon={LuPenLine}
text="Edit"
onClick={() => navigate(`${macro.id}/edit`)}
disabled={actionLoadingId === macro.id}
aria-label={`Edit macro ${macro.name}`}
/>
</div>
</div>
</Card>
))}
<ConfirmDialog
open={showDeleteConfirm}
onClose={() => {
setShowDeleteConfirm(false);
setMacroToDelete(null);
}}
title="Delete Macro"
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
variant="danger"
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
onConfirm={handleDeleteMacro}
isConfirming={actionLoadingId === macroToDelete?.id}
/>
</div>
), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<SettingsPageHeader
title="Keyboard Macros"
description={`Combine keystrokes into a single action for faster workflows.`}
/>
{ macros.length > 0 && (
<div className="flex items-center pl-2">
<Button
size="SM"
theme="primary"
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
onClick={() => navigate("add")}
disabled={isMaxMacrosReached}
aria-label="Add new macro"
/>
</div>
)}
</div>
<div className="space-y-4">
{loading && macros.length === 0 ? (
<EmptyCard
IconElm={LuCommand}
headline="Loading macros..."
BtnElm={
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
</div>
}
/>
) : macros.length === 0 ? (
<EmptyCard
IconElm={LuCommand}
headline="Create Your First Macro"
BtnElm={
<Button
size="SM"
theme="primary"
text="Add New Macro"
onClick={() => navigate("add")}
disabled={isMaxMacrosReached}
aria-label="Add new macro"
/>
}
/>
) : MacroList}
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
LuWrench,
LuArrowLeft,
LuPalette,
LuCommand,
} from "react-icons/lu";
import React, { useEffect, useRef, useState } from "react";
@@ -195,6 +196,17 @@ export default function SettingsRoute() {
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="macros"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuCommand className="h-4 w-4 shrink-0" />
<h1>Keyboard Macros</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="advanced"