feat: add multi-select to gallery

multi-select actions include:
- drag to board to move all to that board
- right click to add all to board or delete all

backend changes:
- add routes for changing board for list of image names, deleting list of images
- change image-specific routes to `images/i/{image_name}` to not clobber other routes (like `images/upload`, `images/delete`)
- subclass pydantic `BaseModel` as `BaseModelExcludeNull`, which excludes null values when calling `dict()` on the model. this fixes inconsistent types related to JSON parsing null values into `null` instead of `undefined`
- remove `board_id` from `remove_image_from_board`

frontend changes:
- multi-selection stuff uses `ImageDTO[]` as payloads, for dnd and other mutations. this gives us access to image `board_id`s when hitting routes, and enables efficient cache updates.
- consolidate change board and delete image modals to handle single and multiples
- board totals are now re-fetched on mutation and not kept in sync manually - was way too tedious to do this
- fixed warning about nested `<p>` elements
- closes #4088 , need to handle case when `autoAddBoardId` is `"none"`
- add option to show gallery image delete button on every gallery image

frontend refactors/organisation:
- make typegen script js instead of ts
- enable `noUncheckedIndexedAccess` to help avoid bugs when indexing into arrays, many small changes needed to satisfy TS after this
- move all image-related endpoints into `endpoints/images.ts`, its a big file now, but this fixes a number of circular dependency issues that were otherwise felt impossible to resolve
This commit is contained in:
psychedelicious
2023-07-31 18:16:52 +10:00
parent e080fd1e08
commit bf94412d14
116 changed files with 2470 additions and 2181 deletions

View File

@ -23,7 +23,7 @@
"dev": "concurrently \"vite dev\" \"yarn run theme:watch\"",
"dev:host": "concurrently \"vite dev --host\" \"yarn run theme:watch\"",
"build": "yarn run lint && vite build",
"typegen": "npx ts-node scripts/typegen.ts",
"typegen": "node scripts/typegen.js",
"preview": "vite preview",
"lint:madge": "madge --circular src/main.tsx",
"lint:eslint": "eslint --max-warnings=0 .",

View File

@ -4,8 +4,9 @@ import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/ap
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { PartialAppConfig } from 'app/types/invokeai';
import ImageUploader from 'common/components/ImageUploader';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import GalleryDrawer from 'features/gallery/components/GalleryPanel';
import DeleteImageModal from 'features/imageDeletion/components/DeleteImageModal';
import SiteHeader from 'features/system/components/SiteHeader';
import { configChanged } from 'features/system/store/configSlice';
import { languageSelector } from 'features/system/store/systemSelectors';
@ -16,7 +17,6 @@ import ParametersDrawer from 'features/ui/components/ParametersDrawer';
import i18n from 'i18n';
import { size } from 'lodash-es';
import { ReactNode, memo, useEffect } from 'react';
import UpdateImageBoardModal from '../../features/gallery/components/Boards/UpdateImageBoardModal';
import GlobalHotkeys from './GlobalHotkeys';
import Toaster from './Toaster';
@ -84,7 +84,7 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
</Portal>
</Grid>
<DeleteImageModal />
<UpdateImageBoardModal />
<ChangeBoardModal />
<Toaster />
<GlobalHotkeys />
</>

View File

@ -58,7 +58,7 @@ const DragPreview = (props: OverlayDragImageProps) => {
);
}
if (props.dragData.payloadType === 'IMAGE_NAMES') {
if (props.dragData.payloadType === 'IMAGE_DTOS') {
return (
<Flex
sx={{
@ -71,7 +71,7 @@ const DragPreview = (props: OverlayDragImageProps) => {
...STYLES,
}}
>
<Heading>{props.dragData.payload.image_names.length}</Heading>
<Heading>{props.dragData.payload.imageDTOs.length}</Heading>
<Heading size="sm">Images</Heading>
</Flex>
);

View File

@ -18,27 +18,32 @@ import {
DragStartEvent,
TypesafeDraggableData,
} from './typesafeDnd';
import { logger } from 'app/logging/logger';
type ImageDndContextProps = PropsWithChildren;
const ImageDndContext = (props: ImageDndContextProps) => {
const [activeDragData, setActiveDragData] =
useState<TypesafeDraggableData | null>(null);
const log = logger('images');
const dispatch = useAppDispatch();
const handleDragStart = useCallback((event: DragStartEvent) => {
console.log('dragStart', event.active.data.current);
const activeData = event.active.data.current;
if (!activeData) {
return;
}
setActiveDragData(activeData);
}, []);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
log.trace({ dragData: event.active.data.current }, 'Drag started');
const activeData = event.active.data.current;
if (!activeData) {
return;
}
setActiveDragData(activeData);
},
[log]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
console.log('dragEnd', event.active.data.current);
log.trace({ dragData: event.active.data.current }, 'Drag ended');
const overData = event.over?.data.current;
if (!activeDragData || !overData) {
return;
@ -46,7 +51,7 @@ const ImageDndContext = (props: ImageDndContextProps) => {
dispatch(dndDropped({ overData, activeData: activeDragData }));
setActiveDragData(null);
},
[activeDragData, dispatch]
[activeDragData, dispatch, log]
);
const mouseSensor = useSensor(MouseSensor, {

View File

@ -11,7 +11,6 @@ import {
useDraggable as useOriginalDraggable,
useDroppable as useOriginalDroppable,
} from '@dnd-kit/core';
import { BoardId } from 'features/gallery/store/types';
import { ImageDTO } from 'services/api/types';
type BaseDropData = {
@ -54,9 +53,13 @@ export type AddToBatchDropData = BaseDropData & {
actionType: 'ADD_TO_BATCH';
};
export type MoveBoardDropData = BaseDropData & {
actionType: 'MOVE_BOARD';
context: { boardId: BoardId };
export type AddToBoardDropData = BaseDropData & {
actionType: 'ADD_TO_BOARD';
context: { boardId: string };
};
export type RemoveFromBoardDropData = BaseDropData & {
actionType: 'REMOVE_FROM_BOARD';
};
export type TypesafeDroppableData =
@ -67,7 +70,8 @@ export type TypesafeDroppableData =
| NodesImageDropData
| AddToBatchDropData
| NodesMultiImageDropData
| MoveBoardDropData;
| AddToBoardDropData
| RemoveFromBoardDropData;
type BaseDragData = {
id: string;
@ -78,14 +82,12 @@ export type ImageDraggableData = BaseDragData & {
payload: { imageDTO: ImageDTO };
};
export type ImageNamesDraggableData = BaseDragData & {
payloadType: 'IMAGE_NAMES';
payload: { image_names: string[] };
export type ImageDTOsDraggableData = BaseDragData & {
payloadType: 'IMAGE_DTOS';
payload: { imageDTOs: ImageDTO[] };
};
export type TypesafeDraggableData =
| ImageDraggableData
| ImageNamesDraggableData;
export type TypesafeDraggableData = ImageDraggableData | ImageDTOsDraggableData;
interface UseDroppableTypesafeArguments
extends Omit<UseDroppableArguments, 'data'> {
@ -156,14 +158,39 @@ export const isValidDrop = (
case 'SET_NODES_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SET_MULTI_NODES_IMAGE':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'MOVE_BOARD': {
return payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
case 'ADD_TO_BOARD': {
// If the board is the same, don't allow the drop
// Check the payload types
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
if (!isPayloadValid) {
return false;
}
// Check if the image's board is the board we are dragging onto
if (payloadType === 'IMAGE_DTO') {
const { imageDTO } = active.data.current.payload;
const currentBoard = imageDTO.board_id ?? 'none';
const destinationBoard = overData.context.boardId;
return currentBoard !== destinationBoard;
}
if (payloadType === 'IMAGE_DTOS') {
// TODO (multi-select)
return true;
}
return false;
}
case 'REMOVE_FROM_BOARD': {
// If the board is the same, don't allow the drop
// Check the payload types
const isPayloadValid = payloadType === 'IMAGE_DTO' || 'IMAGE_DTOS';
if (!isPayloadValid) {
return false;
}
@ -172,20 +199,16 @@ export const isValidDrop = (
if (payloadType === 'IMAGE_DTO') {
const { imageDTO } = active.data.current.payload;
const currentBoard = imageDTO.board_id;
const destinationBoard = overData.context.boardId;
const isSameBoard = currentBoard === destinationBoard;
const isDestinationValid = !currentBoard ? destinationBoard : true;
return !isSameBoard && isDestinationValid;
return currentBoard !== 'none';
}
if (payloadType === 'IMAGE_NAMES') {
if (payloadType === 'IMAGE_DTOS') {
// TODO (multi-select)
return false;
return true;
}
return true;
return false;
}
default:
return false;

View File

@ -1,4 +1,6 @@
import { Middleware } from '@reduxjs/toolkit';
import { store } from 'app/store/store';
import { PartialAppConfig } from 'app/types/invokeai';
import React, {
lazy,
memo,
@ -7,16 +9,11 @@ import React, {
useEffect,
} from 'react';
import { Provider } from 'react-redux';
import { PartialAppConfig } from 'app/types/invokeai';
import { addMiddleware, resetMiddlewares } from 'redux-dynamic-middlewares';
import Loading from '../../common/components/Loading/Loading';
import { Middleware } from '@reduxjs/toolkit';
import { $authToken, $baseUrl, $projectId } from 'services/api/client';
import { socketMiddleware } from 'services/events/middleware';
import Loading from '../../common/components/Loading/Loading';
import '../../i18n';
import { AddImageToBoardContextProvider } from '../contexts/AddImageToBoardContext';
import ImageDndContext from './ImageDnd/ImageDndContext';
const App = lazy(() => import('./App'));
@ -84,9 +81,7 @@ const InvokeAIUI = ({
<React.Suspense fallback={<Loading />}>
<ThemeLocaleProvider>
<ImageDndContext>
<AddImageToBoardContextProvider>
<App config={config} headerComponent={headerComponent} />
</AddImageToBoardContextProvider>
<App config={config} headerComponent={headerComponent} />
</ImageDndContext>
</ThemeLocaleProvider>
</React.Suspense>

View File

@ -1,91 +0,0 @@
import { useDisclosure } from '@chakra-ui/react';
import { PropsWithChildren, createContext, useCallback, useState } from 'react';
import { ImageDTO } from 'services/api/types';
import { imagesApi } from 'services/api/endpoints/images';
import { useAppDispatch } from '../store/storeHooks';
export type ImageUsage = {
isInitialImage: boolean;
isCanvasImage: boolean;
isNodesImage: boolean;
isControlNetImage: boolean;
};
type AddImageToBoardContextValue = {
/**
* Whether the move image dialog is open.
*/
isOpen: boolean;
/**
* Closes the move image dialog.
*/
onClose: () => void;
/**
* The image pending movement
*/
image?: ImageDTO;
onClickAddToBoard: (image: ImageDTO) => void;
handleAddToBoard: (boardId: string) => void;
};
export const AddImageToBoardContext =
createContext<AddImageToBoardContextValue>({
isOpen: false,
onClose: () => undefined,
onClickAddToBoard: () => undefined,
handleAddToBoard: () => undefined,
});
type Props = PropsWithChildren;
export const AddImageToBoardContextProvider = (props: Props) => {
const [imageToMove, setImageToMove] = useState<ImageDTO>();
const { isOpen, onOpen, onClose } = useDisclosure();
const dispatch = useAppDispatch();
// Clean up after deleting or dismissing the modal
const closeAndClearImageToDelete = useCallback(() => {
setImageToMove(undefined);
onClose();
}, [onClose]);
const onClickAddToBoard = useCallback(
(image?: ImageDTO) => {
if (!image) {
return;
}
setImageToMove(image);
onOpen();
},
[setImageToMove, onOpen]
);
const handleAddToBoard = useCallback(
(boardId: string) => {
if (imageToMove) {
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO: imageToMove,
board_id: boardId,
})
);
closeAndClearImageToDelete();
}
},
[dispatch, closeAndClearImageToDelete, imageToMove]
);
return (
<AddImageToBoardContext.Provider
value={{
isOpen,
image: imageToMove,
onClose: closeAndClearImageToDelete,
onClickAddToBoard,
handleAddToBoard,
}}
>
{props.children}
</AddImageToBoardContext.Provider>
);
};

View File

@ -1,8 +0,0 @@
import { createContext } from 'react';
type VoidFunc = () => void;
type ImageUploaderTriggerContextType = VoidFunc | null;
export const ImageUploaderTriggerContext =
createContext<ImageUploaderTriggerContextType>(null);

View File

@ -23,6 +23,6 @@ const serializationDenylist: {
};
export const serialize: SerializeFunction = (data, key) => {
const result = omit(data, serializationDenylist[key]);
const result = omit(data, serializationDenylist[key] ?? []);
return JSON.stringify(result);
};

View File

@ -27,7 +27,8 @@ import {
addImageDeletedFulfilledListener,
addImageDeletedPendingListener,
addImageDeletedRejectedListener,
addRequestedImageDeletionListener,
addRequestedSingleImageDeletionListener,
addRequestedMultipleImageDeletionListener,
} from './listeners/imageDeleted';
import { addImageDroppedListener } from './listeners/imageDropped';
import {
@ -111,7 +112,8 @@ addImageUploadedRejectedListener();
addInitialImageSelectedListener();
// Image deleted
addRequestedImageDeletionListener();
addRequestedSingleImageDeletionListener();
addRequestedMultipleImageDeletionListener();
addImageDeletedPendingListener();
addImageDeletedFulfilledListener();
addImageDeletedRejectedListener();

View File

@ -1,12 +1,10 @@
import { createAction } from '@reduxjs/toolkit';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import {
ImageCache,
getListImagesUrl,
imagesApi,
} from 'services/api/endpoints/images';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
import { getListImagesUrl, imagesAdapter } from 'services/api/util';
import { ImageCache } from 'services/api/types';
export const appStarted = createAction('app/appStarted');
@ -34,7 +32,8 @@ export const addFirstListImagesListener = () => {
if (data.ids.length > 0) {
// Select the first image
dispatch(imageSelected(data.ids[0] as string));
const firstImage = imagesAdapter.getSelectors().selectAll(data)[0];
dispatch(imageSelected(firstImage ?? null));
}
},
});

View File

@ -18,7 +18,9 @@ export const addAppConfigReceivedListener = () => {
const infillMethod = getState().generation.infillMethod;
if (!infill_methods.includes(infillMethod)) {
dispatch(setInfillMethod(infill_methods[0]));
// if there is no infill method, set it to the first one
// if there is no first one... god help us
dispatch(setInfillMethod(infill_methods[0] as string));
}
if (!nsfw_methods.includes('nsfw_checker')) {

View File

@ -1,14 +1,14 @@
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { getImageUsage } from 'features/imageDeletion/store/imageDeletionSelectors';
import { getImageUsage } from 'features/deleteImageModal/store/selectors';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
import { boardsApi } from '../../../../../services/api/endpoints/boards';
export const addDeleteBoardAndImagesFulfilledListener = () => {
startAppListening({
matcher: boardsApi.endpoints.deleteBoardAndImages.matchFulfilled,
matcher: imagesApi.endpoints.deleteBoardAndImages.matchFulfilled,
effect: async (action, { dispatch, getState }) => {
const { deleted_images } = action.payload;

View File

@ -10,6 +10,7 @@ import {
} from 'features/gallery/store/types';
import { imagesApi } from 'services/api/endpoints/images';
import { startAppListening } from '..';
import { imagesSelectors } from 'services/api/util';
export const addBoardIdSelectedListener = () => {
startAppListening({
@ -52,8 +53,9 @@ export const addBoardIdSelectedListener = () => {
queryArgs
)(getState());
if (boardImagesData?.ids.length) {
dispatch(imageSelected((boardImagesData.ids[0] as string) ?? null));
if (boardImagesData) {
const firstImage = imagesSelectors.selectAll(boardImagesData)[0];
dispatch(imageSelected(firstImage ?? null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));

View File

@ -26,6 +26,8 @@ export const addCanvasSavedToGalleryListener = () => {
return;
}
const { autoAddBoardId } = state.gallery;
dispatch(
imagesApi.endpoints.uploadImage.initiate({
file: new File([blob], 'savedCanvas.png', {
@ -33,7 +35,7 @@ export const addCanvasSavedToGalleryListener = () => {
}),
image_category: 'general',
is_intermediate: false,
board_id: state.gallery.autoAddBoardId,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
crop_visible: true,
postUploadAction: {
type: 'TOAST',

View File

@ -31,15 +31,20 @@ const predicate: AnyListenerPredicate<RootState> = (
// do not process if the user just disabled auto-config
if (
prevState.controlNet.controlNets[action.payload.controlNetId]
.shouldAutoConfig === true
?.shouldAutoConfig === true
) {
return false;
}
}
const { controlImage, processorType, shouldAutoConfig } =
state.controlNet.controlNets[action.payload.controlNetId];
const cn = state.controlNet.controlNets[action.payload.controlNetId];
if (!cn) {
// something is wrong, the controlNet should exist
return false;
}
const { controlImage, processorType, shouldAutoConfig } = cn;
if (controlNetModelChanged.match(action) && !shouldAutoConfig) {
// do not process if the action is a model change but the processor settings are dirty
return false;

View File

@ -17,7 +17,7 @@ export const addControlNetImageProcessedListener = () => {
const { controlNetId } = action.payload;
const controlNet = getState().controlNet.controlNets[controlNetId];
if (!controlNet.controlImage) {
if (!controlNet?.controlImage) {
log.error('Unable to process ControlNet image');
return;
}

View File

@ -1,57 +1,72 @@
import { logger } from 'app/logging/logger';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { isModalOpenChanged } from 'features/deleteImageModal/store/slice';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageDeletionConfirmed } from 'features/imageDeletion/store/actions';
import { isModalOpenChanged } from 'features/imageDeletion/store/imageDeletionSlice';
import { nodeEditorReset } from 'features/nodes/store/nodesSlice';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { clamp } from 'lodash-es';
import { api } from 'services/api';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesAdapter } from 'services/api/util';
import { startAppListening } from '..';
/**
* Called when the user requests an image deletion
*/
export const addRequestedImageDeletionListener = () => {
export const addRequestedSingleImageDeletionListener = () => {
startAppListening({
actionCreator: imageDeletionConfirmed,
effect: async (action, { dispatch, getState, condition }) => {
const { imageDTO, imageUsage } = action.payload;
const { imageDTOs, imagesUsage } = action.payload;
if (imageDTOs.length !== 1 || imagesUsage.length !== 1) {
// handle multiples in separate listener
return;
}
const imageDTO = imageDTOs[0];
const imageUsage = imagesUsage[0];
if (!imageDTO || !imageUsage) {
// satisfy noUncheckedIndexedAccess
return;
}
dispatch(isModalOpenChanged(false));
const { image_name } = imageDTO;
const state = getState();
const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1];
state.gallery.selection[state.gallery.selection.length - 1]?.image_name;
if (imageDTO && imageDTO?.image_name === lastSelectedImage) {
const { image_name } = imageDTO;
if (lastSelectedImage === image_name) {
const baseQueryArgs = selectListImagesBaseQueryArgs(state);
const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const ids = data?.ids ?? [];
const cachedImageDTOs = data
? imagesAdapter.getSelectors().selectAll(data)
: [];
const deletedImageIndex = ids.findIndex(
(result) => result.toString() === image_name
const deletedImageIndex = cachedImageDTOs.findIndex(
(i) => i.image_name === image_name
);
const filteredIds = ids.filter((id) => id.toString() !== image_name);
const filteredImageDTOs = cachedImageDTOs.filter(
(i) => i.image_name !== image_name
);
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
filteredImageDTOs.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
const newSelectedImageDTO = filteredImageDTOs[newSelectedImageIndex];
if (newSelectedImageId) {
dispatch(imageSelected(newSelectedImageId as string));
if (newSelectedImageDTO) {
dispatch(imageSelected(newSelectedImageDTO));
} else {
dispatch(imageSelected(null));
}
@ -97,6 +112,66 @@ export const addRequestedImageDeletionListener = () => {
});
};
/**
* Called when the user requests an image deletion
*/
export const addRequestedMultipleImageDeletionListener = () => {
startAppListening({
actionCreator: imageDeletionConfirmed,
effect: async (action, { dispatch, getState }) => {
const { imageDTOs, imagesUsage } = action.payload;
if (imageDTOs.length < 1 || imagesUsage.length < 1) {
// handle singles in separate listener
return;
}
try {
// Delete from server
await dispatch(
imagesApi.endpoints.deleteImages.initiate({ imageDTOs })
).unwrap();
const state = getState();
const baseQueryArgs = selectListImagesBaseQueryArgs(state);
const { data } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const newSelectedImageDTO = data
? imagesAdapter.getSelectors().selectAll(data)[0]
: undefined;
if (newSelectedImageDTO) {
dispatch(imageSelected(newSelectedImageDTO));
} else {
dispatch(imageSelected(null));
}
dispatch(isModalOpenChanged(false));
// We need to reset the features where the image is in use - none of these work if their image(s) don't exist
if (imagesUsage.some((i) => i.isCanvasImage)) {
dispatch(resetCanvas());
}
if (imagesUsage.some((i) => i.isControlNetImage)) {
dispatch(controlNetReset());
}
if (imagesUsage.some((i) => i.isInitialImage)) {
dispatch(clearInitialImage());
}
if (imagesUsage.some((i) => i.isNodesImage)) {
dispatch(nodeEditorReset());
}
} catch {
// no-op
}
},
});
};
/**
* Called when the actual delete request is sent to the server
*/

View File

@ -6,10 +6,7 @@ import {
import { logger } from 'app/logging/logger';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import {
imageSelected,
imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { imagesApi } from 'services/api/endpoints/images';
@ -27,19 +24,32 @@ export const addImageDroppedListener = () => {
const log = logger('images');
const { activeData, overData } = action.payload;
log.debug({ activeData, overData }, 'Image or selection dropped');
if (activeData.payloadType === 'IMAGE_DTO') {
log.debug({ activeData, overData }, 'Image dropped');
} else if (activeData.payloadType === 'IMAGE_DTOS') {
log.debug(
{ activeData, overData },
`Images (${activeData.payload.imageDTOs.length}) dropped`
);
} else {
log.debug({ activeData, overData }, `Unknown payload dropped`);
}
// set current image
/**
* Image dropped on current image
*/
if (
overData.actionType === 'SET_CURRENT_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(imageSelected(activeData.payload.imageDTO.image_name));
dispatch(imageSelected(activeData.payload.imageDTO));
return;
}
// set initial image
/**
* Image dropped on initial image
*/
if (
overData.actionType === 'SET_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
@ -49,27 +59,9 @@ export const addImageDroppedListener = () => {
return;
}
// add image to batch
if (
overData.actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(imagesAddedToBatch([activeData.payload.imageDTO.image_name]));
return;
}
// add multiple images to batch
if (
overData.actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'IMAGE_NAMES'
) {
dispatch(imagesAddedToBatch(activeData.payload.image_names));
return;
}
// set control image
/**
* Image dropped on ControlNet
*/
if (
overData.actionType === 'SET_CONTROLNET_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
@ -85,7 +77,9 @@ export const addImageDroppedListener = () => {
return;
}
// set canvas image
/**
* Image dropped on Canvas
*/
if (
overData.actionType === 'SET_CANVAS_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
@ -95,7 +89,9 @@ export const addImageDroppedListener = () => {
return;
}
// set nodes image
/**
* Image dropped on node image field
*/
if (
overData.actionType === 'SET_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
@ -112,61 +108,36 @@ export const addImageDroppedListener = () => {
return;
}
// set multiple nodes images (single image handler)
if (
overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
const { fieldName, nodeId } = overData.context;
dispatch(
fieldValueChanged({
nodeId,
fieldName,
value: [activeData.payload.imageDTO],
})
);
return;
}
// // set multiple nodes images (multiple images handler)
/**
* TODO
* Image selection dropped on node image collection field
*/
// if (
// overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
// activeData.payloadType === 'IMAGE_NAMES'
// activeData.payloadType === 'IMAGE_DTO' &&
// activeData.payload.imageDTO
// ) {
// const { fieldName, nodeId } = overData.context;
// dispatch(
// imageCollectionFieldValueChanged({
// fieldValueChanged({
// nodeId,
// fieldName,
// value: activeData.payload.image_names.map((image_name) => ({
// image_name,
// })),
// value: [activeData.payload.imageDTO],
// })
// );
// return;
// }
// add image to board
/**
* Image dropped on user board
*/
if (
overData.actionType === 'MOVE_BOARD' &&
overData.actionType === 'ADD_TO_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
const { imageDTO } = activeData.payload;
const { boardId } = overData.context;
// image was droppe on the "NoBoardBoard"
if (!boardId) {
dispatch(
imagesApi.endpoints.removeImageFromBoard.initiate({
imageDTO,
})
);
return;
}
// image was dropped on a user board
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO,
@ -176,67 +147,58 @@ export const addImageDroppedListener = () => {
return;
}
// // add gallery selection to board
// if (
// overData.actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// overData.context.boardId
// ) {
// console.log('adding gallery selection to board');
// const board_id = overData.context.boardId;
// dispatch(
// boardImagesApi.endpoints.addManyBoardImages.initiate({
// board_id,
// image_names: activeData.payload.image_names,
// })
// );
// return;
// }
/**
* Image dropped on 'none' board
*/
if (
overData.actionType === 'REMOVE_FROM_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
const { imageDTO } = activeData.payload;
dispatch(
imagesApi.endpoints.removeImageFromBoard.initiate({
imageDTO,
})
);
return;
}
// // remove gallery selection from board
// if (
// overData.actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// overData.context.boardId === null
// ) {
// console.log('removing gallery selection to board');
// dispatch(
// boardImagesApi.endpoints.deleteManyBoardImages.initiate({
// image_names: activeData.payload.image_names,
// })
// );
// return;
// }
/**
* Multiple images dropped on user board
*/
if (
overData.actionType === 'ADD_TO_BOARD' &&
activeData.payloadType === 'IMAGE_DTOS' &&
activeData.payload.imageDTOs
) {
const { imageDTOs } = activeData.payload;
const { boardId } = overData.context;
dispatch(
imagesApi.endpoints.addImagesToBoard.initiate({
imageDTOs,
board_id: boardId,
})
);
return;
}
// // add batch selection to board
// if (
// overData.actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// overData.context.boardId
// ) {
// const board_id = overData.context.boardId;
// dispatch(
// boardImagesApi.endpoints.addManyBoardImages.initiate({
// board_id,
// image_names: activeData.payload.image_names,
// })
// );
// return;
// }
// // remove batch selection from board
// if (
// overData.actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// overData.context.boardId === null
// ) {
// dispatch(
// boardImagesApi.endpoints.deleteManyBoardImages.initiate({
// image_names: activeData.payload.image_names,
// })
// );
// return;
// }
/**
* Multiple images dropped on 'none' board
*/
if (
overData.actionType === 'REMOVE_FROM_BOARD' &&
activeData.payloadType === 'IMAGE_DTOS' &&
activeData.payload.imageDTOs
) {
const { imageDTOs } = activeData.payload;
dispatch(
imagesApi.endpoints.removeImagesFromBoard.initiate({
imageDTOs,
})
);
return;
}
},
});
};

View File

@ -1,37 +1,32 @@
import { imageDeletionConfirmed } from 'features/imageDeletion/store/actions';
import { selectImageUsage } from 'features/imageDeletion/store/imageDeletionSelectors';
import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions';
import { selectImageUsage } from 'features/deleteImageModal/store/selectors';
import {
imageToDeleteSelected,
imagesToDeleteSelected,
isModalOpenChanged,
} from 'features/imageDeletion/store/imageDeletionSlice';
} from 'features/deleteImageModal/store/slice';
import { startAppListening } from '..';
export const addImageToDeleteSelectedListener = () => {
startAppListening({
actionCreator: imageToDeleteSelected,
actionCreator: imagesToDeleteSelected,
effect: async (action, { dispatch, getState }) => {
const imageDTO = action.payload;
const imageDTOs = action.payload;
const state = getState();
const { shouldConfirmOnDelete } = state.system;
const imageUsage = selectImageUsage(getState());
if (!imageUsage) {
// should never happen
return;
}
const imagesUsage = selectImageUsage(getState());
const isImageInUse =
imageUsage.isCanvasImage ||
imageUsage.isInitialImage ||
imageUsage.isControlNetImage ||
imageUsage.isNodesImage;
imagesUsage.some((i) => i.isCanvasImage) ||
imagesUsage.some((i) => i.isInitialImage) ||
imagesUsage.some((i) => i.isControlNetImage) ||
imagesUsage.some((i) => i.isNodesImage);
if (shouldConfirmOnDelete || isImageInUse) {
dispatch(isModalOpenChanged(true));
return;
}
dispatch(imageDeletionConfirmed({ imageDTO, imageUsage }));
dispatch(imageDeletionConfirmed({ imageDTOs, imagesUsage }));
},
});
};

View File

@ -2,14 +2,13 @@ import { UseToastOptions } from '@chakra-ui/react';
import { logger } from 'app/logging/logger';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice';
import { omit } from 'lodash-es';
import { boardsApi } from 'services/api/endpoints/boards';
import { startAppListening } from '..';
import { imagesApi } from '../../../../../services/api/endpoints/images';
import { omit } from 'lodash-es';
const DEFAULT_UPLOADED_TOAST: UseToastOptions = {
title: 'Image Uploaded',
@ -121,17 +120,6 @@ export const addImageUploadedFulfilledListener = () => {
);
return;
}
if (postUploadAction?.type === 'ADD_TO_BATCH') {
dispatch(imagesAddedToBatch([imageDTO.image_name]));
dispatch(
addToast({
...DEFAULT_UPLOADED_TOAST,
description: 'Added to batch',
})
);
return;
}
},
});
};

View File

@ -15,7 +15,7 @@ import {
setShouldUseSDXLRefiner,
} from 'features/sdxl/store/sdxlSlice';
import { forEach, some } from 'lodash-es';
import { modelsApi } from 'services/api/endpoints/models';
import { modelsApi, vaeModelsAdapter } from 'services/api/endpoints/models';
import { startAppListening } from '..';
export const addModelsLoadedListener = () => {
@ -144,8 +144,9 @@ export const addModelsLoadedListener = () => {
return;
}
const firstModelId = action.payload.ids[0];
const firstModel = action.payload.entities[firstModelId];
const firstModel = vaeModelsAdapter
.getSelectors()
.selectAll(action.payload)[0];
if (!firstModel) {
// No custom VAEs loaded at all; use the default

View File

@ -8,9 +8,10 @@ import {
} from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { progressImageSet } from 'features/system/store/systemSlice';
import { imagesAdapter, imagesApi } from 'services/api/endpoints/images';
import { imagesApi } from 'services/api/endpoints/images';
import { isImageOutput } from 'services/api/guards';
import { sessionCanceled } from 'services/api/thunks/session';
import { imagesAdapter } from 'services/api/util';
import {
appSocketInvocationComplete,
socketInvocationComplete,
@ -67,7 +68,7 @@ export const addInvocationCompleteEventListener = () => {
*/
const { autoAddBoardId } = gallery;
if (autoAddBoardId) {
if (autoAddBoardId && autoAddBoardId !== 'none') {
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
board_id: autoAddBoardId,
@ -83,10 +84,7 @@ export const addInvocationCompleteEventListener = () => {
categories: IMAGE_CATEGORIES,
},
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.addOne(draft, imageDTO);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
imagesAdapter.addOne(draft, imageDTO);
}
)
);
@ -94,8 +92,8 @@ export const addInvocationCompleteEventListener = () => {
dispatch(
imagesApi.util.invalidateTags([
{ type: 'BoardImagesTotal', id: autoAddBoardId ?? 'none' },
{ type: 'BoardAssetsTotal', id: autoAddBoardId ?? 'none' },
{ type: 'BoardImagesTotal', id: autoAddBoardId },
{ type: 'BoardAssetsTotal', id: autoAddBoardId },
])
);
@ -110,7 +108,7 @@ export const addInvocationCompleteEventListener = () => {
} else if (!autoAddBoardId) {
dispatch(galleryViewChanged('images'));
}
dispatch(imageSelected(imageDTO.image_name));
dispatch(imageSelected(imageDTO));
}
}

View File

@ -8,9 +8,9 @@ import {
import canvasReducer from 'features/canvas/store/canvasSlice';
import controlNetReducer from 'features/controlNet/store/controlNetSlice';
import dynamicPromptsReducer from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import boardsReducer from 'features/gallery/store/boardSlice';
import galleryReducer from 'features/gallery/store/gallerySlice';
import imageDeletionReducer from 'features/imageDeletion/store/imageDeletionSlice';
import deleteImageModalReducer from 'features/deleteImageModal/store/slice';
import changeBoardModalReducer from 'features/changeBoardModal/store/slice';
import loraReducer from 'features/lora/store/loraSlice';
import nodesReducer from 'features/nodes/store/nodesSlice';
import generationReducer from 'features/parameters/store/generationSlice';
@ -43,9 +43,9 @@ const allReducers = {
ui: uiReducer,
hotkeys: hotkeysReducer,
controlNet: controlNetReducer,
boards: boardsReducer,
dynamicPrompts: dynamicPromptsReducer,
imageDeletion: imageDeletionReducer,
deleteImageModal: deleteImageModalReducer,
changeBoardModal: changeBoardModalReducer,
lora: loraReducer,
modelmanager: modelmanagerReducer,
sdxl: sdxlReducer,

View File

@ -1,4 +1,4 @@
import { Flex, Text, useColorMode } from '@chakra-ui/react';
import { Box, Flex, useColorMode } from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { ReactNode, memo, useRef } from 'react';
import { mode } from 'theme/util/mode';
@ -74,7 +74,7 @@ export const IAIDropOverlay = (props: Props) => {
justifyContent: 'center',
}}
>
<Text
<Box
sx={{
fontSize: '2xl',
fontWeight: 600,
@ -87,7 +87,7 @@ export const IAIDropOverlay = (props: Props) => {
}}
>
{label}
</Text>
</Box>
</Flex>
</Flex>
</motion.div>

View File

@ -53,7 +53,9 @@ const IAIMantineSearchableSelect = (props: IAISelectProps) => {
// wrap onChange to clear search value on select
const handleChange = useCallback(
(v: string | null) => {
setSearchValue('');
// cannot figure out why we were doing this, but it was causing an issue where if you
// select the currently-selected item, it reset the search value to empty
// setSearchValue('');
if (!onChange) {
return;

View File

@ -78,7 +78,7 @@ const ImageUploader = (props: ImageUploaderProps) => {
image_category: 'user',
is_intermediate: false,
postUploadAction,
board_id: autoAddBoardId,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
});
},
[autoAddBoardId, postUploadAction, uploadImage]

View File

@ -49,7 +49,7 @@ export const useImageUploadButton = ({
image_category: 'user',
is_intermediate: false,
postUploadAction: postUploadAction ?? { type: 'TOAST' },
board_id: autoAddBoardId,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
});
},
[autoAddBoardId, postUploadAction, uploadImage]

View File

@ -33,6 +33,10 @@ const useColorPicker = () => {
1
).data;
if (!(a && r && g && b)) {
return;
}
dispatch(setColorPickerColor({ r, g, b, a }));
},
commitColorUnderCursor: () => {

View File

@ -727,10 +727,13 @@ export const canvasSlice = createSlice({
state.pastLayerStates.shift();
}
state.layerState.objects.push({
...images[selectedImageIndex],
});
const imageToCommit = images[selectedImageIndex];
if (imageToCommit) {
state.layerState.objects.push({
...imageToCommit,
});
}
state.layerState.stagingArea = {
...initialLayerState.stagingArea,
};

View File

@ -0,0 +1,132 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Flex,
Text,
} 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 IAIButton from 'common/components/IAIButton';
import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import {
useAddImagesToBoardMutation,
useRemoveImagesFromBoardMutation,
} from 'services/api/endpoints/images';
import { changeBoardReset, isModalOpenChanged } from '../store/slice';
const selector = createSelector(
[stateSelector],
({ changeBoardModal }) => {
const { isModalOpen, imagesToChange } = changeBoardModal;
return {
isModalOpen,
imagesToChange,
};
},
defaultSelectorOptions
);
const ChangeBoardModal = () => {
const dispatch = useAppDispatch();
const [selectedBoard, setSelectedBoard] = useState<string | null>();
const { data: boards, isFetching } = useListAllBoardsQuery();
const { imagesToChange, isModalOpen } = useAppSelector(selector);
const [addImagesToBoard] = useAddImagesToBoardMutation();
const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation();
const data = useMemo(() => {
const data: { label: string; value: string }[] = [
{ label: 'Uncategorized', value: 'none' },
];
(boards ?? []).forEach((board) =>
data.push({
label: board.board_name,
value: board.board_id,
})
);
return data;
}, [boards]);
const handleClose = useCallback(() => {
dispatch(changeBoardReset());
dispatch(isModalOpenChanged(false));
}, [dispatch]);
const handleChangeBoard = useCallback(() => {
if (!imagesToChange.length || !selectedBoard) {
return;
}
if (selectedBoard === 'none') {
removeImagesFromBoard({ imageDTOs: imagesToChange });
} else {
addImagesToBoard({
imageDTOs: imagesToChange,
board_id: selectedBoard,
});
}
setSelectedBoard(null);
dispatch(changeBoardReset());
}, [
addImagesToBoard,
dispatch,
imagesToChange,
removeImagesFromBoard,
selectedBoard,
]);
const cancelRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog
isOpen={isModalOpen}
onClose={handleClose}
leastDestructiveRef={cancelRef}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Change Board
</AlertDialogHeader>
<AlertDialogBody>
<Flex sx={{ flexDir: 'column', gap: 4 }}>
<Text>
Moving {`${imagesToChange.length}`} image
{`${imagesToChange.length > 1 ? 's' : ''}`} to board:
</Text>
<IAIMantineSearchableSelect
placeholder={isFetching ? 'Loading...' : 'Select Board'}
disabled={isFetching}
onChange={(v) => setSelectedBoard(v)}
value={selectedBoard}
data={data}
/>
</Flex>
</AlertDialogBody>
<AlertDialogFooter>
<IAIButton ref={cancelRef} onClick={handleClose}>
Cancel
</IAIButton>
<IAIButton colorScheme="accent" onClick={handleChangeBoard} ml={3}>
Move
</IAIButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};
export default memo(ChangeBoardModal);

View File

@ -0,0 +1,6 @@
import { ChangeBoardModalState } from './types';
export const initialState: ChangeBoardModalState = {
isModalOpen: false,
imagesToChange: [],
};

View File

@ -0,0 +1,25 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { ImageDTO } from 'services/api/types';
import { initialState } from './initialState';
const changeBoardModal = createSlice({
name: 'changeBoardModal',
initialState,
reducers: {
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isModalOpen = action.payload;
},
imagesToChangeSelected: (state, action: PayloadAction<ImageDTO[]>) => {
state.imagesToChange = action.payload;
},
changeBoardReset: (state) => {
state.imagesToChange = [];
state.isModalOpen = false;
},
},
});
export const { isModalOpenChanged, imagesToChangeSelected, changeBoardReset } =
changeBoardModal.actions;
export default changeBoardModal.reducer;

View File

@ -0,0 +1,6 @@
import { ImageDTO } from 'services/api/types';
export type ChangeBoardModalState = {
isModalOpen: boolean;
imagesToChange: ImageDTO[];
};

View File

@ -3,6 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { memo, useCallback } from 'react';
import { FaCopy, FaTrash } from 'react-icons/fa';
import {
ControlNetConfig,
controlNetDuplicated,
controlNetRemoved,
controlNetToggled,
@ -27,18 +28,27 @@ import ParamControlNetProcessorSelect from './parameters/ParamControlNetProcesso
import ParamControlNetResizeMode from './parameters/ParamControlNetResizeMode';
type ControlNetProps = {
controlNetId: string;
controlNet: ControlNetConfig;
};
const ControlNet = (props: ControlNetProps) => {
const { controlNetId } = props;
const { controlNet } = props;
const { controlNetId } = controlNet;
const dispatch = useAppDispatch();
const selector = createSelector(
stateSelector,
({ controlNet }) => {
const { isEnabled, shouldAutoConfig } =
controlNet.controlNets[controlNetId];
const cn = controlNet.controlNets[controlNetId];
if (!cn) {
return {
isEnabled: false,
shouldAutoConfig: false,
};
}
const { isEnabled, shouldAutoConfig } = cn;
return { isEnabled, shouldAutoConfig };
},
@ -96,7 +106,7 @@ const ControlNet = (props: ControlNetProps) => {
transitionDuration: '0.1s',
}}
>
<ParamControlNetModel controlNetId={controlNetId} />
<ParamControlNetModel controlNet={controlNet} />
</Box>
<IAIIconButton
size="sm"
@ -171,8 +181,8 @@ const ControlNet = (props: ControlNetProps) => {
justifyContent: 'space-between',
}}
>
<ParamControlNetWeight controlNetId={controlNetId} />
<ParamControlNetBeginEnd controlNetId={controlNetId} />
<ParamControlNetWeight controlNet={controlNet} />
<ParamControlNetBeginEnd controlNet={controlNet} />
</Flex>
{!isExpanded && (
<Flex
@ -184,22 +194,22 @@ const ControlNet = (props: ControlNetProps) => {
aspectRatio: '1/1',
}}
>
<ControlNetImagePreview controlNetId={controlNetId} height={28} />
<ControlNetImagePreview controlNet={controlNet} height={28} />
</Flex>
)}
</Flex>
<Flex sx={{ gap: 2 }}>
<ParamControlNetControlMode controlNetId={controlNetId} />
<ParamControlNetResizeMode controlNetId={controlNetId} />
<ParamControlNetControlMode controlNet={controlNet} />
<ParamControlNetResizeMode controlNet={controlNet} />
</Flex>
<ParamControlNetProcessorSelect controlNetId={controlNetId} />
<ParamControlNetProcessorSelect controlNet={controlNet} />
</Flex>
{isExpanded && (
<>
<ControlNetImagePreview controlNetId={controlNetId} height="392px" />
<ParamControlNetShouldAutoConfig controlNetId={controlNetId} />
<ControlNetProcessorComponent controlNetId={controlNetId} />
<ControlNetImagePreview controlNet={controlNet} height="392px" />
<ParamControlNetShouldAutoConfig controlNet={controlNet} />
<ControlNetProcessorComponent controlNet={controlNet} />
</>
)}
</Flex>

View File

@ -12,50 +12,41 @@ import IAIDndImage from 'common/components/IAIDndImage';
import { memo, useCallback, useMemo, useState } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { PostUploadAction } from 'services/api/types';
import { controlNetImageChanged } from '../store/controlNetSlice';
import {
ControlNetConfig,
controlNetImageChanged,
} from '../store/controlNetSlice';
type Props = {
controlNetId: string;
controlNet: ControlNetConfig;
height: SystemStyleObject['h'];
};
const selector = createSelector(
stateSelector,
({ controlNet }) => {
const { pendingControlImages } = controlNet;
return {
pendingControlImages,
};
},
defaultSelectorOptions
);
const ControlNetImagePreview = (props: Props) => {
const { height, controlNetId } = props;
const { height } = props;
const {
controlImage: controlImageName,
processedControlImage: processedControlImageName,
processorType,
isEnabled,
controlNetId,
} = props.controlNet;
const dispatch = useAppDispatch();
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ controlNet }) => {
const { pendingControlImages } = controlNet;
const {
controlImage,
processedControlImage,
processorType,
isEnabled,
} = controlNet.controlNets[controlNetId];
return {
controlImageName: controlImage,
processedControlImageName: processedControlImage,
processorType,
isEnabled,
pendingControlImages,
};
},
defaultSelectorOptions
),
[controlNetId]
);
const {
controlImageName,
processedControlImageName,
processorType,
pendingControlImages,
isEnabled,
} = useAppSelector(selector);
const { pendingControlImages } = useAppSelector(selector);
const [isMouseOverImage, setIsMouseOverImage] = useState(false);

View File

@ -1,8 +1,5 @@
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, useMemo } from 'react';
import { memo } from 'react';
import { ControlNetConfig } from '../store/controlNetSlice';
import CannyProcessor from './processors/CannyProcessor';
import ContentShuffleProcessor from './processors/ContentShuffleProcessor';
import HedProcessor from './processors/HedProcessor';
@ -17,28 +14,11 @@ import PidiProcessor from './processors/PidiProcessor';
import ZoeDepthProcessor from './processors/ZoeDepthProcessor';
export type ControlNetProcessorProps = {
controlNetId: string;
controlNet: ControlNetConfig;
};
const ControlNetProcessorComponent = (props: ControlNetProcessorProps) => {
const { controlNetId } = props;
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ controlNet }) => {
const { isEnabled, processorNode } =
controlNet.controlNets[controlNetId];
return { isEnabled, processorNode };
},
defaultSelectorOptions
),
[controlNetId]
);
const { isEnabled, processorNode } = useAppSelector(selector);
const { controlNetId, isEnabled, processorNode } = props.controlNet;
if (processorNode.type === 'canny_image_processor') {
return (

View File

@ -1,34 +1,19 @@
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 IAISwitch from 'common/components/IAISwitch';
import { controlNetAutoConfigToggled } from 'features/controlNet/store/controlNetSlice';
import {
ControlNetConfig,
controlNetAutoConfigToggled,
} from 'features/controlNet/store/controlNetSlice';
import { selectIsBusy } from 'features/system/store/systemSelectors';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback } from 'react';
type Props = {
controlNetId: string;
controlNet: ControlNetConfig;
};
const ParamControlNetShouldAutoConfig = (props: Props) => {
const { controlNetId } = props;
const { controlNetId, isEnabled, shouldAutoConfig } = props.controlNet;
const dispatch = useAppDispatch();
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ controlNet }) => {
const { isEnabled, shouldAutoConfig } =
controlNet.controlNets[controlNetId];
return { isEnabled, shouldAutoConfig };
},
defaultSelectorOptions
),
[controlNetId]
);
const { isEnabled, shouldAutoConfig } = useAppSelector(selector);
const isBusy = useAppSelector(selectIsBusy);
const handleShouldAutoConfigChanged = useCallback(() => {

View File

@ -9,48 +9,39 @@ import {
RangeSliderTrack,
Tooltip,
} 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 { useAppDispatch } from 'app/store/storeHooks';
import {
ControlNetConfig,
controlNetBeginStepPctChanged,
controlNetEndStepPctChanged,
} from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback } from 'react';
type Props = {
controlNetId: string;
controlNet: ControlNetConfig;
};
const formatPct = (v: number) => `${Math.round(v * 100)}%`;
const ParamControlNetBeginEnd = (props: Props) => {
const { controlNetId } = props;
const { beginStepPct, endStepPct, isEnabled, controlNetId } =
props.controlNet;
const dispatch = useAppDispatch();
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ controlNet }) => {
const { beginStepPct, endStepPct, isEnabled } =
controlNet.controlNets[controlNetId];
return { beginStepPct, endStepPct, isEnabled };
},
defaultSelectorOptions
),
[controlNetId]
);
const { beginStepPct, endStepPct, isEnabled } = useAppSelector(selector);
const handleStepPctChanged = useCallback(
(v: number[]) => {
dispatch(
controlNetBeginStepPctChanged({ controlNetId, beginStepPct: v[0] })
controlNetBeginStepPctChanged({
controlNetId,
beginStepPct: v[0] as number,
})
);
dispatch(
controlNetEndStepPctChanged({
controlNetId,
endStepPct: v[1] as number,
})
);
dispatch(controlNetEndStepPctChanged({ controlNetId, endStepPct: v[1] }));
},
[controlNetId, dispatch]
);

View File

@ -1,16 +1,14 @@
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 { useAppDispatch } from 'app/store/storeHooks';
import IAIMantineSelect from 'common/components/IAIMantineSelect';
import {
ControlModes,
ControlNetConfig,
controlNetControlModeChanged,
} from 'features/controlNet/store/controlNetSlice';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
type ParamControlNetControlModeProps = {
controlNetId: string;
controlNet: ControlNetConfig;
};
const CONTROL_MODE_DATA = [
@ -23,23 +21,8 @@ const CONTROL_MODE_DATA = [
export default function ParamControlNetControlMode(
props: ParamControlNetControlModeProps
) {
const { controlNetId } = props;
const { controlMode, isEnabled, controlNetId } = props.controlNet;
const dispatch = useAppDispatch();
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ controlNet }) => {
const { controlMode, isEnabled } =
controlNet.controlNets[controlNetId];
return { controlMode, isEnabled };
},
defaultSelectorOptions
),
[controlNetId]
);
const { controlMode, isEnabled } = useAppSelector(selector);
const handleControlModeChange = useCallback(
(controlMode: ControlModes) => {

View File

@ -5,7 +5,10 @@ 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 { controlNetModelChanged } from 'features/controlNet/store/controlNetSlice';
import {
ControlNetConfig,
controlNetModelChanged,
} from 'features/controlNet/store/controlNetSlice';
import { MODEL_TYPE_MAP } from 'features/parameters/types/constants';
import { modelIdToControlNetModelParam } from 'features/parameters/util/modelIdToControlNetModelParam';
import { selectIsBusy } from 'features/system/store/systemSelectors';
@ -14,30 +17,24 @@ import { memo, useCallback, useMemo } from 'react';
import { useGetControlNetModelsQuery } from 'services/api/endpoints/models';
type ParamControlNetModelProps = {
controlNetId: string;
controlNet: ControlNetConfig;
};
const selector = createSelector(
stateSelector,
({ generation }) => {
const { model } = generation;
return { mainModel: model };
},
defaultSelectorOptions
);
const ParamControlNetModel = (props: ParamControlNetModelProps) => {
const { controlNetId } = props;
const { controlNetId, model: controlNetModel, isEnabled } = props.controlNet;
const dispatch = useAppDispatch();
const isBusy = useAppSelector(selectIsBusy);
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ generation, controlNet }) => {
const { model } = generation;
const controlNetModel = controlNet.controlNets[controlNetId]?.model;
const isEnabled = controlNet.controlNets[controlNetId]?.isEnabled;
return { mainModel: model, controlNetModel, isEnabled };
},
defaultSelectorOptions
),
[controlNetId]
);
const { mainModel, controlNetModel, isEnabled } = useAppSelector(selector);
const { mainModel } = useAppSelector(selector);
const { data: controlNetModels } = useGetControlNetModelsQuery();

View File

@ -1,7 +1,6 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIMantineSearchableSelect, {
IAISelectDataType,
@ -9,13 +8,16 @@ import IAIMantineSearchableSelect, {
import { configSelector } from 'features/system/store/configSelectors';
import { selectIsBusy } from 'features/system/store/systemSelectors';
import { map } from 'lodash-es';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback } from 'react';
import { CONTROLNET_PROCESSORS } from '../../store/constants';
import { controlNetProcessorTypeChanged } from '../../store/controlNetSlice';
import {
ControlNetConfig,
controlNetProcessorTypeChanged,
} from '../../store/controlNetSlice';
import { ControlNetProcessorType } from '../../store/types';
type ParamControlNetProcessorSelectProps = {
controlNetId: string;
controlNet: ControlNetConfig;
};
const selector = createSelector(
@ -52,23 +54,9 @@ const ParamControlNetProcessorSelect = (
props: ParamControlNetProcessorSelectProps
) => {
const dispatch = useAppDispatch();
const { controlNetId } = props;
const processorNodeSelector = useMemo(
() =>
createSelector(
stateSelector,
({ controlNet }) => {
const { isEnabled, processorNode } =
controlNet.controlNets[controlNetId];
return { isEnabled, processorNode };
},
defaultSelectorOptions
),
[controlNetId]
);
const { controlNetId, isEnabled, processorNode } = props.controlNet;
const isBusy = useAppSelector(selectIsBusy);
const controlNetProcessors = useAppSelector(selector);
const { isEnabled, processorNode } = useAppSelector(processorNodeSelector);
const handleProcessorTypeChanged = useCallback(
(v: string | null) => {

View File

@ -1,16 +1,14 @@
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 { useAppDispatch } from 'app/store/storeHooks';
import IAIMantineSelect from 'common/components/IAIMantineSelect';
import {
ControlNetConfig,
ResizeModes,
controlNetResizeModeChanged,
} from 'features/controlNet/store/controlNetSlice';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
type ParamControlNetResizeModeProps = {
controlNetId: string;
controlNet: ControlNetConfig;
};
const RESIZE_MODE_DATA = [
@ -22,23 +20,8 @@ const RESIZE_MODE_DATA = [
export default function ParamControlNetResizeMode(
props: ParamControlNetResizeModeProps
) {
const { controlNetId } = props;
const { resizeMode, isEnabled, controlNetId } = props.controlNet;
const dispatch = useAppDispatch();
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ controlNet }) => {
const { resizeMode, isEnabled } =
controlNet.controlNets[controlNetId];
return { resizeMode, isEnabled };
},
defaultSelectorOptions
),
[controlNetId]
);
const { resizeMode, isEnabled } = useAppSelector(selector);
const handleResizeModeChange = useCallback(
(resizeMode: ResizeModes) => {

View File

@ -1,32 +1,18 @@
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 { useAppDispatch } from 'app/store/storeHooks';
import IAISlider from 'common/components/IAISlider';
import { controlNetWeightChanged } from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback, useMemo } from 'react';
import {
ControlNetConfig,
controlNetWeightChanged,
} from 'features/controlNet/store/controlNetSlice';
import { memo, useCallback } from 'react';
type ParamControlNetWeightProps = {
controlNetId: string;
controlNet: ControlNetConfig;
};
const ParamControlNetWeight = (props: ParamControlNetWeightProps) => {
const { controlNetId } = props;
const { weight, isEnabled, controlNetId } = props.controlNet;
const dispatch = useAppDispatch();
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ controlNet }) => {
const { weight, isEnabled } = controlNet.controlNets[controlNetId];
return { weight, isEnabled };
},
defaultSelectorOptions
),
[controlNetId]
);
const { weight, isEnabled } = useAppSelector(selector);
const handleWeightChanged = useCallback(
(weight: number) => {
dispatch(controlNetWeightChanged({ controlNetId, weight }));

View File

@ -4,7 +4,7 @@ import {
} from './types';
type ControlNetProcessorsDict = Record<
string,
ControlNetProcessorType,
{
type: ControlNetProcessorType | 'none';
label: string;

View File

@ -96,8 +96,11 @@ export const controlNetSlice = createSlice({
}>
) => {
const { sourceControlNetId, newControlNetId } = action.payload;
const newControlnet = cloneDeep(state.controlNets[sourceControlNetId]);
const oldControlNet = state.controlNets[sourceControlNetId];
if (!oldControlNet) {
return;
}
const newControlnet = cloneDeep(oldControlNet);
newControlnet.controlNetId = newControlNetId;
state.controlNets[newControlNetId] = newControlnet;
},
@ -124,8 +127,11 @@ export const controlNetSlice = createSlice({
action: PayloadAction<{ controlNetId: string }>
) => {
const { controlNetId } = action.payload;
state.controlNets[controlNetId].isEnabled =
!state.controlNets[controlNetId].isEnabled;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
cn.isEnabled = !cn.isEnabled;
},
controlNetImageChanged: (
state,
@ -135,12 +141,14 @@ export const controlNetSlice = createSlice({
}>
) => {
const { controlNetId, controlImage } = action.payload;
state.controlNets[controlNetId].controlImage = controlImage;
state.controlNets[controlNetId].processedControlImage = null;
if (
controlImage !== null &&
state.controlNets[controlNetId].processorType !== 'none'
) {
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
cn.controlImage = controlImage;
cn.processedControlImage = null;
if (controlImage !== null && cn.processorType !== 'none') {
state.pendingControlImages.push(controlNetId);
}
},
@ -152,8 +160,12 @@ export const controlNetSlice = createSlice({
}>
) => {
const { controlNetId, processedControlImage } = action.payload;
state.controlNets[controlNetId].processedControlImage =
processedControlImage;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
cn.processedControlImage = processedControlImage;
state.pendingControlImages = state.pendingControlImages.filter(
(id) => id !== controlNetId
);
@ -166,10 +178,15 @@ export const controlNetSlice = createSlice({
}>
) => {
const { controlNetId, model } = action.payload;
state.controlNets[controlNetId].model = model;
state.controlNets[controlNetId].processedControlImage = null;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
if (state.controlNets[controlNetId].shouldAutoConfig) {
cn.model = model;
cn.processedControlImage = null;
if (cn.shouldAutoConfig) {
let processorType: ControlNetProcessorType | undefined = undefined;
for (const modelSubstring in CONTROLNET_MODEL_DEFAULT_PROCESSORS) {
@ -180,14 +197,13 @@ export const controlNetSlice = createSlice({
}
if (processorType) {
state.controlNets[controlNetId].processorType = processorType;
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[
processorType
].default as RequiredControlNetProcessorNode;
cn.processorType = processorType;
cn.processorNode = CONTROLNET_PROCESSORS[processorType]
.default as RequiredControlNetProcessorNode;
} else {
state.controlNets[controlNetId].processorType = 'none';
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS
.none.default as RequiredControlNetProcessorNode;
cn.processorType = 'none';
cn.processorNode = CONTROLNET_PROCESSORS.none
.default as RequiredControlNetProcessorNode;
}
}
},
@ -196,28 +212,48 @@ export const controlNetSlice = createSlice({
action: PayloadAction<{ controlNetId: string; weight: number }>
) => {
const { controlNetId, weight } = action.payload;
state.controlNets[controlNetId].weight = weight;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
cn.weight = weight;
},
controlNetBeginStepPctChanged: (
state,
action: PayloadAction<{ controlNetId: string; beginStepPct: number }>
) => {
const { controlNetId, beginStepPct } = action.payload;
state.controlNets[controlNetId].beginStepPct = beginStepPct;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
cn.beginStepPct = beginStepPct;
},
controlNetEndStepPctChanged: (
state,
action: PayloadAction<{ controlNetId: string; endStepPct: number }>
) => {
const { controlNetId, endStepPct } = action.payload;
state.controlNets[controlNetId].endStepPct = endStepPct;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
cn.endStepPct = endStepPct;
},
controlNetControlModeChanged: (
state,
action: PayloadAction<{ controlNetId: string; controlMode: ControlModes }>
) => {
const { controlNetId, controlMode } = action.payload;
state.controlNets[controlNetId].controlMode = controlMode;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
cn.controlMode = controlMode;
},
controlNetResizeModeChanged: (
state,
@ -227,7 +263,12 @@ export const controlNetSlice = createSlice({
}>
) => {
const { controlNetId, resizeMode } = action.payload;
state.controlNets[controlNetId].resizeMode = resizeMode;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
cn.resizeMode = resizeMode;
},
controlNetProcessorParamsChanged: (
state,
@ -240,12 +281,17 @@ export const controlNetSlice = createSlice({
}>
) => {
const { controlNetId, changes } = action.payload;
const processorNode = state.controlNets[controlNetId].processorNode;
state.controlNets[controlNetId].processorNode = {
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
const processorNode = cn.processorNode;
cn.processorNode = {
...processorNode,
...changes,
};
state.controlNets[controlNetId].shouldAutoConfig = false;
cn.shouldAutoConfig = false;
},
controlNetProcessorTypeChanged: (
state,
@ -255,12 +301,16 @@ export const controlNetSlice = createSlice({
}>
) => {
const { controlNetId, processorType } = action.payload;
state.controlNets[controlNetId].processedControlImage = null;
state.controlNets[controlNetId].processorType = processorType;
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[
processorType
].default as RequiredControlNetProcessorNode;
state.controlNets[controlNetId].shouldAutoConfig = false;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
cn.processedControlImage = null;
cn.processorType = processorType;
cn.processorNode = CONTROLNET_PROCESSORS[processorType]
.default as RequiredControlNetProcessorNode;
cn.shouldAutoConfig = false;
},
controlNetAutoConfigToggled: (
state,
@ -269,37 +319,36 @@ export const controlNetSlice = createSlice({
}>
) => {
const { controlNetId } = action.payload;
const newShouldAutoConfig =
!state.controlNets[controlNetId].shouldAutoConfig;
const cn = state.controlNets[controlNetId];
if (!cn) {
return;
}
const newShouldAutoConfig = !cn.shouldAutoConfig;
if (newShouldAutoConfig) {
// manage the processor for the user
let processorType: ControlNetProcessorType | undefined = undefined;
for (const modelSubstring in CONTROLNET_MODEL_DEFAULT_PROCESSORS) {
if (
state.controlNets[controlNetId].model?.model_name.includes(
modelSubstring
)
) {
if (cn.model?.model_name.includes(modelSubstring)) {
processorType = CONTROLNET_MODEL_DEFAULT_PROCESSORS[modelSubstring];
break;
}
}
if (processorType) {
state.controlNets[controlNetId].processorType = processorType;
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS[
processorType
].default as RequiredControlNetProcessorNode;
cn.processorType = processorType;
cn.processorNode = CONTROLNET_PROCESSORS[processorType]
.default as RequiredControlNetProcessorNode;
} else {
state.controlNets[controlNetId].processorType = 'none';
state.controlNets[controlNetId].processorNode = CONTROLNET_PROCESSORS
.none.default as RequiredControlNetProcessorNode;
cn.processorType = 'none';
cn.processorNode = CONTROLNET_PROCESSORS.none
.default as RequiredControlNetProcessorNode;
}
}
state.controlNets[controlNetId].shouldAutoConfig = newShouldAutoConfig;
cn.shouldAutoConfig = newShouldAutoConfig;
},
controlNetReset: () => {
return { ...initialControlNetState };
@ -307,9 +356,11 @@ export const controlNetSlice = createSlice({
},
extraReducers: (builder) => {
builder.addCase(controlNetImageProcessed, (state, action) => {
if (
state.controlNets[action.payload.controlNetId].controlImage !== null
) {
const cn = state.controlNets[action.payload.controlNetId];
if (!cn) {
return;
}
if (cn.controlImage !== null) {
state.pendingControlImages.push(action.payload.controlNetId);
}
});

View File

@ -15,30 +15,42 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIButton from 'common/components/IAIButton';
import IAISwitch from 'common/components/IAISwitch';
import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { stateSelector } from 'app/store/store';
import { some } from 'lodash-es';
import { ChangeEvent, memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { imageDeletionConfirmed } from '../store/actions';
import { selectImageUsage } from '../store/imageDeletionSelectors';
import {
imageToDeleteCleared,
isModalOpenChanged,
} from '../store/imageDeletionSlice';
import { getImageUsage, selectImageUsage } from '../store/selectors';
import { imageDeletionCanceled, isModalOpenChanged } from '../store/slice';
import ImageUsageMessage from './ImageUsageMessage';
import { ImageUsage } from '../store/types';
const selector = createSelector(
[stateSelector, selectImageUsage],
({ system, config, imageDeletion }, imageUsage) => {
(state, imagesUsage) => {
const { system, config, deleteImageModal } = state;
const { shouldConfirmOnDelete } = system;
const { canRestoreDeletedImagesFromBin } = config;
const { imageToDelete, isModalOpen } = imageDeletion;
const { imagesToDelete, isModalOpen } = deleteImageModal;
const allImageUsage = (imagesToDelete ?? []).map(({ image_name }) =>
getImageUsage(state, image_name)
);
const imageUsageSummary: ImageUsage = {
isInitialImage: some(allImageUsage, (i) => i.isInitialImage),
isCanvasImage: some(allImageUsage, (i) => i.isCanvasImage),
isNodesImage: some(allImageUsage, (i) => i.isNodesImage),
isControlNetImage: some(allImageUsage, (i) => i.isControlNetImage),
};
return {
shouldConfirmOnDelete,
canRestoreDeletedImagesFromBin,
imageToDelete,
imageUsage,
imagesToDelete,
imagesUsage,
isModalOpen,
imageUsageSummary,
};
},
defaultSelectorOptions
@ -51,9 +63,10 @@ const DeleteImageModal = () => {
const {
shouldConfirmOnDelete,
canRestoreDeletedImagesFromBin,
imageToDelete,
imageUsage,
imagesToDelete,
imagesUsage,
isModalOpen,
imageUsageSummary,
} = useAppSelector(selector);
const handleChangeShouldConfirmOnDelete = useCallback(
@ -63,17 +76,19 @@ const DeleteImageModal = () => {
);
const handleClose = useCallback(() => {
dispatch(imageToDeleteCleared());
dispatch(imageDeletionCanceled());
dispatch(isModalOpenChanged(false));
}, [dispatch]);
const handleDelete = useCallback(() => {
if (!imageToDelete || !imageUsage) {
if (!imagesToDelete.length || !imagesUsage.length) {
return;
}
dispatch(imageToDeleteCleared());
dispatch(imageDeletionConfirmed({ imageDTO: imageToDelete, imageUsage }));
}, [dispatch, imageToDelete, imageUsage]);
dispatch(imageDeletionCanceled());
dispatch(
imageDeletionConfirmed({ imageDTOs: imagesToDelete, imagesUsage })
);
}, [dispatch, imagesToDelete, imagesUsage]);
const cancelRef = useRef<HTMLButtonElement>(null);
@ -92,7 +107,7 @@ const DeleteImageModal = () => {
<AlertDialogBody>
<Flex direction="column" gap={3}>
<ImageUsageMessage imageUsage={imageUsage} />
<ImageUsageMessage imageUsage={imageUsageSummary} />
<Divider />
<Text>
{canRestoreDeletedImagesFromBin

View File

@ -3,6 +3,6 @@ import { ImageDTO } from 'services/api/types';
import { ImageUsage } from './types';
export const imageDeletionConfirmed = createAction<{
imageDTO: ImageDTO;
imageUsage: ImageUsage;
}>('imageDeletion/imageDeletionConfirmed');
imageDTOs: ImageDTO[];
imagesUsage: ImageUsage[];
}>('deleteImageModal/imageDeletionConfirmed');

View File

@ -0,0 +1,6 @@
import { DeleteImageState } from './types';
export const initialDeleteImageState: DeleteImageState = {
imagesToDelete: [],
isModalOpen: false,
};

View File

@ -39,17 +39,17 @@ export const getImageUsage = (state: RootState, image_name: string) => {
export const selectImageUsage = createSelector(
[(state: RootState) => state],
(state) => {
const { imageToDelete } = state.imageDeletion;
const { imagesToDelete } = state.deleteImageModal;
if (!imageToDelete) {
return;
if (!imagesToDelete.length) {
return [];
}
const { image_name } = imageToDelete;
const imagesUsage = imagesToDelete.map((i) =>
getImageUsage(state, i.image_name)
);
const imageUsage = getImageUsage(state, image_name);
return imageUsage;
return imagesUsage;
},
defaultSelectorOptions
);

View File

@ -0,0 +1,28 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { ImageDTO } from 'services/api/types';
import { initialDeleteImageState } from './initialState';
const deleteImageModal = createSlice({
name: 'deleteImageModal',
initialState: initialDeleteImageState,
reducers: {
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isModalOpen = action.payload;
},
imagesToDeleteSelected: (state, action: PayloadAction<ImageDTO[]>) => {
state.imagesToDelete = action.payload;
},
imageDeletionCanceled: (state) => {
state.imagesToDelete = [];
state.isModalOpen = false;
},
},
});
export const {
isModalOpenChanged,
imagesToDeleteSelected,
imageDeletionCanceled,
} = deleteImageModal.actions;
export default deleteImageModal.reducer;

View File

@ -0,0 +1,13 @@
import { ImageDTO } from 'services/api/types';
export type DeleteImageState = {
imagesToDelete: ImageDTO[];
isModalOpen: boolean;
};
export type ImageUsage = {
isInitialImage: boolean;
isCanvasImage: boolean;
isNodesImage: boolean;
isControlNetImage: boolean;
};

View File

@ -56,7 +56,7 @@ const BoardAutoAddSelect = () => {
return;
}
dispatch(autoAddBoardIdChanged(v === 'none' ? undefined : v));
dispatch(autoAddBoardIdChanged(v));
},
[dispatch]
);

View File

@ -11,10 +11,11 @@ import { BoardDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu';
import GalleryBoardContextMenuItems from './GalleryBoardContextMenuItems';
import NoBoardContextMenuItems from './NoBoardContextMenuItems';
import { BoardId } from 'features/gallery/store/types';
type Props = {
board?: BoardDTO;
board_id?: string;
board_id: BoardId;
children: ContextMenuProps<HTMLDivElement>['children'];
setBoardToDelete?: (board?: BoardDTO) => void;
};

View File

@ -1,43 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { AddToBatchDropData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { useCallback } from 'react';
import { FaLayerGroup } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import GenericBoard from './GenericBoard';
const selector = createSelector(stateSelector, (state) => {
return {
count: state.gallery.batchImageNames.length,
};
});
const BatchBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const { count } = useAppSelector(selector);
const handleBatchBoardClick = useCallback(() => {
dispatch(boardIdSelected('batch'));
}, [dispatch]);
const droppableData: AddToBatchDropData = {
id: 'batch-board',
actionType: 'ADD_TO_BATCH',
};
return (
<GenericBoard
board_id="batch"
droppableData={droppableData}
onClick={handleBatchBoardClick}
isSelected={isSelected}
icon={FaLayerGroup}
label="Batch"
badgeCount={count}
/>
);
};
export default BatchBoard;

View File

@ -15,10 +15,9 @@ import NoBoardBoard from './NoBoardBoard';
const selector = createSelector(
[stateSelector],
({ boards, gallery }) => {
const { searchText } = boards;
const { selectedBoardId } = gallery;
return { selectedBoardId, searchText };
({ gallery }) => {
const { selectedBoardId, boardSearchText } = gallery;
return { selectedBoardId, boardSearchText };
},
defaultSelectorOptions
);
@ -29,11 +28,11 @@ type Props = {
const BoardsList = (props: Props) => {
const { isOpen } = props;
const { selectedBoardId, searchText } = useAppSelector(selector);
const { selectedBoardId, boardSearchText } = useAppSelector(selector);
const { data: boards } = useListAllBoardsQuery();
const filteredBoards = searchText
const filteredBoards = boardSearchText
? boards?.filter((board) =>
board.board_name.toLowerCase().includes(searchText.toLowerCase())
board.board_name.toLowerCase().includes(boardSearchText.toLowerCase())
)
: boards;
const [boardToDelete, setBoardToDelete] = useState<BoardDTO>();
@ -75,7 +74,7 @@ const BoardsList = (props: Props) => {
}}
>
<GridItem sx={{ p: 1.5 }}>
<NoBoardBoard isSelected={selectedBoardId === undefined} />
<NoBoardBoard isSelected={selectedBoardId === 'none'} />
</GridItem>
{filteredBoards &&
filteredBoards.map((board) => (

View File

@ -9,7 +9,7 @@ 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 { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
import {
ChangeEvent,
KeyboardEvent,
@ -21,27 +21,27 @@ import {
const selector = createSelector(
[stateSelector],
({ boards }) => {
const { searchText } = boards;
return { searchText };
({ gallery }) => {
const { boardSearchText } = gallery;
return { boardSearchText };
},
defaultSelectorOptions
);
const BoardsSearch = () => {
const dispatch = useAppDispatch();
const { searchText } = useAppSelector(selector);
const { boardSearchText } = useAppSelector(selector);
const inputRef = useRef<HTMLInputElement>(null);
const handleBoardSearch = useCallback(
(searchTerm: string) => {
dispatch(setBoardSearchText(searchTerm));
dispatch(boardSearchTextChanged(searchTerm));
},
[dispatch]
);
const clearBoardSearch = useCallback(() => {
dispatch(setBoardSearchText(''));
dispatch(boardSearchTextChanged(''));
}, [dispatch]);
const handleKeydown = useCallback(
@ -74,11 +74,11 @@ const BoardsSearch = () => {
<Input
ref={inputRef}
placeholder="Search Boards..."
value={searchText}
value={boardSearchText}
onKeyDown={handleKeydown}
onChange={handleChange}
/>
{searchText && searchText.length && (
{boardSearchText && boardSearchText.length && (
<InputRightElement>
<IconButton
onClick={clearBoardSearch}

View File

@ -7,10 +7,11 @@ import {
Icon,
Image,
Text,
Tooltip,
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import { AddToBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
@ -22,7 +23,11 @@ import {
} from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo, useState } from 'react';
import { FaUser } from 'react-icons/fa';
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
import {
useGetBoardAssetsTotalQuery,
useGetBoardImagesTotalQuery,
useUpdateBoardMutation,
} from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
import AutoAddIcon from '../AutoAddIcon';
@ -67,6 +72,18 @@ const GalleryBoard = memo(
const handleMouseOut = useCallback(() => {
setIsHovered(false);
}, []);
const { data: imagesTotal } = useGetBoardImagesTotalQuery(board.board_id);
const { data: assetsTotal } = useGetBoardAssetsTotalQuery(board.board_id);
const tooltip = useMemo(() => {
if (!imagesTotal || !assetsTotal) {
return undefined;
}
return `${imagesTotal} image${
imagesTotal > 1 ? 's' : ''
}, ${assetsTotal} asset${assetsTotal > 1 ? 's' : ''}`;
}, [assetsTotal, imagesTotal]);
const { currentData: coverImage } = useGetImageDTOQuery(
board.cover_image_name ?? skipToken
);
@ -84,10 +101,10 @@ const GalleryBoard = memo(
const [updateBoard, { isLoading: isUpdateBoardLoading }] =
useUpdateBoardMutation();
const droppableData: MoveBoardDropData = useMemo(
const droppableData: AddToBoardDropData = useMemo(
() => ({
id: board_id,
actionType: 'MOVE_BOARD',
actionType: 'ADD_TO_BOARD',
context: { boardId: board_id },
}),
[board_id]
@ -148,60 +165,61 @@ const GalleryBoard = memo(
setBoardToDelete={setBoardToDelete}
>
{(ref) => (
<Flex
ref={ref}
onClick={handleSelectBoard}
sx={{
w: 'full',
h: 'full',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 'base',
cursor: 'pointer',
bg: 'base.200',
_dark: {
bg: 'base.800',
},
}}
>
{coverImage?.thumbnail_url ? (
<Image
src={coverImage?.thumbnail_url}
draggable={false}
sx={{
objectFit: 'cover',
w: 'full',
h: 'full',
maxH: 'full',
borderRadius: 'base',
borderBottomRadius: 'lg',
}}
/>
) : (
<Flex
sx={{
w: 'full',
h: 'full',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon
boxSize={12}
as={FaUser}
<Tooltip label={tooltip} openDelay={1000} hasArrow>
<Flex
ref={ref}
onClick={handleSelectBoard}
sx={{
w: 'full',
h: 'full',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 'base',
cursor: 'pointer',
bg: 'base.200',
_dark: {
bg: 'base.800',
},
}}
>
{coverImage?.thumbnail_url ? (
<Image
src={coverImage?.thumbnail_url}
draggable={false}
sx={{
mt: -6,
opacity: 0.7,
color: 'base.500',
_dark: {
color: 'base.500',
},
objectFit: 'cover',
w: 'full',
h: 'full',
maxH: 'full',
borderRadius: 'base',
borderBottomRadius: 'lg',
}}
/>
</Flex>
)}
{/* <Flex
) : (
<Flex
sx={{
w: 'full',
h: 'full',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon
boxSize={12}
as={FaUser}
sx={{
mt: -6,
opacity: 0.7,
color: 'base.500',
_dark: {
color: 'base.500',
},
}}
/>
</Flex>
)}
{/* <Flex
sx={{
position: 'absolute',
insetInlineEnd: 0,
@ -213,71 +231,72 @@ const GalleryBoard = memo(
{totalImages}/{totalAssets}
</Badge>
</Flex> */}
{isSelectedForAutoAdd && <AutoAddIcon />}
<SelectionOverlay
isSelected={isSelected}
isHovered={isHovered}
/>
<Flex
sx={{
position: 'absolute',
bottom: 0,
left: 0,
p: 1,
justifyContent: 'center',
alignItems: 'center',
w: 'full',
maxW: 'full',
borderBottomRadius: 'base',
bg: isSelected ? 'accent.400' : 'base.500',
color: isSelected ? 'base.50' : 'base.100',
_dark: {
bg: isSelected ? 'accent.500' : 'base.600',
color: isSelected ? 'base.50' : 'base.100',
},
lineHeight: 'short',
fontSize: 'xs',
}}
>
<Editable
value={localBoardName}
isDisabled={isUpdateBoardLoading}
submitOnBlur={true}
onChange={handleChange}
onSubmit={handleSubmit}
{isSelectedForAutoAdd && <AutoAddIcon />}
<SelectionOverlay
isSelected={isSelected}
isHovered={isHovered}
/>
<Flex
sx={{
position: 'absolute',
bottom: 0,
left: 0,
p: 1,
justifyContent: 'center',
alignItems: 'center',
w: 'full',
maxW: 'full',
borderBottomRadius: 'base',
bg: isSelected ? 'accent.400' : 'base.500',
color: isSelected ? 'base.50' : 'base.100',
_dark: {
bg: isSelected ? 'accent.500' : 'base.600',
color: isSelected ? 'base.50' : 'base.100',
},
lineHeight: 'short',
fontSize: 'xs',
}}
>
<EditablePreview
<Editable
value={localBoardName}
isDisabled={isUpdateBoardLoading}
submitOnBlur={true}
onChange={handleChange}
onSubmit={handleSubmit}
sx={{
p: 0,
fontWeight: isSelected ? 700 : 500,
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
w: 'full',
}}
noOfLines={1}
/>
<EditableInput
sx={{
p: 0,
_focusVisible: {
>
<EditablePreview
sx={{
p: 0,
fontWeight: isSelected ? 700 : 500,
textAlign: 'center',
// get rid of the edit border
boxShadow: 'none',
},
}}
/>
</Editable>
</Flex>
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
noOfLines={1}
/>
<EditableInput
sx={{
p: 0,
_focusVisible: {
p: 0,
textAlign: 'center',
// get rid of the edit border
boxShadow: 'none',
},
}}
/>
</Editable>
</Flex>
<IAIDroppable
data={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
/>
</Flex>
<IAIDroppable
data={droppableData}
dropLabel={<Text fontSize="md">Move</Text>}
/>
</Flex>
</Tooltip>
)}
</BoardContextMenu>
</Flex>

View File

@ -1,6 +1,6 @@
import { Box, Flex, Image, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import { RemoveFromBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
@ -15,6 +15,7 @@ import { memo, useCallback, useMemo, useState } from 'react';
import { useBoardName } from 'services/api/hooks/useBoardName';
import AutoAddIcon from '../AutoAddIcon';
import BoardContextMenu from '../BoardContextMenu';
interface Props {
isSelected: boolean;
}
@ -33,26 +34,27 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
const dispatch = useAppDispatch();
const { autoAddBoardId, autoAssignBoardOnClick, isProcessing } =
useAppSelector(selector);
const boardName = useBoardName(undefined);
const boardName = useBoardName('none');
const handleSelectBoard = useCallback(() => {
dispatch(boardIdSelected(undefined));
dispatch(boardIdSelected('none'));
if (autoAssignBoardOnClick && !isProcessing) {
dispatch(autoAddBoardIdChanged(undefined));
dispatch(autoAddBoardIdChanged('none'));
}
}, [dispatch, autoAssignBoardOnClick, isProcessing]);
const [isHovered, setIsHovered] = useState(false);
const handleMouseOver = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseOut = useCallback(() => {
setIsHovered(false);
}, []);
const droppableData: MoveBoardDropData = useMemo(
const droppableData: RemoveFromBoardDropData = useMemo(
() => ({
id: 'no_board',
actionType: 'MOVE_BOARD',
context: { boardId: undefined },
actionType: 'REMOVE_FROM_BOARD',
}),
[]
);
@ -72,7 +74,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
h: 'full',
}}
>
<BoardContextMenu>
<BoardContextMenu board_id="none">
{(ref) => (
<Flex
ref={ref}
@ -99,17 +101,6 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
alignItems: 'center',
}}
>
{/* <Icon
boxSize={12}
as={FaBucket}
sx={{
opacity: 0.7,
color: 'base.500',
_dark: {
color: 'base.500',
},
}}
/> */}
<Image
src={InvokeAILogoImage}
alt="invoke-ai-logo"
@ -125,19 +116,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
}}
/>
</Flex>
{/* <Flex
sx={{
position: 'absolute',
insetInlineEnd: 0,
top: 0,
p: 1,
}}
>
<Badge variant="solid" sx={BASE_BADGE_STYLES}>
{totalImages}/{totalAssets}
</Badge>
</Flex> */}
{!autoAddBoardId && <AutoAddIcon />}
{autoAddBoardId === 'none' && <AutoAddIcon />}
<Flex
sx={{
position: 'absolute',

View File

@ -11,20 +11,20 @@ import {
} from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { ImageUsage } from 'app/contexts/AddImageToBoardContext';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import ImageUsageMessage from 'features/imageDeletion/components/ImageUsageMessage';
import { getImageUsage } from 'features/imageDeletion/store/imageDeletionSelectors';
import ImageUsageMessage from 'features/deleteImageModal/components/ImageUsageMessage';
import { getImageUsage } from 'features/deleteImageModal/store/selectors';
import { ImageUsage } from 'features/deleteImageModal/store/types';
import { some } from 'lodash-es';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useListAllImageNamesForBoardQuery } from 'services/api/endpoints/boards';
import {
useDeleteBoardAndImagesMutation,
useDeleteBoardMutation,
useListAllImageNamesForBoardQuery,
} from 'services/api/endpoints/boards';
} from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
type Props = {
@ -32,7 +32,7 @@ type Props = {
setBoardToDelete: (board?: BoardDTO) => void;
};
const DeleteImageModal = (props: Props) => {
const DeleteBoardModal = (props: Props) => {
const { boardToDelete, setBoardToDelete } = props;
const { t } = useTranslation();
const canRestoreDeletedImagesFromBin = useAppSelector(
@ -49,13 +49,10 @@ const DeleteImageModal = (props: Props) => {
);
const imageUsageSummary: ImageUsage = {
isInitialImage: some(allImageUsage, (usage) => usage.isInitialImage),
isCanvasImage: some(allImageUsage, (usage) => usage.isCanvasImage),
isNodesImage: some(allImageUsage, (usage) => usage.isNodesImage),
isControlNetImage: some(
allImageUsage,
(usage) => usage.isControlNetImage
),
isInitialImage: some(allImageUsage, (i) => i.isInitialImage),
isCanvasImage: some(allImageUsage, (i) => i.isCanvasImage),
isNodesImage: some(allImageUsage, (i) => i.isNodesImage),
isControlNetImage: some(allImageUsage, (i) => i.isControlNetImage),
};
return { imageUsageSummary };
}),
@ -176,4 +173,4 @@ const DeleteImageModal = (props: Props) => {
);
};
export default memo(DeleteImageModal);
export default memo(DeleteBoardModal);

View File

@ -1,93 +0,0 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Box,
Flex,
Spinner,
Text,
} from '@chakra-ui/react';
import IAIButton from 'common/components/IAIButton';
import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect';
import { memo, useContext, useRef, useState } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext';
const UpdateImageBoardModal = () => {
// const boards = useSelector(selectBoardsAll);
const { data: boards, isFetching } = useListAllBoardsQuery();
const { isOpen, onClose, handleAddToBoard, image } = useContext(
AddImageToBoardContext
);
const [selectedBoard, setSelectedBoard] = useState<string | null>();
const cancelRef = useRef<HTMLButtonElement>(null);
const currentBoard = boards?.find(
(board) => board.board_id === image?.board_id
);
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{currentBoard ? 'Move Image to Board' : 'Add Image to Board'}
</AlertDialogHeader>
<AlertDialogBody>
<Box>
<Flex direction="column" gap={3}>
{currentBoard && (
<Text>
Moving this image from{' '}
<strong>{currentBoard.board_name}</strong> to
</Text>
)}
{isFetching ? (
<Spinner />
) : (
<IAIMantineSearchableSelect
placeholder="Select Board"
onChange={(v) => setSelectedBoard(v)}
value={selectedBoard}
data={(boards ?? []).map((board) => ({
label: board.board_name,
value: board.board_id,
}))}
/>
)}
</Flex>
</Box>
</AlertDialogBody>
<AlertDialogFooter>
<IAIButton onClick={onClose}>Cancel</IAIButton>
<IAIButton
isDisabled={!selectedBoard}
colorScheme="accent"
onClick={() => {
if (selectedBoard) {
handleAddToBoard(selectedBoard);
}
}}
ml={3}
>
{currentBoard ? 'Move' : 'Add'}
</IAIButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};
export default memo(UpdateImageBoardModal);

View File

@ -9,16 +9,14 @@ import {
MenuButton,
MenuList,
} from '@chakra-ui/react';
// import { runESRGAN, runFacetool } from 'app/socketio/actions';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { useAppToaster } from 'app/components/Toaster';
import { upscaleRequested } from 'app/store/middleware/listenerMiddleware/listeners/upscaleRequested';
import { stateSelector } from 'app/store/store';
import { DeleteImageButton } from 'features/imageDeletion/components/DeleteImageButton';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import ParamUpscalePopover from 'features/parameters/components/Parameters/Upscale/ParamUpscaleSettings';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
@ -109,13 +107,13 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
);
const { currentData: imageDTO } = useGetImageDTOQuery(
lastSelectedImage ?? skipToken
lastSelectedImage?.image_name ?? skipToken
);
const { currentData: metadataData } = useGetImageMetadataQuery(
debounceState.isPending()
? skipToken
: debouncedMetadataQueryArg ?? skipToken
: debouncedMetadataQueryArg?.image_name ?? skipToken
);
const metadata = metadataData?.metadata;
@ -173,7 +171,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
if (!imageDTO) {
return;
}
dispatch(imageToDeleteSelected(imageDTO));
dispatch(imagesToDeleteSelected([imageDTO]));
}, [dispatch, imageDTO]);
useHotkeys(

View File

@ -32,7 +32,7 @@ export const imagesSelector = createSelector(
return {
shouldShowImageDetails,
shouldHidePreview,
imageName: lastSelectedImage,
imageName: lastSelectedImage?.image_name,
progressImage,
shouldShowProgressInViewer,
shouldAntialiasProgressImage,
@ -57,8 +57,6 @@ const CurrentImagePreview = () => {
const {
handlePrevImage,
handleNextImage,
prevImageId,
nextImageId,
isOnLastImage,
handleLoadMoreImages,
areMoreImagesAvailable,
@ -70,7 +68,7 @@ const CurrentImagePreview = () => {
() => {
handlePrevImage();
},
[prevImageId]
[handlePrevImage]
);
useHotkeys(
@ -85,11 +83,11 @@ const CurrentImagePreview = () => {
}
},
[
nextImageId,
isOnLastImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleNextImage,
]
);

View File

@ -5,17 +5,19 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
import IAISlider from 'common/components/IAISlider';
import IAISwitch from 'common/components/IAISwitch';
import {
autoAssignBoardOnClickChanged,
setGalleryImageMinimumWidth,
shouldAutoSwitchChanged,
shouldShowDeleteButtonChanged,
} from 'features/gallery/store/gallerySlice';
import { ChangeEvent } from 'react';
import { ChangeEvent, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { FaWrench } from 'react-icons/fa';
import BoardAutoAddSelect from './Boards/BoardAutoAddSelect';
import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
const selector = createSelector(
[stateSelector],
@ -24,12 +26,14 @@ const selector = createSelector(
galleryImageMinimumWidth,
shouldAutoSwitch,
autoAssignBoardOnClick,
shouldShowDeleteButton,
} = state.gallery;
return {
galleryImageMinimumWidth,
shouldAutoSwitch,
autoAssignBoardOnClick,
shouldShowDeleteButton,
};
},
defaultSelectorOptions
@ -39,12 +43,37 @@ const GallerySettingsPopover = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { galleryImageMinimumWidth, shouldAutoSwitch, autoAssignBoardOnClick } =
useAppSelector(selector);
const {
galleryImageMinimumWidth,
shouldAutoSwitch,
autoAssignBoardOnClick,
shouldShowDeleteButton,
} = useAppSelector(selector);
const handleChangeGalleryImageMinimumWidth = (v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
};
const handleChangeGalleryImageMinimumWidth = useCallback(
(v: number) => {
dispatch(setGalleryImageMinimumWidth(v));
},
[dispatch]
);
const handleResetGalleryImageMinimumWidth = useCallback(() => {
dispatch(setGalleryImageMinimumWidth(64));
}, [dispatch]);
const handleChangeAutoSwitch = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(shouldAutoSwitchChanged(e.target.checked));
},
[dispatch]
);
const handleChangeShowDeleteButton = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(shouldShowDeleteButtonChanged(e.target.checked));
},
[dispatch]
);
return (
<IAIPopover
@ -57,7 +86,7 @@ const GallerySettingsPopover = () => {
/>
}
>
<Flex direction="column" gap={4}>
<Flex direction="column" gap={2}>
<IAISlider
value={galleryImageMinimumWidth}
onChange={handleChangeGalleryImageMinimumWidth}
@ -66,14 +95,17 @@ const GallerySettingsPopover = () => {
hideTooltip={true}
label={t('gallery.galleryImageSize')}
withReset
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
handleReset={handleResetGalleryImageMinimumWidth}
/>
<IAISimpleCheckbox
<IAISwitch
label={t('gallery.autoSwitchNewImages')}
isChecked={shouldAutoSwitch}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
dispatch(shouldAutoSwitchChanged(e.target.checked))
}
onChange={handleChangeAutoSwitch}
/>
<IAISwitch
label="Show Delete Button"
isChecked={shouldShowDeleteButton}
onChange={handleChangeShowDeleteButton}
/>
<IAISimpleCheckbox
label={t('gallery.autoAssignBoardOnClick')}

View File

@ -4,28 +4,29 @@ import { MouseEvent, memo, useCallback } from 'react';
import { ImageDTO } from 'services/api/types';
import { menuListMotionProps } from 'theme/components/menu';
import SingleSelectionMenuItems from './SingleSelectionMenuItems';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useAppSelector } from 'app/store/storeHooks';
import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
type Props = {
imageDTO: ImageDTO | undefined;
children: ContextMenuProps<HTMLDivElement>['children'];
};
const selector = createSelector(
[stateSelector],
({ gallery }) => {
const selectionCount = gallery.selection.length;
return { selectionCount };
},
defaultSelectorOptions
);
const ImageContextMenu = ({ imageDTO, children }: Props) => {
// const selector = useMemo(
// () =>
// createSelector(
// [stateSelector],
// ({ gallery }) => {
// const selectionCount = gallery.selection.length;
// return { selectionCount };
// },
// defaultSelectorOptions
// ),
// []
// );
// const { selectionCount } = useAppSelector(selector);
const { selectionCount } = useAppSelector(selector);
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
@ -38,8 +39,24 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
bg: 'transparent',
_hover: { bg: 'transparent' },
}}
renderMenu={() =>
imageDTO ? (
renderMenu={() => {
if (!imageDTO) {
return null;
}
if (selectionCount > 1) {
return (
<MenuList
sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps}
onContextMenu={skipEvent}
>
<MultipleSelectionMenuItems />
</MenuList>
);
}
return (
<MenuList
sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps}
@ -47,8 +64,8 @@ const ImageContextMenu = ({ imageDTO, children }: Props) => {
>
<SingleSelectionMenuItems imageDTO={imageDTO} />
</MenuList>
) : null
}
);
}}
>
{children}
</ContextMenu>

View File

@ -1,30 +1,30 @@
import { MenuItem } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
imagesToChangeSelected,
isModalOpenChanged,
} from 'features/changeBoardModal/store/slice';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { useCallback } from 'react';
import { FaFolder, FaFolderPlus, FaTrash } from 'react-icons/fa';
import { FaFolder, FaTrash } from 'react-icons/fa';
const MultipleSelectionMenuItems = () => {
const handleAddSelectionToBoard = useCallback(() => {
// TODO: add selection to board
}, []);
const dispatch = useAppDispatch();
const selection = useAppSelector((state) => state.gallery.selection);
const handleChangeBoard = useCallback(() => {
dispatch(imagesToChangeSelected(selection));
dispatch(isModalOpenChanged(true));
}, [dispatch, selection]);
const handleDeleteSelection = useCallback(() => {
// TODO: delete all selected images
}, []);
const handleAddSelectionToBatch = useCallback(() => {
// TODO: add selection to batch
}, []);
dispatch(imagesToDeleteSelected(selection));
}, [dispatch, selection]);
return (
<>
<MenuItem icon={<FaFolder />} onClickCapture={handleAddSelectionToBoard}>
Move Selection to Board
</MenuItem>
<MenuItem
icon={<FaFolderPlus />}
onClickCapture={handleAddSelectionToBatch}
>
Add Selection to Batch
<MenuItem icon={<FaFolder />} onClickCapture={handleChangeBoard}>
Change Board
</MenuItem>
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}

View File

@ -1,22 +1,22 @@
import { MenuItem } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { useAppToaster } from 'app/components/Toaster';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useAppDispatch } from 'app/store/storeHooks';
import {
resizeAndScaleCanvas,
setInitialCanvasImage,
} from 'features/canvas/store/canvasSlice';
import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import {
imagesToChangeSelected,
isModalOpenChanged,
} from 'features/changeBoardModal/store/slice';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { useCopyImageToClipboard } from 'features/ui/hooks/useCopyImageToClipboard';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback, useContext, useMemo } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
FaAsterisk,
@ -29,13 +29,9 @@ import {
FaShare,
FaTrash,
} from 'react-icons/fa';
import {
useGetImageMetadataQuery,
useRemoveImageFromBoardMutation,
} from 'services/api/endpoints/images';
import { useGetImageMetadataQuery } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types';
import { useDebounce } from 'use-debounce';
import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
type SingleSelectionMenuItemsProps = {
@ -45,32 +41,12 @@ type SingleSelectionMenuItemsProps = {
const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const { imageDTO } = props;
const selector = useMemo(
() =>
createSelector(
[stateSelector],
({ gallery }) => {
const isInBatch = gallery.batchImageNames.includes(
imageDTO.image_name
);
return { isInBatch };
},
defaultSelectorOptions
),
[imageDTO.image_name]
);
const { isInBatch } = useAppSelector(selector);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const toaster = useAppToaster();
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled;
const { onClickAddToBoard } = useContext(AddImageToBoardContext);
const [debouncedMetadataQueryArg, debounceState] = useDebounce(
imageDTO.image_name,
@ -92,14 +68,12 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
if (!imageDTO) {
return;
}
dispatch(imageToDeleteSelected(imageDTO));
dispatch(imagesToDeleteSelected([imageDTO]));
}, [dispatch, imageDTO]);
const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters();
const [removeFromBoard] = useRemoveImageFromBoardMutation();
// Recall parameters handlers
const handleRecallPrompt = useCallback(() => {
recallBothPrompts(
@ -144,20 +118,10 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
recallAllParameters(metadata);
}, [metadata, recallAllParameters]);
const handleAddToBoard = useCallback(() => {
onClickAddToBoard(imageDTO);
}, [imageDTO, onClickAddToBoard]);
const handleRemoveFromBoard = useCallback(() => {
if (!imageDTO.board_id) {
return;
}
removeFromBoard({ imageDTO });
}, [imageDTO, removeFromBoard]);
const handleAddToBatch = useCallback(() => {
dispatch(imagesAddedToBatch([imageDTO.image_name]));
}, [dispatch, imageDTO.image_name]);
const handleChangeBoard = useCallback(() => {
dispatch(imagesToChangeSelected([imageDTO]));
dispatch(isModalOpenChanged(true));
}, [dispatch, imageDTO]);
const handleCopyImage = useCallback(() => {
copyImageToClipboard(imageDTO.image_url);
@ -229,23 +193,9 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
)}
{isBatchEnabled && (
<MenuItem
icon={<FaFolder />}
isDisabled={isInBatch}
onClickCapture={handleAddToBatch}
>
Add to Batch
</MenuItem>
)}
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
{imageDTO.board_id ? 'Change Board' : 'Add to Board'}
<MenuItem icon={<FaFolder />} onClickCapture={handleChangeBoard}>
Change Board
</MenuItem>
{imageDTO.board_id && (
<MenuItem icon={<FaFolder />} onClickCapture={handleRemoveFromBoard}>
Remove from Board
</MenuItem>
)}
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}

View File

@ -20,16 +20,14 @@ 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';
const selector = createSelector(
[stateSelector],
(state) => {
const { selectedBoardId, galleryView } = state.gallery;
const { galleryView } = state.gallery;
return {
selectedBoardId,
galleryView,
};
},
@ -39,7 +37,7 @@ const selector = createSelector(
const ImageGalleryContent = () => {
const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null);
const { selectedBoardId, galleryView } = useAppSelector(selector);
const { galleryView } = useAppSelector(selector);
const dispatch = useAppDispatch();
const { isOpen: isBoardListOpen, onToggle: onToggleBoardList } =
useDisclosure();
@ -130,12 +128,7 @@ const ImageGalleryContent = () => {
</TabList>
</Tabs>
</Flex>
{selectedBoardId === 'batch' ? (
<BatchImageGrid />
) : (
<GalleryImageGrid />
)}
<GalleryImageGrid />
</Flex>
</VStack>
);

View File

@ -1,122 +0,0 @@
import { Box } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage';
import IAIErrorLoadingImageFallback from 'common/components/IAIErrorLoadingImageFallback';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { imagesRemovedFromBatch } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useMemo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const makeSelector = (image_name: string) =>
createSelector(
[stateSelector],
(state) => ({
selectionCount: state.gallery.selection.length,
selection: state.gallery.selection,
isSelected: state.gallery.selection.includes(image_name),
}),
defaultSelectorOptions
);
type BatchImageProps = {
imageName: string;
};
const BatchImage = (props: BatchImageProps) => {
const dispatch = useAppDispatch();
const { imageName } = props;
const {
currentData: imageDTO,
isLoading,
isError,
} = useGetImageDTOQuery(imageName);
const selector = useMemo(() => makeSelector(imageName), [imageName]);
const { isSelected, selectionCount, selection } = useAppSelector(selector);
const handleClickRemove = useCallback(() => {
dispatch(imagesRemovedFromBatch([imageName]));
}, [dispatch, imageName]);
// const handleClick = useCallback(
// (e: MouseEvent<HTMLDivElement>) => {
// if (e.shiftKey) {
// dispatch(imageRangeEndSelected(imageName));
// } else if (e.ctrlKey || e.metaKey) {
// dispatch(imageSelectionToggled(imageName));
// } else {
// dispatch(imageSelected(imageName));
// }
// },
// [dispatch, imageName]
// );
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (selectionCount > 1) {
return {
id: 'batch',
payloadType: 'IMAGE_NAMES',
payload: { image_names: selection },
};
}
if (imageDTO) {
return {
id: 'batch',
payloadType: 'IMAGE_DTO',
payload: { imageDTO },
};
}
}, [imageDTO, selection, selectionCount]);
if (isLoading) {
return <IAIFillSkeleton />;
}
if (isError || !imageDTO) {
return <IAIErrorLoadingImageFallback />;
}
return (
<Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
<ImageContextMenu imageDTO={imageDTO}>
{(ref) => (
<Box
position="relative"
key={imageName}
userSelect="none"
ref={ref}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
aspectRatio: '1/1',
}}
>
<IAIDndImage
// onClick={handleClick}
imageDTO={imageDTO}
draggableData={draggableData}
isSelected={isSelected}
minSize={0}
onClickReset={handleClickRemove}
isDropDisabled={true}
imageSx={{ w: 'full', h: 'full' }}
isUploadDisabled={true}
resetTooltip="Remove from batch"
withResetIcon
thumbnail
/>
</Box>
)}
</ImageContextMenu>
</Box>
);
};
export default memo(BatchImage);

View File

@ -1,87 +0,0 @@
import { Box } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { VirtuosoGrid } from 'react-virtuoso';
import BatchImage from './BatchImage';
import ItemContainer from './ImageGridItemContainer';
import ListContainer from './ImageGridListContainer';
const selector = createSelector(
[stateSelector],
(state) => {
return {
imageNames: state.gallery.batchImageNames,
};
},
defaultSelectorOptions
);
const BatchImageGrid = () => {
const { t } = useTranslation();
const rootRef = useRef(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
options: {
scrollbars: {
visibility: 'auto',
autoHide: 'leave',
autoHideDelay: 1300,
theme: 'os-theme-dark',
},
overflow: { x: 'hidden' },
},
});
const { imageNames } = useAppSelector(selector);
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
if (imageNames.length) {
return (
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={imageNames}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, imageName) => (
<BatchImage key={imageName} imageName={imageName} />
)}
/>
</Box>
);
}
return (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
);
};
export default memo(BatchImageGrid);

View File

@ -1,27 +1,18 @@
import { Box, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import {
ImageDTOsDraggableData,
ImageDraggableData,
TypesafeDraggableData,
} from 'app/components/ImageDnd/typesafeDnd';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage';
import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { useMultiselect } from 'features/gallery/hooks/useMultiselect.ts';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { FaTrash } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
export const makeSelector = (image_name: string) =>
createSelector(
[stateSelector],
({ gallery }) => ({
isSelected: gallery.selection.includes(image_name),
selectionCount: gallery.selection.length,
selection: gallery.selection,
}),
defaultSelectorOptions
);
interface HoverableImageProps {
imageName: string;
}
@ -30,22 +21,12 @@ const GalleryImage = (props: HoverableImageProps) => {
const dispatch = useAppDispatch();
const { imageName } = props;
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const localSelector = useMemo(() => makeSelector(imageName), [imageName]);
const shouldShowDeleteButton = useAppSelector(
(state) => state.gallery.shouldShowDeleteButton
);
const { isSelected, selectionCount, selection } =
useAppSelector(localSelector);
const handleClick = useCallback(() => {
// disable multiselect for now
// if (e.shiftKey) {
// dispatch(imageRangeEndSelected(imageName));
// } else if (e.ctrlKey || e.metaKey) {
// dispatch(imageSelectionToggled(imageName));
// } else {
// dispatch(imageSelected(imageName));
// }
dispatch(imageSelected(imageName));
}, [dispatch, imageName]);
const { handleClick, isSelected, selection, selectionCount } =
useMultiselect(imageDTO);
const handleDelete = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
@ -53,26 +34,28 @@ const GalleryImage = (props: HoverableImageProps) => {
if (!imageDTO) {
return;
}
dispatch(imageToDeleteSelected(imageDTO));
dispatch(imagesToDeleteSelected([imageDTO]));
},
[dispatch, imageDTO]
);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (selectionCount > 1) {
return {
const data: ImageDTOsDraggableData = {
id: 'gallery-image',
payloadType: 'IMAGE_NAMES',
payload: { image_names: selection },
payloadType: 'IMAGE_DTOS',
payload: { imageDTOs: selection },
};
return data;
}
if (imageDTO) {
return {
const data: ImageDraggableData = {
id: 'gallery-image',
payloadType: 'IMAGE_DTO',
payload: { imageDTO },
};
return data;
}
}, [imageDTO, selection, selectionCount]);
@ -103,9 +86,9 @@ const GalleryImage = (props: HoverableImageProps) => {
isUploadDisabled={true}
thumbnail={true}
withHoverOverlay
// resetIcon={<FaTrash />}
// resetTooltip="Delete image"
// withResetIcon // removed bc it's too easy to accidentally delete images
resetIcon={<FaTrash />}
resetTooltip="Delete image"
withResetIcon={shouldShowDeleteButton} // removed bc it's too easy to accidentally delete images
/>
</Flex>
</Box>

View File

@ -1,6 +1,6 @@
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { useCallback } from 'react';
import { UnsafeImageMetadata } from 'services/api/endpoints/images';
import { UnsafeImageMetadata } from 'services/api/types';
import ImageMetadataItem from './ImageMetadataItem';
type Props = {

View File

@ -0,0 +1,93 @@
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 { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { uniq } from 'lodash-es';
import { MouseEvent, useCallback, useMemo } from 'react';
import { useListImagesQuery } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types';
import { selectionChanged } from '../store/gallerySlice';
import { imagesSelectors } from 'services/api/util';
const selector = createSelector(
[stateSelector, selectListImagesBaseQueryArgs],
({ gallery }, queryArgs) => {
const selection = gallery.selection;
return {
queryArgs,
selection,
};
},
defaultSelectorOptions
);
export const useMultiselect = (imageDTO?: ImageDTO) => {
const dispatch = useAppDispatch();
const { queryArgs, selection } = useAppSelector(selector);
const { imageDTOs } = useListImagesQuery(queryArgs, {
selectFromResult: (result) => ({
imageDTOs: result.data ? imagesSelectors.selectAll(result.data) : [],
}),
});
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (!imageDTO) {
return;
}
if (e.shiftKey) {
const rangeEndImageName = imageDTO.image_name;
const lastSelectedImage = selection[selection.length - 1]?.image_name;
const lastClickedIndex = imageDTOs.findIndex(
(n) => n.image_name === lastSelectedImage
);
const currentClickedIndex = imageDTOs.findIndex(
(n) => n.image_name === rangeEndImageName
);
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// We have a valid range!
const start = Math.min(lastClickedIndex, currentClickedIndex);
const end = Math.max(lastClickedIndex, currentClickedIndex);
const imagesToSelect = imageDTOs.slice(start, end + 1);
dispatch(selectionChanged(uniq(selection.concat(imagesToSelect))));
}
} else if (e.ctrlKey || e.metaKey) {
if (
selection.some((i) => i.image_name === imageDTO.image_name) &&
selection.length > 1
) {
dispatch(
selectionChanged(
selection.filter((n) => n.image_name !== imageDTO.image_name)
)
);
} else {
dispatch(selectionChanged(uniq(selection.concat(imageDTO))));
}
} else {
dispatch(selectionChanged([imageDTO]));
}
},
[dispatch, imageDTO, imageDTOs, selection]
);
const isSelected = useMemo(
() =>
imageDTO
? selection.some((i) => i.image_name === imageDTO.image_name)
: false,
[imageDTO, selection]
);
const selectionCount = useMemo(() => selection.length, [selection.length]);
return {
selection,
selectionCount,
isSelected,
handleClick,
};
};

View File

@ -4,14 +4,15 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es';
import { useCallback } from 'react';
import { boardsApi } from 'services/api/endpoints/boards';
import {
ListImagesArgs,
imagesAdapter,
imagesApi,
useLazyListImagesQuery,
} from 'services/api/endpoints/images';
import { selectListImagesBaseQueryArgs } from '../store/gallerySelectors';
import { IMAGE_LIMIT } from '../store/types';
import { ListImagesArgs } from 'services/api/types';
import { imagesAdapter } from 'services/api/util';
export const nextPrevImageButtonsSelector = createSelector(
[stateSelector, selectListImagesBaseQueryArgs],
@ -19,12 +20,21 @@ export const nextPrevImageButtonsSelector = createSelector(
const { data, status } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const { data: total } =
state.gallery.galleryView === 'images'
? boardsApi.endpoints.getBoardImagesTotal.select(
baseQueryArgs.board_id ?? 'none'
)(state)
: boardsApi.endpoints.getBoardAssetsTotal.select(
baseQueryArgs.board_id ?? 'none'
)(state);
const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1];
const isFetching = status === 'pending';
if (!data || !lastSelectedImage || data.total === 0) {
if (!data || !lastSelectedImage || total === 0) {
return {
isFetching,
queryArgs: baseQueryArgs,
@ -44,30 +54,30 @@ export const nextPrevImageButtonsSelector = createSelector(
const images = selectors.selectAll(data);
const currentImageIndex = images.findIndex(
(i) => i.image_name === lastSelectedImage
(i) => i.image_name === lastSelectedImage.image_name
);
const nextImageIndex = clamp(currentImageIndex + 1, 0, images.length - 1);
const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1);
const nextImageId = images[nextImageIndex]?.image_name;
const prevImageId = images[prevImageIndex]?.image_name;
const nextImage = selectors.selectById(data, nextImageId);
const prevImage = selectors.selectById(data, prevImageId);
const nextImage = nextImageId
? selectors.selectById(data, nextImageId)
: undefined;
const prevImage = prevImageId
? selectors.selectById(data, prevImageId)
: undefined;
const imagesLength = images.length;
return {
isOnFirstImage: currentImageIndex === 0,
isOnLastImage:
!isNaN(currentImageIndex) && currentImageIndex === imagesLength - 1,
areMoreImagesAvailable: (data?.total ?? 0) > imagesLength,
loadedImagesCount: images.length,
currentImageIndex,
areMoreImagesAvailable: (total ?? 0) > imagesLength,
isFetching: status === 'pending',
nextImage,
prevImage,
nextImageId,
prevImageId,
queryArgs,
};
},
@ -82,22 +92,22 @@ export const useNextPrevImage = () => {
const dispatch = useAppDispatch();
const {
isOnFirstImage,
isOnLastImage,
nextImageId,
prevImageId,
nextImage,
prevImage,
areMoreImagesAvailable,
isFetching,
queryArgs,
loadedImagesCount,
currentImageIndex,
} = useAppSelector(nextPrevImageButtonsSelector);
const handlePrevImage = useCallback(() => {
prevImageId && dispatch(imageSelected(prevImageId));
}, [dispatch, prevImageId]);
prevImage && dispatch(imageSelected(prevImage));
}, [dispatch, prevImage]);
const handleNextImage = useCallback(() => {
nextImageId && dispatch(imageSelected(nextImageId));
}, [dispatch, nextImageId]);
nextImage && dispatch(imageSelected(nextImage));
}, [dispatch, nextImage]);
const [listImages] = useLazyListImagesQuery();
@ -108,10 +118,12 @@ export const useNextPrevImage = () => {
return {
handlePrevImage,
handleNextImage,
isOnFirstImage,
isOnLastImage,
nextImageId,
prevImageId,
isOnFirstImage: currentImageIndex === 0,
isOnLastImage:
currentImageIndex !== undefined &&
currentImageIndex === loadedImagesCount - 1,
nextImage,
prevImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,

View File

@ -1,5 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { ImageUsage } from 'app/contexts/AddImageToBoardContext';
import { ImageUsage } from 'features/deleteImageModal/store/types';
import { BoardDTO } from 'services/api/types';
export type RequestedBoardImagesDeletionArg = {

View File

@ -1,29 +0,0 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
type BoardsState = {
searchText: string;
updateBoardModalOpen: boolean;
};
export const initialBoardsState: BoardsState = {
updateBoardModalOpen: false,
searchText: '',
};
const boardsSlice = createSlice({
name: 'boards',
initialState: initialBoardsState,
reducers: {
setBoardSearchText: (state, action: PayloadAction<string>) => {
state.searchText = action.payload;
},
setUpdateBoardModalOpen: (state, action: PayloadAction<boolean>) => {
state.updateBoardModalOpen = action.payload;
},
},
});
export const { setBoardSearchText, setUpdateBoardModalOpen } =
boardsSlice.actions;
export default boardsSlice.reducer;

View File

@ -1,7 +1,7 @@
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 { ListImagesArgs } from 'services/api/types';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
@ -24,7 +24,7 @@ export const selectListImagesBaseQueryArgs = createSelector(
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const listImagesBaseQueryArgs: ListImagesArgs = {
board_id: selectedBoardId ?? 'none',
board_id: selectedBoardId,
categories,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,

View File

@ -1,66 +1,32 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice, isAnyOf } from '@reduxjs/toolkit';
import { uniq } from 'lodash-es';
import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types';
import { BoardId, GalleryState, GalleryView } from './types';
export const initialGalleryState: GalleryState = {
selection: [],
shouldAutoSwitch: true,
autoAddBoardId: undefined,
autoAssignBoardOnClick: true,
autoAddBoardId: 'none',
galleryImageMinimumWidth: 96,
selectedBoardId: undefined,
selectedBoardId: 'none',
galleryView: 'images',
batchImageNames: [],
isBatchEnabled: false,
shouldShowDeleteButton: false,
boardSearchText: '',
};
export const gallerySlice = createSlice({
name: 'gallery',
initialState: initialGalleryState,
reducers: {
imageRangeEndSelected: () => {
// TODO
},
// imageRangeEndSelected: (state, action: PayloadAction<string>) => {
// const rangeEndImageName = action.payload;
// const lastSelectedImage = state.selection[state.selection.length - 1];
// const filteredImages = selectFilteredImagesLocal(state);
// const lastClickedIndex = filteredImages.findIndex(
// (n) => n.image_name === lastSelectedImage
// );
// const currentClickedIndex = filteredImages.findIndex(
// (n) => n.image_name === rangeEndImageName
// );
// if (lastClickedIndex > -1 && currentClickedIndex > -1) {
// // We have a valid range!
// const start = Math.min(lastClickedIndex, currentClickedIndex);
// const end = Math.max(lastClickedIndex, currentClickedIndex);
// const imagesToSelect = filteredImages
// .slice(start, end + 1)
// .map((i) => i.image_name);
// state.selection = uniq(state.selection.concat(imagesToSelect));
// }
// },
imageSelectionToggled: () => {
// TODO
},
// imageSelectionToggled: (state, action: PayloadAction<string>) => {
// TODO: multiselect
// if (
// state.selection.includes(action.payload) &&
// state.selection.length > 1
// ) {
// state.selection = state.selection.filter(
// (imageName) => imageName !== action.payload
// );
// } else {
// state.selection = uniq(state.selection.concat(action.payload));
// }
imageSelected: (state, action: PayloadAction<string | null>) => {
imageSelected: (state, action: PayloadAction<ImageDTO | null>) => {
state.selection = action.payload ? [action.payload] : [];
},
selectionChanged: (state, action: PayloadAction<ImageDTO[]>) => {
state.selection = action.payload;
},
shouldAutoSwitchChanged: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSwitch = action.payload;
},
@ -74,53 +40,28 @@ export const gallerySlice = createSlice({
state.selectedBoardId = action.payload;
state.galleryView = 'images';
},
isBatchEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isBatchEnabled = action.payload;
},
imagesAddedToBatch: (state, action: PayloadAction<string[]>) => {
state.batchImageNames = uniq(
state.batchImageNames.concat(action.payload)
);
},
imagesRemovedFromBatch: (state, action: PayloadAction<string[]>) => {
state.batchImageNames = state.batchImageNames.filter(
(imageName) => !action.payload.includes(imageName)
);
const newSelection = state.selection.filter(
(imageName) => !action.payload.includes(imageName)
);
if (newSelection.length) {
state.selection = newSelection;
return;
}
state.selection = [state.batchImageNames[0]] ?? [];
},
batchReset: (state) => {
state.batchImageNames = [];
state.selection = [];
},
autoAddBoardIdChanged: (
state,
action: PayloadAction<string | undefined>
) => {
autoAddBoardIdChanged: (state, action: PayloadAction<BoardId>) => {
state.autoAddBoardId = action.payload;
},
galleryViewChanged: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload;
},
shouldShowDeleteButtonChanged: (state, action: PayloadAction<boolean>) => {
state.shouldShowDeleteButton = action.payload;
},
boardSearchTextChanged: (state, action: PayloadAction<string>) => {
state.boardSearchText = action.payload;
},
},
extraReducers: (builder) => {
builder.addMatcher(isAnyBoardDeleted, (state, action) => {
const deletedBoardId = action.meta.arg.originalArgs;
if (deletedBoardId === state.selectedBoardId) {
state.selectedBoardId = undefined;
state.selectedBoardId = 'none';
state.galleryView = 'images';
}
if (deletedBoardId === state.autoAddBoardId) {
state.autoAddBoardId = undefined;
state.autoAddBoardId = 'none';
}
});
builder.addMatcher(
@ -132,7 +73,7 @@ export const gallerySlice = createSlice({
}
if (!boards.map((b) => b.board_id).includes(state.autoAddBoardId)) {
state.autoAddBoardId = undefined;
state.autoAddBoardId = 'none';
}
}
);
@ -140,23 +81,21 @@ export const gallerySlice = createSlice({
});
export const {
imageRangeEndSelected,
imageSelectionToggled,
imageSelected,
shouldAutoSwitchChanged,
autoAssignBoardOnClickChanged,
setGalleryImageMinimumWidth,
boardIdSelected,
isBatchEnabledChanged,
imagesAddedToBatch,
imagesRemovedFromBatch,
autoAddBoardIdChanged,
galleryViewChanged,
selectionChanged,
shouldShowDeleteButtonChanged,
boardSearchTextChanged,
} = gallerySlice.actions;
export default gallerySlice.reducer;
const isAnyBoardDeleted = isAnyOf(
boardsApi.endpoints.deleteBoard.matchFulfilled,
boardsApi.endpoints.deleteBoardAndImages.matchFulfilled
imagesApi.endpoints.deleteBoard.matchFulfilled,
imagesApi.endpoints.deleteBoardAndImages.matchFulfilled
);

View File

@ -1,4 +1,4 @@
import { ImageCategory } from 'services/api/types';
import { ImageCategory, ImageDTO } from 'services/api/types';
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
export const ASSETS_CATEGORIES: ImageCategory[] = [
@ -11,17 +11,16 @@ export const INITIAL_IMAGE_LIMIT = 100;
export const IMAGE_LIMIT = 20;
export type GalleryView = 'images' | 'assets';
// export type BoardId = 'no_board' | (string & Record<never, never>);
export type BoardId = string | undefined;
export type BoardId = 'none' | (string & Record<never, never>);
export type GalleryState = {
selection: string[];
selection: ImageDTO[];
shouldAutoSwitch: boolean;
autoAddBoardId: string | undefined;
autoAssignBoardOnClick: boolean;
autoAddBoardId: BoardId;
galleryImageMinimumWidth: number;
selectedBoardId: BoardId;
galleryView: GalleryView;
batchImageNames: string[];
isBatchEnabled: boolean;
shouldShowDeleteButton: boolean;
boardSearchText: string;
};

View File

@ -1,37 +0,0 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { ImageDTO } from 'services/api/types';
type DeleteImageState = {
imageToDelete: ImageDTO | null;
isModalOpen: boolean;
};
export const initialDeleteImageState: DeleteImageState = {
imageToDelete: null,
isModalOpen: false,
};
const imageDeletion = createSlice({
name: 'imageDeletion',
initialState: initialDeleteImageState,
reducers: {
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isModalOpen = action.payload;
},
imageToDeleteSelected: (state, action: PayloadAction<ImageDTO>) => {
state.imageToDelete = action.payload;
},
imageToDeleteCleared: (state) => {
state.imageToDelete = null;
state.isModalOpen = false;
},
},
});
export const {
isModalOpenChanged,
imageToDeleteSelected,
imageToDeleteCleared,
} = imageDeletion.actions;
export default imageDeletion.reducer;

View File

@ -1,6 +0,0 @@
export type ImageUsage = {
isInitialImage: boolean;
isCanvasImage: boolean;
isNodesImage: boolean;
isControlNetImage: boolean;
};

View File

@ -39,11 +39,19 @@ export const loraSlice = createSlice({
action: PayloadAction<{ id: string; weight: number }>
) => {
const { id, weight } = action.payload;
state.loras[id].weight = weight;
const lora = state.loras[id];
if (!lora) {
return;
}
lora.weight = weight;
},
loraWeightReset: (state, action: PayloadAction<string>) => {
const id = action.payload;
state.loras[id].weight = defaultLoRAConfig.weight;
const lora = state.loras[id];
if (!lora) {
return;
}
lora.weight = defaultLoRAConfig.weight;
},
},
});

View File

@ -170,15 +170,17 @@ const NodeSearch = () => {
// }
if (key === 'Enter') {
let selectedNodeType: AnyInvocationType;
let selectedNodeType: AnyInvocationType | undefined;
if (searchText.length > 0) {
selectedNodeType = filteredNodes[focusedIndex].item.type;
selectedNodeType = filteredNodes[focusedIndex]?.item.type;
} else {
selectedNodeType = nodes[focusedIndex].type;
selectedNodeType = nodes[focusedIndex]?.type;
}
addNode(selectedNodeType);
if (selectedNodeType) {
addNode(selectedNodeType);
}
setShowNodeList(false);
}

View File

@ -79,9 +79,12 @@ const nodesSlice = createSlice({
) => {
const { nodeId, fieldName, value } = action.payload;
const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId);
const input = state.nodes?.[nodeIndex]?.data?.inputs[fieldName];
if (!input) {
return;
}
if (nodeIndex > -1) {
state.nodes[nodeIndex].data.inputs[fieldName].value = value;
input.value = value;
}
},
imageCollectionFieldValueChanged: (
@ -99,16 +102,19 @@ const nodesSlice = createSlice({
return;
}
const currentValue = cloneDeep(
state.nodes[nodeIndex].data.inputs[fieldName].value
);
if (!currentValue) {
state.nodes[nodeIndex].data.inputs[fieldName].value = value;
const input = state.nodes?.[nodeIndex]?.data?.inputs[fieldName];
if (!input) {
return;
}
state.nodes[nodeIndex].data.inputs[fieldName].value = uniqBy(
const currentValue = cloneDeep(input.value);
if (!currentValue) {
input.value = value;
return;
}
input.value = uniqBy(
(currentValue as ImageField[]).concat(value),
'image_name'
);

View File

@ -29,6 +29,8 @@ import {
VaeInputFieldTemplate,
VaeModelInputFieldTemplate,
} from '../types/types';
import { logger } from 'app/logging/logger';
import { parseify } from 'common/util/serialize';
export type BaseFieldProperties = 'name' | 'title' | 'description';
@ -50,7 +52,13 @@ export type BuildInputFieldArg = {
*/
export const refObjectToFieldType = (
refObject: OpenAPIV3.ReferenceObject
): keyof typeof FIELD_TYPE_MAP => refObject.$ref.split('/').slice(-1)[0];
): keyof typeof FIELD_TYPE_MAP => {
const name = refObject.$ref.split('/').slice(-1)[0];
if (!name) {
return 'UNKNOWN FIELD TYPE';
}
return name;
};
const buildIntegerInputFieldTemplate = ({
schemaObject,
@ -428,7 +436,7 @@ export const getFieldType = (
let rawFieldType = '';
if (typeHints && name in typeHints) {
rawFieldType = typeHints[name];
rawFieldType = typeHints[name] ?? 'UNKNOWN FIELD TYPE';
} else if (!schemaObject.type) {
// if schemaObject has no type, then it should have one of allOf, anyOf, oneOf
if (schemaObject.allOf) {
@ -568,10 +576,23 @@ export const buildOutputFieldTemplates = (
// extract output schema name from ref
const outputSchemaName = refObject.$ref.split('/').slice(-1)[0];
if (!outputSchemaName) {
logger('nodes').error(
{ refObject: parseify(refObject) },
'No output schema name found in ref object'
);
throw 'No output schema name found in ref object';
}
// get the output schema itself
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const outputSchema = openAPI.components!.schemas![outputSchemaName];
if (!outputSchema) {
logger('nodes').error({ outputSchemaName }, 'Output schema not found');
throw 'Output schema not found';
}
if (isSchemaObject(outputSchema)) {
const outputFields = reduce(
outputSchema.properties as OpenAPIV3.SchemaObject,

View File

@ -16,7 +16,10 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { map } from 'lodash-es';
import { Fragment, memo, useCallback } from 'react';
import { FaPlus } from 'react-icons/fa';
import { useGetControlNetModelsQuery } from 'services/api/endpoints/models';
import {
controlNetModelsAdapter,
useGetControlNetModelsQuery,
} from 'services/api/endpoints/models';
import { v4 as uuidv4 } from 'uuid';
const selector = createSelector(
@ -42,7 +45,9 @@ const ParamControlNetCollapse = () => {
const dispatch = useAppDispatch();
const { firstModel } = useGetControlNetModelsQuery(undefined, {
selectFromResult: (result) => {
const firstModel = result.data?.entities[result.data?.ids[0]];
const firstModel = result.data
? controlNetModelsAdapter.getSelectors().selectAll(result.data)[0]
: undefined;
return {
firstModel,
};
@ -95,7 +100,7 @@ const ParamControlNetCollapse = () => {
{controlNetsArray.map((c, i) => (
<Fragment key={c.controlNetId}>
{i > 0 && <Divider />}
<ControlNet controlNetId={c.controlNetId} />
<ControlNet controlNet={c} />
</Fragment>
))}
</Flex>

View File

@ -12,7 +12,7 @@ import {
} from 'features/sdxl/store/sdxlSlice';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { UnsafeImageMetadata } from 'services/api/endpoints/images';
import { UnsafeImageMetadata } from 'services/api/types';
import { ImageDTO } from 'services/api/types';
import { initialImageSelected, modelSelected } from '../store/actions';
import {

View File

@ -28,9 +28,7 @@ export default function AdvancedAddCheckpoint(
const advancedAddCheckpointForm = useForm<CheckpointModelConfig>({
initialValues: {
model_name: model_path
? model_path.split('\\').splice(-1)[0].split('.')[0]
: '',
model_name: model_path?.split('\\').splice(-1)[0]?.split('.')[0] ?? '',
base_model: 'sd-1',
model_type: 'main',
path: model_path ? model_path : '',

View File

@ -25,7 +25,7 @@ export default function AdvancedAddDiffusers(props: AdvancedAddDiffusersProps) {
const advancedAddDiffusersForm = useForm<DiffusersModelConfig>({
initialValues: {
model_name: model_path ? model_path.split('\\').splice(-1)[0] : '',
model_name: model_path?.split('\\').splice(-1)[0] ?? '',
base_model: 'sd-1',
model_type: 'main',
path: model_path ? model_path : '',

View File

@ -59,10 +59,10 @@ export default function MergeModelsPanel() {
}, [sd1DiffusersModels, sd2DiffusersModels]);
const [modelOne, setModelOne] = useState<string | null>(
Object.keys(modelsMap[baseModel as keyof typeof modelsMap])[0]
Object.keys(modelsMap[baseModel as keyof typeof modelsMap])?.[0] ?? null
);
const [modelTwo, setModelTwo] = useState<string | null>(
Object.keys(modelsMap[baseModel as keyof typeof modelsMap])[1]
Object.keys(modelsMap[baseModel as keyof typeof modelsMap])?.[1] ?? null
);
const [modelThree, setModelThree] = useState<string | null>(null);
@ -106,8 +106,9 @@ export default function MergeModelsPanel() {
let modelsToMerge: (string | null)[] = [modelOne, modelTwo, modelThree];
modelsToMerge = modelsToMerge.filter((model) => model !== null);
modelsToMerge.forEach((model) => {
if (model) {
models_names.push(model?.split('/')[2]);
const n = model?.split('/')?.[2];
if (n) {
models_names.push(n);
}
});

View File

@ -47,13 +47,11 @@ export default function ModelConvert(props: ModelConvertProps) {
};
const modelConvertHandler = () => {
const responseBody = {
const queryArg = {
base_model: model.base_model,
model_name: model.model_name,
params: {
convert_dest_directory:
saveLocation === 'Custom' ? customSaveLocation : undefined,
},
convert_dest_directory:
saveLocation === 'Custom' ? customSaveLocation : undefined,
};
if (saveLocation === 'Custom' && customSaveLocation === '') {
@ -74,14 +72,14 @@ export default function ModelConvert(props: ModelConvertProps) {
title: `${t('modelManager.convertingModelBegin')}: ${
model.model_name
}`,
status: 'success',
status: 'info',
})
)
);
convertModel(responseBody)
convertModel(queryArg)
.unwrap()
.then((_) => {
.then(() => {
dispatch(
addToast(
makeToast({
@ -91,7 +89,7 @@ export default function ModelConvert(props: ModelConvertProps) {
)
);
})
.catch((_) => {
.catch(() => {
dispatch(
addToast(
makeToast({

View File

@ -2,12 +2,12 @@ import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { isEqual } from 'lodash-es';
import { tabMap } from './tabMap';
import { InvokeTabName, tabMap } from './tabMap';
import { UIState } from './uiTypes';
export const activeTabNameSelector = createSelector(
(state: RootState) => state.ui,
(ui: UIState) => tabMap[ui.activeTab],
(ui: UIState) => tabMap[ui.activeTab] as InvokeTabName,
{
memoizeOptions: {
equalityCheck: isEqual,

View File

@ -1,36 +0,0 @@
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;
// },
// }),
}),
});
// export const { useListBoardImagesQuery } = boardImagesApi;

View File

@ -1,28 +1,16 @@
import { Update } from '@reduxjs/toolkit';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
} from 'features/gallery/store/types';
import {
BoardDTO,
ImageDTO,
ListBoardsArg,
OffsetPaginatedResults_BoardDTO_,
OffsetPaginatedResults_ImageDTO_,
UpdateBoardArg,
} from 'services/api/types';
import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema';
import { getListImagesUrl, imagesAdapter, imagesApi } from './images';
type ListBoardsArg = NonNullable<
paths['/api/v1/boards/']['get']['parameters']['query']
>;
type UpdateBoardArg =
paths['/api/v1/boards/{board_id}']['patch']['parameters']['path'] & {
changes: paths['/api/v1/boards/{board_id}']['patch']['requestBody']['content']['application/json'];
};
type DeleteBoardResult =
paths['/api/v1/boards/{board_id}']['delete']['responses']['200']['content']['application/json'];
import { getListImagesUrl } from '../util';
export const boardsApi = api.injectEndpoints({
endpoints: (build) => ({
@ -82,6 +70,44 @@ export const boardsApi = api.injectEndpoints({
keepUnusedDataFor: 0,
}),
getBoardImagesTotal: build.query<number, string | undefined>({
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<number, string | undefined>({
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;
},
}),
/**
* Boards Mutations
*/
@ -105,176 +131,15 @@ export const boardsApi = api.injectEndpoints({
{ type: 'Board', id: arg.board_id },
],
}),
deleteBoard: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }),
invalidatesTags: (result, error, board_id) => [
{ type: 'Board', id: LIST_TAG },
// invalidate the 'No Board' cache
{
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 }) {
/**
* Cache changes for deleteBoard:
* - Update every image in the 'getImageDTO' cache that has the board_id
* - Update every image in the 'All Images' cache that has the board_id
* - Update every image in the 'All Assets' cache that has the board_id
* - Invalidate the 'No Board' cache:
* Ideally we'd be able to insert all deleted images into the cache, but we don't
* have access to the deleted images DTOs - only the names, and a network request
* for all of a board's DTOs could be very large. Instead, we invalidate the 'No Board'
* cache.
*/
try {
const { data } = await queryFulfilled;
const { deleted_board_images } = data;
// update getImageDTO caches
deleted_board_images.forEach((image_id) => {
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_id,
(draft) => {
draft.board_id = undefined;
}
)
);
});
// update 'All Images' & 'All Assets' caches
const queryArgsToUpdate = [
{
categories: IMAGE_CATEGORIES,
},
{
categories: ASSETS_CATEGORIES,
},
];
const updates: Update<ImageDTO>[] = deleted_board_images.map(
(image_name) => ({
id: image_name,
changes: { board_id: undefined },
})
);
queryArgsToUpdate.forEach((queryArgs) => {
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.updateMany(draft, updates);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
}
)
);
});
} catch {
//no-op
}
},
}),
deleteBoardAndImages: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({
url: `boards/${board_id}`,
method: 'DELETE',
params: { include_images: true },
}),
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 }) {
/**
* Cache changes for deleteBoardAndImages:
* - ~~Remove every image in the 'getImageDTO' cache that has the board_id~~
* This isn't actually possible, you cannot remove cache entries with RTK Query.
* Instead, we rely on the UI to remove all components that use the deleted images.
* - Remove every image in the 'All Images' cache that has the board_id
* - Remove every image in the 'All Assets' cache that has the board_id
*/
try {
const { data } = await queryFulfilled;
const { deleted_images } = data;
// update 'All Images' & 'All Assets' caches
const queryArgsToUpdate = [
{
categories: IMAGE_CATEGORIES,
},
{
categories: ASSETS_CATEGORIES,
},
];
queryArgsToUpdate.forEach((queryArgs) => {
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.removeMany(
draft,
deleted_images
);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
}
)
);
});
} catch {
//no-op
}
},
}),
}),
});
export const {
useListBoardsQuery,
useListAllBoardsQuery,
useGetBoardImagesTotalQuery,
useGetBoardAssetsTotalQuery,
useCreateBoardMutation,
useUpdateBoardMutation,
useDeleteBoardMutation,
useDeleteBoardAndImagesMutation,
useListAllImageNamesForBoardQuery,
} = boardsApi;

View File

@ -1,93 +1,37 @@
import { EntityState, createEntityAdapter } from '@reduxjs/toolkit';
import { EntityState, Update } from '@reduxjs/toolkit';
import { PatchCollection } from '@reduxjs/toolkit/dist/query/core/buildThunks';
import { dateComparator } from 'common/util/dateComparator';
import {
ASSETS_CATEGORIES,
BoardId,
IMAGE_CATEGORIES,
} from 'features/gallery/store/types';
import queryString from 'query-string';
import { ApiFullTagDescription, api } from '..';
import { components, paths } from '../schema';
import { keyBy } from 'lodash';
import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { components } from '../schema';
import {
DeleteBoardResult,
ImageCategory,
ImageDTO,
ListImagesArgs,
OffsetPaginatedResults_ImageDTO_,
PostUploadAction,
UnsafeImageMetadata,
} from '../types';
const getIsImageInDateRange = (
data: ImageCache | undefined,
imageDTO: ImageDTO
) => {
if (!data) {
return false;
}
const cacheImageDTOS = imagesSelectors.selectAll(data);
if (cacheImageDTOS.length > 1) {
// Images are sorted by `created_at` DESC
// check if the image is newer than the oldest image in the cache
const createdDate = new Date(imageDTO.created_at);
const oldestDate = new Date(
cacheImageDTOS[cacheImageDTOS.length - 1].created_at
);
return createdDate >= oldestDate;
} else if ([0, 1].includes(cacheImageDTOS.length)) {
// if there are only 1 or 0 images in the cache, we consider the image to be in the date range
return true;
}
return false;
};
const getCategories = (imageDTO: ImageDTO) => {
if (IMAGE_CATEGORIES.includes(imageDTO.image_category)) {
return IMAGE_CATEGORIES;
}
return ASSETS_CATEGORIES;
};
export type ListImagesArgs = NonNullable<
paths['/api/v1/images/']['get']['parameters']['query']
>;
/**
* This is an unsafe type; the object inside is not guaranteed to be valid.
*/
export type UnsafeImageMetadata = {
metadata: components['schemas']['CoreMetadata'];
graph: NonNullable<components['schemas']['Graph']>;
};
export type ImageCache = EntityState<ImageDTO> & { total: number };
// The adapter is not actually the data store - it just provides helper functions to interact
// with some other store of data. We will use the RTK Query cache as that store.
export const imagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name,
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
});
// We want to also store the images total in the cache. When we initialize the cache state,
// we will provide this type arg so the adapter knows we want the total.
export type AdditionalImagesAdapterState = { total: number };
// Create selectors for the adapter.
export const imagesSelectors = imagesAdapter.getSelectors();
// Helper to create the url for the listImages endpoint. Also we use it to create the cache key.
export const getListImagesUrl = (queryArgs: ListImagesArgs) =>
`images/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`;
import {
getCategories,
getIsImageInDateRange,
getListImagesUrl,
imagesAdapter,
imagesSelectors,
} from '../util';
import { boardsApi } from './boards';
export const imagesApi = api.injectEndpoints({
endpoints: (build) => ({
/**
* Image Queries
*/
listImages: build.query<
EntityState<ImageDTO> & { total: number },
ListImagesArgs
>({
listImages: build.query<EntityState<ImageDTO>, ListImagesArgs>({
query: (queryArgs) => ({
// Use the helper to create the URL.
url: getListImagesUrl(queryArgs),
@ -110,23 +54,17 @@ export const imagesApi = api.injectEndpoints({
return cacheKey;
},
transformResponse(response: OffsetPaginatedResults_ImageDTO_) {
const { total, items: images } = response;
// Use the adapter to convert the response to the right shape, and adding the new total.
const { items: images } = response;
// Use the adapter to convert the response to the right shape.
// The trick is to just provide an empty state and add the images array to it. This returns
// a properly shaped EntityState.
return imagesAdapter.addMany(
imagesAdapter.getInitialState<AdditionalImagesAdapterState>({
total,
}),
images
);
return imagesAdapter.addMany(imagesAdapter.getInitialState(), images);
},
merge: (cache, response) => {
// Here we actually update the cache. `response` here is the output of `transformResponse`
// above. In a similar vein to `transformResponse`, we can use the imagesAdapter to get
// things in the right shape. Also update the total image count.
// things in the right shape.
imagesAdapter.addMany(cache, imagesSelectors.selectAll(response));
cache.total = response.total;
},
forceRefetch({ currentArg, previousArg }) {
// Refetch when the offset changes (which means we are on a new page).
@ -161,69 +99,26 @@ export const imagesApi = api.injectEndpoints({
},
}),
getImageDTO: build.query<ImageDTO, string>({
query: (image_name) => ({ url: `images/${image_name}` }),
providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [{ type: 'Image', id: arg }];
if (result?.board_id) {
tags.push({ type: 'Board', id: result.board_id });
}
return tags;
},
query: (image_name) => ({ url: `images/i/${image_name}` }),
providesTags: (result, error, image_name) => [
{ type: 'Image', id: image_name },
],
keepUnusedDataFor: 86400, // 24 hours
}),
getImageMetadata: build.query<UnsafeImageMetadata, string>({
query: (image_name) => ({ url: `images/${image_name}/metadata` }),
providesTags: (result, error, arg) => {
const tags: ApiFullTagDescription[] = [
{ type: 'ImageMetadata', id: arg },
];
return tags;
},
query: (image_name) => ({ url: `images/i/${image_name}/metadata` }),
providesTags: (result, error, image_name) => [
{ type: 'ImageMetadata', id: image_name },
],
keepUnusedDataFor: 86400, // 24 hours
}),
getBoardImagesTotal: build.query<number, string | undefined>({
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<number, string | undefined>({
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<number, void>({
query: () => ({ url: `images/clear-intermediates`, method: 'POST' }),
invalidatesTags: ['IntermediatesCount'],
}),
deleteImage: build.mutation<void, ImageDTO>({
query: ({ image_name }) => ({
url: `images/${image_name}`,
url: `images/i/${image_name}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, { board_id }) => [
@ -240,33 +135,77 @@ export const imagesApi = api.injectEndpoints({
const { image_name, board_id } = imageDTO;
// Store patches so we can undo if the query fails
const patches: PatchCollection[] = [];
const queryArg = {
board_id: board_id ?? 'none',
categories: getCategories(imageDTO),
};
// determine `categories`, i.e. do we update "All Images" or "All Assets"
// $cache = [board_id|no_board]/[images|assets]
const categories = getCategories(imageDTO);
// *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;
}
)
)
const patch = dispatch(
imagesApi.util.updateQueryData('listImages', queryArg, (draft) => {
imagesAdapter.removeOne(draft, image_name);
})
);
try {
await queryFulfilled;
} catch {
patches.forEach((patchResult) => patchResult.undo());
patch.undo();
}
},
}),
deleteImages: build.mutation<
components['schemas']['DeleteImagesFromListResult'],
{ imageDTOs: ImageDTO[] }
>({
query: ({ imageDTOs }) => {
const image_names = imageDTOs.map((imageDTO) => imageDTO.image_name);
return {
url: `images/delete`,
method: 'POST',
body: {
image_names,
},
};
},
invalidatesTags: (result, error, imageDTOs) => [],
async onQueryStarted({ imageDTOs }, { dispatch, queryFulfilled }) {
/**
* Cache changes for `deleteImages`:
* - *remove* the deleted images from their boards
*
* Unfortunately we cannot do an optimistic update here due to how immer handles patching
* arrays. You have to undo *all* patches, else the entity adapter's `ids` array is borked.
* So we have to wait for the query to complete before updating the cache.
*/
try {
const { data } = await queryFulfilled;
// convert to an object so we can access the successfully delete image DTOs by name
const groupedImageDTOs = keyBy(imageDTOs, 'image_name');
data.deleted_images.forEach((image_name) => {
const imageDTO = groupedImageDTOs[image_name];
// should never be undefined
if (imageDTO) {
const queryArg = {
board_id: imageDTO.board_id ?? 'none',
categories: getCategories(imageDTO),
};
// remove all deleted images from their boards
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArg,
(draft) => {
imagesAdapter.removeOne(draft, image_name);
}
)
);
}
});
} catch {
//
}
},
}),
@ -278,7 +217,7 @@ export const imagesApi = api.injectEndpoints({
{ imageDTO: ImageDTO; is_intermediate: boolean }
>({
query: ({ imageDTO, is_intermediate }) => ({
url: `images/${imageDTO.image_name}`,
url: `images/i/${imageDTO.image_name}`,
method: 'PATCH',
body: { is_intermediate },
}),
@ -329,20 +268,13 @@ export const imagesApi = api.injectEndpoints({
'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;
imagesAdapter.removeOne(draft, imageDTO.image_name);
}
)
)
);
} else {
// ELSE (it is being changed to a non-intermediate):
console.log(imageDTO);
const queryArgs = {
board_id: imageDTO.board_id ?? 'none',
categories,
@ -352,6 +284,16 @@ export const imagesApi = api.injectEndpoints({
getState()
);
const { data: total } = IMAGE_CATEGORIES.includes(
imageDTO.image_category
)
? boardsApi.endpoints.getBoardImagesTotal.select(
imageDTO.board_id ?? 'none'
)(getState())
: boardsApi.endpoints.getBoardAssetsTotal.select(
imageDTO.board_id ?? 'none'
)(getState());
// IF it eligible for insertion into existing $cache
// "eligible" means either:
// - The cache is fully populated, with all images in the db cached
@ -359,8 +301,7 @@ export const imagesApi = api.injectEndpoints({
// - The image's `created_at` is within the range of the cached images
const isCacheFullyPopulated =
currentCache.data &&
currentCache.data.ids.length >= currentCache.data.total;
currentCache.data && currentCache.data.ids.length >= (total ?? 0);
const isInDateRange = getIsImageInDateRange(
currentCache.data,
@ -375,10 +316,7 @@ export const imagesApi = api.injectEndpoints({
'listImages',
queryArgs,
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.upsertOne(draft, imageDTO);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
imagesAdapter.upsertOne(draft, imageDTO);
}
)
)
@ -401,7 +339,7 @@ export const imagesApi = api.injectEndpoints({
{ imageDTO: ImageDTO; session_id: string }
>({
query: ({ imageDTO, session_id }) => ({
url: `images/${imageDTO.image_name}`,
url: `images/i/${imageDTO.image_name}`,
method: 'PATCH',
body: { session_id },
}),
@ -464,14 +402,14 @@ export const imagesApi = api.injectEndpoints({
const formData = new FormData();
formData.append('file', file);
return {
url: `images/`,
url: `images/upload`,
method: 'POST',
body: formData,
params: {
image_category,
is_intermediate,
session_id,
board_id,
board_id: board_id === 'none' ? undefined : board_id,
crop_visible,
},
};
@ -524,10 +462,7 @@ export const imagesApi = api.injectEndpoints({
categories,
},
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.addOne(draft, imageDTO);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
imagesAdapter.addOne(draft, imageDTO);
}
)
);
@ -543,6 +478,158 @@ export const imagesApi = api.injectEndpoints({
}
},
}),
deleteBoard: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({ url: `boards/${board_id}`, method: 'DELETE' }),
invalidatesTags: (result, error, board_id) => [
{ type: 'Board', id: LIST_TAG },
// invalidate the 'No Board' cache
{
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 }) {
/**
* Cache changes for deleteBoard:
* - Update every image in the 'getImageDTO' cache that has the board_id
* - Update every image in the 'All Images' cache that has the board_id
* - Update every image in the 'All Assets' cache that has the board_id
* - Invalidate the 'No Board' cache:
* Ideally we'd be able to insert all deleted images into the cache, but we don't
* have access to the deleted images DTOs - only the names, and a network request
* for all of a board's DTOs could be very large. Instead, we invalidate the 'No Board'
* cache.
*/
try {
const { data } = await queryFulfilled;
const { deleted_board_images } = data;
// update getImageDTO caches
deleted_board_images.forEach((image_id) => {
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_id,
(draft) => {
draft.board_id = undefined;
}
)
);
});
// update 'All Images' & 'All Assets' caches
const queryArgsToUpdate = [
{
categories: IMAGE_CATEGORIES,
},
{
categories: ASSETS_CATEGORIES,
},
];
const updates: Update<ImageDTO>[] = deleted_board_images.map(
(image_name) => ({
id: image_name,
changes: { board_id: undefined },
})
);
queryArgsToUpdate.forEach((queryArgs) => {
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
imagesAdapter.updateMany(draft, updates);
}
)
);
});
} catch {
//no-op
}
},
}),
deleteBoardAndImages: build.mutation<DeleteBoardResult, string>({
query: (board_id) => ({
url: `boards/${board_id}`,
method: 'DELETE',
params: { include_images: true },
}),
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 }) {
/**
* Cache changes for deleteBoardAndImages:
* - ~~Remove every image in the 'getImageDTO' cache that has the board_id~~
* This isn't actually possible, you cannot remove cache entries with RTK Query.
* Instead, we rely on the UI to remove all components that use the deleted images.
* - Remove every image in the 'All Images' cache that has the board_id
* - Remove every image in the 'All Assets' cache that has the board_id
*/
try {
const { data } = await queryFulfilled;
const { deleted_images } = data;
// update 'All Images' & 'All Assets' caches
const queryArgsToUpdate = [
{
categories: IMAGE_CATEGORIES,
},
{
categories: ASSETS_CATEGORIES,
},
];
queryArgsToUpdate.forEach((queryArgs) => {
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
imagesAdapter.removeMany(draft, deleted_images);
}
)
);
});
} catch {
//no-op
}
},
}),
addImageToBoard: build.mutation<
void,
{ board_id: BoardId; imageDTO: ImageDTO }
@ -556,10 +643,13 @@ export const imagesApi = api.injectEndpoints({
};
},
invalidatesTags: (result, error, { board_id, imageDTO }) => [
// refresh the board itself
{ type: 'Board', id: board_id },
// update old board totals
{ type: 'BoardImagesTotal', id: board_id },
{ type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' },
{ type: 'BoardAssetsTotal', id: board_id },
// update new board totals
{ type: 'BoardImagesTotal', id: imageDTO.board_id ?? 'none' },
{ type: 'BoardAssetsTotal', id: imageDTO.board_id ?? 'none' },
],
async onQueryStarted(
@ -589,7 +679,7 @@ export const imagesApi = api.injectEndpoints({
'getImageDTO',
imageDTO.image_name,
(draft) => {
Object.assign(draft, { board_id });
draft.board_id = board_id;
}
)
)
@ -606,13 +696,7 @@ export const imagesApi = api.injectEndpoints({
categories,
},
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.removeOne(
draft,
imageDTO.image_name
);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
imagesAdapter.removeOne(draft, imageDTO.image_name);
}
)
)
@ -630,9 +714,18 @@ export const imagesApi = api.injectEndpoints({
// OR
// - The image's `created_at` is within the range of the cached images
const { data: total } = IMAGE_CATEGORIES.includes(
imageDTO.image_category
)
? boardsApi.endpoints.getBoardImagesTotal.select(
imageDTO.board_id ?? 'none'
)(getState())
: boardsApi.endpoints.getBoardAssetsTotal.select(
imageDTO.board_id ?? 'none'
)(getState());
const isCacheFullyPopulated =
currentCache.data &&
currentCache.data.ids.length >= currentCache.data.total;
currentCache.data && currentCache.data.ids.length >= (total ?? 0);
const isInDateRange = getIsImageInDateRange(
currentCache.data,
@ -647,10 +740,7 @@ export const imagesApi = api.injectEndpoints({
'listImages',
queryArgs,
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.addOne(draft, imageDTO);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
imagesAdapter.addOne(draft, imageDTO);
}
)
)
@ -667,20 +757,26 @@ export const imagesApi = api.injectEndpoints({
}),
removeImageFromBoard: build.mutation<void, { imageDTO: ImageDTO }>({
query: ({ imageDTO }) => {
const { board_id, image_name } = imageDTO;
const { image_name } = imageDTO;
return {
url: `board_images/`,
method: 'DELETE',
body: { board_id, image_name },
body: { image_name },
};
},
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' },
],
invalidatesTags: (result, error, { imageDTO }) => {
const { board_id } = imageDTO;
return [
// invalidate the image's old board
{ type: 'Board', id: board_id ?? 'none' },
// update old board totals
{ type: 'BoardImagesTotal', id: board_id ?? 'none' },
{ type: 'BoardAssetsTotal', id: board_id ?? 'none' },
// update the no_board totals
{ type: 'BoardImagesTotal', id: 'none' },
{ type: 'BoardAssetsTotal', id: 'none' },
];
},
async onQueryStarted(
{ imageDTO },
{ dispatch, queryFulfilled, getState }
@ -704,7 +800,7 @@ export const imagesApi = api.injectEndpoints({
'getImageDTO',
imageDTO.image_name,
(draft) => {
Object.assign(draft, { board_id: undefined });
draft.board_id = undefined;
}
)
)
@ -720,13 +816,7 @@ export const imagesApi = api.injectEndpoints({
categories,
},
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.removeOne(
draft,
imageDTO.image_name
);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
imagesAdapter.removeOne(draft, imageDTO.image_name);
}
)
)
@ -744,9 +834,18 @@ export const imagesApi = api.injectEndpoints({
// OR
// - The image's `created_at` is within the range of the cached images
const { data: total } = IMAGE_CATEGORIES.includes(
imageDTO.image_category
)
? boardsApi.endpoints.getBoardImagesTotal.select(
imageDTO.board_id ?? 'none'
)(getState())
: boardsApi.endpoints.getBoardAssetsTotal.select(
imageDTO.board_id ?? 'none'
)(getState());
const isCacheFullyPopulated =
currentCache.data &&
currentCache.data.ids.length >= currentCache.data.total;
currentCache.data && currentCache.data.ids.length >= (total ?? 0);
const isInDateRange = getIsImageInDateRange(
currentCache.data,
@ -761,10 +860,7 @@ export const imagesApi = api.injectEndpoints({
'listImages',
queryArgs,
(draft) => {
const oldTotal = draft.total;
const newState = imagesAdapter.upsertOne(draft, imageDTO);
const delta = newState.total - oldTotal;
draft.total = draft.total + delta;
imagesAdapter.upsertOne(draft, imageDTO);
}
)
)
@ -778,6 +874,255 @@ export const imagesApi = api.injectEndpoints({
}
},
}),
addImagesToBoard: build.mutation<
components['schemas']['AddImagesToBoardResult'],
{
board_id: string;
imageDTOs: ImageDTO[];
}
>({
query: ({ board_id, imageDTOs }) => ({
url: `board_images/batch`,
method: 'POST',
body: {
image_names: imageDTOs.map((i) => i.image_name),
board_id,
},
}),
invalidatesTags: (result, error, { board_id }) => [
// update the destination board
{ type: 'Board', id: board_id ?? 'none' },
// update old board totals
{ type: 'BoardImagesTotal', id: board_id ?? 'none' },
{ type: 'BoardAssetsTotal', id: board_id ?? 'none' },
// update the no_board totals
{ type: 'BoardImagesTotal', id: 'none' },
{ type: 'BoardAssetsTotal', id: 'none' },
],
async onQueryStarted(
{ board_id, imageDTOs },
{ dispatch, queryFulfilled, getState }
) {
try {
const { data } = await queryFulfilled;
const { added_image_names } = data;
/**
* Cache changes for addImagesToBoard:
* - *update* getImageDTO for each image
* - *add* to board_id/[images|assets]
* - *remove* from [old_board_id|no_board]/[images|assets]
*/
added_image_names.forEach((image_name) => {
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_name,
(draft) => {
draft.board_id = board_id;
}
)
);
const imageDTO = imageDTOs.find((i) => i.image_name === image_name);
if (!imageDTO) {
return;
}
const categories = getCategories(imageDTO);
const old_board_id = imageDTO.board_id;
// remove from the old board
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{ board_id: old_board_id ?? 'none', categories },
(draft) => {
imagesAdapter.removeOne(draft, imageDTO.image_name);
}
)
);
const queryArgs = {
board_id,
categories,
};
const currentCache = imagesApi.endpoints.listImages.select(
queryArgs
)(getState());
const { data: total } = IMAGE_CATEGORIES.includes(
imageDTO.image_category
)
? boardsApi.endpoints.getBoardImagesTotal.select(
imageDTO.board_id ?? 'none'
)(getState())
: boardsApi.endpoints.getBoardAssetsTotal.select(
imageDTO.board_id ?? 'none'
)(getState());
const isCacheFullyPopulated =
currentCache.data && currentCache.data.ids.length >= (total ?? 0);
const isInDateRange = getIsImageInDateRange(
currentCache.data,
imageDTO
);
if (isCacheFullyPopulated || isInDateRange) {
// *upsert* to $cache
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
imagesAdapter.upsertOne(draft, {
...imageDTO,
board_id,
});
}
)
);
}
});
} catch {
// no-op
}
},
}),
removeImagesFromBoard: build.mutation<
components['schemas']['RemoveImagesFromBoardResult'],
{
imageDTOs: ImageDTO[];
}
>({
query: ({ imageDTOs }) => ({
url: `board_images/batch/delete`,
method: 'POST',
body: {
image_names: imageDTOs.map((i) => i.image_name),
},
}),
invalidatesTags: (result, error, { imageDTOs }) => {
const touchedBoardIds: string[] = [];
const tags: ApiFullTagDescription[] = [
{ type: 'BoardImagesTotal', id: 'none' },
{ type: 'BoardAssetsTotal', id: 'none' },
];
result?.removed_image_names.forEach((image_name) => {
const board_id = imageDTOs.find(
(i) => i.image_name === image_name
)?.board_id;
if (!board_id || touchedBoardIds.includes(board_id)) {
return;
}
tags.push({ type: 'Board', id: board_id });
tags.push({ type: 'BoardImagesTotal', id: board_id });
tags.push({ type: 'BoardAssetsTotal', id: board_id });
});
return tags;
},
async onQueryStarted(
{ imageDTOs },
{ dispatch, queryFulfilled, getState }
) {
try {
const { data } = await queryFulfilled;
const { removed_image_names } = data;
/**
* Cache changes for removeImagesFromBoard:
* - *update* getImageDTO for each image
* - *remove* from old_board_id/[images|assets]
* - *add* to no_board/[images|assets]
*/
removed_image_names.forEach((image_name) => {
dispatch(
imagesApi.util.updateQueryData(
'getImageDTO',
image_name,
(draft) => {
draft.board_id = undefined;
}
)
);
const imageDTO = imageDTOs.find((i) => i.image_name === image_name);
if (!imageDTO) {
return;
}
const categories = getCategories(imageDTO);
// remove from the old board
dispatch(
imagesApi.util.updateQueryData(
'listImages',
{ board_id: imageDTO.board_id ?? 'none', categories },
(draft) => {
imagesAdapter.removeOne(draft, imageDTO.image_name);
}
)
);
// add to `no_board`
const queryArgs = {
board_id: 'none',
categories,
};
const currentCache = imagesApi.endpoints.listImages.select(
queryArgs
)(getState());
const { data: total } = IMAGE_CATEGORIES.includes(
imageDTO.image_category
)
? boardsApi.endpoints.getBoardImagesTotal.select(
imageDTO.board_id ?? 'none'
)(getState())
: boardsApi.endpoints.getBoardAssetsTotal.select(
imageDTO.board_id ?? 'none'
)(getState());
const isCacheFullyPopulated =
currentCache.data && currentCache.data.ids.length >= (total ?? 0);
const isInDateRange = getIsImageInDateRange(
currentCache.data,
imageDTO
);
if (isCacheFullyPopulated || isInDateRange) {
// *upsert* to $cache
dispatch(
imagesApi.util.updateQueryData(
'listImages',
queryArgs,
(draft) => {
imagesAdapter.upsertOne(draft, {
...imageDTO,
board_id: undefined,
});
}
)
);
}
});
} catch {
// no-op
}
},
}),
}),
});
@ -788,10 +1133,15 @@ export const {
useGetImageDTOQuery,
useGetImageMetadataQuery,
useDeleteImageMutation,
useGetBoardImagesTotalQuery,
useGetBoardAssetsTotalQuery,
useDeleteImagesMutation,
useUploadImageMutation,
useClearIntermediatesMutation,
useAddImagesToBoardMutation,
useRemoveImagesFromBoardMutation,
useAddImageToBoardMutation,
useRemoveImageFromBoardMutation,
useClearIntermediatesMutation,
useChangeImageIsIntermediateMutation,
useChangeImageSessionIdMutation,
useDeleteBoardAndImagesMutation,
useDeleteBoardMutation,
} = imagesApi;

View File

@ -5,7 +5,6 @@ import {
BaseModelType,
CheckpointModelConfig,
ControlNetModelConfig,
ConvertModelConfig,
DiffusersModelConfig,
ImportModelConfig,
LoRAModelConfig,
@ -83,7 +82,7 @@ type DeleteLoRAModelResponse = void;
type ConvertMainModelArg = {
base_model: BaseModelType;
model_name: string;
params: ConvertModelConfig;
convert_dest_directory?: string;
};
type ConvertMainModelResponse =
@ -122,7 +121,7 @@ type CheckpointConfigsResponse =
type SearchFolderArg = operations['search_for_models']['parameters']['query'];
const mainModelsAdapter = createEntityAdapter<MainModelConfigEntity>({
export const mainModelsAdapter = createEntityAdapter<MainModelConfigEntity>({
sortComparer: (a, b) => a.model_name.localeCompare(b.model_name),
});
@ -132,15 +131,15 @@ const onnxModelsAdapter = createEntityAdapter<OnnxModelConfigEntity>({
const loraModelsAdapter = createEntityAdapter<LoRAModelConfigEntity>({
sortComparer: (a, b) => a.model_name.localeCompare(b.model_name),
});
const controlNetModelsAdapter =
export const controlNetModelsAdapter =
createEntityAdapter<ControlNetModelConfigEntity>({
sortComparer: (a, b) => a.model_name.localeCompare(b.model_name),
});
const textualInversionModelsAdapter =
export const textualInversionModelsAdapter =
createEntityAdapter<TextualInversionModelConfigEntity>({
sortComparer: (a, b) => a.model_name.localeCompare(b.model_name),
});
const vaeModelsAdapter = createEntityAdapter<VaeModelConfigEntity>({
export const vaeModelsAdapter = createEntityAdapter<VaeModelConfigEntity>({
sortComparer: (a, b) => a.model_name.localeCompare(b.model_name),
});
@ -320,11 +319,11 @@ export const modelsApi = api.injectEndpoints({
ConvertMainModelResponse,
ConvertMainModelArg
>({
query: ({ base_model, model_name, params }) => {
query: ({ base_model, model_name, convert_dest_directory }) => {
return {
url: `models/convert/${base_model}/main/${model_name}`,
method: 'PUT',
params: params,
params: { convert_dest_directory },
};
},
invalidatesTags: [

View File

@ -1,7 +1,7 @@
import { BoardId } from 'features/gallery/store/types';
import { useListAllBoardsQuery } from '../endpoints/boards';
export const useBoardName = (board_id: BoardId | null | undefined) => {
export const useBoardName = (board_id: BoardId) => {
const { boardName } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => {
const selectedBoard = data?.find((b) => b.board_id === board_id);

Some files were not shown because too many files have changed in this diff Show More