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'
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import {
|
||||
ControlNetConfig,
|
||||
@@ -10,11 +10,16 @@ import { Box, Flex, SystemStyleObject } from '@chakra-ui/react';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
|
||||
import { IAILoadingImageFallback } from 'common/components/IAIImageFallback';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import { FaUndo } from 'react-icons/fa';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||
import {
|
||||
TypesafeDraggableData,
|
||||
TypesafeDroppableData,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
import { PostUploadAction } from 'services/api/thunks/image';
|
||||
|
||||
const selector = createSelector(
|
||||
controlNetSelector,
|
||||
@@ -57,22 +62,6 @@ const ControlNetImagePreview = (props: Props) => {
|
||||
isSuccess: isSuccessProcessedControlImage,
|
||||
} = useGetImageDTOQuery(processedControlImageName ?? skipToken);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(droppedImage: ImageDTO) => {
|
||||
if (controlImageName === droppedImage.image_name) {
|
||||
return;
|
||||
}
|
||||
setIsMouseOverImage(false);
|
||||
dispatch(
|
||||
controlNetImageChanged({
|
||||
controlNetId,
|
||||
controlImage: droppedImage.image_name,
|
||||
})
|
||||
);
|
||||
},
|
||||
[controlImageName, controlNetId, dispatch]
|
||||
);
|
||||
|
||||
const handleResetControlImage = useCallback(() => {
|
||||
dispatch(controlNetImageChanged({ controlNetId, controlImage: null }));
|
||||
}, [controlNetId, dispatch]);
|
||||
@@ -84,6 +73,31 @@ const ControlNetImagePreview = (props: Props) => {
|
||||
setIsMouseOverImage(false);
|
||||
}, []);
|
||||
|
||||
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
|
||||
if (controlImage) {
|
||||
return {
|
||||
id: controlNetId,
|
||||
payloadType: 'IMAGE_DTO',
|
||||
payload: { imageDTO: controlImage },
|
||||
};
|
||||
}
|
||||
}, [controlImage, controlNetId]);
|
||||
|
||||
const droppableData = useMemo<TypesafeDroppableData | undefined>(() => {
|
||||
if (controlNetId) {
|
||||
return {
|
||||
id: controlNetId,
|
||||
actionType: 'SET_CONTROLNET_IMAGE',
|
||||
context: { controlNetId },
|
||||
};
|
||||
}
|
||||
}, [controlNetId]);
|
||||
|
||||
const postUploadAction = useMemo<PostUploadAction>(
|
||||
() => ({ type: 'SET_CONTROLNET_IMAGE', controlNetId }),
|
||||
[controlNetId]
|
||||
);
|
||||
|
||||
const shouldShowProcessedImage =
|
||||
controlImage &&
|
||||
processedControlImage &&
|
||||
@@ -104,14 +118,14 @@ const ControlNetImagePreview = (props: Props) => {
|
||||
}}
|
||||
>
|
||||
<IAIDndImage
|
||||
image={controlImage}
|
||||
onDrop={handleDrop}
|
||||
draggableData={draggableData}
|
||||
droppableData={droppableData}
|
||||
imageDTO={controlImage}
|
||||
isDropDisabled={shouldShowProcessedImage}
|
||||
postUploadAction={{ type: 'SET_CONTROLNET_IMAGE', controlNetId }}
|
||||
imageSx={{
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
}}
|
||||
onClickReset={handleResetControlImage}
|
||||
postUploadAction={postUploadAction}
|
||||
resetTooltip="Reset Control Image"
|
||||
withResetIcon={Boolean(controlImage)}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -127,14 +141,13 @@ const ControlNetImagePreview = (props: Props) => {
|
||||
}}
|
||||
>
|
||||
<IAIDndImage
|
||||
image={processedControlImage}
|
||||
onDrop={handleDrop}
|
||||
payloadImage={controlImage}
|
||||
draggableData={draggableData}
|
||||
droppableData={droppableData}
|
||||
imageDTO={processedControlImage}
|
||||
isUploadDisabled={true}
|
||||
imageSx={{
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
}}
|
||||
onClickReset={handleResetControlImage}
|
||||
resetTooltip="Reset Control Image"
|
||||
withResetIcon={Boolean(controlImage)}
|
||||
/>
|
||||
</Box>
|
||||
{pendingControlImages.includes(controlNetId) && (
|
||||
@@ -145,27 +158,12 @@ const ControlNetImagePreview = (props: Props) => {
|
||||
insetInlineStart: 0,
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
>
|
||||
<IAIImageLoadingFallback />
|
||||
<IAILoadingImageFallback image={controlImage} />
|
||||
</Box>
|
||||
)}
|
||||
{controlImage && (
|
||||
<Flex sx={{ position: 'absolute', top: 0, insetInlineEnd: 0 }}>
|
||||
<IAIIconButton
|
||||
aria-label="Reset Control Image"
|
||||
tooltip="Reset Control Image"
|
||||
size="sm"
|
||||
onClick={handleResetControlImage}
|
||||
icon={<FaUndo />}
|
||||
variant="link"
|
||||
sx={{
|
||||
p: 2,
|
||||
color: 'base.50',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Flex, Text, useColorMode } from '@chakra-ui/react';
|
||||
import { Flex, useColorMode } from '@chakra-ui/react';
|
||||
import { FaImages } from 'react-icons/fa';
|
||||
import { boardIdSelected } from '../../store/boardSlice';
|
||||
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IAINoImageFallback } from 'common/components/IAIImageFallback';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { SelectedItemOverlay } from '../SelectedItemOverlay';
|
||||
import { useCallback } from 'react';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
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();
|
||||
@@ -20,31 +20,15 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
|
||||
dispatch(boardIdSelected());
|
||||
};
|
||||
|
||||
const [removeImageFromBoard, { isLoading }] =
|
||||
useRemoveImageFromBoardMutation();
|
||||
const droppableData: MoveBoardDropData = {
|
||||
id: 'all-images-board',
|
||||
actionType: 'MOVE_BOARD',
|
||||
context: { boardId: null },
|
||||
};
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(droppedImage: ImageDTO) => {
|
||||
if (!droppedImage.board_id) {
|
||||
return;
|
||||
}
|
||||
removeImageFromBoard({
|
||||
board_id: droppedImage.board_id,
|
||||
image_name: droppedImage.image_name,
|
||||
});
|
||||
},
|
||||
[removeImageFromBoard]
|
||||
);
|
||||
|
||||
const {
|
||||
isOver,
|
||||
setNodeRef,
|
||||
active: isDropActive,
|
||||
} = useDroppable({
|
||||
const { isOver, setNodeRef, active } = useDroppable({
|
||||
id: `board_droppable_all_images`,
|
||||
data: {
|
||||
handleDrop,
|
||||
},
|
||||
data: droppableData,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -58,10 +42,10 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
|
||||
h: 'full',
|
||||
borderRadius: 'base',
|
||||
}}
|
||||
onClick={handleAllImagesBoardClick}
|
||||
>
|
||||
<Flex
|
||||
ref={setNodeRef}
|
||||
onClick={handleAllImagesBoardClick}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
justifyContent: 'center',
|
||||
@@ -69,18 +53,30 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
|
||||
borderRadius: 'base',
|
||||
w: 'full',
|
||||
aspectRatio: '1/1',
|
||||
overflow: 'hidden',
|
||||
shadow: isSelected ? 'selected.light' : undefined,
|
||||
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IAINoImageFallback iconProps={{ boxSize: 8 }} as={FaImages} />
|
||||
<IAINoContentFallback
|
||||
boxSize={8}
|
||||
icon={FaImages}
|
||||
sx={{
|
||||
border: '2px solid var(--invokeai-colors-base-200)',
|
||||
_dark: { border: '2px solid var(--invokeai-colors-base-800)' },
|
||||
}}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{isSelected && <SelectedItemOverlay />}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{isDropActive && <IAIDropOverlay isOver={isOver} />}
|
||||
{isValidDrop(droppableData, active) && (
|
||||
<IAIDropOverlay isOver={isOver} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Flex>
|
||||
<Text
|
||||
<Flex
|
||||
sx={{
|
||||
h: 'full',
|
||||
alignItems: 'center',
|
||||
color: isSelected
|
||||
? mode('base.900', 'base.50')(colorMode)
|
||||
: mode('base.700', 'base.200')(colorMode),
|
||||
@@ -89,7 +85,7 @@ const AllImagesBoard = ({ isSelected }: { isSelected: boolean }) => {
|
||||
}}
|
||||
>
|
||||
All Images
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Collapse,
|
||||
Flex,
|
||||
Grid,
|
||||
GridItem,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
@@ -10,10 +11,7 @@ import {
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import {
|
||||
boardsSelector,
|
||||
setBoardSearchText,
|
||||
} from 'features/gallery/store/boardSlice';
|
||||
import { setBoardSearchText } from 'features/gallery/store/boardSlice';
|
||||
import { memo, useState } from 'react';
|
||||
import HoverableBoard from './HoverableBoard';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
@@ -21,11 +19,13 @@ 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';
|
||||
|
||||
const selector = createSelector(
|
||||
[boardsSelector],
|
||||
(boardsState) => {
|
||||
const { selectedBoardId, searchText } = boardsState;
|
||||
[stateSelector],
|
||||
({ boards, gallery }) => {
|
||||
const { searchText } = boards;
|
||||
const { selectedBoardId } = gallery;
|
||||
return { selectedBoardId, searchText };
|
||||
},
|
||||
defaultSelectorOptions
|
||||
@@ -109,20 +109,24 @@ const BoardsList = (props: Props) => {
|
||||
<Grid
|
||||
className="list-container"
|
||||
sx={{
|
||||
gap: 2,
|
||||
gridTemplateRows: '5.5rem 5.5rem',
|
||||
gridTemplateRows: '6.5rem 6.5rem',
|
||||
gridAutoFlow: 'column dense',
|
||||
gridAutoColumns: '4rem',
|
||||
gridAutoColumns: '5rem',
|
||||
}}
|
||||
>
|
||||
{!searchMode && <AllImagesBoard isSelected={!selectedBoardId} />}
|
||||
{!searchMode && (
|
||||
<GridItem sx={{ p: 1.5 }}>
|
||||
<AllImagesBoard isSelected={!selectedBoardId} />
|
||||
</GridItem>
|
||||
)}
|
||||
{filteredBoards &&
|
||||
filteredBoards.map((board) => (
|
||||
<HoverableBoard
|
||||
key={board.board_id}
|
||||
board={board}
|
||||
isSelected={selectedBoardId === board.board_id}
|
||||
/>
|
||||
<GridItem key={board.board_id} sx={{ p: 1.5 }}>
|
||||
<HoverableBoard
|
||||
board={board}
|
||||
isSelected={selectedBoardId === board.board_id}
|
||||
/>
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
||||
@@ -15,10 +15,9 @@ 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, ImageDTO } from 'services/api/types';
|
||||
import { IAINoImageFallback } from 'common/components/IAIImageFallback';
|
||||
import { boardIdSelected } from 'features/gallery/store/boardSlice';
|
||||
import { useAddImageToBoardMutation } from 'services/api/endpoints/boardImages';
|
||||
import { BoardDTO } from 'services/api/types';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
|
||||
import {
|
||||
useDeleteBoardMutation,
|
||||
useUpdateBoardMutation,
|
||||
@@ -26,12 +25,15 @@ import {
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
|
||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import IAIDropOverlay from 'common/components/IAIDropOverlay';
|
||||
import { SelectedItemOverlay } from '../SelectedItemOverlay';
|
||||
import { DeleteBoardImagesContext } from '../../../../app/contexts/DeleteBoardImagesContext';
|
||||
import { mode } from 'theme/util/mode';
|
||||
import {
|
||||
MoveBoardDropData,
|
||||
isValidDrop,
|
||||
useDroppable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
|
||||
interface HoverableBoardProps {
|
||||
board: BoardDTO;
|
||||
@@ -61,9 +63,6 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
|
||||
const [deleteBoard, { isLoading: isDeleteBoardLoading }] =
|
||||
useDeleteBoardMutation();
|
||||
|
||||
const [addImageToBoard, { isLoading: isAddImageToBoardLoading }] =
|
||||
useAddImageToBoardMutation();
|
||||
|
||||
const handleUpdateBoardName = (newBoardName: string) => {
|
||||
updateBoard({ board_id, changes: { board_name: newBoardName } });
|
||||
};
|
||||
@@ -77,29 +76,19 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
|
||||
onClickDeleteBoardImages(board);
|
||||
}, [board, onClickDeleteBoardImages]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(droppedImage: ImageDTO) => {
|
||||
if (droppedImage.board_id === board_id) {
|
||||
return;
|
||||
}
|
||||
addImageToBoard({ board_id, image_name: droppedImage.image_name });
|
||||
},
|
||||
[addImageToBoard, board_id]
|
||||
);
|
||||
const droppableData: MoveBoardDropData = {
|
||||
id: board_id,
|
||||
actionType: 'MOVE_BOARD',
|
||||
context: { boardId: board_id },
|
||||
};
|
||||
|
||||
const {
|
||||
isOver,
|
||||
setNodeRef,
|
||||
active: isDropActive,
|
||||
} = useDroppable({
|
||||
const { isOver, setNodeRef, active } = useDroppable({
|
||||
id: `board_droppable_${board_id}`,
|
||||
data: {
|
||||
handleDrop,
|
||||
},
|
||||
data: droppableData,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ touchAction: 'none' }}>
|
||||
<Box sx={{ touchAction: 'none', height: 'full' }}>
|
||||
<ContextMenu<HTMLDivElement>
|
||||
menuProps={{ size: 'sm', isLazy: true }}
|
||||
renderMenu={() => (
|
||||
@@ -148,13 +137,25 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
|
||||
w: 'full',
|
||||
aspectRatio: '1/1',
|
||||
overflow: 'hidden',
|
||||
shadow: isSelected ? 'selected.light' : undefined,
|
||||
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{board.cover_image_name && coverImage?.image_url && (
|
||||
<Image src={coverImage?.image_url} draggable={false} />
|
||||
)}
|
||||
{!(board.cover_image_name && coverImage?.image_url) && (
|
||||
<IAINoImageFallback iconProps={{ boxSize: 8 }} as={FaFolder} />
|
||||
<IAINoContentFallback
|
||||
boxSize={8}
|
||||
icon={FaFolder}
|
||||
sx={{
|
||||
border: '2px solid var(--invokeai-colors-base-200)',
|
||||
_dark: {
|
||||
border: '2px solid var(--invokeai-colors-base-800)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Flex
|
||||
sx={{
|
||||
@@ -167,14 +168,20 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
|
||||
<Badge variant="solid">{board.image_count}</Badge>
|
||||
</Flex>
|
||||
<AnimatePresence>
|
||||
{isSelected && <SelectedItemOverlay />}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{isDropActive && <IAIDropOverlay isOver={isOver} />}
|
||||
{isValidDrop(droppableData, active) && (
|
||||
<IAIDropOverlay isOver={isOver} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Flex>
|
||||
|
||||
<Box sx={{ width: 'full' }}>
|
||||
<Flex
|
||||
sx={{
|
||||
width: 'full',
|
||||
height: 'full',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Editable
|
||||
defaultValue={board_name}
|
||||
submitOnBlur={false}
|
||||
@@ -204,7 +211,7 @@ const HoverableBoard = memo(({ board, isSelected }: HoverableBoardProps) => {
|
||||
}}
|
||||
/>
|
||||
</Editable>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</ContextMenu>
|
||||
|
||||
@@ -38,8 +38,7 @@ import {
|
||||
FaShare,
|
||||
FaShareAlt,
|
||||
} from 'react-icons/fa';
|
||||
import { gallerySelector } from '../store/gallerySelectors';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
|
||||
@@ -49,22 +48,15 @@ import FaceRestoreSettings from 'features/parameters/components/Parameters/FaceR
|
||||
import UpscaleSettings from 'features/parameters/components/Parameters/Upscale/UpscaleSettings';
|
||||
import { useAppToaster } from 'app/components/Toaster';
|
||||
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
|
||||
import { DeleteImageContext } from 'app/contexts/DeleteImageContext';
|
||||
import { DeleteImageButton } from './DeleteImageModal';
|
||||
import { selectImagesById } from '../store/imagesSlice';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
|
||||
import { DeleteImageButton } from 'features/imageDeletion/components/DeleteImageButton';
|
||||
|
||||
const currentImageButtonsSelector = createSelector(
|
||||
[
|
||||
(state: RootState) => state,
|
||||
systemSelector,
|
||||
gallerySelector,
|
||||
postprocessingSelector,
|
||||
uiSelector,
|
||||
lightboxSelector,
|
||||
activeTabNameSelector,
|
||||
],
|
||||
(state, system, gallery, postprocessing, ui, lightbox, activeTabName) => {
|
||||
[stateSelector, activeTabNameSelector],
|
||||
({ gallery, system, postprocessing, ui, lightbox }, activeTabName) => {
|
||||
const {
|
||||
isProcessing,
|
||||
isConnected,
|
||||
@@ -84,9 +76,7 @@ const currentImageButtonsSelector = createSelector(
|
||||
shouldShowProgressInViewer,
|
||||
} = ui;
|
||||
|
||||
const imageDTO = selectImagesById(state, gallery.selectedImage ?? '');
|
||||
|
||||
const { selectedImage } = gallery;
|
||||
const lastSelectedImage = gallery.selection[gallery.selection.length - 1];
|
||||
|
||||
return {
|
||||
canDeleteImage: isConnected && !isProcessing,
|
||||
@@ -97,16 +87,13 @@ const currentImageButtonsSelector = createSelector(
|
||||
isESRGANAvailable,
|
||||
upscalingLevel,
|
||||
facetoolStrength,
|
||||
shouldDisableToolbarButtons: Boolean(progressImage) || !selectedImage,
|
||||
shouldDisableToolbarButtons: Boolean(progressImage) || !lastSelectedImage,
|
||||
shouldShowImageDetails,
|
||||
activeTabName,
|
||||
isLightboxOpen,
|
||||
shouldHidePreview,
|
||||
image: imageDTO,
|
||||
seed: imageDTO?.metadata?.seed,
|
||||
prompt: imageDTO?.metadata?.positive_conditioning,
|
||||
negativePrompt: imageDTO?.metadata?.negative_conditioning,
|
||||
shouldShowProgressInViewer,
|
||||
lastSelectedImage,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -132,7 +119,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
|
||||
isLightboxOpen,
|
||||
activeTabName,
|
||||
shouldHidePreview,
|
||||
image,
|
||||
lastSelectedImage,
|
||||
shouldShowProgressInViewer,
|
||||
} = useAppSelector(currentImageButtonsSelector);
|
||||
|
||||
@@ -147,7 +134,9 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
|
||||
const { recallBothPrompts, recallSeed, recallAllParameters } =
|
||||
useRecallParameters();
|
||||
|
||||
const { onDelete } = useContext(DeleteImageContext);
|
||||
const { currentData: image } = useGetImageDTOQuery(
|
||||
lastSelectedImage ?? skipToken
|
||||
);
|
||||
|
||||
// const handleCopyImage = useCallback(async () => {
|
||||
// if (!image?.url) {
|
||||
@@ -248,8 +237,11 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(image);
|
||||
}, [image, onDelete]);
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
dispatch(imageToDeleteSelected(image));
|
||||
}, [dispatch, image]);
|
||||
|
||||
useHotkeys(
|
||||
'Shift+U',
|
||||
@@ -371,7 +363,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<ButtonGroup isAttached={true}>
|
||||
<ButtonGroup isAttached={true} isDisabled={shouldDisableToolbarButtons}>
|
||||
<IAIPopover
|
||||
triggerComponent={
|
||||
<IAIIconButton
|
||||
@@ -444,11 +436,12 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
|
||||
}
|
||||
isChecked={isLightboxOpen}
|
||||
onClick={handleLightBox}
|
||||
isDisabled={shouldDisableToolbarButtons}
|
||||
/>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup isAttached={true}>
|
||||
<ButtonGroup isAttached={true} isDisabled={shouldDisableToolbarButtons}>
|
||||
<IAIIconButton
|
||||
icon={<FaQuoteRight />}
|
||||
tooltip={`${t('parameters.usePrompt')} (P)`}
|
||||
@@ -478,7 +471,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
|
||||
</ButtonGroup>
|
||||
|
||||
{(isUpscalingEnabled || isFaceRestoreEnabled) && (
|
||||
<ButtonGroup isAttached={true}>
|
||||
<ButtonGroup
|
||||
isAttached={true}
|
||||
isDisabled={shouldDisableToolbarButtons}
|
||||
>
|
||||
{isFaceRestoreEnabled && (
|
||||
<IAIPopover
|
||||
triggerComponent={
|
||||
@@ -543,7 +539,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
<ButtonGroup isAttached={true}>
|
||||
<ButtonGroup isAttached={true} isDisabled={shouldDisableToolbarButtons}>
|
||||
<IAIIconButton
|
||||
icon={<FaCode />}
|
||||
tooltip={`${t('parameters.info')} (I)`}
|
||||
@@ -553,7 +549,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
|
||||
/>
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup isAttached={true}>
|
||||
<ButtonGroup isAttached={true} isDisabled={shouldDisableToolbarButtons}>
|
||||
<IAIIconButton
|
||||
aria-label={t('settings.displayInProgress')}
|
||||
tooltip={t('settings.displayInProgress')}
|
||||
@@ -564,7 +560,10 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => {
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup isAttached={true}>
|
||||
<DeleteImageButton onClick={handleDelete} />
|
||||
<DeleteImageButton
|
||||
onClick={handleDelete}
|
||||
isDisabled={shouldDisableToolbarButtons}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</>
|
||||
|
||||
@@ -1,29 +1,9 @@
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { systemSelector } from 'features/system/store/systemSelectors';
|
||||
|
||||
import { gallerySelector } from '../store/gallerySelectors';
|
||||
import CurrentImageButtons from './CurrentImageButtons';
|
||||
import CurrentImagePreview from './CurrentImagePreview';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
|
||||
export const currentImageDisplaySelector = createSelector(
|
||||
[systemSelector, gallerySelector],
|
||||
(system, gallery) => {
|
||||
const { progressImage } = system;
|
||||
|
||||
return {
|
||||
hasSelectedImage: Boolean(gallery.selectedImage),
|
||||
hasProgressImage: Boolean(progressImage),
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const CurrentImageDisplay = () => {
|
||||
const { hasSelectedImage } = useAppSelector(currentImageDisplaySelector);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
@@ -36,7 +16,7 @@ const CurrentImageDisplay = () => {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{hasSelectedImage && <CurrentImageButtons />}
|
||||
<CurrentImageButtons />
|
||||
<CurrentImagePreview />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
import { Box, Flex, Image } from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { uiSelector } from 'features/ui/store/uiSelectors';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { gallerySelector } from '../store/gallerySelectors';
|
||||
import ImageMetadataViewer from './ImageMetaDataViewer/ImageMetadataViewer';
|
||||
import NextPrevImageButtons from './NextPrevImageButtons';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { systemSelector } from 'features/system/store/systemSelectors';
|
||||
import { imageSelected } from '../store/gallerySlice';
|
||||
import { memo, useMemo } from 'react';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySlice';
|
||||
import {
|
||||
TypesafeDraggableData,
|
||||
TypesafeDroppableData,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
|
||||
export const imagesSelector = createSelector(
|
||||
[uiSelector, gallerySelector, systemSelector],
|
||||
(ui, gallery, system) => {
|
||||
[stateSelector, selectLastSelectedImage],
|
||||
({ ui, system }, lastSelectedImage) => {
|
||||
const {
|
||||
shouldShowImageDetails,
|
||||
shouldHidePreview,
|
||||
shouldShowProgressInViewer,
|
||||
} = ui;
|
||||
const { selectedImage } = gallery;
|
||||
const { progressImage, shouldAntialiasProgressImage } = system;
|
||||
return {
|
||||
shouldShowImageDetails,
|
||||
shouldHidePreview,
|
||||
selectedImage,
|
||||
imageName: lastSelectedImage,
|
||||
progressImage,
|
||||
shouldShowProgressInViewer,
|
||||
shouldAntialiasProgressImage,
|
||||
@@ -45,29 +43,35 @@ export const imagesSelector = createSelector(
|
||||
const CurrentImagePreview = () => {
|
||||
const {
|
||||
shouldShowImageDetails,
|
||||
selectedImage,
|
||||
imageName,
|
||||
progressImage,
|
||||
shouldShowProgressInViewer,
|
||||
shouldAntialiasProgressImage,
|
||||
} = useAppSelector(imagesSelector);
|
||||
|
||||
const {
|
||||
currentData: image,
|
||||
currentData: imageDTO,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useGetImageDTOQuery(selectedImage ?? skipToken);
|
||||
} = useGetImageDTOQuery(imageName ?? skipToken);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
|
||||
if (imageDTO) {
|
||||
return {
|
||||
id: 'current-image',
|
||||
payloadType: 'IMAGE_DTO',
|
||||
payload: { imageDTO },
|
||||
};
|
||||
}
|
||||
}, [imageDTO]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(droppedImage: ImageDTO) => {
|
||||
if (droppedImage.image_name === image?.image_name) {
|
||||
return;
|
||||
}
|
||||
dispatch(imageSelected(droppedImage.image_name));
|
||||
},
|
||||
[dispatch, image?.image_name]
|
||||
const droppableData = useMemo<TypesafeDroppableData | undefined>(
|
||||
() => ({
|
||||
id: 'current-image',
|
||||
actionType: 'SET_CURRENT_IMAGE',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -98,14 +102,15 @@ const CurrentImagePreview = () => {
|
||||
/>
|
||||
) : (
|
||||
<IAIDndImage
|
||||
image={image}
|
||||
onDrop={handleDrop}
|
||||
fallback={<IAIImageLoadingFallback sx={{ bg: 'none' }} />}
|
||||
imageDTO={imageDTO}
|
||||
droppableData={droppableData}
|
||||
draggableData={draggableData}
|
||||
isUploadDisabled={true}
|
||||
fitContainer
|
||||
dropLabel="Set as Current Image"
|
||||
/>
|
||||
)}
|
||||
{shouldShowImageDetails && image && (
|
||||
{shouldShowImageDetails && imageDTO && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -116,10 +121,10 @@ const CurrentImagePreview = () => {
|
||||
overflow: 'scroll',
|
||||
}}
|
||||
>
|
||||
<ImageMetadataViewer image={image} />
|
||||
<ImageMetadataViewer image={imageDTO} />
|
||||
</Box>
|
||||
)}
|
||||
{!shouldShowImageDetails && image && (
|
||||
{!shouldShowImageDetails && imageDTO && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Divider,
|
||||
Flex,
|
||||
ListItem,
|
||||
Text,
|
||||
UnorderedList,
|
||||
} from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import {
|
||||
DeleteImageContext,
|
||||
ImageUsage,
|
||||
} from 'app/contexts/DeleteImageContext';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import IAIButton from 'common/components/IAIButton';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import IAISwitch from 'common/components/IAISwitch';
|
||||
import { configSelector } from 'features/system/store/configSelectors';
|
||||
import { systemSelector } from 'features/system/store/systemSelectors';
|
||||
import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
|
||||
import { some } from 'lodash-es';
|
||||
|
||||
import { ChangeEvent, memo, useCallback, useContext, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaTrash } from 'react-icons/fa';
|
||||
|
||||
const selector = createSelector(
|
||||
[systemSelector, configSelector],
|
||||
(system, config) => {
|
||||
const { shouldConfirmOnDelete } = system;
|
||||
const { canRestoreDeletedImagesFromBin } = config;
|
||||
|
||||
return {
|
||||
shouldConfirmOnDelete,
|
||||
canRestoreDeletedImagesFromBin,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const ImageInUseMessage = (props: { imageUsage?: ImageUsage }) => {
|
||||
const { imageUsage } = props;
|
||||
|
||||
if (!imageUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!some(imageUsage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>This image is currently in use in the following features:</Text>
|
||||
<UnorderedList sx={{ paddingInlineStart: 6 }}>
|
||||
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
|
||||
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
|
||||
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
|
||||
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
|
||||
</UnorderedList>
|
||||
<Text>
|
||||
If you delete this image, those features will immediately be reset.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteImageModal = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { isOpen, onClose, onImmediatelyDelete, image, imageUsage } =
|
||||
useContext(DeleteImageContext);
|
||||
|
||||
const { shouldConfirmOnDelete, canRestoreDeletedImagesFromBin } =
|
||||
useAppSelector(selector);
|
||||
|
||||
const handleChangeShouldConfirmOnDelete = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(setShouldConfirmOnDelete(!e.target.checked)),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
{t('gallery.deleteImage')}
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
<Flex direction="column" gap={3}>
|
||||
<ImageInUseMessage imageUsage={imageUsage} />
|
||||
<Divider />
|
||||
<Text>
|
||||
{canRestoreDeletedImagesFromBin
|
||||
? t('gallery.deleteImageBin')
|
||||
: t('gallery.deleteImagePermanent')}
|
||||
</Text>
|
||||
<Text>{t('common.areYouSure')}</Text>
|
||||
<IAISwitch
|
||||
label={t('common.dontAskMeAgain')}
|
||||
isChecked={!shouldConfirmOnDelete}
|
||||
onChange={handleChangeShouldConfirmOnDelete}
|
||||
/>
|
||||
</Flex>
|
||||
</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<IAIButton ref={cancelRef} onClick={onClose}>
|
||||
Cancel
|
||||
</IAIButton>
|
||||
<IAIButton colorScheme="error" onClick={onImmediatelyDelete} ml={3}>
|
||||
Delete
|
||||
</IAIButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DeleteImageModal);
|
||||
|
||||
const deleteImageButtonsSelector = createSelector(
|
||||
[systemSelector],
|
||||
(system) => {
|
||||
const { isProcessing, isConnected } = system;
|
||||
|
||||
return isConnected && !isProcessing;
|
||||
}
|
||||
);
|
||||
|
||||
type DeleteImageButtonProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const DeleteImageButton = (props: DeleteImageButtonProps) => {
|
||||
const { onClick } = props;
|
||||
const { t } = useTranslation();
|
||||
const canDeleteImage = useAppSelector(deleteImageButtonsSelector);
|
||||
|
||||
return (
|
||||
<IAIIconButton
|
||||
onClick={onClick}
|
||||
icon={<FaTrash />}
|
||||
tooltip={`${t('gallery.deleteImage')} (Del)`}
|
||||
aria-label={`${t('gallery.deleteImage')} (Del)`}
|
||||
isDisabled={!canDeleteImage}
|
||||
colorScheme="error"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { MouseEvent, memo, useCallback, useMemo } from 'react';
|
||||
import { FaTrash } from 'react-icons/fa';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import ImageContextMenu from './ImageContextMenu';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import {
|
||||
imageRangeEndSelected,
|
||||
imageSelected,
|
||||
imageSelectionToggled,
|
||||
} from '../store/gallerySlice';
|
||||
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
|
||||
|
||||
export const selector = createSelector(
|
||||
[stateSelector, (state, { image_name }: ImageDTO) => image_name],
|
||||
({ gallery }, image_name) => {
|
||||
const isSelected = gallery.selection.includes(image_name);
|
||||
const selection = gallery.selection;
|
||||
return {
|
||||
isSelected,
|
||||
selection,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
interface HoverableImageProps {
|
||||
imageDTO: ImageDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gallery image component with delete/use all/use seed buttons on hover.
|
||||
*/
|
||||
const GalleryImage = (props: HoverableImageProps) => {
|
||||
const { isSelected, selection } = useAppSelector((state) =>
|
||||
selector(state, props.imageDTO)
|
||||
);
|
||||
|
||||
const { imageDTO } = props;
|
||||
const { image_url, thumbnail_url, image_name } = imageDTO;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>) => {
|
||||
if (e.shiftKey) {
|
||||
dispatch(imageRangeEndSelected(props.imageDTO.image_name));
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
dispatch(imageSelectionToggled(props.imageDTO.image_name));
|
||||
} else {
|
||||
dispatch(imageSelected(props.imageDTO.image_name));
|
||||
}
|
||||
},
|
||||
[dispatch, props.imageDTO.image_name]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!imageDTO) {
|
||||
return;
|
||||
}
|
||||
dispatch(imageToDeleteSelected(imageDTO));
|
||||
},
|
||||
[dispatch, imageDTO]
|
||||
);
|
||||
|
||||
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
|
||||
if (selection.length > 1) {
|
||||
return {
|
||||
id: 'gallery-image',
|
||||
payloadType: 'IMAGE_NAMES',
|
||||
payload: { imageNames: selection },
|
||||
};
|
||||
}
|
||||
|
||||
if (imageDTO) {
|
||||
return {
|
||||
id: 'gallery-image',
|
||||
payloadType: 'IMAGE_DTO',
|
||||
payload: { imageDTO },
|
||||
};
|
||||
}
|
||||
}, [imageDTO, selection]);
|
||||
|
||||
return (
|
||||
<Box sx={{ w: 'full', h: 'full', touchAction: 'none' }}>
|
||||
<ImageContextMenu image={imageDTO}>
|
||||
{(ref) => (
|
||||
<Box
|
||||
position="relative"
|
||||
key={image_name}
|
||||
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={handleDelete}
|
||||
resetIcon={<FaTrash />}
|
||||
resetTooltip="Delete image"
|
||||
imageSx={{ w: 'full', h: 'full' }}
|
||||
withResetIcon
|
||||
isDropDisabled={true}
|
||||
isUploadDisabled={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</ImageContextMenu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GalleryImage);
|
||||
@@ -1,371 +0,0 @@
|
||||
import { Box, Flex, Icon, Image, MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { imageSelected } from 'features/gallery/store/gallerySlice';
|
||||
import { memo, useCallback, useContext, useState } from 'react';
|
||||
import {
|
||||
FaCheck,
|
||||
FaExpand,
|
||||
FaFolder,
|
||||
FaImage,
|
||||
FaShare,
|
||||
FaTrash,
|
||||
} from 'react-icons/fa';
|
||||
import { ContextMenu } from 'chakra-ui-contextmenu';
|
||||
import {
|
||||
resizeAndScaleCanvas,
|
||||
setInitialCanvasImage,
|
||||
} from 'features/canvas/store/canvasSlice';
|
||||
import { gallerySelector } from 'features/gallery/store/gallerySelectors';
|
||||
import { setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { systemSelector } from 'features/system/store/systemSelectors';
|
||||
import { lightboxSelector } from 'features/lightbox/store/lightboxSelectors';
|
||||
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
|
||||
import { initialImageSelected } from 'features/parameters/store/actions';
|
||||
import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
|
||||
import { useAppToaster } from 'app/components/Toaster';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { DeleteImageContext } from 'app/contexts/DeleteImageContext';
|
||||
import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext';
|
||||
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
|
||||
|
||||
export const selector = createSelector(
|
||||
[gallerySelector, systemSelector, lightboxSelector, activeTabNameSelector],
|
||||
(gallery, system, lightbox, activeTabName) => {
|
||||
const {
|
||||
galleryImageObjectFit,
|
||||
galleryImageMinimumWidth,
|
||||
shouldUseSingleGalleryColumn,
|
||||
} = gallery;
|
||||
|
||||
const { isLightboxOpen } = lightbox;
|
||||
const { isConnected, isProcessing, shouldConfirmOnDelete } = system;
|
||||
|
||||
return {
|
||||
canDeleteImage: isConnected && !isProcessing,
|
||||
shouldConfirmOnDelete,
|
||||
galleryImageObjectFit,
|
||||
galleryImageMinimumWidth,
|
||||
shouldUseSingleGalleryColumn,
|
||||
activeTabName,
|
||||
isLightboxOpen,
|
||||
};
|
||||
},
|
||||
{
|
||||
memoizeOptions: {
|
||||
resultEqualityCheck: isEqual,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface HoverableImageProps {
|
||||
image: ImageDTO;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gallery image component with delete/use all/use seed buttons on hover.
|
||||
*/
|
||||
const HoverableImage = (props: HoverableImageProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const {
|
||||
activeTabName,
|
||||
galleryImageObjectFit,
|
||||
galleryImageMinimumWidth,
|
||||
canDeleteImage,
|
||||
shouldUseSingleGalleryColumn,
|
||||
} = useAppSelector(selector);
|
||||
|
||||
const { image, isSelected } = props;
|
||||
const { image_url, thumbnail_url, image_name } = image;
|
||||
|
||||
const [isHovered, setIsHovered] = useState<boolean>(false);
|
||||
const toaster = useAppToaster();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
|
||||
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
|
||||
|
||||
const { onDelete } = useContext(DeleteImageContext);
|
||||
const { onClickAddToBoard } = useContext(AddImageToBoardContext);
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(image);
|
||||
}, [image, onDelete]);
|
||||
const { recallBothPrompts, recallSeed, recallAllParameters } =
|
||||
useRecallParameters();
|
||||
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: `galleryImage_${image_name}`,
|
||||
data: {
|
||||
image,
|
||||
},
|
||||
});
|
||||
|
||||
const [removeFromBoard] = useRemoveImageFromBoardMutation();
|
||||
|
||||
const handleMouseOver = () => setIsHovered(true);
|
||||
const handleMouseOut = () => setIsHovered(false);
|
||||
|
||||
const handleSelectImage = useCallback(() => {
|
||||
dispatch(imageSelected(image.image_name));
|
||||
}, [image, dispatch]);
|
||||
|
||||
// Recall parameters handlers
|
||||
const handleRecallPrompt = useCallback(() => {
|
||||
recallBothPrompts(
|
||||
image.metadata?.positive_conditioning,
|
||||
image.metadata?.negative_conditioning
|
||||
);
|
||||
}, [
|
||||
image.metadata?.negative_conditioning,
|
||||
image.metadata?.positive_conditioning,
|
||||
recallBothPrompts,
|
||||
]);
|
||||
|
||||
const handleRecallSeed = useCallback(() => {
|
||||
recallSeed(image.metadata?.seed);
|
||||
}, [image, recallSeed]);
|
||||
|
||||
const handleSendToImageToImage = useCallback(() => {
|
||||
dispatch(sentImageToImg2Img());
|
||||
dispatch(initialImageSelected(image));
|
||||
}, [dispatch, image]);
|
||||
|
||||
// const handleRecallInitialImage = useCallback(() => {
|
||||
// recallInitialImage(image.metadata.invokeai?.node?.image);
|
||||
// }, [image, recallInitialImage]);
|
||||
|
||||
/**
|
||||
* TODO: the rest of these
|
||||
*/
|
||||
const handleSendToCanvas = () => {
|
||||
dispatch(sentImageToCanvas());
|
||||
dispatch(setInitialCanvasImage(image));
|
||||
|
||||
dispatch(resizeAndScaleCanvas());
|
||||
|
||||
if (activeTabName !== 'unifiedCanvas') {
|
||||
dispatch(setActiveTab('unifiedCanvas'));
|
||||
}
|
||||
|
||||
toaster({
|
||||
title: t('toast.sentToUnifiedCanvas'),
|
||||
status: 'success',
|
||||
duration: 2500,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUseAllParameters = useCallback(() => {
|
||||
recallAllParameters(image);
|
||||
}, [image, recallAllParameters]);
|
||||
|
||||
const handleLightBox = () => {
|
||||
// dispatch(setCurrentImage(image));
|
||||
// dispatch(setIsLightboxOpen(true));
|
||||
};
|
||||
|
||||
const handleAddToBoard = useCallback(() => {
|
||||
onClickAddToBoard(image);
|
||||
}, [image, onClickAddToBoard]);
|
||||
|
||||
const handleRemoveFromBoard = useCallback(() => {
|
||||
if (!image.board_id) {
|
||||
return;
|
||||
}
|
||||
removeFromBoard({ board_id: image.board_id, image_name: image.image_name });
|
||||
}, [image.board_id, image.image_name, removeFromBoard]);
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(image.image_url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
sx={{ w: 'full', h: 'full', touchAction: 'none' }}
|
||||
>
|
||||
<ContextMenu<HTMLDivElement>
|
||||
menuProps={{ size: 'sm', isLazy: true }}
|
||||
renderMenu={() => (
|
||||
<MenuList sx={{ visibility: 'visible !important' }}>
|
||||
<MenuItem
|
||||
icon={<ExternalLinkIcon />}
|
||||
onClickCapture={handleOpenInNewTab}
|
||||
>
|
||||
{t('common.openInNewTab')}
|
||||
</MenuItem>
|
||||
{isLightboxEnabled && (
|
||||
<MenuItem icon={<FaExpand />} onClickCapture={handleLightBox}>
|
||||
{t('parameters.openInViewer')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<IoArrowUndoCircleOutline />}
|
||||
onClickCapture={handleRecallPrompt}
|
||||
isDisabled={image?.metadata?.positive_conditioning === undefined}
|
||||
>
|
||||
{t('parameters.usePrompt')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
icon={<IoArrowUndoCircleOutline />}
|
||||
onClickCapture={handleRecallSeed}
|
||||
isDisabled={image?.metadata?.seed === undefined}
|
||||
>
|
||||
{t('parameters.useSeed')}
|
||||
</MenuItem>
|
||||
{/* <MenuItem
|
||||
icon={<IoArrowUndoCircleOutline />}
|
||||
onClickCapture={handleRecallInitialImage}
|
||||
isDisabled={image?.metadata?.type !== 'img2img'}
|
||||
>
|
||||
{t('parameters.useInitImg')}
|
||||
</MenuItem> */}
|
||||
<MenuItem
|
||||
icon={<IoArrowUndoCircleOutline />}
|
||||
onClickCapture={handleUseAllParameters}
|
||||
isDisabled={
|
||||
// what should these be
|
||||
!['t2l', 'l2l', 'inpaint'].includes(
|
||||
String(image?.metadata?.type)
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('parameters.useAll')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<FaShare />}
|
||||
onClickCapture={handleSendToImageToImage}
|
||||
id="send-to-img2img"
|
||||
>
|
||||
{t('parameters.sendToImg2Img')}
|
||||
</MenuItem>
|
||||
{isCanvasEnabled && (
|
||||
<MenuItem
|
||||
icon={<FaShare />}
|
||||
onClickCapture={handleSendToCanvas}
|
||||
id="send-to-canvas"
|
||||
>
|
||||
{t('parameters.sendToUnifiedCanvas')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
|
||||
{image.board_id ? 'Change Board' : 'Add to Board'}
|
||||
</MenuItem>
|
||||
{image.board_id && (
|
||||
<MenuItem
|
||||
icon={<FaFolder />}
|
||||
onClickCapture={handleRemoveFromBoard}
|
||||
>
|
||||
Remove from Board
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
sx={{ color: 'error.300' }}
|
||||
icon={<FaTrash />}
|
||||
onClickCapture={handleDelete}
|
||||
>
|
||||
{t('gallery.deleteImage')}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
)}
|
||||
>
|
||||
{(ref) => (
|
||||
<Box
|
||||
position="relative"
|
||||
key={image_name}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
userSelect="none"
|
||||
onClick={handleSelectImage}
|
||||
ref={ref}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
transition: 'transform 0.2s ease-out',
|
||||
aspectRatio: '1/1',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
loading="lazy"
|
||||
objectFit={
|
||||
shouldUseSingleGalleryColumn ? 'contain' : galleryImageObjectFit
|
||||
}
|
||||
draggable={false}
|
||||
rounded="md"
|
||||
src={thumbnail_url || image_url}
|
||||
fallback={<FaImage />}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Flex
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
insetInlineStart: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
filter={'drop-shadow(0px 0px 1rem black)'}
|
||||
as={FaCheck}
|
||||
sx={{
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
maxWidth: '4rem',
|
||||
maxHeight: '4rem',
|
||||
fill: 'ok.500',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
{isHovered && galleryImageMinimumWidth >= 100 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 1,
|
||||
insetInlineEnd: 1,
|
||||
}}
|
||||
>
|
||||
<IAIIconButton
|
||||
onClickCapture={handleDelete}
|
||||
aria-label={t('gallery.deleteImage')}
|
||||
icon={<FaTrash />}
|
||||
size="xs"
|
||||
fontSize={14}
|
||||
isDisabled={!canDeleteImage}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(HoverableImage);
|
||||
@@ -0,0 +1,278 @@
|
||||
import { MenuItem, MenuList } from '@chakra-ui/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
import {
|
||||
FaExpand,
|
||||
FaFolder,
|
||||
FaFolderPlus,
|
||||
FaShare,
|
||||
FaTrash,
|
||||
} from 'react-icons/fa';
|
||||
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
|
||||
import {
|
||||
resizeAndScaleCanvas,
|
||||
setInitialCanvasImage,
|
||||
} from 'features/canvas/store/canvasSlice';
|
||||
import { setActiveTab } from 'features/ui/store/uiSlice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import { IoArrowUndoCircleOutline } from 'react-icons/io5';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
|
||||
import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters';
|
||||
import { initialImageSelected } from 'features/parameters/store/actions';
|
||||
import { sentImageToCanvas, sentImageToImg2Img } from '../store/actions';
|
||||
import { useAppToaster } from 'app/components/Toaster';
|
||||
import { AddImageToBoardContext } from '../../../app/contexts/AddImageToBoardContext';
|
||||
import { useRemoveImageFromBoardMutation } from 'services/api/endpoints/boardImages';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { RootState, stateSelector } from 'app/store/store';
|
||||
import {
|
||||
imagesAddedToBatch,
|
||||
selectionAddedToBatch,
|
||||
} from 'features/batch/store/batchSlice';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import { imageToDeleteSelected } from 'features/imageDeletion/store/imageDeletionSlice';
|
||||
|
||||
const selector = createSelector(
|
||||
[stateSelector, (state: RootState, imageDTO: ImageDTO) => imageDTO],
|
||||
({ gallery, batch }, imageDTO) => {
|
||||
const selectionCount = gallery.selection.length;
|
||||
const isInBatch = batch.imageNames.includes(imageDTO.image_name);
|
||||
|
||||
return { selectionCount, isInBatch };
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
type Props = {
|
||||
image: ImageDTO;
|
||||
children: ContextMenuProps<HTMLDivElement>['children'];
|
||||
};
|
||||
|
||||
const ImageContextMenu = ({ image, children }: Props) => {
|
||||
const { selectionCount, isInBatch } = useAppSelector((state) =>
|
||||
selector(state, image)
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toaster = useAppToaster();
|
||||
|
||||
const isLightboxEnabled = useFeatureStatus('lightbox').isFeatureEnabled;
|
||||
const isCanvasEnabled = useFeatureStatus('unifiedCanvas').isFeatureEnabled;
|
||||
|
||||
const { onClickAddToBoard } = useContext(AddImageToBoardContext);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
dispatch(imageToDeleteSelected(image));
|
||||
}, [dispatch, image]);
|
||||
|
||||
const { recallBothPrompts, recallSeed, recallAllParameters } =
|
||||
useRecallParameters();
|
||||
|
||||
const [removeFromBoard] = useRemoveImageFromBoardMutation();
|
||||
|
||||
// Recall parameters handlers
|
||||
const handleRecallPrompt = useCallback(() => {
|
||||
recallBothPrompts(
|
||||
image.metadata?.positive_conditioning,
|
||||
image.metadata?.negative_conditioning
|
||||
);
|
||||
}, [
|
||||
image.metadata?.negative_conditioning,
|
||||
image.metadata?.positive_conditioning,
|
||||
recallBothPrompts,
|
||||
]);
|
||||
|
||||
const handleRecallSeed = useCallback(() => {
|
||||
recallSeed(image.metadata?.seed);
|
||||
}, [image, recallSeed]);
|
||||
|
||||
const handleSendToImageToImage = useCallback(() => {
|
||||
dispatch(sentImageToImg2Img());
|
||||
dispatch(initialImageSelected(image));
|
||||
}, [dispatch, image]);
|
||||
|
||||
// const handleRecallInitialImage = useCallback(() => {
|
||||
// recallInitialImage(image.metadata.invokeai?.node?.image);
|
||||
// }, [image, recallInitialImage]);
|
||||
|
||||
const handleSendToCanvas = () => {
|
||||
dispatch(sentImageToCanvas());
|
||||
dispatch(setInitialCanvasImage(image));
|
||||
dispatch(resizeAndScaleCanvas());
|
||||
dispatch(setActiveTab('unifiedCanvas'));
|
||||
|
||||
toaster({
|
||||
title: t('toast.sentToUnifiedCanvas'),
|
||||
status: 'success',
|
||||
duration: 2500,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUseAllParameters = useCallback(() => {
|
||||
recallAllParameters(image);
|
||||
}, [image, recallAllParameters]);
|
||||
|
||||
const handleLightBox = () => {
|
||||
// dispatch(setCurrentImage(image));
|
||||
// dispatch(setIsLightboxOpen(true));
|
||||
};
|
||||
|
||||
const handleAddToBoard = useCallback(() => {
|
||||
onClickAddToBoard(image);
|
||||
}, [image, onClickAddToBoard]);
|
||||
|
||||
const handleRemoveFromBoard = useCallback(() => {
|
||||
if (!image.board_id) {
|
||||
return;
|
||||
}
|
||||
removeFromBoard({ board_id: image.board_id, image_name: image.image_name });
|
||||
}, [image.board_id, image.image_name, removeFromBoard]);
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(image.image_url, '_blank');
|
||||
};
|
||||
|
||||
const handleAddSelectionToBatch = useCallback(() => {
|
||||
dispatch(selectionAddedToBatch());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleAddToBatch = useCallback(() => {
|
||||
dispatch(imagesAddedToBatch([image.image_name]));
|
||||
}, [dispatch, image.image_name]);
|
||||
|
||||
return (
|
||||
<ContextMenu<HTMLDivElement>
|
||||
menuProps={{ size: 'sm', isLazy: true }}
|
||||
renderMenu={() => (
|
||||
<MenuList sx={{ visibility: 'visible !important' }}>
|
||||
{selectionCount === 1 ? (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={<ExternalLinkIcon />}
|
||||
onClickCapture={handleOpenInNewTab}
|
||||
>
|
||||
{t('common.openInNewTab')}
|
||||
</MenuItem>
|
||||
{isLightboxEnabled && (
|
||||
<MenuItem icon={<FaExpand />} onClickCapture={handleLightBox}>
|
||||
{t('parameters.openInViewer')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<IoArrowUndoCircleOutline />}
|
||||
onClickCapture={handleRecallPrompt}
|
||||
isDisabled={
|
||||
image?.metadata?.positive_conditioning === undefined
|
||||
}
|
||||
>
|
||||
{t('parameters.usePrompt')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
icon={<IoArrowUndoCircleOutline />}
|
||||
onClickCapture={handleRecallSeed}
|
||||
isDisabled={image?.metadata?.seed === undefined}
|
||||
>
|
||||
{t('parameters.useSeed')}
|
||||
</MenuItem>
|
||||
{/* <MenuItem
|
||||
icon={<IoArrowUndoCircleOutline />}
|
||||
onClickCapture={handleRecallInitialImage}
|
||||
isDisabled={image?.metadata?.type !== 'img2img'}
|
||||
>
|
||||
{t('parameters.useInitImg')}
|
||||
</MenuItem> */}
|
||||
<MenuItem
|
||||
icon={<IoArrowUndoCircleOutline />}
|
||||
onClickCapture={handleUseAllParameters}
|
||||
isDisabled={
|
||||
// what should these be
|
||||
!['t2l', 'l2l', 'inpaint'].includes(
|
||||
String(image?.metadata?.type)
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('parameters.useAll')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<FaShare />}
|
||||
onClickCapture={handleSendToImageToImage}
|
||||
id="send-to-img2img"
|
||||
>
|
||||
{t('parameters.sendToImg2Img')}
|
||||
</MenuItem>
|
||||
{isCanvasEnabled && (
|
||||
<MenuItem
|
||||
icon={<FaShare />}
|
||||
onClickCapture={handleSendToCanvas}
|
||||
id="send-to-canvas"
|
||||
>
|
||||
{t('parameters.sendToUnifiedCanvas')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<FaFolder />}
|
||||
isDisabled={isInBatch}
|
||||
onClickCapture={handleAddToBatch}
|
||||
>
|
||||
Add to Batch
|
||||
</MenuItem>
|
||||
<MenuItem icon={<FaFolder />} onClickCapture={handleAddToBoard}>
|
||||
{image.board_id ? 'Change Board' : 'Add to Board'}
|
||||
</MenuItem>
|
||||
{image.board_id && (
|
||||
<MenuItem
|
||||
icon={<FaFolder />}
|
||||
onClickCapture={handleRemoveFromBoard}
|
||||
>
|
||||
Remove from Board
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
sx={{ color: 'error.600', _dark: { color: 'error.300' } }}
|
||||
icon={<FaTrash />}
|
||||
onClickCapture={handleDelete}
|
||||
>
|
||||
{t('gallery.deleteImage')}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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);
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Flex,
|
||||
FlexProps,
|
||||
Grid,
|
||||
Icon,
|
||||
Skeleton,
|
||||
Text,
|
||||
VStack,
|
||||
@@ -19,12 +18,8 @@ import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import IAIPopover from 'common/components/IAIPopover';
|
||||
import IAISlider from 'common/components/IAISlider';
|
||||
import { gallerySelector } from 'features/gallery/store/gallerySelectors';
|
||||
import {
|
||||
setGalleryImageMinimumWidth,
|
||||
setGalleryImageObjectFit,
|
||||
setShouldAutoSwitchToNewImages,
|
||||
setShouldUseSingleGalleryColumn,
|
||||
setGalleryView,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import { togglePinGalleryPanel } from 'features/ui/store/uiSlice';
|
||||
@@ -43,46 +38,45 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BsPinAngle, BsPinAngleFill } from 'react-icons/bs';
|
||||
import { FaImage, FaServer, FaWrench } from 'react-icons/fa';
|
||||
import { MdPhotoLibrary } from 'react-icons/md';
|
||||
import HoverableImage from './HoverableImage';
|
||||
import GalleryImage from './GalleryImage';
|
||||
|
||||
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||
import { RootState, stateSelector } from 'app/store/store';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import { uiSelector } from 'features/ui/store/uiSelectors';
|
||||
import {
|
||||
ASSETS_CATEGORIES,
|
||||
IMAGE_CATEGORIES,
|
||||
imageCategoriesChanged,
|
||||
selectImagesAll,
|
||||
} from '../store/imagesSlice';
|
||||
shouldAutoSwitchChanged,
|
||||
selectFilteredImages,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import { receivedPageOfImages } from 'services/api/thunks/image';
|
||||
import BoardsList from './Boards/BoardsList';
|
||||
import { boardsSelector } from '../store/boardSlice';
|
||||
import { ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
|
||||
import { mode } from 'theme/util/mode';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
|
||||
const LOADING_IMAGE_ARRAY = Array(20).fill('loading');
|
||||
|
||||
const itemSelector = createSelector(
|
||||
[(state: RootState) => state],
|
||||
(state) => {
|
||||
const { categories, total: allImagesTotal, isLoading } = state.images;
|
||||
const { selectedBoardId } = state.boards;
|
||||
const selector = createSelector(
|
||||
[stateSelector, selectFilteredImages],
|
||||
(state, filteredImages) => {
|
||||
const {
|
||||
categories,
|
||||
total: allImagesTotal,
|
||||
isLoading,
|
||||
selectedBoardId,
|
||||
galleryImageMinimumWidth,
|
||||
galleryView,
|
||||
shouldAutoSwitch,
|
||||
} = state.gallery;
|
||||
const { shouldPinGallery } = state.ui;
|
||||
|
||||
const allImages = selectImagesAll(state);
|
||||
|
||||
const images = allImages.filter((i) => {
|
||||
const isInCategory = categories.includes(i.image_category);
|
||||
const isInSelectedBoard = selectedBoardId
|
||||
? i.board_id === selectedBoardId
|
||||
: true;
|
||||
return isInCategory && isInSelectedBoard;
|
||||
}) as (ImageDTO | string)[];
|
||||
const images = filteredImages as (ImageDTO | string)[];
|
||||
|
||||
return {
|
||||
images: isLoading ? images.concat(LOADING_IMAGE_ARRAY) : images,
|
||||
@@ -90,33 +84,10 @@ const itemSelector = createSelector(
|
||||
isLoading,
|
||||
categories,
|
||||
selectedBoardId,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const mainSelector = createSelector(
|
||||
[gallerySelector, uiSelector, boardsSelector],
|
||||
(gallery, ui, boards) => {
|
||||
const {
|
||||
galleryImageMinimumWidth,
|
||||
galleryImageObjectFit,
|
||||
shouldAutoSwitchToNewImages,
|
||||
shouldUseSingleGalleryColumn,
|
||||
selectedImage,
|
||||
galleryView,
|
||||
} = gallery;
|
||||
|
||||
const { shouldPinGallery } = ui;
|
||||
return {
|
||||
shouldPinGallery,
|
||||
galleryImageMinimumWidth,
|
||||
galleryImageObjectFit,
|
||||
shouldAutoSwitchToNewImages,
|
||||
shouldUseSingleGalleryColumn,
|
||||
selectedImage,
|
||||
shouldAutoSwitch,
|
||||
galleryView,
|
||||
selectedBoardId: boards.selectedBoardId,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
@@ -144,17 +115,16 @@ const ImageGalleryContent = () => {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
const {
|
||||
images,
|
||||
isLoading,
|
||||
allImagesTotal,
|
||||
categories,
|
||||
selectedBoardId,
|
||||
shouldPinGallery,
|
||||
galleryImageMinimumWidth,
|
||||
galleryImageObjectFit,
|
||||
shouldAutoSwitchToNewImages,
|
||||
shouldUseSingleGalleryColumn,
|
||||
selectedImage,
|
||||
shouldAutoSwitch,
|
||||
galleryView,
|
||||
} = useAppSelector(mainSelector);
|
||||
|
||||
const { images, isLoading, allImagesTotal, categories, selectedBoardId } =
|
||||
useAppSelector(itemSelector);
|
||||
} = useAppSelector(selector);
|
||||
|
||||
const { selectedBoard } = useListAllBoardsQuery(undefined, {
|
||||
selectFromResult: ({ data }) => ({
|
||||
@@ -212,12 +182,6 @@ const ImageGalleryContent = () => {
|
||||
return () => osInstance()?.destroy();
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
const setScrollerRef = useCallback((ref: HTMLElement | Window | null) => {
|
||||
if (ref instanceof HTMLElement) {
|
||||
setScroller(ref);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClickImagesCategory = useCallback(() => {
|
||||
dispatch(imageCategoriesChanged(IMAGE_CATEGORIES));
|
||||
dispatch(setGalleryView('images'));
|
||||
@@ -318,29 +282,11 @@ const ImageGalleryContent = () => {
|
||||
withReset
|
||||
handleReset={() => dispatch(setGalleryImageMinimumWidth(64))}
|
||||
/>
|
||||
<IAISimpleCheckbox
|
||||
label={t('gallery.maintainAspectRatio')}
|
||||
isChecked={galleryImageObjectFit === 'contain'}
|
||||
onChange={() =>
|
||||
dispatch(
|
||||
setGalleryImageObjectFit(
|
||||
galleryImageObjectFit === 'contain' ? 'cover' : 'contain'
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<IAISimpleCheckbox
|
||||
label={t('gallery.autoSwitchNewImages')}
|
||||
isChecked={shouldAutoSwitchToNewImages}
|
||||
isChecked={shouldAutoSwitch}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(setShouldAutoSwitchToNewImages(e.target.checked))
|
||||
}
|
||||
/>
|
||||
<IAISimpleCheckbox
|
||||
label={t('gallery.singleColumnLayout')}
|
||||
isChecked={shouldUseSingleGalleryColumn}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(setShouldUseSingleGalleryColumn(e.target.checked))
|
||||
dispatch(shouldAutoSwitchChanged(e.target.checked))
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
@@ -362,55 +308,28 @@ const ImageGalleryContent = () => {
|
||||
{images.length || areMoreAvailable ? (
|
||||
<>
|
||||
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
|
||||
{shouldUseSingleGalleryColumn ? (
|
||||
<Virtuoso
|
||||
style={{ height: '100%' }}
|
||||
data={images as (ImageDTO | string)[]}
|
||||
endReached={handleEndReached}
|
||||
scrollerRef={(ref) => setScrollerRef(ref)}
|
||||
itemContent={(index, item) => (
|
||||
<Flex sx={{ pb: 2 }}>
|
||||
{typeof item === 'string' ? (
|
||||
<Skeleton
|
||||
sx={{ w: 'full', h: 'full', aspectRatio: '1/1' }}
|
||||
/>
|
||||
) : (
|
||||
<HoverableImage
|
||||
key={`${item.image_name}-${item.thumbnail_url}`}
|
||||
image={item}
|
||||
isSelected={selectedImage === item?.image_name}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
style={{ height: '100%' }}
|
||||
data={images}
|
||||
endReached={handleEndReached}
|
||||
components={{
|
||||
Item: ItemContainer,
|
||||
List: ListContainer,
|
||||
}}
|
||||
scrollerRef={setScroller}
|
||||
itemContent={(index, item) => (
|
||||
<Flex sx={{ pb: 2 }}>
|
||||
{typeof item === 'string' ? (
|
||||
<Skeleton
|
||||
sx={{ w: 'full', h: 'full', aspectRatio: '1/1' }}
|
||||
/>
|
||||
) : (
|
||||
<HoverableImage
|
||||
key={`${item.image_name}-${item.thumbnail_url}`}
|
||||
image={item}
|
||||
isSelected={selectedImage === item?.image_name}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
@@ -425,7 +344,10 @@ const ImageGalleryContent = () => {
|
||||
</IAIButton>
|
||||
</>
|
||||
) : (
|
||||
<EmptyGallery />
|
||||
<IAINoContentFallback
|
||||
label={t('gallery.noImagesInGallery')}
|
||||
icon={FaImage}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</VStack>
|
||||
@@ -434,7 +356,7 @@ const ImageGalleryContent = () => {
|
||||
|
||||
type ItemContainerProps = PropsWithChildren & FlexProps;
|
||||
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
|
||||
<Box className="item-container" ref={ref}>
|
||||
<Box className="item-container" ref={ref} p={1.5}>
|
||||
{props.children}
|
||||
</Box>
|
||||
));
|
||||
@@ -451,8 +373,7 @@ const ListContainer = forwardRef((props: ListContainerProps, ref) => {
|
||||
className="list-container"
|
||||
ref={ref}
|
||||
sx={{
|
||||
gap: 2,
|
||||
gridTemplateColumns: `repeat(auto-fit, minmax(${galleryImageMinimumWidth}px, 1fr));`,
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr));`,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
@@ -460,31 +381,4 @@ const ListContainer = forwardRef((props: ListContainerProps, ref) => {
|
||||
);
|
||||
});
|
||||
|
||||
const EmptyGallery = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
padding: 8,
|
||||
h: '100%',
|
||||
w: '100%',
|
||||
color: 'base.500',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
as={MdPhotoLibrary}
|
||||
sx={{
|
||||
w: 16,
|
||||
h: 16,
|
||||
}}
|
||||
/>
|
||||
<Text textAlign="center">{t('gallery.noImagesInGallery')}</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ImageGalleryContent);
|
||||
|
||||
@@ -5,14 +5,13 @@ import { clamp, isEqual } from 'lodash-es';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
|
||||
import { gallerySelector } from '../store/gallerySelectors';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { imageSelected } from '../store/gallerySlice';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import {
|
||||
selectFilteredImagesAsObject,
|
||||
selectFilteredImagesIds,
|
||||
} from '../store/imagesSlice';
|
||||
imageSelected,
|
||||
selectImagesById,
|
||||
} from 'features/gallery/store/gallerySlice';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { selectFilteredImages } from 'features/gallery/store/gallerySlice';
|
||||
|
||||
const nextPrevButtonTriggerAreaStyles: ChakraProps['sx'] = {
|
||||
height: '100%',
|
||||
@@ -25,45 +24,40 @@ const nextPrevButtonStyles: ChakraProps['sx'] = {
|
||||
};
|
||||
|
||||
export const nextPrevImageButtonsSelector = createSelector(
|
||||
[
|
||||
(state: RootState) => state,
|
||||
gallerySelector,
|
||||
selectFilteredImagesAsObject,
|
||||
selectFilteredImagesIds,
|
||||
],
|
||||
(state, gallery, filteredImagesAsObject, filteredImageIds) => {
|
||||
const { selectedImage } = gallery;
|
||||
[stateSelector, selectFilteredImages],
|
||||
(state, filteredImages) => {
|
||||
const lastSelectedImage =
|
||||
state.gallery.selection[state.gallery.selection.length - 1];
|
||||
|
||||
if (!selectedImage) {
|
||||
if (!lastSelectedImage || filteredImages.length === 0) {
|
||||
return {
|
||||
isOnFirstImage: true,
|
||||
isOnLastImage: true,
|
||||
};
|
||||
}
|
||||
|
||||
const currentImageIndex = filteredImageIds.findIndex(
|
||||
(i) => i === selectedImage
|
||||
const currentImageIndex = filteredImages.findIndex(
|
||||
(i) => i.image_name === lastSelectedImage
|
||||
);
|
||||
|
||||
const nextImageIndex = clamp(
|
||||
currentImageIndex + 1,
|
||||
0,
|
||||
filteredImageIds.length - 1
|
||||
filteredImages.length - 1
|
||||
);
|
||||
|
||||
const prevImageIndex = clamp(
|
||||
currentImageIndex - 1,
|
||||
0,
|
||||
filteredImageIds.length - 1
|
||||
filteredImages.length - 1
|
||||
);
|
||||
|
||||
const nextImageId = filteredImageIds[nextImageIndex];
|
||||
const prevImageId = filteredImageIds[prevImageIndex];
|
||||
const nextImageId = filteredImages[nextImageIndex].image_name;
|
||||
const prevImageId = filteredImages[prevImageIndex].image_name;
|
||||
|
||||
const nextImage = filteredImagesAsObject[nextImageId];
|
||||
const prevImage = filteredImagesAsObject[prevImageId];
|
||||
const nextImage = selectImagesById(state, nextImageId);
|
||||
const prevImage = selectImagesById(state, prevImageId);
|
||||
|
||||
const imagesLength = filteredImageIds.length;
|
||||
const imagesLength = filteredImages.length;
|
||||
|
||||
return {
|
||||
isOnFirstImage: currentImageIndex === 0,
|
||||
@@ -101,11 +95,11 @@ const NextPrevImageButtons = () => {
|
||||
}, []);
|
||||
|
||||
const handlePrevImage = useCallback(() => {
|
||||
dispatch(imageSelected(prevImageId));
|
||||
prevImageId && dispatch(imageSelected(prevImageId));
|
||||
}, [dispatch, prevImageId]);
|
||||
|
||||
const handleNextImage = useCallback(() => {
|
||||
dispatch(imageSelected(nextImageId));
|
||||
nextImageId && dispatch(imageSelected(nextImageId));
|
||||
}, [dispatch, nextImageId]);
|
||||
|
||||
useHotkeys(
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useColorMode, useToken } from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { mode } from 'theme/util/mode';
|
||||
|
||||
export const SelectedItemOverlay = () => {
|
||||
const [accent400, accent500] = useToken('colors', [
|
||||
'accent.400',
|
||||
'accent.500',
|
||||
]);
|
||||
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
insetInlineStart: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
boxShadow: `inset 0px 0px 0px 2px ${mode(
|
||||
accent400,
|
||||
accent500
|
||||
)(colorMode)}`,
|
||||
borderRadius: 'var(--invokeai-radii-base)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectImagesEntities } from '../store/imagesSlice';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const useGetImageByName = () => {
|
||||
const images = useAppSelector(selectImagesEntities);
|
||||
return useCallback(
|
||||
(name: string | undefined) => {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
return images[name];
|
||||
},
|
||||
[images]
|
||||
);
|
||||
};
|
||||
|
||||
export default useGetImageByName;
|
||||
@@ -1,15 +1,6 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
import { ImageUsage } from 'app/contexts/DeleteImageContext';
|
||||
import { ImageDTO, BoardDTO } from 'services/api/types';
|
||||
|
||||
export type RequestedImageDeletionArg = {
|
||||
image: ImageDTO;
|
||||
imageUsage: ImageUsage;
|
||||
};
|
||||
|
||||
export const requestedImageDeletion = createAction<RequestedImageDeletionArg>(
|
||||
'gallery/requestedImageDeletion'
|
||||
);
|
||||
import { ImageUsage } from 'app/contexts/AddImageToBoardContext';
|
||||
import { BoardDTO } from 'services/api/types';
|
||||
|
||||
export type RequestedBoardImagesDeletionArg = {
|
||||
board: BoardDTO;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { boardsApi } from 'services/api/endpoints/boards';
|
||||
|
||||
type BoardsState = {
|
||||
searchText: string;
|
||||
selectedBoardId?: string;
|
||||
updateBoardModalOpen: boolean;
|
||||
};
|
||||
|
||||
@@ -17,9 +15,6 @@ const boardsSlice = createSlice({
|
||||
name: 'boards',
|
||||
initialState: initialBoardsState,
|
||||
reducers: {
|
||||
boardIdSelected: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.selectedBoardId = action.payload;
|
||||
},
|
||||
setBoardSearchText: (state, action: PayloadAction<string>) => {
|
||||
state.searchText = action.payload;
|
||||
},
|
||||
@@ -27,19 +22,9 @@ const boardsSlice = createSlice({
|
||||
state.updateBoardModalOpen = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addMatcher(
|
||||
boardsApi.endpoints.deleteBoard.matchFulfilled,
|
||||
(state, action) => {
|
||||
if (action.meta.arg.originalArgs === state.selectedBoardId) {
|
||||
state.selectedBoardId = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const { boardIdSelected, setBoardSearchText, setUpdateBoardModalOpen } =
|
||||
export const { setBoardSearchText, setUpdateBoardModalOpen } =
|
||||
boardsSlice.actions;
|
||||
|
||||
export const boardsSelector = (state: RootState) => state.boards;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { GalleryState } from './gallerySlice';
|
||||
import { initialGalleryState } from './gallerySlice';
|
||||
|
||||
/**
|
||||
* Gallery slice persist denylist
|
||||
*/
|
||||
export const galleryPersistDenylist: (keyof GalleryState)[] = [
|
||||
'shouldAutoSwitchToNewImages',
|
||||
export const galleryPersistDenylist: (keyof typeof initialGalleryState)[] = [
|
||||
'selection',
|
||||
'entities',
|
||||
'ids',
|
||||
'isLoading',
|
||||
'limit',
|
||||
'offset',
|
||||
'selectedBoardId',
|
||||
'total',
|
||||
];
|
||||
|
||||
@@ -1,87 +1,266 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { imageUpserted } from './imagesSlice';
|
||||
import type { PayloadAction, Update } from '@reduxjs/toolkit';
|
||||
import {
|
||||
createEntityAdapter,
|
||||
createSelector,
|
||||
createSlice,
|
||||
} from '@reduxjs/toolkit';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import { dateComparator } from 'common/util/dateComparator';
|
||||
import { imageDeletionConfirmed } from 'features/imageDeletion/store/imageDeletionSlice';
|
||||
import { keyBy, 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';
|
||||
|
||||
type GalleryImageObjectFitType = 'contain' | 'cover';
|
||||
export const imagesAdapter = createEntityAdapter<ImageDTO>({
|
||||
selectId: (image) => image.image_name,
|
||||
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
|
||||
});
|
||||
|
||||
export interface GalleryState {
|
||||
selectedImage?: string;
|
||||
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
|
||||
export const ASSETS_CATEGORIES: ImageCategory[] = [
|
||||
'control',
|
||||
'mask',
|
||||
'user',
|
||||
'other',
|
||||
];
|
||||
|
||||
type AdditionaGalleryState = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
categories: ImageCategory[];
|
||||
selectedBoardId?: string;
|
||||
selection: string[];
|
||||
shouldAutoSwitch: boolean;
|
||||
galleryImageMinimumWidth: number;
|
||||
galleryImageObjectFit: GalleryImageObjectFitType;
|
||||
shouldAutoSwitchToNewImages: boolean;
|
||||
shouldUseSingleGalleryColumn: boolean;
|
||||
galleryView: 'images' | 'assets' | 'boards';
|
||||
}
|
||||
|
||||
export const initialGalleryState: GalleryState = {
|
||||
galleryImageMinimumWidth: 64,
|
||||
galleryImageObjectFit: 'cover',
|
||||
shouldAutoSwitchToNewImages: true,
|
||||
shouldUseSingleGalleryColumn: false,
|
||||
galleryView: 'images',
|
||||
};
|
||||
|
||||
export const initialGalleryState =
|
||||
imagesAdapter.getInitialState<AdditionaGalleryState>({
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
isLoading: true,
|
||||
categories: IMAGE_CATEGORIES,
|
||||
selection: [],
|
||||
shouldAutoSwitch: true,
|
||||
galleryImageMinimumWidth: 64,
|
||||
galleryView: 'images',
|
||||
});
|
||||
|
||||
export const gallerySlice = createSlice({
|
||||
name: 'gallery',
|
||||
initialState: initialGalleryState,
|
||||
reducers: {
|
||||
imageSelected: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.selectedImage = action.payload;
|
||||
// TODO: if the user selects an image, disable the auto switch?
|
||||
// state.shouldAutoSwitchToNewImages = false;
|
||||
imageUpserted: (state, action: PayloadAction<ImageDTO>) => {
|
||||
imagesAdapter.upsertOne(state, action.payload);
|
||||
if (
|
||||
state.shouldAutoSwitch &&
|
||||
action.payload.image_category === 'general'
|
||||
) {
|
||||
state.selection = [action.payload.image_name];
|
||||
}
|
||||
},
|
||||
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
|
||||
imagesAdapter.updateOne(state, action.payload);
|
||||
},
|
||||
imageRemoved: (state, action: PayloadAction<string>) => {
|
||||
imagesAdapter.removeOne(state, action.payload);
|
||||
},
|
||||
imagesRemoved: (state, action: PayloadAction<string[]>) => {
|
||||
imagesAdapter.removeMany(state, action.payload);
|
||||
},
|
||||
imageCategoriesChanged: (state, action: PayloadAction<ImageCategory[]>) => {
|
||||
state.categories = action.payload;
|
||||
},
|
||||
imageRangeEndSelected: (state, action: PayloadAction<string>) => {
|
||||
const rangeEndImageName = action.payload;
|
||||
const lastSelectedImage = state.selection[state.selection.length - 1];
|
||||
|
||||
const filteredImages = selectFilteredImagesLocal(state);
|
||||
|
||||
const lastClickedIndex = filteredImages.findIndex(
|
||||
(n) => n.image_name === lastSelectedImage
|
||||
);
|
||||
|
||||
const currentClickedIndex = filteredImages.findIndex(
|
||||
(n) => n.image_name === rangeEndImageName
|
||||
);
|
||||
|
||||
if (lastClickedIndex > -1 && currentClickedIndex > -1) {
|
||||
// We have a valid range!
|
||||
const start = Math.min(lastClickedIndex, currentClickedIndex);
|
||||
const end = Math.max(lastClickedIndex, currentClickedIndex);
|
||||
|
||||
const imagesToSelect = filteredImages
|
||||
.slice(start, end + 1)
|
||||
.map((i) => i.image_name);
|
||||
|
||||
state.selection = uniq(state.selection.concat(imagesToSelect));
|
||||
}
|
||||
},
|
||||
imageSelectionToggled: (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));
|
||||
}
|
||||
},
|
||||
imageSelected: (state, action: PayloadAction<string | null>) => {
|
||||
state.selection = action.payload
|
||||
? [action.payload]
|
||||
: [String(state.ids[0])];
|
||||
},
|
||||
shouldAutoSwitchChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.shouldAutoSwitch = action.payload;
|
||||
},
|
||||
setGalleryImageMinimumWidth: (state, action: PayloadAction<number>) => {
|
||||
state.galleryImageMinimumWidth = action.payload;
|
||||
},
|
||||
setGalleryImageObjectFit: (
|
||||
state,
|
||||
action: PayloadAction<GalleryImageObjectFitType>
|
||||
) => {
|
||||
state.galleryImageObjectFit = action.payload;
|
||||
},
|
||||
setShouldAutoSwitchToNewImages: (state, action: PayloadAction<boolean>) => {
|
||||
state.shouldAutoSwitchToNewImages = action.payload;
|
||||
},
|
||||
setShouldUseSingleGalleryColumn: (
|
||||
state,
|
||||
action: PayloadAction<boolean>
|
||||
) => {
|
||||
state.shouldUseSingleGalleryColumn = action.payload;
|
||||
},
|
||||
setGalleryView: (
|
||||
state,
|
||||
action: PayloadAction<'images' | 'assets' | 'boards'>
|
||||
) => {
|
||||
state.galleryView = action.payload;
|
||||
},
|
||||
boardIdSelected: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.selectedBoardId = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(imageUpserted, (state, action) => {
|
||||
if (
|
||||
state.shouldAutoSwitchToNewImages &&
|
||||
action.payload.image_category === 'general'
|
||||
) {
|
||||
state.selectedImage = action.payload.image_name;
|
||||
}
|
||||
builder.addCase(receivedPageOfImages.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
});
|
||||
// builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
|
||||
// const { image_name, image_url, thumbnail_url } = action.payload;
|
||||
builder.addCase(receivedPageOfImages.rejected, (state) => {
|
||||
state.isLoading = false;
|
||||
});
|
||||
builder.addCase(receivedPageOfImages.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
const { board_id, categories, image_origin, is_intermediate } =
|
||||
action.meta.arg;
|
||||
|
||||
// if (state.selectedImage?.image_name === image_name) {
|
||||
// state.selectedImage.image_url = image_url;
|
||||
// state.selectedImage.thumbnail_url = thumbnail_url;
|
||||
// }
|
||||
// });
|
||||
const { items, offset, limit, total } = action.payload;
|
||||
|
||||
const transformedItems = items.map((item) => ({
|
||||
...item,
|
||||
isSelected: false,
|
||||
}));
|
||||
|
||||
imagesAdapter.upsertMany(state, transformedItems);
|
||||
|
||||
if (state.selection.length === 0) {
|
||||
state.selection = [items[0].image_name];
|
||||
}
|
||||
|
||||
if (!categories?.includes('general') || board_id) {
|
||||
// need to skip updating the total images count if the images recieved were for a specific board
|
||||
// TODO: this doesn't work when on the Asset tab/category...
|
||||
return;
|
||||
}
|
||||
|
||||
state.offset = offset;
|
||||
state.limit = limit;
|
||||
state.total = total;
|
||||
});
|
||||
builder.addCase(imageDeletionConfirmed, (state, action) => {
|
||||
// Image deleted
|
||||
const { image_name } = action.payload.imageDTO;
|
||||
imagesAdapter.removeOne(state, image_name);
|
||||
});
|
||||
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
|
||||
const { image_name, image_url, thumbnail_url } = action.payload;
|
||||
|
||||
imagesAdapter.updateOne(state, {
|
||||
id: image_name,
|
||||
changes: { image_url, thumbnail_url },
|
||||
});
|
||||
});
|
||||
builder.addMatcher(
|
||||
boardsApi.endpoints.deleteBoard.matchFulfilled,
|
||||
(state, action) => {
|
||||
if (action.meta.arg.originalArgs === state.selectedBoardId) {
|
||||
state.selectedBoardId = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
selectAll: selectImagesAll,
|
||||
selectById: selectImagesById,
|
||||
selectEntities: selectImagesEntities,
|
||||
selectIds: selectImagesIds,
|
||||
selectTotal: selectImagesTotal,
|
||||
} = imagesAdapter.getSelectors<RootState>((state) => state.gallery);
|
||||
|
||||
export const {
|
||||
imageUpserted,
|
||||
imageUpdatedOne,
|
||||
imageRemoved,
|
||||
imagesRemoved,
|
||||
imageCategoriesChanged,
|
||||
imageRangeEndSelected,
|
||||
imageSelectionToggled,
|
||||
imageSelected,
|
||||
shouldAutoSwitchChanged,
|
||||
setGalleryImageMinimumWidth,
|
||||
setGalleryImageObjectFit,
|
||||
setShouldAutoSwitchToNewImages,
|
||||
setShouldUseSingleGalleryColumn,
|
||||
setGalleryView,
|
||||
boardIdSelected,
|
||||
} = 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
|
||||
);
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import {
|
||||
PayloadAction,
|
||||
Update,
|
||||
createEntityAdapter,
|
||||
createSelector,
|
||||
createSlice,
|
||||
} from '@reduxjs/toolkit';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { ImageCategory, ImageDTO } from 'services/api/types';
|
||||
import { dateComparator } from 'common/util/dateComparator';
|
||||
import { keyBy } from 'lodash-es';
|
||||
import {
|
||||
imageDeleted,
|
||||
imageUrlsReceived,
|
||||
receivedPageOfImages,
|
||||
} from 'services/api/thunks/image';
|
||||
|
||||
export const imagesAdapter = createEntityAdapter<ImageDTO>({
|
||||
selectId: (image) => image.image_name,
|
||||
sortComparer: (a, b) => dateComparator(b.updated_at, a.updated_at),
|
||||
});
|
||||
|
||||
export const IMAGE_CATEGORIES: ImageCategory[] = ['general'];
|
||||
export const ASSETS_CATEGORIES: ImageCategory[] = [
|
||||
'control',
|
||||
'mask',
|
||||
'user',
|
||||
'other',
|
||||
];
|
||||
|
||||
type AdditionaImagesState = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
categories: ImageCategory[];
|
||||
};
|
||||
|
||||
export const initialImagesState =
|
||||
imagesAdapter.getInitialState<AdditionaImagesState>({
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
isLoading: true,
|
||||
categories: IMAGE_CATEGORIES,
|
||||
});
|
||||
|
||||
export type ImagesState = typeof initialImagesState;
|
||||
|
||||
const imagesSlice = createSlice({
|
||||
name: 'images',
|
||||
initialState: initialImagesState,
|
||||
reducers: {
|
||||
imageUpserted: (state, action: PayloadAction<ImageDTO>) => {
|
||||
imagesAdapter.upsertOne(state, action.payload);
|
||||
},
|
||||
imageUpdatedOne: (state, action: PayloadAction<Update<ImageDTO>>) => {
|
||||
imagesAdapter.updateOne(state, action.payload);
|
||||
},
|
||||
imageRemoved: (state, action: PayloadAction<string>) => {
|
||||
imagesAdapter.removeOne(state, action.payload);
|
||||
},
|
||||
imagesRemoved: (state, action: PayloadAction<string[]>) => {
|
||||
imagesAdapter.removeMany(state, action.payload);
|
||||
},
|
||||
imageCategoriesChanged: (state, action: PayloadAction<ImageCategory[]>) => {
|
||||
state.categories = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(receivedPageOfImages.pending, (state) => {
|
||||
state.isLoading = true;
|
||||
});
|
||||
builder.addCase(receivedPageOfImages.rejected, (state) => {
|
||||
state.isLoading = false;
|
||||
});
|
||||
builder.addCase(receivedPageOfImages.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
const { board_id, categories, image_origin, is_intermediate } =
|
||||
action.meta.arg;
|
||||
|
||||
const { items, offset, limit, total } = action.payload;
|
||||
imagesAdapter.upsertMany(state, items);
|
||||
|
||||
if (!categories?.includes('general') || board_id) {
|
||||
// need to skip updating the total images count if the images recieved were for a specific board
|
||||
// TODO: this doesn't work when on the Asset tab/category...
|
||||
return;
|
||||
}
|
||||
|
||||
state.offset = offset;
|
||||
state.limit = limit;
|
||||
state.total = total;
|
||||
});
|
||||
builder.addCase(imageDeleted.pending, (state, action) => {
|
||||
// Image deleted
|
||||
const { image_name } = action.meta.arg;
|
||||
imagesAdapter.removeOne(state, image_name);
|
||||
});
|
||||
builder.addCase(imageUrlsReceived.fulfilled, (state, action) => {
|
||||
const { image_name, image_url, thumbnail_url } = action.payload;
|
||||
|
||||
imagesAdapter.updateOne(state, {
|
||||
id: image_name,
|
||||
changes: { image_url, thumbnail_url },
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
selectAll: selectImagesAll,
|
||||
selectById: selectImagesById,
|
||||
selectEntities: selectImagesEntities,
|
||||
selectIds: selectImagesIds,
|
||||
selectTotal: selectImagesTotal,
|
||||
} = imagesAdapter.getSelectors<RootState>((state) => state.images);
|
||||
|
||||
export const {
|
||||
imageUpserted,
|
||||
imageUpdatedOne,
|
||||
imageRemoved,
|
||||
imagesRemoved,
|
||||
imageCategoriesChanged,
|
||||
} = imagesSlice.actions;
|
||||
|
||||
export default imagesSlice.reducer;
|
||||
|
||||
export const selectFilteredImagesAsArray = createSelector(
|
||||
(state: RootState) => state,
|
||||
(state) => {
|
||||
const {
|
||||
images: { categories },
|
||||
} = state;
|
||||
|
||||
return selectImagesAll(state).filter((i) =>
|
||||
categories.includes(i.image_category)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFilteredImagesAsObject = createSelector(
|
||||
(state: RootState) => state,
|
||||
(state) => {
|
||||
const {
|
||||
images: { categories },
|
||||
} = state;
|
||||
|
||||
return keyBy(
|
||||
selectImagesAll(state).filter((i) =>
|
||||
categories.includes(i.image_category)
|
||||
),
|
||||
'image_name'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFilteredImagesIds = createSelector(
|
||||
(state: RootState) => state,
|
||||
(state) => {
|
||||
const {
|
||||
images: { categories },
|
||||
} = state;
|
||||
|
||||
return selectImagesAll(state)
|
||||
.filter((i) => categories.includes(i.image_category))
|
||||
.map((i) => i.image_name);
|
||||
}
|
||||
);
|
||||
|
||||
// export const selectImageById = createSelector(
|
||||
// (state: RootState, imageId) => state,
|
||||
// (state) => {
|
||||
// const {
|
||||
// images: { categories },
|
||||
// } = state;
|
||||
|
||||
// return selectImagesAll(state)
|
||||
// .filter((i) => categories.includes(i.image_category))
|
||||
// .map((i) => i.image_name);
|
||||
// }
|
||||
// );
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IconButtonProps } from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaTrash } from 'react-icons/fa';
|
||||
|
||||
const deleteImageButtonsSelector = createSelector(
|
||||
[stateSelector],
|
||||
({ system }) => {
|
||||
const { isProcessing, isConnected } = system;
|
||||
|
||||
return isConnected && !isProcessing;
|
||||
}
|
||||
);
|
||||
|
||||
type DeleteImageButtonProps = Omit<IconButtonProps, 'aria-label'> & {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const DeleteImageButton = (props: DeleteImageButtonProps) => {
|
||||
const { onClick, isDisabled } = props;
|
||||
const { t } = useTranslation();
|
||||
const canDeleteImage = useAppSelector(deleteImageButtonsSelector);
|
||||
|
||||
return (
|
||||
<IAIIconButton
|
||||
onClick={onClick}
|
||||
icon={<FaTrash />}
|
||||
tooltip={`${t('gallery.deleteImage')} (Del)`}
|
||||
aria-label={`${t('gallery.deleteImage')} (Del)`}
|
||||
isDisabled={isDisabled || !canDeleteImage}
|
||||
colorScheme="error"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Divider,
|
||||
Flex,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import IAIButton from 'common/components/IAIButton';
|
||||
import IAISwitch from 'common/components/IAISwitch';
|
||||
import { setShouldConfirmOnDelete } from 'features/system/store/systemSlice';
|
||||
|
||||
import { ChangeEvent, memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ImageUsageMessage from './ImageUsageMessage';
|
||||
import { stateSelector } from 'app/store/store';
|
||||
import {
|
||||
imageDeletionConfirmed,
|
||||
imageToDeleteCleared,
|
||||
selectImageUsage,
|
||||
} from '../store/imageDeletionSlice';
|
||||
|
||||
const selector = createSelector(
|
||||
[stateSelector, selectImageUsage],
|
||||
({ system, config, imageDeletion }, imageUsage) => {
|
||||
const { shouldConfirmOnDelete } = system;
|
||||
const { canRestoreDeletedImagesFromBin } = config;
|
||||
const { imageToDelete, isModalOpen } = imageDeletion;
|
||||
return {
|
||||
shouldConfirmOnDelete,
|
||||
canRestoreDeletedImagesFromBin,
|
||||
imageToDelete,
|
||||
imageUsage,
|
||||
isModalOpen,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const DeleteImageModal = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
shouldConfirmOnDelete,
|
||||
canRestoreDeletedImagesFromBin,
|
||||
imageToDelete,
|
||||
imageUsage,
|
||||
isModalOpen,
|
||||
} = useAppSelector(selector);
|
||||
|
||||
const handleChangeShouldConfirmOnDelete = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(setShouldConfirmOnDelete(!e.target.checked)),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(imageToDeleteCleared());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!imageToDelete || !imageUsage) {
|
||||
return;
|
||||
}
|
||||
dispatch(imageToDeleteCleared());
|
||||
dispatch(imageDeletionConfirmed({ imageDTO: imageToDelete, imageUsage }));
|
||||
}, [dispatch, imageToDelete, imageUsage]);
|
||||
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleClose}
|
||||
leastDestructiveRef={cancelRef}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
{t('gallery.deleteImage')}
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
<Flex direction="column" gap={3}>
|
||||
<ImageUsageMessage imageUsage={imageUsage} />
|
||||
<Divider />
|
||||
<Text>
|
||||
{canRestoreDeletedImagesFromBin
|
||||
? t('gallery.deleteImageBin')
|
||||
: t('gallery.deleteImagePermanent')}
|
||||
</Text>
|
||||
<Text>{t('common.areYouSure')}</Text>
|
||||
<IAISwitch
|
||||
label={t('common.dontAskMeAgain')}
|
||||
isChecked={!shouldConfirmOnDelete}
|
||||
onChange={handleChangeShouldConfirmOnDelete}
|
||||
/>
|
||||
</Flex>
|
||||
</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<IAIButton ref={cancelRef} onClick={handleClose}>
|
||||
Cancel
|
||||
</IAIButton>
|
||||
<IAIButton colorScheme="error" onClick={handleDelete} ml={3}>
|
||||
Delete
|
||||
</IAIButton>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DeleteImageModal);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { some } from 'lodash-es';
|
||||
import { memo } from 'react';
|
||||
import { ImageUsage } from '../store/imageDeletionSlice';
|
||||
import { ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||
|
||||
const ImageUsageMessage = (props: { imageUsage?: ImageUsage }) => {
|
||||
const { imageUsage } = props;
|
||||
|
||||
if (!imageUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!some(imageUsage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>This image is currently in use in the following features:</Text>
|
||||
<UnorderedList sx={{ paddingInlineStart: 6 }}>
|
||||
{imageUsage.isInitialImage && <ListItem>Image to Image</ListItem>}
|
||||
{imageUsage.isCanvasImage && <ListItem>Unified Canvas</ListItem>}
|
||||
{imageUsage.isControlNetImage && <ListItem>ControlNet</ListItem>}
|
||||
{imageUsage.isNodesImage && <ListItem>Node Editor</ListItem>}
|
||||
</UnorderedList>
|
||||
<Text>
|
||||
If you delete this image, those features will immediately be reset.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ImageUsageMessage);
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
PayloadAction,
|
||||
createAction,
|
||||
createSelector,
|
||||
createSlice,
|
||||
} from '@reduxjs/toolkit';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import { some } from 'lodash-es';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
|
||||
type DeleteImageState = {
|
||||
imageToDelete: ImageDTO | null;
|
||||
isModalOpen: boolean;
|
||||
};
|
||||
|
||||
export const initialDeleteImageState: DeleteImageState = {
|
||||
imageToDelete: null,
|
||||
isModalOpen: false,
|
||||
};
|
||||
|
||||
const imageDeletion = createSlice({
|
||||
name: 'imageDeletion',
|
||||
initialState: initialDeleteImageState,
|
||||
reducers: {
|
||||
isModalOpenChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.isModalOpen = action.payload;
|
||||
},
|
||||
imageToDeleteSelected: (state, action: PayloadAction<ImageDTO>) => {
|
||||
state.imageToDelete = action.payload;
|
||||
},
|
||||
imageToDeleteCleared: (state) => {
|
||||
state.imageToDelete = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
isModalOpenChanged,
|
||||
imageToDeleteSelected,
|
||||
imageToDeleteCleared,
|
||||
} = imageDeletion.actions;
|
||||
|
||||
export default imageDeletion.reducer;
|
||||
|
||||
export type ImageUsage = {
|
||||
isInitialImage: boolean;
|
||||
isCanvasImage: boolean;
|
||||
isNodesImage: boolean;
|
||||
isControlNetImage: boolean;
|
||||
};
|
||||
|
||||
export const selectImageUsage = createSelector(
|
||||
[(state: RootState) => state],
|
||||
({ imageDeletion, generation, canvas, nodes, controlNet }) => {
|
||||
const { imageToDelete } = imageDeletion;
|
||||
|
||||
if (!imageToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { image_name } = imageToDelete;
|
||||
|
||||
const isInitialImage = generation.initialImage?.imageName === image_name;
|
||||
|
||||
const isCanvasImage = canvas.layerState.objects.some(
|
||||
(obj) => obj.kind === 'image' && obj.imageName === image_name
|
||||
);
|
||||
|
||||
const isNodesImage = nodes.nodes.some((node) => {
|
||||
return some(
|
||||
node.data.inputs,
|
||||
(input) =>
|
||||
input.type === 'image' && input.value?.image_name === image_name
|
||||
);
|
||||
});
|
||||
|
||||
const isControlNetImage = some(
|
||||
controlNet.controlNets,
|
||||
(c) =>
|
||||
c.controlImage === image_name || c.processedControlImage === image_name
|
||||
);
|
||||
|
||||
const imageUsage: ImageUsage = {
|
||||
isInitialImage,
|
||||
isCanvasImage,
|
||||
isNodesImage,
|
||||
isControlNetImage,
|
||||
};
|
||||
|
||||
return imageUsage;
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
export const imageDeletionConfirmed = createAction<{
|
||||
imageDTO: ImageDTO;
|
||||
imageUsage: ImageUsage;
|
||||
}>('imageDeletion/imageDeletionConfirmed');
|
||||
@@ -16,6 +16,7 @@ import NumberInputFieldComponent from './fields/NumberInputFieldComponent';
|
||||
import StringInputFieldComponent from './fields/StringInputFieldComponent';
|
||||
import ColorInputFieldComponent from './fields/ColorInputFieldComponent';
|
||||
import ItemInputFieldComponent from './fields/ItemInputFieldComponent';
|
||||
import ImageCollectionInputFieldComponent from './fields/ImageCollectionInputFieldComponent';
|
||||
|
||||
type InputFieldComponentProps = {
|
||||
nodeId: string;
|
||||
@@ -191,6 +192,16 @@ const InputFieldComponent = (props: InputFieldComponentProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'image_collection' && template.type === 'image_collection') {
|
||||
return (
|
||||
<ImageCollectionInputFieldComponent
|
||||
nodeId={nodeId}
|
||||
field={field}
|
||||
template={template}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box p={2}>Unknown field type: {type}</Box>;
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const InvocationComponentWrapper = (props: InvocationComponentWrapperProps) => {
|
||||
position: 'relative',
|
||||
borderRadius: 'md',
|
||||
minWidth: NODE_MIN_WIDTH,
|
||||
boxShadow: props.selected
|
||||
shadow: props.selected
|
||||
? `${nodeSelectedOutline}, ${nodeShadow}`
|
||||
: `${nodeShadow}`,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
|
||||
import { fieldValueChanged } from 'features/nodes/store/nodesSlice';
|
||||
import {
|
||||
ImageCollectionInputFieldTemplate,
|
||||
ImageCollectionInputFieldValue,
|
||||
} from 'features/nodes/types/types';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { FieldComponentProps } from './types';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||
import { uniq, uniqBy } from 'lodash-es';
|
||||
import {
|
||||
NodesMultiImageDropData,
|
||||
isValidDrop,
|
||||
useDroppable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
import IAIDropOverlay from 'common/components/IAIDropOverlay';
|
||||
|
||||
const ImageCollectionInputFieldComponent = (
|
||||
props: FieldComponentProps<
|
||||
ImageCollectionInputFieldValue,
|
||||
ImageCollectionInputFieldTemplate
|
||||
>
|
||||
) => {
|
||||
const { nodeId, field } = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleDrop = useCallback(
|
||||
({ image_name }: ImageDTO) => {
|
||||
dispatch(
|
||||
fieldValueChanged({
|
||||
nodeId,
|
||||
fieldName: field.name,
|
||||
value: uniqBy([...(field.value ?? []), { image_name }], 'image_name'),
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, field.name, field.value, nodeId]
|
||||
);
|
||||
|
||||
const droppableData: NodesMultiImageDropData = {
|
||||
id: `node-${nodeId}-${field.name}`,
|
||||
actionType: 'SET_MULTI_NODES_IMAGE',
|
||||
context: { nodeId, fieldName: field.name },
|
||||
};
|
||||
|
||||
const {
|
||||
isOver,
|
||||
setNodeRef: setDroppableRef,
|
||||
active,
|
||||
over,
|
||||
} = useDroppable({
|
||||
id: `node_${nodeId}`,
|
||||
data: droppableData,
|
||||
});
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
dispatch(
|
||||
fieldValueChanged({
|
||||
nodeId,
|
||||
fieldName: field.name,
|
||||
value: undefined,
|
||||
})
|
||||
);
|
||||
}, [dispatch, field.name, nodeId]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={setDroppableRef}
|
||||
sx={{
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
minH: '10rem',
|
||||
}}
|
||||
>
|
||||
{field.value?.map(({ image_name }) => (
|
||||
<ImageSubField key={image_name} imageName={image_name} />
|
||||
))}
|
||||
{isValidDrop(droppableData, active) && <IAIDropOverlay isOver={isOver} />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ImageCollectionInputFieldComponent);
|
||||
|
||||
type ImageSubFieldProps = { imageName: string };
|
||||
|
||||
const ImageSubField = (props: ImageSubFieldProps) => {
|
||||
const { currentData: image } = useGetImageDTOQuery(props.imageName);
|
||||
|
||||
return (
|
||||
<IAIDndImage imageDTO={image} isDropDisabled={true} isDragDisabled={true} />
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ImageInputFieldTemplate,
|
||||
ImageInputFieldValue,
|
||||
} from 'features/nodes/types/types';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import { FieldComponentProps } from './types';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
@@ -13,6 +13,12 @@ import { ImageDTO } from 'services/api/types';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||
import {
|
||||
NodesImageDropData,
|
||||
TypesafeDraggableData,
|
||||
TypesafeDroppableData,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
import { PostUploadAction } from 'services/api/thunks/image';
|
||||
|
||||
const ImageInputFieldComponent = (
|
||||
props: FieldComponentProps<ImageInputFieldValue, ImageInputFieldTemplate>
|
||||
@@ -22,7 +28,7 @@ const ImageInputFieldComponent = (
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const {
|
||||
currentData: image,
|
||||
currentData: imageDTO,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
@@ -55,6 +61,35 @@ const ImageInputFieldComponent = (
|
||||
);
|
||||
}, [dispatch, field.name, nodeId]);
|
||||
|
||||
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
|
||||
if (imageDTO) {
|
||||
return {
|
||||
id: `node-${nodeId}-${field.name}`,
|
||||
payloadType: 'IMAGE_DTO',
|
||||
payload: { imageDTO },
|
||||
};
|
||||
}
|
||||
}, [field.name, imageDTO, nodeId]);
|
||||
|
||||
const droppableData = useMemo<TypesafeDroppableData | undefined>(() => {
|
||||
if (imageDTO) {
|
||||
return {
|
||||
id: `node-${nodeId}-${field.name}`,
|
||||
actionType: 'SET_NODES_IMAGE',
|
||||
context: { nodeId, fieldName: field.name },
|
||||
};
|
||||
}
|
||||
}, [field.name, imageDTO, nodeId]);
|
||||
|
||||
const postUploadAction = useMemo<PostUploadAction>(
|
||||
() => ({
|
||||
type: 'SET_NODES_IMAGE',
|
||||
nodeId,
|
||||
fieldName: field.name,
|
||||
}),
|
||||
[nodeId, field.name]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
@@ -65,15 +100,11 @@ const ImageInputFieldComponent = (
|
||||
}}
|
||||
>
|
||||
<IAIDndImage
|
||||
image={image}
|
||||
onDrop={handleDrop}
|
||||
onReset={handleReset}
|
||||
resetIconSize="sm"
|
||||
postUploadAction={{
|
||||
type: 'SET_NODES_IMAGE',
|
||||
nodeId,
|
||||
fieldName: field.name,
|
||||
}}
|
||||
imageDTO={imageDTO}
|
||||
droppableData={droppableData}
|
||||
draggableData={draggableData}
|
||||
onClickReset={handleReset}
|
||||
postUploadAction={postUploadAction}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { receivedOpenAPISchema } from 'services/api/thunks/schema';
|
||||
import { InvocationTemplate, InvocationValue } from '../types/types';
|
||||
import { RgbaColor } from 'react-colorful';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { cloneDeep, isArray, uniq, uniqBy } from 'lodash-es';
|
||||
|
||||
export type NodesState = {
|
||||
nodes: Node<InvocationValue>[];
|
||||
@@ -62,7 +63,14 @@ const nodesSlice = createSlice({
|
||||
action: PayloadAction<{
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
value: string | number | boolean | ImageField | RgbaColor | undefined;
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| ImageField
|
||||
| RgbaColor
|
||||
| undefined
|
||||
| ImageField[];
|
||||
}>
|
||||
) => {
|
||||
const { nodeId, fieldName, value } = action.payload;
|
||||
@@ -72,6 +80,35 @@ const nodesSlice = createSlice({
|
||||
state.nodes[nodeIndex].data.inputs[fieldName].value = value;
|
||||
}
|
||||
},
|
||||
imageCollectionFieldValueChanged: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
nodeId: string;
|
||||
fieldName: string;
|
||||
value: ImageField[];
|
||||
}>
|
||||
) => {
|
||||
const { nodeId, fieldName, value } = action.payload;
|
||||
const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId);
|
||||
|
||||
if (nodeIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = cloneDeep(
|
||||
state.nodes[nodeIndex].data.inputs[fieldName].value
|
||||
);
|
||||
|
||||
if (!currentValue) {
|
||||
state.nodes[nodeIndex].data.inputs[fieldName].value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
state.nodes[nodeIndex].data.inputs[fieldName].value = uniqBy(
|
||||
(currentValue as ImageField[]).concat(value),
|
||||
'image_name'
|
||||
);
|
||||
},
|
||||
shouldShowGraphOverlayChanged: (state, action: PayloadAction<boolean>) => {
|
||||
state.shouldShowGraphOverlay = action.payload;
|
||||
},
|
||||
@@ -103,6 +140,7 @@ export const {
|
||||
shouldShowGraphOverlayChanged,
|
||||
nodeTemplatesBuilt,
|
||||
nodeEditorReset,
|
||||
imageCollectionFieldValueChanged,
|
||||
} = nodesSlice.actions;
|
||||
|
||||
export default nodesSlice.reducer;
|
||||
|
||||
@@ -10,6 +10,7 @@ export const FIELD_TYPE_MAP: Record<string, FieldType> = {
|
||||
boolean: 'boolean',
|
||||
enum: 'enum',
|
||||
ImageField: 'image',
|
||||
image_collection: 'image_collection',
|
||||
LatentsField: 'latents',
|
||||
ConditioningField: 'conditioning',
|
||||
UNetField: 'unet',
|
||||
@@ -30,9 +31,6 @@ const COLOR_TOKEN_VALUE = 500;
|
||||
const getColorTokenCssVariable = (color: string) =>
|
||||
`var(--invokeai-colors-${color}-${COLOR_TOKEN_VALUE})`;
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-ignore
|
||||
// @ts-ignore
|
||||
export const FIELDS: Record<FieldType, FieldUIConfig> = {
|
||||
integer: {
|
||||
color: 'red',
|
||||
@@ -70,6 +68,12 @@ export const FIELDS: Record<FieldType, FieldUIConfig> = {
|
||||
title: 'Image',
|
||||
description: 'Images may be passed between nodes.',
|
||||
},
|
||||
image_collection: {
|
||||
color: 'purple',
|
||||
colorCssVar: getColorTokenCssVariable('purple'),
|
||||
title: 'Image Collection',
|
||||
description: 'A collection of images.',
|
||||
},
|
||||
latents: {
|
||||
color: 'pink',
|
||||
colorCssVar: getColorTokenCssVariable('pink'),
|
||||
|
||||
@@ -66,7 +66,8 @@ export type FieldType =
|
||||
| 'model'
|
||||
| 'array'
|
||||
| 'item'
|
||||
| 'color';
|
||||
| 'color'
|
||||
| 'image_collection';
|
||||
|
||||
/**
|
||||
* An input field is persisted across reloads as part of the user's local state.
|
||||
@@ -92,7 +93,8 @@ export type InputFieldValue =
|
||||
| ModelInputFieldValue
|
||||
| ArrayInputFieldValue
|
||||
| ItemInputFieldValue
|
||||
| ColorInputFieldValue;
|
||||
| ColorInputFieldValue
|
||||
| ImageCollectionInputFieldValue;
|
||||
|
||||
/**
|
||||
* An input field template is generated on each page load from the OpenAPI schema.
|
||||
@@ -116,7 +118,8 @@ export type InputFieldTemplate =
|
||||
| ModelInputFieldTemplate
|
||||
| ArrayInputFieldTemplate
|
||||
| ItemInputFieldTemplate
|
||||
| ColorInputFieldTemplate;
|
||||
| ColorInputFieldTemplate
|
||||
| ImageCollectionInputFieldTemplate;
|
||||
|
||||
/**
|
||||
* An output field is persisted across as part of the user's local state.
|
||||
@@ -215,6 +218,11 @@ export type ImageInputFieldValue = FieldValueBase & {
|
||||
value?: ImageField;
|
||||
};
|
||||
|
||||
export type ImageCollectionInputFieldValue = FieldValueBase & {
|
||||
type: 'image_collection';
|
||||
value?: ImageField[];
|
||||
};
|
||||
|
||||
export type ModelInputFieldValue = FieldValueBase & {
|
||||
type: 'model';
|
||||
value?: string;
|
||||
@@ -282,6 +290,11 @@ export type ImageInputFieldTemplate = InputFieldTemplateBase & {
|
||||
type: 'image';
|
||||
};
|
||||
|
||||
export type ImageCollectionInputFieldTemplate = InputFieldTemplateBase & {
|
||||
default: ImageField[];
|
||||
type: 'image_collection';
|
||||
};
|
||||
|
||||
export type LatentsInputFieldTemplate = InputFieldTemplateBase & {
|
||||
default: string;
|
||||
type: 'latents';
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
OutputFieldTemplate,
|
||||
TypeHints,
|
||||
FieldType,
|
||||
ImageCollectionInputFieldTemplate,
|
||||
} from '../types/types';
|
||||
|
||||
export type BaseFieldProperties = 'name' | 'title' | 'description';
|
||||
@@ -189,6 +190,21 @@ const buildImageInputFieldTemplate = ({
|
||||
return template;
|
||||
};
|
||||
|
||||
const buildImageCollectionInputFieldTemplate = ({
|
||||
schemaObject,
|
||||
baseField,
|
||||
}: BuildInputFieldArg): ImageCollectionInputFieldTemplate => {
|
||||
const template: ImageCollectionInputFieldTemplate = {
|
||||
...baseField,
|
||||
type: 'image_collection',
|
||||
inputRequirement: 'always',
|
||||
inputKind: 'any',
|
||||
default: schemaObject.default ?? undefined,
|
||||
};
|
||||
|
||||
return template;
|
||||
};
|
||||
|
||||
const buildLatentsInputFieldTemplate = ({
|
||||
schemaObject,
|
||||
baseField,
|
||||
@@ -400,6 +416,10 @@ export const buildInputFieldTemplate = (
|
||||
if (['image'].includes(fieldType)) {
|
||||
return buildImageInputFieldTemplate({ schemaObject, baseField });
|
||||
}
|
||||
|
||||
if (['image_collection'].includes(fieldType)) {
|
||||
return buildImageCollectionInputFieldTemplate({ schemaObject, baseField });
|
||||
}
|
||||
if (['latents'].includes(fieldType)) {
|
||||
return buildLatentsInputFieldTemplate({ schemaObject, baseField });
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ export const buildInputFieldValue = (
|
||||
fieldValue.value = undefined;
|
||||
}
|
||||
|
||||
if (template.type === 'image_collection') {
|
||||
fieldValue.value = [];
|
||||
}
|
||||
|
||||
if (template.type === 'latents') {
|
||||
fieldValue.value = undefined;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { RootState } from 'app/store/store';
|
||||
import {
|
||||
ImageCollectionInvocation,
|
||||
ImageResizeInvocation,
|
||||
ImageToLatentsInvocation,
|
||||
IterateInvocation,
|
||||
} from 'services/api/types';
|
||||
import { NonNullableGraph } from 'features/nodes/types/types';
|
||||
import { log } from 'app/logging/useLogger';
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
IMAGE_TO_LATENTS,
|
||||
LATENTS_TO_LATENTS,
|
||||
RESIZE,
|
||||
IMAGE_COLLECTION,
|
||||
IMAGE_COLLECTION_ITERATE,
|
||||
} from './constants';
|
||||
import { addControlNetToLinearGraph } from '../addControlNetToLinearGraph';
|
||||
import { modelIdToPipelineModelField } from '../modelIdToPipelineModelField';
|
||||
@@ -42,6 +46,15 @@ export const buildLinearImageToImageGraph = (
|
||||
height,
|
||||
} = state.generation;
|
||||
|
||||
const {
|
||||
isEnabled: isBatchEnabled,
|
||||
imageNames: batchImageNames,
|
||||
asInitialImage,
|
||||
} = state.batch;
|
||||
|
||||
const shouldBatch =
|
||||
isBatchEnabled && batchImageNames.length > 0 && asInitialImage;
|
||||
|
||||
/**
|
||||
* The easiest way to build linear graphs is to do it in the node editor, then copy and paste the
|
||||
* full graph here as a template. Then use the parameters from app state and set friendlier node
|
||||
@@ -51,7 +64,7 @@ export const buildLinearImageToImageGraph = (
|
||||
* the `fit` param. These are added to the graph at the end.
|
||||
*/
|
||||
|
||||
if (!initialImage) {
|
||||
if (!initialImage && !shouldBatch) {
|
||||
moduleLog.error('No initial image found in state');
|
||||
throw new Error('No initial image found in state');
|
||||
}
|
||||
@@ -275,6 +288,41 @@ export const buildLinearImageToImageGraph = (
|
||||
});
|
||||
}
|
||||
|
||||
if (isBatchEnabled && asInitialImage && batchImageNames.length > 0) {
|
||||
// we are going to connect an iterate up to the init image
|
||||
delete (graph.nodes[IMAGE_TO_LATENTS] as ImageToLatentsInvocation).image;
|
||||
|
||||
const imageCollection: ImageCollectionInvocation = {
|
||||
id: IMAGE_COLLECTION,
|
||||
type: 'image_collection',
|
||||
images: batchImageNames.map((image_name) => ({ image_name })),
|
||||
};
|
||||
|
||||
const imageCollectionIterate: IterateInvocation = {
|
||||
id: IMAGE_COLLECTION_ITERATE,
|
||||
type: 'iterate',
|
||||
};
|
||||
|
||||
graph.nodes[IMAGE_COLLECTION] = imageCollection;
|
||||
graph.nodes[IMAGE_COLLECTION_ITERATE] = imageCollectionIterate;
|
||||
|
||||
graph.edges.push({
|
||||
source: { node_id: IMAGE_COLLECTION, field: 'collection' },
|
||||
destination: {
|
||||
node_id: IMAGE_COLLECTION_ITERATE,
|
||||
field: 'collection',
|
||||
},
|
||||
});
|
||||
|
||||
graph.edges.push({
|
||||
source: { node_id: IMAGE_COLLECTION_ITERATE, field: 'item' },
|
||||
destination: {
|
||||
node_id: IMAGE_TO_LATENTS,
|
||||
field: 'image',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// add dynamic prompts, mutating `graph`
|
||||
addDynamicPromptsToGraph(graph, state);
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export const RESIZE = 'resize_image';
|
||||
export const INPAINT = 'inpaint';
|
||||
export const CONTROL_NET_COLLECT = 'control_net_collect';
|
||||
export const DYNAMIC_PROMPT = 'dynamic_prompt';
|
||||
export const IMAGE_COLLECTION = 'image_collection';
|
||||
export const IMAGE_COLLECTION_ITERATE = 'image_collection_iterate';
|
||||
|
||||
// friendly graph ids
|
||||
export const TEXT_TO_IMAGE_GRAPH = 'text_to_image_graph';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
|
||||
const selector = createSelector(
|
||||
[stateSelector],
|
||||
(state) => {
|
||||
const { initialImage } = state.generation;
|
||||
const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch;
|
||||
return {
|
||||
initialImage,
|
||||
useBatchAsInitialImage,
|
||||
isResetButtonDisabled: useBatchAsInitialImage
|
||||
? imageNames.length === 0
|
||||
: !initialImage,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const InitialImage = () => {
|
||||
const { initialImage } = useAppSelector(selector);
|
||||
|
||||
const {
|
||||
currentData: imageDTO,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useGetImageDTOQuery(initialImage?.imageName ?? skipToken);
|
||||
|
||||
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {
|
||||
if (imageDTO) {
|
||||
return {
|
||||
id: 'initial-image',
|
||||
payloadType: 'IMAGE_DTO',
|
||||
payload: { imageDTO },
|
||||
};
|
||||
}
|
||||
}, [imageDTO]);
|
||||
|
||||
const droppableData = useMemo<TypesafeDroppableData | undefined>(
|
||||
() => ({
|
||||
id: 'initial-image',
|
||||
actionType: 'SET_INITIAL_IMAGE',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<IAIDndImage
|
||||
imageDTO={imageDTO}
|
||||
droppableData={droppableData}
|
||||
draggableData={draggableData}
|
||||
isUploadDisabled={true}
|
||||
fitContainer
|
||||
dropLabel="Set as Initial Image"
|
||||
noContentFallback={
|
||||
<IAINoContentFallback label="No initial image selected" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialImage;
|
||||
@@ -1,34 +1,153 @@
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import InitialImagePreview from './InitialImagePreview';
|
||||
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 { PostUploadAction } from 'services/api/thunks/image';
|
||||
import InitialImage from './InitialImage';
|
||||
|
||||
const selector = createSelector(
|
||||
[stateSelector],
|
||||
(state) => {
|
||||
const { initialImage } = state.generation;
|
||||
const { asInitialImage: useBatchAsInitialImage, imageNames } = state.batch;
|
||||
return {
|
||||
initialImage,
|
||||
useBatchAsInitialImage,
|
||||
isResetButtonDisabled: useBatchAsInitialImage
|
||||
? imageNames.length === 0
|
||||
: !initialImage,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const InitialImageDisplay = () => {
|
||||
const { initialImage, useBatchAsInitialImage, 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]);
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
openUploader();
|
||||
}, [openUploader]);
|
||||
|
||||
const handleClickUseBatch = useCallback(() => {
|
||||
dispatch(asInitialImageToggled());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
layerStyle={'first'}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
rowGap: 4,
|
||||
height: 'full',
|
||||
width: 'full',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
borderRadius: 'base',
|
||||
p: 4,
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<InitialImagePreview />
|
||||
<Text
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
userSelect: 'none',
|
||||
color: 'base.700',
|
||||
_dark: {
|
||||
color: 'base.200',
|
||||
},
|
||||
}}
|
||||
>
|
||||
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'
|
||||
}
|
||||
icon={<FaUpload />}
|
||||
onClick={handleUpload}
|
||||
{...getUploadButtonProps()}
|
||||
/>
|
||||
<IAIIconButton
|
||||
tooltip={
|
||||
useBatchAsInitialImage ? 'Reset Batch' : 'Reset Initial Image'
|
||||
}
|
||||
aria-label={
|
||||
useBatchAsInitialImage ? 'Reset Batch' : 'Reset Initial Image'
|
||||
}
|
||||
icon={<FaUndo />}
|
||||
onClick={handleReset}
|
||||
isDisabled={isResetButtonDisabled}
|
||||
/>
|
||||
</Flex>
|
||||
{useBatchAsInitialImage ? <BatchImageContainer /> : <InitialImage />}
|
||||
<input {...getUploadInputProps()} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { Flex, Spacer, Text } from '@chakra-ui/react';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import {
|
||||
clearInitialImage,
|
||||
initialImageChanged,
|
||||
} from 'features/parameters/store/generationSlice';
|
||||
import { useCallback } from 'react';
|
||||
import { generationSelector } from 'features/parameters/store/generationSelectors';
|
||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||
import IAIDndImage from 'common/components/IAIDndImage';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
|
||||
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
|
||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import { FaUndo, FaUpload } from 'react-icons/fa';
|
||||
import useImageUploader from 'common/hooks/useImageUploader';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
|
||||
const selector = createSelector(
|
||||
[generationSelector],
|
||||
(generation) => {
|
||||
const { initialImage } = generation;
|
||||
return {
|
||||
initialImage,
|
||||
};
|
||||
},
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const InitialImagePreview = () => {
|
||||
const { initialImage } = useAppSelector(selector);
|
||||
const dispatch = useAppDispatch();
|
||||
const { openUploader } = useImageUploader();
|
||||
|
||||
const {
|
||||
currentData: image,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useGetImageDTOQuery(initialImage?.imageName ?? skipToken);
|
||||
|
||||
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
|
||||
postUploadAction: { type: 'SET_INITIAL_IMAGE' },
|
||||
});
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(droppedImage: ImageDTO) => {
|
||||
if (droppedImage.image_name === initialImage?.imageName) {
|
||||
return;
|
||||
}
|
||||
dispatch(initialImageChanged(droppedImage));
|
||||
},
|
||||
[dispatch, initialImage]
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
dispatch(clearInitialImage());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
openUploader();
|
||||
}, [openUploader]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
flexDir: 'column',
|
||||
width: 'full',
|
||||
height: 'full',
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
w: 'full',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
sx={{
|
||||
color: 'base.200',
|
||||
fontWeight: 600,
|
||||
fontSize: 'sm',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
Initial Image
|
||||
</Text>
|
||||
<Spacer />
|
||||
<IAIIconButton
|
||||
tooltip="Upload Initial Image"
|
||||
aria-label="Upload Initial Image"
|
||||
icon={<FaUpload />}
|
||||
onClick={handleUpload}
|
||||
{...getUploadButtonProps()}
|
||||
/>
|
||||
<IAIIconButton
|
||||
tooltip="Reset Initial Image"
|
||||
aria-label="Reset Initial Image"
|
||||
icon={<FaUndo />}
|
||||
onClick={handleReset}
|
||||
isDisabled={!initialImage}
|
||||
/>
|
||||
</Flex>
|
||||
<IAIDndImage
|
||||
image={image}
|
||||
onDrop={handleDrop}
|
||||
fallback={<IAIImageLoadingFallback sx={{ bg: 'none' }} />}
|
||||
isUploadDisabled={true}
|
||||
fitContainer
|
||||
/>
|
||||
<input {...getUploadInputProps()} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InitialImagePreview;
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ChangeEvent, memo } from 'react';
|
||||
|
||||
import { RootState } from 'app/store/store';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { setShouldRandomizeSeed } from 'features/parameters/store/generationSlice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FormControl, FormLabel, Switch, Tooltip } from '@chakra-ui/react';
|
||||
import IAISwitch from 'common/components/IAISwitch';
|
||||
|
||||
const ParamSeedRandomize = () => {
|
||||
@@ -25,32 +23,6 @@ const ParamSeedRandomize = () => {
|
||||
onChange={handleChangeShouldRandomizeSeed}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FormLabel
|
||||
sx={{
|
||||
mb: 0,
|
||||
flexGrow: 1,
|
||||
fontSize: 'sm',
|
||||
fontWeight: 600,
|
||||
color: 'base.100',
|
||||
}}
|
||||
>
|
||||
{t('parameters.randomizeSeed')}
|
||||
</FormLabel>
|
||||
<Switch
|
||||
isChecked={shouldRandomizeSeed}
|
||||
onChange={handleChangeShouldRandomizeSeed}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ParamSeedRandomize);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { NUMPY_RAND_MAX, NUMPY_RAND_MIN } from 'app/constants';
|
||||
import { RootState } from 'app/store/store';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import IAIButton from 'common/components/IAIButton';
|
||||
import IAIIconButton from 'common/components/IAIIconButton';
|
||||
import randomInt from 'common/util/randomInt';
|
||||
import { setSeed } from 'features/parameters/store/generationSlice';
|
||||
@@ -29,16 +27,4 @@ export default function ParamSeedShuffle() {
|
||||
icon={<FaRandom />}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<IAIButton
|
||||
size="sm"
|
||||
isDisabled={shouldRandomizeSeed}
|
||||
aria-label={t('parameters.shuffle')}
|
||||
tooltip={t('parameters.shuffle')}
|
||||
onClick={handleClickRandomizeSeed}
|
||||
>
|
||||
<Box px={2}> {t('parameters.shuffle')}</Box>
|
||||
</IAIButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { Box, ChakraProps } from '@chakra-ui/react';
|
||||
import { userInvoked } from 'app/store/actions';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import IAIButton, { IAIButtonProps } from 'common/components/IAIButton';
|
||||
@@ -14,6 +14,16 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FaPlay } from 'react-icons/fa';
|
||||
|
||||
const IN_PROGRESS_STYLES: ChakraProps['sx'] = {
|
||||
_disabled: {
|
||||
bg: 'none',
|
||||
cursor: 'not-allowed',
|
||||
_hover: {
|
||||
bg: 'none',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface InvokeButton
|
||||
extends Omit<IAIButtonProps | IAIIconButtonProps, 'aria-label'> {
|
||||
iconButton?: boolean;
|
||||
@@ -24,6 +34,7 @@ export default function InvokeButton(props: InvokeButton) {
|
||||
const dispatch = useAppDispatch();
|
||||
const isReady = useIsReadyToInvoke();
|
||||
const activeTabName = useAppSelector(activeTabNameSelector);
|
||||
const isProcessing = useAppSelector((state) => state.system.isProcessing);
|
||||
|
||||
const handleInvoke = useCallback(() => {
|
||||
dispatch(clampSymmetrySteps());
|
||||
@@ -69,19 +80,16 @@ export default function InvokeButton(props: InvokeButton) {
|
||||
icon={<FaPlay />}
|
||||
isDisabled={!isReady}
|
||||
onClick={handleInvoke}
|
||||
flexGrow={1}
|
||||
w="100%"
|
||||
tooltip={t('parameters.invoke')}
|
||||
tooltipProps={{ placement: 'top' }}
|
||||
colorScheme="accent"
|
||||
id="invoke-button"
|
||||
_disabled={{
|
||||
background: 'none',
|
||||
_hover: {
|
||||
background: 'none',
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
sx={{
|
||||
w: 'full',
|
||||
flexGrow: 1,
|
||||
...(isProcessing ? IN_PROGRESS_STYLES : {}),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<IAIButton
|
||||
@@ -89,18 +97,15 @@ export default function InvokeButton(props: InvokeButton) {
|
||||
type="submit"
|
||||
isDisabled={!isReady}
|
||||
onClick={handleInvoke}
|
||||
flexGrow={1}
|
||||
w="100%"
|
||||
colorScheme="accent"
|
||||
id="invoke-button"
|
||||
fontWeight={700}
|
||||
_disabled={{
|
||||
background: 'none',
|
||||
_hover: {
|
||||
background: 'none',
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
sx={{
|
||||
w: 'full',
|
||||
flexGrow: 1,
|
||||
fontWeight: 700,
|
||||
...(isProcessing ? IN_PROGRESS_STYLES : {}),
|
||||
}}
|
||||
>
|
||||
Invoke
|
||||
</IAIButton>
|
||||
|
||||
@@ -65,6 +65,8 @@ const ModelSelect = () => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// If the selected model is not in the list of models, select the first one
|
||||
// Handles first-run setting of models, and the user deleting the previously-selected model
|
||||
if (selectedModelId && pipelineModels?.ids.includes(selectedModelId)) {
|
||||
return;
|
||||
}
|
||||
@@ -90,8 +92,9 @@ const ModelSelect = () => {
|
||||
tooltip={selectedModel?.description}
|
||||
label={t('modelManager.model')}
|
||||
value={selectedModelId}
|
||||
placeholder="Pick one"
|
||||
placeholder={data.length > 0 ? 'Select a model' : 'No models detected!'}
|
||||
data={data}
|
||||
error={data.length === 0}
|
||||
onChange={handleChangeModel}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -32,11 +32,12 @@ import ImageGalleryContent from 'features/gallery/components/ImageGalleryContent
|
||||
import TextToImageTab from './tabs/TextToImage/TextToImageTab';
|
||||
import UnifiedCanvasTab from './tabs/UnifiedCanvas/UnifiedCanvasTab';
|
||||
import NodesTab from './tabs/Nodes/NodesTab';
|
||||
import { FaFont, FaImage } from 'react-icons/fa';
|
||||
import { FaFont, FaImage, FaLayerGroup } from 'react-icons/fa';
|
||||
import ResizeHandle from './tabs/ResizeHandle';
|
||||
import ImageTab from './tabs/ImageToImage/ImageToImageTab';
|
||||
import AuxiliaryProgressIndicator from 'app/components/AuxiliaryProgressIndicator';
|
||||
import { useMinimumPanelSize } from '../hooks/useMinimumPanelSize';
|
||||
import BatchTab from './tabs/Batch/BatchTab';
|
||||
|
||||
export interface InvokeTabInfo {
|
||||
id: InvokeTabName;
|
||||
@@ -65,6 +66,11 @@ const tabs: InvokeTabInfo[] = [
|
||||
icon: <Icon as={MdDeviceHub} sx={{ boxSize: 6, pointerEvents: 'none' }} />,
|
||||
content: <NodesTab />,
|
||||
},
|
||||
{
|
||||
id: 'batch',
|
||||
icon: <Icon as={FaLayerGroup} sx={{ boxSize: 6, pointerEvents: 'none' }} />,
|
||||
content: <BatchTab />,
|
||||
},
|
||||
];
|
||||
|
||||
const enabledTabsSelector = createSelector(
|
||||
|
||||
@@ -71,7 +71,15 @@ const ParametersDrawer = () => {
|
||||
onClose={handleClosePanel}
|
||||
>
|
||||
<Flex
|
||||
sx={{ flexDir: 'column', h: 'full', w: PARAMETERS_PANEL_WIDTH, gap: 2 }}
|
||||
sx={{
|
||||
flexDir: 'column',
|
||||
h: 'full',
|
||||
w: PARAMETERS_PANEL_WIDTH,
|
||||
gap: 2,
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
paddingTop={1.5}
|
||||
@@ -82,9 +90,16 @@ const ParametersDrawer = () => {
|
||||
<InvokeAILogoComponent />
|
||||
<PinParametersPanelButton />
|
||||
</Flex>
|
||||
<OverlayScrollable>
|
||||
<Flex sx={{ flexDir: 'column', gap: 2 }}>{drawerContent}</Flex>
|
||||
</OverlayScrollable>
|
||||
<Flex
|
||||
sx={{
|
||||
gap: 2,
|
||||
flexDirection: 'column',
|
||||
h: 'full',
|
||||
w: 'full',
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ResizableDrawer>
|
||||
);
|
||||
|
||||
@@ -42,18 +42,10 @@ const ParametersPinnedWrapper = (props: ParametersPinnedWrapperProps) => {
|
||||
h: 'full',
|
||||
w: 'full',
|
||||
position: 'absolute',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<OverlayScrollable>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Flex>
|
||||
</OverlayScrollable>
|
||||
{props.children}
|
||||
</Flex>
|
||||
|
||||
<PinParametersPanelButton
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { requestCanvasRescale } from 'features/canvas/store/thunks/requestCanvasScale';
|
||||
import InitialImageDisplay from 'features/parameters/components/Parameters/ImageToImage/InitialImageDisplay';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
ImperativePanelGroupHandle,
|
||||
Panel,
|
||||
PanelGroup,
|
||||
} from 'react-resizable-panels';
|
||||
import ResizeHandle from '../ResizeHandle';
|
||||
import TextToImageTabMain from '../TextToImage/TextToImageTabMain';
|
||||
import BatchManager from 'features/batch/components/BatchManager';
|
||||
|
||||
const ImageToImageTab = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
|
||||
|
||||
const handleDoubleClickHandle = useCallback(() => {
|
||||
if (!panelGroupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
panelGroupRef.current.setLayout([50, 50]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
layerStyle={'first'}
|
||||
sx={{
|
||||
gap: 4,
|
||||
p: 4,
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
borderRadius: 'base',
|
||||
}}
|
||||
>
|
||||
<BatchManager />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ImageToImageTab);
|
||||
@@ -14,8 +14,12 @@ import UnifiedCanvasToolbarBeta from './UnifiedCanvasBeta/UnifiedCanvasToolbarBe
|
||||
import UnifiedCanvasToolSettingsBeta from './UnifiedCanvasBeta/UnifiedCanvasToolSettingsBeta';
|
||||
import { ImageDTO } from 'services/api/types';
|
||||
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import IAIDropOverlay from 'common/components/IAIDropOverlay';
|
||||
import {
|
||||
CanvasInitialImageDropData,
|
||||
isValidDrop,
|
||||
useDroppable,
|
||||
} from 'app/components/ImageDnd/typesafeDnd';
|
||||
|
||||
const selector = createSelector(
|
||||
[canvasSelector, uiSelector],
|
||||
@@ -30,28 +34,24 @@ const selector = createSelector(
|
||||
defaultSelectorOptions
|
||||
);
|
||||
|
||||
const droppableData: CanvasInitialImageDropData = {
|
||||
id: 'canvas-intial-image',
|
||||
actionType: 'SET_CANVAS_INITIAL_IMAGE',
|
||||
};
|
||||
|
||||
const UnifiedCanvasContent = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { doesCanvasNeedScaling, shouldUseCanvasBetaLayout } =
|
||||
useAppSelector(selector);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(droppedImage: ImageDTO) => {
|
||||
dispatch(setInitialCanvasImage(droppedImage));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const {
|
||||
isOver,
|
||||
setNodeRef: setDroppableRef,
|
||||
active,
|
||||
} = useDroppable({
|
||||
id: 'unifiedCanvas',
|
||||
data: {
|
||||
handleDrop: onDrop,
|
||||
},
|
||||
data: droppableData,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -97,7 +97,12 @@ const UnifiedCanvasContent = () => {
|
||||
<UnifiedCanvasToolSettingsBeta />
|
||||
<Box sx={{ w: 'full', h: 'full', position: 'relative' }}>
|
||||
{doesCanvasNeedScaling ? <IAICanvasResizer /> : <IAICanvas />}
|
||||
{active && <IAIDropOverlay isOver={isOver} />}
|
||||
{isValidDrop(droppableData, active) && (
|
||||
<IAIDropOverlay
|
||||
isOver={isOver}
|
||||
label="Set Canvas Initial Image"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -139,7 +144,12 @@ const UnifiedCanvasContent = () => {
|
||||
>
|
||||
<Box sx={{ w: 'full', h: 'full', position: 'relative' }}>
|
||||
{doesCanvasNeedScaling ? <IAICanvasResizer /> : <IAICanvas />}
|
||||
{active && <IAIDropOverlay isOver={isOver} />}
|
||||
{isValidDrop(droppableData, active) && (
|
||||
<IAIDropOverlay
|
||||
isOver={isOver}
|
||||
label="Set Canvas Initial Image"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -4,6 +4,7 @@ export const tabMap = [
|
||||
// 'generate',
|
||||
'unifiedCanvas',
|
||||
'nodes',
|
||||
'batch',
|
||||
// 'postprocessing',
|
||||
// 'training',
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user