Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,179 @@
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 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;
return;
}
if (lastSelectedRef.current === lastSelectedItem) {
return;
}
lastSelectedRef.current = lastSelectedItem;
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import AutoSwitchCheckbox from 'features/gallery/components/GallerySettingsPopov
import ImageMinimumWidthSlider from 'features/gallery/components/GallerySettingsPopover/ImageMinimumWidthSlider';
import ShowStarredFirstCheckbox from 'features/gallery/components/GallerySettingsPopover/ShowStarredFirstCheckbox';
import SortDirectionCombobox from 'features/gallery/components/GallerySettingsPopover/SortDirectionCombobox';
import UsePagedGalleryViewCheckbox from 'features/gallery/components/GallerySettingsPopover/UsePagedGalleryViewCheckbox';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiGearSixFill } from 'react-icons/pi';
Expand Down Expand Up @@ -46,6 +47,7 @@ export const GallerySettingsPopover = memo(() => {
<Divider />

<ImageMinimumWidthSlider />
<UsePagedGalleryViewCheckbox />
<AutoSwitchCheckbox />
<AlwaysShowImageSizeCheckbox />

Expand Down
Loading