mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): restore gallery hotkeys (except delete)
This commit is contained in:
@@ -117,4 +117,20 @@ export const useGlobalHotkeys = () => {
|
||||
},
|
||||
dependencies: [dispatch, isModelManagerEnabled],
|
||||
});
|
||||
|
||||
// TODO: implement delete - needs to handle gallery focus, which has changed w/ dockview
|
||||
// useRegisteredHotkeys({
|
||||
// id: 'deleteSelection',
|
||||
// category: 'gallery',
|
||||
// callback: () => {
|
||||
// if (!selection.length) {
|
||||
// return;
|
||||
// }
|
||||
// deleteImageModal.delete(selection);
|
||||
// },
|
||||
// options: {
|
||||
// enabled: (isGalleryFocused || isImageViewerFocused) && isDeleteEnabledByTab && !isWorkflowsFocused,
|
||||
// },
|
||||
// dependencies: [isWorkflowsFocused, isDeleteEnabledByTab, selection, isWorkflowsFocused],
|
||||
// });
|
||||
};
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { Box, Flex, Grid } from '@invoke-ai/ui-library';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { GallerySelectionCountTag } from 'features/gallery/components/ImageGrid/GallerySelectionCountTag';
|
||||
import { useGalleryHotkeys } from 'features/gallery/hooks/useGalleryHotkeys';
|
||||
import {
|
||||
selectGalleryImageMinimumWidth,
|
||||
selectGalleryLimit,
|
||||
selectListImagesQueryArgs,
|
||||
} from 'features/gallery/store/gallerySelectors';
|
||||
import { limitChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiImageBold, PiWarningCircleBold } from 'react-icons/pi';
|
||||
import { useListImagesQuery } from 'services/api/endpoints/images';
|
||||
|
||||
import { GALLERY_GRID_CLASS_NAME } from './constants';
|
||||
import { GALLERY_IMAGE_CONTAINER_CLASS_NAME, GalleryImage } from './GalleryImage';
|
||||
|
||||
const GalleryImageGrid = () => {
|
||||
useGalleryHotkeys();
|
||||
const { t } = useTranslation();
|
||||
const queryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
const { hasImages, isLoading, isError } = useListImagesQuery(queryArgs, {
|
||||
selectFromResult: ({ data, isLoading, isSuccess, isError }) => ({
|
||||
hasImages: data && data.items.length > 0,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
}),
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Box w="full" h="full">
|
||||
<IAINoContentFallback label={t('gallery.unableToLoad')} icon={PiWarningCircleBold} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<IAINoContentFallback label={t('gallery.loading')} icon={PiImageBold} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasImages) {
|
||||
return (
|
||||
<Flex w="full" h="full" alignItems="center" justifyContent="center">
|
||||
<IAINoContentFallback label={t('gallery.noImagesInGallery')} icon={PiImageBold} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return <GalleryImageGridContent />;
|
||||
};
|
||||
|
||||
export default memo(GalleryImageGrid);
|
||||
|
||||
const GalleryImageGridContent = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth);
|
||||
const limit = useAppSelector(selectGalleryLimit);
|
||||
|
||||
// Use a callback ref to get reactivity on the container element because it is conditionally rendered
|
||||
const [container, containerRef] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const calculateNewLimit = useMemo(() => {
|
||||
// Debounce this to not thrash the API
|
||||
return debounce(() => {
|
||||
if (!container) {
|
||||
// Container not rendered yet
|
||||
return;
|
||||
}
|
||||
// Managing refs for dynamically rendered components is a bit tedious:
|
||||
// - https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback
|
||||
// As a easy workaround, we can just grab the first gallery image element directly.
|
||||
const imageEl = document.querySelector(`.${GALLERY_IMAGE_CONTAINER_CLASS_NAME}`);
|
||||
if (!imageEl) {
|
||||
// No images in gallery?
|
||||
return;
|
||||
}
|
||||
|
||||
const gridEl = document.querySelector(`.${GALLERY_GRID_CLASS_NAME}`);
|
||||
|
||||
if (!gridEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageRect = imageEl.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// We need to account for the gap between images
|
||||
const gridElStyle = window.getComputedStyle(gridEl);
|
||||
const gap = parseFloat(gridElStyle.gap);
|
||||
|
||||
if (!imageRect.width || !imageRect.height || !containerRect.width || !containerRect.height) {
|
||||
// Gallery is too small to fit images or not rendered yet
|
||||
return;
|
||||
}
|
||||
|
||||
let imagesPerColumn = 0;
|
||||
let spaceUsed = 0;
|
||||
|
||||
// Floating point precision can cause imagesPerColumn to be 1 too small. Adding 1px to the container size fixes
|
||||
// this. Because the minimum image size is without the possibility of overshooting.
|
||||
while (spaceUsed + imageRect.height <= containerRect.height + 1) {
|
||||
imagesPerColumn++; // Increment the number of images
|
||||
spaceUsed += imageRect.height; // Add image size to the used space
|
||||
if (spaceUsed + gap <= containerRect.height) {
|
||||
spaceUsed += gap; // Add gap size to the used space after each image except after the last image
|
||||
}
|
||||
}
|
||||
|
||||
let imagesPerRow = 0;
|
||||
spaceUsed = 0;
|
||||
|
||||
// Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes
|
||||
// this, without the possibility of accidentally adding an extra column.
|
||||
while (spaceUsed + imageRect.width <= containerRect.width + 1) {
|
||||
imagesPerRow++; // Increment the number of images
|
||||
spaceUsed += imageRect.width; // Add image size to the used space
|
||||
if (spaceUsed + gap <= containerRect.width) {
|
||||
spaceUsed += gap; // Add gap size to the used space after each image except after the last image
|
||||
}
|
||||
}
|
||||
|
||||
// Always load at least 1 row of images
|
||||
const newLimit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn);
|
||||
|
||||
if (limit === 0 || limit === newLimit) {
|
||||
return;
|
||||
}
|
||||
dispatch(limitChanged(newLimit));
|
||||
}, 300);
|
||||
}, [container, dispatch, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
// We want to recalculate the limit when image size changes
|
||||
calculateNewLimit();
|
||||
}, [calculateNewLimit, galleryImageMinimumWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(calculateNewLimit);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// First render
|
||||
calculateNewLimit();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [calculateNewLimit, container, dispatch]);
|
||||
|
||||
return (
|
||||
<Box position="relative" w="full" h="full" mt={2}>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
position="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
w="full"
|
||||
h="full"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Grid
|
||||
className={GALLERY_GRID_CLASS_NAME}
|
||||
gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`}
|
||||
gap={1}
|
||||
>
|
||||
<GalleryImageGridImages />
|
||||
</Grid>
|
||||
</Box>
|
||||
<GallerySelectionCountTag />
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryImageGridContent.displayName = 'GalleryImageGridContent';
|
||||
|
||||
const GalleryImageGridImages = memo(() => {
|
||||
const queryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
const { imageDTOs } = useListImagesQuery(queryArgs, {
|
||||
selectFromResult: ({ data }) => ({ imageDTOs: data?.items ?? EMPTY_ARRAY }),
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{imageDTOs.map((imageDTO) => (
|
||||
<GalleryImage key={imageDTO.image_name} imageDTO={imageDTO} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
GalleryImageGridImages.displayName = 'GalleryImageGridImages';
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Button, Flex, IconButton, Spacer } from '@invoke-ai/ui-library';
|
||||
import { ELLIPSIS, useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
||||
|
||||
import { JumpTo } from './JumpTo';
|
||||
|
||||
export const GalleryPagination = memo(() => {
|
||||
const { goPrev, goNext, isPrevEnabled, isNextEnabled, pageButtons, goToPage, currentPage, total } =
|
||||
useGalleryPagination();
|
||||
|
||||
const onClickPrev = useCallback(() => {
|
||||
goPrev();
|
||||
}, [goPrev]);
|
||||
|
||||
const onClickNext = useCallback(() => {
|
||||
goNext();
|
||||
}, [goNext]);
|
||||
|
||||
if (!total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justifyContent="center" alignItems="center" w="full" gap={1} pt={2}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label="prev"
|
||||
icon={<PiCaretLeftBold />}
|
||||
onClick={onClickPrev}
|
||||
isDisabled={!isPrevEnabled}
|
||||
variant="ghost"
|
||||
/>
|
||||
<Spacer />
|
||||
{pageButtons.map((page, i) => (
|
||||
<PageButton key={`${page}_${i}`} page={page} currentPage={currentPage} goToPage={goToPage} />
|
||||
))}
|
||||
<Spacer />
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label="next"
|
||||
icon={<PiCaretRightBold />}
|
||||
onClick={onClickNext}
|
||||
isDisabled={!isNextEnabled}
|
||||
variant="ghost"
|
||||
/>
|
||||
<JumpTo />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
GalleryPagination.displayName = 'GalleryPagination';
|
||||
|
||||
type PageButtonProps = {
|
||||
page: number | typeof ELLIPSIS;
|
||||
currentPage: number;
|
||||
goToPage: (page: number) => void;
|
||||
};
|
||||
|
||||
const PageButton = memo(({ page, currentPage, goToPage }: PageButtonProps) => {
|
||||
const onClick = useCallback(() => {
|
||||
if (page === ELLIPSIS) {
|
||||
return;
|
||||
}
|
||||
goToPage(page - 1);
|
||||
}, [goToPage, page]);
|
||||
|
||||
if (page === ELLIPSIS) {
|
||||
return (
|
||||
<Button size="sm" variant="link" isDisabled>
|
||||
...
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button size="sm" onClick={onClick} variant={currentPage === page - 1 ? 'solid' : 'outline'}>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
PageButton.displayName = 'PageButton';
|
||||
@@ -1,10 +1,9 @@
|
||||
import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invoke-ai/ui-library';
|
||||
import { useDebouncedImageCollectionQueryArgs } from 'features/gallery/components/NewGallery';
|
||||
import { useGalleryImageNames } from 'features/gallery/components/NewGallery';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
import { useGetImageCollectionCountsQuery } from 'services/api/endpoints/images';
|
||||
|
||||
type Props = {
|
||||
searchTerm: string;
|
||||
@@ -14,8 +13,7 @@ type Props = {
|
||||
|
||||
export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const queryArgs = useDebouncedImageCollectionQueryArgs();
|
||||
const { isFetching } = useGetImageCollectionCountsQuery(queryArgs);
|
||||
const { isFetching } = useGalleryImageNames();
|
||||
|
||||
const handleChangeInput = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
|
||||
import { useGalleryImageNames } from 'features/gallery/components/NewGallery';
|
||||
import {
|
||||
selectFirstSelectedImage,
|
||||
selectSelection,
|
||||
@@ -15,12 +15,12 @@ import { useTranslation } from 'react-i18next';
|
||||
export const GallerySelectionCountTag = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const selection = useAppSelector(selectSelection);
|
||||
const { imageDTOs } = useGalleryImages();
|
||||
const { imageNames } = useGalleryImageNames();
|
||||
const isGalleryFocused = useIsRegionFocused('gallery');
|
||||
|
||||
const onSelectPage = useCallback(() => {
|
||||
dispatch(selectionChanged([...selection, ...imageDTOs.map(({ image_name }) => image_name)]));
|
||||
}, [dispatch, selection, imageDTOs]);
|
||||
dispatch(selectionChanged([...imageNames]));
|
||||
}, [dispatch, imageNames]);
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'selectAllOnPage',
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
CompositeNumberInput,
|
||||
Flex,
|
||||
FormControl,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
useDisclosure,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const JumpTo = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const disclosure = useDisclosure();
|
||||
|
||||
return (
|
||||
<Popover isOpen={disclosure.isOpen} onClose={disclosure.onClose} isLazy lazyBehavior="unmount">
|
||||
<PopoverTrigger>
|
||||
<Button aria-label={t('gallery.jump')} size="sm" onClick={disclosure.onToggle} variant="outline">
|
||||
{t('gallery.jump')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<JumpToContent disclosure={disclosure} />
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
JumpTo.displayName = 'JumpTo';
|
||||
|
||||
const JumpToContent = memo(({ disclosure }: { disclosure: ReturnType<typeof useDisclosure> }) => {
|
||||
const { t } = useTranslation();
|
||||
const { goToPage, currentPage, pages } = useGalleryPagination();
|
||||
const [newPage, setNewPage] = useState(currentPage);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onChangeJumpTo = useCallback((v: number) => {
|
||||
setNewPage(v - 1);
|
||||
}, []);
|
||||
|
||||
const onClickGo = useCallback(() => {
|
||||
goToPage(newPage);
|
||||
disclosure.onClose();
|
||||
}, [goToPage, newPage, disclosure]);
|
||||
|
||||
useHotkeys(
|
||||
'enter',
|
||||
() => {
|
||||
onClickGo();
|
||||
},
|
||||
{ enabled: disclosure.isOpen, enableOnFormTags: ['input'] },
|
||||
[disclosure.isOpen, onClickGo]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
setNewPage(currentPage);
|
||||
disclosure.onClose();
|
||||
},
|
||||
{ enabled: disclosure.isOpen, enableOnFormTags: ['input'] },
|
||||
[disclosure.isOpen, disclosure.onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const input = ref.current?.querySelector('input');
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}, 0);
|
||||
setNewPage(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
return (
|
||||
<Flex gap={2} alignItems="center">
|
||||
<FormControl>
|
||||
<CompositeNumberInput
|
||||
ref={ref}
|
||||
size="sm"
|
||||
maxW="60px"
|
||||
value={newPage + 1}
|
||||
min={1}
|
||||
max={pages}
|
||||
step={1}
|
||||
onChange={onChangeJumpTo}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button h="full" size="sm" onClick={onClickGo}>
|
||||
{t('gallery.go')}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
JumpToContent.displayName = 'JumpToContent';
|
||||
@@ -4,6 +4,7 @@ import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
|
||||
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
|
||||
import type { ProgressImage as ProgressImageType } from 'features/nodes/types/common';
|
||||
import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
|
||||
import type { AnimationProps } from 'framer-motion';
|
||||
@@ -114,7 +115,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu
|
||||
left={0}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{/* <NextPrevImageButtons /> */}
|
||||
<NextPrevImageButtons />
|
||||
</Box>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -2,15 +2,17 @@ import { Box, Flex, forwardRef, Grid, GridItem, Skeleton, Spinner, Text } from '
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { logger } from 'app/logging/logger';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import {
|
||||
selectGalleryImageMinimumWidth,
|
||||
selectImageToCompare,
|
||||
selectLastSelectedImage,
|
||||
selectListImagesQueryArgs,
|
||||
} from 'features/gallery/store/gallerySelectors';
|
||||
import { selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type { MutableRefObject, RefObject } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
GridComponents,
|
||||
@@ -42,7 +44,7 @@ type GridContext = {
|
||||
imageNames: string[];
|
||||
};
|
||||
|
||||
export const useDebouncedImageCollectionQueryArgs = () => {
|
||||
export const useDebouncedListImagesQueryArgs = () => {
|
||||
const _galleryQueryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
const [queryArgs] = useDebounce(_galleryQueryArgs, DEBOUNCE_DELAY);
|
||||
return queryArgs;
|
||||
@@ -93,7 +95,7 @@ const ImageAtPosition = memo(
|
||||
ImageAtPosition.displayName = 'ImageAtPosition';
|
||||
|
||||
// Memoized compute key function using image names
|
||||
const computeItemKey: GridComputeItemKey<string, GridContext> = (index, imageName, { queryArgs }) => {
|
||||
const computeItemKey: GridComputeItemKey<string, GridContext> = (_index, imageName, { queryArgs }) => {
|
||||
return `${JSON.stringify(queryArgs)}-${imageName}`;
|
||||
};
|
||||
|
||||
@@ -202,7 +204,7 @@ const scrollIntoView = (
|
||||
return;
|
||||
};
|
||||
|
||||
const getImageIndex = (imageName: string | undefined, imageNames: string[]) => {
|
||||
const getImageIndex = (imageName: string | undefined | null, imageNames: string[]) => {
|
||||
if (!imageName || imageNames.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
@@ -216,30 +218,30 @@ const useKeyboardNavigation = (
|
||||
virtuosoRef: React.RefObject<VirtuosoGridHandle>,
|
||||
rootRef: React.RefObject<HTMLDivElement>
|
||||
) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
|
||||
const { dispatch, getState } = useAppStore();
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const rootEl = rootRef.current;
|
||||
const virtuosoGridHandle = virtuosoRef.current;
|
||||
if (!rootEl || !virtuosoGridHandle) {
|
||||
return;
|
||||
}
|
||||
if (imageNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle arrow keys
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't interfere if user is typing in an input
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootEl = rootRef.current;
|
||||
const virtuosoGridHandle = virtuosoRef.current;
|
||||
|
||||
if (!rootEl || !virtuosoGridHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (imageNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imagesPerRow = getImagesPerRow(rootEl);
|
||||
|
||||
if (imagesPerRow === 0) {
|
||||
@@ -249,7 +251,14 @@ const useKeyboardNavigation = (
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const currentIndex = getImageIndex(lastSelectedImage, imageNames);
|
||||
const imageName = event.altKey
|
||||
? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected,
|
||||
// we start from the last selected image
|
||||
(selectImageToCompare(getState()) ?? selectLastSelectedImage(getState()))
|
||||
: selectLastSelectedImage(getState());
|
||||
|
||||
const currentIndex = getImageIndex(imageName, imageNames);
|
||||
|
||||
let newIndex = currentIndex;
|
||||
|
||||
switch (event.key) {
|
||||
@@ -290,19 +299,80 @@ const useKeyboardNavigation = (
|
||||
if (newIndex !== currentIndex && newIndex >= 0 && newIndex < imageNames.length) {
|
||||
const newImageName = imageNames[newIndex];
|
||||
if (newImageName) {
|
||||
dispatch(selectionChanged([newImageName]));
|
||||
if (event.altKey) {
|
||||
dispatch(imageToCompareChanged(newImageName));
|
||||
} else {
|
||||
dispatch(selectionChanged([newImageName]));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[rootRef, virtuosoRef, imageNames, lastSelectedImage, dispatch]
|
||||
[rootRef, virtuosoRef, imageNames, getState, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavLeft',
|
||||
category: 'gallery',
|
||||
callback: handleKeyDown,
|
||||
options: { preventDefault: true },
|
||||
dependencies: [handleKeyDown],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavRight',
|
||||
category: 'gallery',
|
||||
callback: handleKeyDown,
|
||||
options: { preventDefault: true },
|
||||
dependencies: [handleKeyDown],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavUp',
|
||||
category: 'gallery',
|
||||
callback: handleKeyDown,
|
||||
options: { preventDefault: true },
|
||||
dependencies: [handleKeyDown],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavDown',
|
||||
category: 'gallery',
|
||||
callback: handleKeyDown,
|
||||
options: { preventDefault: true },
|
||||
dependencies: [handleKeyDown],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavLeftAlt',
|
||||
category: 'gallery',
|
||||
callback: handleKeyDown,
|
||||
options: { preventDefault: true },
|
||||
dependencies: [handleKeyDown],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavRightAlt',
|
||||
category: 'gallery',
|
||||
callback: handleKeyDown,
|
||||
options: { preventDefault: true },
|
||||
dependencies: [handleKeyDown],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavUpAlt',
|
||||
category: 'gallery',
|
||||
callback: handleKeyDown,
|
||||
options: { preventDefault: true },
|
||||
dependencies: [handleKeyDown],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavDownAlt',
|
||||
category: 'gallery',
|
||||
callback: handleKeyDown,
|
||||
options: { preventDefault: true },
|
||||
dependencies: [handleKeyDown],
|
||||
});
|
||||
};
|
||||
|
||||
const useKeepSelectedImageInView = (
|
||||
@@ -330,28 +400,21 @@ const useKeepSelectedImageInView = (
|
||||
};
|
||||
|
||||
const getImageNamesQueryOptions = {
|
||||
selectFromResult: ({ data, isLoading }) => ({
|
||||
selectFromResult: ({ data, isLoading, isFetching }) => ({
|
||||
imageNames: data ?? EMPTY_ARRAY,
|
||||
isLoading,
|
||||
isFetching,
|
||||
}),
|
||||
} satisfies Parameters<typeof useGetImageNamesQuery>[1];
|
||||
|
||||
// Main gallery component
|
||||
export const NewGallery = memo(() => {
|
||||
const queryArgs = useDebouncedImageCollectionQueryArgs();
|
||||
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
|
||||
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
export const useGalleryImageNames = () => {
|
||||
const queryArgs = useDebouncedListImagesQueryArgs();
|
||||
const { imageNames, isLoading, isFetching } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions);
|
||||
return { imageNames, isLoading, isFetching, queryArgs };
|
||||
};
|
||||
|
||||
// Get the ordered list of image names - this is our primary data source for virtualization
|
||||
const { imageNames, isLoading } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions);
|
||||
|
||||
useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef);
|
||||
|
||||
// Enable keyboard navigation
|
||||
useKeyboardNavigation(imageNames, virtuosoRef, rootRef);
|
||||
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const useScrollableGallery = (rootRef: RefObject<HTMLDivElement>) => {
|
||||
const [scroller, scrollerRef] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
events: {
|
||||
@@ -379,9 +442,25 @@ export const NewGallery = memo(() => {
|
||||
return () => {
|
||||
osInstance()?.destroy();
|
||||
};
|
||||
}, [scroller, initialize, osInstance]);
|
||||
}, [scroller, initialize, osInstance, rootRef]);
|
||||
|
||||
// Handle range changes - RTK Query will automatically cache and manage loading
|
||||
return scrollerRef;
|
||||
};
|
||||
|
||||
// Main gallery component
|
||||
export const NewGallery = memo(() => {
|
||||
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
|
||||
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get the ordered list of image names - this is our primary data source for virtualization
|
||||
const { queryArgs, imageNames, isLoading } = useGalleryImageNames();
|
||||
|
||||
useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef);
|
||||
useKeyboardNavigation(imageNames, virtuosoRef, rootRef);
|
||||
const scrollerRef = useScrollableGallery(rootRef);
|
||||
|
||||
// We have to keep track of the visible range for keep-selected-image-in-view functionality
|
||||
const handleRangeChanged = useCallback((range: ListRange) => {
|
||||
rangeRef.current = range;
|
||||
}, []);
|
||||
@@ -422,14 +501,13 @@ export const NewGallery = memo(() => {
|
||||
<VirtuosoGrid<string, GridContext>
|
||||
ref={virtuosoRef}
|
||||
context={context}
|
||||
totalCount={imageNames.length}
|
||||
data={imageNames}
|
||||
increaseViewportBy={VIEWPORT_BUFFER}
|
||||
itemContent={itemContent}
|
||||
computeItemKey={computeItemKey}
|
||||
components={components}
|
||||
style={style}
|
||||
scrollerRef={setScroller}
|
||||
scrollerRef={scrollerRef}
|
||||
scrollSeekConfiguration={scrollSeekConfiguration}
|
||||
rangeChanged={handleRangeChanged}
|
||||
/>
|
||||
|
||||
@@ -1,58 +1,53 @@
|
||||
import type { ChakraProps } from '@invoke-ai/ui-library';
|
||||
import { Box, IconButton } from '@invoke-ai/ui-library';
|
||||
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
||||
|
||||
import { useGalleryImageNames } from './NewGallery';
|
||||
|
||||
const NextPrevImageButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
|
||||
const { imageNames, isFetching } = useGalleryImageNames();
|
||||
|
||||
const { isFetching } = useGalleryImages().queryResult;
|
||||
|
||||
const shouldShowLeftArrow = useMemo(() => {
|
||||
if (!isOnFirstImageOfView) {
|
||||
return true;
|
||||
}
|
||||
if (isPrevEnabled) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [isOnFirstImageOfView, isPrevEnabled]);
|
||||
const isOnFirstImage = useMemo(
|
||||
() => (lastSelectedImage ? imageNames.at(0) === lastSelectedImage : false),
|
||||
[imageNames, lastSelectedImage]
|
||||
);
|
||||
const isOnLastImage = useMemo(
|
||||
() => (lastSelectedImage ? imageNames.at(-1) === lastSelectedImage : false),
|
||||
[imageNames, lastSelectedImage]
|
||||
);
|
||||
|
||||
const onClickLeftArrow = useCallback(() => {
|
||||
if (isOnFirstImageOfView) {
|
||||
if (isPrevEnabled && !isFetching) {
|
||||
goPrev('arrow');
|
||||
}
|
||||
} else {
|
||||
prevImage();
|
||||
const targetIndex = lastSelectedImage ? imageNames.findIndex((n) => n === lastSelectedImage) - 1 : 0;
|
||||
const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1);
|
||||
const n = imageNames.at(clampedIndex);
|
||||
if (!n) {
|
||||
return;
|
||||
}
|
||||
}, [goPrev, isFetching, isOnFirstImageOfView, isPrevEnabled, prevImage]);
|
||||
|
||||
const shouldShowRightArrow = useMemo(() => {
|
||||
if (!isOnLastImageOfView) {
|
||||
return true;
|
||||
}
|
||||
if (isNextEnabled) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [isNextEnabled, isOnLastImageOfView]);
|
||||
dispatch(imageSelected(n));
|
||||
}, [dispatch, imageNames, lastSelectedImage]);
|
||||
|
||||
const onClickRightArrow = useCallback(() => {
|
||||
if (isOnLastImageOfView) {
|
||||
if (isNextEnabled && !isFetching) {
|
||||
goNext('arrow');
|
||||
}
|
||||
} else {
|
||||
nextImage();
|
||||
const targetIndex = lastSelectedImage ? imageNames.findIndex((n) => n === lastSelectedImage) + 1 : 0;
|
||||
const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1);
|
||||
const n = imageNames.at(clampedIndex);
|
||||
if (!n) {
|
||||
return;
|
||||
}
|
||||
}, [goNext, isFetching, isNextEnabled, isOnLastImageOfView, nextImage]);
|
||||
dispatch(imageSelected(n));
|
||||
}, [dispatch, imageNames, lastSelectedImage]);
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="full" w="full">
|
||||
{shouldShowLeftArrow && (
|
||||
{!isOnFirstImage && (
|
||||
<IconButton
|
||||
position="absolute"
|
||||
top="50%"
|
||||
@@ -67,7 +62,7 @@ const NextPrevImageButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineS
|
||||
insetInlineStart={inset}
|
||||
/>
|
||||
)}
|
||||
{shouldShowRightArrow && (
|
||||
{!isOnLastImage && (
|
||||
<IconButton
|
||||
position="absolute"
|
||||
top="50%"
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { useIsRegionFocused } from 'common/hooks/focus';
|
||||
import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
|
||||
import { selectActiveTab, selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
|
||||
import { useMemo } from 'react';
|
||||
import { useListImagesQuery } from 'services/api/endpoints/images';
|
||||
|
||||
/**
|
||||
* Registers gallery hotkeys. This hook is a singleton.
|
||||
*/
|
||||
export const useGalleryHotkeys = () => {
|
||||
const selection = useAppSelector((s) => s.gallery.selection);
|
||||
const queryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
const queryResult = useListImagesQuery(queryArgs);
|
||||
const canvasRightPanelTab = useAppSelector(selectActiveTabCanvasRightPanel);
|
||||
const appTab = useAppSelector(selectActiveTab);
|
||||
const isWorkflowsFocused = useIsRegionFocused('workflows');
|
||||
const isGalleryFocused = useIsRegionFocused('gallery');
|
||||
const isImageViewerFocused = useIsRegionFocused('viewer');
|
||||
const deleteImageModal = useDeleteImageModalApi();
|
||||
|
||||
// When we are on the canvas tab, we need to disable the delete hotkey when the user is focused on the layers tab in
|
||||
// the right hand panel, because the same hotkey is used to delete layers.
|
||||
const isDeleteEnabledByTab = useMemo(() => {
|
||||
if (appTab !== 'canvas') {
|
||||
return true;
|
||||
}
|
||||
return canvasRightPanelTab === 'gallery';
|
||||
}, [appTab, canvasRightPanelTab]);
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavLeft',
|
||||
category: 'gallery',
|
||||
callback: (e) => {
|
||||
// Skip the hotkey if the user is focused on a tab element - the arrow keys are used to navigate between tabs.
|
||||
if (e.target instanceof HTMLElement && e.target.getAttribute('role') === 'tab') {
|
||||
return;
|
||||
}
|
||||
if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) {
|
||||
goPrev('arrow');
|
||||
return;
|
||||
}
|
||||
handleLeftImage(false);
|
||||
},
|
||||
options: { preventDefault: true, enabled: isGalleryFocused || isImageViewerFocused },
|
||||
dependencies: [
|
||||
handleLeftImage,
|
||||
isOnFirstImageOfView,
|
||||
goPrev,
|
||||
isPrevEnabled,
|
||||
queryResult.isFetching,
|
||||
isGalleryFocused,
|
||||
isImageViewerFocused,
|
||||
],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavRight',
|
||||
category: 'gallery',
|
||||
callback: (e) => {
|
||||
// Skip the hotkey if the user is focused on a tab element - the arrow keys are used to navigate between tabs.
|
||||
if (e.target instanceof HTMLElement && e.target.getAttribute('role') === 'tab') {
|
||||
return;
|
||||
}
|
||||
if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) {
|
||||
goNext('arrow');
|
||||
return;
|
||||
}
|
||||
if (!isOnLastImageOfView) {
|
||||
handleRightImage(false);
|
||||
}
|
||||
},
|
||||
options: { preventDefault: true, enabled: isGalleryFocused || isImageViewerFocused },
|
||||
dependencies: [
|
||||
isOnLastImageOfView,
|
||||
goNext,
|
||||
isNextEnabled,
|
||||
queryResult.isFetching,
|
||||
handleRightImage,
|
||||
isGalleryFocused,
|
||||
isImageViewerFocused,
|
||||
],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavUp',
|
||||
category: 'gallery',
|
||||
callback: () => {
|
||||
if (isOnFirstRow && isPrevEnabled && !queryResult.isFetching) {
|
||||
goPrev('arrow');
|
||||
return;
|
||||
}
|
||||
handleUpImage(false);
|
||||
},
|
||||
options: { preventDefault: true, enabled: isGalleryFocused },
|
||||
dependencies: [handleUpImage, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching, isGalleryFocused],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavDown',
|
||||
category: 'gallery',
|
||||
callback: () => {
|
||||
if (isOnLastRow && isNextEnabled && !queryResult.isFetching) {
|
||||
goNext('arrow');
|
||||
return;
|
||||
}
|
||||
handleDownImage(false);
|
||||
},
|
||||
options: { preventDefault: true, enabled: isGalleryFocused },
|
||||
dependencies: [isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage, isGalleryFocused],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavLeftAlt',
|
||||
category: 'gallery',
|
||||
callback: () => {
|
||||
if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) {
|
||||
goPrev('alt+arrow');
|
||||
return;
|
||||
}
|
||||
handleLeftImage(true);
|
||||
},
|
||||
options: { preventDefault: true, enabled: isGalleryFocused || isImageViewerFocused },
|
||||
dependencies: [
|
||||
handleLeftImage,
|
||||
isOnFirstImageOfView,
|
||||
goPrev,
|
||||
isPrevEnabled,
|
||||
queryResult.isFetching,
|
||||
isGalleryFocused,
|
||||
isImageViewerFocused,
|
||||
],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavRightAlt',
|
||||
category: 'gallery',
|
||||
callback: () => {
|
||||
if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) {
|
||||
goNext('alt+arrow');
|
||||
return;
|
||||
}
|
||||
if (!isOnLastImageOfView) {
|
||||
handleRightImage(true);
|
||||
}
|
||||
},
|
||||
options: { preventDefault: true, enabled: isGalleryFocused || isImageViewerFocused },
|
||||
dependencies: [
|
||||
isOnLastImageOfView,
|
||||
goNext,
|
||||
isNextEnabled,
|
||||
queryResult.isFetching,
|
||||
handleRightImage,
|
||||
isGalleryFocused,
|
||||
isImageViewerFocused,
|
||||
],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavUpAlt',
|
||||
category: 'gallery',
|
||||
callback: () => {
|
||||
if (isOnFirstRow && isPrevEnabled && !queryResult.isFetching) {
|
||||
goPrev('alt+arrow');
|
||||
return;
|
||||
}
|
||||
handleUpImage(true);
|
||||
},
|
||||
options: { preventDefault: true, enabled: isGalleryFocused },
|
||||
dependencies: [handleUpImage, isOnFirstRow, goPrev, isPrevEnabled, queryResult.isFetching, isGalleryFocused],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'galleryNavDownAlt',
|
||||
category: 'gallery',
|
||||
callback: () => {
|
||||
if (isOnLastRow && isNextEnabled && !queryResult.isFetching) {
|
||||
goNext('alt+arrow');
|
||||
return;
|
||||
}
|
||||
handleDownImage(true);
|
||||
},
|
||||
options: { preventDefault: true, enabled: isGalleryFocused },
|
||||
dependencies: [isOnLastRow, goNext, isNextEnabled, queryResult.isFetching, handleDownImage, isGalleryFocused],
|
||||
});
|
||||
|
||||
useRegisteredHotkeys({
|
||||
id: 'deleteSelection',
|
||||
category: 'gallery',
|
||||
callback: () => {
|
||||
if (!selection.length) {
|
||||
return;
|
||||
}
|
||||
deleteImageModal.delete(selection);
|
||||
},
|
||||
options: {
|
||||
enabled: (isGalleryFocused || isImageViewerFocused) && isDeleteEnabledByTab && !isWorkflowsFocused,
|
||||
},
|
||||
dependencies: [isWorkflowsFocused, isDeleteEnabledByTab, selection, isWorkflowsFocused],
|
||||
});
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { useMemo } from 'react';
|
||||
import { useListImagesQuery } from 'services/api/endpoints/images';
|
||||
|
||||
export const useGalleryImages = () => {
|
||||
const queryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
const queryResult = useListImagesQuery(queryArgs);
|
||||
const imageDTOs = useMemo(() => queryResult.data?.items ?? EMPTY_ARRAY, [queryResult.data]);
|
||||
return {
|
||||
imageDTOs,
|
||||
queryResult,
|
||||
};
|
||||
};
|
||||
@@ -1,270 +0,0 @@
|
||||
import { useAltModifier } from '@invoke-ai/ui-library';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { GALLERY_GRID_CLASS_NAME } from 'features/gallery/components/ImageGrid/constants';
|
||||
import { GALLERY_IMAGE_CONTAINER_CLASS_NAME } from 'features/gallery/components/ImageGrid/GalleryImage';
|
||||
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
|
||||
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
|
||||
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
|
||||
import { selectImageToCompare, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
|
||||
import { getIsVisible } from 'features/gallery/util/getIsVisible';
|
||||
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
/**
|
||||
* This hook is used to navigate the gallery using the arrow keys.
|
||||
*
|
||||
* The gallery is rendered as a grid. In order to navigate the grid,
|
||||
* we need to know how many images are in each row and whether or not
|
||||
* an image is visible in the gallery.
|
||||
*
|
||||
* We use direct DOM query selectors to check if an image is visible
|
||||
* to avoid having to track a ref for each image.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the number of images per row in the gallery by grabbing their DOM elements.
|
||||
*/
|
||||
const getImagesPerRow = (): number => {
|
||||
const imageEl = document.querySelector(`.${GALLERY_IMAGE_CONTAINER_CLASS_NAME}`);
|
||||
const gridEl = document.querySelector(`.${GALLERY_GRID_CLASS_NAME}`);
|
||||
|
||||
if (!imageEl || !gridEl) {
|
||||
return 0;
|
||||
}
|
||||
const container = gridEl.parentElement;
|
||||
if (!container) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const imageRect = imageEl.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// We need to account for the gap between images
|
||||
const gridElStyle = window.getComputedStyle(gridEl);
|
||||
const gap = parseFloat(gridElStyle.gap);
|
||||
|
||||
if (!imageRect.width || !imageRect.height || !containerRect.width || !containerRect.height) {
|
||||
// Gallery is too small to fit images or not rendered yet
|
||||
return 0;
|
||||
}
|
||||
|
||||
let imagesPerRow = 0;
|
||||
let spaceUsed = 0;
|
||||
|
||||
// Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes
|
||||
// this, without the possibility of accidentally adding an extra column.
|
||||
while (spaceUsed + imageRect.width <= containerRect.width + 1) {
|
||||
imagesPerRow++; // Increment the number of images
|
||||
spaceUsed += imageRect.width; // Add image size to the used space
|
||||
if (spaceUsed + gap <= containerRect.width) {
|
||||
spaceUsed += gap; // Add gap size to the used space after each image except after the last image
|
||||
}
|
||||
}
|
||||
|
||||
return imagesPerRow;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrolls to the image with the given name.
|
||||
* If the image is not fully visible, it will not be scrolled to.
|
||||
* @param imageName The image name to scroll to.
|
||||
* @param index The index of the image in the gallery.
|
||||
*/
|
||||
const scrollToImage = (imageName: string, index: number) => {
|
||||
const virtuosoContext = virtuosoGridRefs.get();
|
||||
const range = virtuosoContext.virtuosoRangeRef?.current;
|
||||
const root = virtuosoContext.rootRef?.current;
|
||||
const virtuoso = virtuosoContext.virtuosoRef?.current;
|
||||
|
||||
if (!range || !virtuoso || !root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageElement = document.querySelector(`[data-testid="${getGalleryImageDataTestId(imageName)}"]`);
|
||||
const itemRect = imageElement?.getBoundingClientRect();
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
if (!itemRect || !getIsVisible(itemRect, rootRect)) {
|
||||
virtuoso.scrollToIndex({
|
||||
index,
|
||||
align: getScrollToIndexAlign(index, range),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Utilities to get the image to the left, right, up, or down of the current image.
|
||||
|
||||
const getLeftImage = (images: ImageDTO[], currentIndex: number) => {
|
||||
const index = clamp(currentIndex - 1, 0, images.length - 1);
|
||||
const image = images[index];
|
||||
return { index, image };
|
||||
};
|
||||
|
||||
const getRightImage = (images: ImageDTO[], currentIndex: number) => {
|
||||
const index = clamp(currentIndex + 1, 0, images.length - 1);
|
||||
const image = images[index];
|
||||
return { index, image };
|
||||
};
|
||||
|
||||
const getUpImage = (images: ImageDTO[], currentIndex: number) => {
|
||||
const imagesPerRow = getImagesPerRow();
|
||||
// If we are on the first row, we want to stay on the first row, not go to first image
|
||||
const isOnFirstRow = currentIndex < imagesPerRow;
|
||||
const index = isOnFirstRow ? currentIndex : clamp(currentIndex - imagesPerRow, 0, images.length - 1);
|
||||
const image = images[index];
|
||||
return { index, image };
|
||||
};
|
||||
|
||||
const getDownImage = (images: ImageDTO[], currentIndex: number) => {
|
||||
const imagesPerRow = getImagesPerRow();
|
||||
// If there are no images below the current image, we want to stay where we are
|
||||
const areImagesBelow = currentIndex < images.length - imagesPerRow;
|
||||
const index = areImagesBelow ? clamp(currentIndex + imagesPerRow, 0, images.length - 1) : currentIndex;
|
||||
const image = images[index];
|
||||
return { index, image };
|
||||
};
|
||||
|
||||
const getImageFuncs = {
|
||||
left: getLeftImage,
|
||||
right: getRightImage,
|
||||
up: getUpImage,
|
||||
down: getDownImage,
|
||||
};
|
||||
|
||||
type UseGalleryNavigationReturn = {
|
||||
handleLeftImage: (alt?: boolean) => void;
|
||||
handleRightImage: (alt?: boolean) => void;
|
||||
handleUpImage: (alt?: boolean) => void;
|
||||
handleDownImage: (alt?: boolean) => void;
|
||||
prevImage: () => void;
|
||||
nextImage: () => void;
|
||||
isOnFirstImage: boolean;
|
||||
isOnLastImage: boolean;
|
||||
isOnFirstRow: boolean;
|
||||
isOnLastRow: boolean;
|
||||
isOnFirstImageOfView: boolean;
|
||||
isOnLastImageOfView: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides access to the gallery navigation via arrow keys.
|
||||
* Also provides information about the current image's position in the gallery,
|
||||
* useful for determining whether to load more images or display navigation
|
||||
* buttons.
|
||||
*/
|
||||
export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
|
||||
const dispatch = useAppDispatch();
|
||||
const alt = useAltModifier();
|
||||
const selectImage = useMemo(
|
||||
() =>
|
||||
createSelector(selectLastSelectedImage, selectImageToCompare, (lastSelectedImage, imageToCompare) => {
|
||||
if (alt) {
|
||||
return imageToCompare ?? lastSelectedImage;
|
||||
} else {
|
||||
return lastSelectedImage;
|
||||
}
|
||||
}),
|
||||
[alt]
|
||||
);
|
||||
const lastSelectedImage = useAppSelector(selectImage);
|
||||
const { imageDTOs } = useGalleryImages();
|
||||
const loadedImagesCount = useMemo(() => imageDTOs.length, [imageDTOs.length]);
|
||||
|
||||
const lastSelectedImageIndex = useMemo(() => {
|
||||
if (imageDTOs.length === 0 || !lastSelectedImage) {
|
||||
return 0;
|
||||
}
|
||||
return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage);
|
||||
}, [imageDTOs, lastSelectedImage]);
|
||||
|
||||
const handleNavigation = useCallback(
|
||||
(direction: 'left' | 'right' | 'up' | 'down', alt?: boolean) => {
|
||||
const { index, image } = getImageFuncs[direction](imageDTOs, lastSelectedImageIndex);
|
||||
if (!image || index === lastSelectedImageIndex) {
|
||||
return;
|
||||
}
|
||||
if (alt) {
|
||||
dispatch(imageToCompareChanged(image.image_name));
|
||||
} else {
|
||||
dispatch(imageSelected(image.image_name));
|
||||
}
|
||||
scrollToImage(image.image_name, index);
|
||||
},
|
||||
[imageDTOs, lastSelectedImageIndex, dispatch]
|
||||
);
|
||||
|
||||
const isOnFirstImage = useMemo(() => lastSelectedImageIndex === 0, [lastSelectedImageIndex]);
|
||||
|
||||
const isOnLastImage = useMemo(
|
||||
() => lastSelectedImageIndex === loadedImagesCount - 1,
|
||||
[lastSelectedImageIndex, loadedImagesCount]
|
||||
);
|
||||
|
||||
const isOnFirstRow = useMemo(() => lastSelectedImageIndex < getImagesPerRow(), [lastSelectedImageIndex]);
|
||||
const isOnLastRow = useMemo(
|
||||
() => lastSelectedImageIndex >= loadedImagesCount - getImagesPerRow(),
|
||||
[lastSelectedImageIndex, loadedImagesCount]
|
||||
);
|
||||
|
||||
const isOnFirstImageOfView = useMemo(() => {
|
||||
return lastSelectedImageIndex === 0;
|
||||
}, [lastSelectedImageIndex]);
|
||||
|
||||
const isOnLastImageOfView = useMemo(() => {
|
||||
return lastSelectedImageIndex === loadedImagesCount - 1;
|
||||
}, [lastSelectedImageIndex, loadedImagesCount]);
|
||||
|
||||
const handleLeftImage = useCallback(
|
||||
(alt?: boolean) => {
|
||||
handleNavigation('left', alt);
|
||||
},
|
||||
[handleNavigation]
|
||||
);
|
||||
|
||||
const handleRightImage = useCallback(
|
||||
(alt?: boolean) => {
|
||||
handleNavigation('right', alt);
|
||||
},
|
||||
[handleNavigation]
|
||||
);
|
||||
|
||||
const handleUpImage = useCallback(
|
||||
(alt?: boolean) => {
|
||||
handleNavigation('up', alt);
|
||||
},
|
||||
[handleNavigation]
|
||||
);
|
||||
|
||||
const handleDownImage = useCallback(
|
||||
(alt?: boolean) => {
|
||||
handleNavigation('down', alt);
|
||||
},
|
||||
[handleNavigation]
|
||||
);
|
||||
|
||||
const nextImage = useCallback(() => {
|
||||
handleRightImage();
|
||||
}, [handleRightImage]);
|
||||
|
||||
const prevImage = useCallback(() => {
|
||||
handleLeftImage();
|
||||
}, [handleLeftImage]);
|
||||
|
||||
return {
|
||||
handleLeftImage,
|
||||
handleRightImage,
|
||||
handleUpImage,
|
||||
handleDownImage,
|
||||
isOnFirstImage,
|
||||
isOnLastImage,
|
||||
isOnFirstRow,
|
||||
isOnLastRow,
|
||||
nextImage,
|
||||
prevImage,
|
||||
isOnFirstImageOfView,
|
||||
isOnLastImageOfView,
|
||||
};
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
|
||||
import { offsetChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useListImagesQuery } from 'services/api/endpoints/images';
|
||||
|
||||
// Some logic copied from https://github.com/chakra-ui/zag/blob/1925b7342dc76fb06a7ec59a5a4c0063a4620422/packages/machines/pagination/src/pagination.utils.ts
|
||||
|
||||
const range = (start: number, end: number) => {
|
||||
const length = end - start + 1;
|
||||
return Array.from({ length }, (_, idx) => idx + start);
|
||||
};
|
||||
|
||||
export const ELLIPSIS = 'ellipsis' as const;
|
||||
|
||||
const getRange = (currentPage: number, totalPages: number, siblingCount: number) => {
|
||||
/**
|
||||
* `2 * ctx.siblingCount + 5` explanation:
|
||||
* 2 * ctx.siblingCount for left/right siblings
|
||||
* 5 for 2x left/right ellipsis, 2x first/last page + 1x current page
|
||||
*
|
||||
* For some page counts (e.g. totalPages: 8, siblingCount: 2),
|
||||
* calculated max page is higher than total pages,
|
||||
* so we need to take the minimum of both.
|
||||
*/
|
||||
const totalPageNumbers = Math.min(2 * siblingCount + 5, totalPages);
|
||||
|
||||
const firstPageIndex = 1;
|
||||
const lastPageIndex = totalPages;
|
||||
|
||||
const leftSiblingIndex = Math.max(currentPage - siblingCount, firstPageIndex);
|
||||
const rightSiblingIndex = Math.min(currentPage + siblingCount, lastPageIndex);
|
||||
|
||||
const showLeftEllipsis = leftSiblingIndex > firstPageIndex + 1;
|
||||
const showRightEllipsis = rightSiblingIndex < lastPageIndex - 1;
|
||||
|
||||
const itemCount = totalPageNumbers - 2; // 2 stands for one ellipsis and either first or last page
|
||||
|
||||
if (!showLeftEllipsis && showRightEllipsis) {
|
||||
const leftRange = range(1, itemCount);
|
||||
return [...leftRange, ELLIPSIS, lastPageIndex];
|
||||
}
|
||||
|
||||
if (showLeftEllipsis && !showRightEllipsis) {
|
||||
const rightRange = range(lastPageIndex - itemCount + 1, lastPageIndex);
|
||||
return [firstPageIndex, ELLIPSIS, ...rightRange];
|
||||
}
|
||||
|
||||
if (showLeftEllipsis && showRightEllipsis) {
|
||||
const middleRange = range(leftSiblingIndex, rightSiblingIndex);
|
||||
return [firstPageIndex, ELLIPSIS, ...middleRange, ELLIPSIS, lastPageIndex];
|
||||
}
|
||||
|
||||
const fullRange = range(firstPageIndex, lastPageIndex);
|
||||
return fullRange as (number | 'ellipsis')[];
|
||||
};
|
||||
|
||||
const selectOffset = createSelector(selectGallerySlice, (gallery) => gallery.offset);
|
||||
const selectLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit);
|
||||
|
||||
export const useGalleryPagination = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const offset = useAppSelector(selectOffset);
|
||||
const limit = useAppSelector(selectLimit);
|
||||
const queryArgs = useAppSelector(selectListImagesQueryArgs);
|
||||
|
||||
const { count, total } = useListImagesQuery(queryArgs, {
|
||||
selectFromResult: ({ data }) => ({ count: data?.items.length ?? 0, total: data?.total ?? 0 }),
|
||||
});
|
||||
|
||||
const currentPage = useMemo(() => Math.ceil(offset / (limit || 0)), [offset, limit]);
|
||||
const pages = useMemo(() => Math.ceil(total / (limit || 0)), [total, limit]);
|
||||
|
||||
const isNextEnabled = useMemo(() => {
|
||||
if (!count) {
|
||||
return false;
|
||||
}
|
||||
return currentPage + 1 < pages;
|
||||
}, [count, currentPage, pages]);
|
||||
const isPrevEnabled = useMemo(() => {
|
||||
if (!count) {
|
||||
return false;
|
||||
}
|
||||
return offset > 0;
|
||||
}, [count, offset]);
|
||||
|
||||
const onOffsetChanged = useCallback(
|
||||
(arg: Parameters<typeof offsetChanged>[0]) => {
|
||||
dispatch(offsetChanged(arg));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const throttledOnOffsetChanged = useMemo(() => throttle(onOffsetChanged, 500), [onOffsetChanged]);
|
||||
|
||||
const goNext = useCallback(
|
||||
(withHotkey?: 'arrow' | 'alt+arrow') => {
|
||||
throttledOnOffsetChanged({ offset: offset + (limit || 0), withHotkey });
|
||||
},
|
||||
[throttledOnOffsetChanged, offset, limit]
|
||||
);
|
||||
|
||||
const goPrev = useCallback(
|
||||
(withHotkey?: 'arrow' | 'alt+arrow') => {
|
||||
throttledOnOffsetChanged({ offset: Math.max(offset - (limit || 0), 0), withHotkey });
|
||||
},
|
||||
[throttledOnOffsetChanged, offset, limit]
|
||||
);
|
||||
|
||||
const goToPage = useCallback(
|
||||
(page: number) => {
|
||||
throttledOnOffsetChanged({ offset: page * (limit || 0) });
|
||||
},
|
||||
[throttledOnOffsetChanged, limit]
|
||||
);
|
||||
|
||||
// handle when total/pages decrease and user is on high page number (ie bulk removing or deleting)
|
||||
useEffect(() => {
|
||||
if (pages && currentPage + 1 > pages) {
|
||||
throttledOnOffsetChanged({ offset: (pages - 1) * (limit || 0) });
|
||||
}
|
||||
}, [currentPage, pages, throttledOnOffsetChanged, limit]);
|
||||
|
||||
const pageButtons = useMemo(() => {
|
||||
if (pages > 7) {
|
||||
return getRange(currentPage + 1, pages, 1);
|
||||
}
|
||||
return range(1, pages);
|
||||
}, [currentPage, pages]);
|
||||
|
||||
return {
|
||||
goPrev,
|
||||
goNext,
|
||||
isPrevEnabled,
|
||||
isNextEnabled,
|
||||
pageButtons,
|
||||
goToPage,
|
||||
currentPage,
|
||||
total,
|
||||
pages,
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import type { NodeProps } from '@xyflow/react';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { DndImage } from 'features/dnd/DndImage';
|
||||
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
||||
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
|
||||
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
|
||||
@@ -74,7 +75,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => {
|
||||
{props.children}
|
||||
{isHovering && (
|
||||
<motion.div key="nextPrevButtons" initial={initial} animate={animate} exit={exit} style={styles}>
|
||||
{/* <NextPrevImageButtons inset={2} /> */}
|
||||
<NextPrevImageButtons inset={2} />
|
||||
</motion.div>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
Reference in New Issue
Block a user