diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx index 3fb610498d9..b4443b87897 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx @@ -13,6 +13,9 @@ 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 { 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'; import type { @@ -80,22 +83,41 @@ 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. */ 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 + const focusedRegion = getFocusedRegion(); + if (!canHandleGridArrowNavigation(activeTab, focusedRegion)) { return; } + // Only handle arrow keys if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { return; @@ -112,7 +134,7 @@ const useKeyboardNavigation = ( return; } - if (imageNames.length === 0) { + if (navigationImageNames.length === 0) { return; } @@ -132,7 +154,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 +168,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 +185,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 +204,7 @@ const useKeyboardNavigation = ( } } }, - [rootRef, virtuosoRef, imageNames, getState, dispatch] + [activeTab, rootRef, virtuosoRef, navigationImageNames, getState, dispatch] ); useRegisteredHotkeys({ @@ -316,13 +338,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 +359,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 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); @@ -36,6 +89,50 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu }, 500); }, []); + 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(); + navigate(); + }, + [activeTab, imageToRender, isFetching] + ); + + const onHotkeyPrevImage = useCallback( + (event: KeyboardEvent) => { + handleViewerArrowNavigation(event, goToPreviousImage); + }, + [goToPreviousImage, handleViewerArrowNavigation] + ); + + const onHotkeyNextImage = useCallback( + (event: KeyboardEvent) => { + handleViewerArrowNavigation(event, goToNextImage); + }, + [goToNextImage, handleViewerArrowNavigation] + ); + + 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 ( @@ -48,19 +145,12 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu justifyContent="center" position="relative" > - {imageDTO && ( - - + {imageToRender && ( + + )} - {!imageDTO && } + {!imageToRender && } {withProgress && ( @@ -72,13 +162,13 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu - {shouldShowItemDetails && imageDTO && !withProgress && ( + {shouldShowItemDetails && imageToRender && !withProgress && ( - + )} - {shouldShowNextPrevButtons && imageDTO && ( + {shouldShowNextPrevButtons && imageToRender && ( { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const lastSelectedItem = useAppSelector(selectLastSelectedItem); - const { imageNames, isFetching } = useGalleryImageNames(); +const preventButtonFocusOnPointerDown = (event: PointerEvent) => { + event.preventDefault(); +}; - const isOnFirstItem = useMemo( - () => (lastSelectedItem ? imageNames.at(0) === lastSelectedItem : false), - [imageNames, lastSelectedItem] - ); - const isOnLastItem = useMemo( - () => (lastSelectedItem ? imageNames.at(-1) === lastSelectedItem : false), - [imageNames, lastSelectedItem] - ); +const preventButtonFocusOnMouseDown = (event: MouseEvent) => { + event.preventDefault(); +}; - 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 blurButtonOnPointerUp = (event: PointerEvent) => { + event.currentTarget.blur(); +}; - 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 NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { + const { t } = useTranslation(); + const { goToPreviousImage, goToNextImage, isOnFirstItem, isOnLastItem, isFetching } = useNextPrevItemNavigation(); return ( @@ -62,7 +39,10 @@ const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineSt minH={0} w={`${ARROW_SIZE}px`} h={`${ARROW_SIZE}px`} - onClick={onClickLeftArrow} + onClick={goToPreviousImage} + onPointerDown={preventButtonFocusOnPointerDown} + onMouseDown={preventButtonFocusOnMouseDown} + onPointerUp={blurButtonOnPointerUp} isDisabled={isFetching} color="base.100" pointerEvents="auto" @@ -82,7 +62,10 @@ const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineSt minH={0} w={`${ARROW_SIZE}px`} h={`${ARROW_SIZE}px`} - onClick={onClickRightArrow} + 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 new file mode 100644 index 00000000000..066282d2553 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts @@ -0,0 +1,47 @@ +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 currentIndex = useMemo( + () => (lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) : -1), + [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(() => { + navigateBy(-1); + }, [navigateBy]); + + const goToNextImage = useCallback(() => { + 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 edd43dd80d1..ce6d4af2983 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts @@ -60,10 +60,15 @@ export const useInvoke = () => { [enqueueCanvas, enqueueGenerate, enqueueUpscaling, enqueueWorkflows, isReady, tabName] ); - const enqueueBack = useCallback(() => { - enqueue(false); + const focusViewerAfterInvoke = useCallback((tab: typeof tabName) => { + void navigationApi.focusPanel(tab, VIEWER_PANEL_ID, 2000, { + blurActiveElement: tab === 'generate' || tab === 'upscaling', + }); + }, []); + + const focusAfterInvoke = useCallback(() => { if (tabName === 'generate' || tabName === 'upscaling' || (tabName === 'canvas' && saveAllImagesToGallery)) { - navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); + 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); @@ -71,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, 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)) { - navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); - } 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, 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 98866a12f94..a1ae782ab01 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'; @@ -9,6 +10,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, @@ -21,6 +23,10 @@ import { 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. @@ -87,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. */ @@ -254,10 +277,12 @@ export class NavigationApi { if (api instanceof DockviewApi) { this._currentActiveDockviewPanel.set(tab, api.activePanel?.id ?? null); this._prevActiveDockviewPanel.set(tab, null); + 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); + this._setFocusedRegionFromPanel(tab, panel); }); this._addDisposeForTab(tab, dispose); } @@ -375,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); @@ -390,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; @@ -715,6 +744,56 @@ 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. + */ + 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