From 9e6b60afe8ef5744eaa238856c32b7d8fe6f572c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 18 Sep 2024 23:48:57 +1000 Subject: [PATCH] feat(ui): update hotkey list - Rework hotkey data to include the keys for each hotkey action. - Add wrapper for `useHotkeys` that accepts a hotkey category and id. Automatically selects the key from the hotkey data. - Add handling for macOS (cmd vs ctrl, option vs alt). - Redo all hotkey descriptions, deleting nonexistant ones. - Some `esc` hotkeys that just close whatever you are currently in are omitted due to their relative simplicity and intuitiveness. --- invokeai/frontend/web/public/locales/en.json | 476 ++++++++++-------- .../web/src/common/hooks/useGlobalHotkeys.ts | 118 +++-- .../components/CanvasRightPanel.tsx | 9 +- .../components/Tool/ToolBboxButton.tsx | 10 +- .../components/Tool/ToolBrushButton.tsx | 10 +- .../components/Tool/ToolBrushWidth.tsx | 18 +- .../components/Tool/ToolColorPickerButton.tsx | 10 +- .../components/Tool/ToolEraserButton.tsx | 10 +- .../components/Tool/ToolEraserWidth.tsx | 18 +- .../components/Tool/ToolMoveButton.tsx | 10 +- .../components/Tool/ToolRectButton.tsx | 10 +- .../components/Tool/ToolViewButton.tsx | 10 +- .../Toolbar/CanvasToolbarResetViewButton.tsx | 18 +- .../hooks/useCanvasDeleteLayerHotkey.ts | 13 +- .../hooks/useCanvasEntityQuickSwitchHotkey.ts | 9 +- .../hooks/useCanvasResetLayerHotkey.ts | 15 +- .../hooks/useCanvasUndoRedoHotkeys.tsx | 27 +- .../controlLayers/hooks/useNextPrevEntity.ts | 21 +- .../ImageGrid/GallerySelectionCountTag.tsx | 25 +- .../components/ImageViewer/CompareToolbar.tsx | 10 +- .../ImageViewer/CurrentImageButtons.tsx | 63 ++- .../ToggleMetadataViewerButton.tsx | 10 +- .../gallery/hooks/useGalleryHotkeys.ts | 179 +++++-- .../flow/AddNodeCmdk/AddNodeCmdk.tsx | 10 +- .../features/nodes/components/flow/Flow.tsx | 143 +++--- .../src/features/nodes/hooks/useCopyPaste.ts | 12 +- .../components/Core/ParamPositivePrompt.tsx | 10 +- .../HotkeysModal/HotkeyListItem.tsx | 31 +- .../components/HotkeysModal/HotkeysModal.tsx | 75 +-- .../components/HotkeysModal/useHotkeyData.ts | 473 +++++++---------- .../src/features/ui/components/AppContent.tsx | 44 +- 31 files changed, 1066 insertions(+), 831 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index a3c3da7eb24..d361290932c 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -353,230 +353,272 @@ "closeViewer": "Close Viewer" }, "hotkeys": { + "hotkeys": "Hotkeys", "searchHotkeys": "Search Hotkeys", "clearSearch": "Clear Search", "noHotkeysFound": "No Hotkeys Found", - "acceptStagingImage": { - "desc": "Accept Current Staging Area Image", - "title": "Accept Staging Image" - }, - "addNodes": { - "desc": "Opens the add node menu", - "title": "Add Nodes" - }, - "appHotkeys": "App", - "cancel": { - "desc": "Cancel current queue item", - "title": "Cancel" - }, - "cancelAndClear": { - "desc": "Cancel current queue item and clear all pending items", - "title": "Cancel and Clear" - }, - "changeTabs": { - "desc": "Switch to another workspace", - "title": "Change Tabs" - }, - "clearMask": { - "desc": "Clear the entire mask", - "title": "Clear Mask" - }, - "closePanels": { - "desc": "Closes open panels", - "title": "Close Panels" - }, - "colorPicker": { - "desc": "Selects the canvas color picker", - "title": "Select Color Picker" - }, - "consoleToggle": { - "desc": "Open and close console", - "title": "Console Toggle" - }, - "copyToClipboard": { - "desc": "Copy current canvas to clipboard", - "title": "Copy to Clipboard" - }, - "decreaseBrushOpacity": { - "desc": "Decreases the opacity of the canvas brush", - "title": "Decrease Brush Opacity" - }, - "decreaseBrushSize": { - "desc": "Decreases the size of the canvas brush/eraser", - "title": "Decrease Brush Size" - }, - "decreaseGalleryThumbSize": { - "desc": "Decreases gallery thumbnails size", - "title": "Decrease Gallery Image Size" - }, - "deleteImage": { - "desc": "Delete the current image", - "title": "Delete Image" - }, - "downloadImage": { - "desc": "Download current canvas", - "title": "Download Image" - }, - "eraseBoundingBox": { - "desc": "Erases the bounding box area", - "title": "Erase Bounding Box" - }, - "fillBoundingBox": { - "desc": "Fills the bounding box with brush color", - "title": "Fill Bounding Box" - }, - "focusPrompt": { - "desc": "Focus the prompt input area", - "title": "Focus Prompt" - }, - "galleryHotkeys": "Gallery", - "generalHotkeys": "General", - "hideMask": { - "desc": "Hide and unhide mask", - "title": "Hide Mask" - }, - "increaseBrushOpacity": { - "desc": "Increases the opacity of the canvas brush", - "title": "Increase Brush Opacity" + "app": { + "title": "App", + "invoke": { + "title": "Invoke", + "desc": "Queue a generation, adding it to the end of the queue." + }, + "invokeFront": { + "title": "Invoke (Front)", + "desc": "Queue a generation, adding it to the front of the queue." + }, + "cancelQueueItem": { + "title": "Cancel", + "desc": "Cancel the currently processing queue item." + }, + "clearQueue": { + "title": "Clear Queue", + "desc": "Cancel and clear all queue items." + }, + "selectCanvasTab": { + "title": "Select the Canvas Tab", + "desc": "Selects the Canvas tab." + }, + "selectUpscalingTab": { + "title": "Select the Upscaling Tab", + "desc": "Selects the Upscaling tab." + }, + "selectWorkflowsTab": { + "title": "Select the Workflows Tab", + "desc": "Selects the Workflows tab." + }, + "selectModelsTab": { + "title": "Select the Models Tab", + "desc": "Selects the Models tab." + }, + "selectQueueTab": { + "title": "Select the Queue Tab", + "desc": "Selects the Queue tab." + }, + "focusPrompt": { + "title": "Focus Prompt", + "desc": "Move cursor focus to the positive prompt." + }, + "toggleLeftPanel": { + "title": "Toggle Left Panel", + "desc": "Show or hide the left panel." + }, + "toggleRightPanel": { + "title": "Toggle Right Panel", + "desc": "Show or hide the right panel." + }, + "resetPanelLayout": { + "title": "Reset Panel Layout", + "desc": "Reset the left and right panels to their default size and layout." + }, + "togglePanels": { + "title": "Toggle Panels", + "desc": "Show or hide both left and right panels at once." + } }, - "increaseBrushSize": { - "desc": "Increases the size of the canvas brush/eraser", - "title": "Increase Brush Size" + "canvas": { + "title": "Canvas", + "selectBrushTool": { + "title": "Brush Tool", + "desc": "Select the brush tool." + }, + "selectBboxTool": { + "title": "Bbox Tool", + "desc": "Select the bounding box tool." + }, + "decrementToolWidth": { + "title": "Decrement Tool Width", + "desc": "Decrement the brush or eraser tool width, whichever is selected." + }, + "incrementToolWidth": { + "title": "Increment Tool Width", + "desc": "Increment the brush or eraser tool width, whichever is selected." + }, + "selectColorPickerTool": { + "title": "Color Picker Tool", + "desc": "Select the color picker tool." + }, + "selectEraserTool": { + "title": "Eraser Tool", + "desc": "Select the eraser tool." + }, + "selectMoveTool": { + "title": "Move Tool", + "desc": "Select the move tool." + }, + "selectRectTool": { + "title": "Rect Tool", + "desc": "Select the rect tool." + }, + "selectViewTool": { + "title": "View Tool", + "desc": "Select the view tool." + }, + "fitLayersToCanvas": { + "title": "Fit Layers to Canvas", + "desc": "Scales and positions the view to fit all visible layers." + }, + "setZoomTo100Percent": { + "title": "Reset Zoom", + "desc": "Resets the canvas zoom to 100%." + }, + "quickSwitch": { + "title": "Layer Quick Switch", + "desc": "Switch between the last two selected layers. If a layer is bookmarked, always switch between it and the last non-bookmarked layer." + }, + "deleteSelected": { + "title": "Delete Layer", + "desc": "Delete the selected layer." + }, + "resetSelected": { + "title": "Reset Layer", + "desc": "Reset the selected layer. Only applies to Inpaint Mask and Regional Guidance." + }, + "undo": { + "title": "Undo", + "desc": "Undo the last canvas action." + }, + "redo": { + "title": "redo", + "desc": "Redo the last canvas action." + }, + "nextEntity": { + "title": "Next Layer", + "desc": "Select the next layer in the list." + }, + "prevEntity": { + "title": "Prev Layer", + "desc": "Select the previous layer in the list." + } }, - "increaseGalleryThumbSize": { - "desc": "Increases gallery thumbnails size", - "title": "Increase Gallery Image Size" + "workflows": { + "title": "Workflows", + "addNode": { + "title": "Add Node", + "descl": "Open the add node menu." + }, + "copySelection": { + "title": "Copy", + "descl": "Copy selected nodes and edges." + }, + "pasteSelection": { + "title": "Paste", + "descl": "Paste copied nodes and edges." + }, + "pasteSelectionWithEdges": { + "title": "Paste with Edges", + "descl": "Paste copied nodes, edges, and all edges connected to copied nodes." + }, + "selectAll": { + "title": "Select All", + "descl": "Select all nodes and edges." + }, + "deleteSelection": { + "title": "Delete", + "descl": "Delete selected nodes and edges." + }, + "undo": { + "title": "Undo", + "descl": "Undo the last workflow action." + }, + "redo": { + "title": "Redo", + "descl": "Redo the last workflow action." + } }, - "invoke": { - "desc": "Generate an image", - "title": "Invoke" + "viewer": { + "title": "Image Viewer", + "toggleViewer": { + "title": "Show/Hide Image Viewer", + "desc": "Show or hide the image viewer. Only available on the Canvas tab." + }, + "swapImages": { + "title": "Swap Comparison Images", + "desc": "Swap the images being compared." + }, + "nextComparisonMode": { + "title": "Next Comparison Mode", + "desc": "Cycle through comparison modes." + }, + "loadWorkflow": { + "title": "Load Workflow", + "desc": "Load the current image's saved workflow (if it has one)." + }, + "recallAll": { + "title": "Recall All Metadata", + "desc": "Recall all metadata for the current image." + }, + "recallSeed": { + "title": "Recall Seed", + "desc": "Recall the seed for the current image." + }, + "recallPrompts": { + "title": "Recall Prompts", + "desc": "Recall the positive and negative prompts for the current image." + }, + "remix": { + "title": "Remix", + "desc": "Recall all metadata except for the seed for the current image." + }, + "useSize": { + "title": "Use Size", + "desc": "Use the current image's size as the bbox size." + }, + "runPostprocessing": { + "title": "Run Postprocessing", + "desc": "Run the selected postprocessing on the current image." + }, + "toggleMetadata": { + "title": "Show/Hide Metadata", + "desc": "Show or hide the current image's metadata overlay." + } }, - "keyboardShortcuts": "Hotkeys", - "maximizeWorkSpace": { - "desc": "Close panels and maximize work area", - "title": "Maximize Workspace" - }, - "mergeVisible": { - "desc": "Merge all visible layers of canvas", - "title": "Merge Visible" - }, - "moveTool": { - "desc": "Allows canvas navigation", - "title": "Move Tool" - }, - "nextImage": { - "desc": "Display the next image in gallery", - "title": "Next Image" - }, - "nextStagingImage": { - "desc": "Next Staging Area Image", - "title": "Next Staging Image" - }, - "nodesHotkeys": "Nodes", - "pinOptions": { - "desc": "Pin the options panel", - "title": "Pin Options" - }, - "previousImage": { - "desc": "Display the previous image in gallery", - "title": "Previous Image" - }, - "previousStagingImage": { - "desc": "Previous Staging Area Image", - "title": "Previous Staging Image" - }, - "quickToggleMove": { - "desc": "Temporarily toggles Move mode", - "title": "Quick Toggle Move" - }, - "redoStroke": { - "desc": "Redo a brush stroke", - "title": "Redo Stroke" - }, - "resetView": { - "desc": "Reset Canvas View", - "title": "Reset View" - }, - "restoreFaces": { - "desc": "Restore the current image", - "title": "Restore Faces" - }, - "saveToGallery": { - "desc": "Save current canvas to gallery", - "title": "Save To Gallery" - }, - "selectBrush": { - "desc": "Selects the canvas brush", - "title": "Select Brush" - }, - "selectEraser": { - "desc": "Selects the canvas eraser", - "title": "Select Eraser" - }, - "sendToImageToImage": { - "desc": "Send current image to Image to Image", - "title": "Send To Image To Image" - }, - "remixImage": { - "desc": "Use all parameters except seed from the current image", - "title": "Remix image" - }, - "setParameters": { - "desc": "Use all parameters of the current image", - "title": "Set Parameters" - }, - "setPrompt": { - "desc": "Use the prompt of the current image", - "title": "Set Prompt" - }, - "setSeed": { - "desc": "Use the seed of the current image", - "title": "Set Seed" - }, - "showHideBoundingBox": { - "desc": "Toggle visibility of bounding box", - "title": "Show/Hide Bounding Box" - }, - "showInfo": { - "desc": "Show metadata info of the current image", - "title": "Show Info" - }, - "toggleGallery": { - "desc": "Open and close the gallery drawer", - "title": "Toggle Gallery" - }, - "toggleOptions": { - "desc": "Open and close the options panel", - "title": "Toggle Options" - }, - "toggleOptionsAndGallery": { - "desc": "Open and close the options and gallery panels", - "title": "Toggle Options and Gallery" - }, - "resetOptionsAndGallery": { - "desc": "Resets the options and gallery panels", - "title": "Reset Options and Gallery" - }, - "toggleLayer": { - "desc": "Toggles mask/base layer selection", - "title": "Toggle Layer" - }, - "toggleSnap": { - "desc": "Toggles Snap to Grid", - "title": "Toggle Snap" - }, - "undoStroke": { - "desc": "Undo a brush stroke", - "title": "Undo Stroke" - }, - "unifiedCanvasHotkeys": "Unified Canvas", - "postProcess": { - "desc": "Process the current image using the selected post-processing model", - "title": "Process Image" - }, - "toggleViewer": { - "desc": "Switches between the Image Viewer and workspace for the current tab.", - "title": "Toggle Image Viewer" + "gallery": { + "title": "Gallery", + "selectAllOnPage": { + "title": "Select All On Page", + "desc": "Select all images on the current page." + }, + "clearSelection": { + "title": "Clear Selection", + "desc": "Clear the current selection, if any." + }, + "galleryNavUp": { + "title": "Navigate Up", + "desc": "Navigate up in the gallery grid, selecting that image. If at the top of the page, go to the previous page." + }, + "galleryNavRight": { + "title": "Navigate Right", + "desc": "Navigate right in the gallery grid, selecting that image. If at the last image of the row, go to the next row. If at the last image of the page, go to the next page." + }, + "galleryNavDown": { + "title": "Navigate Down", + "desc": "Navigate down in the gallery grid, selecting that image. If at the bottom of the page, go to the next page." + }, + "galleryNavLeft": { + "title": "Navigate Left", + "desc": "Navigate left in the gallery grid, selecting that image. If at the first image of the row, go to the previous row. If at the first image of the page, go to the previous page." + }, + "galleryNavUpAlt": { + "title": "Navigate Up (Compare Image)", + "desc": "Same as Navigate Up, but selects the compare image, opening compare mode if it isn't already open." + }, + "galleryNavRightAlt": { + "title": "Navigate Right (Compare Image)", + "desc": "Same as Navigate Right, but selects the compare image, opening compare mode if it isn't already open." + }, + "galleryNavDownAlt": { + "title": "Navigate Down (Compare Image)", + "desc": "Same as Navigate Down, but selects the compare image, opening compare mode if it isn't already open." + }, + "galleryNavLeftAlt": { + "title": "Navigate Left (Compare Image)", + "desc": "Same as Navigate Left, but selects the compare image, opening compare mode if it isn't already open." + }, + "deleteSelection": { + "title": "Delete", + "desc": "Delete all selected images. By default, you will be prompted to confirm deletion. If the images are currently in use in the app, you will be warned." + } } }, "metadata": { diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts index 6715f53d8ee..cacfbd0c627 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts @@ -3,36 +3,38 @@ import { addScope, removeScope, setScopes } from 'common/hooks/interactionScopes import { useClearQueue } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; import { useInvoke } from 'features/queue/hooks/useInvoke'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { setActiveTab } from 'features/ui/store/uiSlice'; -import { useHotkeys } from 'react-hotkeys-hook'; export const useGlobalHotkeys = () => { const dispatch = useAppDispatch(); const isModelManagerEnabled = useFeatureStatus('modelManager'); const queue = useInvoke(); - useHotkeys( - ['ctrl+enter', 'meta+enter'], - queue.queueBack, - { + useRegisteredHotkeys({ + id: 'invoke', + category: 'app', + callback: queue.queueBack, + options: { enabled: !queue.isDisabled && !queue.isLoading, preventDefault: true, enableOnFormTags: ['input', 'textarea', 'select'], }, - [queue] - ); + dependencies: [queue], + }); - useHotkeys( - ['ctrl+shift+enter', 'meta+shift+enter'], - queue.queueFront, - { + useRegisteredHotkeys({ + id: 'invokeFront', + category: 'app', + callback: queue.queueFront, + options: { enabled: !queue.isDisabled && !queue.isLoading, preventDefault: true, enableOnFormTags: ['input', 'textarea', 'select'], }, - [queue] - ); + dependencies: [queue], + }); const { cancelQueueItem, @@ -40,75 +42,83 @@ export const useGlobalHotkeys = () => { isLoading: isLoadingCancelQueueItem, } = useCancelCurrentQueueItem(); - useHotkeys( - ['shift+x'], - cancelQueueItem, - { + useRegisteredHotkeys({ + id: 'cancelQueueItem', + category: 'app', + callback: cancelQueueItem, + options: { enabled: !isDisabledCancelQueueItem && !isLoadingCancelQueueItem, preventDefault: true, }, - [cancelQueueItem, isDisabledCancelQueueItem, isLoadingCancelQueueItem] - ); + dependencies: [cancelQueueItem, isDisabledCancelQueueItem, isLoadingCancelQueueItem], + }); const { clearQueue, isDisabled: isDisabledClearQueue, isLoading: isLoadingClearQueue } = useClearQueue(); - useHotkeys( - ['ctrl+shift+x', 'meta+shift+x'], - clearQueue, - { + useRegisteredHotkeys({ + id: 'clearQueue', + category: 'app', + callback: clearQueue, + options: { enabled: !isDisabledClearQueue && !isLoadingClearQueue, preventDefault: true, }, - [clearQueue, isDisabledClearQueue, isLoadingClearQueue] - ); + dependencies: [clearQueue, isDisabledClearQueue, isLoadingClearQueue], + }); - useHotkeys( - '1', - () => { + useRegisteredHotkeys({ + id: 'selectCanvasTab', + category: 'app', + callback: () => { dispatch(setActiveTab('canvas')); addScope('canvas'); removeScope('workflows'); }, - [dispatch] - ); + dependencies: [dispatch], + }); - useHotkeys( - '2', - () => { + useRegisteredHotkeys({ + id: 'selectUpscalingTab', + category: 'app', + callback: () => { dispatch(setActiveTab('upscaling')); removeScope('canvas'); removeScope('workflows'); }, - [dispatch] - ); + dependencies: [dispatch], + }); - useHotkeys( - '3', - () => { + useRegisteredHotkeys({ + id: 'selectWorkflowsTab', + category: 'app', + callback: () => { dispatch(setActiveTab('workflows')); removeScope('canvas'); addScope('workflows'); }, - [dispatch] - ); + dependencies: [dispatch], + }); - useHotkeys( - '4', - () => { - if (isModelManagerEnabled) { - dispatch(setActiveTab('models')); - setScopes([]); - } + useRegisteredHotkeys({ + id: 'selectModelsTab', + category: 'app', + callback: () => { + dispatch(setActiveTab('models')); + setScopes([]); + }, + options: { + enabled: isModelManagerEnabled, }, - [dispatch, isModelManagerEnabled] - ); + dependencies: [dispatch, isModelManagerEnabled], + }); - useHotkeys( - isModelManagerEnabled ? '5' : '4', - () => { + useRegisteredHotkeys({ + id: 'selectQueueTab', + category: 'app', + callback: () => { dispatch(setActiveTab('queue')); setScopes([]); }, - [dispatch, isModelManagerEnabled] - ); + dependencies: [dispatch, isModelManagerEnabled], + }); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index 70e1cfbb84a..2eea46811c4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -12,8 +12,8 @@ import { import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo, useCallback, useMemo, useRef } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; export const CanvasRightPanel = memo(() => { @@ -28,7 +28,12 @@ export const CanvasRightPanel = memo(() => { } imageViewer.toggle(); }, [imageViewer]); - useHotkeys('z', imageViewer.toggle); + useRegisteredHotkeys({ + id: 'toggleViewer', + category: 'viewer', + callback: imageViewer.toggle, + dependencies: [imageViewer], + }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx index b47f9de4296..2e1c3d6a7e4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolBboxButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold } from 'react-icons/pi'; @@ -10,7 +10,13 @@ export const ToolBboxButton = memo(() => { const selectBbox = useSelectTool('bbox'); const isSelected = useToolIsSelected('bbox'); - useHotkeys('c', selectBbox, { enabled: !isSelected }, [selectBbox, isSelected]); + useRegisteredHotkeys({ + id: 'selectBboxTool', + category: 'canvas', + callback: selectBbox, + options: { enabled: !isSelected }, + dependencies: [selectBbox, isSelected], + }); return ( { const isSelected = useToolIsSelected('brush'); const selectBrush = useSelectTool('brush'); - useHotkeys('b', selectBrush, { enabled: !isSelected }, [isSelected, selectBrush]); + useRegisteredHotkeys({ + id: 'selectBrushTool', + category: 'canvas', + callback: selectBrush, + options: { enabled: !isSelected }, + dependencies: [isSelected, selectBrush], + }); return ( { setLocalValue(width); }, [width]); - useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]); - useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]); + useRegisteredHotkeys({ + id: 'incrementToolWidth', + category: 'canvas', + callback: decrement, + options: { enabled: isSelected }, + dependencies: [decrement, isSelected], + }); + useRegisteredHotkeys({ + id: 'incrementToolWidth', + category: 'canvas', + callback: increment, + options: { enabled: isSelected }, + dependencies: [increment, isSelected], + }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx index d74dea968b1..1f6d32d0e78 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolColorPickerButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiEyedropperBold } from 'react-icons/pi'; @@ -10,7 +10,13 @@ export const ToolColorPickerButton = memo(() => { const isSelected = useToolIsSelected('colorPicker'); const selectColorPicker = useSelectTool('colorPicker'); - useHotkeys('i', selectColorPicker, { enabled: !isSelected }, [selectColorPicker, isSelected]); + useRegisteredHotkeys({ + id: 'selectColorPickerTool', + category: 'canvas', + callback: selectColorPicker, + options: { enabled: !isSelected }, + dependencies: [selectColorPicker, isSelected], + }); return ( { const isSelected = useToolIsSelected('eraser'); const selectEraser = useSelectTool('eraser'); - useHotkeys('e', selectEraser, { enabled: !isSelected }, [isSelected, selectEraser]); + useRegisteredHotkeys({ + id: 'selectEraserTool', + category: 'canvas', + callback: selectEraser, + options: { enabled: !isSelected }, + dependencies: [isSelected, selectEraser], + }); return ( { setLocalValue(width); }, [width]); - useHotkeys('[', decrement, { enabled: isSelected }, [decrement, isSelected]); - useHotkeys(']', increment, { enabled: isSelected }, [increment, isSelected]); + useRegisteredHotkeys({ + id: 'incrementToolWidth', + category: 'canvas', + callback: decrement, + options: { enabled: isSelected }, + dependencies: [decrement, isSelected], + }); + useRegisteredHotkeys({ + id: 'incrementToolWidth', + category: 'canvas', + callback: increment, + options: { enabled: isSelected }, + dependencies: [increment, isSelected], + }); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx index 2506cb03da3..8353b616f35 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolMoveButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiCursorBold } from 'react-icons/pi'; @@ -10,7 +10,13 @@ export const ToolMoveButton = memo(() => { const isSelected = useToolIsSelected('move'); const selectMove = useSelectTool('move'); - useHotkeys('v', selectMove, { enabled: !isSelected }, [isSelected, selectMove]); + useRegisteredHotkeys({ + id: 'selectMoveTool', + category: 'canvas', + callback: selectMove, + options: { enabled: !isSelected }, + dependencies: [isSelected, selectMove], + }); return ( { const isSelected = useToolIsSelected('rect'); const selectRect = useSelectTool('rect'); - useHotkeys('u', selectRect, { enabled: !isSelected }, [isSelected, selectRect]); + useRegisteredHotkeys({ + id: 'selectRectTool', + category: 'canvas', + callback: selectRect, + options: { enabled: !isSelected }, + dependencies: [isSelected, selectRect], + }); return ( { const isSelected = useToolIsSelected('view'); const selectView = useSelectTool('view'); - useHotkeys('h', selectView, { enabled: !isSelected }, [selectView, isSelected]); + useRegisteredHotkeys({ + id: 'selectViewTool', + category: 'canvas', + callback: selectView, + options: { enabled: !isSelected }, + dependencies: [selectView, isSelected], + }); return ( { } }, [resetView, resetZoom]); - useHotkeys('r', resetView, { enabled: isCanvasActive }, [isCanvasActive]); - useHotkeys('alt+r', resetZoom, { enabled: isCanvasActive }, [isCanvasActive]); + useRegisteredHotkeys({ + id: 'fitLayersToCanvas', + category: 'canvas', + callback: resetView, + options: { enabled: isCanvasActive }, + dependencies: [isCanvasActive], + }); + useRegisteredHotkeys({ + id: 'setZoomTo100Percent', + category: 'canvas', + callback: resetZoom, + options: { enabled: isCanvasActive }, + dependencies: [isCanvasActive], + }); return ( { const dispatch = useAppDispatch(); @@ -43,5 +43,10 @@ export const useCanvasEntityQuickSwitchHotkey = () => { } }, [bookmarked, current, dispatch, prev]); - useHotkeys('q', onQuickSwitch); + useRegisteredHotkeys({ + id: 'quickSwitch', + category: 'canvas', + callback: onQuickSwitch, + dependencies: [onQuickSwitch], + }); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasResetLayerHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasResetLayerHotkey.ts index 0fe1042b707..91f5f0e6824 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasResetLayerHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasResetLayerHotkey.ts @@ -8,8 +8,8 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { entityReset } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import { isMaskEntityIdentifier } from 'features/controlLayers/store/types'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; const selectSelectedEntityIdentifier = createMemoizedSelector( selectCanvasSlice, @@ -37,10 +37,11 @@ export function useCanvasResetLayerHotkey() { [selectedEntityIdentifier] ); - useHotkeys('shift+c', resetSelectedLayer, { enabled: isResetEnabled && !isBusy && isInteractable }, [ - isResetEnabled, - isBusy, - isInteractable, - resetSelectedLayer, - ]); + useRegisteredHotkeys({ + id: 'resetSelected', + category: 'canvas', + callback: resetSelectedLayer, + options: { enabled: isResetEnabled && !isBusy && isInteractable }, + dependencies: [isResetEnabled, isBusy, isInteractable, resetSelectedLayer], + }); } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasUndoRedoHotkeys.tsx b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasUndoRedoHotkeys.tsx index 93a001ff75c..9e90a197e6c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasUndoRedoHotkeys.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasUndoRedoHotkeys.tsx @@ -3,8 +3,8 @@ import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { canvasRedo, canvasUndo } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasMayRedo, selectCanvasMayUndo } from 'features/controlLayers/store/selectors'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useDispatch } from 'react-redux'; export const useCanvasUndoRedoHotkeys = () => { @@ -16,20 +16,23 @@ export const useCanvasUndoRedoHotkeys = () => { const handleUndo = useCallback(() => { dispatch(canvasUndo()); }, [dispatch]); - useHotkeys(['meta+z', 'ctrl+z'], handleUndo, { enabled: mayUndo && !isBusy, preventDefault: true }, [ - mayUndo, - isBusy, - handleUndo, - ]); + useRegisteredHotkeys({ + id: 'undo', + category: 'canvas', + callback: handleUndo, + options: { enabled: mayUndo && !isBusy, preventDefault: true }, + dependencies: [mayUndo, isBusy, handleUndo], + }); const mayRedo = useAppSelector(selectCanvasMayRedo); const handleRedo = useCallback(() => { dispatch(canvasRedo()); }, [dispatch]); - useHotkeys( - ['meta+shift+z', 'ctrl+shift+z', 'meta+y', 'ctrl+y'], - handleRedo, - { enabled: mayRedo && !isBusy, preventDefault: true }, - [mayRedo, handleRedo, isBusy] - ); + useRegisteredHotkeys({ + id: 'redo', + category: 'canvas', + callback: handleRedo, + options: { enabled: mayRedo && !isBusy, preventDefault: true }, + dependencies: [mayRedo, handleRedo, isBusy], + }); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useNextPrevEntity.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useNextPrevEntity.ts index b50a8d2dec7..000ecc53725 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useNextPrevEntity.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useNextPrevEntity.ts @@ -5,6 +5,7 @@ import { entitySelected } from 'features/controlLayers/store/canvasSlice'; import { selectAllEntities, selectCanvasSlice } from 'features/controlLayers/store/selectors'; import type { CanvasEntityState } from 'features/controlLayers/store/types'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -59,6 +60,9 @@ export const useNextPrevEntityHotkeys = () => { } }, [dispatch, prevEntityIdentifier]); + // There's some weirdness with the key codes - I need to register both `“` and `alt+[` for prev, and `‘` and `alt+]` + // for next to get these to work as expected. + useHotkeys( // “ === alt+[ ['“'], @@ -66,8 +70,13 @@ export const useNextPrevEntityHotkeys = () => { { preventDefault: true, ignoreModifiers: true }, [selectPrevEntity] ); - - useHotkeys(['alt+['], selectPrevEntity, { preventDefault: true }, [selectPrevEntity]); + useRegisteredHotkeys({ + category: 'canvas', + id: 'prevEntity', + callback: selectPrevEntity, + options: { preventDefault: true }, + dependencies: [selectPrevEntity], + }); useHotkeys( // ‘ === alt+] ['‘'], @@ -75,5 +84,11 @@ export const useNextPrevEntityHotkeys = () => { { preventDefault: true, ignoreModifiers: true }, [selectNextEntity] ); - useHotkeys(['alt+]'], selectNextEntity, { preventDefault: true }, [selectNextEntity]); + useRegisteredHotkeys({ + category: 'canvas', + id: 'nextEntity', + callback: selectNextEntity, + options: { preventDefault: true }, + dependencies: [selectNextEntity], + }); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index cc6872ab7eb..15e086ceaf2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -4,10 +4,10 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { $activeScopes } from 'common/hooks/interactionScopes'; import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { $isRightPanelOpen } from 'features/ui/store/uiSlice'; import { computed } from 'nanostores'; import { useCallback } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; const $isSelectAllEnabled = computed([$activeScopes, $isRightPanelOpen], (activeScopes, isGalleryPanelOpen) => { @@ -32,16 +32,21 @@ export const GallerySelectionCountTag = () => { dispatch(selectionChanged([...selection, ...imageDTOs])); }, [dispatch, selection, imageDTOs]); - useHotkeys(['ctrl+a', 'meta+a'], onSelectPage, { preventDefault: true, enabled: isSelectAllEnabled }, [ - onSelectPage, - isSelectAllEnabled, - ]); + useRegisteredHotkeys({ + id: 'selectAllOnPage', + category: 'gallery', + callback: onSelectPage, + options: { preventDefault: true, enabled: isSelectAllEnabled }, + dependencies: [onSelectPage, isSelectAllEnabled], + }); - useHotkeys('esc', onClearSelection, { enabled: selection.length > 0 && isSelectAllEnabled }, [ - onClearSelection, - selection, - isSelectAllEnabled, - ]); + useRegisteredHotkeys({ + id: 'clearSelection', + category: 'gallery', + callback: onClearSelection, + options: { enabled: selection.length > 0 && isSelectAllEnabled }, + dependencies: [onClearSelection, selection, isSelectAllEnabled], + }); if (selection.length <= 1) { return null; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CompareToolbar.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CompareToolbar.tsx index e2f2944c314..5a6d326a52f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CompareToolbar.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CompareToolbar.tsx @@ -18,6 +18,7 @@ import { comparisonModeCycled, imageToCompareChanged, } from 'features/gallery/store/gallerySlice'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo, useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; @@ -40,7 +41,12 @@ export const CompareToolbar = memo(() => { const swapImages = useCallback(() => { dispatch(comparedImagesSwapped()); }, [dispatch]); - useHotkeys('c', swapImages, [swapImages]); + useRegisteredHotkeys({ + id: 'swapImages', + category: 'viewer', + callback: swapImages, + dependencies: [swapImages], + }); const toggleComparisonFit = useCallback(() => { dispatch(comparisonFitChanged(comparisonFit === 'contain' ? 'fill' : 'contain')); }, [dispatch, comparisonFit]); @@ -51,7 +57,7 @@ export const CompareToolbar = memo(() => { const nextMode = useCallback(() => { dispatch(comparisonModeCycled()); }, [dispatch]); - useHotkeys('m', nextMode, [nextMode]); + useRegisteredHotkeys({ id: 'nextComparisonMode', category: 'viewer', callback: nextMode, dependencies: [nextMode] }); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index 35e9f6f5b60..f4301b0532f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -13,11 +13,11 @@ import { parseAndRecallImageDimensions } from 'features/metadata/util/handlers'; import { $templates } from 'features/nodes/store/nodesSlice'; import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover'; import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useGetAndLoadEmbeddedWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadEmbeddedWorkflow'; import { size } from 'lodash-es'; import { memo, useCallback, useMemo } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiArrowsCounterClockwiseBold, @@ -75,18 +75,55 @@ const CurrentImageButtons = () => { dispatch(imagesToDeleteSelected([imageDTO])); }, [dispatch, imageDTO]); - useHotkeys('w', handleLoadWorkflow, { enabled: isImageViewerActive }, [lastSelectedImage, isImageViewerActive]); - useHotkeys('a', recallAll, { enabled: isImageViewerActive }, [recallAll, isImageViewerActive]); - useHotkeys('s', recallSeed, { enabled: isImageViewerActive }, [recallSeed, isImageViewerActive]); - useHotkeys('p', recallPrompts, { enabled: isImageViewerActive }, [recallPrompts, isImageViewerActive]); - useHotkeys('r', remix, { enabled: isImageViewerActive }, [remix, isImageViewerActive]); - useHotkeys('d', handleUseSize, { enabled: isImageViewerActive }, [handleUseSize, isImageViewerActive]); - useHotkeys( - 'shift+u', - handleClickUpscale, - { enabled: Boolean(isUpscalingEnabled && isImageViewerActive && isConnected) }, - [isUpscalingEnabled, imageDTO, shouldDisableToolbarButtons, isConnected, isImageViewerActive] - ); + useRegisteredHotkeys({ + id: 'loadWorkflow', + category: 'viewer', + callback: handleLoadWorkflow, + options: { enabled: isImageViewerActive }, + dependencies: [handleLoadWorkflow, isImageViewerActive], + }); + useRegisteredHotkeys({ + id: 'recallAll', + category: 'viewer', + callback: recallAll, + options: { enabled: isImageViewerActive }, + dependencies: [recallAll, isImageViewerActive], + }); + useRegisteredHotkeys({ + id: 'recallSeed', + category: 'viewer', + callback: recallSeed, + options: { enabled: isImageViewerActive }, + dependencies: [recallSeed, isImageViewerActive], + }); + useRegisteredHotkeys({ + id: 'recallPrompts', + category: 'viewer', + callback: recallPrompts, + options: { enabled: isImageViewerActive }, + dependencies: [recallPrompts, isImageViewerActive], + }); + useRegisteredHotkeys({ + id: 'remix', + category: 'viewer', + callback: remix, + options: { enabled: isImageViewerActive }, + dependencies: [remix, isImageViewerActive], + }); + useRegisteredHotkeys({ + id: 'useSize', + category: 'viewer', + callback: handleUseSize, + options: { enabled: isImageViewerActive }, + dependencies: [handleUseSize, isImageViewerActive], + }); + useRegisteredHotkeys({ + id: 'runPostprocessing', + category: 'viewer', + callback: handleClickUpscale, + options: { enabled: Boolean(isUpscalingEnabled && isImageViewerActive && isConnected) }, + dependencies: [isUpscalingEnabled, imageDTO, shouldDisableToolbarButtons, isConnected, isImageViewerActive], + }); return ( <> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx index a27875f81d2..e728a87caf5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx @@ -2,10 +2,10 @@ import { IconButton } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { selectShouldShowImageDetails } from 'features/ui/store/uiSelectors'; import { setShouldShowImageDetails } from 'features/ui/store/uiSlice'; import { memo, useCallback } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiInfoBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; @@ -23,7 +23,13 @@ export const ToggleMetadataViewerButton = memo(() => { [dispatch, shouldShowImageDetails] ); - useHotkeys('i', toggleMetadataViewer, { enabled: Boolean(imageDTO) }, [imageDTO, shouldShowImageDetails]); + useRegisteredHotkeys({ + id: 'toggleMetadata', + category: 'viewer', + callback: toggleMetadataViewer, + options: { enabled: Boolean(imageDTO) }, + dependencies: [imageDTO, shouldShowImageDetails], + }); return ( { @@ -59,69 +59,160 @@ export const useGalleryHotkeys = () => { isOnLastImageOfView, } = useGalleryNavigation(); - useHotkeys( - ['left', 'alt+left'], - (e) => { + useRegisteredHotkeys({ + id: 'galleryNavLeft', + category: 'gallery', + callback: () => { if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) { - goPrev(e.altKey ? 'alt+arrow' : 'arrow'); + goPrev('arrow'); return; } - handleLeftImage(e.altKey); + handleLeftImage(false); }, - { preventDefault: true, enabled: leftRightHotkeysEnabled }, - [handleLeftImage, isOnFirstImageOfView, goPrev, isPrevEnabled, queryResult.isFetching, leftRightHotkeysEnabled] - ); + options: { preventDefault: true, enabled: leftRightHotkeysEnabled }, + dependencies: [ + handleLeftImage, + isOnFirstImageOfView, + goPrev, + isPrevEnabled, + queryResult.isFetching, + leftRightHotkeysEnabled, + ], + }); - useHotkeys( - ['right', 'alt+right'], - (e) => { + useRegisteredHotkeys({ + id: 'galleryNavRight', + category: 'gallery', + callback: () => { if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) { - goNext(e.altKey ? 'alt+arrow' : 'arrow'); + goNext('arrow'); return; } if (!isOnLastImageOfView) { - handleRightImage(e.altKey); + handleRightImage(false); } }, - { preventDefault: true, enabled: leftRightHotkeysEnabled }, - [isOnLastImageOfView, goNext, isNextEnabled, queryResult.isFetching, handleRightImage, leftRightHotkeysEnabled] - ); + options: { preventDefault: true, enabled: leftRightHotkeysEnabled }, + dependencies: [ + isOnLastImageOfView, + goNext, + isNextEnabled, + queryResult.isFetching, + handleRightImage, + leftRightHotkeysEnabled, + ], + }); - useHotkeys( - ['up', 'alt+up'], - (e) => { + useRegisteredHotkeys({ + id: 'galleryNavUp', + category: 'gallery', + callback: () => { if (isOnFirstRow && isPrevEnabled && !queryResult.isFetching) { - goPrev(e.altKey ? 'alt+arrow' : 'arrow'); + goPrev('arrow'); return; } - handleUpImage(e.altKey); + handleUpImage(false); }, - { preventDefault: true, enabled: upDownHotkeysEnabled }, - [handleUpImage, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching, upDownHotkeysEnabled] - ); + options: { preventDefault: true, enabled: upDownHotkeysEnabled }, + dependencies: [handleUpImage, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching, upDownHotkeysEnabled], + }); - useHotkeys( - ['down', 'alt+down'], - (e) => { + useRegisteredHotkeys({ + id: 'galleryNavDown', + category: 'gallery', + callback: () => { if (isOnLastRow && isNextEnabled && !queryResult.isFetching) { - goNext(e.altKey ? 'alt+arrow' : 'arrow'); + goNext('arrow'); return; } - handleDownImage(e.altKey); + handleDownImage(false); }, - { preventDefault: true, enabled: upDownHotkeysEnabled }, - [isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage, upDownHotkeysEnabled] - ); + options: { preventDefault: true, enabled: upDownHotkeysEnabled }, + dependencies: [isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage, upDownHotkeysEnabled], + }); - const handleDelete = useCallback(() => { - if (!selection.length) { - return; - } - dispatch(imagesToDeleteSelected(selection)); - }, [dispatch, selection]); + useRegisteredHotkeys({ + id: 'galleryNavLeftAlt', + category: 'gallery', + callback: () => { + if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) { + goPrev('alt+arrow'); + return; + } + handleLeftImage(true); + }, + options: { preventDefault: true, enabled: leftRightHotkeysEnabled }, + dependencies: [ + handleLeftImage, + isOnFirstImageOfView, + goPrev, + isPrevEnabled, + queryResult.isFetching, + leftRightHotkeysEnabled, + ], + }); - useHotkeys(['delete', 'backspace'], handleDelete, { enabled: leftRightHotkeysEnabled && isDeleteEnabledByTab }, [ - leftRightHotkeysEnabled, - isDeleteEnabledByTab, - ]); + useRegisteredHotkeys({ + id: 'galleryNavRightAlt', + category: 'gallery', + callback: () => { + if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) { + goNext('alt+arrow'); + return; + } + if (!isOnLastImageOfView) { + handleRightImage(true); + } + }, + options: { preventDefault: true, enabled: leftRightHotkeysEnabled }, + dependencies: [ + isOnLastImageOfView, + goNext, + isNextEnabled, + queryResult.isFetching, + handleRightImage, + leftRightHotkeysEnabled, + ], + }); + + useRegisteredHotkeys({ + id: 'galleryNavUpAlt', + category: 'gallery', + callback: () => { + if (isOnFirstRow && isPrevEnabled && !queryResult.isFetching) { + goPrev('alt+arrow'); + return; + } + handleUpImage(true); + }, + options: { preventDefault: true, enabled: upDownHotkeysEnabled }, + dependencies: [handleUpImage, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching, upDownHotkeysEnabled], + }); + + useRegisteredHotkeys({ + id: 'galleryNavDownAlt', + category: 'gallery', + callback: () => { + if (isOnLastRow && isNextEnabled && !queryResult.isFetching) { + goNext('alt+arrow'); + return; + } + handleDownImage(true); + }, + options: { preventDefault: true, enabled: upDownHotkeysEnabled }, + dependencies: [isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage, upDownHotkeysEnabled], + }); + + useRegisteredHotkeys({ + id: 'deleteSelection', + category: 'gallery', + callback: () => { + if (!selection.length) { + return; + } + dispatch(imagesToDeleteSelected(selection)); + }, + options: { enabled: leftRightHotkeysEnabled && isDeleteEnabledByTab }, + dependencies: [leftRightHotkeysEnabled, isDeleteEnabledByTab], + }); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx index 4e63c78bca6..d23c81fec09 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx @@ -32,13 +32,13 @@ import { getFirstValidConnection } from 'features/nodes/store/util/getFirstValid import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes'; import { isInvocationNode } from 'features/nodes/types/invocation'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { toast } from 'features/toast/toast'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memoize } from 'lodash-es'; import { computed } from 'nanostores'; import type { ChangeEvent } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { PiFlaskBold, PiHammerBold } from 'react-icons/pi'; import type { EdgeChange, NodeChange } from 'reactflow'; @@ -169,7 +169,13 @@ export const AddNodeCmdk = memo(() => { const tab = useAppSelector(selectActiveTab); const throttledSearchTerm = useThrottle(searchTerm, 100); - useHotkeys(['shift+a', 'space'], addNodeCmdk.setTrue, { enabled: tab === 'workflows', preventDefault: true }, [tab]); + useRegisteredHotkeys({ + id: 'addNode', + category: 'workflows', + callback: addNodeCmdk.setTrue, + options: { enabled: tab === 'workflows', preventDefault: true }, + dependencies: [addNodeCmdk.setTrue, tab], + }); const onChange = useCallback((e: ChangeEvent) => { setSearchTerm(e.target.value); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index ad631d3125c..b60ef6715fc 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -30,6 +30,7 @@ import { } from 'features/nodes/store/selectors'; import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import type { CSSProperties, MouseEvent } from 'react'; import { memo, useCallback, useMemo, useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -208,75 +209,80 @@ export const Flow = memo(() => { // #endregion - const { copySelection, pasteSelection } = useCopyPaste(); + const { copySelection, pasteSelection, pasteSelectionWithEdges } = useCopyPaste(); - const onCopyHotkey = useCallback( - (e: KeyboardEvent) => { - e.preventDefault(); - copySelection(); - }, - [copySelection] - ); - useHotkeys(['Ctrl+c', 'Meta+c'], onCopyHotkey); - - const onSelectAllHotkey = useCallback( - (e: KeyboardEvent) => { - e.preventDefault(); - const { nodes, edges } = selectNodesSlice(store.getState()); - const nodeChanges: NodeChange[] = []; - const edgeChanges: EdgeChange[] = []; - nodes.forEach(({ id, selected }) => { - if (!selected) { - nodeChanges.push({ type: 'select', id, selected: true }); - } - }); - edges.forEach(({ id, selected }) => { - if (!selected) { - edgeChanges.push({ type: 'select', id, selected: true }); - } - }); - if (nodeChanges.length > 0) { - dispatch(nodesChanged(nodeChanges)); + useRegisteredHotkeys({ + id: 'copySelection', + category: 'workflows', + callback: copySelection, + options: { preventDefault: true }, + dependencies: [copySelection], + }); + + const selectAll = useCallback(() => { + const { nodes, edges } = selectNodesSlice(store.getState()); + const nodeChanges: NodeChange[] = []; + const edgeChanges: EdgeChange[] = []; + nodes.forEach(({ id, selected }) => { + if (!selected) { + nodeChanges.push({ type: 'select', id, selected: true }); } - if (edgeChanges.length > 0) { - dispatch(edgesChanged(edgeChanges)); + }); + edges.forEach(({ id, selected }) => { + if (!selected) { + edgeChanges.push({ type: 'select', id, selected: true }); } - }, - [dispatch, store] - ); - useHotkeys(['Ctrl+a', 'Meta+a'], onSelectAllHotkey, { enabled: isWorkflowsActive }, [isWorkflowsActive]); - - const onPasteHotkey = useCallback( - (e: KeyboardEvent) => { - e.preventDefault(); - pasteSelection(); - }, - [pasteSelection] - ); - useHotkeys(['Ctrl+v', 'Meta+v'], onPasteHotkey); - - const onPasteWithEdgesToNodesHotkey = useCallback( - (e: KeyboardEvent) => { - e.preventDefault(); - pasteSelection(true); - }, - [pasteSelection] - ); - useHotkeys(['Ctrl+shift+v', 'Meta+shift+v'], onPasteWithEdgesToNodesHotkey); - - const onUndoHotkey = useCallback(() => { - if (mayUndo) { - dispatch(undo()); + }); + if (nodeChanges.length > 0) { + dispatch(nodesChanged(nodeChanges)); } - }, [dispatch, mayUndo]); - useHotkeys(['meta+z', 'ctrl+z'], onUndoHotkey); - - const onRedoHotkey = useCallback(() => { - if (mayRedo) { - dispatch(redo()); + if (edgeChanges.length > 0) { + dispatch(edgesChanged(edgeChanges)); } - }, [dispatch, mayRedo]); - useHotkeys(['meta+shift+z', 'ctrl+shift+z'], onRedoHotkey); + }, [dispatch, store]); + useRegisteredHotkeys({ + id: 'selectAll', + category: 'workflows', + callback: selectAll, + options: { enabled: isWorkflowsActive, preventDefault: true }, + dependencies: [selectAll, isWorkflowsActive], + }); + + useRegisteredHotkeys({ + id: 'pasteSelection', + category: 'workflows', + callback: pasteSelection, + options: { preventDefault: true }, + dependencies: [pasteSelection], + }); + + useRegisteredHotkeys({ + id: 'pasteSelectionWithEdges', + category: 'workflows', + callback: pasteSelectionWithEdges, + options: { preventDefault: true }, + dependencies: [pasteSelectionWithEdges], + }); + + useRegisteredHotkeys({ + id: 'undo', + category: 'workflows', + callback: () => { + dispatch(undo()); + }, + options: { enabled: mayUndo, preventDefault: true }, + dependencies: [mayUndo], + }); + + useRegisteredHotkeys({ + id: 'redo', + category: 'workflows', + callback: () => { + dispatch(redo()); + }, + options: { enabled: mayRedo, preventDefault: true }, + dependencies: [mayRedo], + }); const onEscapeHotkey = useCallback(() => { if (!$edgePendingUpdate.get()) { @@ -287,7 +293,7 @@ export const Flow = memo(() => { }, [cancelConnection]); useHotkeys('esc', onEscapeHotkey); - const onDeleteHotkey = useCallback(() => { + const deleteSelection = useCallback(() => { const { nodes, edges } = selectNodesSlice(store.getState()); const nodeChanges: NodeChange[] = []; const edgeChanges: EdgeChange[] = []; @@ -308,7 +314,12 @@ export const Flow = memo(() => { dispatch(edgesChanged(edgeChanges)); } }, [dispatch, store]); - useHotkeys(['delete', 'backspace'], onDeleteHotkey); + useRegisteredHotkeys({ + id: 'deleteSelection', + category: 'workflows', + callback: deleteSelection, + dependencies: [deleteSelection], + }); return ( { $edgesToCopiedNodes.set(edgesToSelectedNodes); }; -const pasteSelection = (withEdgesToCopiedNodes?: boolean) => { +const _pasteSelection = (withEdgesToCopiedNodes?: boolean) => { const { getState, dispatch } = getStore(); const { nodes, edges } = selectNodesSlice(getState()); const cursorPos = $cursorPos.get(); @@ -116,7 +116,15 @@ const pasteSelection = (withEdgesToCopiedNodes?: boolean) => { } }; -const api = { copySelection, pasteSelection }; +const pasteSelection = () => { + _pasteSelection(false); +}; + +const pasteSelectionWithEdges = () => { + _pasteSelection(true); +}; + +const api = { copySelection, pasteSelection, pasteSelectionWithEdges }; export const useCopyPaste = () => { return api; diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx index cc3f9a86be5..0953b668c05 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx @@ -13,9 +13,9 @@ import { selectStylePresetActivePresetId, selectStylePresetViewMode, } from 'features/stylePresets/store/stylePresetSlice'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo, useCallback, useRef } from 'react'; import type { HotkeyCallback } from 'react-hotkeys-hook'; -import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; @@ -58,7 +58,13 @@ export const ParamPositivePrompt = memo(() => { [onFocus] ); - useHotkeys('alt+a', focus, []); + useRegisteredHotkeys({ + id: 'focusPrompt', + category: 'app', + callback: focus, + options: { preventDefault: true, enableOnFormTags: ['INPUT', 'SELECT', 'TEXTAREA'] }, + dependencies: [focus], + }); return ( diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx index 864d9be8831..39267ddc9d9 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeyListItem.tsx @@ -1,37 +1,34 @@ import { Flex, Kbd, Spacer, Text } from '@invoke-ai/ui-library'; +import type { Hotkey } from 'features/system/components/HotkeysModal/useHotkeyData'; import { Fragment, memo } from 'react'; import { useTranslation } from 'react-i18next'; -interface HotkeysModalProps { - hotkeys: string[][]; - title: string; - description: string; +interface Props { + hotkey: Hotkey; } -const HotkeyListItem = (props: HotkeysModalProps) => { +const HotkeyListItem = ({ hotkey }: Props) => { const { t } = useTranslation(); - const { title, hotkeys, description } = props; + const { id, platformKeys, title, desc } = hotkey; return ( {title} - {hotkeys.map((hotkey, index) => { + {platformKeys.map((hotkey, i1) => { return ( - - {hotkey.map((key, index) => ( - <> - - {key} - - {index !== hotkey.length - 1 && ( + + {hotkey.map((key, i2) => ( + + {key} + {i2 !== hotkey.length - 1 && ( + )} - + ))} - {index !== hotkeys.length - 1 && ( + {i1 !== platformKeys.length - 1 && ( {t('common.or')} @@ -40,7 +37,7 @@ const HotkeyListItem = (props: HotkeysModalProps) => { ); })} - {description} + {desc} ); }; diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx index 5189520ad3a..953f0f724c3 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/HotkeysModal.tsx @@ -16,11 +16,11 @@ import { } from '@invoke-ai/ui-library'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; -import type { HotkeyGroup } from 'features/system/components/HotkeysModal/useHotkeyData'; +import type { Hotkey } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useHotkeyData } from 'features/system/components/HotkeysModal/useHotkeyData'; import { StickyScrollable } from 'features/system/components/StickyScrollable'; import type { ChangeEventHandler, ReactElement } from 'react'; -import { cloneElement, Fragment, memo, useCallback, useMemo, useState } from 'react'; +import { cloneElement, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; @@ -31,53 +31,62 @@ type HotkeysModalProps = { children: ReactElement; }; +type TransformedHotkeysCategoryData = { + title: string; + hotkeys: Hotkey[]; +}; + const HotkeysModal = ({ children }: HotkeysModalProps) => { const { isOpen, onOpen, onClose } = useDisclosure(); const { t } = useTranslation(); const [hotkeyFilter, setHotkeyFilter] = useState(''); + const inputRef = useRef(null); const clearHotkeyFilter = useCallback(() => setHotkeyFilter(''), []); const onChange = useCallback>((e) => setHotkeyFilter(e.target.value), []); - const hotkeyGroups = useHotkeyData(); - const filteredHotkeyGroups = useMemo(() => { - if (!hotkeyFilter.trim().length) { - return hotkeyGroups; - } + const hotkeysData = useHotkeyData(); + const filteredHotkeys = useMemo(() => { const trimmedHotkeyFilter = hotkeyFilter.trim().toLowerCase(); - const filteredGroups: HotkeyGroup[] = []; - hotkeyGroups.forEach((group) => { - const filteredGroup: HotkeyGroup = { - title: group.title, - hotkeyListItems: [], + const filteredCategories: TransformedHotkeysCategoryData[] = []; + Object.values(hotkeysData).forEach((category) => { + const filteredGroup: TransformedHotkeysCategoryData = { + title: category.title, + hotkeys: [], }; - group.hotkeyListItems.forEach((item) => { - if ( - item.title.toLowerCase().includes(trimmedHotkeyFilter) || - item.desc.toLowerCase().includes(trimmedHotkeyFilter) || - item.hotkeys.some((hotkey) => hotkey.some((key) => key.toLowerCase().includes(trimmedHotkeyFilter))) + Object.values(category.hotkeys).forEach((item) => { + if (!trimmedHotkeyFilter.length) { + filteredGroup.hotkeys.push(item); + } else if (item.title.toLowerCase().includes(trimmedHotkeyFilter)) { + filteredGroup.hotkeys.push(item); + } else if (item.desc.toLowerCase().includes(trimmedHotkeyFilter)) { + filteredGroup.hotkeys.push(item); + } else if (item.category.toLowerCase().includes(trimmedHotkeyFilter)) { + filteredGroup.hotkeys.push(item); + } else if ( + item.platformKeys.some((hotkey) => hotkey.some((key) => key.toLowerCase().includes(trimmedHotkeyFilter))) ) { - filteredGroup.hotkeyListItems.push(item); + filteredGroup.hotkeys.push(item); } }); - if (filteredGroup.hotkeyListItems.length) { - filteredGroups.push(filteredGroup); + if (filteredGroup.hotkeys.length) { + filteredCategories.push(filteredGroup); } }); - return filteredGroups; - }, [hotkeyGroups, hotkeyFilter]); + return filteredCategories; + }, [hotkeysData, hotkeyFilter]); return ( <> {cloneElement(children, { onClick: onOpen, })} - + - {t('hotkeys.keyboardShortcuts')} + {t('hotkeys.hotkeys')} - + {hotkeyFilter.length && ( { - {filteredHotkeyGroups.map((group) => ( - - {group.hotkeyListItems.map((hotkey, i) => ( - - - {i < group.hotkeyListItems.length - 1 && } + {filteredHotkeys.map((category) => ( + + {category.hotkeys.map((hotkey, i) => ( + + + {i < category.hotkeys.length - 1 && } ))} ))} - {!filteredHotkeyGroups.length && ( - - )} + {!filteredHotkeys.length && } diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts index 76590db443b..5f7b5878d7f 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts @@ -1,315 +1,194 @@ +import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useMemo } from 'react'; +import { type HotkeyCallback, type Options, useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; +import { assert } from 'tsafe'; -type HotkeyListItem = { +type HotkeyCategory = 'app' | 'canvas' | 'viewer' | 'gallery' | 'workflows'; + +export type Hotkey = { + id: string; + category: string; title: string; desc: string; - hotkeys: string[][]; + hotkeys: string[]; + platformKeys: string[][]; }; -export type HotkeyGroup = { - title: string; - hotkeyListItems: HotkeyListItem[]; +type HotkeyCategoryData = { title: string; hotkeys: Record }; + +type HotkeysData = Record; + +const formatKeysForPlatform = (keys: string[], isMacOS: boolean): string[][] => { + return keys.map((k) => { + if (isMacOS) { + return k.split('+').map((i) => i.replaceAll('mod', 'cmd').replaceAll('alt', 'option')); + } else { + return k.split('+').map((i) => i.replaceAll('mod', 'ctrl')); + } + }); }; -export const useHotkeyData = (): HotkeyGroup[] => { +export const useHotkeyData = (): HotkeysData => { const { t } = useTranslation(); + const isModelManagerEnabled = useFeatureStatus('modelManager'); + const isMacOS = useMemo(() => { + return navigator.userAgent.toLowerCase().includes('mac'); + }, []); + + const hotkeysData = useMemo(() => { + const data: HotkeysData = { + app: { + title: t('hotkeys.app.title'), + hotkeys: {}, + }, + canvas: { + title: t('hotkeys.canvas.title'), + hotkeys: {}, + }, + viewer: { + title: t('hotkeys.viewer.title'), + hotkeys: {}, + }, + gallery: { + title: t('hotkeys.gallery.title'), + hotkeys: {}, + }, + workflows: { + title: t('hotkeys.workflows.title'), + hotkeys: {}, + }, + }; + + const addHotkey = (category: HotkeyCategory, id: string, keys: string[]) => { + data[category].hotkeys[id] = { + id, + category, + title: t(`hotkeys.${category}.${id}.title`), + desc: t(`hotkeys.${category}.${id}.desc`), + hotkeys: keys, + platformKeys: formatKeysForPlatform(keys, isMacOS), + }; + }; - const appHotkeys = useMemo( - () => ({ - title: t('hotkeys.appHotkeys'), - hotkeyListItems: [ - { - title: t('hotkeys.invoke.title'), - desc: t('hotkeys.invoke.desc'), - hotkeys: [['Ctrl', 'Enter']], - }, - { - title: t('hotkeys.cancel.title'), - desc: t('hotkeys.cancel.desc'), - hotkeys: [['Shift', 'X']], - }, - { - title: t('hotkeys.cancelAndClear.title'), - desc: t('hotkeys.cancelAndClear.desc'), - hotkeys: [ - ['Shift', 'Ctrl', 'X'], - ['Shift', 'Cmd', 'X'], - ], - }, - { - title: t('hotkeys.focusPrompt.title'), - desc: t('hotkeys.focusPrompt.desc'), - hotkeys: [['Alt', 'A']], - }, - { - title: t('hotkeys.toggleOptions.title'), - desc: t('hotkeys.toggleOptions.desc'), - hotkeys: [['T'], ['O']], - }, - { - title: t('hotkeys.toggleGallery.title'), - desc: t('hotkeys.toggleGallery.desc'), - hotkeys: [['G']], - }, - { - title: t('hotkeys.toggleOptionsAndGallery.title'), - desc: t('hotkeys.toggleOptionsAndGallery.desc'), - hotkeys: [['F']], - }, - { - title: t('hotkeys.resetOptionsAndGallery.title'), - desc: t('hotkeys.resetOptionsAndGallery.desc'), - hotkeys: [['Shift', 'R']], - }, - { - title: t('hotkeys.maximizeWorkSpace.title'), - desc: t('hotkeys.maximizeWorkSpace.desc'), - hotkeys: [['F']], - }, - { - title: t('hotkeys.changeTabs.title'), - desc: t('hotkeys.changeTabs.desc'), - hotkeys: [['1 - 6']], - }, - ], - }), - [t] - ); + // App + addHotkey('app', 'invoke', ['mod+enter']); + addHotkey('app', 'invokeFront', ['mod+shift+enter']); + addHotkey('app', 'cancelQueueItem', ['shift+x']); + addHotkey('app', 'clearQueue', ['mod+shift+x']); + addHotkey('app', 'selectCanvasTab', ['1']); + addHotkey('app', 'selectUpscalingTab', ['2']); + addHotkey('app', 'selectWorkflowsTab', ['3']); + if (isModelManagerEnabled) { + addHotkey('app', 'selectModelsTab', ['4']); + } + addHotkey('app', 'selectQueueTab', isModelManagerEnabled ? ['5'] : ['4']); + addHotkey('app', 'focusPrompt', ['alt+a']); + addHotkey('app', 'toggleLeftPanel', ['t', 'o']); + addHotkey('app', 'toggleRightPanel', ['g']); + addHotkey('app', 'resetPanelLayout', ['shift+r']); + addHotkey('app', 'togglePanels', ['f']); - const generalHotkeys = useMemo( - () => ({ - title: t('hotkeys.generalHotkeys'), - hotkeyListItems: [ - { - title: t('hotkeys.remixImage.title'), - desc: t('hotkeys.remixImage.desc'), - hotkeys: [['R']], - }, - { - title: t('hotkeys.setPrompt.title'), - desc: t('hotkeys.setPrompt.desc'), - hotkeys: [['P']], - }, - { - title: t('hotkeys.setSeed.title'), - desc: t('hotkeys.setSeed.desc'), - hotkeys: [['S']], - }, - { - title: t('hotkeys.setParameters.title'), - desc: t('hotkeys.setParameters.desc'), - hotkeys: [['A']], - }, - { - title: t('hotkeys.postProcess.title'), - desc: t('hotkeys.postProcess.desc'), - hotkeys: [['Shift', 'U']], - }, - { - title: t('hotkeys.showInfo.title'), - desc: t('hotkeys.showInfo.desc'), - hotkeys: [['I']], - }, - { - title: t('hotkeys.sendToImageToImage.title'), - desc: t('hotkeys.sendToImageToImage.desc'), - hotkeys: [['Shift', 'I']], - }, - { - title: t('hotkeys.deleteImage.title'), - desc: t('hotkeys.deleteImage.desc'), - hotkeys: [['Del']], - }, - ], - }), - [t] - ); + // Canvas + addHotkey('canvas', 'selectBrushTool', ['b']); + addHotkey('canvas', 'selectBboxTool', ['c']); + addHotkey('canvas', 'decrementToolWidth', ['[']); + addHotkey('canvas', 'incrementToolWidth', [']']); + addHotkey('canvas', 'selectColorPickerTool', ['i']); + addHotkey('canvas', 'selectEraserTool', ['e']); + addHotkey('canvas', 'selectMoveTool', ['v']); + addHotkey('canvas', 'selectRectTool', ['r']); + addHotkey('canvas', 'selectViewTool', ['h']); + addHotkey('canvas', 'fitLayersToCanvas', ['r']); + addHotkey('canvas', 'setZoomTo100Percent', ['alt+r']); + addHotkey('canvas', 'quickSwitch', ['q']); + addHotkey('canvas', 'deleteSelected', ['delete', 'backspace']); + addHotkey('canvas', 'resetSelected', ['shift+c']); + addHotkey('canvas', 'undo', ['mod+z']); + addHotkey('canvas', 'redo', ['mod+shift+z', 'mod+y']); + addHotkey('canvas', 'nextEntity', ['alt+]']); + addHotkey('canvas', 'prevEntity', ['alt+[']); - const galleryHotkeys = useMemo( - () => ({ - title: t('hotkeys.galleryHotkeys'), - hotkeyListItems: [ - { - title: t('hotkeys.previousImage.title'), - desc: t('hotkeys.previousImage.desc'), - hotkeys: [['Arrow Left']], - }, - { - title: t('hotkeys.nextImage.title'), - desc: t('hotkeys.nextImage.desc'), - hotkeys: [['Arrow Right']], - }, - { - title: t('hotkeys.toggleViewer.title'), - desc: t('hotkeys.toggleViewer.desc'), - hotkeys: [['Z']], - }, - ], - }), - [t] - ); + // Workflows + addHotkey('workflows', 'addNode', ['shift+a', 'space']); + addHotkey('workflows', 'copySelection', ['mod+c']); + addHotkey('workflows', 'pasteSelection', ['mod+v']); + addHotkey('workflows', 'pasteSelectionWithEdges', ['mod+shift+v']); + addHotkey('workflows', 'selectAll', ['mod+a']); + addHotkey('workflows', 'deleteSelection', ['delete', 'backspace']); + addHotkey('workflows', 'undo', ['mod+z']); + addHotkey('workflows', 'redo', ['mod+shift+z', 'mod+y']); - const unifiedCanvasHotkeys = useMemo( - () => ({ - title: t('hotkeys.unifiedCanvasHotkeys'), - hotkeyListItems: [ - { - title: t('hotkeys.selectBrush.title'), - desc: t('hotkeys.selectBrush.desc'), - hotkeys: [['B']], - }, - { - title: t('hotkeys.selectEraser.title'), - desc: t('hotkeys.selectEraser.desc'), - hotkeys: [['E']], - }, - { - title: t('hotkeys.decreaseBrushSize.title'), - desc: t('hotkeys.decreaseBrushSize.desc'), - hotkeys: [['[']], - }, - { - title: t('hotkeys.increaseBrushSize.title'), - desc: t('hotkeys.increaseBrushSize.desc'), - hotkeys: [[']']], - }, - { - title: t('hotkeys.decreaseBrushOpacity.title'), - desc: t('hotkeys.decreaseBrushOpacity.desc'), - hotkeys: [['Shift', '[']], - }, - { - title: t('hotkeys.increaseBrushOpacity.title'), - desc: t('hotkeys.increaseBrushOpacity.desc'), - hotkeys: [['Shift', ']']], - }, - { - title: t('hotkeys.moveTool.title'), - desc: t('hotkeys.moveTool.desc'), - hotkeys: [['V']], - }, - { - title: t('hotkeys.fillBoundingBox.title'), - desc: t('hotkeys.fillBoundingBox.desc'), - hotkeys: [['Shift', 'F']], - }, - { - title: t('hotkeys.eraseBoundingBox.title'), - desc: t('hotkeys.eraseBoundingBox.desc'), - hotkeys: [['Delete', 'Backspace']], - }, - { - title: t('hotkeys.colorPicker.title'), - desc: t('hotkeys.colorPicker.desc'), - hotkeys: [['C']], - }, - { - title: t('hotkeys.toggleSnap.title'), - desc: t('hotkeys.toggleSnap.desc'), - hotkeys: [['N']], - }, - { - title: t('hotkeys.quickToggleMove.title'), - desc: t('hotkeys.quickToggleMove.desc'), - hotkeys: [['Hold Space']], - }, - { - title: t('hotkeys.toggleLayer.title'), - desc: t('hotkeys.toggleLayer.desc'), - hotkeys: [['Q']], - }, - { - title: t('hotkeys.clearMask.title'), - desc: t('hotkeys.clearMask.desc'), - hotkeys: [['Shift', 'C']], - }, - { - title: t('hotkeys.hideMask.title'), - desc: t('hotkeys.hideMask.desc'), - hotkeys: [['H']], - }, - { - title: t('hotkeys.showHideBoundingBox.title'), - desc: t('hotkeys.showHideBoundingBox.desc'), - hotkeys: [['Shift', 'H']], - }, - { - title: t('hotkeys.mergeVisible.title'), - desc: t('hotkeys.mergeVisible.desc'), - hotkeys: [['Shift', 'M']], - }, - { - title: t('hotkeys.saveToGallery.title'), - desc: t('hotkeys.saveToGallery.desc'), - hotkeys: [['Shift', 'S']], - }, - { - title: t('hotkeys.copyToClipboard.title'), - desc: t('hotkeys.copyToClipboard.desc'), - hotkeys: [['Ctrl', 'C']], - }, - { - title: t('hotkeys.downloadImage.title'), - desc: t('hotkeys.downloadImage.desc'), - hotkeys: [['Shift', 'D']], - }, - { - title: t('hotkeys.undoStroke.title'), - desc: t('hotkeys.undoStroke.desc'), - hotkeys: [['Ctrl', 'Z']], - }, - { - title: t('hotkeys.redoStroke.title'), - desc: t('hotkeys.redoStroke.desc'), - hotkeys: [ - ['Ctrl', 'Shift', 'Z'], - ['Ctrl', 'Y'], - ], - }, - { - title: t('hotkeys.resetView.title'), - desc: t('hotkeys.resetView.desc'), - hotkeys: [['R']], - }, - { - title: t('hotkeys.previousStagingImage.title'), - desc: t('hotkeys.previousStagingImage.desc'), - hotkeys: [['Arrow Left']], - }, - { - title: t('hotkeys.nextStagingImage.title'), - desc: t('hotkeys.nextStagingImage.desc'), - hotkeys: [['Arrow Right']], - }, - { - title: t('hotkeys.acceptStagingImage.title'), - desc: t('hotkeys.acceptStagingImage.desc'), - hotkeys: [['Enter']], - }, - ], - }), - [t] - ); + // Viewer + addHotkey('viewer', 'toggleViewer', ['z']); + addHotkey('viewer', 'swapImages', ['c']); + addHotkey('viewer', 'nextComparisonMode', ['m']); + addHotkey('viewer', 'loadWorkflow', ['w']); + addHotkey('viewer', 'recallAll', ['a']); + addHotkey('viewer', 'recallSeed', ['s']); + addHotkey('viewer', 'recallPrompts', ['p']); + addHotkey('viewer', 'remix', ['r']); + addHotkey('viewer', 'useSize', ['d']); + addHotkey('viewer', 'runPostprocessing', ['shift+u']); + addHotkey('viewer', 'toggleMetadata', ['i']); - const nodesHotkeys = useMemo( - () => ({ - title: t('hotkeys.nodesHotkeys'), - hotkeyListItems: [ - { - title: t('hotkeys.addNodes.title'), - desc: t('hotkeys.addNodes.desc'), - hotkeys: [['Shift', 'A'], ['Space']], - }, - ], - }), - [t] - ); + // Gallery + addHotkey('gallery', 'selectAllOnPage', ['mod+a']); + addHotkey('gallery', 'clearSelection', ['esc']); + addHotkey('gallery', 'galleryNavUp', ['up']); + addHotkey('gallery', 'galleryNavRight', ['right']); + addHotkey('gallery', 'galleryNavDown', ['down']); + addHotkey('gallery', 'galleryNavLeft', ['up']); + addHotkey('gallery', 'galleryNavUpAlt', ['alt+up']); + addHotkey('gallery', 'galleryNavRightAlt', ['alt+right']); + addHotkey('gallery', 'galleryNavDownAlt', ['alt+down']); + addHotkey('gallery', 'galleryNavLeftAlt', ['alt+up']); + addHotkey('gallery', 'deleteSelection', ['delete', 'backspace']); + + return data; + }, [isMacOS, isModelManagerEnabled, t]); + + return hotkeysData; +}; + +type UseRegisteredHotkeysArg = { + /** + * The unique identifier for the hotkey. If `title` and `description` are omitted, the `id` will be used to look up + * the translation strings for those fields: + * - `hotkeys.${id}.label` + * - `hotkeys.${id}.description` + */ + id: string; + /** + * The category of the hotkey. This is used to group hotkeys in the hotkeys modal. + */ + category: HotkeyCategory; + /** + * The callback to be invoked when the hotkey is triggered. + */ + callback: HotkeyCallback; + /** + * The options for the hotkey. These are passed directly to `useHotkeys`. + */ + options?: Options; + /** + * The dependencies for the hotkey. These are passed directly to `useHotkeys`. + */ + dependencies?: readonly unknown[]; +}; - const hotkeyGroups = useMemo( - () => [appHotkeys, generalHotkeys, galleryHotkeys, unifiedCanvasHotkeys, nodesHotkeys], - [appHotkeys, generalHotkeys, galleryHotkeys, unifiedCanvasHotkeys, nodesHotkeys] - ); +/** + * A wrapper around `useHotkeys` that registers the hotkey with the hotkey registry. + * + * Registered hotkeys will be displayed in the hotkeys modal. + */ +export const useRegisteredHotkeys = ({ id, category, callback, options, dependencies }: UseRegisteredHotkeysArg) => { + const hotkeysData = useHotkeyData(); + const keys = useMemo(() => { + const _keys = hotkeysData[category].hotkeys[id]?.hotkeys; + assert(_keys !== undefined, `Hotkey ${category}.${id} not found`); + return _keys; + }, [category, hotkeysData, id]); - return hotkeyGroups; + return useHotkeys(keys, callback, options, dependencies); }; diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx index ed1a05c28e6..8f1880651f8 100644 --- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx @@ -7,6 +7,7 @@ import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup'; import QueueControls from 'features/queue/components/QueueControls'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton'; import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons'; import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage'; @@ -29,7 +30,6 @@ import { } from 'features/ui/store/uiSlice'; import type { CSSProperties } from 'react'; import { memo, useMemo, useRef } from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; import type { ImperativePanelGroupHandle } from 'react-resizable-panels'; import { Panel, PanelGroup } from 'react-resizable-panels'; @@ -61,7 +61,13 @@ export const AppContent = memo(() => { [] ); const leftPanel = usePanel(leftPanelUsePanelOptions); - useHotkeys(['t', 'o'], leftPanel.toggle, { enabled: withLeftPanel }, [leftPanel.toggle, withLeftPanel]); + useRegisteredHotkeys({ + id: 'toggleLeftPanel', + category: 'app', + callback: leftPanel.toggle, + options: { enabled: withLeftPanel }, + dependencies: [leftPanel.toggle, withLeftPanel], + }); const withRightPanel = useAppSelector(selectWithRightPanel); const rightPanelUsePanelOptions = useMemo( @@ -77,19 +83,27 @@ export const AppContent = memo(() => { [] ); const rightPanel = usePanel(rightPanelUsePanelOptions); - useHotkeys('g', rightPanel.toggle, { enabled: withRightPanel }, [rightPanel.toggle, withRightPanel]); - - useHotkeys( - 'shift+r', - () => { + useRegisteredHotkeys({ + id: 'toggleRightPanel', + category: 'app', + callback: rightPanel.toggle, + options: { enabled: withRightPanel }, + dependencies: [rightPanel.toggle, withRightPanel], + }); + + useRegisteredHotkeys({ + id: 'resetPanelLayout', + category: 'app', + callback: () => { leftPanel.reset(); rightPanel.reset(); }, - [leftPanel.reset, rightPanel.reset] - ); - useHotkeys( - 'f', - () => { + dependencies: [leftPanel.reset, rightPanel.reset], + }); + useRegisteredHotkeys({ + id: 'togglePanels', + category: 'app', + callback: () => { if (leftPanel.isCollapsed || rightPanel.isCollapsed) { leftPanel.expand(); rightPanel.expand(); @@ -98,15 +112,15 @@ export const AppContent = memo(() => { rightPanel.collapse(); } }, - [ + dependencies: [ leftPanel.isCollapsed, rightPanel.isCollapsed, leftPanel.expand, rightPanel.expand, leftPanel.collapse, rightPanel.collapse, - ] - ); + ], + }); return (