Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -80,22 +83,41 @@ const computeItemKey: GridComputeItemKey<string, GridContext> = (index, imageNam
return `${JSON.stringify(queryArgs)}-${imageName ?? index}`;
};

const canHandleGridArrowNavigation = (
activeTab: ReturnType<typeof selectActiveTab>,
focusedRegion: ReturnType<typeof getFocusedRegion>
) => {
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<VirtuosoGridHandle>,
rootRef: React.RefObject<HTMLDivElement>
) => {
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;
Expand All @@ -112,7 +134,7 @@ const useKeyboardNavigation = (
return;
}

if (imageNames.length === 0) {
if (navigationImageNames.length === 0) {
return;
}

Expand All @@ -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;

Expand All @@ -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
Expand All @@ -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));
Expand All @@ -182,7 +204,7 @@ const useKeyboardNavigation = (
}
}
},
[rootRef, virtuosoRef, imageNames, getState, dispatch]
[activeTab, rootRef, virtuosoRef, navigationImageNames, getState, dispatch]
);

useRegisteredHotkeys({
Expand Down Expand Up @@ -316,13 +338,14 @@ const useStarImageHotkey = () => {

type GalleryImageGridContentProps = {
imageNames: string[];
navigationImageNames?: string[];
isLoading: boolean;
queryArgs: ListImageNamesQueryArgs;
rootRef?: React.RefObject<HTMLDivElement>;
};

export const GalleryImageGridContent = memo(
({ imageNames, isLoading, queryArgs, rootRef: rootRefProp }: GalleryImageGridContentProps) => {
({ imageNames, navigationImageNames, isLoading, queryArgs, rootRef: rootRefProp }: GalleryImageGridContentProps) => {
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
const internalRootRef = useRef<HTMLDivElement>(null);
Expand All @@ -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);

/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export const GalleryImageGridPaged = memo(() => {
<Flex w="full" h="full">
<GalleryImageGridContent
imageNames={pageImageNames}
navigationImageNames={imageNames}
isLoading={false}
queryArgs={queryArgs}
rootRef={gridRootRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ import { CanvasAlertsInvocationProgress } from 'features/controlLayers/component
import { DndImage } from 'features/dnd/DndImage';
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons';
import { selectShouldShowItemDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
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 {
selectActiveTab,
selectShouldShowItemDetails,
selectShouldShowProgressInViewer,
} 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';
Expand All @@ -17,11 +25,56 @@ import { ProgressImage } from './ProgressImage2';
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<ImageDTO | null>(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<boolean>(false);
Expand All @@ -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 (
Expand All @@ -48,19 +145,12 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu
justifyContent="center"
position="relative"
>
{imageDTO && (
<Flex
key={imageDTO.image_name}
w="full"
h="full"
position="absolute"
alignItems="center"
justifyContent="center"
>
<DndImage imageDTO={imageDTO} onLoad={onLoadImage} borderRadius="base" />
{imageToRender && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center">
<DndImage imageDTO={imageToRender} onLoad={onLoadImage} borderRadius="base" />
</Flex>
)}
{!imageDTO && <NoContentForViewer />}
{!imageToRender && <NoContentForViewer />}
{withProgress && (
<Flex w="full" h="full" position="absolute" alignItems="center" justifyContent="center" bg="base.900">
<ProgressImage progressImage={progressImage} />
Expand All @@ -72,13 +162,13 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu
<Flex flexDir="column" gap={2} position="absolute" top={0} insetInlineStart={0} alignItems="flex-start">
<CanvasAlertsInvocationProgress />
</Flex>
{shouldShowItemDetails && imageDTO && !withProgress && (
{shouldShowItemDetails && imageToRender && !withProgress && (
<Box position="absolute" opacity={0.8} top={0} width="full" height="full" borderRadius="base">
<ImageMetadataViewer image={imageDTO} />
<ImageMetadataViewer image={imageToRender} />
</Box>
)}
<AnimatePresence>
{shouldShowNextPrevButtons && imageDTO && (
{shouldShowNextPrevButtons && imageToRender && (
<Box
as={motion.div}
key="nextPrevButtons"
Expand Down
Loading