2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 14:10:52 +00:00

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
This commit is contained in:
Matthias Mair
2026-06-13 11:57:57 +02:00
committed by GitHub
parent e4178b1fb8
commit 457fe16f9c
10 changed files with 124 additions and 17 deletions
+13 -9
View File
@@ -17,18 +17,22 @@ export type InvenTreeHotkeyItem = [
HotkeyItemOptions? HotkeyItemOptions?
]; ];
export function useInvenTreeHotkeys(hotkeys: InvenTreeHotkeyItem[]) { export function useInvenTreeHotkeys(_keys: InvenTreeHotkeyItem[]) {
// Register the hotkeys using the Mantine hook const keyelems: [string, string][] = _keys.map(([key, description]) => [
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]) => [
key, key,
description 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(() => { useEffect(() => {
useLocalLibState.getState().addHotkeys(keyelems); useLocalLibState.getState().addHotkeys(keyelems);
return () => return () =>
+1
View File
@@ -17,6 +17,7 @@ export type PanelType = {
disabled?: boolean; disabled?: boolean;
showHeadline?: boolean; showHeadline?: boolean;
supportsDirty?: boolean; supportsDirty?: boolean;
hotkey?: string;
}; };
/** /**
@@ -28,7 +28,7 @@ export function ScanButton({
if (hotkey) { if (hotkey) {
useInvenTreeHotkeys([ useInvenTreeHotkeys([
[ [
'mod+b', 'mod+Shift+B',
t`Open barcode scanner`, t`Open barcode scanner`,
() => { () => {
open(); open();
@@ -34,6 +34,7 @@ export type ActionDropdownItem = {
hidden?: boolean; hidden?: boolean;
onClick: (event?: any) => void; onClick: (event?: any) => void;
indicator?: Omit<IndicatorProps, 'children'>; indicator?: Omit<IndicatorProps, 'children'>;
hotkey?: string;
}; };
/** /**
@@ -184,7 +185,8 @@ export function BarcodeActionDropdown({
title: t`Link Barcode`, title: t`Link Barcode`,
icon: <IconLink />, icon: <IconLink />,
tooltip: t`Link a custom barcode to this item`, tooltip: t`Link a custom barcode to this item`,
ChildItem: QRCodeLink ChildItem: QRCodeLink,
hotkey: 'mod+L'
}), }),
GeneralBarcodeAction({ GeneralBarcodeAction({
hidden: hidden || !hash || !permission, hidden: hidden || !hash || !permission,
@@ -220,6 +222,7 @@ function GeneralBarcodeAction({
icon: ReactNode; icon: ReactNode;
tooltip: string; tooltip: string;
ChildItem: any; ChildItem: any;
hotkey?: string;
}): ActionDropdownItem { }): ActionDropdownItem {
const onClick = () => { const onClick = () => {
modals.open({ modals.open({
@@ -255,7 +258,8 @@ export function DeleteItemAction(
...props, ...props,
icon: <IconTrash color='red' />, icon: <IconTrash color='red' />,
name: t`Delete`, 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, ...props,
icon: <InvenTreeIcon icon='cancel' iconProps={{ color: 'red' }} />, icon: <InvenTreeIcon icon='cancel' iconProps={{ color: 'red' }} />,
name: t`Cancel`, name: t`Cancel`,
tooltip: props.tooltip ?? t`Cancel` tooltip: props.tooltip ?? t`Cancel`,
hotkey: 'mod+X'
}; };
} }
@@ -287,6 +292,7 @@ export function DuplicateItemAction(
...props, ...props,
icon: <IconCopy color='green' />, icon: <IconCopy color='green' />,
name: t`Duplicate`, name: t`Duplicate`,
tooltip: props.tooltip ?? t`Duplicate item` tooltip: props.tooltip ?? t`Duplicate item`,
hotkey: 'mod+D'
}; };
} }
@@ -45,7 +45,8 @@ export function HotkeyModal({
); );
keys.sort((a, b) => a.key.localeCompare(b.key)); keys.sort((a, b) => a.key.localeCompare(b.key));
return keys; return keys;
}, []); }, [context, id]);
const data = useMemo(() => { const data = useMemo(() => {
return { return {
head: ['Hotkey', 'Action'], head: ['Hotkey', 'Action'],
+73 -1
View File
@@ -57,7 +57,7 @@ export function PageDetail({
useInvenTreeHotkeys([ useInvenTreeHotkeys([
[ [
'mod+E', 'mod+E',
title ? t`Edit ${title}` : t`Edit`, t`Edit`,
(event) => { (event) => {
if (event.repeat) { if (event.repeat) {
return; return;
@@ -68,6 +68,7 @@ export function PageDetail({
} }
] ]
]); ]);
useActionHotkeys(actions);
const pageTitleString = useMemo( 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;
}
@@ -19,6 +19,7 @@ export default function AttachmentPanel({
name: 'attachments', name: 'attachments',
label: t`Attachments`, label: t`Attachments`,
icon: <IconPaperclip />, icon: <IconPaperclip />,
hotkey: 'mod+Shift+A',
notification_dot: async () => { notification_dot: async () => {
if (!model_type || !model_id) { if (!model_type || !model_id) {
return null; return null;
@@ -26,6 +26,7 @@ export default function NotesPanel({
name: 'notes', name: 'notes',
label: t`Notes`, label: t`Notes`,
icon: <IconNotes />, icon: <IconNotes />,
hotkey: 'mod+Shift+N',
notification_dot: has_note ? 'info' : null, notification_dot: has_note ? 'info' : null,
content: content:
model_type && model_id ? ( model_type && model_id ? (
@@ -37,7 +37,11 @@ import { Boundary } from '@lib/components/Boundary';
import { StylishText } from '@lib/components/StylishText'; import { StylishText } from '@lib/components/StylishText';
import type { ModelType, PluginPanelKey } from '@lib/enums/ModelType'; import type { ModelType, PluginPanelKey } from '@lib/enums/ModelType';
import { identifierString } from '@lib/functions/Conversion'; 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 { eventModified, getBaseUrl } from '@lib/functions/Navigation';
import { navigateToLink } from '@lib/functions/Navigation'; import { navigateToLink } from '@lib/functions/Navigation';
import type { import type {
@@ -335,6 +339,22 @@ function BasePanelGroup({
} }
}, [activePanels, panel]); }, [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); const [isDirty, setIsDirty] = useState(false);
useWindowEvent('beforeunload', (event) => { useWindowEvent('beforeunload', (event) => {
if (isDirty) { if (isDirty) {
@@ -23,6 +23,7 @@ export default function ParametersPanel({
name: 'parameters', name: 'parameters',
label: t`Parameters`, label: t`Parameters`,
icon: <IconListDetails />, icon: <IconListDetails />,
hotkey: 'mod+Shift+P',
hidden: hidden ?? false, hidden: hidden ?? false,
notification_dot: async () => { notification_dot: async () => {
if (!model_type || !model_id) { if (!model_type || !model_id) {