remove rest of cache, add bulk select UI

This commit is contained in:
Mary Hipp 2024-06-24 14:09:32 -04:00
parent 451c0f00e0
commit 62b4614aed
11 changed files with 107 additions and 131 deletions

View File

@ -385,6 +385,8 @@
"viewerImage": "Viewer Image",
"compareImage": "Compare Image",
"openInViewer": "Open in Viewer",
"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

@ -3,7 +3,6 @@ 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 { imagesApi } from 'services/api/endpoints/images';
import { imagesSelectors } from 'services/api/util';
export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => {
startAppListening({
@ -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

@ -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) => {
@ -123,23 +122,11 @@ export const addRequestedSingleImageDeletionListener = (startAppListening: AppSt
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));
if (data && data.items[0]) {
dispatch(imageSelected(data.items[0]));
} else {
dispatch(imageSelected(null));
}
@ -188,10 +175,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, IMAGE_LIMIT } 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 { imageListDefaultSort } 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,32 +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,
offset: gallery.offset,
limit: gallery.limit,
is_intermediate: false,
},
(draft) => {
const updatedListLength = draft.items.unshift(imageDTO);
draft.items.sort(imageListDefaultSort());
if (updatedListLength > IMAGE_LIMIT) {
draft.items.pop();
}
draft.total += 1;
}
)
);
// update the total images for the board
dispatch(
boardsApi.util.updateQueryData('getBoardImagesTotal', imageDTO.board_id ?? 'none', (draft) => {
@ -86,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;
@ -106,6 +91,8 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
);
}
dispatch(offsetChanged(0));
if (!imageDTO.board_id && gallery.selectedBoardId !== 'none') {
dispatch(
boardIdSelected({

View File

@ -0,0 +1,47 @@
import { Flex, IconButton, 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';
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">
<Tag>
<TagLabel>
{selection.length} {t('common.selected')}
</TagLabel>
{selection.length > 0 && (
<Tooltip label="Clear selection">
<TagCloseButton onClick={onClickClearSelection} />
</Tooltip>
)}
</Tag>
<Tooltip label={t('gallery.selectAllOnPage')}>
<IconButton
variant="outline"
size="sm"
icon={<BiSelectMultiple />}
aria-label="Bulk select"
onClick={onClickSelectAllPage}
/>
</Tooltip>
</Flex>
);
};

View File

@ -10,6 +10,7 @@ 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';
@ -71,6 +72,8 @@ const ImageGalleryContent = () => {
</TabList>
</Tabs>
</Flex>
<GalleryBulkSelect />
<GalleryImageGrid />
<GalleryPagination />
</Flex>

View File

@ -1,7 +1,7 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { offsetChanged } from 'features/gallery/store/gallerySlice';
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useListImagesQuery } from 'services/api/endpoints/images';
export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
@ -50,6 +50,13 @@ export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
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 (currentPage + 1 > pages) {
goToLast();
}
}, [currentPage, pages, goToLast]);
// calculate the page buttons to display - current page with 3 around it
const pageButtons = useMemo(() => {
const buttons = [];
@ -77,6 +84,10 @@ export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
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,
@ -94,6 +105,7 @@ export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
isFirstEnabled,
isLastEnabled,
rangeDisplay,
numberOnPage,
}),
[
count,
@ -111,6 +123,7 @@ export const useGalleryPagination = (pageButtonsPerSide: number = 2) => {
isFirstEnabled,
isLastEnabled,
rangeDisplay,
numberOnPage,
]
);

View File

@ -107,7 +107,7 @@ export const gallerySlice = createSlice({
offsetChanged: (state, action: PayloadAction<number>) => {
state.offset = action.payload;
},
limitChanged: (state, action: PayloadAction<number | undefined>) => {
limitChanged: (state, action: PayloadAction<number>) => {
state.limit = action.payload;
},
},

View File

@ -1,56 +1,9 @@
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,22 +12,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);
},
});
export const imageListDefaultSort = () => {
return (a: ImageDTO, b: ImageDTO) => {
if (a.starred && !b.starred) {
@ -87,9 +24,6 @@ export const imageListDefaultSort = () => {
};
};
// 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' })}`);