diff --git a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx index af4b5bbe3b..1b2b10c897 100644 --- a/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx +++ b/invokeai/frontend/web/src/app/components/ImageDnd/typesafeDnd.tsx @@ -175,9 +175,7 @@ export const isValidDrop = ( const destinationBoard = overData.context.boardId; const isSameBoard = currentBoard === destinationBoard; - const isDestinationValid = !currentBoard - ? destinationBoard !== 'no_board' - : true; + const isDestinationValid = !currentBoard ? destinationBoard : true; return !isSameBoard && isDestinationValid; } diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index c3e789ff6e..a2ac34969f 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -1,20 +1,20 @@ import { log } from 'app/logging/useLogger'; import { + ASSETS_CATEGORIES, + IMAGE_CATEGORIES, boardIdSelected, + galleryViewChanged, imageSelected, } from 'features/gallery/store/gallerySlice'; -import { - getBoardIdQueryParamForBoard, - getCategoriesQueryParamForBoard, -} from 'features/gallery/store/util'; import { imagesApi } from 'services/api/endpoints/images'; import { startAppListening } from '..'; +import { isAnyOf } from '@reduxjs/toolkit'; const moduleLog = log.child({ namespace: 'boards' }); export const addBoardIdSelectedListener = () => { startAppListening({ - actionCreator: boardIdSelected, + matcher: isAnyOf(boardIdSelected, galleryViewChanged), effect: async ( action, { getState, dispatch, condition, cancelActiveListeners } @@ -22,12 +22,21 @@ export const addBoardIdSelectedListener = () => { // Cancel any in-progress instances of this listener, we don't want to select an image from a previous board cancelActiveListeners(); - const _board_id = action.payload; - // when a board is selected, we need to wait until the board has loaded *some* images, then select the first one + const state = getState(); - const categories = getCategoriesQueryParamForBoard(_board_id); - const board_id = getBoardIdQueryParamForBoard(_board_id); - const queryArgs = { board_id, categories }; + const board_id = boardIdSelected.match(action) + ? action.payload + : state.gallery.selectedBoardId; + + 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 }; // 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 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/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts index 97cccfa05c..df67f286d6 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts @@ -3,6 +3,7 @@ import { addImageToStagingArea } from 'features/canvas/store/canvasSlice'; import { IMAGE_CATEGORIES, boardIdSelected, + galleryViewChanged, imageSelected, } from 'features/gallery/store/gallerySlice'; import { progressImageSet } from 'features/system/store/systemSlice'; @@ -55,34 +56,6 @@ export const addInvocationCompleteEventListener = () => { } if (!imageDTO.is_intermediate) { - // update the cache for 'All Images' - dispatch( - imagesApi.util.updateQueryData( - 'listImages', - { - categories: IMAGE_CATEGORIES, - }, - (draft) => { - imagesAdapter.addOne(draft, imageDTO); - draft.total = draft.total + 1; - } - ) - ); - - // update the cache for 'No Board' - dispatch( - imagesApi.util.updateQueryData( - 'listImages', - { - board_id: 'none', - }, - (draft) => { - imagesAdapter.addOne(draft, imageDTO); - draft.total = draft.total + 1; - } - ) - ); - const { autoAddBoardId } = gallery; // add image to the board if auto-add is enabled @@ -93,8 +66,31 @@ export const addInvocationCompleteEventListener = () => { imageDTO, }) ); + } else { + // add to no board board + // update the cache for 'No Board' + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { + board_id: 'none', + categories: IMAGE_CATEGORIES, + }, + (draft) => { + imagesAdapter.addOne(draft, imageDTO); + draft.total = draft.total + 1; + } + ) + ); } + dispatch( + imagesApi.util.invalidateTags([ + { type: 'BoardImagesTotal', id: autoAddBoardId ?? 'none' }, + { type: 'BoardAssetsTotal', id: autoAddBoardId ?? 'none' }, + ]) + ); + const { selectedBoardId, shouldAutoSwitch } = gallery; // If auto-switch is enabled, select the new image @@ -102,8 +98,9 @@ export const addInvocationCompleteEventListener = () => { // if auto-add is enabled, switch the board as the image comes in if (autoAddBoardId && autoAddBoardId !== selectedBoardId) { dispatch(boardIdSelected(autoAddBoardId)); + dispatch(galleryViewChanged('images')); } else if (!autoAddBoardId) { - dispatch(boardIdSelected('images')); + dispatch(galleryViewChanged('images')); } dispatch(imageSelected(imageDTO.image_name)); } diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddIcon.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddIcon.tsx new file mode 100644 index 0000000000..e9052a20c9 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/AutoAddIcon.tsx @@ -0,0 +1,23 @@ +import { Badge, Flex } from '@chakra-ui/react'; + +const AutoAddIcon = () => { + return ( + + + auto + + + ); +}; + +export default AutoAddIcon; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx index 827d49c88e..ad0e5ab80d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx @@ -52,7 +52,7 @@ const BoardAutoAddSelect = () => { return; } - dispatch(autoAddBoardIdChanged(v === 'none' ? null : v)); + dispatch(autoAddBoardIdChanged(v === 'none' ? undefined : v)); }, [dispatch] ); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx index 3b3303f0c8..b23360555b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx @@ -1,4 +1,4 @@ -import { Box, MenuItem, MenuList } from '@chakra-ui/react'; +import { MenuItem, MenuList } from '@chakra-ui/react'; import { useAppDispatch } from 'app/store/storeHooks'; import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; import { boardIdSelected } from 'features/gallery/store/gallerySlice'; @@ -7,11 +7,11 @@ import { FaFolder } from 'react-icons/fa'; import { BoardDTO } from 'services/api/types'; import { menuListMotionProps } from 'theme/components/menu'; import GalleryBoardContextMenuItems from './GalleryBoardContextMenuItems'; -import SystemBoardContextMenuItems from './SystemBoardContextMenuItems'; +import NoBoardContextMenuItems from './NoBoardContextMenuItems'; type Props = { board?: BoardDTO; - board_id: string; + board_id?: string; children: ContextMenuProps['children']; setBoardToDelete?: (board?: BoardDTO) => void; }; @@ -19,9 +19,11 @@ type Props = { const BoardContextMenu = memo( ({ board, board_id, setBoardToDelete, children }: Props) => { const dispatch = useAppDispatch(); + const handleSelectBoard = useCallback(() => { dispatch(boardIdSelected(board?.board_id ?? board_id)); }, [board?.board_id, board_id, dispatch]); + return ( menuProps={{ size: 'sm', isLazy: true }} @@ -37,7 +39,7 @@ const BoardContextMenu = memo( } onClickCapture={handleSelectBoard}> Select Board - {!board && } + {!board && } {board && ( { ) : 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..8b5d871799 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 @@ -19,17 +19,14 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIDroppable from 'common/components/IAIDroppable'; import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { memo, useCallback, useMemo, useState } from 'react'; -import { FaFolder } from 'react-icons/fa'; +import { FaUser } from 'react-icons/fa'; import { useUpdateBoardMutation } from 'services/api/endpoints/boards'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { useBoardTotal } from 'services/api/hooks/useBoardTotal'; import { BoardDTO } from 'services/api/types'; +import AutoAddIcon from '../AutoAddIcon'; import BoardContextMenu from '../BoardContextMenu'; -const AUTO_ADD_BADGE_STYLES: ChakraProps['sx'] = { - bg: 'accent.200', - color: 'blackAlpha.900', -}; - const BASE_BADGE_STYLES: ChakraProps['sx'] = { bg: 'base.500', color: 'whiteAlpha.900', @@ -64,6 +61,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 +142,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} + {isSelectedForAutoAdd && } { - const dispatch = useDispatch(); +const selector = createSelector( + stateSelector, + ({ gallery }) => { + const { autoAddBoardId } = gallery; + return { autoAddBoardId }; + }, + defaultSelectorOptions +); - const handleClick = () => { - dispatch(boardIdSelected('no_board')); - }; +const NoBoardBoard = memo(({ isSelected }: Props) => { + const dispatch = useAppDispatch(); + const { totalImages, totalAssets } = useBoardTotal(undefined); + const { autoAddBoardId } = useAppSelector(selector); + const handleSelectBoard = useCallback(() => { + dispatch(boardIdSelected(undefined)); + }, [dispatch]); - 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} - /> + + + + {(ref) => ( + + + + + + + {totalImages}/{totalAssets} + + + {!autoAddBoardId && } + + Move} + /> + + )} + + + ); -}; +}); + +NoBoardBoard.displayName = 'HoverableBoard'; export default NoBoardBoard; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx index 5d39eaaf28..e8bd1be992 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice'; import { memo, useCallback, useMemo } from 'react'; -import { FaMinus, FaPlus, FaTrash } from 'react-icons/fa'; +import { FaPlus, FaTrash } from 'react-icons/fa'; import { BoardDTO } from 'services/api/types'; type Props = { @@ -42,7 +42,7 @@ const GalleryBoardContextMenuItems = ({ board, setBoardToDelete }: Props) => { const handleToggleAutoAdd = useCallback(() => { dispatch( - autoAddBoardIdChanged(isSelectedForAutoAdd ? null : board.board_id) + autoAddBoardIdChanged(isSelectedForAutoAdd ? undefined : board.board_id) ); }, [board.board_id, dispatch, isSelectedForAutoAdd]); @@ -59,16 +59,15 @@ const GalleryBoardContextMenuItems = ({ board, setBoardToDelete }: Props) => { */} )} - : } - onClickCapture={handleToggleAutoAdd} - > - {isSelectedForAutoAdd ? 'Disable Auto-Add' : 'Auto-Add to this Board'} - + {!isSelectedForAutoAdd && ( + } onClick={handleToggleAutoAdd}> + Auto-add to this Board + + )} } - onClickCapture={handleDelete} + onClick={handleDelete} > Delete Board diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/NoBoardContextMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/NoBoardContextMenuItems.tsx new file mode 100644 index 0000000000..34b4c5f790 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/NoBoardContextMenuItems.tsx @@ -0,0 +1,28 @@ +import { MenuItem } from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback } from 'react'; +import { FaPlus } from 'react-icons/fa'; + +const NoBoardContextMenuItems = () => { + const dispatch = useAppDispatch(); + + const autoAddBoardId = useAppSelector( + (state) => state.gallery.autoAddBoardId + ); + const handleDisableAutoAdd = useCallback(() => { + dispatch(autoAddBoardIdChanged(undefined)); + }, [dispatch]); + + return ( + <> + {autoAddBoardId && ( + } onClick={handleDisableAutoAdd}> + Auto-add to this Board + + )} + + ); +}; + +export default memo(NoBoardContextMenuItems); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx deleted file mode 100644 index 58eb6d2c0c..0000000000 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { BoardId } from 'features/gallery/store/gallerySlice'; -import { memo } from 'react'; - -type Props = { - board_id: BoardId; -}; - -const SystemBoardContextMenuItems = ({ board_id }: Props) => { - return <>; -}; - -export default memo(SystemBoardContextMenuItems); diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx index 60926e165e..7e2048e628 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx @@ -1,5 +1,5 @@ import { ChevronUpIcon } from '@chakra-ui/icons'; -import { Box, Button, Flex, Spacer, Text } from '@chakra-ui/react'; +import { Button, Flex, Text } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; @@ -27,52 +27,60 @@ 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 ( - - - - {formattedBoardName} - - - + + {formattedBoardName} + { const { onClickAddToBoard } = useContext(AddImageToBoardContext); - const { currentData } = useGetImageMetadataQuery(imageDTO.image_name); + const [debouncedMetadataQueryArg, debounceState] = useDebounce( + imageDTO.image_name, + 500 + ); + + const { currentData } = useGetImageMetadataQuery( + debounceState.isPending() + ? skipToken + : debouncedMetadataQueryArg ?? skipToken + ); const { isClipboardAPIAvailable, copyImageToClipboard } = useCopyImageToClipboard(); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx index 98b4c33408..1b3f220311 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGalleryContent.tsx @@ -1,23 +1,37 @@ -import { Box, Flex, VStack, useDisclosure } from '@chakra-ui/react'; +import { + Box, + ButtonGroup, + Flex, + Spacer, + Tab, + TabList, + Tabs, + VStack, + useDisclosure, +} from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; -import { useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { memo, useRef } from 'react'; +import { memo, useCallback, useRef } from 'react'; import BoardsList from './Boards/BoardsList/BoardsList'; import GalleryBoardName from './GalleryBoardName'; import GalleryPinButton from './GalleryPinButton'; import GallerySettingsPopover from './GallerySettingsPopover'; import BatchImageGrid from './ImageGrid/BatchImageGrid'; import GalleryImageGrid from './ImageGrid/GalleryImageGrid'; +import IAIButton from 'common/components/IAIButton'; +import { FaImages, FaServer } from 'react-icons/fa'; +import { galleryViewChanged } from '../store/gallerySlice'; const selector = createSelector( [stateSelector], (state) => { - const { selectedBoardId } = state.gallery; + const { selectedBoardId, galleryView } = state.gallery; return { selectedBoardId, + galleryView, }; }, defaultSelectorOptions @@ -26,10 +40,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 +83,36 @@ const ImageGalleryContent = () => { + + + + + Images + + + Assets + + + + + {selectedBoardId === 'batch' ? ( ) : ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx index a43f9ce07b..e4b996fc96 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx @@ -19,6 +19,7 @@ import GalleryImage from './GalleryImage'; import ImageGridItemContainer from './ImageGridItemContainer'; import ImageGridListContainer from './ImageGridListContainer'; import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { useBoardTotal } from 'services/api/hooks/useBoardTotal'; const overlayScrollbarsConfig: UseOverlayScrollbarsParams = { defer: true, @@ -40,7 +41,10 @@ const GalleryImageGrid = () => { const [initialize, osInstance] = useOverlayScrollbars( overlayScrollbarsConfig ); - + const selectedBoardId = useAppSelector( + (state) => state.gallery.selectedBoardId + ); + const { currentViewTotal } = useBoardTotal(selectedBoardId); const queryArgs = useAppSelector(selectListImagesBaseQueryArgs); const { currentData, isFetching, isSuccess, isError } = @@ -49,19 +53,23 @@ const GalleryImageGrid = () => { const [listImages] = useLazyListImagesQuery(); const areMoreAvailable = useMemo(() => { - if (!currentData) { + if (!currentData || !currentViewTotal) { return false; } - return currentData.ids.length < currentData.total; - }, [currentData]); + return currentData.ids.length < currentViewTotal; + }, [currentData, currentViewTotal]); const handleLoadMoreImages = useCallback(() => { + if (!areMoreAvailable) { + return; + } + listImages({ ...queryArgs, offset: currentData?.ids.length ?? 0, limit: IMAGE_LIMIT, }); - }, [listImages, queryArgs, currentData?.ids.length]); + }, [areMoreAvailable, listImages, queryArgs, currentData?.ids.length]); useEffect(() => { // Initialize the gallery's custom scrollbar diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts b/invokeai/frontend/web/src/features/gallery/hooks/useNextPrevImage.ts index b389ffff50..7b4b8bed7b 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'; @@ -53,8 +52,8 @@ export const nextPrevImageButtonsSelector = createSelector( const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1); - const nextImageId = images[nextImageIndex].image_name; - const prevImageId = images[prevImageIndex].image_name; + const nextImageId = images[nextImageIndex]?.image_name; + const prevImageId = images[prevImageIndex]?.image_name; const nextImage = selectors.selectById(data, nextImageId); const prevImage = selectors.selectById(data, prevImageId); @@ -65,7 +64,7 @@ export const nextPrevImageButtonsSelector = createSelector( isOnFirstImage: currentImageIndex === 0, isOnLastImage: !isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1, - areMoreImagesAvailable: data?.total ?? 0 > imagesLength, + areMoreImagesAvailable: (data?.total ?? 0) > imagesLength, isFetching: status === 'pending', nextImage, prevImage, 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..ab4942d927 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -14,20 +14,17 @@ 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[]; shouldAutoSwitch: boolean; - autoAddBoardId: string | null; + autoAddBoardId: string | undefined; galleryImageMinimumWidth: number; selectedBoardId: BoardId; + galleryView: GalleryView; batchImageNames: string[]; isBatchEnabled: boolean; }; @@ -35,9 +32,10 @@ type GalleryState = { export const initialGalleryState: GalleryState = { selection: [], shouldAutoSwitch: true, - autoAddBoardId: null, + autoAddBoardId: undefined, 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; @@ -125,9 +124,15 @@ export const gallerySlice = createSlice({ state.batchImageNames = []; state.selection = []; }, - autoAddBoardIdChanged: (state, action: PayloadAction) => { + autoAddBoardIdChanged: ( + state, + action: PayloadAction + ) => { state.autoAddBoardId = action.payload; }, + galleryViewChanged: (state, action: PayloadAction) => { + state.galleryView = action.payload; + }, }, extraReducers: (builder) => { builder.addMatcher( @@ -135,10 +140,11 @@ export const gallerySlice = createSlice({ (state, action) => { const deletedBoardId = action.meta.arg.originalArgs; if (deletedBoardId === state.selectedBoardId) { - state.selectedBoardId = 'images'; + state.selectedBoardId = undefined; + state.galleryView = 'images'; } if (deletedBoardId === state.autoAddBoardId) { - state.autoAddBoardId = null; + state.autoAddBoardId = undefined; } } ); @@ -151,7 +157,7 @@ export const gallerySlice = createSlice({ } if (!boards.map((b) => b.board_id).includes(state.autoAddBoardId)) { - state.autoAddBoardId = null; + state.autoAddBoardId = undefined; } } ); @@ -170,6 +176,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/boardImages.ts b/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts index 368303c7c5..2dc292321e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/boardImages.ts @@ -1,52 +1,36 @@ -import { ImageDTO, OffsetPaginatedResults_ImageDTO_ } from 'services/api/types'; -import { ApiFullTagDescription, LIST_TAG, api } from '..'; -import { paths } from '../schema'; -import { BoardId } from 'features/gallery/store/gallerySlice'; - -type ListBoardImagesArg = - paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] & - paths['/api/v1/board_images/{board_id}']['get']['parameters']['query']; - -type AddImageToBoardArg = - paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json']; - -type RemoveImageFromBoardArg = - paths['/api/v1/board_images/']['delete']['requestBody']['content']['application/json']; +import { api } from '..'; export const boardImagesApi = api.injectEndpoints({ endpoints: (build) => ({ /** * Board Images Queries */ - - listBoardImages: build.query< - OffsetPaginatedResults_ImageDTO_, - ListBoardImagesArg - >({ - query: ({ board_id, offset, limit }) => ({ - url: `board_images/${board_id}`, - method: 'GET', - }), - providesTags: (result, error, arg) => { - // any list of boardimages - const tags: ApiFullTagDescription[] = [ - { type: 'BoardImage', id: `${arg.board_id}_${LIST_TAG}` }, - ]; - - if (result) { - // and individual tags for each boardimage - tags.push( - ...result.items.map(({ board_id, image_name }) => ({ - type: 'BoardImage' as const, - id: `${board_id}_${image_name}`, - })) - ); - } - - return tags; - }, - }), + // listBoardImages: build.query< + // OffsetPaginatedResults_ImageDTO_, + // ListBoardImagesArg + // >({ + // query: ({ board_id, offset, limit }) => ({ + // url: `board_images/${board_id}`, + // method: 'GET', + // }), + // providesTags: (result, error, arg) => { + // // any list of boardimages + // const tags: ApiFullTagDescription[] = [ + // { type: 'BoardImage', id: `${arg.board_id}_${LIST_TAG}` }, + // ]; + // if (result) { + // // and individual tags for each boardimage + // tags.push( + // ...result.items.map(({ board_id, image_name }) => ({ + // type: 'BoardImage' as const, + // id: `${board_id}_${image_name}`, + // })) + // ); + // } + // return tags; + // }, + // }), }), }); -export const { useListBoardImagesQuery } = boardImagesApi; +// export const { useListBoardImagesQuery } = boardImagesApi; diff --git a/invokeai/frontend/web/src/services/api/endpoints/boards.ts b/invokeai/frontend/web/src/services/api/endpoints/boards.ts index b019652ce5..779e5708fe 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/boards.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/boards.ts @@ -109,10 +109,25 @@ export const boardsApi = api.injectEndpoints({ deleteBoard: build.mutation({ query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }), - invalidatesTags: (result, error, arg) => [ - { type: 'Board', id: arg }, + invalidatesTags: (result, error, board_id) => [ + { type: 'Board', id: LIST_TAG }, // invalidate the 'No Board' cache - { type: 'ImageList', id: getListImagesUrl({ board_id: 'none' }) }, + { + type: 'ImageList', + id: getListImagesUrl({ + board_id: 'none', + categories: IMAGE_CATEGORIES, + }), + }, + { + type: 'ImageList', + id: getListImagesUrl({ + board_id: 'none', + categories: ASSETS_CATEGORIES, + }), + }, + { type: 'BoardImagesTotal', id: 'none' }, + { type: 'BoardAssetsTotal', id: 'none' }, ], async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) { /** @@ -167,24 +182,14 @@ export const boardsApi = api.injectEndpoints({ 'listImages', queryArgs, (draft) => { - const oldCount = imagesAdapter - .getSelectors() - .selectTotal(draft); + const oldTotal = draft.total; const newState = imagesAdapter.updateMany(draft, updates); - const newCount = imagesAdapter - .getSelectors() - .selectTotal(newState); - draft.total = Math.max( - draft.total - (oldCount - newCount), - 0 - ); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; } ) ); }); - - // after deleting a board, select the 'All Images' board - dispatch(boardIdSelected('images')); } catch { //no-op } @@ -197,9 +202,24 @@ export const boardsApi = api.injectEndpoints({ method: 'DELETE', params: { include_images: true }, }), - invalidatesTags: (result, error, arg) => [ - { type: 'Board', id: arg }, - { type: 'ImageList', id: getListImagesUrl({ board_id: 'none' }) }, + invalidatesTags: (result, error, board_id) => [ + { type: 'Board', id: LIST_TAG }, + { + type: 'ImageList', + id: getListImagesUrl({ + board_id: 'none', + categories: IMAGE_CATEGORIES, + }), + }, + { + type: 'ImageList', + id: getListImagesUrl({ + board_id: 'none', + categories: ASSETS_CATEGORIES, + }), + }, + { type: 'BoardImagesTotal', id: 'none' }, + { type: 'BoardAssetsTotal', id: 'none' }, ], async onQueryStarted(board_id, { dispatch, queryFulfilled, getState }) { /** @@ -231,27 +251,17 @@ export const boardsApi = api.injectEndpoints({ 'listImages', queryArgs, (draft) => { - const oldCount = imagesAdapter - .getSelectors() - .selectTotal(draft); + const oldTotal = draft.total; const newState = imagesAdapter.removeMany( draft, deleted_images ); - const newCount = imagesAdapter - .getSelectors() - .selectTotal(newState); - draft.total = Math.max( - draft.total - (oldCount - newCount), - 0 - ); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; } ) ); }); - - // after deleting a board, select the 'All Images' board - dispatch(boardIdSelected('images')); } catch { //no-op } diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 5eeb86d9c5..d1bf8e3977 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 { getCategories } from 'features/gallery/store/util'; 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 { getIsImageInDateRange } from './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: board_id ?? 'none', 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,168 @@ 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 ?? 'none', 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 ?? 'none', + 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 +442,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 +458,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 +504,103 @@ export const imagesApi = api.injectEndpoints({ body: { board_id, image_name }, }; }, - invalidatesTags: (result, error, arg) => [ - { type: 'BoardImage' }, - { type: 'Board', id: arg.board_id }, + invalidatesTags: (result, error, { board_id, imageDTO }) => [ + { 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) => { + // *remove* from [no_board|board_id]/[images|assets] + patches.push( + dispatch( + imagesApi.util.updateQueryData( + 'listImages', + { + board_id: imageDTO.board_id ?? 'none', + categories, + }, + (draft) => { + const oldTotal = draft.total; + const newState = imagesAdapter.removeOne( + draft, + imageDTO.image_name + ); + const delta = newState.total - oldTotal; + draft.total = draft.total + delta; + } + ) + ) + ); + + // $cache = board_id/[images|assets] + const queryArgs = { board_id: board_id ?? 'none', 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 +619,97 @@ export const imagesApi = api.injectEndpoints({ body: { board_id, image_name }, }; }, - invalidatesTags: (result, error, arg) => [ - { type: 'BoardImage' }, - { type: 'Board', id: arg.imageDTO.board_id }, + invalidatesTags: (result, error, { imageDTO }) => [ + { 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 ?? 'none', + 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 +733,9 @@ export const { useGetImageDTOQuery, useGetImageMetadataQuery, useDeleteImageMutation, - useUpdateImageMutation, + // useUpdateImageMutation, + useGetBoardImagesTotalQuery, + useGetBoardAssetsTotalQuery, useUploadImageMutation, useAddImageToBoardMutation, useRemoveImageFromBoardMutation, diff --git a/invokeai/frontend/web/src/services/api/endpoints/util.ts b/invokeai/frontend/web/src/services/api/endpoints/util.ts index d613711dc2..0172c1af44 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/util.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/util.ts @@ -25,27 +25,27 @@ export const getIsImageInDateRange = ( return false; }; -/** - * Determines the action we should take when an image may need to be added or updated in a cache. - */ -export const getCacheAction = ( - data: ImageCache | undefined, - imageDTO: ImageDTO -): 'add' | 'update' | 'none' => { - const isInDateRange = getIsImageInDateRange(data, imageDTO); - const isCacheFullyPopulated = data && data.total === data.ids.length; - const shouldUpdateCache = - Boolean(isInDateRange) || Boolean(isCacheFullyPopulated); +// /** +// * Determines the action we should take when an image may need to be added or updated in a cache. +// */ +// export const getCacheAction = ( +// data: ImageCache | undefined, +// imageDTO: ImageDTO +// ): 'add' | 'update' | 'none' => { +// const isInDateRange = getIsImageInDateRange(data, imageDTO); +// const isCacheFullyPopulated = data && data.total === data.ids.length; +// const shouldUpdateCache = +// Boolean(isInDateRange) || Boolean(isCacheFullyPopulated); - const isImageInCache = data && data.ids.includes(imageDTO.image_name); +// const isImageInCache = data && data.ids.includes(imageDTO.image_name); - if (shouldUpdateCache && isImageInCache) { - return 'update'; - } +// if (shouldUpdateCache && isImageInCache) { +// return 'update'; +// } - if (shouldUpdateCache && !isImageInCache) { - return 'add'; - } +// if (shouldUpdateCache && !isImageInCache) { +// return 'add'; +// } - return 'none'; -}; +// return 'none'; +// }; 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..1a9e69ff2d 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts @@ -1,53 +1,21 @@ -import { skipToken } from '@reduxjs/toolkit/dist/query'; -import { - ASSETS_CATEGORIES, - BoardId, - IMAGE_CATEGORIES, - INITIAL_IMAGE_LIMIT, -} from 'features/gallery/store/gallerySlice'; +import { useAppSelector } from 'app/store/storeHooks'; +import { BoardId } from 'features/gallery/store/gallerySlice'; import { useMemo } from 'react'; -import { ListImagesArgs, useListImagesQuery } from '../endpoints/images'; +import { + useGetBoardAssetsTotalQuery, + useGetBoardImagesTotalQuery, +} from '../endpoints/images'; -const baseQueryArg: 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 galleryView = useAppSelector((state) => state.gallery.galleryView); + + const { data: totalImages } = useGetBoardImagesTotalQuery(board_id); + const { data: totalAssets } = useGetBoardAssetsTotalQuery(board_id); + + const currentViewTotal = useMemo( + () => (galleryView === 'images' ? totalImages : totalAssets), + [galleryView, totalAssets, totalImages] + ); + + return { totalImages, totalAssets, currentViewTotal }; }; 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',