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:
@@ -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 () =>
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user