From 457fe16f9c31d08fbb6f3493f17105aac2638e86 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 13 Jun 2026 11:57:57 +0200 Subject: [PATCH] feat(frontend): add more general hotkeys (#12138) * add admin button hotkey * add duplicate hotkey * generalised hotkeys for actions * move to fnc * fix type checking * more hotkeys! * add the first primary action to the hotkeys * small fixes * use capital hotkeys * add keys for navigation * add panel navigation hotkeys * remove admin hotkey - navigating to attachments is imo more helpful * address conflict on macos --- src/frontend/lib/functions/Events.tsx | 22 +++--- src/frontend/lib/types/Panel.tsx | 1 + .../src/components/buttons/ScanButton.tsx | 2 +- .../src/components/items/ActionDropdown.tsx | 14 +++- .../src/components/modals/HotkeyModal.tsx | 3 +- .../src/components/nav/PageDetail.tsx | 74 ++++++++++++++++++- .../src/components/panels/AttachmentPanel.tsx | 1 + .../src/components/panels/NotesPanel.tsx | 1 + .../src/components/panels/PanelGroup.tsx | 22 +++++- .../src/components/panels/ParametersPanel.tsx | 1 + 10 files changed, 124 insertions(+), 17 deletions(-) diff --git a/src/frontend/lib/functions/Events.tsx b/src/frontend/lib/functions/Events.tsx index 12043cbbb4..115e8939a8 100644 --- a/src/frontend/lib/functions/Events.tsx +++ b/src/frontend/lib/functions/Events.tsx @@ -17,18 +17,22 @@ export type InvenTreeHotkeyItem = [ HotkeyItemOptions? ]; -export function useInvenTreeHotkeys(hotkeys: InvenTreeHotkeyItem[]) { - // Register the hotkeys using the Mantine hook - useHotkeys( - hotkeys.map(([key, _, handler, options]) => [key, handler, options]) - ); - - // register to helper state to store hotkeys - // This allows us to display the hotkeys in the UI - const keyelems: [string, string][] = hotkeys.map(([key, description]) => [ +export function useInvenTreeHotkeys(_keys: InvenTreeHotkeyItem[]) { + const keyelems: [string, string][] = _keys.map(([key, description]) => [ key, description ]); + + const mappedHotkeys: [ + string, + (event: KeyboardEvent) => void, + HotkeyItemOptions? + ][] = _keys.map(([key, _, handler, options]) => [key, handler, options]); + // Register the hotkeys using the Mantine hook + useHotkeys(mappedHotkeys); + + // register to helper state to store hotkeys + // This allows us to display the hotkeys in the UI useEffect(() => { useLocalLibState.getState().addHotkeys(keyelems); return () => diff --git a/src/frontend/lib/types/Panel.tsx b/src/frontend/lib/types/Panel.tsx index 5e3bd103d6..9cb1b6159a 100644 --- a/src/frontend/lib/types/Panel.tsx +++ b/src/frontend/lib/types/Panel.tsx @@ -17,6 +17,7 @@ export type PanelType = { disabled?: boolean; showHeadline?: boolean; supportsDirty?: boolean; + hotkey?: string; }; /** diff --git a/src/frontend/src/components/buttons/ScanButton.tsx b/src/frontend/src/components/buttons/ScanButton.tsx index 420ce8d0c7..b3acfba5f7 100644 --- a/src/frontend/src/components/buttons/ScanButton.tsx +++ b/src/frontend/src/components/buttons/ScanButton.tsx @@ -28,7 +28,7 @@ export function ScanButton({ if (hotkey) { useInvenTreeHotkeys([ [ - 'mod+b', + 'mod+Shift+B', t`Open barcode scanner`, () => { open(); diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index 111eae6305..7daf6fac35 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -34,6 +34,7 @@ export type ActionDropdownItem = { hidden?: boolean; onClick: (event?: any) => void; indicator?: Omit; + hotkey?: string; }; /** @@ -184,7 +185,8 @@ export function BarcodeActionDropdown({ title: t`Link Barcode`, icon: , tooltip: t`Link a custom barcode to this item`, - ChildItem: QRCodeLink + ChildItem: QRCodeLink, + hotkey: 'mod+L' }), GeneralBarcodeAction({ hidden: hidden || !hash || !permission, @@ -220,6 +222,7 @@ function GeneralBarcodeAction({ icon: ReactNode; tooltip: string; ChildItem: any; + hotkey?: string; }): ActionDropdownItem { const onClick = () => { modals.open({ @@ -255,7 +258,8 @@ export function DeleteItemAction( ...props, icon: , name: t`Delete`, - tooltip: props.tooltip ?? t`Delete item` + tooltip: props.tooltip ?? t`Delete item`, + hotkey: 'mod+X' }; } @@ -275,7 +279,8 @@ export function CancelItemAction( ...props, icon: , name: t`Cancel`, - tooltip: props.tooltip ?? t`Cancel` + tooltip: props.tooltip ?? t`Cancel`, + hotkey: 'mod+X' }; } @@ -287,6 +292,7 @@ export function DuplicateItemAction( ...props, icon: , name: t`Duplicate`, - tooltip: props.tooltip ?? t`Duplicate item` + tooltip: props.tooltip ?? t`Duplicate item`, + hotkey: 'mod+D' }; } diff --git a/src/frontend/src/components/modals/HotkeyModal.tsx b/src/frontend/src/components/modals/HotkeyModal.tsx index 80c158d4fe..be2549bba0 100644 --- a/src/frontend/src/components/modals/HotkeyModal.tsx +++ b/src/frontend/src/components/modals/HotkeyModal.tsx @@ -45,7 +45,8 @@ export function HotkeyModal({ ); keys.sort((a, b) => a.key.localeCompare(b.key)); return keys; - }, []); + }, [context, id]); + const data = useMemo(() => { return { head: ['Hotkey', 'Action'], diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx index 5f5a441a95..915f37388d 100644 --- a/src/frontend/src/components/nav/PageDetail.tsx +++ b/src/frontend/src/components/nav/PageDetail.tsx @@ -57,7 +57,7 @@ export function PageDetail({ useInvenTreeHotkeys([ [ 'mod+E', - title ? t`Edit ${title}` : t`Edit`, + t`Edit`, (event) => { if (event.repeat) { return; @@ -68,6 +68,7 @@ export function PageDetail({ } ] ]); + useActionHotkeys(actions); const pageTitleString = useMemo( () => @@ -196,3 +197,74 @@ export function PageDetail({ ); } + +function useActionHotkeys(actions: ReactNode[] = []) { + const hotkeys = useMemo(() => extractHotkeys(actions), [actions]); + + useInvenTreeHotkeys( + hotkeys.map(({ hotkey, onClick, name }) => [ + hotkey, + name, + (event) => { + if (event.repeat) { + return; + } + onClick(); + } + ]) + ); +} + +function extractHotkeys(actions: ReactNode[]) { + const calcActions = actions + .filter( + (action) => + action && + typeof action === 'object' && + 'hotkey' in action && + action.hotkey + ) + .map((action: any) => { + return { + hotkey: action?.hotkey, + name: action?.name, + onClick: action?.onClick + }; + }) + .filter((action) => action !== null); + + let primaryActionHotkeyAdded = false; + // now iterate over the actions to extract more possible hotkeys + actions.forEach((action: any) => { + const typeName = action?.type?.name; + + // dropdowns - nested actions + if (typeName === 'ActionDropdown' || typeName === 'OptionsActionDropdown') { + const dropdownActions = action?.props?.actions as any[]; + dropdownActions.forEach((dropdownAction: any) => { + if (dropdownAction.hotkey) { + calcActions.push({ + hotkey: dropdownAction.hotkey, + name: dropdownAction.name, + onClick: dropdownAction.onClick + }); + } + }); + } + + // PrimaryActionButton - use the 'mod+A' hotkey if it is enabled + if (typeName === 'PrimaryActionButton' && action?.props?.hidden !== true) { + if (primaryActionHotkeyAdded) return; + + const hotkey = action?.props?.hotkey ?? 'mod+A'; + calcActions.push({ + hotkey, + name: + action?.props?.tooltip ?? action?.props?.title ?? t`Primary Action`, + onClick: action?.props?.onClick + }); + primaryActionHotkeyAdded = true; + } + }); + return calcActions; +} diff --git a/src/frontend/src/components/panels/AttachmentPanel.tsx b/src/frontend/src/components/panels/AttachmentPanel.tsx index 8102581de0..4ab4017717 100644 --- a/src/frontend/src/components/panels/AttachmentPanel.tsx +++ b/src/frontend/src/components/panels/AttachmentPanel.tsx @@ -19,6 +19,7 @@ export default function AttachmentPanel({ name: 'attachments', label: t`Attachments`, icon: , + hotkey: 'mod+Shift+A', notification_dot: async () => { if (!model_type || !model_id) { return null; diff --git a/src/frontend/src/components/panels/NotesPanel.tsx b/src/frontend/src/components/panels/NotesPanel.tsx index 66696384bb..fe5b01d627 100644 --- a/src/frontend/src/components/panels/NotesPanel.tsx +++ b/src/frontend/src/components/panels/NotesPanel.tsx @@ -26,6 +26,7 @@ export default function NotesPanel({ name: 'notes', label: t`Notes`, icon: , + hotkey: 'mod+Shift+N', notification_dot: has_note ? 'info' : null, content: model_type && model_id ? ( diff --git a/src/frontend/src/components/panels/PanelGroup.tsx b/src/frontend/src/components/panels/PanelGroup.tsx index c07249f303..6dc549270d 100644 --- a/src/frontend/src/components/panels/PanelGroup.tsx +++ b/src/frontend/src/components/panels/PanelGroup.tsx @@ -37,7 +37,11 @@ import { Boundary } from '@lib/components/Boundary'; import { StylishText } from '@lib/components/StylishText'; import type { ModelType, PluginPanelKey } from '@lib/enums/ModelType'; import { identifierString } from '@lib/functions/Conversion'; -import { cancelEvent } from '@lib/functions/Events'; +import { + type InvenTreeHotkeyItem, + cancelEvent, + useInvenTreeHotkeys +} from '@lib/functions/Events'; import { eventModified, getBaseUrl } from '@lib/functions/Navigation'; import { navigateToLink } from '@lib/functions/Navigation'; import type { @@ -335,6 +339,22 @@ function BasePanelGroup({ } }, [activePanels, panel]); + // hotkeys + const hotkeys = useMemo(() => { + const keys: InvenTreeHotkeyItem[] = []; + activePanels.forEach((panel) => { + if (panel.hotkey) { + keys.push([ + panel.hotkey, + t`Navigate to panel ${panel.name}`, + () => handlePanelChange(panel.name) + ]); + } + }); + return keys; + }, [activePanels]); + useInvenTreeHotkeys(hotkeys); + const [isDirty, setIsDirty] = useState(false); useWindowEvent('beforeunload', (event) => { if (isDirty) { diff --git a/src/frontend/src/components/panels/ParametersPanel.tsx b/src/frontend/src/components/panels/ParametersPanel.tsx index a0bf301909..389a219d5e 100644 --- a/src/frontend/src/components/panels/ParametersPanel.tsx +++ b/src/frontend/src/components/panels/ParametersPanel.tsx @@ -23,6 +23,7 @@ export default function ParametersPanel({ name: 'parameters', label: t`Parameters`, icon: , + hotkey: 'mod+Shift+P', hidden: hidden ?? false, notification_dot: async () => { if (!model_type || !model_id) {