From b22408266744109cae9b5876218be15fcdfbc23a Mon Sep 17 00:00:00 2001 From: DustyShoe Date: Sat, 14 Feb 2026 19:00:35 +0200 Subject: [PATCH 1/4] fix(gallery): restore arrow-key browsing and extract shared prev/next navigation --- .../gallery/components/GalleryImageGrid.tsx | 36 +++++++----- .../components/GalleryImageGridPaged.tsx | 1 + .../ImageViewer/CurrentImagePreview.tsx | 55 ++++++++++++++++++- .../components/NextPrevItemButtons.tsx | 45 ++------------- .../components/useNextPrevItemNavigation.ts | 44 +++++++++++++++ .../src/features/ui/layouts/navigation-api.ts | 44 +++++++++++++++ 6 files changed, 171 insertions(+), 54 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx index 3fb610498d9..62000b182d8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx @@ -13,6 +13,8 @@ import { } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { navigationApi } from 'features/ui/layouts/navigation-api'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import type { MutableRefObject } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import type { @@ -84,16 +86,23 @@ const computeItemKey: GridComputeItemKey = (index, imageNam * Handles keyboard navigation for the gallery. */ const useKeyboardNavigation = ( - imageNames: string[], + navigationImageNames: string[], virtuosoRef: React.RefObject, rootRef: React.RefObject ) => { const { dispatch, getState } = useAppStore(); + const activeTab = useAppSelector(selectActiveTab); const handleKeyDown = useCallback( (event: KeyboardEvent) => { - if (getFocusedRegion() !== 'gallery') { - // Only handle keyboard navigation when the gallery is focused + if (navigationApi.isViewerArrowNavigationMode(activeTab)) { + // When gallery is not effectively available, viewer hotkeys own left/right navigation. + return; + } + + const focusedRegion = getFocusedRegion(); + if (focusedRegion !== 'gallery' && focusedRegion !== 'viewer') { + // Allow navigation from both gallery and viewer-focused states. return; } // Only handle arrow keys @@ -112,7 +121,7 @@ const useKeyboardNavigation = ( return; } - if (imageNames.length === 0) { + if (navigationImageNames.length === 0) { return; } @@ -132,7 +141,7 @@ const useKeyboardNavigation = ( (selectImageToCompare(state) ?? selectLastSelectedItem(state)) : selectLastSelectedItem(state); - const currentIndex = getItemIndex(imageName ?? null, imageNames); + const currentIndex = getItemIndex(imageName ?? null, navigationImageNames); let newIndex = currentIndex; @@ -146,7 +155,7 @@ const useKeyboardNavigation = ( } break; case 'ArrowRight': - if (currentIndex < imageNames.length - 1) { + if (currentIndex < navigationImageNames.length - 1) { newIndex = currentIndex + 1; // } else { // // Wrap to first image @@ -163,16 +172,16 @@ const useKeyboardNavigation = ( break; case 'ArrowDown': // If no images below, stay on current image - if (currentIndex >= imageNames.length - imagesPerRow) { + if (currentIndex >= navigationImageNames.length - imagesPerRow) { newIndex = currentIndex; } else { - newIndex = Math.min(imageNames.length - 1, currentIndex + imagesPerRow); + newIndex = Math.min(navigationImageNames.length - 1, currentIndex + imagesPerRow); } break; } - if (newIndex !== currentIndex && newIndex >= 0 && newIndex < imageNames.length) { - const newImageName = imageNames[newIndex]; + if (newIndex !== currentIndex && newIndex >= 0 && newIndex < navigationImageNames.length) { + const newImageName = navigationImageNames[newIndex]; if (newImageName) { if (event.altKey) { dispatch(imageToCompareChanged(newImageName)); @@ -182,7 +191,7 @@ const useKeyboardNavigation = ( } } }, - [rootRef, virtuosoRef, imageNames, getState, dispatch] + [activeTab, rootRef, virtuosoRef, navigationImageNames, getState, dispatch] ); useRegisteredHotkeys({ @@ -316,13 +325,14 @@ const useStarImageHotkey = () => { type GalleryImageGridContentProps = { imageNames: string[]; + navigationImageNames?: string[]; isLoading: boolean; queryArgs: ListImageNamesQueryArgs; rootRef?: React.RefObject; }; export const GalleryImageGridContent = memo( - ({ imageNames, isLoading, queryArgs, rootRef: rootRefProp }: GalleryImageGridContentProps) => { + ({ imageNames, navigationImageNames, isLoading, queryArgs, rootRef: rootRefProp }: GalleryImageGridContentProps) => { const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); const internalRootRef = useRef(null); @@ -336,7 +346,7 @@ export const GalleryImageGridContent = memo( useStarImageHotkey(); useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef); - useKeyboardNavigation(imageNames, virtuosoRef, rootRef); + useKeyboardNavigation(navigationImageNames ?? imageNames, virtuosoRef, rootRef); const scrollerRef = useScrollableGallery(rootRef); /* diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx index af6101d85a0..c5b4fc405de 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx @@ -181,6 +181,7 @@ export const GalleryImageGridPaged = memo(() => { { + const activeTab = useAppSelector(selectActiveTab); const shouldShowItemDetails = useAppSelector(selectShouldShowItemDetails); const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); + const { goToPreviousImage, goToNextImage, isFetching } = useNextPrevItemNavigation(); const { onLoadImage, $progressEvent, $progressImage } = useImageViewerContext(); const progressEvent = useStore($progressEvent); const progressImage = useStore($progressImage); @@ -36,6 +45,50 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu }, 500); }, []); + const onHotkeyPrevImage = useCallback( + (event: KeyboardEvent) => { + if (!navigationApi.isViewerArrowNavigationMode(activeTab) || !imageDTO || isFetching) { + return; + } + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return; + } + event.preventDefault(); + goToPreviousImage(); + }, + [activeTab, goToPreviousImage, imageDTO, isFetching] + ); + + const onHotkeyNextImage = useCallback( + (event: KeyboardEvent) => { + if (!navigationApi.isViewerArrowNavigationMode(activeTab) || !imageDTO || isFetching) { + return; + } + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return; + } + event.preventDefault(); + goToNextImage(); + }, + [activeTab, goToNextImage, imageDTO, isFetching] + ); + + useRegisteredHotkeys({ + id: 'galleryNavLeft', + category: 'gallery', + callback: onHotkeyPrevImage, + options: { preventDefault: true }, + dependencies: [onHotkeyPrevImage], + }); + + useRegisteredHotkeys({ + id: 'galleryNavRight', + category: 'gallery', + callback: onHotkeyNextImage, + options: { preventDefault: true }, + dependencies: [onHotkeyNextImage], + }); + const withProgress = shouldShowProgressInViewer && progressImage !== null; return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevItemButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevItemButtons.tsx index d364279ac5a..0f07263f870 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevItemButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevItemButtons.tsx @@ -1,51 +1,16 @@ import type { ChakraProps } from '@invoke-ai/ui-library'; import { Box, IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { clamp } from 'es-toolkit/compat'; -import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; -import { useGalleryImageNames } from './use-gallery-image-names'; +import { useNextPrevItemNavigation } from './useNextPrevItemNavigation'; const ARROW_SIZE = 48; const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const lastSelectedItem = useAppSelector(selectLastSelectedItem); - const { imageNames, isFetching } = useGalleryImageNames(); - - const isOnFirstItem = useMemo( - () => (lastSelectedItem ? imageNames.at(0) === lastSelectedItem : false), - [imageNames, lastSelectedItem] - ); - const isOnLastItem = useMemo( - () => (lastSelectedItem ? imageNames.at(-1) === lastSelectedItem : false), - [imageNames, lastSelectedItem] - ); - - const onClickLeftArrow = useCallback(() => { - const targetIndex = lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) - 1 : 0; - const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); - const n = imageNames.at(clampedIndex); - if (!n) { - return; - } - dispatch(imageSelected(n)); - }, [dispatch, imageNames, lastSelectedItem]); - - const onClickRightArrow = useCallback(() => { - const targetIndex = lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) + 1 : 0; - const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); - const n = imageNames.at(clampedIndex); - if (!n) { - return; - } - dispatch(imageSelected(n)); - }, [dispatch, imageNames, lastSelectedItem]); + const { goToPreviousImage, goToNextImage, isOnFirstItem, isOnLastItem, isFetching } = useNextPrevItemNavigation(); return ( @@ -62,7 +27,7 @@ const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineSt minH={0} w={`${ARROW_SIZE}px`} h={`${ARROW_SIZE}px`} - onClick={onClickLeftArrow} + onClick={goToPreviousImage} isDisabled={isFetching} color="base.100" pointerEvents="auto" @@ -82,7 +47,7 @@ const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineSt minH={0} w={`${ARROW_SIZE}px`} h={`${ARROW_SIZE}px`} - onClick={onClickRightArrow} + onClick={goToNextImage} isDisabled={isFetching} color="base.100" pointerEvents="auto" diff --git a/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts b/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts new file mode 100644 index 00000000000..d32427e0fac --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts @@ -0,0 +1,44 @@ +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { clamp } from 'es-toolkit/compat'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { useCallback, useMemo } from 'react'; + +import { useGalleryImageNames } from './use-gallery-image-names'; + +export const useNextPrevItemNavigation = () => { + const dispatch = useAppDispatch(); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); + const { imageNames, isFetching } = useGalleryImageNames(); + + const isOnFirstItem = useMemo( + () => (lastSelectedItem ? imageNames.at(0) === lastSelectedItem : false), + [imageNames, lastSelectedItem] + ); + const isOnLastItem = useMemo( + () => (lastSelectedItem ? imageNames.at(-1) === lastSelectedItem : false), + [imageNames, lastSelectedItem] + ); + + const goToPreviousImage = useCallback(() => { + const targetIndex = lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) - 1 : 0; + const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); + const imageName = imageNames.at(clampedIndex); + if (!imageName) { + return; + } + dispatch(imageSelected(imageName)); + }, [dispatch, imageNames, lastSelectedItem]); + + const goToNextImage = useCallback(() => { + const targetIndex = lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) + 1 : 0; + const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); + const imageName = imageNames.at(clampedIndex); + if (!imageName) { + return; + } + dispatch(imageSelected(imageName)); + }, [dispatch, imageNames, lastSelectedItem]); + + return { goToPreviousImage, goToNextImage, isOnFirstItem, isOnLastItem, isFetching }; +}; diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts index 98866a12f94..72eaa461fdb 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts @@ -9,6 +9,7 @@ import type { Atom } from 'nanostores'; import { atom } from 'nanostores'; import { + GALLERY_PANEL_ID, LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, @@ -715,6 +716,49 @@ export class NavigationApi { .map((key) => key.substring(prefix.length)); }; + /** + * Returns true when both side panels are collapsed in the provided tab. + */ + isFullscreen = (tab: TabName): boolean => { + const leftPanel = this.getPanel(tab, LEFT_PANEL_ID); + const rightPanel = this.getPanel(tab, RIGHT_PANEL_ID); + + if (!(leftPanel instanceof GridviewPanel) || !(rightPanel instanceof GridviewPanel)) { + return false; + } + + return leftPanel.width === 0 && rightPanel.width === 0; + }; + + /** + * Returns true when the gallery panel is collapsed in the provided tab. + */ + isGalleryPanelCollapsed = (tab: TabName): boolean => { + const galleryPanel = this.getPanel(tab, GALLERY_PANEL_ID); + if (!(galleryPanel instanceof GridviewPanel)) { + return false; + } + return galleryPanel.height <= (galleryPanel.minimumHeight ?? 0); + }; + + /** + * Returns true when the right panel is collapsed in the provided tab. + */ + isRightPanelCollapsed = (tab: TabName): boolean => { + const rightPanel = this.getPanel(tab, RIGHT_PANEL_ID); + if (!(rightPanel instanceof GridviewPanel)) { + return false; + } + return rightPanel.width === 0; + }; + + /** + * Returns true when viewer-level left/right arrow navigation should be active for gallery browsing. + */ + isViewerArrowNavigationMode = (tab: TabName): boolean => { + return this.isFullscreen(tab) || this.isRightPanelCollapsed(tab) || this.isGalleryPanelCollapsed(tab); + }; + /** * Unregister all panels for a tab. Any pending waiters for these panels will be rejected. * @param tab - The tab to unregister panels for From 3cfedae27dc6a9d0e70a23d7db46814be9cfa947 Mon Sep 17 00:00:00 2001 From: DustyShoe Date: Sat, 14 Feb 2026 19:31:15 +0200 Subject: [PATCH 2/4] Added same behavior to Upscale mode and autofocus to gallery after using hotkeys Ctrl+Enter and Ctrl+Shift+Enter --- .../web/src/features/queue/hooks/useInvoke.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts index edd43dd80d1..0013c534ad1 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react'; import { logger } from 'app/logging/logger'; import { useAppSelector } from 'app/store/storeHooks'; +import { setFocusedRegion } from 'common/hooks/focus'; import { withResultAsync } from 'common/util/result'; import { selectSaveAllImagesToGallery } from 'features/controlLayers/store/canvasSettingsSlice'; import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows'; @@ -60,10 +61,20 @@ export const useInvoke = () => { [enqueueCanvas, enqueueGenerate, enqueueUpscaling, enqueueWorkflows, isReady, tabName] ); + const setViewerFocusForGalleryNavigation = useCallback(() => { + if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + setFocusedRegion('viewer'); + }, []); + const enqueueBack = useCallback(() => { enqueue(false); if (tabName === 'generate' || tabName === 'upscaling' || (tabName === 'canvas' && saveAllImagesToGallery)) { navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); + if (tabName === 'generate' || tabName === 'upscaling') { + setViewerFocusForGalleryNavigation(); + } } else if (tabName === 'workflows') { // Only switch to viewer if the workflow editor is not currently active const workspace = navigationApi.getPanel('workflows', WORKSPACE_PANEL_ID); @@ -73,12 +84,15 @@ export const useInvoke = () => { } else if (tabName === 'canvas') { navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); } - }, [enqueue, saveAllImagesToGallery, tabName]); + }, [enqueue, saveAllImagesToGallery, setViewerFocusForGalleryNavigation, tabName]); const enqueueFront = useCallback(() => { enqueue(true); if (tabName === 'generate' || tabName === 'upscaling' || (tabName === 'canvas' && saveAllImagesToGallery)) { navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); + if (tabName === 'generate' || tabName === 'upscaling') { + setViewerFocusForGalleryNavigation(); + } } else if (tabName === 'workflows') { // Only switch to viewer if the workflow editor is not currently active const workspace = navigationApi.getPanel('workflows', WORKSPACE_PANEL_ID); @@ -88,7 +102,7 @@ export const useInvoke = () => { } else if (tabName === 'canvas') { navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); } - }, [enqueue, saveAllImagesToGallery, tabName]); + }, [enqueue, saveAllImagesToGallery, setViewerFocusForGalleryNavigation, tabName]); return { enqueueBack, enqueueFront, isLoading, isDisabled: !isReady, enqueue }; }; From df65d319689321beadd9d6a748488685c26e4468 Mon Sep 17 00:00:00 2001 From: DustyShoe Date: Sat, 14 Feb 2026 20:13:22 +0200 Subject: [PATCH 3/4] restore arrow navigation focus flow across viewer states --- .../gallery/components/GalleryImageGrid.tsx | 7 ++++-- .../web/src/features/queue/hooks/useInvoke.ts | 25 +++++++++++-------- .../src/features/ui/layouts/navigation-api.ts | 17 +++++++++++++ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx index 62000b182d8..6a70d4484f1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx @@ -14,6 +14,7 @@ import { import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { navigationApi } from 'features/ui/layouts/navigation-api'; +import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; import type { MutableRefObject } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; @@ -101,8 +102,10 @@ const useKeyboardNavigation = ( } const focusedRegion = getFocusedRegion(); - if (focusedRegion !== 'gallery' && focusedRegion !== 'viewer') { - // Allow navigation from both gallery and viewer-focused states. + const isFocusRegionEligible = focusedRegion === 'gallery' || focusedRegion === 'viewer'; + const isViewerDockTabActive = navigationApi.isDockviewPanelActive(activeTab, VIEWER_PANEL_ID); + if (!isFocusRegionEligible && !isViewerDockTabActive) { + // Fallback for tab-switch edge case: allow nav when viewer dock tab is active before first click. return; } // Only handle arrow keys diff --git a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts index 0013c534ad1..990a3c1ca2c 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts @@ -68,13 +68,21 @@ export const useInvoke = () => { setFocusedRegion('viewer'); }, []); + const focusViewerAfterInvoke = useCallback( + (tab: typeof tabName) => { + void navigationApi.focusPanel(tab, VIEWER_PANEL_ID).then((didFocus) => { + if (didFocus && (tab === 'generate' || tab === 'upscaling')) { + setViewerFocusForGalleryNavigation(); + } + }); + }, + [setViewerFocusForGalleryNavigation] + ); + const enqueueBack = useCallback(() => { enqueue(false); if (tabName === 'generate' || tabName === 'upscaling' || (tabName === 'canvas' && saveAllImagesToGallery)) { - navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); - if (tabName === 'generate' || tabName === 'upscaling') { - setViewerFocusForGalleryNavigation(); - } + focusViewerAfterInvoke(tabName); } else if (tabName === 'workflows') { // Only switch to viewer if the workflow editor is not currently active const workspace = navigationApi.getPanel('workflows', WORKSPACE_PANEL_ID); @@ -84,15 +92,12 @@ export const useInvoke = () => { } else if (tabName === 'canvas') { navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); } - }, [enqueue, saveAllImagesToGallery, setViewerFocusForGalleryNavigation, tabName]); + }, [enqueue, focusViewerAfterInvoke, saveAllImagesToGallery, tabName]); const enqueueFront = useCallback(() => { enqueue(true); if (tabName === 'generate' || tabName === 'upscaling' || (tabName === 'canvas' && saveAllImagesToGallery)) { - navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); - if (tabName === 'generate' || tabName === 'upscaling') { - setViewerFocusForGalleryNavigation(); - } + focusViewerAfterInvoke(tabName); } else if (tabName === 'workflows') { // Only switch to viewer if the workflow editor is not currently active const workspace = navigationApi.getPanel('workflows', WORKSPACE_PANEL_ID); @@ -102,7 +107,7 @@ export const useInvoke = () => { } else if (tabName === 'canvas') { navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); } - }, [enqueue, saveAllImagesToGallery, setViewerFocusForGalleryNavigation, tabName]); + }, [enqueue, focusViewerAfterInvoke, saveAllImagesToGallery, tabName]); return { enqueueBack, enqueueFront, isLoading, isDisabled: !isReady, enqueue }; }; diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts index 72eaa461fdb..84eff419a00 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts @@ -1,4 +1,5 @@ import { logger } from 'app/logging/logger'; +import { type FocusRegionName, setFocusedRegion } from 'common/hooks/focus'; import { createDeferredPromise, type Deferred } from 'common/util/createDeferredPromise'; import { parseify } from 'common/util/serialize'; import type { GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview'; @@ -22,6 +23,7 @@ import { const log = logger('system'); type PanelType = IGridviewPanel | IDockviewPanel; +type PanelWithFocusRegion = { params?: { focusRegion?: FocusRegionName } }; /** * An object that represents a promise that is waiting for a panel to be registered and ready. @@ -255,10 +257,18 @@ export class NavigationApi { if (api instanceof DockviewApi) { this._currentActiveDockviewPanel.set(tab, api.activePanel?.id ?? null); this._prevActiveDockviewPanel.set(tab, null); + const initialFocusRegion = (api.activePanel as PanelWithFocusRegion | null)?.params?.focusRegion; + if (initialFocusRegion && this._app?.activeTab.get() === tab) { + setFocusedRegion(initialFocusRegion); + } const { dispose } = api.onDidActivePanelChange((panel) => { const previousPanelId = this._currentActiveDockviewPanel.get(tab); this._prevActiveDockviewPanel.set(tab, previousPanelId ?? null); this._currentActiveDockviewPanel.set(tab, panel?.id ?? null); + const focusRegion = (panel as PanelWithFocusRegion | null)?.params?.focusRegion; + if (focusRegion && this._app?.activeTab.get() === tab) { + setFocusedRegion(focusRegion); + } }); this._addDisposeForTab(tab, dispose); } @@ -716,6 +726,13 @@ export class NavigationApi { .map((key) => key.substring(prefix.length)); }; + /** + * Returns true when a specific dockview panel is the currently active panel for the tab. + */ + isDockviewPanelActive = (tab: TabName, panelId: string): boolean => { + return this._currentActiveDockviewPanel.get(tab) === panelId; + }; + /** * Returns true when both side panels are collapsed in the provided tab. */ From 17f8b1370e1e8ed790d9c08b222750f374da6a24 Mon Sep 17 00:00:00 2001 From: DustyShoe Date: Mon, 23 Feb 2026 20:53:19 +0200 Subject: [PATCH 4/4] fix(gallery): stabilize arrow-key browsing, remove viewer UI flicker, and optimize code --- .../gallery/components/GalleryImageGrid.tsx | 28 ++++-- .../ImageViewer/CurrentImagePreview.tsx | 95 +++++++++++++------ .../components/NextPrevItemButtons.tsx | 20 +++- .../components/useNextPrevItemNavigation.ts | 45 +++++---- .../web/src/features/queue/hooks/useInvoke.ts | 55 ++++------- .../src/features/ui/layouts/navigation-api.ts | 36 +++++-- 6 files changed, 176 insertions(+), 103 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx index 6a70d4484f1..b4443b87897 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx @@ -83,6 +83,23 @@ const computeItemKey: GridComputeItemKey = (index, imageNam return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; }; +const canHandleGridArrowNavigation = ( + activeTab: ReturnType, + focusedRegion: ReturnType +) => { + if (navigationApi.isViewerArrowNavigationMode(activeTab)) { + // When gallery is not effectively available, viewer hotkeys own left/right navigation. + return false; + } + + if (focusedRegion === 'gallery' || focusedRegion === 'viewer') { + return true; + } + + // Fallback for tab-switch edge case: allow nav when viewer dock tab is active before first click. + return navigationApi.isDockviewPanelActive(activeTab, VIEWER_PANEL_ID); +}; + /** * Handles keyboard navigation for the gallery. */ @@ -96,18 +113,11 @@ const useKeyboardNavigation = ( const handleKeyDown = useCallback( (event: KeyboardEvent) => { - if (navigationApi.isViewerArrowNavigationMode(activeTab)) { - // When gallery is not effectively available, viewer hotkeys own left/right navigation. - return; - } - const focusedRegion = getFocusedRegion(); - const isFocusRegionEligible = focusedRegion === 'gallery' || focusedRegion === 'viewer'; - const isViewerDockTabActive = navigationApi.isDockviewPanelActive(activeTab, VIEWER_PANEL_ID); - if (!isFocusRegionEligible && !isViewerDockTabActive) { - // Fallback for tab-switch edge case: allow nav when viewer dock tab is active before first click. + if (!canHandleGridArrowNavigation(activeTab, focusedRegion)) { return; } + // Only handle arrow keys if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { return; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index b95e73558f8..a39cf9be514 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -6,6 +6,7 @@ import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons'; import { useNextPrevItemNavigation } from 'features/gallery/components/useNextPrevItemNavigation'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { @@ -15,7 +16,7 @@ import { } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; -import { memo, useCallback, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; import { useImageViewerContext } from './context'; @@ -25,12 +26,55 @@ import { ProgressIndicator } from './ProgressIndicator2'; export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | null }) => { const activeTab = useAppSelector(selectActiveTab); + const selectedImageName = useAppSelector(selectLastSelectedItem); const shouldShowItemDetails = useAppSelector(selectShouldShowItemDetails); const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); const { goToPreviousImage, goToNextImage, isFetching } = useNextPrevItemNavigation(); const { onLoadImage, $progressEvent, $progressImage } = useImageViewerContext(); const progressEvent = useStore($progressEvent); const progressImage = useStore($progressImage); + const [imageToRender, setImageToRender] = useState(null); + + useEffect(() => { + if (!selectedImageName) { + setImageToRender(null); + return; + } + + if (!imageDTO || imageToRender?.image_name === imageDTO.image_name) { + return; + } + + let canceled = false; + + const onReady = () => { + if (canceled) { + return; + } + setImageToRender(imageDTO); + }; + + if (typeof window === 'undefined') { + onReady(); + return; + } + + const preloader = new window.Image(); + + preloader.onload = onReady; + preloader.onerror = onReady; + preloader.src = imageDTO.image_url; + + if (preloader.complete) { + onReady(); + } + + return () => { + canceled = true; + preloader.onload = null; + preloader.onerror = null; + }; + }, [imageDTO, imageToRender?.image_name, selectedImageName]); // Show and hide the next/prev buttons on mouse move const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState(false); @@ -45,32 +89,32 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu }, 500); }, []); - const onHotkeyPrevImage = useCallback( - (event: KeyboardEvent) => { - if (!navigationApi.isViewerArrowNavigationMode(activeTab) || !imageDTO || isFetching) { + const handleViewerArrowNavigation = useCallback( + (event: KeyboardEvent, navigate: () => void) => { + if (!navigationApi.isViewerArrowNavigationMode(activeTab) || !imageToRender || isFetching) { return; } if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return; } event.preventDefault(); - goToPreviousImage(); + navigate(); + }, + [activeTab, imageToRender, isFetching] + ); + + const onHotkeyPrevImage = useCallback( + (event: KeyboardEvent) => { + handleViewerArrowNavigation(event, goToPreviousImage); }, - [activeTab, goToPreviousImage, imageDTO, isFetching] + [goToPreviousImage, handleViewerArrowNavigation] ); const onHotkeyNextImage = useCallback( (event: KeyboardEvent) => { - if (!navigationApi.isViewerArrowNavigationMode(activeTab) || !imageDTO || isFetching) { - return; - } - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { - return; - } - event.preventDefault(); - goToNextImage(); + handleViewerArrowNavigation(event, goToNextImage); }, - [activeTab, goToNextImage, imageDTO, isFetching] + [goToNextImage, handleViewerArrowNavigation] ); useRegisteredHotkeys({ @@ -101,19 +145,12 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu justifyContent="center" position="relative" > - {imageDTO && ( - - + {imageToRender && ( + + )} - {!imageDTO && } + {!imageToRender && } {withProgress && ( @@ -125,13 +162,13 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu - {shouldShowItemDetails && imageDTO && !withProgress && ( + {shouldShowItemDetails && imageToRender && !withProgress && ( - + )} - {shouldShowNextPrevButtons && imageDTO && ( + {shouldShowNextPrevButtons && imageToRender && ( ) => { + event.preventDefault(); +}; + +const preventButtonFocusOnMouseDown = (event: MouseEvent) => { + event.preventDefault(); +}; + +const blurButtonOnPointerUp = (event: PointerEvent) => { + event.currentTarget.blur(); +}; + const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { const { t } = useTranslation(); const { goToPreviousImage, goToNextImage, isOnFirstItem, isOnLastItem, isFetching } = useNextPrevItemNavigation(); @@ -28,6 +40,9 @@ const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineSt w={`${ARROW_SIZE}px`} h={`${ARROW_SIZE}px`} onClick={goToPreviousImage} + onPointerDown={preventButtonFocusOnPointerDown} + onMouseDown={preventButtonFocusOnMouseDown} + onPointerUp={blurButtonOnPointerUp} isDisabled={isFetching} color="base.100" pointerEvents="auto" @@ -48,6 +63,9 @@ const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineSt w={`${ARROW_SIZE}px`} h={`${ARROW_SIZE}px`} onClick={goToNextImage} + onPointerDown={preventButtonFocusOnPointerDown} + onMouseDown={preventButtonFocusOnMouseDown} + onPointerUp={blurButtonOnPointerUp} isDisabled={isFetching} color="base.100" pointerEvents="auto" diff --git a/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts b/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts index d32427e0fac..066282d2553 100644 --- a/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts +++ b/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts @@ -11,34 +11,37 @@ export const useNextPrevItemNavigation = () => { const lastSelectedItem = useAppSelector(selectLastSelectedItem); const { imageNames, isFetching } = useGalleryImageNames(); - const isOnFirstItem = useMemo( - () => (lastSelectedItem ? imageNames.at(0) === lastSelectedItem : false), + const currentIndex = useMemo( + () => (lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) : -1), [imageNames, lastSelectedItem] ); - const isOnLastItem = useMemo( - () => (lastSelectedItem ? imageNames.at(-1) === lastSelectedItem : false), - [imageNames, lastSelectedItem] + const isOnFirstItem = currentIndex === 0; + const isOnLastItem = currentIndex >= 0 && currentIndex === imageNames.length - 1; + + const navigateBy = useCallback( + (delta: number) => { + const maxIndex = imageNames.length - 1; + if (maxIndex < 0) { + return; + } + + const targetIndex = currentIndex >= 0 ? clamp(currentIndex + delta, 0, maxIndex) : 0; + const imageName = imageNames[targetIndex]; + if (!imageName) { + return; + } + dispatch(imageSelected(imageName)); + }, + [currentIndex, dispatch, imageNames] ); const goToPreviousImage = useCallback(() => { - const targetIndex = lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) - 1 : 0; - const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); - const imageName = imageNames.at(clampedIndex); - if (!imageName) { - return; - } - dispatch(imageSelected(imageName)); - }, [dispatch, imageNames, lastSelectedItem]); + navigateBy(-1); + }, [navigateBy]); const goToNextImage = useCallback(() => { - const targetIndex = lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) + 1 : 0; - const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); - const imageName = imageNames.at(clampedIndex); - if (!imageName) { - return; - } - dispatch(imageSelected(imageName)); - }, [dispatch, imageNames, lastSelectedItem]); + navigateBy(1); + }, [navigateBy]); return { goToPreviousImage, goToNextImage, isOnFirstItem, isOnLastItem, isFetching }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts index 990a3c1ca2c..ce6d4af2983 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts @@ -1,7 +1,6 @@ import { useStore } from '@nanostores/react'; import { logger } from 'app/logging/logger'; import { useAppSelector } from 'app/store/storeHooks'; -import { setFocusedRegion } from 'common/hooks/focus'; import { withResultAsync } from 'common/util/result'; import { selectSaveAllImagesToGallery } from 'features/controlLayers/store/canvasSettingsSlice'; import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows'; @@ -61,26 +60,13 @@ export const useInvoke = () => { [enqueueCanvas, enqueueGenerate, enqueueUpscaling, enqueueWorkflows, isReady, tabName] ); - const setViewerFocusForGalleryNavigation = useCallback(() => { - if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - setFocusedRegion('viewer'); + const focusViewerAfterInvoke = useCallback((tab: typeof tabName) => { + void navigationApi.focusPanel(tab, VIEWER_PANEL_ID, 2000, { + blurActiveElement: tab === 'generate' || tab === 'upscaling', + }); }, []); - const focusViewerAfterInvoke = useCallback( - (tab: typeof tabName) => { - void navigationApi.focusPanel(tab, VIEWER_PANEL_ID).then((didFocus) => { - if (didFocus && (tab === 'generate' || tab === 'upscaling')) { - setViewerFocusForGalleryNavigation(); - } - }); - }, - [setViewerFocusForGalleryNavigation] - ); - - const enqueueBack = useCallback(() => { - enqueue(false); + const focusAfterInvoke = useCallback(() => { if (tabName === 'generate' || tabName === 'upscaling' || (tabName === 'canvas' && saveAllImagesToGallery)) { focusViewerAfterInvoke(tabName); } else if (tabName === 'workflows') { @@ -90,24 +76,25 @@ export const useInvoke = () => { navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); } } else if (tabName === 'canvas') { - navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); + void navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); } - }, [enqueue, focusViewerAfterInvoke, saveAllImagesToGallery, tabName]); + }, [focusViewerAfterInvoke, saveAllImagesToGallery, tabName]); + + const enqueueAndFocus = useCallback( + (prepend: boolean) => { + enqueue(prepend); + focusAfterInvoke(); + }, + [enqueue, focusAfterInvoke] + ); + + const enqueueBack = useCallback(() => { + enqueueAndFocus(false); + }, [enqueueAndFocus]); const enqueueFront = useCallback(() => { - enqueue(true); - if (tabName === 'generate' || tabName === 'upscaling' || (tabName === 'canvas' && saveAllImagesToGallery)) { - focusViewerAfterInvoke(tabName); - } else if (tabName === 'workflows') { - // Only switch to viewer if the workflow editor is not currently active - const workspace = navigationApi.getPanel('workflows', WORKSPACE_PANEL_ID); - if (!workspace?.api.isActive) { - navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); - } - } else if (tabName === 'canvas') { - navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); - } - }, [enqueue, focusViewerAfterInvoke, saveAllImagesToGallery, tabName]); + enqueueAndFocus(true); + }, [enqueueAndFocus]); return { enqueueBack, enqueueFront, isLoading, isDisabled: !isReady, enqueue }; }; diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts index 84eff419a00..a1ae782ab01 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts @@ -24,6 +24,9 @@ const log = logger('system'); type PanelType = IGridviewPanel | IDockviewPanel; type PanelWithFocusRegion = { params?: { focusRegion?: FocusRegionName } }; +type FocusPanelOptions = { + blurActiveElement?: boolean; +}; /** * An object that represents a promise that is waiting for a panel to be registered and ready. @@ -90,6 +93,23 @@ export class NavigationApi { */ _disposablesForTab: Map void>> = new Map(); + _setFocusedRegionFromPanel = (tab: TabName, panel: PanelType | null | undefined): void => { + const focusRegion = (panel as PanelWithFocusRegion | null)?.params?.focusRegion; + if (focusRegion && this._app?.activeTab.get() === tab) { + setFocusedRegion(focusRegion); + } + }; + + _blurActiveElement = (): void => { + if (typeof document === 'undefined') { + return; + } + if (!(document.activeElement instanceof HTMLElement)) { + return; + } + document.activeElement.blur(); + }; + /** * Convenience method to add a dispose function for a specific tab. */ @@ -257,18 +277,12 @@ export class NavigationApi { if (api instanceof DockviewApi) { this._currentActiveDockviewPanel.set(tab, api.activePanel?.id ?? null); this._prevActiveDockviewPanel.set(tab, null); - const initialFocusRegion = (api.activePanel as PanelWithFocusRegion | null)?.params?.focusRegion; - if (initialFocusRegion && this._app?.activeTab.get() === tab) { - setFocusedRegion(initialFocusRegion); - } + this._setFocusedRegionFromPanel(tab, api.activePanel); const { dispose } = api.onDidActivePanelChange((panel) => { const previousPanelId = this._currentActiveDockviewPanel.get(tab); this._prevActiveDockviewPanel.set(tab, previousPanelId ?? null); this._currentActiveDockviewPanel.set(tab, panel?.id ?? null); - const focusRegion = (panel as PanelWithFocusRegion | null)?.params?.focusRegion; - if (focusRegion && this._app?.activeTab.get() === tab) { - setFocusedRegion(focusRegion); - } + this._setFocusedRegionFromPanel(tab, panel); }); this._addDisposeForTab(tab, dispose); } @@ -386,7 +400,7 @@ export class NavigationApi { * } * ``` */ - focusPanel = async (tab: TabName, panelId: string, timeout = 2000): Promise => { + focusPanel = async (tab: TabName, panelId: string, timeout = 2000, options?: FocusPanelOptions): Promise => { try { this.switchToTab(tab); await this.waitForPanel(tab, panelId, timeout); @@ -401,6 +415,10 @@ export class NavigationApi { // Dockview uses the term "active", but we use "focused" for consistency. panel.api.setActive(); + if (options?.blurActiveElement) { + this._blurActiveElement(); + } + this._setFocusedRegionFromPanel(tab, panel); log.trace(`Focused panel ${key}`); return true;