From 187cf906fa53330c53ca07b3ffd85abe83cdf014 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 20 Jul 2023 15:44:22 +1000 Subject: [PATCH] ui: enhance intermediates clear, enhance board auto-add (#3851) * feat(ui): enhance clear intermediates feature - retrieve the # of intermediates using a new query (just uses list images endpoint w/ limit of 0) - display the count in the UI - add types for clearIntermediates mutation - minor styling and verbiage changes * feat(ui): remove unused settings option for guides * feat(ui): use solid badge variant consistent with the rest of the usage of badges * feat(ui): update board ctx menu, add board auto-add - add context menu to system boards - only open is select board. did this so that you dont think its broken when you click it - add auto-add board. you can right click a user board to enable it for auto-add, or use the gallery settings popover to select it. the invoke button has a tooltip on a short delay to remind you that you have auto-add enabled - made useBoardName hook, provide it a board id and it gets your the board name - removed `boardIdToAdTo` state & logic, updated workflows to auto-switch and auto-add on image generation * fix(ui): clear controlnet when clearing intermediates * feat: Make Add Board icon a button * feat(db, api): clear intermediates now clears all of them * feat(ui): make reset webui text subtext style * feat(ui): board name change submits on blur --------- Co-authored-by: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> --- invokeai/app/api/routers/images.py | 22 ++-- invokeai/app/services/image_record_storage.py | 31 +++++ invokeai/app/services/images.py | 60 ++++----- .../socketio/socketInvocationComplete.ts | 33 +++-- .../components/Boards/BoardAutoAddSelect.tsx | 80 ++++++++++++ .../components/Boards/BoardContextMenu.tsx | 60 +++++++++ .../Boards/BoardsList/AddBoardButton.tsx | 12 +- .../Boards/BoardsList/AllAssetsBoard.tsx | 1 + .../Boards/BoardsList/AllImagesBoard.tsx | 1 + .../Boards/BoardsList/BatchBoard.tsx | 1 + .../Boards/BoardsList/GalleryBoard.tsx | 92 ++++++------- .../Boards/BoardsList/GenericBoard.tsx | 121 ++++++++++-------- .../Boards/BoardsList/NoBoardBoard.tsx | 1 + .../Boards/GalleryBoardContextMenuItems.tsx | 79 ++++++++++++ .../Boards/SystemBoardContextMenuItems.tsx | 12 ++ .../gallery/components/GalleryBoardName.tsx | 59 ++++----- .../components/GallerySettingsPopover.tsx | 18 +-- .../features/gallery/store/gallerySlice.ts | 25 +++- .../ProcessButtons/InvokeButton.tsx | 108 ++++++++++------ .../components/ProcessButtons/Loopback.tsx | 33 ----- .../ProcessButtons/ProcessButtons.tsx | 1 - .../SettingsClearIntermediates.tsx | 59 +++++---- .../SettingsModal/SettingsModal.tsx | 22 +--- .../src/features/system/store/systemSlice.ts | 9 -- .../ModelManagerPanel/ModelListItem.tsx | 11 +- .../web/src/services/api/endpoints/images.ts | 11 +- .../src/services/api/hooks/useBoardName.ts | 26 ++++ 27 files changed, 647 insertions(+), 341 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx delete mode 100644 invokeai/frontend/web/src/features/parameters/components/ProcessButtons/Loopback.tsx create mode 100644 invokeai/frontend/web/src/services/api/hooks/useBoardName.ts diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 559afa3b37..01e7dd2c26 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -1,8 +1,7 @@ import io from typing import Optional -from fastapi import (Body, HTTPException, Path, Query, Request, Response, - UploadFile) +from fastapi import Body, HTTPException, Path, Query, Request, Response, UploadFile from fastapi.responses import FileResponse from fastapi.routing import APIRouter from PIL import Image @@ -11,9 +10,11 @@ from invokeai.app.invocations.metadata import ImageMetadata from invokeai.app.models.image import ImageCategory, ResourceOrigin from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.item_storage import PaginatedResults -from invokeai.app.services.models.image_record import (ImageDTO, - ImageRecordChanges, - ImageUrlsDTO) +from invokeai.app.services.models.image_record import ( + ImageDTO, + ImageRecordChanges, + ImageUrlsDTO, +) from ..dependencies import ApiDependencies @@ -84,15 +85,16 @@ async def delete_image( # TODO: Does this need any exception handling at all? pass + @images_router.post("/clear-intermediates", operation_id="clear_intermediates") async def clear_intermediates() -> int: - """Clears first 100 intermediates""" + """Clears all intermediates""" try: - count_deleted = ApiDependencies.invoker.services.images.delete_many(is_intermediate=True) + count_deleted = ApiDependencies.invoker.services.images.delete_intermediates() return count_deleted except Exception as e: - # TODO: Does this need any exception handling at all? + raise HTTPException(status_code=500, detail="Failed to clear intermediates") pass @@ -130,6 +132,7 @@ async def get_image_dto( except Exception as e: raise HTTPException(status_code=404) + @images_router.get( "/{image_name}/metadata", operation_id="get_image_metadata", @@ -254,7 +257,8 @@ async def list_image_dtos( default=None, description="Whether to list intermediate images." ), board_id: Optional[str] = Query( - default=None, description="The board id to filter by. Use 'none' to find images without a board." + default=None, + description="The board id to filter by. Use 'none' to find images without a board.", ), offset: int = Query(default=0, description="The page offset"), limit: int = Query(default=10, description="The number of images per page"), diff --git a/invokeai/app/services/image_record_storage.py b/invokeai/app/services/image_record_storage.py index 09c3bdcc3e..eb69679a35 100644 --- a/invokeai/app/services/image_record_storage.py +++ b/invokeai/app/services/image_record_storage.py @@ -122,6 +122,11 @@ class ImageRecordStorageBase(ABC): """Deletes many image records.""" pass + @abstractmethod + def delete_intermediates(self) -> list[str]: + """Deletes all intermediate image records, returning a list of deleted image names.""" + pass + @abstractmethod def save( self, @@ -461,6 +466,32 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): finally: self._lock.release() + + def delete_intermediates(self) -> list[str]: + try: + self._lock.acquire() + self._cursor.execute( + """--sql + SELECT image_name FROM images + WHERE is_intermediate = TRUE; + """ + ) + result = cast(list[sqlite3.Row], self._cursor.fetchall()) + image_names = list(map(lambda r: r[0], result)) + self._cursor.execute( + """--sql + DELETE FROM images + WHERE is_intermediate = TRUE; + """ + ) + self._conn.commit() + return image_names + except sqlite3.Error as e: + self._conn.rollback() + raise ImageRecordDeleteException from e + finally: + self._lock.release() + def save( self, image_name: str, diff --git a/invokeai/app/services/images.py b/invokeai/app/services/images.py index 13c6c04719..6fdb6237f8 100644 --- a/invokeai/app/services/images.py +++ b/invokeai/app/services/images.py @@ -6,21 +6,33 @@ from typing import TYPE_CHECKING, Optional from PIL.Image import Image as PILImageType from invokeai.app.invocations.metadata import ImageMetadata -from invokeai.app.models.image import (ImageCategory, - InvalidImageCategoryException, - InvalidOriginException, ResourceOrigin) -from invokeai.app.services.board_image_record_storage import \ - BoardImageRecordStorageBase +from invokeai.app.models.image import ( + ImageCategory, + InvalidImageCategoryException, + InvalidOriginException, + ResourceOrigin, +) +from invokeai.app.services.board_image_record_storage import BoardImageRecordStorageBase from invokeai.app.services.image_file_storage import ( - ImageFileDeleteException, ImageFileNotFoundException, - ImageFileSaveException, ImageFileStorageBase) + ImageFileDeleteException, + ImageFileNotFoundException, + ImageFileSaveException, + ImageFileStorageBase, +) from invokeai.app.services.image_record_storage import ( - ImageRecordDeleteException, ImageRecordNotFoundException, - ImageRecordSaveException, ImageRecordStorageBase, OffsetPaginatedResults) + ImageRecordDeleteException, + ImageRecordNotFoundException, + ImageRecordSaveException, + ImageRecordStorageBase, + OffsetPaginatedResults, +) from invokeai.app.services.item_storage import ItemStorageABC -from invokeai.app.services.models.image_record import (ImageDTO, ImageRecord, - ImageRecordChanges, - image_record_to_dto) +from invokeai.app.services.models.image_record import ( + ImageDTO, + ImageRecord, + ImageRecordChanges, + image_record_to_dto, +) from invokeai.app.services.resource_name import NameServiceBase from invokeai.app.services.urls import UrlServiceBase from invokeai.app.util.metadata import get_metadata_graph_from_raw_session @@ -109,12 +121,10 @@ class ImageServiceABC(ABC): pass @abstractmethod - def delete_many(self, is_intermediate: bool) -> int: - """Deletes many images.""" + def delete_intermediates(self) -> int: + """Deletes all intermediate images.""" pass - - @abstractmethod def delete_images_on_board(self, board_id: str): """Deletes all images on a board.""" @@ -401,21 +411,13 @@ class ImageService(ImageServiceABC): except Exception as e: self._services.logger.error("Problem deleting image records and files") raise e - - def delete_many(self, is_intermediate: bool): + + def delete_intermediates(self) -> int: try: - # only clears 100 at a time - images = self._services.image_records.get_many(offset=0, limit=100, is_intermediate=is_intermediate,) - count = len(images.items) - image_name_list = list( - map( - lambda r: r.image_name, - images.items, - ) - ) - for image_name in image_name_list: + image_names = self._services.image_records.delete_intermediates() + count = len(image_names) + for image_name in image_names: self._services.image_files.delete(image_name) - self._services.image_records.delete_many(image_name_list) return count except ImageRecordDeleteException: self._services.logger.error(f"Failed to delete image records") 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 c2c57e0913..97cccfa05c 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 @@ -6,11 +6,7 @@ import { imageSelected, } from 'features/gallery/store/gallerySlice'; import { progressImageSet } from 'features/system/store/systemSlice'; -import { - SYSTEM_BOARDS, - imagesAdapter, - imagesApi, -} from 'services/api/endpoints/images'; +import { imagesAdapter, imagesApi } from 'services/api/endpoints/images'; import { isImageOutput } from 'services/api/guards'; import { sessionCanceled } from 'services/api/thunks/session'; import { @@ -32,8 +28,7 @@ export const addInvocationCompleteEventListener = () => { ); const session_id = action.payload.data.graph_execution_state_id; - const { cancelType, isCancelScheduled, boardIdToAddTo } = - getState().system; + const { cancelType, isCancelScheduled } = getState().system; // Handle scheduled cancelation if (cancelType === 'scheduled' && isCancelScheduled) { @@ -88,26 +83,28 @@ export const addInvocationCompleteEventListener = () => { ) ); - // add image to the board if we had one selected - if (boardIdToAddTo && !SYSTEM_BOARDS.includes(boardIdToAddTo)) { + const { autoAddBoardId } = gallery; + + // add image to the board if auto-add is enabled + if (autoAddBoardId) { dispatch( imagesApi.endpoints.addImageToBoard.initiate({ - board_id: boardIdToAddTo, + board_id: autoAddBoardId, imageDTO, }) ); } - const { selectedBoardId } = gallery; - - if (boardIdToAddTo && boardIdToAddTo !== selectedBoardId) { - dispatch(boardIdSelected(boardIdToAddTo)); - } else if (!boardIdToAddTo) { - dispatch(boardIdSelected('all')); - } + const { selectedBoardId, shouldAutoSwitch } = gallery; // If auto-switch is enabled, select the new image - if (getState().gallery.shouldAutoSwitch) { + if (shouldAutoSwitch) { + // if auto-add is enabled, switch the board as the image comes in + if (autoAddBoardId && autoAddBoardId !== selectedBoardId) { + dispatch(boardIdSelected(autoAddBoardId)); + } else if (!autoAddBoardId) { + dispatch(boardIdSelected('images')); + } dispatch(imageSelected(imageDTO.image_name)); } } diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx new file mode 100644 index 0000000000..827d49c88e --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx @@ -0,0 +1,80 @@ +import { SelectItem } from '@mantine/core'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; +import IAIMantineSelectItemWithTooltip from 'common/components/IAIMantineSelectItemWithTooltip'; +import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice'; +import { useCallback, useRef } from 'react'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; + +const selector = createSelector( + [stateSelector], + ({ gallery }) => { + const { autoAddBoardId } = gallery; + + return { + autoAddBoardId, + }; + }, + defaultSelectorOptions +); + +const BoardAutoAddSelect = () => { + const dispatch = useAppDispatch(); + const { autoAddBoardId } = useAppSelector(selector); + const inputRef = useRef(null); + const { boards, hasBoards } = useListAllBoardsQuery(undefined, { + selectFromResult: ({ data }) => { + const boards: SelectItem[] = [ + { + label: 'None', + value: 'none', + }, + ]; + data?.forEach(({ board_id, board_name }) => { + boards.push({ + label: board_name, + value: board_id, + }); + }); + return { + boards, + hasBoards: boards.length > 1, + }; + }, + }); + + const handleChange = useCallback( + (v: string | null) => { + if (!v) { + return; + } + + dispatch(autoAddBoardIdChanged(v === 'none' ? null : v)); + }, + [dispatch] + ); + + return ( + + item.label?.toLowerCase().includes(value.toLowerCase().trim()) || + item.value.toLowerCase().includes(value.toLowerCase().trim()) + } + onChange={handleChange} + /> + ); +}; + +export default BoardAutoAddSelect; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx new file mode 100644 index 0000000000..fa3a6b03be --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx @@ -0,0 +1,60 @@ +import { Box, 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'; +import { memo, useCallback } from 'react'; +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'; + +type Props = { + board?: BoardDTO; + board_id: string; + children: ContextMenuProps['children']; + setBoardToDelete?: (board?: BoardDTO) => void; +}; + +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 }} + menuButtonProps={{ + bg: 'transparent', + _hover: { bg: 'transparent' }, + }} + renderMenu={() => ( + + } onClickCapture={handleSelectBoard}> + Select Board + + {!board && } + {board && ( + + )} + + )} + > + {children} + + + ); + } +); + +BoardContextMenu.displayName = 'HoverableBoard'; + +export default BoardContextMenu; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx index a08fdec07f..7a07680878 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx @@ -1,5 +1,6 @@ -import IAIButton from 'common/components/IAIButton'; +import IAIIconButton from 'common/components/IAIIconButton'; import { useCallback } from 'react'; +import { FaPlus } from 'react-icons/fa'; import { useCreateBoardMutation } from 'services/api/endpoints/boards'; const DEFAULT_BOARD_NAME = 'My Board'; @@ -12,15 +13,14 @@ const AddBoardButton = () => { }, [createBoard]); return ( - } isLoading={isLoading} + tooltip="Add Board" aria-label="Add Board" onClick={handleCreateBoard} size="sm" - sx={{ px: 4 }} - > - Add Board - + /> ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllAssetsBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllAssetsBoard.tsx index 5f4f1cbeb0..76f6238cd0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllAssetsBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllAssetsBoard.tsx @@ -38,6 +38,7 @@ const AllAssetsBoard = ({ isSelected }: { isSelected: boolean }) => { return ( { return ( { return ( { const dispatch = useAppDispatch(); + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ gallery }) => { + const isSelectedForAutoAdd = + board.board_id === gallery.autoAddBoardId; + + return { isSelectedForAutoAdd }; + }, + defaultSelectorOptions + ), + [board.board_id] + ); + + const { isSelectedForAutoAdd } = useAppSelector(selector); const { currentData: coverImage } = useGetImageDTOQuery( board.cover_image_name ?? skipToken @@ -53,10 +79,6 @@ const GalleryBoard = memo( updateBoard({ board_id, changes: { board_name: newBoardName } }); }; - const handleDeleteBoard = useCallback(() => { - setBoardToDelete(board); - }, [board, setBoardToDelete]); - const droppableData: MoveBoardDropData = useMemo( () => ({ id: board_id, @@ -68,37 +90,10 @@ const GalleryBoard = memo( return ( - - menuProps={{ size: 'sm', isLazy: true }} - menuButtonProps={{ - bg: 'transparent', - _hover: { bg: 'transparent' }, - }} - renderMenu={() => ( - - {board.image_count > 0 && ( - <> - {/* } - onClickCapture={handleAddBoardToBatch} - > - Add Board to Batch - */} - - )} - } - onClickCapture={handleDeleteBoard} - > - Delete Board - - - )} + {(ref) => ( - {board.image_count} + + {board.image_count} + { handleUpdateBoardName(nextValue); }} @@ -205,7 +209,7 @@ const GalleryBoard = memo( )} - + ); } diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx index 5067dac33a..226100c490 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx @@ -2,9 +2,12 @@ import { As, Badge, Flex } from '@chakra-ui/react'; import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd'; import IAIDroppable from 'common/components/IAIDroppable'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { BoardId } from 'features/gallery/store/gallerySlice'; import { ReactNode } from 'react'; +import BoardContextMenu from '../BoardContextMenu'; type GenericBoardProps = { + board_id: BoardId; droppableData?: TypesafeDroppableData; onClick: () => void; isSelected: boolean; @@ -22,6 +25,7 @@ const formatBadgeCount = (count: number) => const GenericBoard = (props: GenericBoardProps) => { const { + board_id, droppableData, onClick, isSelected, @@ -32,67 +36,72 @@ const GenericBoard = (props: GenericBoardProps) => { } = props; return ( - - - + + {(ref) => ( - {badgeCount !== undefined && ( - {formatBadgeCount(badgeCount)} - )} + + + + {badgeCount !== undefined && ( + {formatBadgeCount(badgeCount)} + )} + + + + + {label} + - - - - {label} - - + )} + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index a47d4cd49f..772f5af97d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -39,6 +39,7 @@ const NoBoardBoard = ({ isSelected }: { isSelected: boolean }) => { return ( Move} onClick={handleClick} diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx new file mode 100644 index 0000000000..5d39eaaf28 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx @@ -0,0 +1,79 @@ +import { MenuItem } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +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 { BoardDTO } from 'services/api/types'; + +type Props = { + board: BoardDTO; + setBoardToDelete?: (board?: BoardDTO) => void; +}; + +const GalleryBoardContextMenuItems = ({ board, setBoardToDelete }: Props) => { + const dispatch = useAppDispatch(); + + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ gallery }) => { + const isSelectedForAutoAdd = + board.board_id === gallery.autoAddBoardId; + + return { isSelectedForAutoAdd }; + }, + defaultSelectorOptions + ), + [board.board_id] + ); + + const { isSelectedForAutoAdd } = useAppSelector(selector); + + const handleDelete = useCallback(() => { + if (!setBoardToDelete) { + return; + } + setBoardToDelete(board); + }, [board, setBoardToDelete]); + + const handleToggleAutoAdd = useCallback(() => { + dispatch( + autoAddBoardIdChanged(isSelectedForAutoAdd ? null : board.board_id) + ); + }, [board.board_id, dispatch, isSelectedForAutoAdd]); + + return ( + <> + {board.image_count > 0 && ( + <> + {/* } + onClickCapture={handleAddBoardToBatch} + > + Add Board to Batch + */} + + )} + : } + onClickCapture={handleToggleAutoAdd} + > + {isSelectedForAutoAdd ? 'Disable Auto-Add' : 'Auto-Add to this Board'} + + } + onClickCapture={handleDelete} + > + Delete Board + + + ); +}; + +export default memo(GalleryBoardContextMenuItems); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx new file mode 100644 index 0000000000..58eb6d2c0c --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx @@ -0,0 +1,12 @@ +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 4aa65b234e..12454dd15b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx @@ -1,20 +1,18 @@ import { ChevronUpIcon } from '@chakra-ui/icons'; -import { Button, Flex, Text } from '@chakra-ui/react'; +import { Box, Button, Flex, Spacer, Text } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { memo } from 'react'; -import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; +import { useBoardName } from 'services/api/hooks/useBoardName'; const selector = createSelector( [stateSelector], (state) => { const { selectedBoardId } = state.gallery; - return { - selectedBoardId, - }; + return { selectedBoardId }; }, defaultSelectorOptions ); @@ -27,25 +25,7 @@ type Props = { const GalleryBoardName = (props: Props) => { const { isOpen, onToggle } = props; const { selectedBoardId } = useAppSelector(selector); - const { selectedBoardName } = useListAllBoardsQuery(undefined, { - selectFromResult: ({ data }) => { - let selectedBoardName = ''; - if (selectedBoardId === 'images') { - selectedBoardName = 'All Images'; - } else if (selectedBoardId === 'assets') { - selectedBoardName = 'All Assets'; - } else if (selectedBoardId === 'no_board') { - selectedBoardName = 'No Board'; - } else if (selectedBoardId === 'batch') { - selectedBoardName = 'Batch'; - } else { - const selectedBoard = data?.find((b) => b.board_id === selectedBoardId); - selectedBoardName = selectedBoard?.board_name || 'Unknown Board'; - } - - return { selectedBoardName }; - }, - }); + const boardName = useBoardName(selectedBoardId); return ( { size="sm" variant="ghost" sx={{ + position: 'relative', + gap: 2, w: 'full', justifyContent: 'center', alignItems: 'center', @@ -64,19 +46,22 @@ const GalleryBoardName = (props: Props) => { }, }} > - - {selectedBoardName} - + + + + {boardName} + + + { /> } > - + { dispatch(shouldAutoSwitchChanged(e.target.checked)) } /> + ); diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 340559561f..314f933e9b 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -25,6 +25,7 @@ export type BoardId = type GalleryState = { selection: string[]; shouldAutoSwitch: boolean; + autoAddBoardId: string | null; galleryImageMinimumWidth: number; selectedBoardId: BoardId; batchImageNames: string[]; @@ -34,6 +35,7 @@ type GalleryState = { export const initialGalleryState: GalleryState = { selection: [], shouldAutoSwitch: true, + autoAddBoardId: null, galleryImageMinimumWidth: 96, selectedBoardId: 'images', batchImageNames: [], @@ -123,14 +125,34 @@ export const gallerySlice = createSlice({ state.batchImageNames = []; state.selection = []; }, + autoAddBoardIdChanged: (state, action: PayloadAction) => { + state.autoAddBoardId = action.payload; + }, }, extraReducers: (builder) => { builder.addMatcher( boardsApi.endpoints.deleteBoard.matchFulfilled, (state, action) => { - if (action.meta.arg.originalArgs === state.selectedBoardId) { + const deletedBoardId = action.meta.arg.originalArgs; + if (deletedBoardId === state.selectedBoardId) { state.selectedBoardId = 'images'; } + if (deletedBoardId === state.autoAddBoardId) { + state.autoAddBoardId = null; + } + } + ); + builder.addMatcher( + boardsApi.endpoints.listAllBoards.matchFulfilled, + (state, action) => { + const boards = action.payload; + if (!state.autoAddBoardId) { + return; + } + + if (!boards.map((b) => b.board_id).includes(state.autoAddBoardId)) { + state.autoAddBoardId = null; + } } ); }, @@ -147,6 +169,7 @@ export const { isBatchEnabledChanged, imagesAddedToBatch, imagesRemovedFromBatch, + autoAddBoardIdChanged, } = gallerySlice.actions; export default gallerySlice.reducer; diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx index ab4949392d..3880f717b9 100644 --- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx @@ -1,6 +1,9 @@ -import { Box, ChakraProps } from '@chakra-ui/react'; +import { Box, ChakraProps, Tooltip } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; import { userInvoked } from 'app/store/actions'; +import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIButton, { IAIButtonProps } from 'common/components/IAIButton'; import IAIIconButton, { IAIIconButtonProps, @@ -8,11 +11,13 @@ import IAIIconButton, { import { useIsReadyToInvoke } from 'common/hooks/useIsReadyToInvoke'; import { clampSymmetrySteps } from 'features/parameters/store/generationSlice'; import ProgressBar from 'features/system/components/ProgressBar'; +import { selectIsBusy } from 'features/system/store/systemSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { FaPlay } from 'react-icons/fa'; +import { useBoardName } from 'services/api/hooks/useBoardName'; const IN_PROGRESS_STYLES: ChakraProps['sx'] = { _disabled: { @@ -26,6 +31,20 @@ const IN_PROGRESS_STYLES: ChakraProps['sx'] = { }, }; +const selector = createSelector( + [stateSelector, activeTabNameSelector, selectIsBusy], + ({ gallery }, activeTabName, isBusy) => { + const { autoAddBoardId } = gallery; + + return { + isBusy, + autoAddBoardId, + activeTabName, + }; + }, + defaultSelectorOptions +); + interface InvokeButton extends Omit { iconButton?: boolean; @@ -35,8 +54,8 @@ export default function InvokeButton(props: InvokeButton) { const { iconButton = false, ...rest } = props; const dispatch = useAppDispatch(); const isReady = useIsReadyToInvoke(); - const activeTabName = useAppSelector(activeTabNameSelector); - const isProcessing = useAppSelector((state) => state.system.isProcessing); + const { isBusy, autoAddBoardId, activeTabName } = useAppSelector(selector); + const autoAddBoardName = useBoardName(autoAddBoardId); const handleInvoke = useCallback(() => { dispatch(clampSymmetrySteps()); @@ -75,43 +94,52 @@ export default function InvokeButton(props: InvokeButton) { )} - {iconButton ? ( - } - isDisabled={!isReady || isProcessing} - onClick={handleInvoke} - tooltip={t('parameters.invoke')} - tooltipProps={{ placement: 'top' }} - colorScheme="accent" - id="invoke-button" - {...rest} - sx={{ - w: 'full', - flexGrow: 1, - ...(isProcessing ? IN_PROGRESS_STYLES : {}), - }} - /> - ) : ( - - Invoke - - )} + + {iconButton ? ( + } + isDisabled={!isReady || isBusy} + onClick={handleInvoke} + tooltip={t('parameters.invoke')} + tooltipProps={{ placement: 'top' }} + colorScheme="accent" + id="invoke-button" + {...rest} + sx={{ + w: 'full', + flexGrow: 1, + ...(isBusy ? IN_PROGRESS_STYLES : {}), + }} + /> + ) : ( + + Invoke + + )} + ); diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/Loopback.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/Loopback.tsx deleted file mode 100644 index 3bd405d1ce..0000000000 --- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/Loopback.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIIconButton from 'common/components/IAIIconButton'; -import { postprocessingSelector } from 'features/parameters/store/postprocessingSelectors'; -import { setShouldLoopback } from 'features/parameters/store/postprocessingSlice'; -import { useTranslation } from 'react-i18next'; -import { FaRecycle } from 'react-icons/fa'; - -const loopbackSelector = createSelector( - postprocessingSelector, - ({ shouldLoopback }) => shouldLoopback -); - -const LoopbackButton = () => { - const dispatch = useAppDispatch(); - const shouldLoopback = useAppSelector(loopbackSelector); - - const { t } = useTranslation(); - - return ( - } - onClick={() => { - dispatch(setShouldLoopback(!shouldLoopback)); - }} - /> - ); -}; - -export default LoopbackButton; diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/ProcessButtons.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/ProcessButtons.tsx index 4449866ef2..f132092012 100644 --- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/ProcessButtons.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/ProcessButtons.tsx @@ -9,7 +9,6 @@ const ProcessButtons = () => { return ( - {/* {activeTabName === 'img2img' && } */} ); diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx index d75eb4d4c2..9d095f3511 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx @@ -1,60 +1,71 @@ -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useCallback, useEffect, useState } from 'react'; -import { StyledFlex } from './SettingsModal'; import { Heading, Text } from '@chakra-ui/react'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useCallback, useEffect } from 'react'; import IAIButton from '../../../../common/components/IAIButton'; -import { useClearIntermediatesMutation } from '../../../../services/api/endpoints/images'; -import { addToast } from '../../store/systemSlice'; +import { + useClearIntermediatesMutation, + useGetIntermediatesCountQuery, +} from '../../../../services/api/endpoints/images'; import { resetCanvas } from '../../../canvas/store/canvasSlice'; +import { addToast } from '../../store/systemSlice'; +import { StyledFlex } from './SettingsModal'; +import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; export default function SettingsClearIntermediates() { const dispatch = useAppDispatch(); - const [isDisabled, setIsDisabled] = useState(false); + + const { data: intermediatesCount, refetch: updateIntermediatesCount } = + useGetIntermediatesCountQuery(); const [clearIntermediates, { isLoading: isLoadingClearIntermediates }] = useClearIntermediatesMutation(); const handleClickClearIntermediates = useCallback(() => { - clearIntermediates({}) + clearIntermediates() .unwrap() .then((response) => { + dispatch(controlNetReset()); dispatch(resetCanvas()); dispatch( addToast({ - title: - response === 0 - ? `No intermediates to clear` - : `Successfully cleared ${response} intermediates`, + title: `Cleared ${response} intermediates`, status: 'info', }) ); - if (response < 100) { - setIsDisabled(true); - } }); }, [clearIntermediates, dispatch]); + useEffect(() => { + // update the count on mount + updateIntermediatesCount(); + }, [updateIntermediatesCount]); + + const buttonText = intermediatesCount + ? `Clear ${intermediatesCount} Intermediate${ + intermediatesCount > 1 ? 's' : '' + }` + : 'No Intermediates to Clear'; + return ( Clear Intermediates - {isDisabled ? 'Intermediates Cleared' : 'Clear 100 Intermediates'} + {buttonText} - - Will permanently delete first 100 intermediates found on disk and in - database + + Clearing intermediates will reset your Canvas and ControlNet state. - This will also clear your canvas state. - + Intermediate images are byproducts of generation, different from the - result images in the gallery. Purging intermediates will free disk - space. Your gallery images will not be deleted. + result images in the gallery. Clearing intermediates will free disk + space. + Your gallery images will not be deleted. ); } diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx index 5ec7a09b67..31dd6162ec 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx @@ -24,7 +24,6 @@ import { setEnableImageDebugging, setIsNodesEnabled, setShouldConfirmOnDelete, - setShouldDisplayGuides, shouldAntialiasProgressImageChanged, shouldLogToConsoleChanged, } from 'features/system/store/systemSlice'; @@ -56,7 +55,6 @@ const selector = createSelector( (system: SystemState, ui: UIState) => { const { shouldConfirmOnDelete, - shouldDisplayGuides, enableImageDebugging, consoleLogLevel, shouldLogToConsole, @@ -73,7 +71,6 @@ const selector = createSelector( return { shouldConfirmOnDelete, - shouldDisplayGuides, enableImageDebugging, shouldUseCanvasBetaLayout, shouldUseSliders, @@ -139,7 +136,6 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { const { shouldConfirmOnDelete, - shouldDisplayGuides, enableImageDebugging, shouldUseCanvasBetaLayout, shouldUseSliders, @@ -195,7 +191,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { @@ -231,14 +227,6 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { {t('settings.ui')} - ) => - dispatch(setShouldDisplayGuides(e.target.checked)) - } - /> - { {shouldShowResetWebUiText && ( <> - {t('settings.resetWebUIDesc1')} - {t('settings.resetWebUIDesc2')} + + {t('settings.resetWebUIDesc1')} + + + {t('settings.resetWebUIDesc2')} + )} diff --git a/invokeai/frontend/web/src/features/system/store/systemSlice.ts b/invokeai/frontend/web/src/features/system/store/systemSlice.ts index 5bbaa2265f..1f2b452151 100644 --- a/invokeai/frontend/web/src/features/system/store/systemSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/systemSlice.ts @@ -38,7 +38,6 @@ export interface SystemState { currentIteration: number; totalIterations: number; currentStatusHasSteps: boolean; - shouldDisplayGuides: boolean; isCancelable: boolean; enableImageDebugging: boolean; toastQueue: UseToastOptions[]; @@ -84,14 +83,12 @@ export interface SystemState { shouldAntialiasProgressImage: boolean; language: keyof typeof LANGUAGES; isUploading: boolean; - boardIdToAddTo?: string; isNodesEnabled: boolean; } export const initialSystemState: SystemState = { isConnected: false, isProcessing: false, - shouldDisplayGuides: true, isGFPGANAvailable: true, isESRGANAvailable: true, shouldConfirmOnDelete: true, @@ -134,9 +131,6 @@ export const systemSlice = createSlice({ setShouldConfirmOnDelete: (state, action: PayloadAction) => { state.shouldConfirmOnDelete = action.payload; }, - setShouldDisplayGuides: (state, action: PayloadAction) => { - state.shouldDisplayGuides = action.payload; - }, setIsCancelable: (state, action: PayloadAction) => { state.isCancelable = action.payload; }, @@ -204,7 +198,6 @@ export const systemSlice = createSlice({ */ builder.addCase(appSocketSubscribed, (state, action) => { state.sessionId = action.payload.sessionId; - state.boardIdToAddTo = action.payload.boardId; state.canceledSession = ''; }); @@ -213,7 +206,6 @@ export const systemSlice = createSlice({ */ builder.addCase(appSocketUnsubscribed, (state) => { state.sessionId = null; - state.boardIdToAddTo = undefined; }); /** @@ -390,7 +382,6 @@ export const { setIsProcessing, setShouldConfirmOnDelete, setCurrentStatus, - setShouldDisplayGuides, setIsCancelable, setEnableImageDebugging, addToast, diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelListItem.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelListItem.tsx index 224b0ac003..4de5131f65 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelListItem.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelListItem.tsx @@ -98,16 +98,7 @@ export default function ModelListItem(props: ModelListItemProps) { onClick={handleSelectModel} > - + { modelBaseTypeMap[ model.base_model as keyof typeof modelBaseTypeMap diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index a37edd48aa..52f410e315 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -127,6 +127,13 @@ export const imagesApi = api.injectEndpoints({ // 24 hours - reducing this to a few minutes would reduce memory usage. keepUnusedDataFor: 86400, }), + getIntermediatesCount: build.query({ + query: () => ({ url: getListImagesUrl({ is_intermediate: true }) }), + providesTags: ['IntermediatesCount'], + transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { + return response.total; + }, + }), getImageDTO: build.query({ query: (image_name) => ({ url: `images/${image_name}` }), providesTags: (result, error, arg) => { @@ -148,8 +155,9 @@ export const imagesApi = api.injectEndpoints({ }, keepUnusedDataFor: 86400, // 24 hours }), - clearIntermediates: build.mutation({ + clearIntermediates: build.mutation({ query: () => ({ url: `images/clear-intermediates`, method: 'POST' }), + invalidatesTags: ['IntermediatesCount'], }), deleteImage: build.mutation({ query: ({ image_name }) => ({ @@ -617,6 +625,7 @@ export const imagesApi = api.injectEndpoints({ }); export const { + useGetIntermediatesCountQuery, useListImagesQuery, useLazyListImagesQuery, useGetImageDTOQuery, diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts new file mode 100644 index 0000000000..d63b6e0425 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts @@ -0,0 +1,26 @@ +import { BoardId } from 'features/gallery/store/gallerySlice'; +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 = 'All Images'; + } else if (board_id === 'assets') { + boardName = 'All 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'; + } + + return { boardName }; + }, + }); + + return boardName; +};