feat(ui): restore gallery hotkeys (except delete)

This commit is contained in:
psychedelicious
2025-06-25 13:02:13 +10:00
parent b5eb3d9798
commit 98368b0665
14 changed files with 184 additions and 1117 deletions

View File

@@ -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],
// });
};

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>) => {

View File

@@ -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',

View File

@@ -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';

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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%"

View File

@@ -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],
});
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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>