ui: gallery enhancements (#3752)

* feat(ui): salvaged gallery UI enhancements

* restore boardimage functionality, load boardimages and remove some cachine optimizations in the name of data integrity

* fix assets, fix load more params

* jk NOW fix assets, fix load more params

---------

Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local>
Co-authored-by: Mary Hipp Rogers <maryhipp@gmail.com>
This commit is contained in:
psychedelicious 2023-07-14 06:31:10 +10:00 committed by GitHub
parent 271f64068c
commit 536a397b12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
84 changed files with 1668 additions and 3288 deletions

View File

@ -36,6 +36,7 @@ module.exports = {
],
'prettier/prettier': ['error', { endOfLine: 'auto' }],
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-empty-interface': [
'error',
{

View File

@ -119,7 +119,7 @@
"pinGallery": "Pin Gallery",
"allImagesLoaded": "All Images Loaded",
"loadMore": "Load More",
"noImagesInGallery": "No Images In Gallery",
"noImagesInGallery": "No Images to Display",
"deleteImage": "Delete Image",
"deleteImageBin": "Deleted images will be sent to your operating system's Bin.",
"deleteImagePermanent": "Deleted images cannot be restored.",

View File

@ -6,9 +6,7 @@ import { PartialAppConfig } from 'app/types/invokeai';
import ImageUploader from 'common/components/ImageUploader';
import GalleryDrawer from 'features/gallery/components/GalleryPanel';
import DeleteImageModal from 'features/imageDeletion/components/DeleteImageModal';
import Lightbox from 'features/lightbox/components/Lightbox';
import SiteHeader from 'features/system/components/SiteHeader';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { configChanged } from 'features/system/store/configSlice';
import { languageSelector } from 'features/system/store/systemSelectors';
import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton';
@ -34,8 +32,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
const log = useLogger();
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
const dispatch = useAppDispatch();
useEffect(() => {
@ -54,7 +50,6 @@ const App = ({ config = DEFAULT_CONFIG, headerComponent }: Props) => {
return (
<>
<Grid w="100vw" h="100vh" position="relative" overflow="hidden">
{isLightboxEnabled && <Lightbox />}
<ImageUploader>
<Grid
sx={{

View File

@ -1,8 +1,4 @@
import { Box, ChakraProps, Flex, Heading, Image } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { memo } from 'react';
import { TypesafeDraggableData } from './typesafeDnd';
@ -32,24 +28,7 @@ const STYLES: ChakraProps['sx'] = {
},
};
const selector = createSelector(
stateSelector,
(state) => {
const gallerySelectionCount = state.gallery.selection.length;
const batchSelectionCount = state.batch.selection.length;
return {
gallerySelectionCount,
batchSelectionCount,
};
},
defaultSelectorOptions
);
const DragPreview = (props: OverlayDragImageProps) => {
const { gallerySelectionCount, batchSelectionCount } =
useAppSelector(selector);
if (!props.dragData) {
return;
}
@ -82,7 +61,7 @@ const DragPreview = (props: OverlayDragImageProps) => {
);
}
if (props.dragData.payloadType === 'BATCH_SELECTION') {
if (props.dragData.payloadType === 'IMAGE_NAMES') {
return (
<Flex
sx={{
@ -95,26 +74,7 @@ const DragPreview = (props: OverlayDragImageProps) => {
...STYLES,
}}
>
<Heading>{batchSelectionCount}</Heading>
<Heading size="sm">Images</Heading>
</Flex>
);
}
if (props.dragData.payloadType === 'GALLERY_SELECTION') {
return (
<Flex
sx={{
cursor: 'none',
userSelect: 'none',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
flexDir: 'column',
...STYLES,
}}
>
<Heading>{gallerySelectionCount}</Heading>
<Heading>{props.dragData.payload.image_names.length}</Heading>
<Heading size="sm">Images</Heading>
</Flex>
);

View File

@ -6,18 +6,18 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
import { useAppDispatch } from 'app/store/storeHooks';
import { AnimatePresence, motion } from 'framer-motion';
import { PropsWithChildren, memo, useCallback, useState } from 'react';
import DragPreview from './DragPreview';
import { snapCenterToCursor } from '@dnd-kit/modifiers';
import { AnimatePresence, motion } from 'framer-motion';
import {
DndContext,
DragEndEvent,
DragStartEvent,
TypesafeDraggableData,
} from './typesafeDnd';
import { useAppDispatch } from 'app/store/storeHooks';
import { imageDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
type ImageDndContextProps = PropsWithChildren;
@ -42,18 +42,18 @@ const ImageDndContext = (props: ImageDndContextProps) => {
if (!activeData || !overData) {
return;
}
dispatch(imageDropped({ overData, activeData }));
dispatch(dndDropped({ overData, activeData }));
setActiveDragData(null);
},
[dispatch]
);
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 150, tolerance: 5 },
activationConstraint: { distance: 10 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 5 },
activationConstraint: { distance: 10 },
});
// TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos

View File

@ -77,18 +77,14 @@ export type ImageDraggableData = BaseDragData & {
payload: { imageDTO: ImageDTO };
};
export type GallerySelectionDraggableData = BaseDragData & {
payloadType: 'GALLERY_SELECTION';
};
export type BatchSelectionDraggableData = BaseDragData & {
payloadType: 'BATCH_SELECTION';
export type ImageNamesDraggableData = BaseDragData & {
payloadType: 'IMAGE_NAMES';
payload: { image_names: string[] };
};
export type TypesafeDraggableData =
| ImageDraggableData
| GallerySelectionDraggableData
| BatchSelectionDraggableData;
| ImageNamesDraggableData;
interface UseDroppableTypesafeArguments
extends Omit<UseDroppableArguments, 'data'> {
@ -159,13 +155,11 @@ export const isValidDrop = (
case 'SET_NODES_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SET_MULTI_NODES_IMAGE':
return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION';
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'ADD_TO_BATCH':
return payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION';
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
case 'MOVE_BOARD':
return (
payloadType === 'IMAGE_DTO' || 'GALLERY_SELECTION' || 'BATCH_SELECTION'
);
return payloadType === 'IMAGE_DTO' || 'IMAGE_NAMES';
default:
return false;
}

View File

@ -1,67 +0,0 @@
// import { createAction } from '@reduxjs/toolkit';
// import * as InvokeAI from 'app/types/invokeai';
// import { GalleryCategory } from 'features/gallery/store/gallerySlice';
// import { InvokeTabName } from 'features/ui/store/tabMap';
// /**
// * We can't use redux-toolkit's createSlice() to make these actions,
// * because they have no associated reducer. They only exist to dispatch
// * requests to the server via socketio. These actions will be handled
// * by the middleware.
// */
// export const generateImage = createAction<InvokeTabName>(
// 'socketio/generateImage'
// );
// export const runESRGAN = createAction<InvokeAI._Image>('socketio/runESRGAN');
// export const runFacetool = createAction<InvokeAI._Image>(
// 'socketio/runFacetool'
// );
// export const deleteImage = createAction<InvokeAI._Image>(
// 'socketio/deleteImage'
// );
// export const requestImages = createAction<GalleryCategory>(
// 'socketio/requestImages'
// );
// export const requestNewImages = createAction<GalleryCategory>(
// 'socketio/requestNewImages'
// );
// export const cancelProcessing = createAction<undefined>(
// 'socketio/cancelProcessing'
// );
// export const requestSystemConfig = createAction<undefined>(
// 'socketio/requestSystemConfig'
// );
// export const searchForModels = createAction<string>('socketio/searchForModels');
// export const addNewModel = createAction<
// InvokeAI.InvokeModelConfigProps | InvokeAI.InvokeDiffusersModelConfigProps
// >('socketio/addNewModel');
// export const deleteModel = createAction<string>('socketio/deleteModel');
// export const convertToDiffusers =
// createAction<InvokeAI.InvokeModelConversionProps>(
// 'socketio/convertToDiffusers'
// );
// export const mergeDiffusersModels =
// createAction<InvokeAI.InvokeModelMergingProps>(
// 'socketio/mergeDiffusersModels'
// );
// export const requestModelChange = createAction<string>(
// 'socketio/requestModelChange'
// );
// export const saveStagingAreaImageToGallery = createAction<string>(
// 'socketio/saveStagingAreaImageToGallery'
// );
// export const emptyTempFolder = createAction<undefined>(
// 'socketio/requestEmptyTempFolder'
// );
export default {};

View File

@ -1,209 +0,0 @@
import { AnyAction, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit';
import * as InvokeAI from 'app/types/invokeai';
import type { RootState } from 'app/store/store';
import {
frontendToBackendParameters,
FrontendToBackendParametersConfig,
} from 'common/util/parameterTranslation';
import dateFormat from 'dateformat';
import {
GalleryCategory,
GalleryState,
removeImage,
} from 'features/gallery/store/gallerySlice';
import {
generationRequested,
modelChangeRequested,
modelConvertRequested,
modelMergingRequested,
setIsProcessing,
} from 'features/system/store/systemSlice';
import { InvokeTabName } from 'features/ui/store/tabMap';
import { Socket } from 'socket.io-client';
/**
* Returns an object containing all functions which use `socketio.emit()`.
* i.e. those which make server requests.
*/
const makeSocketIOEmitters = (
store: MiddlewareAPI<Dispatch<AnyAction>, RootState>,
socketio: Socket
) => {
// We need to dispatch actions to redux and get pieces of state from the store.
const { dispatch, getState } = store;
return {
emitGenerateImage: (generationMode: InvokeTabName) => {
dispatch(setIsProcessing(true));
const state: RootState = getState();
const {
generation: generationState,
postprocessing: postprocessingState,
system: systemState,
canvas: canvasState,
} = state;
const frontendToBackendParametersConfig: FrontendToBackendParametersConfig =
{
generationMode,
generationState,
postprocessingState,
canvasState,
systemState,
};
dispatch(generationRequested());
const { generationParameters, esrganParameters, facetoolParameters } =
frontendToBackendParameters(frontendToBackendParametersConfig);
socketio.emit(
'generateImage',
generationParameters,
esrganParameters,
facetoolParameters
);
// we need to truncate the init_mask base64 else it takes up the whole log
// TODO: handle maintaining masks for reproducibility in future
if (generationParameters.init_mask) {
generationParameters.init_mask = generationParameters.init_mask
.substr(0, 64)
.concat('...');
}
if (generationParameters.init_img) {
generationParameters.init_img = generationParameters.init_img
.substr(0, 64)
.concat('...');
}
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Image generation requested: ${JSON.stringify({
...generationParameters,
...esrganParameters,
...facetoolParameters,
})}`,
})
);
},
emitRunESRGAN: (imageToProcess: InvokeAI._Image) => {
dispatch(setIsProcessing(true));
const {
postprocessing: {
upscalingLevel,
upscalingDenoising,
upscalingStrength,
},
} = getState();
const esrganParameters = {
upscale: [upscalingLevel, upscalingDenoising, upscalingStrength],
};
socketio.emit('runPostprocessing', imageToProcess, {
type: 'esrgan',
...esrganParameters,
});
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `ESRGAN upscale requested: ${JSON.stringify({
file: imageToProcess.url,
...esrganParameters,
})}`,
})
);
},
emitRunFacetool: (imageToProcess: InvokeAI._Image) => {
dispatch(setIsProcessing(true));
const {
postprocessing: { facetoolType, facetoolStrength, codeformerFidelity },
} = getState();
const facetoolParameters: Record<string, unknown> = {
facetool_strength: facetoolStrength,
};
if (facetoolType === 'codeformer') {
facetoolParameters.codeformer_fidelity = codeformerFidelity;
}
socketio.emit('runPostprocessing', imageToProcess, {
type: facetoolType,
...facetoolParameters,
});
dispatch(
addLogEntry({
timestamp: dateFormat(new Date(), 'isoDateTime'),
message: `Face restoration (${facetoolType}) requested: ${JSON.stringify(
{
file: imageToProcess.url,
...facetoolParameters,
}
)}`,
})
);
},
emitDeleteImage: (imageToDelete: InvokeAI._Image) => {
const { url, uuid, category, thumbnail } = imageToDelete;
dispatch(removeImage(imageToDelete));
socketio.emit('deleteImage', url, thumbnail, uuid, category);
},
emitRequestImages: (category: GalleryCategory) => {
const gallery: GalleryState = getState().gallery;
const { earliest_mtime } = gallery.categories[category];
socketio.emit('requestImages', category, earliest_mtime);
},
emitRequestNewImages: (category: GalleryCategory) => {
const gallery: GalleryState = getState().gallery;
const { latest_mtime } = gallery.categories[category];
socketio.emit('requestLatestImages', category, latest_mtime);
},
emitCancelProcessing: () => {
socketio.emit('cancel');
},
emitRequestSystemConfig: () => {
socketio.emit('requestSystemConfig');
},
emitSearchForModels: (modelFolder: string) => {
socketio.emit('searchForModels', modelFolder);
},
emitAddNewModel: (modelConfig: InvokeAI.InvokeModelConfigProps) => {
socketio.emit('addNewModel', modelConfig);
},
emitDeleteModel: (modelName: string) => {
socketio.emit('deleteModel', modelName);
},
emitConvertToDiffusers: (
modelToConvert: InvokeAI.InvokeModelConversionProps
) => {
dispatch(modelConvertRequested());
socketio.emit('convertToDiffusers', modelToConvert);
},
emitMergeDiffusersModels: (
modelMergeInfo: InvokeAI.InvokeModelMergingProps
) => {
dispatch(modelMergingRequested());
socketio.emit('mergeDiffusersModels', modelMergeInfo);
},
emitRequestModelChange: (modelName: string) => {
dispatch(modelChangeRequested());
socketio.emit('requestModelChange', modelName);
},
emitSaveStagingAreaImageToGallery: (url: string) => {
socketio.emit('requestSaveStagingAreaImageToGallery', url);
},
emitRequestEmptyTempFolder: () => {
socketio.emit('requestEmptyTempFolder');
},
};
};
export default makeSocketIOEmitters;
export default {};

View File

@ -1,502 +0,0 @@
// import { AnyAction, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit';
// import dateFormat from 'dateformat';
// import i18n from 'i18n';
// import { v4 as uuidv4 } from 'uuid';
// import * as InvokeAI from 'app/types/invokeai';
// import {
// addToast,
// errorOccurred,
// processingCanceled,
// setCurrentStatus,
// setFoundModels,
// setIsCancelable,
// setIsConnected,
// setIsProcessing,
// setModelList,
// setSearchFolder,
// setSystemConfig,
// setSystemStatus,
// } from 'features/system/store/systemSlice';
// import {
// addGalleryImages,
// addImage,
// clearIntermediateImage,
// GalleryState,
// removeImage,
// setIntermediateImage,
// } from 'features/gallery/store/gallerySlice';
// import type { RootState } from 'app/store/store';
// import { addImageToStagingArea } from 'features/canvas/store/canvasSlice';
// import {
// clearInitialImage,
// initialImageSelected,
// setInfillMethod,
// // setInitialImage,
// setMaskPath,
// } from 'features/parameters/store/generationSlice';
// import { tabMap } from 'features/ui/store/tabMap';
// import {
// requestImages,
// requestNewImages,
// requestSystemConfig,
// } from './actions';
// /**
// * Returns an object containing listener callbacks for socketio events.
// * TODO: This file is large, but simple. Should it be split up further?
// */
// const makeSocketIOListeners = (
// store: MiddlewareAPI<Dispatch<AnyAction>, RootState>
// ) => {
// const { dispatch, getState } = store;
// return {
// /**
// * Callback to run when we receive a 'connect' event.
// */
// onConnect: () => {
// try {
// dispatch(setIsConnected(true));
// dispatch(setCurrentStatus(i18n.t('common.statusConnected')));
// dispatch(requestSystemConfig());
// const gallery: GalleryState = getState().gallery;
// if (gallery.categories.result.latest_mtime) {
// dispatch(requestNewImages('result'));
// } else {
// dispatch(requestImages('result'));
// }
// if (gallery.categories.user.latest_mtime) {
// dispatch(requestNewImages('user'));
// } else {
// dispatch(requestImages('user'));
// }
// } catch (e) {
// console.error(e);
// }
// },
// /**
// * Callback to run when we receive a 'disconnect' event.
// */
// onDisconnect: () => {
// try {
// dispatch(setIsConnected(false));
// dispatch(setCurrentStatus(i18n.t('common.statusDisconnected')));
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Disconnected from server`,
// level: 'warning',
// })
// );
// } catch (e) {
// console.error(e);
// }
// },
// /**
// * Callback to run when we receive a 'generationResult' event.
// */
// onGenerationResult: (data: InvokeAI.ImageResultResponse) => {
// try {
// const state = getState();
// const { activeTab } = state.ui;
// const { shouldLoopback } = state.postprocessing;
// const { boundingBox: _, generationMode, ...rest } = data;
// const newImage = {
// uuid: uuidv4(),
// ...rest,
// };
// if (['txt2img', 'img2img'].includes(generationMode)) {
// dispatch(
// addImage({
// category: 'result',
// image: { ...newImage, category: 'result' },
// })
// );
// }
// if (generationMode === 'unifiedCanvas' && data.boundingBox) {
// const { boundingBox } = data;
// dispatch(
// addImageToStagingArea({
// image: { ...newImage, category: 'temp' },
// boundingBox,
// })
// );
// if (state.canvas.shouldAutoSave) {
// dispatch(
// addImage({
// image: { ...newImage, category: 'result' },
// category: 'result',
// })
// );
// }
// }
// // TODO: fix
// // if (shouldLoopback) {
// // const activeTabName = tabMap[activeTab];
// // switch (activeTabName) {
// // case 'img2img': {
// // dispatch(initialImageSelected(newImage.uuid));
// // // dispatch(setInitialImage(newImage));
// // break;
// // }
// // }
// // }
// dispatch(clearIntermediateImage());
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Image generated: ${data.url}`,
// })
// );
// } catch (e) {
// console.error(e);
// }
// },
// /**
// * Callback to run when we receive a 'intermediateResult' event.
// */
// onIntermediateResult: (data: InvokeAI.ImageResultResponse) => {
// try {
// dispatch(
// setIntermediateImage({
// uuid: uuidv4(),
// ...data,
// category: 'result',
// })
// );
// if (!data.isBase64) {
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Intermediate image generated: ${data.url}`,
// })
// );
// }
// } catch (e) {
// console.error(e);
// }
// },
// /**
// * Callback to run when we receive an 'esrganResult' event.
// */
// onPostprocessingResult: (data: InvokeAI.ImageResultResponse) => {
// try {
// dispatch(
// addImage({
// category: 'result',
// image: {
// uuid: uuidv4(),
// ...data,
// category: 'result',
// },
// })
// );
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Postprocessed: ${data.url}`,
// })
// );
// } catch (e) {
// console.error(e);
// }
// },
// /**
// * Callback to run when we receive a 'progressUpdate' event.
// * TODO: Add additional progress phases
// */
// onProgressUpdate: (data: InvokeAI.SystemStatus) => {
// try {
// dispatch(setIsProcessing(true));
// dispatch(setSystemStatus(data));
// } catch (e) {
// console.error(e);
// }
// },
// /**
// * Callback to run when we receive a 'progressUpdate' event.
// */
// onError: (data: InvokeAI.ErrorResponse) => {
// const { message, additionalData } = data;
// if (additionalData) {
// // TODO: handle more data than short message
// }
// try {
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Server error: ${message}`,
// level: 'error',
// })
// );
// dispatch(errorOccurred());
// dispatch(clearIntermediateImage());
// } catch (e) {
// console.error(e);
// }
// },
// /**
// * Callback to run when we receive a 'galleryImages' event.
// */
// onGalleryImages: (data: InvokeAI.GalleryImagesResponse) => {
// const { images, areMoreImagesAvailable, category } = data;
// /**
// * the logic here ideally would be in the reducer but we have a side effect:
// * generating a uuid. so the logic needs to be here, outside redux.
// */
// // Generate a UUID for each image
// const preparedImages = images.map((image): InvokeAI._Image => {
// return {
// uuid: uuidv4(),
// ...image,
// };
// });
// dispatch(
// addGalleryImages({
// images: preparedImages,
// areMoreImagesAvailable,
// category,
// })
// );
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Loaded ${images.length} images`,
// })
// );
// },
// /**
// * Callback to run when we receive a 'processingCanceled' event.
// */
// onProcessingCanceled: () => {
// dispatch(processingCanceled());
// const { intermediateImage } = getState().gallery;
// if (intermediateImage) {
// if (!intermediateImage.isBase64) {
// dispatch(
// addImage({
// category: 'result',
// image: intermediateImage,
// })
// );
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Intermediate image saved: ${intermediateImage.url}`,
// })
// );
// }
// dispatch(clearIntermediateImage());
// }
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Processing canceled`,
// level: 'warning',
// })
// );
// },
// /**
// * Callback to run when we receive a 'imageDeleted' event.
// */
// onImageDeleted: (data: InvokeAI.ImageDeletedResponse) => {
// const { url } = data;
// // remove image from gallery
// dispatch(removeImage(data));
// // remove references to image in options
// const {
// generation: { initialImage, maskPath },
// } = getState();
// if (
// initialImage === url ||
// (initialImage as InvokeAI._Image)?.url === url
// ) {
// dispatch(clearInitialImage());
// }
// if (maskPath === url) {
// dispatch(setMaskPath(''));
// }
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Image deleted: ${url}`,
// })
// );
// },
// onSystemConfig: (data: InvokeAI.SystemConfig) => {
// dispatch(setSystemConfig(data));
// if (!data.infill_methods.includes('patchmatch')) {
// dispatch(setInfillMethod(data.infill_methods[0]));
// }
// },
// onFoundModels: (data: InvokeAI.FoundModelResponse) => {
// const { search_folder, found_models } = data;
// dispatch(setSearchFolder(search_folder));
// dispatch(setFoundModels(found_models));
// },
// onNewModelAdded: (data: InvokeAI.ModelAddedResponse) => {
// const { new_model_name, model_list, update } = data;
// dispatch(setModelList(model_list));
// dispatch(setIsProcessing(false));
// dispatch(setCurrentStatus(i18n.t('modelManager.modelAdded')));
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Model Added: ${new_model_name}`,
// level: 'info',
// })
// );
// dispatch(
// addToast({
// title: !update
// ? `${i18n.t('modelManager.modelAdded')}: ${new_model_name}`
// : `${i18n.t('modelManager.modelUpdated')}: ${new_model_name}`,
// status: 'success',
// duration: 2500,
// isClosable: true,
// })
// );
// },
// onModelDeleted: (data: InvokeAI.ModelDeletedResponse) => {
// const { deleted_model_name, model_list } = data;
// dispatch(setModelList(model_list));
// dispatch(setIsProcessing(false));
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `${i18n.t(
// 'modelManager.modelAdded'
// )}: ${deleted_model_name}`,
// level: 'info',
// })
// );
// dispatch(
// addToast({
// title: `${i18n.t(
// 'modelManager.modelEntryDeleted'
// )}: ${deleted_model_name}`,
// status: 'success',
// duration: 2500,
// isClosable: true,
// })
// );
// },
// onModelConverted: (data: InvokeAI.ModelConvertedResponse) => {
// const { converted_model_name, model_list } = data;
// dispatch(setModelList(model_list));
// dispatch(setCurrentStatus(i18n.t('common.statusModelConverted')));
// dispatch(setIsProcessing(false));
// dispatch(setIsCancelable(true));
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Model converted: ${converted_model_name}`,
// level: 'info',
// })
// );
// dispatch(
// addToast({
// title: `${i18n.t(
// 'modelManager.modelConverted'
// )}: ${converted_model_name}`,
// status: 'success',
// duration: 2500,
// isClosable: true,
// })
// );
// },
// onModelsMerged: (data: InvokeAI.ModelsMergedResponse) => {
// const { merged_models, merged_model_name, model_list } = data;
// dispatch(setModelList(model_list));
// dispatch(setCurrentStatus(i18n.t('common.statusMergedModels')));
// dispatch(setIsProcessing(false));
// dispatch(setIsCancelable(true));
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Models merged: ${merged_models}`,
// level: 'info',
// })
// );
// dispatch(
// addToast({
// title: `${i18n.t('modelManager.modelsMerged')}: ${merged_model_name}`,
// status: 'success',
// duration: 2500,
// isClosable: true,
// })
// );
// },
// onModelChanged: (data: InvokeAI.ModelChangeResponse) => {
// const { model_name, model_list } = data;
// dispatch(setModelList(model_list));
// dispatch(setCurrentStatus(i18n.t('common.statusModelChanged')));
// dispatch(setIsProcessing(false));
// dispatch(setIsCancelable(true));
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Model changed: ${model_name}`,
// level: 'info',
// })
// );
// },
// onModelChangeFailed: (data: InvokeAI.ModelChangeResponse) => {
// const { model_name, model_list } = data;
// dispatch(setModelList(model_list));
// dispatch(setIsProcessing(false));
// dispatch(setIsCancelable(true));
// dispatch(errorOccurred());
// dispatch(
// addLogEntry({
// timestamp: dateFormat(new Date(), 'isoDateTime'),
// message: `Model change failed: ${model_name}`,
// level: 'error',
// })
// );
// },
// onTempFolderEmptied: () => {
// dispatch(
// addToast({
// title: i18n.t('toast.tempFoldersEmptied'),
// status: 'success',
// duration: 2500,
// isClosable: true,
// })
// );
// },
// };
// };
// export default makeSocketIOListeners;
export default {};

View File

@ -1,248 +0,0 @@
// import { Middleware } from '@reduxjs/toolkit';
// import { io } from 'socket.io-client';
// import makeSocketIOEmitters from './emitters';
// import makeSocketIOListeners from './listeners';
// import * as InvokeAI from 'app/types/invokeai';
// /**
// * Creates a socketio middleware to handle communication with server.
// *
// * Special `socketio/actionName` actions are created in actions.ts and
// * exported for use by the application, which treats them like any old
// * action, using `dispatch` to dispatch them.
// *
// * These actions are intercepted here, where `socketio.emit()` calls are
// * made on their behalf - see `emitters.ts`. The emitter functions
// * are the outbound communication to the server.
// *
// * Listeners are also established here - see `listeners.ts`. The listener
// * functions receive communication from the server and usually dispatch
// * some new action to handle whatever data was sent from the server.
// */
// export const socketioMiddleware = () => {
// const { origin } = new URL(window.location.href);
// const socketio = io(origin, {
// timeout: 60000,
// path: `${window.location.pathname}socket.io`,
// });
// socketio.disconnect();
// let areListenersSet = false;
// const middleware: Middleware = (store) => (next) => (action) => {
// const {
// onConnect,
// onDisconnect,
// onError,
// onPostprocessingResult,
// onGenerationResult,
// onIntermediateResult,
// onProgressUpdate,
// onGalleryImages,
// onProcessingCanceled,
// onImageDeleted,
// onSystemConfig,
// onModelChanged,
// onFoundModels,
// onNewModelAdded,
// onModelDeleted,
// onModelConverted,
// onModelsMerged,
// onModelChangeFailed,
// onTempFolderEmptied,
// } = makeSocketIOListeners(store);
// const {
// emitGenerateImage,
// emitRunESRGAN,
// emitRunFacetool,
// emitDeleteImage,
// emitRequestImages,
// emitRequestNewImages,
// emitCancelProcessing,
// emitRequestSystemConfig,
// emitSearchForModels,
// emitAddNewModel,
// emitDeleteModel,
// emitConvertToDiffusers,
// emitMergeDiffusersModels,
// emitRequestModelChange,
// emitSaveStagingAreaImageToGallery,
// emitRequestEmptyTempFolder,
// } = makeSocketIOEmitters(store, socketio);
// /**
// * If this is the first time the middleware has been called (e.g. during store setup),
// * initialize all our socket.io listeners.
// */
// if (!areListenersSet) {
// socketio.on('connect', () => onConnect());
// socketio.on('disconnect', () => onDisconnect());
// socketio.on('error', (data: InvokeAI.ErrorResponse) => onError(data));
// socketio.on('generationResult', (data: InvokeAI.ImageResultResponse) =>
// onGenerationResult(data)
// );
// socketio.on(
// 'postprocessingResult',
// (data: InvokeAI.ImageResultResponse) => onPostprocessingResult(data)
// );
// socketio.on('intermediateResult', (data: InvokeAI.ImageResultResponse) =>
// onIntermediateResult(data)
// );
// socketio.on('progressUpdate', (data: InvokeAI.SystemStatus) =>
// onProgressUpdate(data)
// );
// socketio.on('galleryImages', (data: InvokeAI.GalleryImagesResponse) =>
// onGalleryImages(data)
// );
// socketio.on('processingCanceled', () => {
// onProcessingCanceled();
// });
// socketio.on('imageDeleted', (data: InvokeAI.ImageDeletedResponse) => {
// onImageDeleted(data);
// });
// socketio.on('systemConfig', (data: InvokeAI.SystemConfig) => {
// onSystemConfig(data);
// });
// socketio.on('foundModels', (data: InvokeAI.FoundModelResponse) => {
// onFoundModels(data);
// });
// socketio.on('newModelAdded', (data: InvokeAI.ModelAddedResponse) => {
// onNewModelAdded(data);
// });
// socketio.on('modelDeleted', (data: InvokeAI.ModelDeletedResponse) => {
// onModelDeleted(data);
// });
// socketio.on('modelConverted', (data: InvokeAI.ModelConvertedResponse) => {
// onModelConverted(data);
// });
// socketio.on('modelsMerged', (data: InvokeAI.ModelsMergedResponse) => {
// onModelsMerged(data);
// });
// socketio.on('modelChanged', (data: InvokeAI.ModelChangeResponse) => {
// onModelChanged(data);
// });
// socketio.on('modelChangeFailed', (data: InvokeAI.ModelChangeResponse) => {
// onModelChangeFailed(data);
// });
// socketio.on('tempFolderEmptied', () => {
// onTempFolderEmptied();
// });
// areListenersSet = true;
// }
// /**
// * Handle redux actions caught by middleware.
// */
// switch (action.type) {
// case 'socketio/generateImage': {
// emitGenerateImage(action.payload);
// break;
// }
// case 'socketio/runESRGAN': {
// emitRunESRGAN(action.payload);
// break;
// }
// case 'socketio/runFacetool': {
// emitRunFacetool(action.payload);
// break;
// }
// case 'socketio/deleteImage': {
// emitDeleteImage(action.payload);
// break;
// }
// case 'socketio/requestImages': {
// emitRequestImages(action.payload);
// break;
// }
// case 'socketio/requestNewImages': {
// emitRequestNewImages(action.payload);
// break;
// }
// case 'socketio/cancelProcessing': {
// emitCancelProcessing();
// break;
// }
// case 'socketio/requestSystemConfig': {
// emitRequestSystemConfig();
// break;
// }
// case 'socketio/searchForModels': {
// emitSearchForModels(action.payload);
// break;
// }
// case 'socketio/addNewModel': {
// emitAddNewModel(action.payload);
// break;
// }
// case 'socketio/deleteModel': {
// emitDeleteModel(action.payload);
// break;
// }
// case 'socketio/convertToDiffusers': {
// emitConvertToDiffusers(action.payload);
// break;
// }
// case 'socketio/mergeDiffusersModels': {
// emitMergeDiffusersModels(action.payload);
// break;
// }
// case 'socketio/requestModelChange': {
// emitRequestModelChange(action.payload);
// break;
// }
// case 'socketio/saveStagingAreaImageToGallery': {
// emitSaveStagingAreaImageToGallery(action.payload);
// break;
// }
// case 'socketio/requestEmptyTempFolder': {
// emitRequestEmptyTempFolder();
// break;
// }
// }
// next(action);
// };
// return middleware;
// };
export default {};

View File

@ -1,7 +1,6 @@
import { canvasPersistDenylist } from 'features/canvas/store/canvasPersistDenylist';
import { controlNetDenylist } from 'features/controlNet/store/controlNetDenylist';
import { galleryPersistDenylist } from 'features/gallery/store/galleryPersistDenylist';
import { lightboxPersistDenylist } from 'features/lightbox/store/lightboxPersistDenylist';
import { nodesPersistDenylist } from 'features/nodes/store/nodesPersistDenylist';
import { generationPersistDenylist } from 'features/parameters/store/generationPersistDenylist';
import { postprocessingPersistDenylist } from 'features/parameters/store/postprocessingPersistDenylist';
@ -16,7 +15,6 @@ const serializationDenylist: {
canvas: canvasPersistDenylist,
gallery: galleryPersistDenylist,
generation: generationPersistDenylist,
lightbox: lightboxPersistDenylist,
nodes: nodesPersistDenylist,
postprocessing: postprocessingPersistDenylist,
system: systemPersistDenylist,

View File

@ -1,7 +1,6 @@
import { initialCanvasState } from 'features/canvas/store/canvasSlice';
import { initialControlNetState } from 'features/controlNet/store/controlNetSlice';
import { initialGalleryState } from 'features/gallery/store/gallerySlice';
import { initialLightboxState } from 'features/lightbox/store/lightboxSlice';
import { initialNodesState } from 'features/nodes/store/nodesSlice';
import { initialGenerationState } from 'features/parameters/store/generationSlice';
import { initialPostprocessingState } from 'features/parameters/store/postprocessingSlice';
@ -18,7 +17,6 @@ const initialStates: {
canvas: initialCanvasState,
gallery: initialGalleryState,
generation: initialGenerationState,
lightbox: initialLightboxState,
nodes: initialNodesState,
postprocessing: initialPostprocessingState,
system: initialSystemState,

View File

@ -1,4 +1,8 @@
/**
* This is a list of actions that should be excluded in the Redux DevTools.
*/
export const actionsDenylist = [
// very spammy canvas actions
'canvas/setCursorPosition',
'canvas/setStageCoordinates',
'canvas/setStageScale',
@ -7,7 +11,11 @@ export const actionsDenylist = [
'canvas/setBoundingBoxDimensions',
'canvas/setIsDrawing',
'canvas/addPointToCurrentLine',
// bazillions during generation
'socket/socketGeneratorProgress',
'socket/appSocketGeneratorProgress',
// every time user presses shift
'hotkeys/shiftKeyPressed',
// this happens after every state change
'@@REMEMBER_PERSISTED',
];

View File

@ -58,7 +58,6 @@ import {
addReceivedPageOfImagesFulfilledListener,
addReceivedPageOfImagesRejectedListener,
} from './listeners/receivedPageOfImages';
import { addSelectionAddedToBatchListener } from './listeners/selectionAddedToBatch';
import {
addSessionCanceledFulfilledListener,
addSessionCanceledPendingListener,
@ -215,9 +214,6 @@ addBoardIdSelectedListener();
// Node schemas
addReceivedOpenAPISchemaListener();
// Batches
addSelectionAddedToBatchListener();
// DND
addImageDroppedListener();

View File

@ -1,5 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
INITIAL_IMAGE_LIMIT,
isLoadingChanged,
} from 'features/gallery/store/gallerySlice';
@ -20,7 +22,7 @@ export const addAppStartedListener = () => {
// fill up the gallery tab with images
await dispatch(
receivedPageOfImages({
categories: ['general'],
categories: IMAGE_CATEGORIES,
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,
@ -30,7 +32,7 @@ export const addAppStartedListener = () => {
// fill up the assets tab with images
await dispatch(
receivedPageOfImages({
categories: ['control', 'mask', 'user', 'other'],
categories: ASSETS_CATEGORIES,
is_intermediate: false,
offset: 0,
limit: INITIAL_IMAGE_LIMIT,

View File

@ -1,15 +1,18 @@
import { log } from 'app/logging/useLogger';
import { startAppListening } from '..';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
boardIdSelected,
imageSelected,
selectImagesAll,
boardIdSelected,
} from 'features/gallery/store/gallerySlice';
import { boardsApi } from 'services/api/endpoints/boards';
import {
IMAGES_PER_PAGE,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { boardsApi } from 'services/api/endpoints/boards';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' });
@ -24,19 +27,24 @@ export const addBoardIdSelectedListener = () => {
const state = getState();
const allImages = selectImagesAll(state);
if (!board_id) {
// a board was unselected
dispatch(imageSelected(allImages[0]?.image_name));
if (board_id === 'all') {
// Selected all images
dispatch(imageSelected(allImages[0]?.image_name ?? null));
return;
}
const { categories } = state.gallery;
if (board_id === 'batch') {
// Selected the batch
dispatch(imageSelected(state.gallery.batchImageNames[0] ?? null));
return;
}
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = board_id ? i.board_id === board_id : true;
return isInCategory && isInSelectedBoard;
});
const filteredImages = selectFilteredImages(state);
const categories =
state.gallery.galleryView === 'images'
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
// get the board from the cache
const { data: boards } =
@ -45,7 +53,7 @@ export const addBoardIdSelectedListener = () => {
if (!board) {
// can't find the board in cache...
dispatch(imageSelected(allImages[0]?.image_name));
dispatch(boardIdSelected('all'));
return;
}
@ -63,48 +71,3 @@ export const addBoardIdSelectedListener = () => {
},
});
};
export const addBoardIdSelected_changeSelectedImage_listener = () => {
startAppListening({
actionCreator: boardIdSelected,
effect: (action, { getState, dispatch }) => {
const board_id = action.payload;
const state = getState();
// we need to check if we need to fetch more images
if (!board_id) {
// a board was unselected - we don't need to do anything
return;
}
const { categories } = state.gallery;
const filteredImages = selectImagesAll(state).filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = board_id ? i.board_id === board_id : true;
return isInCategory && isInSelectedBoard;
});
// get the board from the cache
const { data: boards } =
boardsApi.endpoints.listAllBoards.select()(state);
const board = boards?.find((b) => b.board_id === board_id);
if (!board) {
// can't find the board in cache...
return;
}
// if we haven't loaded one full page of images from this board, load more
if (
filteredImages.length < board.image_count &&
filteredImages.length < IMAGES_PER_PAGE
) {
dispatch(
receivedPageOfImages({ categories, board_id, is_intermediate: false })
);
}
},
});
};

View File

@ -1,6 +1,5 @@
import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { imageDTOReceived } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' });
@ -15,12 +14,6 @@ export const addImageAddedToBoardFulfilledListener = () => {
{ data: { board_id, image_name } },
'Image added to board'
);
dispatch(
imageDTOReceived({
image_name,
})
);
},
});
};

View File

@ -1,10 +1,10 @@
import { log } from 'app/logging/useLogger';
import { resetCanvas } from 'features/canvas/store/canvasSlice';
import { controlNetReset } from 'features/controlNet/store/controlNetSlice';
import { selectNextImageToSelect } from 'features/gallery/store/gallerySelectors';
import {
imageRemoved,
imageSelected,
selectFilteredImages,
} from 'features/gallery/store/gallerySlice';
import {
imageDeletionConfirmed,
@ -12,7 +12,6 @@ import {
} 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 { imageDeleted } from 'services/api/thunks/image';
import { startAppListening } from '..';
@ -37,26 +36,10 @@ export const addRequestedImageDeletionListener = () => {
state.gallery.selection[state.gallery.selection.length - 1];
if (lastSelectedImage === image_name) {
const filteredImages = selectFilteredImages(state);
const ids = filteredImages.map((i) => i.image_name);
const deletedImageIndex = ids.findIndex(
(result) => result.toString() === image_name
);
const filteredIds = ids.filter((id) => id.toString() !== image_name);
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
const newSelectedImageId = selectNextImageToSelect(state, image_name);
if (newSelectedImageId) {
dispatch(imageSelected(newSelectedImageId as string));
dispatch(imageSelected(newSelectedImageId));
} else {
dispatch(imageSelected(null));
}

View File

@ -4,13 +4,12 @@ import {
TypesafeDroppableData,
} from 'app/components/ImageDnd/typesafeDnd';
import { log } from 'app/logging/useLogger';
import {
imageAddedToBatch,
imagesAddedToBatch,
} from 'features/batch/store/batchSlice';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import {
imageSelected,
imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice';
import {
fieldValueChanged,
imageCollectionFieldValueChanged,
@ -21,57 +20,66 @@ import { startAppListening } from '../';
const moduleLog = log.child({ namespace: 'dnd' });
export const imageDropped = createAction<{
export const dndDropped = createAction<{
overData: TypesafeDroppableData;
activeData: TypesafeDraggableData;
}>('dnd/imageDropped');
}>('dnd/dndDropped');
export const addImageDroppedListener = () => {
startAppListening({
actionCreator: imageDropped,
effect: (action, { dispatch, getState }) => {
actionCreator: dndDropped,
effect: async (action, { dispatch, getState, take }) => {
const { activeData, overData } = action.payload;
const { actionType } = overData;
const state = getState();
moduleLog.debug(
{ data: { activeData, overData } },
'Image or selection dropped'
);
// set current image
if (
actionType === 'SET_CURRENT_IMAGE' &&
overData.actionType === 'SET_CURRENT_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(imageSelected(activeData.payload.imageDTO.image_name));
return;
}
// set initial image
if (
actionType === 'SET_INITIAL_IMAGE' &&
overData.actionType === 'SET_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(initialImageChanged(activeData.payload.imageDTO));
return;
}
// add image to batch
if (
actionType === 'ADD_TO_BATCH' &&
overData.actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(imageAddedToBatch(activeData.payload.imageDTO.image_name));
dispatch(imagesAddedToBatch([activeData.payload.imageDTO.image_name]));
return;
}
// add multiple images to batch
if (
actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'GALLERY_SELECTION'
overData.actionType === 'ADD_TO_BATCH' &&
activeData.payloadType === 'IMAGE_NAMES'
) {
dispatch(imagesAddedToBatch(state.gallery.selection));
dispatch(imagesAddedToBatch(activeData.payload.image_names));
return;
}
// set control image
if (
actionType === 'SET_CONTROLNET_IMAGE' &&
overData.actionType === 'SET_CONTROLNET_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
@ -82,20 +90,22 @@ export const addImageDroppedListener = () => {
controlNetId,
})
);
return;
}
// set canvas image
if (
actionType === 'SET_CANVAS_INITIAL_IMAGE' &&
overData.actionType === 'SET_CANVAS_INITIAL_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
dispatch(setInitialCanvasImage(activeData.payload.imageDTO));
return;
}
// set nodes image
if (
actionType === 'SET_NODES_IMAGE' &&
overData.actionType === 'SET_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
@ -107,11 +117,12 @@ export const addImageDroppedListener = () => {
value: activeData.payload.imageDTO,
})
);
return;
}
// set multiple nodes images (single image handler)
if (
actionType === 'SET_MULTI_NODES_IMAGE' &&
overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
@ -123,43 +134,30 @@ export const addImageDroppedListener = () => {
value: [activeData.payload.imageDTO],
})
);
return;
}
// set multiple nodes images (multiple images handler)
if (
actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'GALLERY_SELECTION'
overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
activeData.payloadType === 'IMAGE_NAMES'
) {
const { fieldName, nodeId } = overData.context;
dispatch(
imageCollectionFieldValueChanged({
nodeId,
fieldName,
value: state.gallery.selection.map((image_name) => ({
value: activeData.payload.image_names.map((image_name) => ({
image_name,
})),
})
);
return;
}
// remove image from board
// TODO: remove board_id from `removeImageFromBoard()` endpoint
// TODO: handle multiple images
// if (
// actionType === 'MOVE_BOARD' &&
// activeData.payloadType === 'IMAGE_DTO' &&
// activeData.payload.imageDTO &&
// overData.boardId !== null
// ) {
// const { image_name } = activeData.payload.imageDTO;
// dispatch(
// boardImagesApi.endpoints.removeImageFromBoard.initiate({ image_name })
// );
// }
// add image to board
if (
actionType === 'MOVE_BOARD' &&
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO &&
overData.context.boardId
@ -172,17 +170,89 @@ export const addImageDroppedListener = () => {
board_id: boardId,
})
);
return;
}
// add multiple images to board
// TODO: add endpoint
// if (
// actionType === 'ADD_TO_BATCH' &&
// activeData.payloadType === 'IMAGE_NAMES' &&
// activeData.payload.imageDTONames
// ) {
// dispatch(boardImagesApi.endpoints.addImagesToBoard.intiate({}));
// }
// remove image from board
if (
overData.actionType === 'MOVE_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO &&
overData.context.boardId === null
) {
const { image_name, board_id } = activeData.payload.imageDTO;
if (board_id) {
dispatch(
boardImagesApi.endpoints.removeImageFromBoard.initiate({
image_name,
board_id,
})
);
}
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;
}
// 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;
}
// 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;
}
},
});
};

View File

@ -1,6 +1,5 @@
import { log } from 'app/logging/useLogger';
import { boardImagesApi } from 'services/api/endpoints/boardImages';
import { imageDTOReceived } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'boards' });
@ -15,12 +14,6 @@ export const addImageRemovedFromBoardFulfilledListener = () => {
{ data: { board_id, image_name } },
'Image added to board'
);
dispatch(
imageDTOReceived({
image_name,
})
);
},
});
};

View File

@ -1,13 +1,15 @@
import { startAppListening } from '..';
import { imageUploaded } from 'services/api/thunks/image';
import { addToast } from 'features/system/store/systemSlice';
import { log } from 'app/logging/useLogger';
import { imageUpserted } from 'features/gallery/store/gallerySlice';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import { controlNetImageChanged } from 'features/controlNet/store/controlNetSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import {
imageUpserted,
imagesAddedToBatch,
} from 'features/gallery/store/gallerySlice';
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
import { imageAddedToBatch } from 'features/batch/store/batchSlice';
import { initialImageChanged } from 'features/parameters/store/generationSlice';
import { addToast } from 'features/system/store/systemSlice';
import { imageUploaded } from 'services/api/thunks/image';
import { startAppListening } from '..';
const moduleLog = log.child({ namespace: 'image' });
@ -73,7 +75,7 @@ export const addImageUploadedFulfilledListener = () => {
}
if (postUploadAction?.type === 'ADD_TO_BATCH') {
dispatch(imageAddedToBatch(image.image_name));
dispatch(imagesAddedToBatch([image.image_name]));
return;
}
},

View File

@ -1,19 +0,0 @@
import { startAppListening } from '..';
import { log } from 'app/logging/useLogger';
import {
imagesAddedToBatch,
selectionAddedToBatch,
} from 'features/batch/store/batchSlice';
const moduleLog = log.child({ namespace: 'batch' });
export const addSelectionAddedToBatchListener = () => {
startAppListening({
actionCreator: selectionAddedToBatch,
effect: (action, { dispatch, getState }) => {
const { selection } = getState().gallery;
dispatch(imagesAddedToBatch(selection));
},
});
};

View File

@ -9,14 +9,12 @@ import {
import dynamicMiddlewares from 'redux-dynamic-middlewares';
import { rememberEnhancer, rememberReducer } from 'redux-remember';
import batchReducer from 'features/batch/store/batchSlice';
import canvasReducer from 'features/canvas/store/canvasSlice';
import controlNetReducer from 'features/controlNet/store/controlNetSlice';
import dynamicPromptsReducer from 'features/dynamicPrompts/store/slice';
import boardsReducer from 'features/gallery/store/boardSlice';
import galleryReducer from 'features/gallery/store/gallerySlice';
import imageDeletionReducer from 'features/imageDeletion/store/imageDeletionSlice';
import lightboxReducer from 'features/lightbox/store/lightboxSlice';
import loraReducer from 'features/lora/store/loraSlice';
import nodesReducer from 'features/nodes/store/nodesSlice';
import generationReducer from 'features/parameters/store/generationSlice';
@ -40,7 +38,6 @@ const allReducers = {
canvas: canvasReducer,
gallery: galleryReducer,
generation: generationReducer,
lightbox: lightboxReducer,
nodes: nodesReducer,
postprocessing: postprocessingReducer,
system: systemReducer,
@ -50,7 +47,6 @@ const allReducers = {
controlNet: controlNetReducer,
boards: boardsReducer,
dynamicPrompts: dynamicPromptsReducer,
batch: batchReducer,
imageDeletion: imageDeletionReducer,
lora: loraReducer,
[api.reducerPath]: api.reducer,
@ -64,18 +60,13 @@ const rememberedKeys: (keyof typeof allReducers)[] = [
'canvas',
'gallery',
'generation',
'lightbox',
'nodes',
'postprocessing',
'system',
'ui',
'controlNet',
'dynamicPrompts',
'batch',
'lora',
// 'boards',
// 'hotkeys',
// 'config',
];
export const store = configureStore({
@ -101,10 +92,26 @@ export const store = configureStore({
.concat(dynamicMiddlewares)
.prepend(listenerMiddleware.middleware),
devTools: {
actionsDenylist,
actionSanitizer,
stateSanitizer,
trace: true,
predicate: (state, action) => {
// TODO: hook up to the log level param in system slice
// manually type state, cannot type the arg
// const typedState = state as ReturnType<typeof rootReducer>;
if (action.type.startsWith('api/')) {
// don't log api actions, with manual cache updates they are extremely noisy
return false;
}
if (actionsDenylist.includes(action.type)) {
// don't log other noisy actions
return false;
}
return true;
},
},
});

View File

@ -94,7 +94,8 @@ export type AppFeature =
| 'bugLink'
| 'localization'
| 'consoleLogging'
| 'dynamicPrompting';
| 'dynamicPrompting'
| 'batches';
/**
* A disable-able Stable Diffusion feature

View File

@ -6,30 +6,21 @@ import {
useColorMode,
useColorModeValue,
} from '@chakra-ui/react';
import { useCombinedRefs } from '@dnd-kit/utilities';
import IAIIconButton from 'common/components/IAIIconButton';
import {
IAILoadingImageFallback,
IAINoContentFallback,
} from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { AnimatePresence } from 'framer-motion';
import { MouseEvent, ReactElement, SyntheticEvent } from 'react';
import { memo, useRef } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { ImageDTO } from 'services/api/types';
import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay';
import { PostUploadAction } from 'services/api/thunks/image';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { mode } from 'theme/util/mode';
import {
TypesafeDraggableData,
TypesafeDroppableData,
isValidDrop,
useDraggable,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
import IAIIconButton from 'common/components/IAIIconButton';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { PostUploadAction } from 'services/api/thunks/image';
import { ImageDTO } from 'services/api/types';
import { mode } from 'theme/util/mode';
import IAIDraggable from './IAIDraggable';
import IAIDroppable from './IAIDroppable';
type IAIDndImageProps = {
imageDTO: ImageDTO | undefined;
@ -83,28 +74,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
const { colorMode } = useColorMode();
const dndId = useRef(uuidv4());
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
isDragging,
active,
} = useDraggable({
id: dndId.current,
disabled: isDragDisabled || !imageDTO,
data: draggableData,
});
const { isOver, setNodeRef: setDroppableRef } = useDroppable({
id: dndId.current,
disabled: isDropDisabled,
data: droppableData,
});
const setDndRef = useCombinedRefs(setDroppableRef, setDraggableRef);
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction,
isDisabled: isUploadDisabled,
@ -139,9 +108,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
userSelect: 'none',
cursor: isDragDisabled || !imageDTO ? 'default' : 'pointer',
}}
{...attributes}
{...listeners}
ref={setDndRef}
>
{imageDTO && (
<Flex
@ -154,10 +120,13 @@ const IAIDndImage = (props: IAIDndImageProps) => {
}}
>
<Image
onClick={onClick}
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError"
fallback={<IAILoadingImageFallback image={imageDTO} />}
// If we fall back to thumbnail, it feels much snappier than the skeleton...
fallbackSrc={imageDTO.thumbnail_url}
// fallback={<IAILoadingImageFallback image={imageDTO} />}
width={imageDTO.width}
height={imageDTO.height}
onError={onError}
draggable={false}
sx={{
@ -171,30 +140,6 @@ const IAIDndImage = (props: IAIDndImageProps) => {
}}
/>
{withMetadataOverlay && <ImageMetadataOverlay image={imageDTO} />}
{onClickReset && withResetIcon && (
<IAIIconButton
onClick={onClickReset}
aria-label={resetTooltip}
tooltip={resetTooltip}
icon={resetIcon}
size="sm"
variant="link"
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: resetIconShadow,
},
}}
/>
)}
</Flex>
)}
{!imageDTO && !isUploadDisabled && (
@ -225,11 +170,42 @@ const IAIDndImage = (props: IAIDndImageProps) => {
</>
)}
{!imageDTO && isUploadDisabled && noContentFallback}
<AnimatePresence>
{isValidDrop(droppableData, active) && !isDragging && (
<IAIDropOverlay isOver={isOver} label={dropLabel} />
)}
</AnimatePresence>
<IAIDroppable
data={droppableData}
disabled={isDropDisabled}
dropLabel={dropLabel}
/>
{imageDTO && (
<IAIDraggable
data={draggableData}
disabled={isDragDisabled || !imageDTO}
onClick={onClick}
/>
)}
{onClickReset && withResetIcon && imageDTO && (
<IAIIconButton
onClick={onClickReset}
aria-label={resetTooltip}
tooltip={resetTooltip}
icon={resetIcon}
size="sm"
variant="link"
sx={{
position: 'absolute',
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: resetIconShadow,
},
}}
/>
)}
</Flex>
);
};

View File

@ -0,0 +1,40 @@
import { Box } from '@chakra-ui/react';
import {
TypesafeDraggableData,
useDraggable,
} from 'app/components/ImageDnd/typesafeDnd';
import { MouseEvent, memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
type IAIDraggableProps = {
disabled?: boolean;
data?: TypesafeDraggableData;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
};
const IAIDraggable = (props: IAIDraggableProps) => {
const { data, disabled, onClick } = props;
const dndId = useRef(uuidv4());
const { attributes, listeners, setNodeRef } = useDraggable({
id: dndId.current,
disabled,
data,
});
return (
<Box
onClick={onClick}
ref={setNodeRef}
position="absolute"
w="full"
h="full"
top={0}
insetInlineStart={0}
{...attributes}
{...listeners}
/>
);
};
export default memo(IAIDraggable);

View File

@ -0,0 +1,47 @@
import { Box } from '@chakra-ui/react';
import {
TypesafeDroppableData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
import { AnimatePresence } from 'framer-motion';
import { memo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import IAIDropOverlay from './IAIDropOverlay';
type IAIDroppableProps = {
dropLabel?: string;
disabled?: boolean;
data?: TypesafeDroppableData;
};
const IAIDroppable = (props: IAIDroppableProps) => {
const { dropLabel, data, disabled } = props;
const dndId = useRef(uuidv4());
const { isOver, setNodeRef, active } = useDroppable({
id: dndId.current,
disabled,
data,
});
return (
<Box
ref={setNodeRef}
position="absolute"
top={0}
insetInlineStart={0}
w="full"
h="full"
pointerEvents="none"
>
<AnimatePresence>
{isValidDrop(data, active) && (
<IAIDropOverlay isOver={isOver} label={dropLabel} />
)}
</AnimatePresence>
</Box>
);
};
export default memo(IAIDroppable);

View File

@ -0,0 +1,42 @@
import { Box, Flex, Icon } from '@chakra-ui/react';
import { FaExclamation } from 'react-icons/fa';
const IAIErrorLoadingImageFallback = () => {
return (
<Box
sx={{
position: 'relative',
height: 'full',
width: 'full',
'::before': {
content: "''",
display: 'block',
pt: '100%',
},
}}
>
<Flex
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
height: 'full',
width: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
bg: 'base.100',
color: 'base.500',
_dark: {
color: 'base.700',
bg: 'base.850',
},
}}
>
<Icon as={FaExclamation} boxSize={16} opacity={0.7} />
</Flex>
</Box>
);
};
export default IAIErrorLoadingImageFallback;

View File

@ -0,0 +1,30 @@
import { Box, Skeleton } from '@chakra-ui/react';
const IAIFillSkeleton = () => {
return (
<Skeleton
sx={{
position: 'relative',
height: 'full',
width: 'full',
'::before': {
content: "''",
display: 'block',
pt: '100%',
},
}}
>
<Box
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
height: 'full',
width: 'full',
}}
/>
</Skeleton>
);
};
export default IAIFillSkeleton;

View File

@ -3,36 +3,22 @@ import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { validateSeedWeights } from 'common/util/seedWeightPairs';
import { generationSelector } from 'features/parameters/store/generationSelectors';
import { systemSelector } from 'features/system/store/systemSelectors';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import {
modelsApi,
useGetMainModelsQuery,
} from '../../services/api/endpoints/models';
import { modelsApi } from '../../services/api/endpoints/models';
const readinessSelector = createSelector(
[stateSelector, activeTabNameSelector],
(state, activeTabName) => {
const { generation, system, batch } = state;
const { generation, system } = state;
const { shouldGenerateVariations, seedWeights, initialImage, seed } =
generation;
const { isProcessing, isConnected } = system;
const {
isEnabled: isBatchEnabled,
asInitialImage,
imageNames: batchImageNames,
} = batch;
let isReady = true;
const reasonsWhyNotReady: string[] = [];
if (
activeTabName === 'img2img' &&
!initialImage &&
!(asInitialImage && batchImageNames.length > 1)
) {
if (activeTabName === 'img2img' && !initialImage) {
isReady = false;
reasonsWhyNotReady.push('No initial image selected');
}

View File

@ -1,67 +0,0 @@
import {
Flex,
FormControl,
FormLabel,
Heading,
Spacer,
Switch,
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 IAISwitch from 'common/components/IAISwitch';
import { ControlNetConfig } from 'features/controlNet/store/controlNetSlice';
import { ChangeEvent, memo, useCallback } from 'react';
import { controlNetToggled } from '../store/batchSlice';
type Props = {
controlNet: ControlNetConfig;
};
const selector = createSelector(
[stateSelector, (state, controlNetId: string) => controlNetId],
(state, controlNetId) => {
const isControlNetEnabled = state.batch.controlNets.includes(controlNetId);
return { isControlNetEnabled };
},
defaultSelectorOptions
);
const BatchControlNet = (props: Props) => {
const dispatch = useAppDispatch();
const { isControlNetEnabled } = useAppSelector((state) =>
selector(state, props.controlNet.controlNetId)
);
const { processorType, model } = props.controlNet;
const handleChangeAsControlNet = useCallback(() => {
dispatch(controlNetToggled(props.controlNet.controlNetId));
}, [dispatch, props.controlNet.controlNetId]);
return (
<Flex
layerStyle="second"
sx={{ flexDir: 'column', gap: 1, p: 4, borderRadius: 'base' }}
>
<Flex sx={{ justifyContent: 'space-between' }}>
<FormControl as={Flex} onClick={handleChangeAsControlNet}>
<FormLabel>
<Heading size="sm">ControlNet</Heading>
</FormLabel>
<Spacer />
<Switch isChecked={isControlNetEnabled} />
</FormControl>
</Flex>
<Text>
<strong>Model:</strong> {model}
</Text>
<Text>
<strong>Processor:</strong> {processorType}
</Text>
</Flex>
);
};
export default memo(BatchControlNet);

View File

@ -1,116 +0,0 @@
import { Box, Icon, Skeleton } 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 {
batchImageRangeEndSelected,
batchImageSelected,
batchImageSelectionToggled,
imageRemovedFromBatch,
} from 'features/batch/store/batchSlice';
import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { FaExclamationCircle } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const makeSelector = (image_name: string) =>
createSelector(
[stateSelector],
(state) => ({
selectionCount: state.batch.selection.length,
isSelected: state.batch.selection.includes(image_name),
}),
defaultSelectorOptions
);
type BatchImageProps = {
imageName: string;
};
const BatchImage = (props: BatchImageProps) => {
const {
currentData: imageDTO,
isFetching,
isError,
isSuccess,
} = useGetImageDTOQuery(props.imageName);
const dispatch = useAppDispatch();
const selector = useMemo(
() => makeSelector(props.imageName),
[props.imageName]
);
const { isSelected, selectionCount } = useAppSelector(selector);
const handleClickRemove = useCallback(() => {
dispatch(imageRemovedFromBatch(props.imageName));
}, [dispatch, props.imageName]);
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (e.shiftKey) {
dispatch(batchImageRangeEndSelected(props.imageName));
} else if (e.ctrlKey || e.metaKey) {
dispatch(batchImageSelectionToggled(props.imageName));
} else {
dispatch(batchImageSelected(props.imageName));
}
},
[dispatch, props.imageName]
);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
if (selectionCount > 1) {
return {
id: 'batch',
payloadType: 'BATCH_SELECTION',
};
}
if (imageDTO) {
return {
id: 'batch',
payloadType: 'IMAGE_DTO',
payload: { imageDTO },
};
}
}, [imageDTO, selectionCount]);
if (isError) {
return <Icon as={FaExclamationCircle} />;
}
if (isFetching) {
return (
<Skeleton>
<Box w="full" h="full" aspectRatio="1/1" />
</Skeleton>
);
}
return (
<Box sx={{ position: 'relative', aspectRatio: '1/1' }}>
<IAIDndImage
imageDTO={imageDTO}
draggableData={draggableData}
isDropDisabled={true}
isUploadDisabled={true}
imageSx={{
w: 'full',
h: 'full',
}}
onClick={handleClick}
isSelected={isSelected}
onClickReset={handleClickRemove}
resetTooltip="Remove from batch"
withResetIcon
thumbnail
/>
</Box>
);
};
export default memo(BatchImage);

View File

@ -1,31 +0,0 @@
import { Box } from '@chakra-ui/react';
import BatchImageGrid from './BatchImageGrid';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import {
AddToBatchDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
const droppableData: AddToBatchDropData = {
id: 'batch',
actionType: 'ADD_TO_BATCH',
};
const BatchImageContainer = () => {
const { isOver, setNodeRef, active } = useDroppable({
id: 'batch-manager',
data: droppableData,
});
return (
<Box ref={setNodeRef} position="relative" w="full" h="full">
<BatchImageGrid />
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} label="Add to Batch" />
)}
</Box>
);
};
export default BatchImageContainer;

View File

@ -1,54 +0,0 @@
import { FaImages } from 'react-icons/fa';
import { Grid, GridItem } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import BatchImage from './BatchImage';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
const selector = createSelector(
stateSelector,
(state) => {
const imageNames = state.batch.imageNames.concat().reverse();
return { imageNames };
},
defaultSelectorOptions
);
const BatchImageGrid = () => {
const { imageNames } = useAppSelector(selector);
if (imageNames.length === 0) {
return (
<IAINoContentFallback
icon={FaImages}
boxSize={16}
label="No images in Batch"
/>
);
}
return (
<Grid
sx={{
position: 'absolute',
flexWrap: 'wrap',
w: 'full',
minH: 0,
maxH: 'full',
overflowY: 'scroll',
gridTemplateColumns: `repeat(auto-fill, minmax(128px, 1fr))`,
}}
>
{imageNames.map((imageName) => (
<GridItem key={imageName} sx={{ p: 1.5 }}>
<BatchImage imageName={imageName} />
</GridItem>
))}
</Grid>
);
};
export default BatchImageGrid;

View File

@ -1,103 +0,0 @@
import { Flex, Heading, Spacer } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useCallback } from 'react';
import IAISwitch from 'common/components/IAISwitch';
import {
asInitialImageToggled,
batchReset,
isEnabledChanged,
} from 'features/batch/store/batchSlice';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIButton from 'common/components/IAIButton';
import BatchImageContainer from './BatchImageGrid';
import { map } from 'lodash-es';
import BatchControlNet from './BatchControlNet';
const selector = createSelector(
stateSelector,
(state) => {
const { controlNets } = state.controlNet;
const {
imageNames,
asInitialImage,
controlNets: batchControlNets,
isEnabled,
} = state.batch;
return {
imageCount: imageNames.length,
asInitialImage,
controlNets,
batchControlNets,
isEnabled,
};
},
defaultSelectorOptions
);
const BatchManager = () => {
const dispatch = useAppDispatch();
const { imageCount, isEnabled, controlNets, batchControlNets } =
useAppSelector(selector);
const handleResetBatch = useCallback(() => {
dispatch(batchReset());
}, [dispatch]);
const handleToggle = useCallback(() => {
dispatch(isEnabledChanged(!isEnabled));
}, [dispatch, isEnabled]);
const handleChangeAsInitialImage = useCallback(() => {
dispatch(asInitialImageToggled());
}, [dispatch]);
return (
<Flex
sx={{
h: 'full',
w: 'full',
flexDir: 'column',
position: 'relative',
gap: 2,
minW: 0,
}}
>
<Flex sx={{ alignItems: 'center' }}>
<Heading
size={'md'}
sx={{ color: 'base.800', _dark: { color: 'base.200' } }}
>
{imageCount || 'No'} images
</Heading>
<Spacer />
<IAIButton onClick={handleResetBatch}>Reset</IAIButton>
</Flex>
<Flex
sx={{
alignItems: 'center',
flexDir: 'column',
gap: 4,
}}
>
<IAISwitch
label="Use as Initial Image"
onChange={handleChangeAsInitialImage}
/>
{map(controlNets, (controlNet) => {
return (
<BatchControlNet
key={controlNet.controlNetId}
controlNet={controlNet}
/>
);
})}
</Flex>
<BatchImageContainer />
</Flex>
);
};
export default BatchManager;

View File

@ -1,142 +0,0 @@
import { PayloadAction, createAction, createSlice } from '@reduxjs/toolkit';
import { uniq } from 'lodash-es';
import { imageDeleted } from 'services/api/thunks/image';
type BatchState = {
isEnabled: boolean;
imageNames: string[];
asInitialImage: boolean;
controlNets: string[];
selection: string[];
};
export const initialBatchState: BatchState = {
isEnabled: false,
imageNames: [],
asInitialImage: false,
controlNets: [],
selection: [],
};
const batch = createSlice({
name: 'batch',
initialState: initialBatchState,
reducers: {
isEnabledChanged: (state, action: PayloadAction<boolean>) => {
state.isEnabled = action.payload;
},
imageAddedToBatch: (state, action: PayloadAction<string>) => {
state.imageNames = uniq(state.imageNames.concat(action.payload));
},
imagesAddedToBatch: (state, action: PayloadAction<string[]>) => {
state.imageNames = uniq(state.imageNames.concat(action.payload));
},
imageRemovedFromBatch: (state, action: PayloadAction<string>) => {
state.imageNames = state.imageNames.filter(
(imageName) => action.payload !== imageName
);
state.selection = state.selection.filter(
(imageName) => action.payload !== imageName
);
},
imagesRemovedFromBatch: (state, action: PayloadAction<string[]>) => {
state.imageNames = state.imageNames.filter(
(imageName) => !action.payload.includes(imageName)
);
state.selection = state.selection.filter(
(imageName) => !action.payload.includes(imageName)
);
},
batchImageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload;
const lastSelectedImage = state.selection[state.selection.length - 1];
const lastClickedIndex = state.imageNames.findIndex(
(n) => n === lastSelectedImage
);
const currentClickedIndex = state.imageNames.findIndex(
(n) => n === 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 = state.imageNames.slice(start, end + 1);
state.selection = uniq(state.selection.concat(imagesToSelect));
}
},
batchImageSelectionToggled: (state, action: PayloadAction<string>) => {
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));
}
},
batchImageSelected: (state, action: PayloadAction<string | null>) => {
state.selection = action.payload
? [action.payload]
: [String(state.imageNames[0])];
},
batchReset: (state) => {
state.imageNames = [];
state.selection = [];
},
asInitialImageToggled: (state) => {
state.asInitialImage = !state.asInitialImage;
},
controlNetAddedToBatch: (state, action: PayloadAction<string>) => {
state.controlNets = uniq(state.controlNets.concat(action.payload));
},
controlNetRemovedFromBatch: (state, action: PayloadAction<string>) => {
state.controlNets = state.controlNets.filter(
(controlNetId) => controlNetId !== action.payload
);
},
controlNetToggled: (state, action: PayloadAction<string>) => {
if (state.controlNets.includes(action.payload)) {
state.controlNets = state.controlNets.filter(
(controlNetId) => controlNetId !== action.payload
);
} else {
state.controlNets = uniq(state.controlNets.concat(action.payload));
}
},
},
extraReducers: (builder) => {
builder.addCase(imageDeleted.fulfilled, (state, action) => {
state.imageNames = state.imageNames.filter(
(imageName) => imageName !== action.meta.arg.image_name
);
state.selection = state.selection.filter(
(imageName) => imageName !== action.meta.arg.image_name
);
});
},
});
export const {
isEnabledChanged,
imageAddedToBatch,
imagesAddedToBatch,
imageRemovedFromBatch,
imagesRemovedFromBatch,
asInitialImageToggled,
controlNetAddedToBatch,
controlNetRemovedFromBatch,
batchReset,
controlNetToggled,
batchImageRangeEndSelected,
batchImageSelectionToggled,
batchImageSelected,
} = batch.actions;
export default batch.reducer;
export const selectionAddedToBatch = createAction(
'batch/selectionAddedToBatch'
);

View File

@ -1,93 +0,0 @@
import { Flex, useColorMode } from '@chakra-ui/react';
import { FaImages } from 'react-icons/fa';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { useDispatch } from 'react-redux';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { AnimatePresence } from 'framer-motion';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import { mode } from 'theme/util/mode';
import {
MoveBoardDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const { colorMode } = useColorMode();
const handleAllImagesBoardClick = () => {
dispatch(boardIdSelected());
};
const droppableData: MoveBoardDropData = {
id: 'all-images-board',
actionType: 'MOVE_BOARD',
context: { boardId: null },
};
const { isOver, setNodeRef, active } = useDroppable({
id: `board_droppable_all_images`,
data: droppableData,
});
return (
<Flex
sx={{
flexDir: 'column',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
w: 'full',
h: 'full',
borderRadius: 'base',
}}
>
<Flex
ref={setNodeRef}
onClick={handleAllImagesBoardClick}
sx={{
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 'base',
w: 'full',
aspectRatio: '1/1',
overflow: 'hidden',
shadow: isSelected ? 'selected.light' : undefined,
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
flexShrink: 0,
}}
>
<IAINoContentFallback
boxSize={8}
icon={FaImages}
sx={{
border: '2px solid var(--invokeai-colors-base-200)',
_dark: { border: '2px solid var(--invokeai-colors-base-800)' },
}}
/>
<AnimatePresence>
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} />
)}
</AnimatePresence>
</Flex>
<Flex
sx={{
h: 'full',
alignItems: 'center',
color: isSelected
? mode('base.900', 'base.50')(colorMode)
: mode('base.700', 'base.200')(colorMode),
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
}}
>
All Images
</Flex>
</Flex>
);
};
export default AllImagesBoard;

View File

@ -0,0 +1,31 @@
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { FaImages } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import GenericBoard from './GenericBoard';
const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
const dispatch = useDispatch();
const handleAllImagesBoardClick = () => {
dispatch(boardIdSelected('all'));
};
const droppableData: MoveBoardDropData = {
id: 'all-images-board',
actionType: 'MOVE_BOARD',
context: { boardId: null },
};
return (
<GenericBoard
droppableData={droppableData}
onClick={handleAllImagesBoardClick}
isSelected={isSelected}
icon={FaImages}
label="All Images"
/>
);
};
export default AllImagesBoard;

View File

@ -0,0 +1,42 @@
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
droppableData={droppableData}
onClick={handleBatchBoardClick}
isSelected={isSelected}
icon={FaLayerGroup}
label="Batch"
badgeCount={count}
/>
);
};
export default BatchBoard;

View File

@ -1,3 +1,4 @@
import { CloseIcon } from '@chakra-ui/icons';
import {
Collapse,
Flex,
@ -9,17 +10,18 @@ import {
InputRightElement,
} 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 { setBoardSearchText } from 'features/gallery/store/boardSlice';
import { memo, useState } from 'react';
import HoverableBoard from './HoverableBoard';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import { memo, useState } from 'react';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import AddBoardButton from './AddBoardButton';
import AllImagesBoard from './AllImagesBoard';
import { CloseIcon } from '@chakra-ui/icons';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { stateSelector } from 'app/store/store';
import BatchBoard from './BatchBoard';
import GalleryBoard from './GalleryBoard';
import { useFeatureStatus } from '../../../../system/hooks/useFeatureStatus';
const selector = createSelector(
[stateSelector],
@ -42,6 +44,8 @@ const BoardsList = (props: Props) => {
const { data: boards } = useListAllBoardsQuery();
const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled;
const filteredBoards = searchText
? boards?.filter((board) =>
board.board_name.toLowerCase().includes(searchText.toLowerCase())
@ -115,14 +119,21 @@ const BoardsList = (props: Props) => {
}}
>
{!searchMode && (
<GridItem sx={{ p: 1.5 }}>
<AllImagesBoard isSelected={!selectedBoardId} />
</GridItem>
<>
<GridItem sx={{ p: 1.5 }}>
<AllImagesBoard isSelected={selectedBoardId === 'all'} />
</GridItem>
{isBatchEnabled && (
<GridItem sx={{ p: 1.5 }}>
<BatchBoard isSelected={selectedBoardId === 'batch'} />
</GridItem>
)}
</>
)}
{filteredBoards &&
filteredBoards.map((board) => (
<GridItem key={board.board_id} sx={{ p: 1.5 }}>
<HoverableBoard
<GalleryBoard
board={board}
isSelected={selectedBoardId === board.board_id}
/>

View File

@ -12,35 +12,31 @@ import {
} from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { memo, useCallback, useContext } from 'react';
import { FaFolder, FaTrash } from 'react-icons/fa';
import { ContextMenu } from 'chakra-ui-contextmenu';
import { BoardDTO } from 'services/api/types';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useContext, useMemo } from 'react';
import { FaFolder, FaImages, FaTrash } from 'react-icons/fa';
import {
useDeleteBoardMutation,
useUpdateBoardMutation,
} from 'services/api/endpoints/boards';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { BoardDTO } from 'services/api/types';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { AnimatePresence } from 'framer-motion';
import IAIDropOverlay from 'common/components/IAIDropOverlay';
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd';
// import { boardAddedToBatch } from 'app/store/middleware/listenerMiddleware/listeners/addBoardToBatch';
import IAIDroppable from 'common/components/IAIDroppable';
import { mode } from 'theme/util/mode';
import {
MoveBoardDropData,
isValidDrop,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
import { DeleteBoardImagesContext } from '../../../../../app/contexts/DeleteBoardImagesContext';
interface HoverableBoardProps {
interface GalleryBoardProps {
board: BoardDTO;
isSelected: boolean;
}
const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
const GalleryBoard = memo(({ board, isSelected }: GalleryBoardProps) => {
const dispatch = useAppDispatch();
const { currentData: coverImage } = useGetImageDTOQuery(
@ -71,21 +67,22 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
deleteBoard(board_id);
}, [board_id, deleteBoard]);
const handleAddBoardToBatch = useCallback(() => {
// dispatch(boardAddedToBatch({ board_id }));
}, []);
const handleDeleteBoardAndImages = useCallback(() => {
console.log({ board });
onClickDeleteBoardImages(board);
}, [board, onClickDeleteBoardImages]);
const droppableData: MoveBoardDropData = {
id: board_id,
actionType: 'MOVE_BOARD',
context: { boardId: board_id },
};
const { isOver, setNodeRef, active } = useDroppable({
id: `board_droppable_${board_id}`,
data: droppableData,
});
const droppableData: MoveBoardDropData = useMemo(
() => ({
id: board_id,
actionType: 'MOVE_BOARD',
context: { boardId: board_id },
}),
[board_id]
);
return (
<Box sx={{ touchAction: 'none', height: 'full' }}>
@ -94,16 +91,25 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}>
{board.image_count > 0 && (
<MenuItem
sx={{ color: 'error.300' }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoardAndImages}
>
Delete Board and Images
</MenuItem>
<>
<MenuItem
isDisabled={!board.image_count}
icon={<FaImages />}
onClickCapture={handleAddBoardToBatch}
>
Add Board to Batch
</MenuItem>
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoardAndImages}
>
Delete Board and Images
</MenuItem>
</>
)}
<MenuItem
sx={{ color: mode('error.700', 'error.300')(colorMode) }}
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteBoard}
>
@ -127,7 +133,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
}}
>
<Flex
ref={setNodeRef}
onClick={handleSelectBoard}
sx={{
position: 'relative',
@ -167,11 +172,7 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
>
<Badge variant="solid">{board.image_count}</Badge>
</Flex>
<AnimatePresence>
{isValidDrop(droppableData, active) && (
<IAIDropOverlay isOver={isOver} />
)}
</AnimatePresence>
<IAIDroppable data={droppableData} />
</Flex>
<Flex
@ -219,6 +220,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
);
});
HoverableBoard.displayName = 'HoverableBoard';
GalleryBoard.displayName = 'HoverableBoard';
export default HoverableBoard;
export default GalleryBoard;

View File

@ -0,0 +1,83 @@
import { As, Badge, Flex } from '@chakra-ui/react';
import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd';
import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
type GenericBoardProps = {
droppableData: TypesafeDroppableData;
onClick: () => void;
isSelected: boolean;
icon: As;
label: string;
badgeCount?: number;
};
const GenericBoard = (props: GenericBoardProps) => {
const { droppableData, onClick, isSelected, icon, label, badgeCount } = props;
return (
<Flex
sx={{
flexDir: 'column',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
w: 'full',
h: 'full',
borderRadius: 'base',
}}
>
<Flex
onClick={onClick}
sx={{
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 'base',
w: 'full',
aspectRatio: '1/1',
overflow: 'hidden',
shadow: isSelected ? 'selected.light' : undefined,
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
flexShrink: 0,
}}
>
<IAINoContentFallback
boxSize={8}
icon={icon}
sx={{
border: '2px solid var(--invokeai-colors-base-200)',
_dark: { border: '2px solid var(--invokeai-colors-base-800)' },
}}
/>
<Flex
sx={{
position: 'absolute',
insetInlineEnd: 0,
top: 0,
p: 1,
}}
>
{badgeCount !== undefined && (
<Badge variant="solid">{badgeCount}</Badge>
)}
</Flex>
<IAIDroppable data={droppableData} />
</Flex>
<Flex
sx={{
h: 'full',
alignItems: 'center',
fontWeight: isSelected ? 600 : undefined,
fontSize: 'xs',
color: isSelected ? 'base.900' : 'base.700',
_dark: { color: isSelected ? 'base.50' : 'base.200' },
}}
>
{label}
</Flex>
</Flex>
);
};
export default GenericBoard;

View File

@ -8,8 +8,6 @@ import IAIButton from 'common/components/IAIButton';
import IAIIconButton from 'common/components/IAIIconButton';
import IAIPopover from 'common/components/IAIPopover';
import { setIsLightboxOpen } from 'features/lightbox/store/lightboxSlice';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { useAppToaster } from 'app/components/Toaster';
import { stateSelector } from 'app/store/store';
@ -36,7 +34,6 @@ import {
FaCode,
FaCopy,
FaDownload,
FaExpand,
FaExpandArrowsAlt,
FaGrinStars,
FaHourglassHalf,
@ -50,11 +47,11 @@ import {
useGetImageMetadataQuery,
} from 'services/api/endpoints/images';
import { useDebounce } from 'use-debounce';
import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
const currentImageButtonsSelector = createSelector(
[stateSelector, activeTabNameSelector],
({ gallery, system, postprocessing, ui, lightbox }, activeTabName) => {
({ gallery, system, postprocessing, ui }, activeTabName) => {
const {
isProcessing,
isConnected,
@ -66,8 +63,6 @@ const currentImageButtonsSelector = createSelector(
const { upscalingLevel, facetoolStrength } = postprocessing;
const { isLightboxOpen } = lightbox;
const {
shouldShowImageDetails,
shouldHidePreview,
@ -88,7 +83,6 @@ const currentImageButtonsSelector = createSelector(
shouldDisableToolbarButtons: Boolean(progressImage) || !lastSelectedImage,
shouldShowImageDetails,
activeTabName,
isLightboxOpen,
shouldHidePreview,
shouldShowProgressInViewer,
lastSelectedImage,
@ -114,14 +108,11 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
facetoolStrength,
shouldDisableToolbarButtons,
shouldShowImageDetails,
isLightboxOpen,
activeTabName,
shouldHidePreview,
lastSelectedImage,
shouldShowProgressInViewer,
} = useAppSelector(currentImageButtonsSelector);
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
const isUpscalingEnabled = useFeatureStatus('upscaling').isFeatureEnabled;
const isFaceRestoreEnabled = useFeatureStatus('faceRestore').isFeatureEnabled;
@ -149,30 +140,6 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const metadata = metadataData?.metadata;
// const handleCopyImage = useCallback(async () => {
// if (!image?.url) {
// return;
// }
// const url = getUrl(image.url);
// if (!url) {
// return;
// }
// const blob = await fetch(url).then((res) => res.blob());
// const data = [new ClipboardItem({ [blob.type]: blob })];
// await navigator.clipboard.write(data);
// toast({
// title: t('toast.imageCopied'),
// status: 'success',
// duration: 2500,
// isClosable: true,
// });
// }, [getUrl, t, image?.url, toast]);
const handleCopyImageLink = useCallback(() => {
const getImageUrl = () => {
if (!image) {
@ -318,7 +285,6 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
const handleSendToCanvas = useCallback(() => {
if (!image) return;
dispatch(sentImageToCanvas());
if (isLightboxOpen) dispatch(setIsLightboxOpen(false));
dispatch(setInitialCanvasImage(image));
dispatch(requestCanvasRescale());
@ -333,7 +299,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
duration: 2500,
isClosable: true,
});
}, [image, isLightboxOpen, dispatch, activeTabName, toaster, t]);
}, [image, dispatch, activeTabName, toaster, t]);
useHotkeys(
'i',
@ -356,10 +322,6 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
dispatch(setShouldShowProgressInViewer(!shouldShowProgressInViewer));
}, [dispatch, shouldShowProgressInViewer]);
const handleLightBox = useCallback(() => {
dispatch(setIsLightboxOpen(!isLightboxOpen));
}, [dispatch, isLightboxOpen]);
return (
<>
<Flex
@ -429,24 +391,6 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
</Link>
</Flex>
</IAIPopover>
{isLightboxEnabled && (
<IAIIconButton
icon={<FaExpand />}
tooltip={
!isLightboxOpen
? `${t('parameters.openInViewer')} (Z)`
: `${t('parameters.closeViewer')} (Z)`
}
aria-label={
!isLightboxOpen
? `${t('parameters.openInViewer')} (Z)`
: `${t('parameters.closeViewer')} (Z)`
}
isChecked={isLightboxOpen}
onClick={handleLightBox}
isDisabled={shouldDisableToolbarButtons}
/>
)}
</ButtonGroup>
<ButtonGroup isAttached={true} isDisabled={shouldDisableToolbarButtons}>

View File

@ -8,14 +8,15 @@ import {
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySlice';
import { useNextPrevImage } from 'features/gallery/hooks/useNextPrevImage';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { AnimatePresence, motion } from 'framer-motion';
import { isEqual } from 'lodash-es';
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useNextPrevImage } from '../hooks/useNextPrevImage';
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
import NextPrevImageButtons from './NextPrevImageButtons';
import ImageMetadataViewer from '../ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from '../NextPrevImageButtons';
export const imagesSelector = createSelector(
[stateSelector, selectLastSelectedImage],
@ -115,8 +116,27 @@ const CurrentImagePreview = () => {
[]
);
// Show and hide the next/prev buttons on mouse move
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] =
useState<boolean>(false);
const timeoutId = useRef(0);
const handleMouseOver = useCallback(() => {
setShouldShowNextPrevButtons(true);
window.clearTimeout(timeoutId.current);
}, []);
const handleMouseOut = useCallback(() => {
timeoutId.current = window.setTimeout(() => {
setShouldShowNextPrevButtons(false);
}, 500);
}, []);
return (
<Flex
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
sx={{
width: 'full',
height: 'full',
@ -164,19 +184,33 @@ const CurrentImagePreview = () => {
<ImageMetadataViewer image={imageDTO} />
</Box>
)}
{!shouldShowImageDetails && imageDTO && (
<Box
sx={{
position: 'absolute',
top: '0',
width: 'full',
height: 'full',
pointerEvents: 'none',
}}
>
<NextPrevImageButtons />
</Box>
)}
<AnimatePresence>
{!shouldShowImageDetails && imageDTO && shouldShowNextPrevButtons && (
<motion.div
key="nextPrevButtons"
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
transition: { duration: 0.1 },
}}
exit={{
opacity: 0,
transition: { duration: 0.1 },
}}
style={{
position: 'absolute',
top: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
>
<NextPrevImageButtons />
</motion.div>
)}
</AnimatePresence>
</Flex>
);
};

View File

@ -5,32 +5,23 @@ import { setGalleryImageMinimumWidth } from 'features/gallery/store/gallerySlice
import { clamp, isEqual } from 'lodash-es';
import { useHotkeys } from 'react-hotkeys-hook';
import './ImageGallery.css';
import ImageGalleryContent from './ImageGalleryContent';
import ResizableDrawer from 'features/ui/components/common/ResizableDrawer/ResizableDrawer';
import { setShouldShowGallery } from 'features/ui/store/uiSlice';
import { createSelector } from '@reduxjs/toolkit';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import ResizableDrawer from 'features/ui/components/common/ResizableDrawer/ResizableDrawer';
import {
activeTabNameSelector,
uiSelector,
} from 'features/ui/store/uiSelectors';
import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
import { setShouldShowGallery } from 'features/ui/store/uiSlice';
import { memo } from 'react';
import ImageGalleryContent from './ImageGalleryContent';
const selector = createSelector(
[
activeTabNameSelector,
uiSelector,
gallerySelector,
isStagingSelector,
lightboxSelector,
],
(activeTabName, ui, gallery, isStaging, lightbox) => {
[activeTabNameSelector, uiSelector, gallerySelector, isStagingSelector],
(activeTabName, ui, gallery, isStaging) => {
const { shouldPinGallery, shouldShowGallery } = ui;
const { galleryImageMinimumWidth } = gallery;
const { isLightboxOpen } = lightbox;
return {
activeTabName,
@ -39,7 +30,6 @@ const selector = createSelector(
shouldShowGallery,
galleryImageMinimumWidth,
isResizable: activeTabName !== 'unifiedCanvas',
isLightboxOpen,
};
},
{
@ -58,7 +48,6 @@ const GalleryDrawer = () => {
// activeTabName,
// isStaging,
// isResizable,
// isLightboxOpen,
} = useAppSelector(selector);
const handleCloseGallery = () => {

View File

@ -0,0 +1,52 @@
import { MenuList } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
import { memo, useMemo } from 'react';
import { ImageDTO } from 'services/api/types';
import MultipleSelectionMenuItems from './MultipleSelectionMenuItems';
import SingleSelectionMenuItems from './SingleSelectionMenuItems';
type Props = {
imageDTO: ImageDTO;
children: ContextMenuProps<HTMLDivElement>['children'];
};
const ImageContextMenu = ({ imageDTO, children }: Props) => {
const selector = useMemo(
() =>
createSelector(
[stateSelector],
({ gallery }) => {
const selectionCount = gallery.selection.length;
return { selectionCount };
},
defaultSelectorOptions
),
[]
);
const { selectionCount } = useAppSelector(selector);
return (
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}>
{selectionCount === 1 ? (
<SingleSelectionMenuItems imageDTO={imageDTO} />
) : (
<MultipleSelectionMenuItems />
)}
</MenuList>
)}
>
{children}
</ContextMenu>
);
};
export default memo(ImageContextMenu);

View File

@ -0,0 +1,40 @@
import { MenuItem } from '@chakra-ui/react';
import { useCallback } from 'react';
import { FaFolder, FaFolderPlus, FaTrash } from 'react-icons/fa';
const MultipleSelectionMenuItems = () => {
const handleAddSelectionToBoard = useCallback(() => {
// TODO: add selection to board
}, []);
const handleDeleteSelection = useCallback(() => {
// TODO: delete all selected images
}, []);
const handleAddSelectionToBatch = useCallback(() => {
// TODO: add selection to batch
}, []);
return (
<>
<MenuItem icon={<FaFolder />} onClickCapture={handleAddSelectionToBoard}>
Move Selection to Board
</MenuItem>
<MenuItem
icon={<FaFolderPlus />}
onClickCapture={handleAddSelectionToBatch}
>
Add Selection to Batch
</MenuItem>
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDeleteSelection}
>
Delete Selection
</MenuItem>
</>
);
};
export default MultipleSelectionMenuItems;

View File

@ -1,16 +1,15 @@
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { MenuItem, MenuList } from '@chakra-ui/react';
import { MenuItem } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
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 { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
import { imagesAddedToBatch } from 'features/batch/store/batchSlice';
import {
resizeAndScaleCanvas,
setInitialCanvasImage,
} from 'features/canvas/store/canvasSlice';
import { imagesAddedToBatch } from 'features/gallery/store/gallerySlice';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { initialImageSelected } from 'features/parameters/store/actions';
@ -18,109 +17,35 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaExpand, FaFolder, FaShare, FaTrash } from 'react-icons/fa';
import { FaFolder, FaShare, FaTrash } from 'react-icons/fa';
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
import { useGetImageMetadataQuery } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types';
import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext';
import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
import { AddImageToBoardContext } from '../../../../app/contexts/AddImageToBoardContext';
import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions';
type Props = {
image: ImageDTO;
children: ContextMenuProps<HTMLDivElement>['children'];
type SingleSelectionMenuItemsProps = {
imageDTO: ImageDTO;
};
const ImageContextMenu = ({ image, children }: Props) => {
const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const { imageDTO } = props;
const selector = useMemo(
() =>
createSelector(
[stateSelector],
({ gallery }) => {
const selectionCount = gallery.selection.length;
return { selectionCount };
},
defaultSelectorOptions
),
[]
);
const { selectionCount } = useAppSelector(selector);
const dispatch = useAppDispatch();
const { onClickAddToBoard } = useContext(AddImageToBoardContext);
const handleDelete = useCallback(() => {
if (!image) {
return;
}
dispatch(imageToDeleteSelected(image));
}, [dispatch, image]);
const handleAddToBoard = useCallback(() => {
onClickAddToBoard(image);
}, [image, onClickAddToBoard]);
return (
<ContextMenu<HTMLDivElement>
menuProps={{ size: 'sm', isLazy: true }}
renderMenu={() => (
<MenuList sx={{ visibility: 'visible !important' }}>
{selectionCount === 1 ? (
<SingleSelectionMenuItems image={image} />
) : (
<>
<MenuItem
isDisabled={true}
icon={<FaFolder />}
onClickCapture={handleAddToBoard}
>
Move Selection to Board
</MenuItem>
{/* <MenuItem
icon={<FaFolderPlus />}
onClickCapture={handleAddSelectionToBatch}
>
Add Selection to Batch
</MenuItem> */}
<MenuItem
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
icon={<FaTrash />}
onClickCapture={handleDelete}
>
Delete Selection
</MenuItem>
</>
)}
</MenuList>
)}
>
{children}
</ContextMenu>
);
};
export default memo(ImageContextMenu);
type SingleSelectionMenuItemsProps = {
image: ImageDTO;
};
const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const { image } = props;
const selector = useMemo(
() =>
createSelector(
[stateSelector],
({ batch }) => {
const isInBatch = batch.imageNames.includes(image.image_name);
const isInBatch = gallery.batchImageNames.includes(
imageDTO.image_name
);
return { isInBatch };
},
defaultSelectorOptions
),
[image.image_name]
[imageDTO.image_name]
);
const { isInBatch } = useAppSelector(selector);
@ -129,21 +54,21 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const toaster = useAppToaster();
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
const isBatchEnabled = useFeatureStatus('batches').isFeatureEnabled;
const { onClickAddToBoard } = useContext(AddImageToBoardContext);
const { currentData } = useGetImageMetadataQuery(image.image_name);
const { currentData } = useGetImageMetadataQuery(imageDTO.image_name);
const metadata = currentData?.metadata;
const handleDelete = useCallback(() => {
if (!image) {
if (!imageDTO) {
return;
}
dispatch(imageToDeleteSelected(image));
}, [dispatch, image]);
dispatch(imageToDeleteSelected(imageDTO));
}, [dispatch, imageDTO]);
const { recallBothPrompts, recallSeed, recallAllParameters } =
useRecallParameters();
@ -161,12 +86,12 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const handleSendToImageToImage = useCallback(() => {
dispatch(sentImageToImg2Img());
dispatch(initialImageSelected(image));
}, [dispatch, image]);
dispatch(initialImageSelected(imageDTO));
}, [dispatch, imageDTO]);
const handleSendToCanvas = () => {
const handleSendToCanvas = useCallback(() => {
dispatch(sentImageToCanvas());
dispatch(setInitialCanvasImage(image));
dispatch(setInitialCanvasImage(imageDTO));
dispatch(resizeAndScaleCanvas());
dispatch(setActiveTab('unifiedCanvas'));
@ -176,47 +101,40 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
duration: 2500,
isClosable: true,
});
};
}, [dispatch, imageDTO, t, toaster]);
const handleUseAllParameters = useCallback(() => {
console.log(metadata);
recallAllParameters(metadata);
}, [metadata, recallAllParameters]);
const handleLightBox = () => {
// dispatch(setCurrentImage(image));
// dispatch(setIsLightboxOpen(true));
};
const handleAddToBoard = useCallback(() => {
onClickAddToBoard(image);
}, [image, onClickAddToBoard]);
onClickAddToBoard(imageDTO);
}, [imageDTO, onClickAddToBoard]);
const handleRemoveFromBoard = useCallback(() => {
if (!image.board_id) {
if (!imageDTO.board_id) {
return;
}
removeFromBoard({ board_id: image.board_id, image_name: image.image_name });
}, [image.board_id, image.image_name, removeFromBoard]);
removeFromBoard({
board_id: imageDTO.board_id,
image_name: imageDTO.image_name,
});
}, [imageDTO.board_id, imageDTO.image_name, removeFromBoard]);
const handleOpenInNewTab = () => {
window.open(image.image_url, '_blank');
};
const handleOpenInNewTab = useCallback(() => {
window.open(imageDTO.image_url, '_blank');
}, [imageDTO.image_url]);
const handleAddToBatch = useCallback(() => {
dispatch(imagesAddedToBatch([image.image_name]));
}, [dispatch, image.image_name]);
dispatch(imagesAddedToBatch([imageDTO.image_name]));
}, [dispatch, imageDTO.image_name]);
return (
<>
<MenuItem icon={<ExternalLinkIcon />} onClickCapture={handleOpenInNewTab}>
{t('common.openInNewTab')}
</MenuItem>
{isLightboxEnabled && (
<MenuItem icon={<FaExpand />} onClickCapture={handleLightBox}>
{t('parameters.openInViewer')}
</MenuItem>
)}
<MenuItem
icon={<IoArrowUndoCircleOutline />}
onClickCapture={handleRecallPrompt}
@ -258,17 +176,19 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('parameters.sendToUnifiedCanvas')}
</MenuItem>
)}
<MenuItem
icon={<FaFolder />}
isDisabled={isInBatch}
onClickCapture={handleAddToBatch}
>
Add to Batch
</MenuItem>
{isBatchEnabled && (
<MenuItem
icon={<FaFolder />}
isDisabled={isInBatch}
onClickCapture={handleAddToBatch}
>
Add to Batch
</MenuItem>
)}
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
{image.board_id ? 'Change Board' : 'Add to Board'}
{imageDTO.board_id ? 'Change Board' : 'Add to Board'}
</MenuItem>
{image.board_id && (
{imageDTO.board_id && (
<MenuItem icon={<FaFolder />} onClickCapture={handleRemoveFromBoard}>
Remove from Board
</MenuItem>
@ -283,3 +203,5 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
</>
);
};
export default memo(SingleSelectionMenuItems);

View File

@ -1,35 +0,0 @@
.ltr-image-gallery-css-transition-enter {
transform: translateX(150%);
}
.ltr-image-gallery-css-transition-enter-active {
transform: translateX(0);
transition: all 120ms ease-out;
}
.ltr-image-gallery-css-transition-exit {
transform: translateX(0);
}
.ltr-image-gallery-css-transition-exit-active {
transform: translateX(150%);
transition: all 120ms ease-out;
}
.rtl-image-gallery-css-transition-enter {
transform: translateX(-150%);
}
.rtl-image-gallery-css-transition-enter-active {
transform: translateX(0);
transition: all 120ms ease-out;
}
.rtl-image-gallery-css-transition-exit {
transform: translateX(0);
}
.rtl-image-gallery-css-transition-exit-active {
transform: translateX(-150%);
transition: all 120ms ease-out;
}

View File

@ -19,7 +19,7 @@ import {
} from 'features/gallery/store/gallerySlice';
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
import { ChangeEvent, memo, useCallback, useRef } from 'react';
import { ChangeEvent, memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
import { FaImage, FaServer, FaWrench } from 'react-icons/fa';
@ -29,16 +29,12 @@ import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
imageCategoriesChanged,
shouldAutoSwitchChanged,
} from 'features/gallery/store/gallerySlice';
import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { mode } from 'theme/util/mode';
import BoardsList from './Boards/BoardsList';
import ImageGalleryGrid from './ImageGalleryGrid';
import BoardsList from './Boards/BoardsList/BoardsList';
import BatchImageGrid from './ImageGrid/BatchImageGrid';
import GalleryImageGrid from './ImageGrid/GalleryImageGrid';
const selector = createSelector(
[stateSelector],
@ -66,6 +62,7 @@ const ImageGalleryContent = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const resizeObserverRef = useRef<HTMLDivElement>(null);
const galleryGridRef = useRef<HTMLDivElement>(null);
const { colorMode } = useColorMode();
@ -83,6 +80,16 @@ const ImageGalleryContent = () => {
}),
});
const boardTitle = useMemo(() => {
if (selectedBoardId === 'batch') {
return 'Batch';
}
if (selectedBoard) {
return selectedBoard.board_name;
}
return 'All Images';
}, [selectedBoard, selectedBoardId]);
const { isOpen: isBoardListOpen, onToggle } = useDisclosure();
const handleChangeGalleryImageMinimumWidth = (v: number) => {
@ -95,12 +102,10 @@ const ImageGalleryContent = () => {
};
const handleClickImagesCategory = useCallback(() => {
dispatch(imageCategoriesChanged(IMAGE_CATEGORIES));
dispatch(setGalleryView('images'));
}, [dispatch]);
const handleClickAssetsCategory = useCallback(() => {
dispatch(imageCategoriesChanged(ASSETS_CATEGORIES));
dispatch(setGalleryView('assets'));
}, [dispatch]);
@ -163,7 +168,7 @@ const ImageGalleryContent = () => {
fontWeight: 600,
}}
>
{selectedBoard ? selectedBoard.board_name : 'All Images'}
{boardTitle}
</Text>
<ChevronUpIcon
sx={{
@ -216,8 +221,12 @@ const ImageGalleryContent = () => {
<BoardsList isOpen={isBoardListOpen} />
</Box>
</Box>
<Flex direction="column" gap={2} h="full" w="full">
<ImageGalleryGrid />
<Flex ref={galleryGridRef} direction="column" gap={2} h="full" w="full">
{selectedBoardId === 'batch' ? (
<BatchImageGrid />
) : (
<GalleryImageGrid />
)}
</Flex>
</VStack>
);

View File

@ -1,233 +0,0 @@
import {
Box,
Flex,
FlexProps,
Grid,
Skeleton,
Spinner,
forwardRef,
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import { IMAGE_LIMIT } from 'features/gallery/store/gallerySlice';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import {
PropsWithChildren,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';
import GalleryImage from './GalleryImage';
import { createSelector } from '@reduxjs/toolkit';
import { RootState, stateSelector } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { selectFilteredImages } from 'features/gallery/store/gallerySlice';
import { VirtuosoGrid } from 'react-virtuoso';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { ImageDTO } from 'services/api/types';
const selector = createSelector(
[stateSelector, selectFilteredImages],
(state, filteredImages) => {
const {
categories,
total: allImagesTotal,
isLoading,
isFetching,
selectedBoardId,
} = state.gallery;
let images = filteredImages as (ImageDTO | 'loading')[];
if (!isLoading && isFetching) {
// loading, not not the initial load
images = images.concat(Array(IMAGE_LIMIT).fill('loading'));
}
return {
images,
allImagesTotal,
isLoading,
isFetching,
categories,
selectedBoardId,
};
},
defaultSelectorOptions
);
const ImageGalleryGrid = () => {
const dispatch = useAppDispatch();
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 {
images,
isLoading,
isFetching,
allImagesTotal,
categories,
selectedBoardId,
} = useAppSelector(selector);
const { selectedBoard } = useListAllBoardsQuery(undefined, {
selectFromResult: ({ data }) => ({
selectedBoard: data?.find((b) => b.board_id === selectedBoardId),
}),
});
const filteredImagesTotal = useMemo(
() => selectedBoard?.image_count ?? allImagesTotal,
[allImagesTotal, selectedBoard?.image_count]
);
const areMoreAvailable = useMemo(() => {
return images.length < filteredImagesTotal;
}, [images.length, filteredImagesTotal]);
const handleLoadMoreImages = useCallback(() => {
dispatch(
receivedPageOfImages({
categories,
board_id: selectedBoardId,
is_intermediate: false,
})
);
}, [categories, dispatch, selectedBoardId]);
const handleEndReached = useMemo(() => {
if (areMoreAvailable && !isLoading) {
return handleLoadMoreImages;
}
return undefined;
}, [areMoreAvailable, handleLoadMoreImages, isLoading]);
useEffect(() => {
const { current: root } = rootRef;
if (scroller && root) {
initialize({
target: root,
elements: {
viewport: scroller,
},
});
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
if (isLoading) {
return (
<Flex
sx={{
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spinner
size="xl"
sx={{ color: 'base.300', _dark: { color: 'base.700' } }}
/>
</Flex>
);
}
if (images.length) {
return (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={images}
endReached={handleEndReached}
components={{
Item: ItemContainer,
List: ListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, item) =>
typeof item === 'string' ? (
<Skeleton sx={{ w: 'full', h: 'full', aspectRatio: '1/1' }} />
) : (
<GalleryImage
key={`${item.image_name}-${item.thumbnail_url}`}
imageDTO={item}
/>
)
}
/>
</Box>
<IAIButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable}
isLoading={isFetching}
loadingText="Loading"
flexShrink={0}
>
{areMoreAvailable
? t('gallery.loadMore')
: t('gallery.allImagesLoaded')}
</IAIButton>
</>
);
}
return (
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
);
};
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref} p={1.5}>
{props.children}
</Box>
));
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
(state: RootState) => state.gallery.galleryImageMinimumWidth
);
return (
<Grid
{...props}
className="list-container"
ref={ref}
sx={{
gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
</Grid>
);
});
export default memo(ImageGalleryGrid);

View File

@ -0,0 +1,128 @@
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 {
imageRangeEndSelected,
imageSelected,
imageSelectionToggled,
imagesRemovedFromBatch,
} from 'features/gallery/store/gallerySlice';
import { MouseEvent, 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,
isSuccess,
} = 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

@ -0,0 +1,87 @@
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,69 +1,57 @@
import { Box } from '@chakra-ui/react';
import { Box, Spinner } 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 { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaTrash } from 'react-icons/fa';
import { ImageDTO } from 'services/api/types';
import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import {
imageRangeEndSelected,
imageSelected,
imageSelectionToggled,
} from '../store/gallerySlice';
import ImageContextMenu from './ImageContextMenu';
} from 'features/gallery/store/gallerySlice';
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
import { MouseEvent, memo, useCallback, useMemo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
export const makeSelector = (image_name: string) =>
createSelector(
[stateSelector],
({ gallery }) => {
const isSelected = gallery.selection.includes(image_name);
const selectionCount = gallery.selection.length;
return {
isSelected,
selectionCount,
};
},
({ gallery }) => ({
isSelected: gallery.selection.includes(image_name),
selectionCount: gallery.selection.length,
selection: gallery.selection,
}),
defaultSelectorOptions
);
interface HoverableImageProps {
imageDTO: ImageDTO;
imageName: string;
}
/**
* Gallery image component with delete/use all/use seed buttons on hover.
*/
const GalleryImage = (props: HoverableImageProps) => {
const { imageDTO } = props;
const { image_url, thumbnail_url, image_name } = imageDTO;
const localSelector = useMemo(() => makeSelector(image_name), [image_name]);
const { isSelected, selectionCount } = useAppSelector(localSelector);
const dispatch = useAppDispatch();
const { imageName } = props;
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const localSelector = useMemo(() => makeSelector(imageName), [imageName]);
const { t } = useTranslation();
const { isSelected, selectionCount, selection } =
useAppSelector(localSelector);
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
// multiselect disabled for now
// disable multiselect for now
// if (e.shiftKey) {
// dispatch(imageRangeEndSelected(props.imageDTO.image_name));
// dispatch(imageRangeEndSelected(imageName));
// } else if (e.ctrlKey || e.metaKey) {
// dispatch(imageSelectionToggled(props.imageDTO.image_name));
// dispatch(imageSelectionToggled(imageName));
// } else {
// dispatch(imageSelected(props.imageDTO.image_name));
// dispatch(imageSelected(imageName));
// }
dispatch(imageSelected(props.imageDTO.image_name));
dispatch(imageSelected(imageName));
},
[dispatch, props.imageDTO.image_name]
[dispatch, imageName]
);
const handleDelete = useCallback(
@ -81,7 +69,8 @@ const GalleryImage = (props: HoverableImageProps) => {
if (selectionCount > 1) {
return {
id: 'gallery-image',
payloadType: 'GALLERY_SELECTION',
payloadType: 'IMAGE_NAMES',
payload: { image_names: selection },
};
}
@ -92,15 +81,19 @@ const GalleryImage = (props: HoverableImageProps) => {
payload: { imageDTO },
};
}
}, [imageDTO, selectionCount]);
}, [imageDTO, selection, selectionCount]);
if (!imageDTO) {
return <Spinner />;
}
return (
<Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
<ImageContextMenu image={imageDTO}>
<ImageContextMenu imageDTO={imageDTO}>
{(ref) => (
<Box
position="relative"
key={image_name}
key={imageName}
userSelect="none"
ref={ref}
sx={{
@ -117,13 +110,13 @@ const GalleryImage = (props: HoverableImageProps) => {
isSelected={isSelected}
minSize={0}
onClickReset={handleDelete}
resetIcon={<FaTrash />}
resetTooltip="Delete image"
imageSx={{ w: 'full', h: 'full' }}
// withResetIcon // removed bc it's too easy to accidentally delete images
isDropDisabled={true}
isUploadDisabled={true}
thumbnail={true}
// resetIcon={<FaTrash />}
// resetTooltip="Delete image"
// withResetIcon // removed bc it's too easy to accidentally delete images
/>
</Box>
)}

View File

@ -0,0 +1,204 @@
import { Box } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIButton from 'common/components/IAIButton';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';
import GalleryImage from './GalleryImage';
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 {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
IMAGE_LIMIT,
selectImagesAll,
} from 'features/gallery//store/gallerySlice';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import { VirtuosoGrid } from 'react-virtuoso';
import { receivedPageOfImages } from 'services/api/thunks/image';
import ImageGridItemContainer from './ImageGridItemContainer';
import ImageGridListContainer from './ImageGridListContainer';
import { useListBoardImagesQuery } from '../../../../services/api/endpoints/boardImages';
const selector = createSelector(
[stateSelector, selectFilteredImages],
(state, filteredImages) => {
const {
galleryImageMinimumWidth,
selectedBoardId,
galleryView,
total,
isLoading,
} = state.gallery;
return {
imageNames: filteredImages.map((i) => i.image_name),
total,
selectedBoardId,
galleryView,
galleryImageMinimumWidth,
isLoading,
};
},
defaultSelectorOptions
);
const GalleryImageGrid = () => {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const emptyGalleryRef = useRef<HTMLDivElement>(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 [didInitialFetch, setDidInitialFetch] = useState(false);
const dispatch = useAppDispatch();
const {
galleryImageMinimumWidth,
imageNames: imageNamesAll, //all images names loaded on main tab,
total: totalAll,
selectedBoardId,
galleryView,
isLoading: isLoadingAll,
} = useAppSelector(selector);
const { data: imagesForBoard, isLoading: isLoadingImagesForBoard } =
useListBoardImagesQuery(
{ board_id: selectedBoardId },
{ skip: selectedBoardId === 'all' }
);
const imageNames = useMemo(() => {
if (selectedBoardId === 'all') {
return imageNamesAll; // already sorted by images/uploads in gallery selector
} else {
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const imageList = (imagesForBoard?.items || []).filter((img) =>
categories.includes(img.image_category)
);
return imageList.map((img) => img.image_name);
}
}, [selectedBoardId, galleryView, imagesForBoard, imageNamesAll]);
const areMoreAvailable = useMemo(() => {
return selectedBoardId === 'all' ? totalAll > imageNamesAll.length : false;
}, [selectedBoardId, imageNamesAll.length, totalAll]);
const isLoading = useMemo(() => {
return selectedBoardId === 'all' ? isLoadingAll : isLoadingImagesForBoard;
}, [selectedBoardId, isLoadingAll, isLoadingImagesForBoard]);
const handleLoadMoreImages = useCallback(() => {
dispatch(
receivedPageOfImages({
categories:
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES,
is_intermediate: false,
offset: imageNames.length,
limit: IMAGE_LIMIT,
})
);
}, [dispatch, imageNames.length, galleryView]);
const handleEndReached = useMemo(() => {
if (areMoreAvailable) {
return handleLoadMoreImages;
}
return undefined;
}, [areMoreAvailable, handleLoadMoreImages]);
// useEffect(() => {
// if (!didInitialFetch) {
// return;
// }
// // rough, conservative calculation of how many images fit in the gallery
// // TODO: this gets an incorrect value on first load...
// const galleryHeight = rootRef.current?.clientHeight ?? 0;
// const galleryWidth = rootRef.current?.clientHeight ?? 0;
// const rows = galleryHeight / galleryImageMinimumWidth;
// const columns = galleryWidth / galleryImageMinimumWidth;
// const imagesToLoad = Math.ceil(rows * columns);
// setDidInitialFetch(true);
// // load up that many images
// dispatch(
// receivedPageOfImages({
// offset: 0,
// limit: 10,
// })
// );
// }, [
// didInitialFetch,
// dispatch,
// galleryImageMinimumWidth,
// galleryView,
// selectedBoardId,
// ]);
if (!isLoading && imageNames.length === 0) {
return (
<Box ref={emptyGalleryRef} sx={{ w: 'full', h: 'full' }}>
<IAINoContentFallback
label={t('gallery.noImagesInGallery')}
icon={FaImage}
/>
</Box>
);
}
console.log({ selectedBoardId });
if (status !== 'rejected') {
return (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={{ height: '100%' }}
data={imageNames}
components={{
Item: ImageGridItemContainer,
List: ImageGridListContainer,
}}
scrollerRef={setScroller}
itemContent={(index, imageName) => (
<GalleryImage key={imageName} imageName={imageName} />
)}
/>
</Box>
<IAIButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreAvailable}
isLoading={status === 'pending'}
loadingText="Loading"
flexShrink={0}
>
{areMoreAvailable
? t('gallery.loadMore')
: t('gallery.allImagesLoaded')}
</IAIButton>
</>
);
}
};
export default memo(GalleryImageGrid);

View File

@ -0,0 +1,11 @@
import { Box, FlexProps, forwardRef } from '@chakra-ui/react';
import { PropsWithChildren } from 'react';
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box className="item-container" ref={ref} p={1.5}>
{props.children}
</Box>
));
export default ItemContainer;

View File

@ -0,0 +1,26 @@
import { FlexProps, Grid, forwardRef } from '@chakra-ui/react';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { PropsWithChildren } from 'react';
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
(state: RootState) => state.gallery.galleryImageMinimumWidth
);
return (
<Grid
{...props}
className="list-container"
ref={ref}
sx={{
gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
}}
>
{props.children}
</Grid>
);
});
export default ListContainer;

View File

@ -1,7 +1,7 @@
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
import { useCallback } from 'react';
import { UnsafeImageMetadata } from 'services/api/endpoints/images';
import MetadataItem from './MetadataItem';
import ImageMetadataItem from './ImageMetadataItem';
type Props = {
metadata?: UnsafeImageMetadata['metadata'];
@ -73,13 +73,13 @@ const ImageMetadataActions = (props: Props) => {
return (
<>
{metadata.generation_mode && (
<MetadataItem
<ImageMetadataItem
label="Generation Mode"
value={metadata.generation_mode}
/>
)}
{metadata.positive_prompt && (
<MetadataItem
<ImageMetadataItem
label="Positive Prompt"
labelPosition="top"
value={metadata.positive_prompt}
@ -87,7 +87,7 @@ const ImageMetadataActions = (props: Props) => {
/>
)}
{metadata.negative_prompt && (
<MetadataItem
<ImageMetadataItem
label="Negative Prompt"
labelPosition="top"
value={metadata.negative_prompt}
@ -95,28 +95,28 @@ const ImageMetadataActions = (props: Props) => {
/>
)}
{metadata.seed !== undefined && (
<MetadataItem
<ImageMetadataItem
label="Seed"
value={metadata.seed}
onClick={handleRecallSeed}
/>
)}
{metadata.model !== undefined && (
<MetadataItem
<ImageMetadataItem
label="Model"
value={metadata.model.model_name}
onClick={handleRecallModel}
/>
)}
{metadata.width && (
<MetadataItem
<ImageMetadataItem
label="Width"
value={metadata.width}
onClick={handleRecallWidth}
/>
)}
{metadata.height && (
<MetadataItem
<ImageMetadataItem
label="Height"
value={metadata.height}
onClick={handleRecallHeight}
@ -137,21 +137,21 @@ const ImageMetadataActions = (props: Props) => {
/>
)} */}
{metadata.scheduler && (
<MetadataItem
<ImageMetadataItem
label="Scheduler"
value={metadata.scheduler}
onClick={handleRecallScheduler}
/>
)}
{metadata.steps && (
<MetadataItem
<ImageMetadataItem
label="Steps"
value={metadata.steps}
onClick={handleRecallSteps}
/>
)}
{metadata.cfg_scale !== undefined && (
<MetadataItem
<ImageMetadataItem
label="CFG scale"
value={metadata.cfg_scale}
onClick={handleRecallCfgScale}
@ -192,7 +192,7 @@ const ImageMetadataActions = (props: Props) => {
/>
)} */}
{metadata.strength && (
<MetadataItem
<ImageMetadataItem
label="Image to image strength"
value={metadata.strength}
onClick={handleRecallStrength}

View File

@ -16,7 +16,7 @@ type MetadataItemProps = {
/**
* Component to display an individual metadata item or parameter.
*/
const MetadataItem = ({
const ImageMetadataItem = ({
label,
value,
onClick,
@ -74,4 +74,4 @@ const MetadataItem = ({
);
};
export default MetadataItem;
export default ImageMetadataItem;

View File

@ -8,7 +8,7 @@ type Props = {
jsonObject: object;
};
const MetadataJSONViewer = (props: Props) => {
const ImageMetadataJSON = (props: Props) => {
const { copyTooltip, jsonObject } = props;
const jsonString = useMemo(
() => JSON.stringify(jsonObject, null, 2),
@ -67,4 +67,4 @@ const MetadataJSONViewer = (props: Props) => {
);
};
export default MetadataJSONViewer;
export default ImageMetadataJSON;

View File

@ -15,7 +15,7 @@ import { useGetImageMetadataQuery } from 'services/api/endpoints/images';
import { ImageDTO } from 'services/api/types';
import { useDebounce } from 'use-debounce';
import ImageMetadataActions from './ImageMetadataActions';
import MetadataJSONViewer from './MetadataJSONViewer';
import ImageMetadataJSON from './ImageMetadataJSON';
type ImageMetadataViewerProps = {
image: ImageDTO;
@ -123,7 +123,7 @@ const ImageMetadataViewer = ({ image }: ImageMetadataViewerProps) => {
key={tab.label}
sx={{ w: 'full', h: 'full', p: 0, pt: 4 }}
>
<MetadataJSONViewer
<ImageMetadataJSON
jsonObject={tab.data}
copyTooltip={tab.copyTooltip}
/>

View File

@ -1,17 +1,12 @@
import { ChakraProps, Flex, Grid, IconButton, Spinner } from '@chakra-ui/react';
import { memo, useCallback, useState } from 'react';
import { Box, ChakraProps, Flex, IconButton, Spinner } from '@chakra-ui/react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { useNextPrevImage } from '../hooks/useNextPrevImage';
const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = {
height: '100%',
width: '15%',
alignItems: 'center',
pointerEvents: 'auto',
};
const nextPrevButtonStyles: ChakraProps['sx'] = {
color: 'base.100',
pointerEvents: 'auto',
};
const NextPrevImageButtons = () => {
@ -27,35 +22,23 @@ const NextPrevImageButtons = () => {
isFetching,
} = useNextPrevImage();
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] =
useState<boolean>(false);
const handleCurrentImagePreviewMouseOver = useCallback(() => {
setShouldShowNextPrevButtons(true);
}, []);
const handleCurrentImagePreviewMouseOut = useCallback(() => {
setShouldShowNextPrevButtons(false);
}, []);
return (
<Flex
<Box
sx={{
justifyContent: 'space-between',
position: 'relative',
height: '100%',
width: '100%',
pointerEvents: 'none',
}}
>
<Grid
<Box
sx={{
...nextPrevButtonTriggerAreaStyles,
justifyContent: 'flex-start',
pos: 'absolute',
top: '50%',
transform: 'translate(0, -50%)',
insetInlineStart: 0,
}}
onMouseOver={handleCurrentImagePreviewMouseOver}
onMouseOut={handleCurrentImagePreviewMouseOut}
>
{shouldShowNextPrevButtons && !isOnFirstImage && (
{!isOnFirstImage && (
<IconButton
aria-label={t('accessibility.previousImage')}
icon={<FaAngleLeft size={64} />}
@ -65,16 +48,16 @@ const NextPrevImageButtons = () => {
sx={nextPrevButtonStyles}
/>
)}
</Grid>
<Grid
</Box>
<Box
sx={{
...nextPrevButtonTriggerAreaStyles,
justifyContent: 'flex-end',
pos: 'absolute',
top: '50%',
transform: 'translate(0, -50%)',
insetInlineEnd: 0,
}}
onMouseOver={handleCurrentImagePreviewMouseOver}
onMouseOut={handleCurrentImagePreviewMouseOut}
>
{shouldShowNextPrevButtons && !isOnLastImage && (
{!isOnLastImage && (
<IconButton
aria-label={t('accessibility.nextImage')}
icon={<FaAngleRight size={64} />}
@ -84,36 +67,30 @@ const NextPrevImageButtons = () => {
sx={nextPrevButtonStyles}
/>
)}
{shouldShowNextPrevButtons &&
isOnLastImage &&
areMoreImagesAvailable &&
!isFetching && (
<IconButton
aria-label={t('accessibility.loadMore')}
icon={<FaAngleDoubleRight size={64} />}
variant="unstyled"
onClick={handleLoadMoreImages}
boxSize={16}
sx={nextPrevButtonStyles}
/>
)}
{shouldShowNextPrevButtons &&
isOnLastImage &&
areMoreImagesAvailable &&
isFetching && (
<Flex
sx={{
w: 16,
h: 16,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spinner opacity={0.5} size="xl" />
</Flex>
)}
</Grid>
</Flex>
{isOnLastImage && areMoreImagesAvailable && !isFetching && (
<IconButton
aria-label={t('accessibility.loadMore')}
icon={<FaAngleDoubleRight size={64} />}
variant="unstyled"
onClick={handleLoadMoreImages}
boxSize={16}
sx={nextPrevButtonStyles}
/>
)}
{isOnLastImage && areMoreImagesAvailable && isFetching && (
<Flex
sx={{
w: 16,
h: 16,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spinner opacity={0.5} size="xl" />
</Flex>
)}
</Box>
</Box>
);
};

View File

@ -3,12 +3,12 @@ import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
imageSelected,
selectFilteredImages,
selectImagesById,
} from 'features/gallery/store/gallerySlice';
import { clamp, isEqual } from 'lodash-es';
import { useCallback } from 'react';
import { receivedPageOfImages } from 'services/api/thunks/image';
import { selectFilteredImages } from '../store/gallerySelectors';
export const nextPrevImageButtonsSelector = createSelector(
[stateSelector, selectFilteredImages],

View File

@ -11,7 +11,6 @@ export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [
'limit',
'offset',
'selectedBoardId',
'categories',
'galleryView',
'total',
'isInitialized',

View File

@ -1,3 +1,136 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { clamp, keyBy } from 'lodash-es';
import { ImageDTO } from 'services/api/types';
import {
ASSETS_CATEGORIES,
BoardId,
IMAGE_CATEGORIES,
imagesAdapter,
initialGalleryState,
} from './gallerySlice';
export const gallerySelector = (state: RootState) => state.gallery;
const isInSelectedBoard = (
selectedBoardId: BoardId,
imageDTO: ImageDTO,
batchImageNames: string[]
) => {
if (selectedBoardId === 'all') {
// all images are in the "All Images" board
return true;
}
if (selectedBoardId === 'none' && !imageDTO.board_id) {
// Only images without a board are in the "No Board" board
return true;
}
if (
selectedBoardId === 'batch' &&
batchImageNames.includes(imageDTO.image_name)
) {
// Only images with is_batch are in the "Batch" board
return true;
}
return selectedBoardId === imageDTO.board_id;
};
export const selectFilteredImagesLocal = createSelector(
[(state: typeof initialGalleryState) => state],
(galleryState) => {
const allImages = imagesAdapter.getSelectors().selectAll(galleryState);
const { galleryView, selectedBoardId } = galleryState;
const categories =
galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInBoard = isInSelectedBoard(
selectedBoardId,
i,
galleryState.batchImageNames
);
return isInCategory && isInBoard;
});
return filteredImages;
}
);
export const selectFilteredImages = createSelector(
(state: RootState) => state,
(state) => {
return selectFilteredImagesLocal(state.gallery);
},
defaultSelectorOptions
);
export const selectFilteredImagesAsObject = createSelector(
selectFilteredImages,
(filteredImages) => keyBy(filteredImages, 'image_name')
);
export const selectFilteredImagesIds = createSelector(
selectFilteredImages,
(filteredImages) => filteredImages.map((i) => i.image_name)
);
export const selectLastSelectedImage = createSelector(
(state: RootState) => state,
(state) => state.gallery.selection[state.gallery.selection.length - 1],
defaultSelectorOptions
);
export const selectSelectedImages = createSelector(
(state: RootState) => state,
(state) =>
imagesAdapter
.getSelectors()
.selectAll(state.gallery)
.filter((i) => state.gallery.selection.includes(i.image_name)),
defaultSelectorOptions
);
export const selectNextImageToSelectLocal = createSelector(
[
(state: typeof initialGalleryState) => state,
(state: typeof initialGalleryState, image_name: string) => image_name,
],
(state, image_name) => {
const filteredImages = selectFilteredImagesLocal(state);
const ids = filteredImages.map((i) => i.image_name);
const deletedImageIndex = ids.findIndex(
(result) => result.toString() === image_name
);
const filteredIds = ids.filter((id) => id.toString() !== image_name);
const newSelectedImageIndex = clamp(
deletedImageIndex,
0,
filteredIds.length - 1
);
const newSelectedImageId = filteredIds[newSelectedImageIndex];
return newSelectedImageId;
}
);
export const selectNextImageToSelect = createSelector(
[
(state: RootState) => state,
(state: RootState, image_name: string) => image_name,
],
(state, image_name) => {
return selectNextImageToSelectLocal(state.gallery, image_name);
},
defaultSelectorOptions
);

View File

@ -1,19 +1,15 @@
import type { PayloadAction, Update } from '@reduxjs/toolkit';
import {
createEntityAdapter,
createSelector,
createSlice,
} from '@reduxjs/toolkit';
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { dateComparator } from 'common/util/dateComparator';
import { keyBy, uniq } from 'lodash-es';
import { uniq } from 'lodash-es';
import { boardsApi } from 'services/api/endpoints/boards';
import {
imageUrlsReceived,
receivedPageOfImages,
} from 'services/api/thunks/image';
import { ImageCategory, ImageDTO } from 'services/api/types';
import { selectFilteredImagesLocal } from './gallerySelectors';
export const imagesAdapter = createEntityAdapter<ImageDTO>({
selectId: (image) => image.image_name,
@ -27,23 +23,30 @@ export const ASSETS_CATEGORIES: ImageCategory[] = [
'user',
'other',
];
export const INITIAL_IMAGE_LIMIT = 100;
export const IMAGE_LIMIT = 20;
export type GalleryView = 'images' | 'assets';
export type BoardId =
| 'all'
| 'none'
| 'batch'
| (string & Record<never, never>);
type AdditionaGalleryState = {
offset: number;
limit: number;
total: number;
isLoading: boolean;
isFetching: boolean;
categories: ImageCategory[];
selectedBoardId?: string;
selection: string[];
shouldAutoSwitch: boolean;
galleryImageMinimumWidth: number;
galleryView: 'images' | 'assets';
galleryView: GalleryView;
selectedBoardId: BoardId;
isInitialized: boolean;
batchImageNames: string[];
isBatchEnabled: boolean;
};
export const initialGalleryState =
@ -53,12 +56,14 @@ export const initialGalleryState =
total: 0,
isLoading: true,
isFetching: true,
categories: IMAGE_CATEGORIES,
selection: [],
shouldAutoSwitch: true,
galleryImageMinimumWidth: 96,
galleryView: 'images',
selectedBoardId: 'all',
isInitialized: false,
batchImageNames: [],
isBatchEnabled: false,
});
export const gallerySlice = createSlice({
@ -73,7 +78,7 @@ export const gallerySlice = createSlice({
) {
state.selection = [action.payload.image_name];
state.galleryView = 'images';
state.categories = IMAGE_CATEGORIES;
state.selectedBoardId = 'all';
}
},
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
@ -81,12 +86,15 @@ export const gallerySlice = createSlice({
},
imageRemoved: (state, action: PayloadAction<string>) => {
imagesAdapter.removeOne(state, action.payload);
state.batchImageNames = state.batchImageNames.filter(
(name) => name !== action.payload
);
},
imagesRemoved: (state, action: PayloadAction<string[]>) => {
imagesAdapter.removeMany(state, action.payload);
},
imageCategoriesChanged: (state, action: PayloadAction<ImageCategory[]>) => {
state.categories = action.payload;
state.batchImageNames = state.batchImageNames.filter(
(name) => !action.payload.includes(name)
);
},
imageRangeEndSelected: (state, action: PayloadAction<string>) => {
const rangeEndImageName = action.payload;
@ -127,9 +135,7 @@ export const gallerySlice = createSlice({
}
},
imageSelected: (state, action: PayloadAction<string | null>) => {
state.selection = action.payload
? [action.payload]
: [String(state.ids[0])];
state.selection = action.payload ? [action.payload] : [];
},
shouldAutoSwitchChanged: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSwitch = action.payload;
@ -137,15 +143,43 @@ export const gallerySlice = createSlice({
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
state.galleryImageMinimumWidth = action.payload;
},
setGalleryView: (state, action: PayloadAction<'images' | 'assets'>) => {
setGalleryView: (state, action: PayloadAction<GalleryView>) => {
state.galleryView = action.payload;
},
boardIdSelected: (state, action: PayloadAction<string | undefined>) => {
boardIdSelected: (state, action: PayloadAction<BoardId>) => {
state.selectedBoardId = action.payload;
},
isLoadingChanged: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
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 = [];
},
},
extraReducers: (builder) => {
builder.addCase(receivedPageOfImages.pending, (state) => {
@ -188,7 +222,7 @@ export const gallerySlice = createSlice({
boardsApi.endpoints.deleteBoard.matchFulfilled,
(state, action) => {
if (action.meta.arg.originalArgs === state.selectedBoardId) {
state.selectedBoardId = undefined;
state.selectedBoardId = 'all';
}
}
);
@ -208,7 +242,6 @@ export const {
imageUpdatedOne,
imageRemoved,
imagesRemoved,
imageCategoriesChanged,
imageRangeEndSelected,
imageSelectionToggled,
imageSelected,
@ -217,48 +250,9 @@ export const {
setGalleryView,
boardIdSelected,
isLoadingChanged,
isBatchEnabledChanged,
imagesAddedToBatch,
imagesRemovedFromBatch,
} = gallerySlice.actions;
export default gallerySlice.reducer;
export const selectFilteredImagesLocal = createSelector(
(state: typeof initialGalleryState) => state,
(galleryState) => {
const allImages = imagesAdapter.getSelectors().selectAll(galleryState);
const { categories, selectedBoardId } = galleryState;
const filteredImages = allImages.filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = selectedBoardId
? i.board_id === selectedBoardId
: true;
return isInCategory && isInSelectedBoard;
});
return filteredImages;
}
);
export const selectFilteredImages = createSelector(
(state: RootState) => state,
(state) => {
return selectFilteredImagesLocal(state.gallery);
},
defaultSelectorOptions
);
export const selectFilteredImagesAsObject = createSelector(
selectFilteredImages,
(filteredImages) => keyBy(filteredImages, 'image_name')
);
export const selectFilteredImagesIds = createSelector(
selectFilteredImages,
(filteredImages) => filteredImages.map((i) => i.image_name)
);
export const selectLastSelectedImage = createSelector(
(state: RootState) => state,
(state) => state.gallery.selection[state.gallery.selection.length - 1],
defaultSelectorOptions
);

View File

@ -1,167 +0,0 @@
import { Box, Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import CurrentImageButtons from 'features/gallery/components/CurrentImageButtons';
import ImageMetadataViewer from 'features/gallery/components/ImageMetaDataViewer/ImageMetadataViewer';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import { gallerySelector } from 'features/gallery/store/gallerySelectors';
import { setIsLightboxOpen } from 'features/lightbox/store/lightboxSlice';
import { uiSelector } from 'features/ui/store/uiSelectors';
import { AnimatePresence, motion } from 'framer-motion';
import { isEqual } from 'lodash-es';
import { useHotkeys } from 'react-hotkeys-hook';
import { BiExit } from 'react-icons/bi';
import { TransformWrapper } from 'react-zoom-pan-pinch';
import { PROGRESS_BAR_THICKNESS } from 'theme/util/constants';
import useImageTransform from '../hooks/useImageTransform';
import ReactPanZoomButtons from './ReactPanZoomButtons';
import ReactPanZoomImage from './ReactPanZoomImage';
export const lightboxSelector = createSelector(
[gallerySelector, uiSelector],
(gallery, ui) => {
const { currentImage } = gallery;
const { shouldShowImageDetails } = ui;
return {
viewerImageToDisplay: currentImage,
shouldShowImageDetails,
};
},
{
memoizeOptions: {
resultEqualityCheck: isEqual,
},
}
);
export default function Lightbox() {
const dispatch = useAppDispatch();
const isLightBoxOpen = useAppSelector(
(state: RootState) => state.lightbox.isLightboxOpen
);
const {
rotation,
scaleX,
scaleY,
flipHorizontally,
flipVertically,
rotateCounterClockwise,
rotateClockwise,
reset,
} = useImageTransform();
const { viewerImageToDisplay, shouldShowImageDetails } =
useAppSelector(lightboxSelector);
useHotkeys(
'Esc',
() => {
if (isLightBoxOpen) dispatch(setIsLightboxOpen(false));
},
[isLightBoxOpen]
);
return (
<AnimatePresence>
{isLightBoxOpen && (
<motion.div
key="lightbox"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: 'easeInOut' }}
style={{
display: 'flex',
width: '100vw',
height: `calc(100vh - ${PROGRESS_BAR_THICKNESS * 4}px)`,
position: 'fixed',
top: `${PROGRESS_BAR_THICKNESS * 4}px`,
background: 'var(--invokeai-colors-base-900)',
zIndex: 99,
}}
>
<TransformWrapper
centerOnInit
minScale={0.1}
initialPositionX={50}
initialPositionY={50}
>
<Flex
sx={{
flexDir: 'column',
position: 'absolute',
insetInlineStart: 4,
gap: 4,
zIndex: 3,
top: 4,
}}
>
<IAIIconButton
icon={<BiExit />}
aria-label="Exit Viewer"
className="lightbox-close-btn"
onClick={() => {
dispatch(setIsLightboxOpen(false));
}}
fontSize={20}
/>
<ReactPanZoomButtons
flipHorizontally={flipHorizontally}
flipVertically={flipVertically}
rotateCounterClockwise={rotateCounterClockwise}
rotateClockwise={rotateClockwise}
reset={reset}
/>
</Flex>
<Flex
sx={{
position: 'absolute',
top: 4,
zIndex: 3,
insetInlineStart: '50%',
transform: 'translate(-50%, 0)',
}}
>
<CurrentImageButtons />
</Flex>
{viewerImageToDisplay && (
<>
<ReactPanZoomImage
rotation={rotation}
scaleX={scaleX}
scaleY={scaleY}
image={viewerImageToDisplay}
styleClass="lightbox-image"
/>
{shouldShowImageDetails && (
<ImageMetadataViewer image={viewerImageToDisplay} />
)}
{!shouldShowImageDetails && (
<Box
sx={{
position: 'absolute',
top: 0,
insetInlineStart: 0,
w: '100vw',
h: '100vh',
px: 16,
pointerEvents: 'none',
}}
>
<NextPrevImageButtons />
</Box>
)}
</>
)}
</TransformWrapper>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -1,96 +0,0 @@
import { ButtonGroup } from '@chakra-ui/react';
import IAIIconButton from 'common/components/IAIIconButton';
import { useTranslation } from 'react-i18next';
import {
BiReset,
BiRotateLeft,
BiRotateRight,
BiZoomIn,
BiZoomOut,
} from 'react-icons/bi';
import { MdFlip } from 'react-icons/md';
import { useTransformContext } from 'react-zoom-pan-pinch';
type ReactPanZoomButtonsProps = {
flipHorizontally: () => void;
flipVertically: () => void;
rotateCounterClockwise: () => void;
rotateClockwise: () => void;
reset: () => void;
};
const ReactPanZoomButtons = ({
flipHorizontally,
flipVertically,
rotateCounterClockwise,
rotateClockwise,
reset,
}: ReactPanZoomButtonsProps) => {
const { zoomIn, zoomOut, resetTransform } = useTransformContext();
const { t } = useTranslation();
return (
<ButtonGroup isAttached orientation="vertical">
<IAIIconButton
icon={<BiZoomIn />}
aria-label={t('accessibility.zoomIn')}
tooltip={t('accessibility.zoomIn')}
onClick={() => zoomIn()}
fontSize={20}
/>
<IAIIconButton
icon={<BiZoomOut />}
aria-label={t('accessibility.zoomOut')}
tooltip={t('accessibility.zoomOut')}
onClick={() => zoomOut()}
fontSize={20}
/>
<IAIIconButton
icon={<BiRotateLeft />}
aria-label={t('accessibility.rotateCounterClockwise')}
tooltip={t('accessibility.rotateCounterClockwise')}
onClick={rotateCounterClockwise}
fontSize={20}
/>
<IAIIconButton
icon={<BiRotateRight />}
aria-label={t('accessibility.rotateClockwise')}
tooltip={t('accessibility.rotateClockwise')}
onClick={rotateClockwise}
fontSize={20}
/>
<IAIIconButton
icon={<MdFlip />}
aria-label={t('accessibility.flipHorizontally')}
tooltip={t('accessibility.flipHorizontally')}
onClick={flipHorizontally}
fontSize={20}
/>
<IAIIconButton
icon={<MdFlip style={{ transform: 'rotate(90deg)' }} />}
aria-label={t('accessibility.flipVertically')}
tooltip={t('accessibility.flipVertically')}
onClick={flipVertically}
fontSize={20}
/>
<IAIIconButton
icon={<BiReset />}
aria-label={t('accessibility.reset')}
tooltip={t('accessibility.reset')}
onClick={() => {
resetTransform();
reset();
}}
fontSize={20}
/>
</ButtonGroup>
);
};
export default ReactPanZoomButtons;

View File

@ -1,46 +0,0 @@
import * as React from 'react';
import { TransformComponent, useTransformContext } from 'react-zoom-pan-pinch';
import { ImageDTO } from 'services/api/types';
type ReactPanZoomProps = {
image: ImageDTO;
styleClass?: string;
alt?: string;
ref?: React.Ref<HTMLImageElement>;
rotation: number;
scaleX: number;
scaleY: number;
};
export default function ReactPanZoomImage({
image,
alt,
ref,
styleClass,
rotation,
scaleX,
scaleY,
}: ReactPanZoomProps) {
const { centerView } = useTransformContext();
return (
<TransformComponent
wrapperStyle={{
width: '100%',
height: '100%',
}}
>
<img
style={{
transform: `rotate(${rotation}deg) scaleX(${scaleX}) scaleY(${scaleY})`,
width: '100%',
}}
src={image.image_url}
alt={alt}
ref={ref}
className={styleClass ? styleClass : ''}
onLoad={() => centerView(1, 0, 'easeOut')}
/>
</TransformComponent>
);
}

View File

@ -1,50 +0,0 @@
import { useState } from 'react';
const useImageTransform = () => {
const [rotation, setRotation] = useState(0);
const [scaleX, setScaleX] = useState(1);
const [scaleY, setScaleY] = useState(1);
const rotateCounterClockwise = () => {
if (rotation === -270) {
setRotation(0);
} else {
setRotation(rotation - 90);
}
};
const rotateClockwise = () => {
if (rotation === 270) {
setRotation(0);
} else {
setRotation(rotation + 90);
}
};
const flipHorizontally = () => {
setScaleX(scaleX * -1);
};
const flipVertically = () => {
setScaleY(scaleY * -1);
};
const reset = () => {
setRotation(0);
setScaleX(1);
setScaleY(1);
};
return {
rotation,
scaleX,
scaleY,
flipHorizontally,
flipVertically,
rotateCounterClockwise,
rotateClockwise,
reset,
};
};
export default useImageTransform;

View File

@ -1,8 +0,0 @@
import { LightboxState } from './lightboxSlice';
/**
* Lightbox slice persist denylist
*/
export const lightboxPersistDenylist: (keyof LightboxState)[] = [
'isLightboxOpen',
];

View File

@ -1,13 +0,0 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { isEqual } from 'lodash-es';
export const lightboxSelector = createSelector(
(state: RootState) => state.lightbox,
(lightbox) => lightbox,
{
memoizeOptions: {
equalityCheck: isEqual,
},
}
);

View File

@ -1,26 +0,0 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
export interface LightboxState {
isLightboxOpen: boolean;
}
export const initialLightboxState: LightboxState = {
isLightboxOpen: false,
};
const initialState: LightboxState = initialLightboxState;
export const lightboxSlice = createSlice({
name: 'lightbox',
initialState,
reducers: {
setIsLightboxOpen: (state, action: PayloadAction<boolean>) => {
state.isLightboxOpen = action.payload;
},
},
});
export const { setIsLightboxOpen } = lightboxSlice.actions;
export default lightboxSlice.reducer;

View File

@ -1,30 +1,24 @@
import { Flex, Icon, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useMemo } from 'react';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import { FaImage } from 'react-icons/fa';
import { stateSelector } from 'app/store/store';
import {
TypesafeDraggableData,
TypesafeDroppableData,
} from 'app/components/ImageDnd/typesafeDnd';
import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDndImage from 'common/components/IAIDndImage';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { useMemo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const selector = createSelector(
[stateSelector],
(state) => {
const { initialImage } = state.generation;
const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch;
return {
initialImage,
useBatchAsInitialImage,
isResetButtonDisabled: useBatchAsInitialImage
? imageNames.length === 0
: !initialImage,
isResetButtonDisabled: !initialImage,
};
},
defaultSelectorOptions

View File

@ -1,22 +1,14 @@
import { Flex, Spacer, Text } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { useCallback, useMemo } from 'react';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { skipToken } from '@reduxjs/toolkit/dist/query';
import IAIIconButton from 'common/components/IAIIconButton';
import { FaLayerGroup, FaUndo, FaUpload } from 'react-icons/fa';
import useImageUploader from 'common/hooks/useImageUploader';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import IAIButton from 'common/components/IAIButton';
import { stateSelector } from 'app/store/store';
import {
asInitialImageToggled,
batchReset,
} from 'features/batch/store/batchSlice';
import BatchImageContainer from 'features/batch/components/BatchImageContainer';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIIconButton from 'common/components/IAIIconButton';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import useImageUploader from 'common/hooks/useImageUploader';
import { clearInitialImage } from 'features/parameters/store/generationSlice';
import { useCallback } from 'react';
import { FaUndo, FaUpload } from 'react-icons/fa';
import { PostUploadAction } from 'services/api/thunks/image';
import InitialImage from './InitialImage';
@ -24,59 +16,34 @@ const selector = createSelector(
[stateSelector],
(state) => {
const { initialImage } = state.generation;
const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch;
return {
initialImage,
useBatchAsInitialImage,
isResetButtonDisabled: useBatchAsInitialImage
? imageNames.length === 0
: !initialImage,
isResetButtonDisabled: !initialImage,
};
},
defaultSelectorOptions
);
const postUploadAction: PostUploadAction = {
type: 'SET_INITIAL_IMAGE',
};
const InitialImageDisplay = () => {
const { initialImage, useBatchAsInitialImage, isResetButtonDisabled } =
useAppSelector(selector);
const { isResetButtonDisabled } = useAppSelector(selector);
const dispatch = useAppDispatch();
const { openUploader } = useImageUploader();
const {
currentData: imageDTO,
isLoading,
isError,
isSuccess,
} = useGetImageDTOQuery(initialImage?.imageName ?? skipToken);
const postUploadAction = useMemo<PostUploadAction>(
() =>
useBatchAsInitialImage
? { type: 'ADD_TO_BATCH' }
: { type: 'SET_INITIAL_IMAGE' },
[useBatchAsInitialImage]
);
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction,
});
const handleReset = useCallback(() => {
if (useBatchAsInitialImage) {
dispatch(batchReset());
} else {
dispatch(clearInitialImage());
}
}, [dispatch, useBatchAsInitialImage]);
dispatch(clearInitialImage());
}, [dispatch]);
const handleUpload = useCallback(() => {
openUploader();
}, [openUploader]);
const handleClickUseBatch = useCallback(() => {
dispatch(asInitialImageToggled());
}, [dispatch]);
return (
<Flex
layerStyle={'first'}
@ -114,40 +81,22 @@ const InitialImageDisplay = () => {
Initial Image
</Text>
<Spacer />
{/* <IAIButton
tooltip={useBatchAsInitialImage ? 'Disable Batch' : 'Enable Batch'}
aria-label={useBatchAsInitialImage ? 'Disable Batch' : 'Enable Batch'}
leftIcon={<FaLayerGroup />}
isChecked={useBatchAsInitialImage}
onClick={handleClickUseBatch}
>
{useBatchAsInitialImage ? 'Batch' : 'Single'}
</IAIButton> */}
<IAIIconButton
tooltip={
useBatchAsInitialImage ? 'Upload to Batch' : 'Upload Initial Image'
}
aria-label={
useBatchAsInitialImage ? 'Upload to Batch' : 'Upload Initial Image'
}
tooltip={'Upload Initial Image'}
aria-label={'Upload Initial Image'}
icon={<FaUpload />}
onClick={handleUpload}
{...getUploadButtonProps()}
/>
<IAIIconButton
tooltip={
useBatchAsInitialImage ? 'Reset Batch' : 'Reset Initial Image'
}
aria-label={
useBatchAsInitialImage ? 'Reset Batch' : 'Reset Initial Image'
}
tooltip={'Reset Initial Image'}
aria-label={'Reset Initial Image'}
icon={<FaUndo />}
onClick={handleReset}
isDisabled={isResetButtonDisabled}
/>
</Flex>
<InitialImage />
{/* {useBatchAsInitialImage ? <BatchImageContainer /> : <InitialImage />} */}
<input {...getUploadInputProps()} />
</Flex>
);

View File

@ -6,7 +6,7 @@ import { merge } from 'lodash-es';
export const initialConfigState: AppConfig = {
shouldUpdateImagesOnConnect: false,
disabledTabs: [],
disabledFeatures: ['lightbox', 'faceRestore'],
disabledFeatures: ['lightbox', 'faceRestore', 'batches'],
disabledSDFeatures: [
'variation',
'seamless',

View File

@ -15,7 +15,6 @@ import { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent';
import { setIsLightboxOpen } from 'features/lightbox/store/lightboxSlice';
import { configSelector } from 'features/system/store/configSelectors';
import { InvokeTabName } from 'features/ui/store/tabMap';
import { setActiveTab, togglePanels } from 'features/ui/store/uiSlice';
@ -38,7 +37,6 @@ import NodesTab from './tabs/Nodes/NodesTab';
import ResizeHandle from './tabs/ResizeHandle';
import TextToImageTab from './tabs/TextToImage/TextToImageTab';
import UnifiedCanvasTab from './tabs/UnifiedCanvas/UnifiedCanvasTab';
import { useFeatureStatus } from '../../system/hooks/useFeatureStatus';
export interface InvokeTabInfo {
id: InvokeTabName;
@ -105,10 +103,6 @@ const InvokeTabs = () => {
const activeTab = useAppSelector(activeTabIndexSelector);
const activeTabName = useAppSelector(activeTabNameSelector);
const enabledTabs = useAppSelector(enabledTabsSelector);
const isLightBoxOpen = useAppSelector(
(state: RootState) => state.lightbox.isLightboxOpen
);
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
const { shouldPinGallery, shouldPinParametersPanel, shouldShowGallery } =
useAppSelector((state: RootState) => state.ui);
@ -117,17 +111,6 @@ const InvokeTabs = () => {
const dispatch = useAppDispatch();
// Lightbox Hotkey
useHotkeys(
'z',
() => {
if (isLightboxEnabled) {
dispatch(setIsLightboxOpen(!isLightBoxOpen));
}
},
[isLightBoxOpen]
);
useHotkeys(
'f',
() => {

View File

@ -2,7 +2,6 @@ import { Flex } from '@chakra-ui/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent';
import {
activeTabNameSelector,
@ -12,19 +11,16 @@ import { setShouldShowParametersPanel } from 'features/ui/store/uiSlice';
import { memo, useMemo } from 'react';
import { PARAMETERS_PANEL_WIDTH } from 'theme/util/constants';
import PinParametersPanelButton from './PinParametersPanelButton';
import OverlayScrollable from './common/OverlayScrollable';
import ResizableDrawer from './common/ResizableDrawer/ResizableDrawer';
import ImageToImageTabParameters from './tabs/ImageToImage/ImageToImageTabParameters';
import TextToImageTabParameters from './tabs/TextToImage/TextToImageTabParameters';
import UnifiedCanvasParameters from './tabs/UnifiedCanvas/UnifiedCanvasParameters';
const selector = createSelector(
[uiSelector, activeTabNameSelector, lightboxSelector],
(ui, activeTabName, lightbox) => {
[uiSelector, activeTabNameSelector],
(ui, activeTabName) => {
const { shouldPinParametersPanel, shouldShowParametersPanel } = ui;
const { isLightboxOpen } = lightbox;
return {
activeTabName,
shouldPinParametersPanel,

View File

@ -1,5 +1,5 @@
import { Box, Flex } from '@chakra-ui/react';
import CurrentImageDisplay from 'features/gallery/components/CurrentImageDisplay';
import CurrentImageDisplay from 'features/gallery/components/CurrentImage/CurrentImageDisplay';
const TextToImageTabMain = () => {
return (

View File

@ -1,11 +1,10 @@
import { OffsetPaginatedResults_ImageDTO_ } from 'services/api/types';
import { api } from '..';
import { ApiFullTagDescription, LIST_TAG, api } from '..';
import { paths } from '../schema';
import { imagesApi } from './images';
type ListBoardImagesArg =
paths['/api/v1/board_images/{board_id}']['get']['parameters']['path'] &
paths['/api/v1/board_images/{board_id}']['get']['parameters']['query'];
paths['/api/v1/board_images/{board_id}']['get']['parameters']['query'];
type AddImageToBoardArg =
paths['/api/v1/board_images/']['post']['requestBody']['content']['application/json'];
@ -25,9 +24,25 @@ export const boardImagesApi = api.injectEndpoints({
>({
query: ({ board_id, offset, limit }) => ({
url: `board_images/${board_id}`,
method: 'DELETE',
body: { offset, limit },
method: 'GET',
}),
providesTags: (result, error, arg) => {
// any list of boardimages
const tags: ApiFullTagDescription[] = [{ id: 'BoardImage', type: `${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;
},
}),
/**
@ -41,23 +56,9 @@ export const boardImagesApi = api.injectEndpoints({
body: { board_id, image_name },
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg.board_id },
{ type: 'BoardImage' },
{ type: 'Board', id: arg.board_id }
],
async onQueryStarted(
{ image_name, ...patch },
{ dispatch, queryFulfilled }
) {
const patchResult = dispatch(
imagesApi.util.updateQueryData('getImageDTO', image_name, (draft) => {
Object.assign(draft, patch);
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
removeImageFromBoard: build.mutation<void, RemoveImageFromBoardArg>({
@ -67,23 +68,9 @@ export const boardImagesApi = api.injectEndpoints({
body: { board_id, image_name },
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Board', id: arg.board_id },
{ type: 'BoardImage' },
{ type: 'Board', id: arg.board_id }
],
async onQueryStarted(
{ image_name, ...patch },
{ dispatch, queryFulfilled }
) {
const patchResult = dispatch(
imagesApi.util.updateQueryData('getImageDTO', image_name, (draft) => {
Object.assign(draft, { board_id: null });
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
}),
});

View File

@ -1,5 +1,9 @@
import { createAppAsyncThunk } from 'app/store/storeUtils';
import { selectImagesAll } from 'features/gallery/store/gallerySlice';
import { selectFilteredImages } from 'features/gallery/store/gallerySelectors';
import {
ASSETS_CATEGORIES,
IMAGE_CATEGORIES,
} from 'features/gallery/store/gallerySlice';
import { size } from 'lodash-es';
import queryString from 'query-string';
import { $client } from 'services/api/client';
@ -287,15 +291,12 @@ export const receivedPageOfImages = createAppAsyncThunk<
const { get } = $client.get();
const state = getState();
const { categories, selectedBoardId } = state.gallery;
const images = selectImagesAll(state).filter((i) => {
const isInCategory = categories.includes(i.image_category);
const isInSelectedBoard = selectedBoardId
? i.board_id === selectedBoardId
: true;
return isInCategory && isInSelectedBoard;
});
const images = selectFilteredImages(state);
const categories =
state.gallery.galleryView === 'images'
? IMAGE_CATEGORIES
: ASSETS_CATEGORIES;
let query: ListImagesArg = {};