mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): add multi-select and batch capabilities
This introduces the core functionality for batch operations on images and multiple selection in the gallery/batch manager. A number of other substantial changes are included: - `imagesSlice` is consolidated into `gallerySlice`, allowing for simpler selection of filtered images - `batchSlice` is added to manage the batch - The wonky context pattern for image deletion has been changed, much simpler now using a `imageDeletionSlice` and redux listeners; this needs to be implemented still for the other image modals - Minimum gallery size in px implemented as a hook - Many style fixes & several bug fixes TODO: - The UI and UX need to be figured out, especially for controlnet - Batch processing is not hooked up; generation does not do anything with batch - Routes to support batch image operations, specifically delete and add/remove to/from boards
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
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);
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Box, Icon, Skeleton } from '@chakra-ui/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { FaExclamationCircle } from 'react-icons/fa';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { MouseEvent, memo, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
batchImageRangeEndSelected,
|
||||
batchImageSelected,
|
||||
batchImageSelectionToggled,
|
||||
imageRemovedFromBatch,
|
||||
} from 'features/batch/store/batchSlice';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { RootState, stateSelector } from 'app/store/store';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
|
||||
|
||||
const isSelectedSelector = createSelector(
|
||||
[stateSelector, (state: RootState, imageName: string) => imageName],
|
||||
(state, imageName) => ({
|
||||
selection: state.batch.selection,
|
||||
isSelected: state.batch.selection.includes(imageName),
|
||||
}),
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
type BatchImageProps = {
|
||||
imageName: string;
|
||||
};
|
||||
|
||||
const BatchImage = (props: BatchImageProps) => {
|
||||
const {
|
||||
currentData: imageDTO,
|
||||
isFetching,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useGetImageDTOQuery(props.imageName);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { isSelected, selection } = useAppSelector((state) =>
|
||||
isSelectedSelector(state, props.imageName)
|
||||
);
|
||||
|
||||
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 (selection.length > 1) {
|
||||
return {
|
||||
id: 'batch',
|
||||
payloadType: 'IMAGE_NAMES',
|
||||
payload: {
|
||||
imageNames: selection,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (imageDTO) {
|
||||
return {
|
||||
id: 'batch',
|
||||
payloadType: 'IMAGE_DTO',
|
||||
payload: { imageDTO },
|
||||
};
|
||||
}
|
||||
}, [imageDTO, selection]);
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
142
invokeai/frontend/web/src/features/batch/store/batchSlice.ts
Normal file
142
invokeai/frontend/web/src/features/batch/store/batchSlice.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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'
|
||||
);
|
||||
Reference in New Issue
Block a user