Skip to content
1 change: 1 addition & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@
"oldestFirst": "Oldest First",
"sortDirection": "Sort Direction",
"showStarredImagesFirst": "Show Starred Images First",
"usePagedGalleryView": "Use Paged Gallery View",
"noImageSelected": "No Image Selected",
"noImagesInGallery": "No Images to Display",
"starImage": "Star",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ const useKeepSelectedImageInView = (
return;
}

if (!imageNames.includes(targetImageName)) {
return;
}

setTimeout(() => {
scrollIntoView(targetImageName, imageNames, rootEl, virtuosoGridHandle, range);
}, 0);
Expand Down Expand Up @@ -310,75 +314,89 @@ const useStarImageHotkey = () => {
});
};

export const GalleryImageGrid = 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();
type GalleryImageGridContentProps = {
imageNames: string[];
isLoading: boolean;
queryArgs: ListImageNamesQueryArgs;
rootRef?: React.RefObject<HTMLDivElement>;
};

// Use range-based fetching for bulk loading image DTOs into cache based on the visible range
const { onRangeChanged } = useRangeBasedImageFetching({
imageNames,
enabled: !isLoading,
});
export const GalleryImageGridContent = memo(
({ imageNames, isLoading, queryArgs, rootRef: rootRefProp }: GalleryImageGridContentProps) => {
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
const rangeRef = useRef<ListRange>({ startIndex: 0, endIndex: 0 });
const internalRootRef = useRef<HTMLDivElement>(null);
const rootRef = rootRefProp ?? internalRootRef;

// Use range-based fetching for bulk loading image DTOs into cache based on the visible range
const { onRangeChanged } = useRangeBasedImageFetching({
imageNames,
enabled: !isLoading,
});

useStarImageHotkey();
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 and push the range to
* the range-based image fetching hook.
*/
const handleRangeChanged = useCallback(
(range: ListRange) => {
rangeRef.current = range;
onRangeChanged(range);
},
[onRangeChanged]
);

useStarImageHotkey();
useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef);
useKeyboardNavigation(imageNames, virtuosoRef, rootRef);
const scrollerRef = useScrollableGallery(rootRef);
const context = useMemo<GridContext>(() => ({ imageNames, queryArgs }), [imageNames, queryArgs]);

/*
* We have to keep track of the visible range for keep-selected-image-in-view functionality and push the range to
* the range-based image fetching hook.
*/
const handleRangeChanged = useCallback(
(range: ListRange) => {
rangeRef.current = range;
onRangeChanged(range);
},
[onRangeChanged]
);
if (isLoading) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center" gap={4}>
<Spinner size="lg" opacity={0.3} />
<Text color="base.300">Loading gallery...</Text>
</Flex>
);
}

const context = useMemo<GridContext>(() => ({ imageNames, queryArgs }), [imageNames, queryArgs]);
if (imageNames.length === 0) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text color="base.300">No images found</Text>
</Flex>
);
}

if (isLoading) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center" gap={4}>
<Spinner size="lg" opacity={0.3} />
<Text color="base.300">Loading gallery...</Text>
</Flex>
// This wrapper component is necessary to initialize the overlay scrollbars!
<Box data-overlayscrollbars-initialize="" ref={rootRef} position="relative" w="full" h="full">
<VirtuosoGrid<string, GridContext>
ref={virtuosoRef}
context={context}
data={imageNames}
increaseViewportBy={4096}
itemContent={itemContent}
computeItemKey={computeItemKey}
components={components}
style={style}
scrollerRef={scrollerRef}
scrollSeekConfiguration={scrollSeekConfiguration}
rangeChanged={handleRangeChanged}
/>
<GallerySelectionCountTag imageNames={imageNames} />
</Box>
);
}
);

if (imageNames.length === 0) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Text color="base.300">No images found</Text>
</Flex>
);
}
GalleryImageGridContent.displayName = 'GalleryImageGridContent';

return (
// This wrapper component is necessary to initialize the overlay scrollbars!
<Box data-overlayscrollbars-initialize="" ref={rootRef} position="relative" w="full" h="full">
<VirtuosoGrid<string, GridContext>
ref={virtuosoRef}
context={context}
data={imageNames}
increaseViewportBy={4096}
itemContent={itemContent}
computeItemKey={computeItemKey}
components={components}
style={style}
scrollerRef={scrollerRef}
scrollSeekConfiguration={scrollSeekConfiguration}
rangeChanged={handleRangeChanged}
/>
<GallerySelectionCountTag />
</Box>
);
export const GalleryImageGrid = memo(() => {
const { queryArgs, imageNames, isLoading } = useGalleryImageNames();
return <GalleryImageGridContent imageNames={imageNames} isLoading={isLoading} queryArgs={queryArgs} />;
});

GalleryImageGrid.displayName = 'GalleryImageGrid';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { GalleryImageGridContent } from 'features/gallery/components/GalleryImageGrid';
import { GalleryPaginationPaged } from 'features/gallery/components/ImageGrid/GalleryPaginationPaged';
import { useGalleryImageNames } from 'features/gallery/components/use-gallery-image-names';
import { selectGalleryImageMinimumWidth, selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';

import { getItemsPerPage } from './getItemsPerPage';

const FALLBACK_PAGE_SIZE = 200;

export const GalleryImageGridPaged = memo(() => {
const { queryArgs, imageNames, isLoading } = useGalleryImageNames();
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(FALLBACK_PAGE_SIZE);
const gridRootRef = useRef<HTMLDivElement>(null);
const lastSelectedRef = useRef<string | null>(null);
const lastPageSizeRef = useRef<number | null>(null);
const lastImageNamesRef = useRef<string[] | null>(null);

const pageCount = Math.ceil(imageNames.length / pageSize);
const pageImageNames = useMemo(() => {
const start = pageIndex * pageSize;
return imageNames.slice(start, start + pageSize);
}, [imageNames, pageIndex, pageSize]);

useEffect(() => {
if (pageIndex >= pageCount && pageCount > 0) {
setPageIndex(pageCount - 1);
}
}, [pageCount, pageIndex]);

useEffect(() => {
if (!lastSelectedItem) {
lastSelectedRef.current = null;
lastPageSizeRef.current = null;
lastImageNamesRef.current = imageNames;
return;
}

const shouldRecompute =
lastSelectedRef.current !== lastSelectedItem ||
lastPageSizeRef.current !== pageSize ||
lastImageNamesRef.current !== imageNames;

if (!shouldRecompute) {
return;
}

lastSelectedRef.current = lastSelectedItem;
lastPageSizeRef.current = pageSize;
lastImageNamesRef.current = imageNames;

const selectedIndex = imageNames.indexOf(lastSelectedItem);
if (selectedIndex === -1) {
return;
}
const nextPageIndex = Math.floor(selectedIndex / pageSize);
if (nextPageIndex !== pageIndex) {
setPageIndex(nextPageIndex);
}
}, [imageNames, lastSelectedItem, pageIndex, pageSize]);

const recalculatePageSize = useCallback(() => {
const rootEl = gridRootRef.current;
if (!rootEl) {
return;
}
const nextPageSize = getItemsPerPage(rootEl);
if (nextPageSize > 0 && nextPageSize !== pageSize) {
setPageSize(nextPageSize);
}
}, [pageSize]);

useLayoutEffect(() => {
if (isLoading) {
return;
}
let frame = 0;
let attempts = 0;
const tick = () => {
const rootEl = gridRootRef.current;
if (!rootEl) {
frame = requestAnimationFrame(tick);
return;
}
const nextPageSize = getItemsPerPage(rootEl);
if (nextPageSize > 0) {
if (nextPageSize !== pageSize) {
setPageSize(nextPageSize);
}
return;
}
if (attempts < 10) {
attempts += 1;
frame = requestAnimationFrame(tick);
}
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [galleryImageMinimumWidth, imageNames.length, isLoading, pageIndex, pageSize]);

useEffect(() => {
if (isLoading) {
return;
}
const timeout = setTimeout(() => {
recalculatePageSize();
requestAnimationFrame(recalculatePageSize);
}, 350);
return () => clearTimeout(timeout);
}, [galleryImageMinimumWidth, isLoading, recalculatePageSize]);

useEffect(() => {
const rootEl = gridRootRef.current;
if (!rootEl || typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() => {
recalculatePageSize();
});
observer.observe(rootEl);
return () => observer.disconnect();
}, [galleryImageMinimumWidth, recalculatePageSize]);

useEffect(() => {
const rootEl = gridRootRef.current;
if (!rootEl || typeof MutationObserver === 'undefined') {
return;
}
const observer = new MutationObserver(() => {
recalculatePageSize();
});
observer.observe(rootEl, { childList: true, subtree: true });
return () => observer.disconnect();
}, [recalculatePageSize]);

const handleTabChange = useCallback((index: number) => {
setPageIndex(index);
}, []);

const handlePreviousPage = useCallback(() => {
setPageIndex((prev) => Math.max(0, prev - 1));
}, []);

const handleNextPage = useCallback(() => {
setPageIndex((prev) => Math.min(pageCount - 1, prev + 1));
}, [pageCount]);

const handlePageInputChange = useCallback(
(valueAsString: string, valueAsNumber: number) => {
if (!valueAsString) {
return;
}
if (Number.isNaN(valueAsNumber)) {
return;
}
const nextIndex = Math.min(Math.max(valueAsNumber, 1), pageCount) - 1;
setPageIndex(nextIndex);
},
[pageCount]
);

if (isLoading || imageNames.length === 0) {
return <GalleryImageGridContent imageNames={imageNames} isLoading={isLoading} queryArgs={queryArgs} />;
}

return (
<Flex w="full" h="full" flexDir="column" gap={2}>
<GalleryPaginationPaged
pageIndex={pageIndex}
pageCount={pageCount}
onPrev={handlePreviousPage}
onNext={handleNextPage}
onGoToPage={handleTabChange}
onPageInputChange={handlePageInputChange}
/>
<Flex w="full" h="full">
<GalleryImageGridContent
imageNames={pageImageNames}
isLoading={false}
queryArgs={queryArgs}
rootRef={gridRootRef}
/>
</Flex>
</Flex>
);
});

GalleryImageGridPaged.displayName = 'GalleryImageGridPaged';
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
import { galleryViewChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { useAutoLayoutContext } from 'features/ui/layouts/auto-layout-context';
import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel';
import { selectShouldUsePagedGalleryView } from 'features/ui/store/uiSelectors';
import type { CSSProperties } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold, PiCaretUpBold, PiMagnifyingGlassBold } from 'react-icons/pi';
import { useBoardName } from 'services/api/hooks/useBoardName';

import { GalleryImageGrid } from './GalleryImageGrid';
import { GalleryImageGridPaged } from './GalleryImageGridPaged';
import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover';
import { GalleryUploadButton } from './GalleryUploadButton';
import { GallerySearch } from './ImageGrid/GallerySearch';
Expand All @@ -32,6 +34,7 @@ export const GalleryPanel = memo(() => {
const isCollapsed = useStore(galleryPanel.$isCollapsed);
const galleryView = useAppSelector(selectGalleryView);
const initialSearchTerm = useAppSelector(selectSearchTerm);
const shouldUsePagedGalleryView = useAppSelector(selectShouldUsePagedGalleryView);
const searchDisclosure = useDisclosure(!!initialSearchTerm);
const [searchTerm, onChangeSearchTerm, onResetSearchTerm] = useGallerySearchTerm();
const handleClickImages = useCallback(() => {
Expand Down Expand Up @@ -110,7 +113,7 @@ export const GalleryPanel = memo(() => {
</Collapse>
<Divider pt={2} />
<Flex w="full" h="full" pt={2}>
<GalleryImageGrid />
{shouldUsePagedGalleryView ? <GalleryImageGridPaged /> : <GalleryImageGrid />}
</Flex>
</Flex>
);
Expand Down
Loading