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:
psychedelicious
2023-07-04 00:09:18 +10:00
parent fa169b5517
commit 90aa97edd4
100 changed files with 3476 additions and 2075 deletions

View File

@@ -15,10 +15,25 @@ export interface IAIButtonProps extends ButtonProps {
}
const IAIButton = forwardRef((props: IAIButtonProps, forwardedRef) => {
const { children, tooltip = '', tooltipProps, isChecked, ...rest } = props;
const {
children,
tooltip = '',
tooltipProps: { placement = 'top', hasArrow = true, ...tooltipProps } = {},
isChecked,
...rest
} = props;
return (
<Tooltip label={tooltip} {...tooltipProps}>
<Button ref={forwardedRef} aria-checked={isChecked} {...rest}>
<Tooltip
label={tooltip}
placement={placement}
hasArrow={hasArrow}
{...tooltipProps}
>
<Button
ref={forwardedRef}
colorScheme={isChecked ? 'accent' : 'base'}
{...rest}
>
{children}
</Button>
</Tooltip>

View File

@@ -1,19 +1,20 @@
import {
Box,
ChakraProps,
Flex,
Icon,
IconButtonProps,
Image,
useColorMode,
useColorModeValue,
} from '@chakra-ui/react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { useCombinedRefs } from '@dnd-kit/utilities';
import IAIIconButton from 'common/components/IAIIconButton';
import { IAIImageLoadingFallback } from 'common/components/IAIImageFallback';
import {
IAILoadingImageFallback,
IAINoContentFallback,
} from 'common/components/IAIImageFallback';
import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
import { AnimatePresence } from 'framer-motion';
import { ReactElement, SyntheticEvent } from 'react';
import { MouseEvent, ReactElement, SyntheticEvent } from 'react';
import { memo, useRef } from 'react';
import { FaImage, FaUndo, FaUpload } from 'react-icons/fa';
import { ImageDTO } from 'services/api/types';
@@ -22,81 +23,97 @@ import IAIDropOverlay from './IAIDropOverlay';
import { PostUploadAction } from 'services/api/thunks/image';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { mode } from 'theme/util/mode';
import {
TypesafeDraggableData,
TypesafeDroppableData,
isValidDrop,
useDraggable,
useDroppable,
} from 'app/components/ImageDnd/typesafeDnd';
type IAIDndImageProps = {
image: ImageDTO | null | undefined;
onDrop: (droppedImage: ImageDTO) => void;
onReset?: () => void;
imageDTO: ImageDTO | undefined;
onError?: (event: SyntheticEvent<HTMLImageElement>) => void;
onLoad?: (event: SyntheticEvent<HTMLImageElement>) => void;
resetIconSize?: IconButtonProps['size'];
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
onClickReset?: (event: MouseEvent<HTMLButtonElement>) => void;
withResetIcon?: boolean;
resetIcon?: ReactElement;
resetTooltip?: string;
withMetadataOverlay?: boolean;
isDragDisabled?: boolean;
isDropDisabled?: boolean;
isUploadDisabled?: boolean;
fallback?: ReactElement;
payloadImage?: ImageDTO | null | undefined;
minSize?: number;
postUploadAction?: PostUploadAction;
imageSx?: ChakraProps['sx'];
fitContainer?: boolean;
droppableData?: TypesafeDroppableData;
draggableData?: TypesafeDraggableData;
dropLabel?: string;
isSelected?: boolean;
thumbnail?: boolean;
noContentFallback?: ReactElement;
};
const IAIDndImage = (props: IAIDndImageProps) => {
const {
image,
onDrop,
onReset,
imageDTO,
onClickReset,
onError,
resetIconSize = 'md',
onClick,
withResetIcon = false,
withMetadataOverlay = false,
isDropDisabled = false,
isDragDisabled = false,
isUploadDisabled = false,
fallback = <IAIImageLoadingFallback />,
payloadImage,
minSize = 24,
postUploadAction,
imageSx,
fitContainer = false,
droppableData,
draggableData,
dropLabel,
isSelected = false,
thumbnail = false,
resetTooltip = 'Reset',
resetIcon = <FaUndo />,
noContentFallback = <IAINoContentFallback icon={FaImage} />,
} = props;
const dndId = useRef(uuidv4());
const { colorMode } = useColorMode();
const {
isOver,
setNodeRef: setDroppableRef,
active: isDropActive,
} = useDroppable({
id: dndId.current,
disabled: isDropDisabled,
data: {
handleDrop: onDrop,
},
});
const dndId = useRef(uuidv4());
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
isDragging,
active,
} = useDraggable({
id: dndId.current,
data: {
image: payloadImage ? payloadImage : image,
},
disabled: isDragDisabled || !image,
disabled: isDragDisabled || !imageDTO,
data: draggableData,
});
const { isOver, setNodeRef: setDroppableRef } = useDroppable({
id: dndId.current,
disabled: isDropDisabled,
data: droppableData,
});
const setDndRef = useCombinedRefs(setDroppableRef, setDraggableRef);
const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
postUploadAction,
isDisabled: isUploadDisabled,
});
const setNodeRef = useCombinedRefs(setDroppableRef, setDraggableRef);
const resetIconShadow = useColorModeValue(
`drop-shadow(0px 0px 0.1rem var(--invokeai-colors-base-600))`,
`drop-shadow(0px 0px 0.1rem var(--invokeai-colors-base-800))`
);
const uploadButtonStyles = isUploadDisabled
? {}
@@ -117,16 +134,16 @@ const IAIDndImage = (props: IAIDndImageProps) => {
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
minW: minSize,
minH: minSize,
minW: minSize ? minSize : undefined,
minH: minSize ? minSize : undefined,
userSelect: 'none',
cursor: isDragDisabled || !image ? 'auto' : 'grab',
cursor: isDragDisabled || !imageDTO ? 'default' : 'pointer',
}}
{...attributes}
{...listeners}
ref={setNodeRef}
ref={setDndRef}
>
{image && (
{imageDTO && (
<Flex
sx={{
w: 'full',
@@ -137,42 +154,50 @@ const IAIDndImage = (props: IAIDndImageProps) => {
}}
>
<Image
src={image.image_url}
fallback={fallback}
onClick={onClick}
src={thumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
fallbackStrategy="beforeLoadOrError"
fallback={<IAILoadingImageFallback image={imageDTO} />}
onError={onError}
objectFit="contain"
draggable={false}
sx={{
objectFit: 'contain',
maxW: 'full',
maxH: 'full',
borderRadius: 'base',
shadow: isSelected ? 'selected.light' : undefined,
_dark: { shadow: isSelected ? 'selected.dark' : undefined },
...imageSx,
}}
/>
{withMetadataOverlay && <ImageMetadataOverlay image={image} />}
{onReset && withResetIcon && (
<Box
{withMetadataOverlay && <ImageMetadataOverlay image={imageDTO} />}
{onClickReset && withResetIcon && (
<IAIIconButton
onClick={onClickReset}
aria-label={resetTooltip}
tooltip={resetTooltip}
icon={resetIcon}
size="sm"
variant="link"
sx={{
position: 'absolute',
top: 0,
right: 0,
top: 1,
insetInlineEnd: 1,
p: 0,
minW: 0,
svg: {
transitionProperty: 'common',
transitionDuration: 'normal',
fill: 'base.100',
_hover: { fill: 'base.50' },
filter: resetIconShadow,
},
}}
>
<IAIIconButton
size={resetIconSize}
tooltip="Reset Image"
aria-label="Reset Image"
icon={<FaUndo />}
onClick={onReset}
/>
</Box>
/>
)}
<AnimatePresence>
{isDropActive && <IAIDropOverlay isOver={isOver} />}
</AnimatePresence>
</Flex>
)}
{!image && (
{!imageDTO && !isUploadDisabled && (
<>
<Flex
sx={{
@@ -191,17 +216,20 @@ const IAIDndImage = (props: IAIDndImageProps) => {
>
<input {...getUploadInputProps()} />
<Icon
as={isUploadDisabled ? FaImage : FaUpload}
as={FaUpload}
sx={{
boxSize: 12,
boxSize: 16,
}}
/>
</Flex>
<AnimatePresence>
{isDropActive && <IAIDropOverlay isOver={isOver} />}
</AnimatePresence>
</>
)}
{!imageDTO && isUploadDisabled && noContentFallback}
<AnimatePresence>
{isValidDrop(droppableData, active) && !isDragging && (
<IAIDropOverlay isOver={isOver} label={dropLabel} />
)}
</AnimatePresence>
</Flex>
);
};

View File

@@ -62,7 +62,7 @@ export const IAIDropOverlay = (props: Props) => {
w: 'full',
h: 'full',
opacity: 1,
borderWidth: 2,
borderWidth: 3,
borderColor: isOver
? mode('base.50', 'base.200')(colorMode)
: mode('base.100', 'base.500')(colorMode),
@@ -78,10 +78,10 @@ export const IAIDropOverlay = (props: Props) => {
sx={{
fontSize: '2xl',
fontWeight: 600,
transform: isOver ? 'scale(1.1)' : 'scale(1)',
transform: isOver ? 'scale(1.02)' : 'scale(1)',
color: isOver
? mode('base.100', 'base.100')(colorMode)
: mode('base.200', 'base.500')(colorMode),
? mode('base.50', 'base.50')(colorMode)
: mode('base.100', 'base.200')(colorMode),
transitionProperty: 'common',
transitionDuration: '0.1s',
}}

View File

@@ -29,7 +29,7 @@ const IAIIconButton = forwardRef((props: IAIIconButtonProps, forwardedRef) => {
<IconButton
ref={forwardedRef}
role={role}
aria-checked={isChecked !== undefined ? isChecked : undefined}
colorScheme={isChecked ? 'accent' : 'base'}
{...rest}
/>
</Tooltip>

View File

@@ -1,73 +1,82 @@
import {
As,
ChakraProps,
Flex,
FlexProps,
Icon,
IconProps,
Skeleton,
Spinner,
SpinnerProps,
useColorMode,
StyleProps,
Text,
} from '@chakra-ui/react';
import { FaImage } from 'react-icons/fa';
import { mode } from 'theme/util/mode';
import { ImageDTO } from 'services/api/types';
type Props = FlexProps & {
spinnerProps?: SpinnerProps;
};
type Props = { image: ImageDTO | undefined };
export const IAILoadingImageFallback = (props: Props) => {
if (props.image) {
return (
<Skeleton
sx={{
w: `${props.image.width}px`,
h: 'auto',
objectFit: 'contain',
aspectRatio: `${props.image.width}/${props.image.height}`,
}}
/>
);
}
export const IAIImageLoadingFallback = (props: Props) => {
const { spinnerProps, ...rest } = props;
const { sx, ...restFlexProps } = rest;
const { colorMode } = useColorMode();
return (
<Flex
sx={{
bg: mode('base.200', 'base.900')(colorMode),
opacity: 0.7,
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
...sx,
bg: 'base.200',
_dark: {
bg: 'base.900',
},
}}
{...restFlexProps}
>
<Spinner size="xl" {...spinnerProps} />
<Spinner size="xl" />
</Flex>
);
};
type IAINoImageFallbackProps = {
flexProps?: FlexProps;
iconProps?: IconProps;
as?: As;
label?: string;
icon?: As;
boxSize?: StyleProps['boxSize'];
sx?: ChakraProps['sx'];
};
export const IAINoImageFallback = (props: IAINoImageFallbackProps) => {
const { sx: flexSx, ...restFlexProps } = props.flexProps ?? { sx: {} };
const { sx: iconSx, ...restIconProps } = props.iconProps ?? { sx: {} };
const { colorMode } = useColorMode();
export const IAINoContentFallback = (props: IAINoImageFallbackProps) => {
const { icon = FaImage, boxSize = 16 } = props;
return (
<Flex
sx={{
bg: mode('base.200', 'base.900')(colorMode),
opacity: 0.7,
w: 'full',
h: 'full',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'base',
...flexSx,
flexDir: 'column',
gap: 2,
userSelect: 'none',
color: 'base.700',
_dark: {
color: 'base.500',
},
...props.sx,
}}
{...restFlexProps}
>
<Icon
as={props.as ?? FaImage}
sx={{ color: mode('base.700', 'base.500')(colorMode), ...iconSx }}
{...restIconProps}
/>
<Icon as={icon} boxSize={boxSize} opacity={0.7} />
{props.label && <Text textAlign="center">{props.label}</Text>}
</Flex>
);
};