diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts index 4da7264cbb..a7c8306c64 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts @@ -156,14 +156,13 @@ export const addImageDroppedListener = () => { if ( overData.actionType === 'MOVE_BOARD' && activeData.payloadType === 'IMAGE_DTO' && - activeData.payload.imageDTO && - overData.context.boardId + activeData.payload.imageDTO ) { const { imageDTO } = activeData.payload; const { boardId } = overData.context; - // if the board is "No Board", this is a remove action - if (boardId === 'no_board') { + // image was droppe on the "NoBoardBoard" + if (!boardId) { dispatch( imagesApi.endpoints.removeImageFromBoard.initiate({ imageDTO, @@ -172,12 +171,7 @@ export const addImageDroppedListener = () => { return; } - // Handle adding image to batch - if (boardId === 'batch') { - // TODO - } - - // Otherwise, add the image to the board + // image was dropped on a user board dispatch( imagesApi.endpoints.addImageToBoard.initiate({ imageDTO, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUpdated.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUpdated.ts index d6a24cda24..851b6be33f 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUpdated.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUpdated.ts @@ -5,30 +5,30 @@ import { startAppListening } from '..'; const moduleLog = log.child({ namespace: 'image' }); export const addImageUpdatedFulfilledListener = () => { - startAppListening({ - matcher: imagesApi.endpoints.updateImage.matchFulfilled, - effect: (action, { dispatch, getState }) => { - moduleLog.debug( - { - data: { - oldImage: action.meta.arg.originalArgs, - updatedImage: action.payload, - }, - }, - 'Image updated' - ); - }, - }); + // startAppListening({ + // matcher: imagesApi.endpoints.updateImage.matchFulfilled, + // effect: (action, { dispatch, getState }) => { + // moduleLog.debug( + // { + // data: { + // oldImage: action.meta.arg.originalArgs, + // updatedImage: action.payload, + // }, + // }, + // 'Image updated' + // ); + // }, + // }); }; export const addImageUpdatedRejectedListener = () => { - startAppListening({ - matcher: imagesApi.endpoints.updateImage.matchRejected, - effect: (action, { dispatch }) => { - moduleLog.debug( - { data: action.meta.arg.originalArgs }, - 'Image update failed' - ); - }, - }); + // startAppListening({ + // matcher: imagesApi.endpoints.updateImage.matchRejected, + // effect: (action, { dispatch }) => { + // moduleLog.debug( + // { data: action.meta.arg.originalArgs }, + // 'Image update failed' + // ); + // }, + // }); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx index 60be0c4ab3..1cd32f93b0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx @@ -16,6 +16,7 @@ import AddBoardButton from './AddBoardButton'; import BoardsSearch from './BoardsSearch'; import GalleryBoard from './GalleryBoard'; import SystemBoardButton from './SystemBoardButton'; +import NoBoardBoard from './NoBoardBoard'; const selector = createSelector( [stateSelector], @@ -42,10 +43,6 @@ const BoardsList = (props: Props) => { ) : boards; const [boardToDelete, setBoardToDelete] = useState(); - const [isSearching, setIsSearching] = useState(false); - const handleClickSearchIcon = useCallback(() => { - setIsSearching((v) => !v); - }, []); return ( <> @@ -61,54 +58,7 @@ const BoardsList = (props: Props) => { }} > - - {isSearching ? ( - - - - ) : ( - - - - - - - - )} - - } - /> + { maxH: 346, }} > + + + {filteredBoards && filteredBoards.map((board) => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx index f556b83d24..800ffc651f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx @@ -28,12 +28,7 @@ const selector = createSelector( defaultSelectorOptions ); -type Props = { - setIsSearching: (isSearching: boolean) => void; -}; - -const BoardsSearch = (props: Props) => { - const { setIsSearching } = props; +const BoardsSearch = () => { const dispatch = useAppDispatch(); const { searchText } = useAppSelector(selector); const inputRef = useRef(null); @@ -47,8 +42,7 @@ const BoardsSearch = (props: Props) => { const clearBoardSearch = useCallback(() => { dispatch(setBoardSearchText('')); - setIsSearching(false); - }, [dispatch, setIsSearching]); + }, [dispatch]); const handleKeydown = useCallback( (e: KeyboardEvent) => { diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 5d76ad743c..787a08a136 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -24,6 +24,7 @@ import { useUpdateBoardMutation } from 'services/api/endpoints/boards'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { BoardDTO } from 'services/api/types'; import BoardContextMenu from '../BoardContextMenu'; +import { useBoardTotal } from 'services/api/hooks/useBoardTotal'; const AUTO_ADD_BADGE_STYLES: ChakraProps['sx'] = { bg: 'accent.200', @@ -64,6 +65,8 @@ const GalleryBoard = memo( board.cover_image_name ?? skipToken ); + const { totalImages, totalAssets } = useBoardTotal(board.board_id); + const { board_name, board_id } = board; const [localBoardName, setLocalBoardName] = useState(board_name); @@ -143,56 +146,48 @@ const GalleryBoard = memo( alignItems: 'center', borderRadius: 'base', cursor: 'pointer', + bg: 'base.200', + _dark: { + bg: 'base.800', + }, }} > - - {coverImage?.thumbnail_url ? ( - + ) : ( + + - ) : ( - - - - )} - + + )} - - {board.image_count} + + {totalImages}/{totalAssets} { - const dispatch = useDispatch(); +const NoBoardBoard = memo(({ isSelected }: Props) => { + const dispatch = useAppDispatch(); + const { totalImages, totalAssets } = useBoardTotal(undefined); + const handleSelectBoard = useCallback(() => { + dispatch(boardIdSelected(undefined)); + }, [dispatch]); - const handleClick = () => { - dispatch(boardIdSelected('no_board')); - }; - - const { total } = useListImagesQuery(baseQueryArg, { - selectFromResult: ({ data }) => ({ total: data?.total ?? 0 }), - }); - - // TODO: Do we support making 'images' 'assets? if yes, we need to handle this - const droppableData: MoveBoardDropData = { - id: 'all-images-board', - actionType: 'MOVE_BOARD', - context: { boardId: 'no_board' }, - }; + const droppableData: MoveBoardDropData = useMemo( + () => ({ + id: 'no_board', + actionType: 'MOVE_BOARD', + context: { boardId: undefined }, + }), + [] + ); return ( - Move} - onClick={handleClick} - isSelected={isSelected} - icon={FaFolderOpen} - label="No Board" - badgeCount={total} - /> + + + + + + + + + + {totalImages}/{totalAssets} + + + + Move} + /> + + ); -}; +}); + +NoBoardBoard.displayName = 'HoverableBoard'; export default NoBoardBoard; diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx index 52e26c55e7..72f2dcf893 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImagePreview.tsx @@ -54,44 +54,44 @@ const CurrentImagePreview = () => { shouldAntialiasProgressImage, } = useAppSelector(imagesSelector); - const { - handlePrevImage, - handleNextImage, - prevImageId, - nextImageId, - isOnLastImage, - handleLoadMoreImages, - areMoreImagesAvailable, - isFetching, - } = useNextPrevImage(); + // const { + // handlePrevImage, + // handleNextImage, + // prevImageId, + // nextImageId, + // isOnLastImage, + // handleLoadMoreImages, + // areMoreImagesAvailable, + // isFetching, + // } = useNextPrevImage(); - useHotkeys( - 'left', - () => { - handlePrevImage(); - }, - [prevImageId] - ); + // useHotkeys( + // 'left', + // () => { + // handlePrevImage(); + // }, + // [prevImageId] + // ); - useHotkeys( - 'right', - () => { - if (isOnLastImage && areMoreImagesAvailable && !isFetching) { - handleLoadMoreImages(); - return; - } - if (!isOnLastImage) { - handleNextImage(); - } - }, - [ - nextImageId, - isOnLastImage, - areMoreImagesAvailable, - handleLoadMoreImages, - isFetching, - ] - ); + // useHotkeys( + // 'right', + // () => { + // if (isOnLastImage && areMoreImagesAvailable && !isFetching) { + // handleLoadMoreImages(); + // return; + // } + // if (!isOnLastImage) { + // handleNextImage(); + // } + // }, + // [ + // nextImageId, + // isOnLastImage, + // areMoreImagesAvailable, + // handleLoadMoreImages, + // isFetching, + // ] + // ); const { currentData: imageDTO, @@ -213,7 +213,7 @@ const CurrentImagePreview = () => { pointerEvents: 'none', }} > - + {/* */} )} diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx index 60926e165e..d777ac827a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx @@ -27,16 +27,24 @@ const GalleryBoardName = (props: Props) => { const { isOpen, onToggle } = props; const { selectedBoardId } = useAppSelector(selector); const boardName = useBoardName(selectedBoardId); - const numOfBoardImages = useBoardTotal(selectedBoardId); + const { totalImages, totalAssets } = useBoardTotal(selectedBoardId); const formattedBoardName = useMemo(() => { - if (!boardName) return ''; - if (boardName && !numOfBoardImages) return boardName; - if (boardName.length > 20) { - return `${boardName.substring(0, 20)}... (${numOfBoardImages})`; + if (!boardName) { + return ''; } - return `${boardName} (${numOfBoardImages})`; - }, [boardName, numOfBoardImages]); + + if (boardName && (totalImages === undefined || totalAssets === undefined)) { + return boardName; + } + + const count = `${totalImages}/${totalAssets}`; + + if (boardName.length > 20) { + return `${boardName.substring(0, 20)}... (${count})`; + } + return `${boardName} (${count})`; + }, [boardName, totalAssets, totalImages]); return ( { - const { selectedBoardId } = state.gallery; + const { selectedBoardId, galleryView } = state.gallery; return { selectedBoardId, + galleryView, }; }, defaultSelectorOptions @@ -26,10 +37,19 @@ const selector = createSelector( const ImageGalleryContent = () => { const resizeObserverRef = useRef(null); const galleryGridRef = useRef(null); - const { selectedBoardId } = useAppSelector(selector); + const { selectedBoardId, galleryView } = useAppSelector(selector); + const dispatch = useAppDispatch(); const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } = useDisclosure(); + const handleClickImages = useCallback(() => { + dispatch(galleryViewChanged('images')); + }, [dispatch]); + + const handleClickAssets = useCallback(() => { + dispatch(galleryViewChanged('assets')); + }, [dispatch]); + return ( { gap: 2, }} > - + @@ -60,6 +80,39 @@ const ImageGalleryContent = () => { + + + } + size="sm" + isChecked={galleryView === 'images'} + onClick={handleClickImages} + sx={{ + w: 'full', + }} + > + Images + + } + size="sm" + isChecked={galleryView === 'assets'} + onClick={handleClickAssets} + sx={{ + w: 'full', + }} + > + Assets + + + + {selectedBoardId === 'batch' ? ( ) : ( diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts b/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts index b389ffff50..57db29c6f1 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts @@ -4,7 +4,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IMAGE_LIMIT, imageSelected, - selectImagesById, } from 'features/gallery/store/gallerySlice'; import { clamp, isEqual } from 'lodash-es'; import { useCallback } from 'react'; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index 19da92e083..db520c2f35 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -2,11 +2,11 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from 'app/store/store'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { ListImagesArgs } from 'services/api/endpoints/images'; -import { INITIAL_IMAGE_LIMIT } from './gallerySlice'; import { - getBoardIdQueryParamForBoard, - getCategoriesQueryParamForBoard, -} from './util'; + ASSETS_CATEGORIES, + IMAGE_CATEGORIES, + INITIAL_IMAGE_LIMIT, +} from './gallerySlice'; export const gallerySelector = (state: RootState) => state.gallery; @@ -19,14 +19,13 @@ export const selectLastSelectedImage = createSelector( export const selectListImagesBaseQueryArgs = createSelector( [(state: RootState) => state], (state) => { - const { selectedBoardId } = state.gallery; - - const categories = getCategoriesQueryParamForBoard(selectedBoardId); - const board_id = getBoardIdQueryParamForBoard(selectedBoardId); + const { selectedBoardId, galleryView } = state.gallery; + const categories = + galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; const listImagesBaseQueryArgs: ListImagesArgs = { + board_id: selectedBoardId ?? 'none', categories, - board_id, offset: 0, limit: INITIAL_IMAGE_LIMIT, is_intermediate: false, diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 314f933e9b..642a75330b 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -14,13 +14,9 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [ export const INITIAL_IMAGE_LIMIT = 100; export const IMAGE_LIMIT = 20; -// export type GalleryView = 'images' | 'assets'; -export type BoardId = - | 'images' - | 'assets' - | 'no_board' - | 'batch' - | (string & Record); +export type GalleryView = 'images' | 'assets'; +// export type BoardId = 'no_board' | (string & Record); +export type BoardId = string | undefined; type GalleryState = { selection: string[]; @@ -28,6 +24,7 @@ type GalleryState = { autoAddBoardId: string | null; galleryImageMinimumWidth: number; selectedBoardId: BoardId; + galleryView: GalleryView; batchImageNames: string[]; isBatchEnabled: boolean; }; @@ -37,7 +34,8 @@ export const initialGalleryState: GalleryState = { shouldAutoSwitch: true, autoAddBoardId: null, galleryImageMinimumWidth: 96, - selectedBoardId: 'images', + selectedBoardId: undefined, + galleryView: 'images', batchImageNames: [], isBatchEnabled: false, }; @@ -96,6 +94,7 @@ export const gallerySlice = createSlice({ }, boardIdSelected: (state, action: PayloadAction) => { state.selectedBoardId = action.payload; + state.galleryView = 'images'; }, isBatchEnabledChanged: (state, action: PayloadAction) => { state.isBatchEnabled = action.payload; @@ -128,6 +127,9 @@ export const gallerySlice = createSlice({ autoAddBoardIdChanged: (state, action: PayloadAction) => { state.autoAddBoardId = action.payload; }, + galleryViewChanged: (state, action: PayloadAction) => { + state.galleryView = action.payload; + }, }, extraReducers: (builder) => { builder.addMatcher( @@ -170,6 +172,7 @@ export const { imagesAddedToBatch, imagesRemovedFromBatch, autoAddBoardIdChanged, + galleryViewChanged, } = gallerySlice.actions; export default gallerySlice.reducer; diff --git a/invokeai/frontend/web/src/features/gallery/store/util.ts b/invokeai/frontend/web/src/features/gallery/store/util.ts index fcc39bae82..dfe812822f 100644 --- a/invokeai/frontend/web/src/features/gallery/store/util.ts +++ b/invokeai/frontend/web/src/features/gallery/store/util.ts @@ -1,6 +1,11 @@ -import { SYSTEM_BOARDS } from 'services/api/endpoints/images'; -import { ASSETS_CATEGORIES, BoardId, IMAGE_CATEGORIES } from './gallerySlice'; -import { ImageCategory } from 'services/api/types'; +import { ListImagesArgs, SYSTEM_BOARDS } from 'services/api/endpoints/images'; +import { + ASSETS_CATEGORIES, + BoardId, + GalleryView, + IMAGE_CATEGORIES, +} from './gallerySlice'; +import { ImageCategory, ImageDTO } from 'services/api/types'; import { isEqual } from 'lodash-es'; export const getCategoriesQueryParamForBoard = ( @@ -20,16 +25,11 @@ export const getCategoriesQueryParamForBoard = ( export const getBoardIdQueryParamForBoard = ( board_id: BoardId -): string | undefined => { - if (board_id === 'no_board') { +): string | null => { + if (board_id === undefined) { return 'none'; } - // system boards besides 'no_board' - if (SYSTEM_BOARDS.includes(board_id)) { - return undefined; - } - // user boards return board_id; }; @@ -52,3 +52,10 @@ export const getBoardIdFromBoardAndCategoriesQueryParam = ( return board_id ?? 'UNKNOWN_BOARD'; }; + +export const getCategories = (imageDTO: ImageDTO) => { + if (IMAGE_CATEGORIES.includes(imageDTO.image_category)) { + return IMAGE_CATEGORIES; + } + return ASSETS_CATEGORIES; +}; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 5eeb86d9c5..e0cfbd13ea 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -6,18 +6,17 @@ import { BoardId, IMAGE_CATEGORIES, } from 'features/gallery/store/gallerySlice'; -import { omit } from 'lodash-es'; import queryString from 'query-string'; import { ApiFullTagDescription, api } from '..'; import { components, paths } from '../schema'; import { ImageCategory, - ImageChanges, ImageDTO, OffsetPaginatedResults_ImageDTO_, PostUploadAction, } from '../types'; -import { getCacheAction } from './util'; +import { getCacheAction, getIsImageInDateRange } from './util'; +import { getCategories } from 'features/gallery/store/util'; export type ListImagesArgs = NonNullable< paths['/api/v1/images/']['get']['parameters']['query'] @@ -155,6 +154,42 @@ export const imagesApi = api.injectEndpoints({ }, keepUnusedDataFor: 86400, // 24 hours }), + getBoardImagesTotal: build.query({ + query: (board_id) => ({ + url: getListImagesUrl({ + board_id: board_id ?? 'none', + categories: IMAGE_CATEGORIES, + is_intermediate: false, + limit: 0, + offset: 0, + }), + method: 'GET', + }), + providesTags: (result, error, arg) => [ + { type: 'BoardImagesTotal', id: arg ?? 'none' }, + ], + transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { + return response.total; + }, + }), + getBoardAssetsTotal: build.query({ + query: (board_id) => ({ + url: getListImagesUrl({ + board_id: board_id ?? 'none', + categories: ASSETS_CATEGORIES, + is_intermediate: false, + limit: 0, + offset: 0, + }), + method: 'GET', + }), + providesTags: (result, error, arg) => [ + { type: 'BoardAssetsTotal', id: arg ?? 'none' }, + ], + transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { + return response.total; + }, + }), clearIntermediates: build.mutation({ query: () => ({ url: `images/clear-intermediates`, method: 'POST' }), invalidatesTags: ['IntermediatesCount'], @@ -164,56 +199,42 @@ export const imagesApi = api.injectEndpoints({ url: `images/${image_name}`, method: 'DELETE', }), - invalidatesTags: (result, error, arg) => [ - { type: 'Image', id: arg.image_name }, + invalidatesTags: (result, error, { board_id }) => [ + { type: 'BoardImagesTotal', id: board_id ?? 'none' }, + { type: 'BoardAssetsTotal', id: board_id ?? 'none' }, ], async onQueryStarted(imageDTO, { dispatch, queryFulfilled }) { /** * Cache changes for `deleteImage`: - * - *remove* from "All Images" / "All Assets" - * - IF it has a board: - * - THEN *remove* from it's own board - * - ELSE *remove* from "No Board" + * - NOT POSSIBLE: *remove* from getImageDTO + * - $cache = [board_id|no_board]/[images|assets] + * - *remove* from $cache */ - const { image_name, board_id, image_category } = imageDTO; + const { image_name, board_id } = imageDTO; - // Figure out the `listImages` caches that we need to update - // That means constructing the possible query args that are serialized into the cache key... - - const removeFromCacheKeys: ListImagesArgs[] = []; + // Store patches so we can undo if the query fails + const patches: PatchCollection[] = []; // determine `categories`, i.e. do we update "All Images" or "All Assets" - const categories = IMAGE_CATEGORIES.includes(image_category) - ? IMAGE_CATEGORIES - : ASSETS_CATEGORIES; + // $cache = [board_id|no_board]/[images|assets] + const categories = getCategories(imageDTO); - // remove from "All Images" - removeFromCacheKeys.push({ categories }); - - if (board_id) { - // remove from it's own board - removeFromCacheKeys.push({ board_id }); - } else { - // remove from "No Board" - removeFromCacheKeys.push({ board_id: 'none' }); - } - - const patches: PatchCollection[] = []; - removeFromCacheKeys.forEach((cacheKey) => { - patches.push( - dispatch( - imagesApi.util.updateQueryData( - 'listImages', - cacheKey, - (draft) => { - imagesAdapter.removeOne(draft, image_name); - draft.total = Math.max(draft.total - 1, 0); - } - ) + // *remove* from $cache + patches.push( + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { board_id, categories }, + (draft) => { + const oldTotal = draft.total; + const newState = imagesAdapter.removeOne(draft, image_name); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; + } ) - ); - }); + ) + ); try { await queryFulfilled; @@ -222,122 +243,165 @@ export const imagesApi = api.injectEndpoints({ } }, }), - updateImage: build.mutation< + /** + * Change an image's `is_intermediate` property. + */ + changeImageIsIntermediate: build.mutation< ImageDTO, - { - imageDTO: ImageDTO; - // For now, we will not allow image categories to change - changes: Omit; - } + { imageDTO: ImageDTO; is_intermediate: boolean } >({ - query: ({ imageDTO, changes }) => ({ + query: ({ imageDTO, is_intermediate }) => ({ url: `images/${imageDTO.image_name}`, method: 'PATCH', - body: changes, + body: { is_intermediate }, }), invalidatesTags: (result, error, { imageDTO }) => [ - { type: 'Image', id: imageDTO.image_name }, + { type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' }, + { type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' }, ], async onQueryStarted( - { imageDTO: oldImageDTO, changes: _changes }, + { imageDTO, is_intermediate }, { dispatch, queryFulfilled, getState } ) { - // let's be extra-sure we do not accidentally change categories - const changes = omit(_changes, 'image_category'); - /** - * Cache changes for "updateImage": - * - *update* "getImageDTO" cache - * - for "All Images" || "All Assets": - * - IF it is not already in the cache - * - THEN *add* it to "All Images" / "All Assets" and update the total - * - ELSE *update* it - * - IF the image has a board: - * - THEN *update* it's own board - * - ELSE *update* the "No Board" board + * Cache changes for `changeImageIsIntermediate`: + * - *update* getImageDTO + * - $cache = [board_id|no_board]/[images|assets] + * - IF it is being changed to an intermediate: + * - remove from $cache + * - ELSE (it is being changed to a non-intermediate): + * - IF it eligible for insertion into existing $cache: + * - *upsert* to $cache */ + // Store patches so we can undo if the query fails const patches: PatchCollection[] = []; - const { image_name, board_id, image_category, is_intermediate } = - oldImageDTO; - const isChangingFromIntermediate = changes.is_intermediate === false; - // do not add intermediates to gallery cache - if (is_intermediate && !isChangingFromIntermediate) { - return; - } - - // determine `categories`, i.e. do we update "All Images" or "All Assets" - const categories = IMAGE_CATEGORIES.includes(image_category) - ? IMAGE_CATEGORIES - : ASSETS_CATEGORIES; - - // update `getImageDTO` cache + // *update* getImageDTO patches.push( dispatch( imagesApi.util.updateQueryData( 'getImageDTO', - image_name, + imageDTO.image_name, (draft) => { - Object.assign(draft, changes); + Object.assign(draft, { is_intermediate }); } ) ) ); - // Update the "All Image" or "All Assets" board - const queryArgsToUpdate: ListImagesArgs[] = [{ categories }]; + // $cache = [board_id|no_board]/[images|assets] + const categories = getCategories(imageDTO); - // IF the image has a board: - if (board_id) { - // THEN update it's own board - queryArgsToUpdate.push({ board_id }); + if (is_intermediate) { + // IF it is being changed to an intermediate: + // remove from $cache + patches.push( + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { board_id: imageDTO.board_id, categories }, + (draft) => { + const oldTotal = draft.total; + const newState = imagesAdapter.removeOne( + draft, + imageDTO.image_name + ); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; + } + ) + ) + ); } else { - // ELSE update the "No Board" board - queryArgsToUpdate.push({ board_id: 'none' }); - } + // ELSE (it is being changed to a non-intermediate): + const queryArgs = { board_id: imageDTO.board_id, categories }; - queryArgsToUpdate.forEach((queryArg) => { - const { data } = imagesApi.endpoints.listImages.select(queryArg)( + const currentCache = imagesApi.endpoints.listImages.select(queryArgs)( getState() ); - const cacheAction = getCacheAction(data, oldImageDTO); + // IF it eligible for insertion into existing $cache + // "eligible" means either: + // - The cache is fully populated, with all images in the db cached + // OR + // - The image's `created_at` is within the range of the cached images - if (['update', 'add'].includes(cacheAction)) { + const isCacheFullyPopulated = + currentCache.data && + currentCache.data.ids.length >= currentCache.data.total; + + const isInDateRange = getIsImageInDateRange( + currentCache.data, + imageDTO + ); + + if (isCacheFullyPopulated || isInDateRange) { + // *upsert* to $cache patches.push( dispatch( imagesApi.util.updateQueryData( 'listImages', - queryArg, + queryArgs, (draft) => { - // One of the common changes is to make a canvas intermediate a non-intermediate, - // i.e. save a canvas image to the gallery. - // If that was the change, need to add the image to the cache instead of updating - // the existing cache entry. - if ( - changes.is_intermediate === false || - cacheAction === 'add' - ) { - // add it to the cache - imagesAdapter.addOne(draft, { - ...oldImageDTO, - ...changes, - }); - draft.total += 1; - } else if (cacheAction === 'update') { - // just update it - imagesAdapter.updateOne(draft, { - id: image_name, - changes, - }); - } + const oldTotal = draft.total; + const newState = imagesAdapter.upsertOne(draft, imageDTO); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; } ) ) ); } - }); + } + + try { + await queryFulfilled; + } catch { + patches.forEach((patchResult) => patchResult.undo()); + } + }, + }), + /** + * Change an image's `session_id` association. + */ + changeImageSessionId: build.mutation< + ImageDTO, + { imageDTO: ImageDTO; session_id: string } + >({ + query: ({ imageDTO, session_id }) => ({ + url: `images/${imageDTO.image_name}`, + method: 'PATCH', + body: { session_id }, + }), + invalidatesTags: (result, error, { imageDTO }) => [ + { type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' }, + { type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' }, + ], + async onQueryStarted( + { imageDTO, session_id }, + { dispatch, queryFulfilled, getState } + ) { + /** + * Cache changes for `changeImageSessionId`: + * - *update* getImageDTO + */ + + // Store patches so we can undo if the query fails + const patches: PatchCollection[] = []; + + // *update* getImageDTO + patches.push( + dispatch( + imagesApi.util.updateQueryData( + 'getImageDTO', + imageDTO.image_name, + (draft) => { + Object.assign(draft, { session_id }); + } + ) + ) + ); try { await queryFulfilled; @@ -375,6 +439,15 @@ export const imagesApi = api.injectEndpoints({ { dispatch, queryFulfilled } ) { try { + /** + * NOTE: PESSIMISTIC UPDATE + * Cache changes for `uploadImage`: + * - IF the image is an intermediate: + * - BAIL OUT + * - *add* to `getImageDTO` + * - *add* to no_board/assets + */ + const { data: imageDTO } = await queryFulfilled; if (imageDTO.is_intermediate) { @@ -382,21 +455,37 @@ export const imagesApi = api.injectEndpoints({ return; } - // determine `categories`, i.e. do we update "All Images" or "All Assets" - const categories = IMAGE_CATEGORIES.includes(image_category) - ? IMAGE_CATEGORIES - : ASSETS_CATEGORIES; + // *add* to `getImageDTO` + dispatch( + imagesApi.util.upsertQueryData( + 'getImageDTO', + imageDTO.image_name, + imageDTO + ) + ); - const queryArg = { categories }; + // *add* to no_board/assets + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { board_id: 'none', categories: ASSETS_CATEGORIES }, + (draft) => { + const oldTotal = draft.total; + const newState = imagesAdapter.addOne(draft, imageDTO); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; + } + ) + ); dispatch( - imagesApi.util.updateQueryData('listImages', queryArg, (draft) => { - imagesAdapter.addOne(draft, imageDTO); - draft.total = draft.total + 1; - }) + imagesApi.util.invalidateTags([ + { type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' }, + { type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' }, + ]) ); } catch { - // no-op + // query failed, no action needed } }, }), @@ -412,107 +501,122 @@ export const imagesApi = api.injectEndpoints({ body: { board_id, image_name }, }; }, - invalidatesTags: (result, error, arg) => [ + invalidatesTags: (result, error, { board_id, imageDTO }) => [ { type: 'BoardImage' }, - { type: 'Board', id: arg.board_id }, + { type: 'Board', id: board_id }, + { type: 'BoardImagesTotal', id: board_id }, + { type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' }, + { type: 'BoardAssetsTotal', id: board_id }, + { type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' }, ], async onQueryStarted( - { board_id, imageDTO: oldImageDTO }, + { board_id, imageDTO }, { dispatch, queryFulfilled, getState } ) { /** * Cache changes for `addImageToBoard`: - * - *update* the `getImageDTO` cache - * - *remove* from "No Board" - * - IF the image has an old `board_id`: - * - THEN *remove* from it's old `board_id` - * - IF the image's `created_at` is within the range of the board's cached images - * - OR the board cache has length of 0 or 1 - * - THEN *add* it to new `board_id` + * - *update* getImageDTO + * - IF it has an old board_id: + * - THEN *remove* from old board_id/[images|assets] + * - ELSE *remove* from no_board/[images|assets] + * - $cache = board_id/[images|assets] + * - IF it eligible for insertion into existing $cache: + * - THEN *add* to $cache */ - const { image_name, board_id: old_board_id } = oldImageDTO; - - // Figure out the `listImages` caches that we need to update - const removeFromQueryArgs: ListImagesArgs[] = []; - - // remove from "No Board" - removeFromQueryArgs.push({ board_id: 'none' }); - - // remove from old board - if (old_board_id) { - removeFromQueryArgs.push({ board_id: old_board_id }); - } - - // Store all patch results in case we need to roll back const patches: PatchCollection[] = []; + const categories = getCategories(imageDTO); - // Updated imageDTO with new board_id - const newImageDTO = { ...oldImageDTO, board_id }; - - // Update getImageDTO cache + // *update* getImageDTO patches.push( dispatch( imagesApi.util.updateQueryData( 'getImageDTO', - image_name, + imageDTO.image_name, (draft) => { - Object.assign(draft, newImageDTO); + Object.assign(draft, { board_id }); } ) ) ); - // Do the "Remove from" cache updates - removeFromQueryArgs.forEach((queryArgs) => { + if (imageDTO.board_id) { + // *remove* from old board_id/[images|assets] + patches.push( + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { + board_id: imageDTO.board_id, + categories, + }, + (draft) => { + const oldTotal = draft.total; + const newState = imagesAdapter.removeOne( + draft, + imageDTO.image_name + ); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; + } + ) + ) + ); + } else { + // *remove* from no_board/[images|assets] + patches.push( + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { + board_id: 'none', + categories, + }, + (draft) => { + imagesAdapter.removeOne(draft, imageDTO.image_name); + } + ) + ) + ); + } + + // $cache = board_id/[images|assets] + const queryArgs = { board_id, categories }; + const currentCache = imagesApi.endpoints.listImages.select(queryArgs)( + getState() + ); + + // IF it eligible for insertion into existing $cache + // "eligible" means either: + // - The cache is fully populated, with all images in the db cached + // OR + // - The image's `created_at` is within the range of the cached images + + const isCacheFullyPopulated = + currentCache.data && + currentCache.data.ids.length >= currentCache.data.total; + + const isInDateRange = getIsImageInDateRange( + currentCache.data, + imageDTO + ); + + if (isCacheFullyPopulated || isInDateRange) { + // THEN *add* to $cache patches.push( dispatch( imagesApi.util.updateQueryData( 'listImages', queryArgs, (draft) => { - // sanity check - if (draft.ids.includes(image_name)) { - imagesAdapter.removeOne(draft, image_name); - draft.total = Math.max(draft.total - 1, 0); - } + const oldTotal = draft.total; + const newState = imagesAdapter.addOne(draft, imageDTO); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; } ) ) ); - }); - - // We only need to add to the cache if the board is not a system board - if (!SYSTEM_BOARDS.includes(board_id)) { - const queryArgs = { board_id }; - const { data } = imagesApi.endpoints.listImages.select(queryArgs)( - getState() - ); - - const cacheAction = getCacheAction(data, oldImageDTO); - - if (['add', 'update'].includes(cacheAction)) { - // Do the "Add to" cache updates - patches.push( - dispatch( - imagesApi.util.updateQueryData( - 'listImages', - queryArgs, - (draft) => { - if (cacheAction === 'add') { - imagesAdapter.addOne(draft, newImageDTO); - draft.total += 1; - } else { - imagesAdapter.updateOne(draft, { - id: image_name, - changes: { board_id }, - }); - } - } - ) - ) - ); - } } try { @@ -531,87 +635,98 @@ export const imagesApi = api.injectEndpoints({ body: { board_id, image_name }, }; }, - invalidatesTags: (result, error, arg) => [ + invalidatesTags: (result, error, { imageDTO }) => [ { type: 'BoardImage' }, - { type: 'Board', id: arg.imageDTO.board_id }, + { type: 'Board', id: imageDTO.board_id }, + { type: 'BoardImagesTotal', id: imageDTO.board_id }, + { type: 'BoardImagesTotal', id: 'none' }, + { type: 'BoardAssetsTotal', id: imageDTO.board_id }, + { type: 'BoardAssetsTotal', id: 'none' }, ], async onQueryStarted( { imageDTO }, { dispatch, queryFulfilled, getState } ) { /** - * Cache changes for `removeImageFromBoard`: - * - *update* `getImageDTO` - * - IF the image's `created_at` is within the range of the board's cached images - * - THEN *add* to "No Board" - * - *remove* from `old_board_id` + * Cache changes for removeImageFromBoard: + * - *update* getImageDTO + * - *remove* from board_id/[images|assets] + * - $cache = no_board/[images|assets] + * - IF it eligible for insertion into existing $cache: + * - THEN *upsert* to $cache */ - const { image_name, board_id: old_board_id } = imageDTO; - + const categories = getCategories(imageDTO); const patches: PatchCollection[] = []; - // Updated imageDTO with new board_id - const newImageDTO = { ...imageDTO, board_id: undefined }; - - // Update getImageDTO cache + // *update* getImageDTO patches.push( dispatch( imagesApi.util.updateQueryData( 'getImageDTO', - image_name, + imageDTO.image_name, (draft) => { - Object.assign(draft, newImageDTO); + Object.assign(draft, { board_id: undefined }); } ) ) ); - // Remove from old board - if (old_board_id) { - const oldBoardQueryArgs = { board_id: old_board_id }; - patches.push( - dispatch( - imagesApi.util.updateQueryData( - 'listImages', - oldBoardQueryArgs, - (draft) => { - // sanity check - if (draft.ids.includes(image_name)) { - imagesAdapter.removeOne(draft, image_name); - draft.total = Math.max(draft.total - 1, 0); - } - } - ) + // *remove* from board_id/[images|assets] + patches.push( + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { + board_id: imageDTO.board_id, + categories, + }, + (draft) => { + const oldTotal = draft.total; + const newState = imagesAdapter.removeOne( + draft, + imageDTO.image_name + ); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; + } ) - ); - } + ) + ); - // Add to "No Board" - const noBoardQueryArgs = { board_id: 'none' }; - const { data } = imagesApi.endpoints.listImages.select( - noBoardQueryArgs - )(getState()); + // $cache = no_board/[images|assets] + const queryArgs = { board_id: 'none', categories }; + const currentCache = imagesApi.endpoints.listImages.select(queryArgs)( + getState() + ); - // Check if we need to make any cache changes - const cacheAction = getCacheAction(data, imageDTO); + // IF it eligible for insertion into existing $cache + // "eligible" means either: + // - The cache is fully populated, with all images in the db cached + // OR + // - The image's `created_at` is within the range of the cached images - if (['add', 'update'].includes(cacheAction)) { + const isCacheFullyPopulated = + currentCache.data && + currentCache.data.ids.length >= currentCache.data.total; + + const isInDateRange = getIsImageInDateRange( + currentCache.data, + imageDTO + ); + + if (isCacheFullyPopulated || isInDateRange) { + // THEN *upsert* to $cache patches.push( dispatch( imagesApi.util.updateQueryData( 'listImages', - noBoardQueryArgs, + queryArgs, (draft) => { - if (cacheAction === 'add') { - imagesAdapter.addOne(draft, imageDTO); - draft.total += 1; - } else { - imagesAdapter.updateOne(draft, { - id: image_name, - changes: { board_id: undefined }, - }); - } + const oldTotal = draft.total; + const newState = imagesAdapter.upsertOne(draft, imageDTO); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; } ) ) @@ -635,7 +750,9 @@ export const { useGetImageDTOQuery, useGetImageMetadataQuery, useDeleteImageMutation, - useUpdateImageMutation, + // useUpdateImageMutation, + useGetBoardImagesTotalQuery, + useGetBoardAssetsTotalQuery, useUploadImageMutation, useAddImageToBoardMutation, useRemoveImageFromBoardMutation, diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts index cbe0ec1808..d6b010e3ab 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts @@ -4,19 +4,8 @@ import { useListAllBoardsQuery } from '../endpoints/boards'; export const useBoardName = (board_id: BoardId | null | undefined) => { const { boardName } = useListAllBoardsQuery(undefined, { selectFromResult: ({ data }) => { - let boardName = ''; - if (board_id === 'images') { - boardName = 'Images'; - } else if (board_id === 'assets') { - boardName = 'Assets'; - } else if (board_id === 'no_board') { - boardName = 'No Board'; - } else if (board_id === 'batch') { - boardName = 'Batch'; - } else { - const selectedBoard = data?.find((b) => b.board_id === board_id); - boardName = selectedBoard?.board_name || 'Unknown Board'; - } + const selectedBoard = data?.find((b) => b.board_id === board_id); + const boardName = selectedBoard?.board_name || 'Uncategorized'; return { boardName }; }, diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts index 8deccd8947..e693ff87d0 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts @@ -1,53 +1,45 @@ -import { skipToken } from '@reduxjs/toolkit/dist/query'; import { - ASSETS_CATEGORIES, BoardId, - IMAGE_CATEGORIES, INITIAL_IMAGE_LIMIT, } from 'features/gallery/store/gallerySlice'; -import { useMemo } from 'react'; -import { ListImagesArgs, useListImagesQuery } from '../endpoints/images'; +import { + ListImagesArgs, + useGetBoardAssetsTotalQuery, + useGetBoardImagesTotalQuery, +} from '../endpoints/images'; -const baseQueryArg: ListImagesArgs = { +const baseQueryArgs: ListImagesArgs = { offset: 0, limit: INITIAL_IMAGE_LIMIT, is_intermediate: false, }; -const imagesQueryArg: ListImagesArgs = { - categories: IMAGE_CATEGORIES, - ...baseQueryArg, -}; - -const assetsQueryArg: ListImagesArgs = { - categories: ASSETS_CATEGORIES, - ...baseQueryArg, -}; - -const noBoardQueryArg: ListImagesArgs = { - board_id: 'none', - ...baseQueryArg, -}; - -export const useBoardTotal = (board_id: BoardId | null | undefined) => { - const queryArg = useMemo(() => { - if (!board_id) { - return; - } - if (board_id === 'images') { - return imagesQueryArg; - } else if (board_id === 'assets') { - return assetsQueryArg; - } else if (board_id === 'no_board') { - return noBoardQueryArg; - } else { - return { board_id, ...baseQueryArg }; - } - }, [board_id]); - - const { total } = useListImagesQuery(queryArg ?? skipToken, { - selectFromResult: ({ currentData }) => ({ total: currentData?.total }), - }); - - return total; +export const useBoardTotal = (board_id: BoardId) => { + const { data: totalImages } = useGetBoardImagesTotalQuery(board_id); + const { data: totalAssets } = useGetBoardAssetsTotalQuery(board_id); + // const imagesQueryArg = useMemo(() => { + // const categories = IMAGE_CATEGORIES; + // return { board_id, categories, ...baseQueryArgs }; + // }, [board_id]); + + // const assetsQueryArg = useMemo(() => { + // const categories = ASSETS_CATEGORIES; + // return { board_id, categories, ...baseQueryArgs }; + // }, [board_id]); + + // const { total: totalImages } = useListImagesQuery( + // imagesQueryArg ?? skipToken, + // { + // selectFromResult: ({ currentData }) => ({ total: currentData?.total }), + // } + // ); + + // const { total: totalAssets } = useListImagesQuery( + // assetsQueryArg ?? skipToken, + // { + // selectFromResult: ({ currentData }) => ({ total: currentData?.total }), + // } + // ); + + return { totalImages, totalAssets }; }; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index e10c96543e..0a0391898c 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -10,6 +10,8 @@ import { $authToken, $baseUrl } from 'services/api/client'; export const tagTypes = [ 'Board', + 'BoardImagesTotal', + 'BoardAssetsTotal', 'Image', 'ImageNameList', 'ImageList',