Compare commits

...

25 Commits

Author SHA1 Message Date
maryhipp
89c5662848 add optional search term to search image metadata 2024-06-25 20:27:37 -04:00
Mary Hipp
e3e8d689d7 mvp gallery search 2024-06-25 20:26:54 -04:00
Mary Hipp
9d86c2e2c1 lint fix 2024-06-25 15:17:52 -04:00
Mary Hipp
c3dd91e3c2 use correct query params for boardIdSelected listener 2024-06-25 15:12:21 -04:00
Mary Hipp
aaf83de364 fix when deleting first image in list 2024-06-25 15:06:19 -04:00
Mary Hipp
959f70da71 GG another fix 2024-06-24 15:08:04 -04:00
Mary Hipp
d551338d62 appease the knip 2024-06-24 15:07:22 -04:00
Mary Hipp
1304fbb36f lint fix 2024-06-24 15:00:04 -04:00
Mary Hipp
a2a70b6eb0 fix circular dep 2024-06-24 14:53:40 -04:00
Mary Hipp
9c328056d5 only show selected when greater than 0 2024-06-24 14:41:14 -04:00
Mary Hipp
977dbd8051 clear selection when board or gallery view changes 2024-06-24 14:27:06 -04:00
Mary Hipp
14250a0593 fix neg pages 2024-06-24 14:13:13 -04:00
Mary Hipp
62b4614aed remove rest of cache, add bulk select UI 2024-06-24 14:09:32 -04:00
Mary Hipp
451c0f00e0 lint fix 2024-06-23 20:11:05 -04:00
Mary Hipp
05485e1b47 implmenet custom sort to replace images adapter logic 2024-06-23 19:26:04 -04:00
psychedelicious
01164a404f feat(ui): more efficient board totals fetching
We only need to show the totals in the tooltip. Tooltips accpet a component for the tooltip label. The component isn't rendered until the tooltip is triggered.

Move the board total fetching into a tooltip component for the boards. Now we only fire these requests when the user mouses over the board
2024-06-21 18:50:50 +10:00
psychedelicious
f0b587da27 feat(ui): tweak pagination buttons
- Fix off-by-one error when going to last page
- Update component to have minimal/no layout shift
2024-06-21 18:20:45 +10:00
psychedelicious
f6b30d2b6b feat(ui): iterate on dynamic gallery limit
- Simplify the gallery layout
- Set an initial gallery limit to load _some_ images immediately.
- Refactor the resize observer to use the actual rendered image component to calculate the number of images per row/col. This prevents inaccuracies caused by image padding that could result in the wrong number of images.
- Debounce the limit update to not thrash teh API
- Use absolute positioning trick to ensure the gallery container is always exactly the right size
- Minimum of `imagesPerRow` images loaded at all times
2024-06-21 18:02:44 +10:00
psychedelicious
6d4fc6e55b fix(ui): gallery content overflow
This is one of those unexpected CSS quirks. Flex containers need min-width or min-height for their children to not overflow. Add `minH={0}` to gallery container.
2024-06-21 17:38:21 +10:00
Mary Hipp
4e1a0b8a7f wip change limit based on size of gallery 2024-06-20 21:13:48 -04:00
Mary Hipp
67abe33c02 trying to invalidate all the tags 2024-06-20 15:40:59 -04:00
Mary Hipp
a3c736c0dc fix single pagers 2024-06-20 15:17:20 -04:00
Mary Hipp
e4738b4bee handle generations coming in, fix pagination to use total from list query so it updates as that changes 2024-06-20 15:15:46 -04:00
Mary Hipp
fa13ec1f6b some cleanup, add page buttons 2024-06-20 13:29:16 -04:00
Mary Hipp
5ced646210 pull in spencers work 2024-06-20 12:03:24 -04:00
43 changed files with 1076 additions and 1675 deletions

View File

@@ -316,6 +316,7 @@ async def list_image_dtos(
),
offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of images per page"),
search_term: Optional[str] = Query(default=None, description="The term to search for"),
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets a list of image DTOs"""
@@ -326,6 +327,7 @@ async def list_image_dtos(
categories,
is_intermediate,
board_id,
search_term
)
return image_dtos

View File

@@ -41,6 +41,7 @@ class ImageRecordStorageBase(ABC):
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> OffsetPaginatedResults[ImageRecord]:
"""Gets a page of image records."""
pass

View File

@@ -148,6 +148,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> OffsetPaginatedResults[ImageRecord]:
try:
self._lock.acquire()
@@ -208,6 +209,13 @@ class SqliteImageRecordStorage(ImageRecordStorageBase):
"""
query_params.append(board_id)
# Search term condition
if search_term:
query_conditions += """--sql
AND json_extract(images.metadata, '$') LIKE ?
"""
query_params.append(f'%{search_term}%')
query_pagination = """--sql
ORDER BY images.starred DESC, images.created_at DESC LIMIT ? OFFSET ?
"""

View File

@@ -120,6 +120,7 @@ class ImageServiceABC(ABC):
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None
) -> OffsetPaginatedResults[ImageDTO]:
"""Gets a paginated list of image DTOs."""
pass

View File

@@ -206,6 +206,7 @@ class ImageService(ImageServiceABC):
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
search_term: Optional[str] = None,
) -> OffsetPaginatedResults[ImageDTO]:
try:
results = self.__invoker.services.image_records.get_many(
@@ -215,6 +216,7 @@ class ImageService(ImageServiceABC):
categories,
is_intermediate,
board_id,
search_term
)
image_dtos = [

View File

@@ -37,7 +37,11 @@
"selectBoard": "Select a Board",
"topMessage": "This board contains images used in the following features:",
"uncategorized": "Uncategorized",
"downloadBoard": "Download Board"
"downloadBoard": "Download Board",
"imagesWithCount_one": "{{count}} image",
"imagesWithCount_other": "{{count}} images",
"assetsWithCount_one": "{{count}} asset",
"assetsWithCount_other": "{{count}} assets"
},
"accordions": {
"generation": {
@@ -380,7 +384,11 @@
"problemDeletingImagesDesc": "One or more images could not be deleted",
"viewerImage": "Viewer Image",
"compareImage": "Compare Image",
"noActiveSearch": "No active search",
"openInViewer": "Open in Viewer",
"searchingBy": "Searching by",
"selectAllOnPage": "Select All On Page",
"selectAllOnBoard": "Select All On Board",
"selectForCompare": "Select for Compare",
"selectAnImageToCompare": "Select an Image to Compare",
"slider": "Slider",

View File

@@ -2,8 +2,7 @@ import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageCache } from 'services/api/types';
import { getListImagesUrl, imagesSelectors } from 'services/api/util';
import { getListImagesUrl } from 'services/api/util';
export const addFirstListImagesListener = (startAppListening: AppStartListening) => {
startAppListening({
@@ -18,13 +17,10 @@ export const addFirstListImagesListener = (startAppListening: AppStartListening)
cancelActiveListeners();
unsubscribe();
// TODO: figure out how to type the predicate
const data = action.payload as ImageCache;
const data = action.payload;
if (data.ids.length > 0) {
// Select the first image
const firstImage = imagesSelectors.selectAll(data)[0];
dispatch(imageSelected(firstImage ?? null));
if (data.items.length > 0) {
dispatch(imageSelected(data.items[0] ?? null));
}
},
});

View File

@@ -1,9 +1,13 @@
import { isAnyOf } from '@reduxjs/toolkit';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import {
boardIdSelected,
galleryViewChanged,
imageSelected,
selectionChanged,
} from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesSelectors } from 'services/api/util';
export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => {
startAppListening({
@@ -14,14 +18,9 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening)
const state = getState();
const board_id = boardIdSelected.match(action) ? action.payload.boardId : state.gallery.selectedBoardId;
const queryArgs = selectListImagesQueryArgs(state);
const galleryView = galleryViewChanged.match(action) ? action.payload : state.gallery.galleryView;
// when a board is selected, we need to wait until the board has loaded *some* images, then select the first one
const categories = galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const queryArgs = { board_id: board_id ?? 'none', categories };
dispatch(selectionChanged([]));
// wait until the board has some images - maybe it already has some from a previous fetch
// must use getState() to ensure we do not have stale state
@@ -35,11 +34,12 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening)
const { data: boardImagesData } = imagesApi.endpoints.listImages.select(queryArgs)(getState());
if (boardImagesData && boardIdSelected.match(action) && action.payload.selectedImageName) {
const selectedImage = imagesSelectors.selectById(boardImagesData, action.payload.selectedImageName);
const selectedImage = boardImagesData.items.find(
(item) => item.image_name === action.payload.selectedImageName
);
dispatch(imageSelected(selectedImage || null));
} else if (boardImagesData) {
const firstImage = imagesSelectors.selectAll(boardImagesData)[0];
dispatch(imageSelected(firstImage || null));
dispatch(imageSelected(boardImagesData.items[0] || null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));

View File

@@ -4,7 +4,6 @@ import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelecto
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
export const galleryImageClicked = createAction<{
imageDTO: ImageDTO;
@@ -32,14 +31,14 @@ export const addGalleryImageClickedListener = (startAppListening: AppStartListen
const { imageDTO, shiftKey, ctrlKey, metaKey, altKey } = action.payload;
const state = getState();
const queryArgs = selectListImagesQueryArgs(state);
const { data: listImagesData } = imagesApi.endpoints.listImages.select(queryArgs)(state);
const queryResult = imagesApi.endpoints.listImages.select(queryArgs)(state);
if (!listImagesData) {
if (!queryResult.data) {
// Should never happen if we have clicked a gallery image
return;
}
const imageDTOs = imagesSelectors.selectAll(listImagesData);
const imageDTOs = queryResult.data.items;
const selection = state.gallery.selection;
if (altKey) {

View File

@@ -22,11 +22,10 @@ import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { isImageFieldInputInstance } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { clamp, forEach } from 'lodash-es';
import { forEach } from 'lodash-es';
import { api } from 'services/api';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => {
state.nodes.present.nodes.forEach((node) => {
@@ -118,32 +117,7 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
}
dispatch(isModalOpenChanged(false));
const state = getState();
const lastSelectedImage = state.gallery.selection[state.gallery.selection.length - 1]?.image_name;
if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
const { image_name } = imageDTO;
const baseQueryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const cachedImageDTOs = data ? imagesSelectors.selectAll(data) : [];
const deletedImageIndex = cachedImageDTOs.findIndex((i) => i.image_name === image_name);
const filteredImageDTOs = cachedImageDTOs.filter((i) => i.image_name !== image_name);
const newSelectedImageIndex = clamp(deletedImageIndex, 0, filteredImageDTOs.length - 1);
const newSelectedImageDTO = filteredImageDTOs[newSelectedImageIndex];
if (newSelectedImageDTO) {
dispatch(imageSelected(newSelectedImageDTO));
} else {
dispatch(imageSelected(null));
}
}
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
if (imageUsage.isCanvasImage) {
@@ -168,6 +142,20 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
if (wasImageDeleted) {
dispatch(api.util.invalidateTags([{ type: 'Board', id: imageDTO.board_id ?? 'none' }]));
}
const lastSelectedImage = state.gallery.selection[state.gallery.selection.length - 1]?.image_name;
if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
const baseQueryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
if (data && data.items) {
const newlySelectedImage = data?.items.find((img) => img.image_name !== imageDTO?.image_name);
dispatch(imageSelected(newlySelectedImage || null));
} else {
dispatch(imageSelected(null));
}
}
},
});
@@ -188,10 +176,8 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
const queryArgs = selectListImagesQueryArgs(state);
const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state);
const newSelectedImageDTO = data ? imagesSelectors.selectAll(data)[0] : undefined;
if (newSelectedImageDTO) {
dispatch(imageSelected(newSelectedImageDTO));
if (data && data.items[0]) {
dispatch(imageSelected(data.items[0]));
} else {
dispatch(imageSelected(null));
}

View File

@@ -15,7 +15,12 @@ import {
} from 'features/controlLayers/store/controlLayersSlice';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import { isValidDrop } from 'features/dnd/util/isValidDrop';
import { imageSelected, imageToCompareChanged, isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import {
imageSelected,
imageToCompareChanged,
isImageViewerOpenChanged,
selectionChanged,
} from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
import { imagesApi } from 'services/api/endpoints/images';
@@ -216,6 +221,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
board_id: boardId,
})
);
dispatch(selectionChanged([]));
return;
}
@@ -233,6 +239,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
imageDTO,
})
);
dispatch(selectionChanged([]));
return;
}
@@ -248,6 +255,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
board_id: boardId,
})
);
dispatch(selectionChanged([]));
return;
}
@@ -261,6 +269,7 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
imageDTOs,
})
);
dispatch(selectionChanged([]));
return;
}
},

View File

@@ -8,14 +8,14 @@ import {
galleryViewChanged,
imageSelected,
isImageViewerOpenChanged,
offsetChanged,
} from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
import { zNodeStatus } from 'features/nodes/types/invocation';
import { CANVAS_OUTPUT } from 'features/nodes/util/graph/constants';
import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesAdapter } from 'services/api/util';
import { getCategories, getListImagesUrl } from 'services/api/util';
import { socketInvocationComplete } from 'services/events/actions';
// These nodes output an image, but do not actually *save* an image, so we don't want to handle the gallery logic on them
@@ -52,24 +52,6 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
}
if (!imageDTO.is_intermediate) {
/**
* Cache updates for when an image result is received
* - add it to the no_board/images
*/
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{
board_id: imageDTO.board_id ?? 'none',
categories: IMAGE_CATEGORIES,
},
(draft) => {
imagesAdapter.addOne(draft, imageDTO);
}
)
);
// update the total images for the board
dispatch(
boardsApi.util.updateQueryData('getBoardImagesTotal', imageDTO.board_id ?? 'none', (draft) => {
@@ -78,7 +60,18 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
})
);
dispatch(imagesApi.util.invalidateTags([{ type: 'Board', id: imageDTO.board_id ?? 'none' }]));
dispatch(
imagesApi.util.invalidateTags([
{ type: 'Board', id: imageDTO.board_id ?? 'none' },
{
type: 'ImageList',
id: getListImagesUrl({
board_id: imageDTO.board_id ?? 'none',
categories: getCategories(imageDTO),
}),
},
])
);
const { shouldAutoSwitch } = gallery;
@@ -98,6 +91,8 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
);
}
dispatch(offsetChanged(0));
if (!imageDTO.board_id && gallery.selectedBoardId !== 'none') {
dispatch(
boardIdSelected({

View File

@@ -1,47 +1,37 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import type { IconButtonProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import type { MouseEvent, ReactElement } from 'react';
import { memo, useMemo } from 'react';
import type { MouseEvent } from 'react';
import { memo } from 'react';
type Props = {
const sx: SystemStyleObject = {
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: 'drop-shadow(0px 0px 0.1rem var(--invoke-colors-base-800))',
},
};
type Props = Omit<IconButtonProps, 'aria-label' | 'onClick' | 'tooltip'> & {
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
tooltip: string;
icon?: ReactElement;
styleOverrides?: SystemStyleObject;
};
const IAIDndImageIcon = (props: Props) => {
const { onClick, tooltip, icon, styleOverrides } = props;
const sx = useMemo(
() => ({
position: 'absolute',
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: 'drop-shadow(0px 0px 0.1rem var(--invoke-colors-base-800))',
},
...styleOverrides,
}),
[styleOverrides]
);
const { onClick, tooltip, icon, ...rest } = props;
return (
<IconButton
onClick={onClick}
aria-label={tooltip}
tooltip={tooltip}
icon={icon}
size="sm"
variant="link"
sx={sx}
data-testid={tooltip}
{...rest}
/>
);
};

View File

@@ -1,16 +0,0 @@
/**
* Comparator function for sorting dates in ascending order
*/
export const dateComparator = (a: string, b: string) => {
const dateA = new Date(a);
const dateB = new Date(b);
// sort in ascending order
if (dateA > dateB) {
return 1;
}
if (dateA < dateB) {
return -1;
}
return 0;
};

View File

@@ -1,4 +1,3 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Spinner } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
@@ -185,7 +184,7 @@ const ControlAdapterImagePreview = ({ isSmall, id }: Props) => {
/>
</Box>
<>
<Flex flexDir="column" top={1} insetInlineEnd={1}>
<IAIDndImageIcon
onClick={handleResetControlImage}
icon={controlImage ? <PiArrowCounterClockwiseBold size={16} /> : undefined}
@@ -195,15 +194,13 @@ const ControlAdapterImagePreview = ({ isSmall, id }: Props) => {
onClick={handleSaveControlImage}
icon={controlImage ? <PiFloppyDiskBold size={16} /> : undefined}
tooltip={t('controlnet.saveControlImage')}
styleOverrides={saveControlImageStyleOverrides}
/>
<IAIDndImageIcon
onClick={handleSetControlImageToDimensions}
icon={controlImage ? <PiRulerBold size={16} /> : undefined}
tooltip={t('controlnet.setControlImageDimensions')}
styleOverrides={setControlImageDimensionsStyleOverrides}
/>
</>
</Flex>
{pendingControlImages.includes(id) && (
<Flex
@@ -226,6 +223,3 @@ const ControlAdapterImagePreview = ({ isSmall, id }: Props) => {
};
export default memo(ControlAdapterImagePreview);
const saveControlImageStyleOverrides: SystemStyleObject = { mt: 6 };
const setControlImageDimensionsStyleOverrides: SystemStyleObject = { mt: 12 };

View File

@@ -1,4 +1,3 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Spinner, useShiftModifier } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
@@ -203,13 +202,13 @@ export const ControlAdapterImagePreview = memo(
onClick={handleSaveControlImage}
icon={controlImage ? <PiFloppyDiskBold size={16} /> : undefined}
tooltip={t('controlnet.saveControlImage')}
styleOverrides={saveControlImageStyleOverrides}
mt={6}
/>
<IAIDndImageIcon
onClick={handleSetControlImageToDimensions}
icon={controlImage ? <PiRulerBold size={16} /> : undefined}
tooltip={shift ? t('controlnet.setControlImageDimensionsForce') : t('controlnet.setControlImageDimensions')}
styleOverrides={setControlImageDimensionsStyleOverrides}
mt={12}
/>
</>
@@ -235,6 +234,3 @@ export const ControlAdapterImagePreview = memo(
);
ControlAdapterImagePreview.displayName = 'ControlAdapterImagePreview';
const saveControlImageStyleOverrides: SystemStyleObject = { mt: 6 };
const setControlImageDimensionsStyleOverrides: SystemStyleObject = { mt: 12 };

View File

@@ -1,4 +1,3 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, useShiftModifier } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
@@ -100,7 +99,7 @@ export const IPAdapterImagePreview = memo(
onClick={handleSetControlImageToDimensions}
icon={controlImage ? <PiRulerBold size={16} /> : undefined}
tooltip={shift ? t('controlnet.setControlImageDimensionsForce') : t('controlnet.setControlImageDimensions')}
styleOverrides={setControlImageDimensionsStyleOverrides}
mt={6}
/>
</>
</Flex>
@@ -109,5 +108,3 @@ export const IPAdapterImagePreview = memo(
);
IPAdapterImagePreview.displayName = 'IPAdapterImagePreview';
const setControlImageDimensionsStyleOverrides: SystemStyleObject = { mt: 6 };

View File

@@ -1,4 +1,3 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, useShiftModifier } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
@@ -97,7 +96,7 @@ export const InitialImagePreview = memo(({ image, onChangeImage, droppableData,
onClick={onUseSize}
icon={imageDTO ? <PiRulerBold size={16} /> : undefined}
tooltip={shift ? t('controlnet.setControlImageDimensionsForce') : t('controlnet.setControlImageDimensions')}
styleOverrides={useSizeStyleOverrides}
mt={6}
/>
</>
</Flex>
@@ -105,5 +104,3 @@ export const InitialImagePreview = memo(({ image, onChangeImage, droppableData,
});
InitialImagePreview.displayName = 'InitialImagePreview';
const useSizeStyleOverrides: SystemStyleObject = { mt: 6 };

View File

@@ -0,0 +1,21 @@
import { useTranslation } from 'react-i18next';
import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards';
type Props = {
board_id: string;
};
export const BoardTotalsTooltip = ({ board_id }: Props) => {
const { t } = useTranslation();
const { imagesTotal } = useGetBoardImagesTotalQuery(board_id, {
selectFromResult: ({ data }) => {
return { imagesTotal: data?.total ?? 0 };
},
});
const { assetsTotal } = useGetBoardAssetsTotalQuery(board_id, {
selectFromResult: ({ data }) => {
return { assetsTotal: data?.total ?? 0 };
},
});
return `${t('boards.imagesWithCount', { count: imagesTotal })}, ${t('boards.assetsWithCount', { count: assetsTotal })}`;
};

View File

@@ -8,15 +8,12 @@ import SelectionOverlay from 'common/components/SelectionOverlay';
import type { AddToBoardDropData } from 'features/dnd/types';
import AutoAddIcon from 'features/gallery/components/Boards/AutoAddIcon';
import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu';
import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTotalsTooltip';
import { autoAddBoardIdChanged, boardIdSelected, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImagesSquare } from 'react-icons/pi';
import {
useGetBoardAssetsTotalQuery,
useGetBoardImagesTotalQuery,
useUpdateBoardMutation,
} from 'services/api/endpoints/boards';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { BoardDTO } from 'services/api/types';
@@ -51,17 +48,6 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
setIsHovered(false);
}, []);
const { data: imagesTotal } = useGetBoardImagesTotalQuery(board.board_id);
const { data: assetsTotal } = useGetBoardAssetsTotalQuery(board.board_id);
const tooltip = useMemo(() => {
if (imagesTotal?.total === undefined || assetsTotal?.total === undefined) {
return undefined;
}
return `${imagesTotal.total} image${imagesTotal.total === 1 ? '' : 's'}, ${
assetsTotal.total
} asset${assetsTotal.total === 1 ? '' : 's'}`;
}, [assetsTotal, imagesTotal]);
const { currentData: coverImage } = useGetImageDTOQuery(board.cover_image_name ?? skipToken);
const { board_name, board_id } = board;
@@ -132,7 +118,7 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps
>
<BoardContextMenu board={board} board_id={board_id} setBoardToDelete={setBoardToDelete}>
{(ref) => (
<Tooltip label={tooltip} openDelay={1000}>
<Tooltip label={<BoardTotalsTooltip board_id={board.board_id} />} openDelay={1000}>
<Flex
ref={ref}
onClick={handleSelectBoard}

View File

@@ -5,11 +5,11 @@ import SelectionOverlay from 'common/components/SelectionOverlay';
import type { RemoveFromBoardDropData } from 'features/dnd/types';
import AutoAddIcon from 'features/gallery/components/Boards/AutoAddIcon';
import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu';
import { BoardTotalsTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTotalsTooltip';
import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice';
import InvokeLogoSVG from 'public/assets/images/invoke-symbol-wht-lrg.svg';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards';
import { useBoardName } from 'services/api/hooks/useBoardName';
interface Props {
@@ -29,17 +29,6 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
}, [dispatch, autoAssignBoardOnClick]);
const [isHovered, setIsHovered] = useState(false);
const { data: imagesTotal } = useGetBoardImagesTotalQuery('none');
const { data: assetsTotal } = useGetBoardAssetsTotalQuery('none');
const tooltip = useMemo(() => {
if (imagesTotal?.total === undefined || assetsTotal?.total === undefined) {
return undefined;
}
return `${imagesTotal.total} image${imagesTotal.total === 1 ? '' : 's'}, ${
assetsTotal.total
} asset${assetsTotal.total === 1 ? '' : 's'}`;
}, [assetsTotal, imagesTotal]);
const handleMouseOver = useCallback(() => {
setIsHovered(true);
}, []);
@@ -71,7 +60,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
>
<BoardContextMenu board_id="none">
{(ref) => (
<Tooltip label={tooltip} openDelay={1000}>
<Tooltip label={<BoardTotalsTooltip board_id="none" />} openDelay={1000}>
<Flex
ref={ref}
onClick={handleSelectBoard}

View File

@@ -0,0 +1,55 @@
import { Flex, IconButton, Spacer, Tag, TagCloseButton, TagLabel, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BiSelectMultiple } from 'react-icons/bi';
import { GallerySearch } from './GallerySearch';
export const GalleryBulkSelect = () => {
const dispatch = useAppDispatch();
const { selection } = useAppSelector((s) => s.gallery);
const { t } = useTranslation();
const { imageDTOs } = useGalleryImages();
const onClickClearSelection = useCallback(() => {
dispatch(selectionChanged([]));
}, [dispatch]);
const onClickSelectAllPage = useCallback(() => {
dispatch(selectionChanged(selection.concat(imageDTOs)));
}, [dispatch, imageDTOs, selection]);
return (
<Flex alignItems="center" justifyContent="space-between">
<Flex>
{selection.length > 0 ? (
<Tag>
<TagLabel>
{selection.length} {t('common.selected')}
</TagLabel>
<Tooltip label="Clear selection">
<TagCloseButton onClick={onClickClearSelection} />
</Tooltip>
</Tag>
) : (
<Spacer />
)}
<Tooltip label={t('gallery.selectAllOnPage')}>
<IconButton
variant="outline"
size="sm"
icon={<BiSelectMultiple />}
aria-label="Bulk select"
onClick={onClickSelectAllPage}
/>
</Tooltip>
</Flex>
<GallerySearch />
</Flex>
);
};

View File

@@ -0,0 +1,97 @@
import { Flex, IconButton, Input, InputGroup, InputRightElement, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { searchTermChanged } from 'features/gallery/store/gallerySlice';
import { motion } from 'framer-motion';
import { debounce } from 'lodash-es';
import type { ChangeEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiMagnifyingGlassBold, PiXBold } from 'react-icons/pi';
export const GallerySearch = () => {
const dispatch = useAppDispatch();
const { searchTerm } = useAppSelector((s) => s.gallery);
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [searchTermInput, setSearchTermInput] = useState('');
const debouncedSetSearchTerm = useMemo(() => {
return debounce((value: string) => {
dispatch(searchTermChanged(value));
}, 1000);
}, [dispatch]);
const onChangeInput = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setSearchTermInput(e.target.value);
debouncedSetSearchTerm(e.target.value);
},
[debouncedSetSearchTerm]
);
const onClearInput = useCallback(() => {
setSearchTermInput('');
debouncedSetSearchTerm('');
}, [debouncedSetSearchTerm]);
const toggleExpanded = useCallback((newState: boolean) => {
setExpanded(newState);
}, []);
return (
<Flex>
{!expanded && (
<Tooltip
label={
searchTerm && searchTerm.length ? `${t('gallery.searchingBy')} ${searchTerm}` : t('gallery.noActiveSearch')
}
>
<IconButton
aria-label="Close"
icon={<PiMagnifyingGlassBold />}
onClick={toggleExpanded.bind(null, true)}
variant="outline"
size="sm"
/>
</Tooltip>
)}
<motion.div
initial={false}
animate={{ width: expanded ? '200px' : '0px' }}
transition={{ duration: 0.3 }}
style={{ overflow: 'hidden' }}
>
<InputGroup size="sm">
<IconButton
aria-label="Close"
icon={<PiMagnifyingGlassBold />}
onClick={toggleExpanded.bind(null, false)}
variant="ghost"
size="sm"
/>
<Input
type="text"
placeholder="Search..."
size="sm"
variant="outline"
onChange={onChangeInput}
value={searchTermInput}
/>
{searchTermInput && searchTermInput.length && (
<InputRightElement h="full" pe={2}>
<IconButton
onClick={onClearInput}
size="sm"
variant="link"
aria-label={t('boards.clearSearch')}
icon={<PiXBold />}
/>
</InputRightElement>
)}
</InputGroup>
</motion.div>
</Flex>
);
};

View File

@@ -1,22 +1,22 @@
import { Box, Button, ButtonGroup, Flex, Tab, TabList, Tabs, useDisclosure, VStack } from '@invoke-ai/ui-library';
import { Box, Button, ButtonGroup, Flex, Tab, TabList, Tabs, useDisclosure } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $galleryHeader } from 'app/store/nanostores/galleryHeader';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { galleryViewChanged } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useRef } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImagesBold } from 'react-icons/pi';
import { RiServerLine } from 'react-icons/ri';
import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName';
import { GalleryBulkSelect } from './GalleryBulkSelect';
import GallerySettingsPopover from './GallerySettingsPopover';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
import { GalleryPagination } from './ImageGrid/GalleryPagination';
const ImageGalleryContent = () => {
const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null);
const galleryView = useAppSelector((s) => s.gallery.galleryView);
const dispatch = useAppDispatch();
const galleryHeader = useStore($galleryHeader);
@@ -31,10 +31,10 @@ const ImageGalleryContent = () => {
}, [dispatch]);
return (
<VStack layerStyle="first" flexDirection="column" h="full" w="full" borderRadius="base" p={2}>
<Flex layerStyle="first" flexDirection="column" h="full" w="full" borderRadius="base" p={2} gap={2}>
{galleryHeader}
<Box w="full">
<Flex ref={resizeObserverRef} alignItems="center" justifyContent="space-between" gap={2}>
<Box>
<Flex alignItems="center" justifyContent="space-between" gap={2}>
<GalleryBoardName isOpen={isBoardListOpen} onToggle={onToggleBoardList} />
<GallerySettingsPopover />
</Flex>
@@ -42,40 +42,41 @@ const ImageGalleryContent = () => {
<BoardsList isOpen={isBoardListOpen} />
</Box>
</Box>
<Flex ref={galleryGridRef} direction="column" gap={2} h="full" w="full">
<Flex alignItems="center" justifyContent="space-between" gap={2}>
<Tabs index={galleryView === 'images' ? 0 : 1} variant="unstyled" size="sm" w="full">
<TabList>
<ButtonGroup w="full">
<Tab
as={Button}
size="sm"
isChecked={galleryView === 'images'}
onClick={handleClickImages}
w="full"
leftIcon={<PiImagesBold size="16px" />}
data-testid="images-tab"
>
{t('parameters.images')}
</Tab>
<Tab
as={Button}
size="sm"
isChecked={galleryView === 'assets'}
onClick={handleClickAssets}
w="full"
leftIcon={<RiServerLine size="16px" />}
data-testid="assets-tab"
>
{t('gallery.assets')}
</Tab>
</ButtonGroup>
</TabList>
</Tabs>
</Flex>
<GalleryImageGrid />
<Flex alignItems="center" justifyContent="space-between" gap={2}>
<Tabs index={galleryView === 'images' ? 0 : 1} variant="unstyled" size="sm" w="full">
<TabList>
<ButtonGroup w="full">
<Tab
as={Button}
size="sm"
isChecked={galleryView === 'images'}
onClick={handleClickImages}
w="full"
leftIcon={<PiImagesBold size="16px" />}
data-testid="images-tab"
>
{t('parameters.images')}
</Tab>
<Tab
as={Button}
size="sm"
isChecked={galleryView === 'assets'}
onClick={handleClickAssets}
w="full"
leftIcon={<RiServerLine size="16px" />}
data-testid="assets-tab"
>
{t('gallery.assets')}
</Tab>
</ButtonGroup>
</TabList>
</Tabs>
</Flex>
</VStack>
<GalleryBulkSelect />
<GalleryImageGrid />
<GalleryPagination />
</Flex>
);
};

View File

@@ -16,13 +16,13 @@ import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiStarBold, PiStarFill, PiTrashSimpleFill } from 'react-icons/pi';
import { useGetImageDTOQuery, useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images';
import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
// This class name is used to calculate the number of images that fit in the gallery
export const GALLERY_IMAGE_CLASS_NAME = 'gallery-image';
const imageSx: SystemStyleObject = { w: 'full', h: 'full' };
const imageIconStyleOverrides: SystemStyleObject = {
bottom: 2,
top: 'auto',
};
const boxSx: SystemStyleObject = {
containerType: 'inline-size',
};
@@ -34,24 +34,22 @@ const badgeSx: SystemStyleObject = {
};
interface HoverableImageProps {
imageName: string;
imageDTO: ImageDTO;
index: number;
}
const GalleryImage = (props: HoverableImageProps) => {
const GalleryImage = ({ index, imageDTO }: HoverableImageProps) => {
const dispatch = useAppDispatch();
const { imageName } = props;
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const shift = useShiftModifier();
const { t } = useTranslation();
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
const alwaysShowImageSizeBadge = useAppSelector((s) => s.gallery.alwaysShowImageSizeBadge);
const isSelectedForCompare = useAppSelector((s) => s.gallery.imageToCompare?.image_name === imageName);
const isSelectedForCompare = useAppSelector((s) => s.gallery.imageToCompare?.image_name === imageDTO.image_name);
const { handleClick, isSelected, areMultiplesSelected } = useMultiselect(imageDTO);
const customStarUi = useStore($customStarUI);
const imageContainerRef = useScrollIntoView(isSelected, props.index, areMultiplesSelected);
const imageContainerRef = useScrollIntoView(isSelected, index, areMultiplesSelected);
const handleDelete = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
@@ -114,32 +112,32 @@ const GalleryImage = (props: HoverableImageProps) => {
}, []);
const starIcon = useMemo(() => {
if (imageDTO?.starred) {
if (imageDTO.starred) {
return customStarUi ? customStarUi.on.icon : <PiStarFill size="20" />;
}
if (!imageDTO?.starred && isHovered) {
if (!imageDTO.starred && isHovered) {
return customStarUi ? customStarUi.off.icon : <PiStarBold size="20" />;
}
}, [imageDTO?.starred, isHovered, customStarUi]);
}, [imageDTO.starred, isHovered, customStarUi]);
const starTooltip = useMemo(() => {
if (imageDTO?.starred) {
if (imageDTO.starred) {
return customStarUi ? customStarUi.off.text : 'Unstar';
}
if (!imageDTO?.starred) {
if (!imageDTO.starred) {
return customStarUi ? customStarUi.on.text : 'Star';
}
return '';
}, [imageDTO?.starred, customStarUi]);
}, [imageDTO.starred, customStarUi]);
const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO?.image_name), [imageDTO?.image_name]);
const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]);
if (!imageDTO) {
return <IAIFillSkeleton />;
}
return (
<Box w="full" h="full" className="gallerygrid-image" data-testid={dataTestId} sx={boxSx}>
<Box w="full" h="full" p={1.5} className={GALLERY_IMAGE_CLASS_NAME} data-testid={dataTestId} sx={boxSx}>
<Flex
ref={imageContainerRef}
userSelect="none"
@@ -183,14 +181,23 @@ const GalleryImage = (props: HoverableImageProps) => {
pointerEvents="none"
>{`${imageDTO.width}x${imageDTO.height}`}</Text>
)}
<IAIDndImageIcon onClick={toggleStarredState} icon={starIcon} tooltip={starTooltip} />
<IAIDndImageIcon
onClick={toggleStarredState}
icon={starIcon}
tooltip={starTooltip}
position="absolute"
top={1}
insetInlineEnd={1}
/>
{isHovered && shift && (
<IAIDndImageIcon
onClick={handleDelete}
icon={<PiTrashSimpleFill size="16px" />}
tooltip={t('gallery.deleteImage', { count: 1 })}
styleOverrides={imageIconStyleOverrides}
tooltip={t('gallery.deleteImage_one')}
position="absolute"
bottom={1}
insetInlineEnd={1}
/>
)}
</>

View File

@@ -1,120 +1,32 @@
import { Box, Button, Flex } from '@invoke-ai/ui-library';
import type { EntityId } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
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 { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryHotkeys } from 'features/gallery/hooks/useGalleryHotkeys';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { 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 type { GridComponents, ItemContent, ListRange, VirtuosoGridHandle } from 'react-virtuoso';
import { VirtuosoGrid } from 'react-virtuoso';
import { useBoardTotal } from 'services/api/hooks/useBoardTotal';
import { useListImagesQuery } from 'services/api/endpoints/images';
import GalleryImage from './GalleryImage';
import ImageGridItemContainer from './ImageGridItemContainer';
import ImageGridListContainer from './ImageGridListContainer';
const components: GridComponents = {
Item: ImageGridItemContainer,
List: ImageGridListContainer,
};
const virtuosoStyles: CSSProperties = { height: '100%' };
import { GALLERY_GRID_CLASS_NAME } from './constants';
import GalleryImage, { GALLERY_IMAGE_CLASS_NAME } from './GalleryImage';
const GalleryImageGrid = () => {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars(overlayScrollbarsParams);
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
const { currentViewTotal } = useBoardTotal(selectedBoardId);
const virtuosoRangeRef = useRef<ListRange | null>(null);
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
const {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult: { currentData, isFetching, isSuccess, isError },
} = useGalleryImages();
useGalleryHotkeys();
const itemContentFunc: ItemContent<EntityId, void> = useCallback(
(index, imageName) => <GalleryImage key={imageName} index={index} imageName={imageName as string} />,
[]
);
useEffect(() => {
// Initialize the gallery's custom scrollbar
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
const onRangeChanged = useCallback((range: ListRange) => {
virtuosoRangeRef.current = range;
}, []);
useEffect(() => {
virtuosoGridRefs.set({ rootRef, virtuosoRangeRef, virtuosoRef });
return () => {
virtuosoGridRefs.set({});
};
}, []);
if (!currentData) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<IAINoContentFallback label={t('gallery.loading')} icon={PiImageBold} />
</Flex>
);
}
if (isSuccess && currentData?.ids.length === 0) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<IAINoContentFallback label={t('gallery.noImagesInGallery')} icon={PiImageBold} />
</Flex>
);
}
if (isSuccess && currentData) {
return (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%" id="gallery-grid">
<VirtuosoGrid
style={virtuosoStyles}
data={currentData.ids}
endReached={handleLoadMoreImages}
components={components}
scrollerRef={setScroller}
itemContent={itemContentFunc}
ref={virtuosoRef}
rangeChanged={onRangeChanged}
overscan={10}
/>
</Box>
<Button
onClick={handleLoadMoreImages}
isDisabled={!areMoreImagesAvailable}
isLoading={isFetching}
loadingText={t('gallery.loading')}
flexShrink={0}
>
{`${t('accessibility.loadMore')} (${currentData.ids.length} / ${currentViewTotal})`}
</Button>
</>
);
}
const { t } = useTranslation();
const queryArgs = useAppSelector(selectListImagesQueryArgs);
const { imageDTOs, isLoading, isError, isFetching } = useListImagesQuery(queryArgs, {
selectFromResult: ({ data, isLoading, isSuccess, isError, isFetching }) => ({
imageDTOs: data?.items ?? EMPTY_ARRAY,
isLoading,
isSuccess,
isError,
isFetching,
}),
});
if (isError) {
return (
@@ -124,7 +36,115 @@ const GalleryImageGrid = () => {
);
}
return null;
if (isLoading || isFetching) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<IAINoContentFallback label={t('gallery.loading')} icon={PiImageBold} />
</Flex>
);
}
if (imageDTOs.length === 0) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<IAINoContentFallback label={t('gallery.noImagesInGallery')} icon={PiImageBold} />
</Flex>
);
}
return <Content />;
};
export default memo(GalleryImageGrid);
const Content = () => {
const dispatch = useAppDispatch();
const galleryImageMinimumWidth = useAppSelector((s) => s.gallery.galleryImageMinimumWidth);
const queryArgs = useAppSelector(selectListImagesQueryArgs);
const { imageDTOs } = useListImagesQuery(queryArgs, {
selectFromResult: ({ data }) => ({ imageDTOs: data?.items ?? EMPTY_ARRAY }),
});
// 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 galleryImageEl = document.querySelector(`.${GALLERY_IMAGE_CLASS_NAME}`);
if (!galleryImageEl) {
// No images in gallery?
return;
}
const galleryImageRect = galleryImageEl.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (!galleryImageRect.width || !galleryImageRect.height || !containerRect.width || !containerRect.height) {
// Gallery is too small to fit images or not rendered yet
return;
}
// Floating-point precision requires we round to get the correct number of images per row
const imagesPerRow = Math.round(containerRect.width / galleryImageRect.width);
// However, when calculating the number of images per column, we want to floor the value to not overflow the container
const imagesPerColumn = Math.floor(containerRect.height / galleryImageRect.height);
// Always load at least 1 row of images
const limit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn);
dispatch(limitChanged(limit));
}, 300);
}, [container, dispatch]);
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">
<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))`}
>
{imageDTOs.map((imageDTO, index) => (
<GalleryImage key={imageDTO.image_name} imageDTO={imageDTO} index={index} />
))}
</Grid>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,73 @@
import { Button, Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { PiCaretDoubleLeftBold, PiCaretDoubleRightBold, PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
export const GalleryPagination = () => {
const {
goPrev,
goNext,
goToFirst,
goToLast,
isFirstEnabled,
isLastEnabled,
isPrevEnabled,
isNextEnabled,
pageButtons,
goToPage,
currentPage,
rangeDisplay,
total,
} = useGalleryPagination();
if (!total) {
return <Flex flexDir="column" alignItems="center" gap="2" height="48px"></Flex>;
}
return (
<Flex flexDir="column" alignItems="center" gap="2" height="48px">
<Flex gap={2} alignItems="center" w="full">
<IconButton
size="sm"
aria-label="prev"
icon={<PiCaretDoubleLeftBold />}
onClick={goToFirst}
isDisabled={!isFirstEnabled}
/>
<IconButton
size="sm"
aria-label="prev"
icon={<PiCaretLeftBold />}
onClick={goPrev}
isDisabled={!isPrevEnabled}
/>
<Spacer />
{pageButtons.map((page) => (
<Button
size="sm"
key={page}
onClick={goToPage.bind(null, page)}
variant={currentPage === page ? 'solid' : 'outline'}
>
{page + 1}
</Button>
))}
<Spacer />
<IconButton
size="sm"
aria-label="next"
icon={<PiCaretRightBold />}
onClick={goNext}
isDisabled={!isNextEnabled}
/>
<IconButton
size="sm"
aria-label="next"
icon={<PiCaretDoubleRightBold />}
onClick={goToLast}
isDisabled={!isLastEnabled}
/>
</Flex>
<Text>{rangeDisplay}</Text>
</Flex>
);
};

View File

@@ -1,15 +0,0 @@
import type { FlexProps } from '@invoke-ai/ui-library';
import { Box, forwardRef } from '@invoke-ai/ui-library';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
export const imageItemContainerTestId = 'image-item-container';
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref} p={1.5} data-testid={imageItemContainerTestId}>
{props.children}
</Box>
));
export default memo(ItemContainer);

View File

@@ -1,26 +0,0 @@
import type { FlexProps } from '@invoke-ai/ui-library';
import { forwardRef, Grid } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
export const imageListContainerTestId = 'image-list-container';
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector((s) => s.gallery.galleryImageMinimumWidth);
return (
<Grid
{...props}
className="list-container"
ref={ref}
gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`}
data-testid={imageListContainerTestId}
>
{props.children}
</Grid>
);
});
export default memo(ListContainer);

View File

@@ -0,0 +1 @@
export const GALLERY_GRID_CLASS_NAME = 'gallery-grid';

View File

@@ -2,6 +2,7 @@ import type { ChakraProps } from '@invoke-ai/ui-library';
import { Box, Flex, IconButton, Spinner } from '@invoke-ai/ui-library';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDoubleRightBold, PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
@@ -16,11 +17,8 @@ const NextPrevImageButtons = () => {
const { prevImage, nextImage, isOnFirstImage, isOnLastImage } = useGalleryNavigation();
const {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult: { isFetching },
} = useGalleryImages();
const { isFetching } = useGalleryImages().queryResult;
const { isNextEnabled, goNext } = useGalleryPagination();
return (
<Box pos="relative" h="full" w="full">
@@ -47,17 +45,17 @@ const NextPrevImageButtons = () => {
sx={nextPrevButtonStyles}
/>
)}
{isOnLastImage && areMoreImagesAvailable && !isFetching && (
{isOnLastImage && isNextEnabled && !isFetching && (
<IconButton
aria-label={t('accessibility.loadMore')}
icon={<PiCaretDoubleRightBold size={64} />}
variant="unstyled"
onClick={handleLoadMoreImages}
onClick={goNext}
boxSize={16}
sx={nextPrevButtonStyles}
/>
)}
{isOnLastImage && areMoreImagesAvailable && isFetching && (
{isOnLastImage && isNextEnabled && isFetching && (
<Flex w={16} h={16} alignItems="center" justifyContent="center">
<Spinner opacity={0.5} size="xl" />
</Flex>

View File

@@ -1,10 +1,12 @@
import { useAppSelector } from 'app/store/storeHooks';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useGalleryPagination } from 'features/gallery/hooks/useGalleryPagination';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useListImagesQuery } from 'services/api/endpoints/images';
/**
* Registers gallery hotkeys. This hook is a singleton.
@@ -17,21 +19,30 @@ export const useGalleryHotkeys = () => {
return activeTabName !== 'canvas' || !isStaging;
}, [activeTabName, isStaging]);
const {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult: { isFetching },
} = useGalleryImages();
const { goNext, goPrev, isNextEnabled, isPrevEnabled } = useGalleryPagination();
const queryArgs = useAppSelector(selectListImagesQueryArgs);
const queryResult = useListImagesQuery(queryArgs);
const { handleLeftImage, handleRightImage, handleUpImage, handleDownImage, isOnLastImage, areImagesBelowCurrent } =
useGalleryNavigation();
const {
handleLeftImage,
handleRightImage,
handleUpImage,
handleDownImage,
areImagesBelowCurrent,
isOnFirstImageOfView,
isOnLastImageOfView,
} = useGalleryNavigation();
useHotkeys(
['left', 'alt+left'],
(e) => {
if (isOnFirstImageOfView && isPrevEnabled && !queryResult.isFetching) {
goPrev();
return;
}
canNavigateGallery && handleLeftImage(e.altKey);
},
[handleLeftImage, canNavigateGallery]
[handleLeftImage, canNavigateGallery, isOnFirstImageOfView, goPrev, isPrevEnabled, queryResult.isFetching]
);
useHotkeys(
@@ -40,15 +51,15 @@ export const useGalleryHotkeys = () => {
if (!canNavigateGallery) {
return;
}
if (isOnLastImage && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
if (isOnLastImageOfView && isNextEnabled && !queryResult.isFetching) {
goNext();
return;
}
if (!isOnLastImage) {
if (!isOnLastImageOfView) {
handleRightImage(e.altKey);
}
},
[isOnLastImage, areMoreImagesAvailable, handleLoadMoreImages, isFetching, handleRightImage, canNavigateGallery]
[isOnLastImageOfView, goNext, isNextEnabled, queryResult.isFetching, handleRightImage, canNavigateGallery]
);
useHotkeys(
@@ -63,13 +74,13 @@ export const useGalleryHotkeys = () => {
useHotkeys(
['down', 'alt+down'],
(e) => {
if (!areImagesBelowCurrent && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
if (!areImagesBelowCurrent && isNextEnabled && !queryResult.isFetching) {
goNext();
return;
}
handleDownImage(e.altKey);
},
{ preventDefault: true },
[areImagesBelowCurrent, areMoreImagesAvailable, handleLoadMoreImages, isFetching, handleDownImage]
[areImagesBelowCurrent, goNext, isNextEnabled, queryResult.isFetching, handleDownImage]
);
};

View File

@@ -1,38 +1,15 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { moreImagesLoaded } from 'features/gallery/store/gallerySlice';
import { useCallback, useMemo } from 'react';
import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards';
import { useMemo } from 'react';
import { useListImagesQuery } from 'services/api/endpoints/images';
/**
* Provides access to the gallery images and a way to imperatively fetch more.
*/
export const useGalleryImages = () => {
const dispatch = useAppDispatch();
const galleryView = useAppSelector((s) => s.gallery.galleryView);
const queryArgs = useAppSelector(selectListImagesQueryArgs);
const queryResult = useListImagesQuery(queryArgs);
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
const { data: assetsTotal } = useGetBoardAssetsTotalQuery(selectedBoardId);
const { data: imagesTotal } = useGetBoardImagesTotalQuery(selectedBoardId);
const currentViewTotal = useMemo(
() => (galleryView === 'images' ? imagesTotal?.total : assetsTotal?.total),
[assetsTotal?.total, galleryView, imagesTotal?.total]
);
const areMoreImagesAvailable = useMemo(() => {
if (!currentViewTotal || !queryResult.data) {
return false;
}
return queryResult.data.ids.length < currentViewTotal;
}, [queryResult.data, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => {
dispatch(moreImagesLoaded());
}, [dispatch]);
const imageDTOs = useMemo(() => queryResult.data?.items ?? EMPTY_ARRAY, [queryResult.data]);
return {
areMoreImagesAvailable,
handleLoadMoreImages,
imageDTOs,
queryResult,
};
};

View File

@@ -1,8 +1,8 @@
import { useAltModifier } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { GALLERY_GRID_CLASS_NAME } from 'features/gallery/components/ImageGrid/constants';
import { GALLERY_IMAGE_CLASS_NAME } from 'features/gallery/components/ImageGrid/GalleryImage';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { imageItemContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridItemContainer';
import { imageListContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridListContainer';
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
@@ -11,7 +11,6 @@ import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAli
import { clamp } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
/**
* This hook is used to navigate the gallery using the arrow keys.
@@ -29,10 +28,9 @@ import { imagesSelectors } from 'services/api/util';
*/
const getImagesPerRow = (): number => {
const widthOfGalleryImage =
document.querySelector(`[data-testid="${imageItemContainerTestId}"]`)?.getBoundingClientRect().width ?? 1;
document.querySelector(`.${GALLERY_IMAGE_CLASS_NAME}`)?.getBoundingClientRect().width ?? 1;
const widthOfGalleryGrid =
document.querySelector(`[data-testid="${imageListContainerTestId}"]`)?.getBoundingClientRect().width ?? 0;
const widthOfGalleryGrid = document.querySelector(`.${GALLERY_GRID_CLASS_NAME}`)?.getBoundingClientRect().width ?? 0;
const imagesPerRow = Math.round(widthOfGalleryGrid / widthOfGalleryImage);
@@ -115,6 +113,8 @@ type UseGalleryNavigationReturn = {
isOnFirstImage: boolean;
isOnLastImage: boolean;
areImagesBelowCurrent: boolean;
isOnFirstImageOfView: boolean;
isOnLastImageOfView: boolean;
};
/**
@@ -134,23 +134,19 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
return lastSelected;
}
});
const {
queryResult: { data },
} = useGalleryImages();
const loadedImagesCount = useMemo(() => data?.ids.length ?? 0, [data?.ids.length]);
const { imageDTOs } = useGalleryImages();
const loadedImagesCount = useMemo(() => imageDTOs.length, [imageDTOs.length]);
const lastSelectedImageIndex = useMemo(() => {
if (!data || !lastSelectedImage) {
if (imageDTOs.length === 0 || !lastSelectedImage) {
return 0;
}
return imagesSelectors.selectAll(data).findIndex((i) => i.image_name === lastSelectedImage.image_name);
}, [lastSelectedImage, data]);
return imageDTOs.findIndex((i) => i.image_name === lastSelectedImage.image_name);
}, [imageDTOs, lastSelectedImage]);
const handleNavigation = useCallback(
(direction: 'left' | 'right' | 'up' | 'down', alt?: boolean) => {
if (!data) {
return;
}
const { index, image } = getImageFuncs[direction](imagesSelectors.selectAll(data), lastSelectedImageIndex);
const { index, image } = getImageFuncs[direction](imageDTOs, lastSelectedImageIndex);
if (!image || index === lastSelectedImageIndex) {
return;
}
@@ -161,7 +157,7 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
}
scrollToImage(image.image_name, index);
},
[data, lastSelectedImageIndex, dispatch]
[imageDTOs, lastSelectedImageIndex, dispatch]
);
const isOnFirstImage = useMemo(() => lastSelectedImageIndex === 0, [lastSelectedImageIndex]);
@@ -176,6 +172,14 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
return lastSelectedImageIndex + imagesPerRow < loadedImagesCount;
}, [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);
@@ -222,5 +226,7 @@ export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
areImagesBelowCurrent,
nextImage,
prevImage,
isOnFirstImageOfView,
isOnLastImageOfView,
};
};

View File

@@ -0,0 +1,131 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { offsetChanged } from 'features/gallery/store/gallerySlice';
import { useCallback, useEffect, useMemo } from 'react';
import { useListImagesQuery } from 'services/api/endpoints/images';
export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
const dispatch = useAppDispatch();
const { offset, limit } = useAppSelector((s) => s.gallery);
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 goNext = useCallback(() => {
dispatch(offsetChanged(offset + (limit || 0)));
}, [dispatch, offset, limit]);
const goPrev = useCallback(() => {
dispatch(offsetChanged(Math.max(offset - (limit || 0), 0)));
}, [dispatch, offset, limit]);
const goToPage = useCallback(
(page: number) => {
dispatch(offsetChanged(page * (limit || 0)));
},
[dispatch, limit]
);
const goToFirst = useCallback(() => {
dispatch(offsetChanged(0));
}, [dispatch]);
const goToLast = useCallback(() => {
dispatch(offsetChanged((pages - 1) * (limit || 0)));
}, [dispatch, pages, limit]);
// handle when total/pages decrease and user is on high page number (ie bulk removing or deleting)
useEffect(() => {
if (pages && currentPage + 1 > pages) {
goToLast();
}
}, [currentPage, pages, goToLast]);
// calculate the page buttons to display - current page with 3 around it
const pageButtons = useMemo(() => {
const buttons = [];
const maxPageButtons = pageButtonsPerSide * 2 + 1;
let startPage = Math.max(currentPage - Math.floor(maxPageButtons / 2), 0);
const endPage = Math.min(startPage + maxPageButtons - 1, pages - 1);
if (endPage - startPage < maxPageButtons - 1) {
startPage = Math.max(endPage - maxPageButtons + 1, 0);
}
for (let i = startPage; i <= endPage; i++) {
buttons.push(i);
}
return buttons;
}, [currentPage, pageButtonsPerSide, pages]);
const isFirstEnabled = useMemo(() => currentPage > 0, [currentPage]);
const isLastEnabled = useMemo(() => currentPage < pages - 1, [currentPage, pages]);
const rangeDisplay = useMemo(() => {
const startItem = currentPage * (limit || 0) + 1;
const endItem = Math.min((currentPage + 1) * (limit || 0), total);
return `${startItem}-${endItem} of ${total}`;
}, [total, currentPage, limit]);
const numberOnPage = useMemo(() => {
return Math.min((currentPage + 1) * (limit || 0), total);
}, [currentPage, limit, total]);
const api = useMemo(
() => ({
count,
total,
currentPage,
pages,
isNextEnabled,
isPrevEnabled,
goNext,
goPrev,
goToPage,
goToFirst,
goToLast,
pageButtons,
isFirstEnabled,
isLastEnabled,
rangeDisplay,
numberOnPage,
}),
[
count,
total,
currentPage,
pages,
isNextEnabled,
isPrevEnabled,
goNext,
goPrev,
goToPage,
goToFirst,
goToLast,
pageButtons,
isFirstEnabled,
isLastEnabled,
rangeDisplay,
numberOnPage,
]
);
return api;
};

View File

@@ -1,3 +1,5 @@
import type { SkipToken } from '@reduxjs/toolkit/query';
import { skipToken } from '@reduxjs/toolkit/query';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
@@ -10,11 +12,15 @@ export const selectLastSelectedImage = createMemoizedSelector(
export const selectListImagesQueryArgs = createMemoizedSelector(
selectGallerySlice,
(gallery): ListImagesArgs => ({
board_id: gallery.selectedBoardId,
categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
offset: gallery.offset,
limit: gallery.limit,
is_intermediate: false,
})
(gallery): ListImagesArgs | SkipToken =>
gallery.limit
? {
board_id: gallery.selectedBoardId,
categories: gallery.galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
offset: gallery.offset,
limit: gallery.limit,
is_intermediate: false,
search_term: gallery.searchTerm,
}
: skipToken
);

View File

@@ -7,7 +7,7 @@ import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { BoardId, ComparisonMode, GalleryState, GalleryView } from './types';
import { IMAGE_LIMIT, INITIAL_IMAGE_LIMIT } from './types';
import { IMAGE_LIMIT } from './types';
const initialGalleryState: GalleryState = {
selection: [],
@@ -19,7 +19,7 @@ const initialGalleryState: GalleryState = {
selectedBoardId: 'none',
galleryView: 'images',
boardSearchText: '',
limit: INITIAL_IMAGE_LIMIT,
limit: 20,
offset: 0,
isImageViewerOpen: true,
imageToCompare: null,
@@ -72,7 +72,6 @@ export const gallerySlice = createSlice({
state.selectedBoardId = action.payload.boardId;
state.galleryView = 'images';
state.offset = 0;
state.limit = INITIAL_IMAGE_LIMIT;
},
autoAddBoardIdChanged: (state, action: PayloadAction<BoardId>) => {
if (!action.payload) {
@@ -84,20 +83,11 @@ export const gallerySlice = createSlice({
galleryViewChanged: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload;
state.offset = 0;
state.limit = INITIAL_IMAGE_LIMIT;
state.limit = IMAGE_LIMIT;
},
boardSearchTextChanged: (state, action: PayloadAction<string>) => {
state.boardSearchText = action.payload;
},
moreImagesLoaded: (state) => {
if (state.offset === 0 && state.limit === INITIAL_IMAGE_LIMIT) {
state.offset = INITIAL_IMAGE_LIMIT;
state.limit = IMAGE_LIMIT;
} else {
state.offset += IMAGE_LIMIT;
state.limit += IMAGE_LIMIT;
}
},
alwaysShowImageSizeBadgeChanged: (state, action: PayloadAction<boolean>) => {
state.alwaysShowImageSizeBadge = action.payload;
},
@@ -114,6 +104,15 @@ export const gallerySlice = createSlice({
comparisonFitChanged: (state, action: PayloadAction<'contain' | 'fill'>) => {
state.comparisonFit = action.payload;
},
offsetChanged: (state, action: PayloadAction<number>) => {
state.offset = action.payload;
},
limitChanged: (state, action: PayloadAction<number>) => {
state.limit = action.payload;
},
searchTermChanged: (state, action: PayloadAction<string | undefined>) => {
state.searchTerm = action.payload;
},
},
extraReducers: (builder) => {
builder.addMatcher(isAnyBoardDeleted, (state, action) => {
@@ -149,7 +148,6 @@ export const {
galleryViewChanged,
selectionChanged,
boardSearchTextChanged,
moreImagesLoaded,
alwaysShowImageSizeBadgeChanged,
isImageViewerOpenChanged,
imageToCompareChanged,
@@ -157,6 +155,9 @@ export const {
comparedImagesSwapped,
comparisonFitChanged,
comparisonModeCycled,
offsetChanged,
limitChanged,
searchTermChanged,
} = gallerySlice.actions;
const isAnyBoardDeleted = isAnyOf(

View File

@@ -2,8 +2,7 @@ import type { ImageCategory, ImageDTO } from 'services/api/types';
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
export const ASSETS_CATEGORIES: ImageCategory[] = ['control', 'mask', 'user', 'other'];
export const INITIAL_IMAGE_LIMIT = 100;
export const IMAGE_LIMIT = 20;
export const IMAGE_LIMIT = 15;
export type GalleryView = 'images' | 'assets';
export type BoardId = 'none' | (string & Record<never, never>);
@@ -21,6 +20,7 @@ export type GalleryState = {
boardSearchText: string;
offset: number;
limit: number;
searchTerm?: string;
alwaysShowImageSizeBadge: boolean;
imageToCompare: ImageDTO | null;
comparisonMode: ComparisonMode;

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
import { useAppSelector } from 'app/store/storeHooks';
import type { BoardId } from 'features/gallery/store/types';
import { useMemo } from 'react';
import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards';
export const useBoardTotal = (board_id: BoardId) => {
const galleryView = useAppSelector((s) => s.gallery.galleryView);
const { data: totalImages } = useGetBoardImagesTotalQuery(board_id);
const { data: totalAssets } = useGetBoardAssetsTotalQuery(board_id);
const currentViewTotal = useMemo(
() => (galleryView === 'images' ? totalImages?.total : totalAssets?.total),
[galleryView, totalAssets, totalImages]
);
return { totalImages, totalAssets, currentViewTotal };
};

View File

@@ -7283,144 +7283,144 @@ export type components = {
project_id: string | null;
};
InvocationOutputMap: {
midas_depth_image_processor: components["schemas"]["ImageOutput"];
lscale: components["schemas"]["LatentsOutput"];
string_split: components["schemas"]["String2Output"];
mask_edge: components["schemas"]["ImageOutput"];
content_shuffle_image_processor: components["schemas"]["ImageOutput"];
color_correct: components["schemas"]["ImageOutput"];
save_image: components["schemas"]["ImageOutput"];
show_image: components["schemas"]["ImageOutput"];
segment_anything_processor: components["schemas"]["ImageOutput"];
latents: components["schemas"]["LatentsOutput"];
lineart_image_processor: components["schemas"]["ImageOutput"];
hed_image_processor: components["schemas"]["ImageOutput"];
infill_lama: components["schemas"]["ImageOutput"];
infill_patchmatch: components["schemas"]["ImageOutput"];
float_collection: components["schemas"]["FloatCollectionOutput"];
denoise_latents: components["schemas"]["LatentsOutput"];
metadata: components["schemas"]["MetadataOutput"];
compel: components["schemas"]["ConditioningOutput"];
img_blur: components["schemas"]["ImageOutput"];
img_crop: components["schemas"]["ImageOutput"];
image_mask_to_tensor: components["schemas"]["MaskOutput"];
sdxl_lora_collection_loader: components["schemas"]["SDXLLoRALoaderOutput"];
img_ilerp: components["schemas"]["ImageOutput"];
img_paste: components["schemas"]["ImageOutput"];
core_metadata: components["schemas"]["MetadataOutput"];
lora_collection_loader: components["schemas"]["LoRALoaderOutput"];
lora_selector: components["schemas"]["LoRASelectorOutput"];
create_denoise_mask: components["schemas"]["DenoiseMaskOutput"];
rectangle_mask: components["schemas"]["MaskOutput"];
noise: components["schemas"]["NoiseOutput"];
float_to_int: components["schemas"]["IntegerOutput"];
esrgan: components["schemas"]["ImageOutput"];
merge_tiles_to_image: components["schemas"]["ImageOutput"];
prompt_from_file: components["schemas"]["StringCollectionOutput"];
infill_rgba: components["schemas"]["ImageOutput"];
sdxl_lora_loader: components["schemas"]["SDXLLoRALoaderOutput"];
lora_loader: components["schemas"]["LoRALoaderOutput"];
iterate: components["schemas"]["IterateInvocationOutput"];
t2i_adapter: components["schemas"]["T2IAdapterOutput"];
color_map_image_processor: components["schemas"]["ImageOutput"];
blank_image: components["schemas"]["ImageOutput"];
normalbae_image_processor: components["schemas"]["ImageOutput"];
canvas_paste_back: components["schemas"]["ImageOutput"];
string_split_neg: components["schemas"]["StringPosNegOutput"];
img_channel_offset: components["schemas"]["ImageOutput"];
face_mask_detection: components["schemas"]["FaceMaskOutput"];
cv_inpaint: components["schemas"]["ImageOutput"];
clip_skip: components["schemas"]["CLIPSkipInvocationOutput"];
latents_collection: components["schemas"]["LatentsCollectionOutput"];
metadata: components["schemas"]["MetadataOutput"];
invert_tensor_mask: components["schemas"]["MaskOutput"];
tomask: components["schemas"]["ImageOutput"];
main_model_loader: components["schemas"]["ModelLoaderOutput"];
img_watermark: components["schemas"]["ImageOutput"];
img_pad_crop: components["schemas"]["ImageOutput"];
random_range: components["schemas"]["IntegerCollectionOutput"];
mlsd_image_processor: components["schemas"]["ImageOutput"];
merge_metadata: components["schemas"]["MetadataOutput"];
lora_collection_loader: components["schemas"]["LoRALoaderOutput"];
string_split: components["schemas"]["String2Output"];
integer_collection: components["schemas"]["IntegerCollectionOutput"];
boolean_collection: components["schemas"]["BooleanCollectionOutput"];
noise: components["schemas"]["NoiseOutput"];
float_math: components["schemas"]["FloatOutput"];
seamless: components["schemas"]["SeamlessModeOutput"];
img_lerp: components["schemas"]["ImageOutput"];
img_blur: components["schemas"]["ImageOutput"];
string_join: components["schemas"]["StringOutput"];
vae_loader: components["schemas"]["VAEOutput"];
calculate_image_tiles_even_split: components["schemas"]["CalculateImageTilesOutput"];
calculate_image_tiles_min_overlap: components["schemas"]["CalculateImageTilesOutput"];
mask_from_id: components["schemas"]["ImageOutput"];
zoe_depth_image_processor: components["schemas"]["ImageOutput"];
img_resize: components["schemas"]["ImageOutput"];
string_replace: components["schemas"]["StringOutput"];
face_identifier: components["schemas"]["ImageOutput"];
t2i_adapter: components["schemas"]["T2IAdapterOutput"];
mul: components["schemas"]["IntegerOutput"];
l2i: components["schemas"]["ImageOutput"];
img_chan: components["schemas"]["ImageOutput"];
conditioning_collection: components["schemas"]["ConditioningCollectionOutput"];
blank_image: components["schemas"]["ImageOutput"];
ip_adapter: components["schemas"]["IPAdapterOutput"];
tile_image_processor: components["schemas"]["ImageOutput"];
integer_math: components["schemas"]["IntegerOutput"];
infill_tile: components["schemas"]["ImageOutput"];
color_correct: components["schemas"]["ImageOutput"];
show_image: components["schemas"]["ImageOutput"];
float: components["schemas"]["FloatOutput"];
prompt_from_file: components["schemas"]["StringCollectionOutput"];
merge_metadata: components["schemas"]["MetadataOutput"];
img_scale: components["schemas"]["ImageOutput"];
string_join_three: components["schemas"]["StringOutput"];
dw_openpose_image_processor: components["schemas"]["ImageOutput"];
freeu: components["schemas"]["UNetOutput"];
img_channel_multiply: components["schemas"]["ImageOutput"];
sdxl_compel_prompt: components["schemas"]["ConditioningOutput"];
img_conv: components["schemas"]["ImageOutput"];
latents: components["schemas"]["LatentsOutput"];
face_mask_detection: components["schemas"]["FaceMaskOutput"];
canny_image_processor: components["schemas"]["ImageOutput"];
collect: components["schemas"]["CollectInvocationOutput"];
infill_tile: components["schemas"]["ImageOutput"];
integer_collection: components["schemas"]["IntegerCollectionOutput"];
img_lerp: components["schemas"]["ImageOutput"];
step_param_easing: components["schemas"]["FloatCollectionOutput"];
lresize: components["schemas"]["LatentsOutput"];
img_mul: components["schemas"]["ImageOutput"];
create_gradient_mask: components["schemas"]["GradientMaskOutput"];
img_scale: components["schemas"]["ImageOutput"];
rand_float: components["schemas"]["FloatOutput"];
tile_to_properties: components["schemas"]["TileToPropertiesOutput"];
calculate_image_tiles: components["schemas"]["CalculateImageTilesOutput"];
range_of_size: components["schemas"]["IntegerCollectionOutput"];
sdxl_refiner_model_loader: components["schemas"]["SDXLRefinerModelLoaderOutput"];
heuristic_resize: components["schemas"]["ImageOutput"];
controlnet: components["schemas"]["ControlOutput"];
string: components["schemas"]["StringOutput"];
tile_image_processor: components["schemas"]["ImageOutput"];
metadata_item: components["schemas"]["MetadataItemOutput"];
freeu: components["schemas"]["UNetOutput"];
round_float: components["schemas"]["FloatOutput"];
conditioning: components["schemas"]["ConditioningOutput"];
ideal_size: components["schemas"]["IdealSizeOutput"];
float: components["schemas"]["FloatOutput"];
conditioning_collection: components["schemas"]["ConditioningCollectionOutput"];
alpha_mask_to_tensor: components["schemas"]["MaskOutput"];
integer_math: components["schemas"]["IntegerOutput"];
string_collection: components["schemas"]["StringCollectionOutput"];
img_conv: components["schemas"]["ImageOutput"];
img_channel_multiply: components["schemas"]["ImageOutput"];
lblend: components["schemas"]["LatentsOutput"];
calculate_image_tiles_even_split: components["schemas"]["CalculateImageTilesOutput"];
color: components["schemas"]["ColorOutput"];
image: components["schemas"]["ImageOutput"];
sdxl_model_loader: components["schemas"]["SDXLModelLoaderOutput"];
image_collection: components["schemas"]["ImageCollectionOutput"];
model_identifier: components["schemas"]["ModelIdentifierOutput"];
l2i: components["schemas"]["ImageOutput"];
seamless: components["schemas"]["SeamlessModeOutput"];
boolean_collection: components["schemas"]["BooleanCollectionOutput"];
string_join_three: components["schemas"]["StringOutput"];
ip_adapter: components["schemas"]["IPAdapterOutput"];
add: components["schemas"]["IntegerOutput"];
crop_latents: components["schemas"]["LatentsOutput"];
float_range: components["schemas"]["FloatCollectionOutput"];
mul: components["schemas"]["IntegerOutput"];
dw_openpose_image_processor: components["schemas"]["ImageOutput"];
boolean: components["schemas"]["BooleanOutput"];
dynamic_prompt: components["schemas"]["StringCollectionOutput"];
mediapipe_face_processor: components["schemas"]["ImageOutput"];
i2l: components["schemas"]["LatentsOutput"];
latents_collection: components["schemas"]["LatentsCollectionOutput"];
integer: components["schemas"]["IntegerOutput"];
img_chan: components["schemas"]["ImageOutput"];
pair_tile_image: components["schemas"]["PairTileImageOutput"];
unsharp_mask: components["schemas"]["ImageOutput"];
img_hue_adjust: components["schemas"]["ImageOutput"];
lineart_anime_image_processor: components["schemas"]["ImageOutput"];
face_off: components["schemas"]["FaceOffOutput"];
mask_combine: components["schemas"]["ImageOutput"];
leres_image_processor: components["schemas"]["ImageOutput"];
image_mask_to_tensor: components["schemas"]["MaskOutput"];
sdxl_refiner_compel_prompt: components["schemas"]["ConditioningOutput"];
scheduler: components["schemas"]["SchedulerOutput"];
sub: components["schemas"]["IntegerOutput"];
pidi_image_processor: components["schemas"]["ImageOutput"];
infill_cv2: components["schemas"]["ImageOutput"];
div: components["schemas"]["IntegerOutput"];
img_nsfw: components["schemas"]["ImageOutput"];
depth_anything_image_processor: components["schemas"]["ImageOutput"];
sdxl_compel_prompt: components["schemas"]["ConditioningOutput"];
range: components["schemas"]["IntegerCollectionOutput"];
range_of_size: components["schemas"]["IntegerCollectionOutput"];
img_resize: components["schemas"]["ImageOutput"];
img_watermark: components["schemas"]["ImageOutput"];
esrgan: components["schemas"]["ImageOutput"];
calculate_image_tiles: components["schemas"]["CalculateImageTilesOutput"];
img_paste: components["schemas"]["ImageOutput"];
face_identifier: components["schemas"]["ImageOutput"];
create_denoise_mask: components["schemas"]["DenoiseMaskOutput"];
content_shuffle_image_processor: components["schemas"]["ImageOutput"];
round_float: components["schemas"]["FloatOutput"];
calculate_image_tiles_min_overlap: components["schemas"]["CalculateImageTilesOutput"];
lscale: components["schemas"]["LatentsOutput"];
rand_int: components["schemas"]["IntegerOutput"];
float_math: components["schemas"]["FloatOutput"];
infill_cv2: components["schemas"]["ImageOutput"];
sdxl_lora_loader: components["schemas"]["SDXLLoRALoaderOutput"];
img_nsfw: components["schemas"]["ImageOutput"];
main_model_loader: components["schemas"]["ModelLoaderOutput"];
tomask: components["schemas"]["ImageOutput"];
string_replace: components["schemas"]["StringOutput"];
face_off: components["schemas"]["FaceOffOutput"];
string: components["schemas"]["StringOutput"];
heuristic_resize: components["schemas"]["ImageOutput"];
midas_depth_image_processor: components["schemas"]["ImageOutput"];
alpha_mask_to_tensor: components["schemas"]["MaskOutput"];
mask_combine: components["schemas"]["ImageOutput"];
clip_skip: components["schemas"]["CLIPSkipInvocationOutput"];
image: components["schemas"]["ImageOutput"];
infill_rgba: components["schemas"]["ImageOutput"];
img_hue_adjust: components["schemas"]["ImageOutput"];
vae_loader: components["schemas"]["VAEOutput"];
sdxl_refiner_compel_prompt: components["schemas"]["ConditioningOutput"];
segment_anything_processor: components["schemas"]["ImageOutput"];
sub: components["schemas"]["IntegerOutput"];
iterate: components["schemas"]["IterateInvocationOutput"];
img_mul: components["schemas"]["ImageOutput"];
denoise_latents: components["schemas"]["LatentsOutput"];
lineart_image_processor: components["schemas"]["ImageOutput"];
rand_float: components["schemas"]["FloatOutput"];
rectangle_mask: components["schemas"]["MaskOutput"];
lora_selector: components["schemas"]["LoRASelectorOutput"];
pair_tile_image: components["schemas"]["PairTileImageOutput"];
cv_inpaint: components["schemas"]["ImageOutput"];
hed_image_processor: components["schemas"]["ImageOutput"];
range: components["schemas"]["IntegerCollectionOutput"];
img_pad_crop: components["schemas"]["ImageOutput"];
string_split_neg: components["schemas"]["StringPosNegOutput"];
string_collection: components["schemas"]["StringCollectionOutput"];
zoe_depth_image_processor: components["schemas"]["ImageOutput"];
save_image: components["schemas"]["ImageOutput"];
img_ilerp: components["schemas"]["ImageOutput"];
compel: components["schemas"]["ConditioningOutput"];
unsharp_mask: components["schemas"]["ImageOutput"];
image_collection: components["schemas"]["ImageCollectionOutput"];
lineart_anime_image_processor: components["schemas"]["ImageOutput"];
float_to_int: components["schemas"]["IntegerOutput"];
random_range: components["schemas"]["IntegerCollectionOutput"];
ideal_size: components["schemas"]["IdealSizeOutput"];
i2l: components["schemas"]["LatentsOutput"];
infill_patchmatch: components["schemas"]["ImageOutput"];
depth_anything_image_processor: components["schemas"]["ImageOutput"];
infill_lama: components["schemas"]["ImageOutput"];
mask_from_id: components["schemas"]["ImageOutput"];
conditioning: components["schemas"]["ConditioningOutput"];
lresize: components["schemas"]["LatentsOutput"];
step_param_easing: components["schemas"]["FloatCollectionOutput"];
metadata_item: components["schemas"]["MetadataItemOutput"];
controlnet: components["schemas"]["ControlOutput"];
merge_tiles_to_image: components["schemas"]["ImageOutput"];
boolean: components["schemas"]["BooleanOutput"];
core_metadata: components["schemas"]["MetadataOutput"];
img_channel_offset: components["schemas"]["ImageOutput"];
model_identifier: components["schemas"]["ModelIdentifierOutput"];
scheduler: components["schemas"]["SchedulerOutput"];
create_gradient_mask: components["schemas"]["GradientMaskOutput"];
color_map_image_processor: components["schemas"]["ImageOutput"];
canvas_paste_back: components["schemas"]["ImageOutput"];
mask_edge: components["schemas"]["ImageOutput"];
lora_loader: components["schemas"]["LoRALoaderOutput"];
float_collection: components["schemas"]["FloatCollectionOutput"];
float_range: components["schemas"]["FloatCollectionOutput"];
normalbae_image_processor: components["schemas"]["ImageOutput"];
lblend: components["schemas"]["LatentsOutput"];
sdxl_refiner_model_loader: components["schemas"]["SDXLRefinerModelLoaderOutput"];
dynamic_prompt: components["schemas"]["StringCollectionOutput"];
leres_image_processor: components["schemas"]["ImageOutput"];
add: components["schemas"]["IntegerOutput"];
tile_to_properties: components["schemas"]["TileToPropertiesOutput"];
img_crop: components["schemas"]["ImageOutput"];
integer: components["schemas"]["IntegerOutput"];
crop_latents: components["schemas"]["LatentsOutput"];
mlsd_image_processor: components["schemas"]["ImageOutput"];
};
/**
* InvocationStartedEvent
@@ -14108,7 +14108,7 @@ export type operations = {
install_hugging_face_model: {
parameters: {
query: {
/** @description Hugging Face repo_id to install */
/** @description HuggingFace repo_id to install */
source: string;
};
};
@@ -14698,6 +14698,8 @@ export type operations = {
offset?: number;
/** @description The number of images per page */
limit?: number;
/** @description The term to search for */
search_term?: string | null;
};
};
responses: {

View File

@@ -1,12 +1,10 @@
import type { EntityState } from '@reduxjs/toolkit';
import type { components, paths } from 'services/api/schema';
import type { O } from 'ts-toolbelt';
export type S = components['schemas'];
export type ImageCache = EntityState<ImageDTO, string>;
export type ListImagesArgs = NonNullable<paths['/api/v1/images/']['get']['parameters']['query']>;
export type ListImagesResponse = paths['/api/v1/images/']['get']['responses']['200']['content']['application/json'];
export type DeleteBoardResult =
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'];

View File

@@ -1,56 +1,8 @@
import { createEntityAdapter } from '@reduxjs/toolkit';
import { getSelectorsOptions } from 'app/store/createMemoizedSelector';
import { dateComparator } from 'common/util/dateComparator';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import queryString from 'query-string';
import { buildV1Url } from 'services/api';
import type { ImageCache, ImageDTO, ListImagesArgs } from './types';
export const getIsImageInDateRange = (data: ImageCache | undefined, imageDTO: ImageDTO) => {
if (!data) {
return false;
}
const totalCachedImageDtos = imagesSelectors.selectAll(data);
if (totalCachedImageDtos.length <= 1) {
return true;
}
const cachedStarredImages = [];
const cachedUnstarredImages = [];
for (let index = 0; index < totalCachedImageDtos.length; index++) {
const image = totalCachedImageDtos[index];
if (image?.starred) {
cachedStarredImages.push(image);
}
if (!image?.starred) {
cachedUnstarredImages.push(image);
}
}
if (imageDTO.starred) {
const lastStarredImage = cachedStarredImages[cachedStarredImages.length - 1];
// if starring or already starred, want to look in list of starred images
if (!lastStarredImage) {
return true;
} // no starred images showing, so always show this one
const createdDate = new Date(imageDTO.created_at);
const oldestDate = new Date(lastStarredImage.created_at);
return createdDate >= oldestDate;
} else {
const lastUnstarredImage = cachedUnstarredImages[cachedUnstarredImages.length - 1];
// if unstarring or already unstarred, want to look in list of unstarred images
if (!lastUnstarredImage) {
return false;
} // no unstarred images showing, so don't show this one
const createdDate = new Date(imageDTO.created_at);
const oldestDate = new Date(lastUnstarredImage.created_at);
return createdDate >= oldestDate;
}
};
import type { ImageDTO, ListImagesArgs } from './types';
export const getCategories = (imageDTO: ImageDTO) => {
if (IMAGE_CATEGORIES.includes(imageDTO.image_category)) {
@@ -59,25 +11,6 @@ export const getCategories = (imageDTO: ImageDTO) => {
return ASSETS_CATEGORIES;
};
// The adapter is not actually the data store - it just provides helper functions to interact
// with some other store of data. We will use the RTK Query cache as that store.
export const imagesAdapter = createEntityAdapter<ImageDTO, string>({
selectId: (image) => image.image_name,
sortComparer: (a, b) => {
// Compare starred images first
if (a.starred && !b.starred) {
return -1;
}
if (!a.starred && b.starred) {
return 1;
}
return dateComparator(b.created_at, a.created_at);
},
});
// Create selectors for the adapter.
export const imagesSelectors = imagesAdapter.getSelectors(undefined, getSelectorsOptions);
// Helper to create the url for the listImages endpoint. Also we use it to create the cache key.
export const getListImagesUrl = (queryArgs: ListImagesArgs) =>
buildV1Url(`images/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`);