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 9a2b01d0f74..908a42c64d1 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,8 @@
import { IconButton, Tooltip } 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 { useSharedInfoHotkeyTarget } from 'features/system/components/HotkeysModal/useSharedInfoHotkeyTarget';
+import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyedropperBold } from 'react-icons/pi';
@@ -9,12 +10,15 @@ export const ToolColorPickerButton = memo(() => {
const { t } = useTranslation();
const isSelected = useToolIsSelected('colorPicker');
const selectColorPicker = useSelectTool('colorPicker');
+ const hotkeyTarget = useSharedInfoHotkeyTarget();
+ const hotkeyOptions = useMemo(() => ({ enabled: hotkeyTarget === 'colorPicker' }), [hotkeyTarget]);
useRegisteredHotkeys({
id: 'selectColorPickerTool',
category: 'canvas',
callback: selectColorPicker,
- dependencies: [selectColorPicker],
+ options: hotkeyOptions,
+ dependencies: [selectColorPicker, hotkeyTarget],
});
return (
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx
index 9e829ea8dcf..11bafe9554d 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx
@@ -6,6 +6,7 @@ import { memo } from 'react';
import { ImageViewerContextProvider } from './context';
import { ImageComparison } from './ImageComparison';
import { ImageViewer } from './ImageViewer';
+import { useToggleMetadataHotkey } from './useToggleMetadataHotkey';
const selectIsComparing = createSelector(
[selectLastSelectedItem, selectImageToCompare],
@@ -13,18 +14,28 @@ const selectIsComparing = createSelector(
);
export const ImageViewerPanel = memo(() => {
+ return (
+
+
+
+ );
+});
+ImageViewerPanel.displayName = 'ImageViewerPanel';
+
+const ImageViewerPanelContent = memo(() => {
const isComparing = useAppSelector(selectIsComparing);
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
+ useToggleMetadataHotkey();
return (
-
+ <>
{
// The image viewer renders progress images - if no image is selected, show the image viewer anyway
!isComparing && !lastSelectedItem &&
}
{!isComparing && }
{isComparing && }
-
+ >
);
});
-ImageViewerPanel.displayName = 'ImageViewerPanel';
+ImageViewerPanelContent.displayName = 'ImageViewerPanelContent';
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 021d69b0176..33b7bccc9cb 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx
@@ -1,9 +1,6 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { useIsRegionFocused } from 'common/hooks/focus';
-import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
-import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectShouldShowItemDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
import { setShouldShowItemDetails } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
@@ -22,22 +19,12 @@ export const ToggleMetadataViewerButton = memo(() => {
const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer && !isTemporarilyShowingSelectedImage;
const shouldShowItemDetails = useAppSelector(selectShouldShowItemDetails);
- const imageDTO = useAppSelector(selectLastSelectedItem);
const { t } = useTranslation();
- const isViewerFocused = useIsRegionFocused('viewer');
const toggleMetadataViewer = useCallback(() => {
dispatch(setShouldShowItemDetails(!shouldShowItemDetails));
}, [dispatch, shouldShowItemDetails]);
- useRegisteredHotkeys({
- id: 'toggleMetadata',
- category: 'viewer',
- callback: toggleMetadataViewer,
- options: { enabled: isViewerFocused && !isDisabledOverride, preventDefault: true },
- dependencies: [imageDTO, shouldShowItemDetails, isViewerFocused, isDisabledOverride],
- });
-
return (
}
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useToggleMetadataHotkey.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useToggleMetadataHotkey.ts
new file mode 100644
index 00000000000..77fd972bf80
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useToggleMetadataHotkey.ts
@@ -0,0 +1,36 @@
+import { useStore } from '@nanostores/react';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useImageViewerContext } from 'features/gallery/components/ImageViewer/context';
+import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
+import { useSharedInfoHotkeyTarget } from 'features/system/components/HotkeysModal/useSharedInfoHotkeyTarget';
+import { selectShouldShowItemDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
+import { setShouldShowItemDetails } from 'features/ui/store/uiSlice';
+import { useCallback, useMemo } from 'react';
+
+export const useToggleMetadataHotkey = () => {
+ const dispatch = useAppDispatch();
+ const ctx = useImageViewerContext();
+ const hasProgressImage = useStore(ctx.$hasProgressImage);
+ const isTemporarilyShowingSelectedImage = useStore(ctx.$isTemporarilyShowingSelectedImage);
+ const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
+ const shouldShowItemDetails = useAppSelector(selectShouldShowItemDetails);
+
+ const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer && !isTemporarilyShowingSelectedImage;
+ const hotkeyTarget = useSharedInfoHotkeyTarget();
+
+ const toggleMetadataViewer = useCallback(() => {
+ dispatch(setShouldShowItemDetails(!shouldShowItemDetails));
+ }, [dispatch, shouldShowItemDetails]);
+ const hotkeyOptions = useMemo(
+ () => ({ enabled: hotkeyTarget === 'metadata' && !isDisabledOverride, preventDefault: true }),
+ [hotkeyTarget, isDisabledOverride]
+ );
+
+ useRegisteredHotkeys({
+ id: 'toggleMetadata',
+ category: 'viewer',
+ callback: toggleMetadataViewer,
+ options: hotkeyOptions,
+ dependencies: [toggleMetadataViewer, hotkeyTarget, isDisabledOverride],
+ });
+};
diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts
index 54c65c541ea..f42d88f947d 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts
@@ -91,6 +91,10 @@ export const selectGalleryImageMinimumWidth = createSelector(
export const selectComparisonMode = createSelector(selectGallerySlice, (gallery) => gallery.comparisonMode);
export const selectComparisonFit = createSelector(selectGallerySlice, (gallery) => gallery.comparisonFit);
export const selectImageToCompare = createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare);
+export const selectHasMetadataViewerItem = createSelector(
+ [selectLastSelectedItem, selectImageToCompare],
+ (lastSelectedItem, imageToCompare) => Boolean(lastSelectedItem && !imageToCompare)
+);
export const selectAlwaysShouldImageSizeBadge = createSelector(
selectGallerySlice,
(gallery) => gallery.alwaysShowImageSizeBadge
diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/registeredHotkeyOptions.test.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/registeredHotkeyOptions.test.ts
new file mode 100644
index 00000000000..9902737ad26
--- /dev/null
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/registeredHotkeyOptions.test.ts
@@ -0,0 +1,40 @@
+import type { Options } from 'react-hotkeys-hook';
+import { describe, expect, it, vi } from 'vitest';
+
+import { getRegisteredHotkeyOptions } from './registeredHotkeyOptions';
+
+type EnabledPredicate = Exclude;
+
+const event = {} as Parameters[0];
+const hotkey = {} as Parameters[1];
+
+describe('getRegisteredHotkeyOptions', () => {
+ it('keeps a statically disabled hotkey disabled so it does not intercept a shared key', () => {
+ const options: Options = { enabled: false, preventDefault: true };
+ const isTextSessionActive = vi.fn(() => false);
+
+ expect(getRegisteredHotkeyOptions(options, isTextSessionActive)).toEqual(options);
+ });
+
+ it('suppresses an enabled hotkey during an uncommitted canvas text session', () => {
+ const result = getRegisteredHotkeyOptions({ enabled: true }, () => true);
+
+ expect(typeof result.enabled).toBe('function');
+ expect((result.enabled as EnabledPredicate)(event, hotkey)).toBe(false);
+ });
+
+ it('enables a hotkey with no configured enabled option outside a canvas text session', () => {
+ const result = getRegisteredHotkeyOptions({}, () => false);
+
+ expect(typeof result.enabled).toBe('function');
+ expect((result.enabled as EnabledPredicate)(event, hotkey)).toBe(true);
+ });
+
+ it('preserves a configured enabled predicate outside a canvas text session', () => {
+ const configuredEnabled = vi.fn(() => false);
+ const result = getRegisteredHotkeyOptions({ enabled: configuredEnabled }, () => false);
+
+ expect((result.enabled as EnabledPredicate)(event, hotkey)).toBe(false);
+ expect(configuredEnabled).toHaveBeenCalledWith(event, hotkey);
+ });
+});
diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/registeredHotkeyOptions.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/registeredHotkeyOptions.ts
new file mode 100644
index 00000000000..10a7a29ca82
--- /dev/null
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/registeredHotkeyOptions.ts
@@ -0,0 +1,32 @@
+import type { Options } from 'react-hotkeys-hook';
+
+/**
+ * Adds the canvas text-session guard without converting a statically disabled hotkey into a mounted listener.
+ *
+ * react-hotkeys-hook does not attach its listener when `enabled` is exactly `false`. If `false` is wrapped in a
+ * function instead, the listener remains attached and stops immediate propagation when the key matches. That can
+ * prevent another, enabled handler for the same key from running.
+ */
+export const getRegisteredHotkeyOptions = (
+ options: Options,
+ isUncommittedCanvasTextSessionActive: () => boolean
+): Options => {
+ if (options.enabled === false) {
+ return options;
+ }
+
+ const configuredEnabled = options.enabled;
+
+ return {
+ ...options,
+ enabled: (event, hotkeysEvent) => {
+ if (isUncommittedCanvasTextSessionActive()) {
+ return false;
+ }
+ if (typeof configuredEnabled === 'function') {
+ return configuredEnabled(event, hotkeysEvent);
+ }
+ return configuredEnabled ?? true;
+ },
+ };
+};
diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/sharedHotkeyRouting.test.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/sharedHotkeyRouting.test.ts
new file mode 100644
index 00000000000..03884fd3670
--- /dev/null
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/sharedHotkeyRouting.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+
+import { getSharedInfoHotkeyTarget } from './sharedHotkeyRouting';
+
+describe('getSharedInfoHotkeyTarget', () => {
+ it.each([
+ { isGalleryFocused: true, isViewerFocused: false },
+ { isGalleryFocused: false, isViewerFocused: true },
+ ])('routes the shared hotkey to metadata when Gallery or Viewer owns focus', (focusedRegion) => {
+ expect(
+ getSharedInfoHotkeyTarget({
+ hasMetadataViewerItem: true,
+ isViewerPanelActive: true,
+ ...focusedRegion,
+ })
+ ).toBe('metadata');
+ });
+
+ it('keeps the shared hotkey on the color picker when Gallery is next to the active Canvas', () => {
+ expect(
+ getSharedInfoHotkeyTarget({
+ hasMetadataViewerItem: true,
+ isGalleryFocused: true,
+ isViewerPanelActive: false,
+ isViewerFocused: false,
+ })
+ ).toBe('colorPicker');
+ });
+
+ it('routes the shared hotkey to the color picker outside Gallery and Viewer', () => {
+ expect(
+ getSharedInfoHotkeyTarget({
+ hasMetadataViewerItem: true,
+ isGalleryFocused: false,
+ isViewerPanelActive: true,
+ isViewerFocused: false,
+ })
+ ).toBe('colorPicker');
+ });
+
+ it.each([
+ { isGalleryFocused: true, isViewerFocused: false },
+ { isGalleryFocused: false, isViewerFocused: true },
+ ])('falls back to the color picker when the focused media panel has no selected item', (focusedRegion) => {
+ expect(
+ getSharedInfoHotkeyTarget({
+ hasMetadataViewerItem: false,
+ isViewerPanelActive: true,
+ ...focusedRegion,
+ })
+ ).toBe('colorPicker');
+ });
+});
diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/sharedHotkeyRouting.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/sharedHotkeyRouting.ts
new file mode 100644
index 00000000000..454a7a8d4e1
--- /dev/null
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/sharedHotkeyRouting.ts
@@ -0,0 +1,19 @@
+type SharedInfoHotkeyTarget = 'colorPicker' | 'metadata';
+
+export const getSharedInfoHotkeyTarget = ({
+ hasMetadataViewerItem,
+ isGalleryFocused,
+ isViewerPanelActive,
+ isViewerFocused,
+}: {
+ hasMetadataViewerItem: boolean;
+ isGalleryFocused: boolean;
+ isViewerPanelActive: boolean;
+ isViewerFocused: boolean;
+}): SharedInfoHotkeyTarget => {
+ if (hasMetadataViewerItem && isViewerPanelActive && (isGalleryFocused || isViewerFocused)) {
+ return 'metadata';
+ }
+
+ return 'colorPicker';
+};
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 c31bf418eba..9ede67fc056 100644
--- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts
@@ -7,6 +7,7 @@ import {
type HotkeyKeyboardLayoutMap,
IS_MAC_OS,
} from 'features/system/components/HotkeysModal/hotkeyStrings';
+import { getRegisteredHotkeyOptions } from 'features/system/components/HotkeysModal/registeredHotkeyOptions';
import { useKeyboardLayoutMap } from 'features/system/components/HotkeysModal/useKeyboardLayoutMap';
import { selectCustomHotkeys } from 'features/system/store/hotkeysSlice';
import { useMemo } from 'react';
@@ -276,21 +277,10 @@ export const useRegisteredHotkeys = ({ id, category, callback, options, dependen
} satisfies Options;
}, [data.isEnabled, options]);
- const _optionsWithCanvasTextGuard = useMemo(() => {
- return {
- ..._options,
- enabled: (event, hotkeysEvent) => {
- // Suppress all registered hotkeys while text editing is still uncommitted.
- if (isUncommittedCanvasTextSessionActive()) {
- return false;
- }
- if (typeof _options.enabled === 'function') {
- return _options.enabled(event, hotkeysEvent);
- }
- return _options.enabled ?? true;
- },
- } satisfies Options;
- }, [_options, isUncommittedCanvasTextSessionActive]);
+ const _optionsWithCanvasTextGuard = useMemo(
+ () => getRegisteredHotkeyOptions(_options, isUncommittedCanvasTextSessionActive),
+ [_options, isUncommittedCanvasTextSessionActive]
+ );
return useHotkeys(data.hotkeys, callback, _optionsWithCanvasTextGuard, dependencies);
};
diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useSharedInfoHotkeyTarget.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useSharedInfoHotkeyTarget.ts
new file mode 100644
index 00000000000..499c161aa33
--- /dev/null
+++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useSharedInfoHotkeyTarget.ts
@@ -0,0 +1,22 @@
+import { useAppSelector } from 'app/store/storeHooks';
+import { useIsRegionFocused } from 'common/hooks/focus';
+import { selectHasMetadataViewerItem } from 'features/gallery/store/gallerySelectors';
+import { getSharedInfoHotkeyTarget } from 'features/system/components/HotkeysModal/sharedHotkeyRouting';
+import { navigationApi } from 'features/ui/layouts/navigation-api';
+import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
+import { selectActiveTab } from 'features/ui/store/uiSelectors';
+
+export const useSharedInfoHotkeyTarget = () => {
+ const activeTab = useAppSelector(selectActiveTab);
+ const hasMetadataViewerItem = useAppSelector(selectHasMetadataViewerItem);
+ const isGalleryFocused = useIsRegionFocused('gallery');
+ const isViewerFocused = useIsRegionFocused('viewer');
+ const isViewerPanelActive = navigationApi.isDockviewPanelActive(activeTab, VIEWER_PANEL_ID);
+
+ return getSharedInfoHotkeyTarget({
+ hasMetadataViewerItem,
+ isGalleryFocused,
+ isViewerPanelActive,
+ isViewerFocused,
+ });
+};