feat(ui): add comparison modes, side-by-side view

This commit is contained in:
psychedelicious 2024-05-31 17:20:13 +10:00
parent 1af53aed60
commit 8f8ddd620b
19 changed files with 319 additions and 104 deletions

View File

@ -148,6 +148,8 @@
"viewingDesc": "Review images in a large gallery view",
"editing": "Editing",
"editingDesc": "Edit on the Control Layers canvas",
"comparing": "Comparing",
"comparingDesc": "Comparing two images",
"enabled": "Enabled",
"disabled": "Disabled"
},
@ -377,7 +379,8 @@
"problemDeletingImages": "Problem Deleting Images",
"problemDeletingImagesDesc": "One or more images could not be deleted",
"firstImage": "First Image",
"secondImage": "Second Image"
"secondImage": "Second Image",
"selectForCompare": "Select for Compare"
},
"hotkeys": {
"searchHotkeys": "Search Hotkeys",

View File

@ -1,6 +1,6 @@
import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import { viewerModeChanged } from 'features/gallery/store/gallerySlice';
import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig';
import { buildGenerationTabGraph } from 'features/nodes/util/graph/generation/buildGenerationTabGraph';
import { buildGenerationTabSDXLGraph } from 'features/nodes/util/graph/generation/buildGenerationTabSDXLGraph';
@ -34,7 +34,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
try {
await req.unwrap();
if (shouldShowProgressInViewer) {
dispatch(isImageViewerOpenChanged(true));
dispatch(viewerModeChanged('view'));
}
} finally {
req.reset();

View File

@ -14,7 +14,7 @@ import {
rgLayerIPAdapterImageChanged,
} from 'features/controlLayers/store/controlLayersSlice';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
import { imagesApi } from 'services/api/endpoints/images';
@ -181,40 +181,31 @@ export const addImageDroppedListener = (startAppListening: AppStartListening) =>
return;
}
/**
* TODO
* Image selection dropped on node image collection field
*/
// if (
// overData.actionType === 'SET_MULTI_NODES_IMAGE' &&
// activeData.payloadType === 'IMAGE_DTO' &&
// activeData.payload.imageDTO
// ) {
// const { fieldName, nodeId } = overData.context;
// dispatch(
// fieldValueChanged({
// nodeId,
// fieldName,
// value: [activeData.payload.imageDTO],
// })
// );
// return;
// }
/**
* Image dropped on user board
*/
if (
overData.actionType === 'ADD_TO_BOARD' &&
overData.actionType === 'SELECT_FOR_COMPARE' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
const { imageDTO } = activeData.payload;
dispatch(imageToCompareChanged(imageDTO));
return;
}
/**
* Image dropped on 'none' board
*/
if (
overData.actionType === 'REMOVE_FROM_BOARD' &&
activeData.payloadType === 'IMAGE_DTO' &&
activeData.payload.imageDTO
) {
const { imageDTO } = activeData.payload;
const { boardId } = overData.context;
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imagesApi.endpoints.removeImageFromBoard.initiate({
imageDTO,
board_id: boardId,
})
);
return;

View File

@ -7,7 +7,7 @@ import {
boardIdSelected,
galleryViewChanged,
imageSelected,
isImageViewerOpenChanged,
viewerModeChanged,
} from 'features/gallery/store/gallerySlice';
import { IMAGE_CATEGORIES } from 'features/gallery/store/types';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState';
@ -108,7 +108,7 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi
}
dispatch(imageSelected(imageDTO));
dispatch(isImageViewerOpenChanged(true));
dispatch(viewerModeChanged('view'));
}
}
}

View File

@ -79,6 +79,10 @@ export type RemoveFromBoardDropData = BaseDropData & {
actionType: 'REMOVE_FROM_BOARD';
};
export type SelectForCompareDropData = BaseDropData & {
actionType: 'SELECT_FOR_COMPARE';
};
export type TypesafeDroppableData =
| CurrentImageDropData
| ControlAdapterDropData
@ -89,7 +93,8 @@ export type TypesafeDroppableData =
| CALayerImageDropData
| IPALayerImageDropData
| RGLayerIPAdapterImageDropData
| IILayerImageDropData;
| IILayerImageDropData
| SelectForCompareDropData;
type BaseDragData = {
id: string;

View File

@ -29,6 +29,8 @@ export const isValidDrop = (overData: TypesafeDroppableData | undefined, active:
return payloadType === 'IMAGE_DTO';
case 'SET_NODES_IMAGE':
return payloadType === 'IMAGE_DTO';
case 'SELECT_FOR_COMPARE':
return payloadType === 'IMAGE_DTO';
case 'ADD_TO_BOARD': {
// If the board is the same, don't allow the drop

View File

@ -10,6 +10,7 @@ import { iiLayerAdded } from 'features/controlLayers/store/controlLayersSlice';
import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
import { useImageActions } from 'features/gallery/hooks/useImageActions';
import { sentImageToCanvas, sentImageToImg2Img } from 'features/gallery/store/actions';
import { imageToCompareChanged } from 'features/gallery/store/gallerySlice';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectOptimalDimension } from 'features/parameters/store/generationSlice';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
@ -27,6 +28,7 @@ import {
PiDownloadSimpleBold,
PiFlowArrowBold,
PiFoldersBold,
PiImagesBold,
PiPlantBold,
PiQuotesBold,
PiShareFatBold,
@ -117,6 +119,10 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
downloadImage(imageDTO.image_url, imageDTO.image_name);
}, [downloadImage, imageDTO.image_name, imageDTO.image_url]);
const handleSelectImageForCompare = useCallback(() => {
dispatch(imageToCompareChanged(imageDTO));
}, [dispatch, imageDTO]);
return (
<>
<MenuItem as="a" href={imageDTO.image_url} target="_blank" icon={<PiShareFatBold />}>
@ -130,6 +136,9 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
<MenuItem icon={<PiDownloadSimpleBold />} onClickCapture={handleDownloadImage}>
{t('parameters.downloadImage')}
</MenuItem>
<MenuItem icon={<PiImagesBold />} onClickCapture={handleSelectImageForCompare}>
{t('gallery.selectForCompare')}
</MenuItem>
<MenuDivider />
<MenuItem
icon={getAndLoadEmbeddedWorkflowResult.isLoading ? <SpinnerIcon /> : <PiFlowArrowBold />}

View File

@ -11,7 +11,7 @@ import type { GallerySelectionDraggableData, ImageDraggableData, TypesafeDraggab
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { useMultiselect } from 'features/gallery/hooks/useMultiselect';
import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView';
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import { viewerModeChanged } from 'features/gallery/store/gallerySlice';
import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -104,7 +104,7 @@ const GalleryImage = (props: HoverableImageProps) => {
}, []);
const onDoubleClick = useCallback(() => {
dispatch(isImageViewerOpenChanged(true));
dispatch(viewerModeChanged('view'));
}, [dispatch]);
const handleMouseOut = useCallback(() => {

View File

@ -3,8 +3,9 @@ import { createSelector } from '@reduxjs/toolkit';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage';
import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import type { SelectForCompareDropData, TypesafeDraggableData } from 'features/dnd/types';
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
@ -22,21 +23,12 @@ const selectLastSelectedImageName = createSelector(
(lastSelectedImage) => lastSelectedImage?.image_name
);
type Props = {
isDragDisabled?: boolean;
isDropDisabled?: boolean;
withNextPrevButtons?: boolean;
withMetadata?: boolean;
alwaysShowProgress?: boolean;
const droppableData: SelectForCompareDropData = {
id: 'current-image',
actionType: 'SELECT_FOR_COMPARE',
};
const CurrentImagePreview = ({
isDragDisabled = false,
isDropDisabled = false,
withNextPrevButtons = true,
withMetadata = true,
alwaysShowProgress = false,
}: Props) => {
const CurrentImagePreview = () => {
const { t } = useTranslation();
const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails);
const imageName = useAppSelector(selectLastSelectedImageName);
@ -55,14 +47,6 @@ const CurrentImagePreview = ({
}
}, [imageDTO]);
const droppableData = useMemo<TypesafeDroppableData | undefined>(
() => ({
id: 'current-image',
actionType: 'SET_CURRENT_IMAGE',
}),
[]
);
// Show and hide the next/prev buttons on mouse move
const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState<boolean>(false);
const timeoutId = useRef(0);
@ -86,15 +70,13 @@ const CurrentImagePreview = ({
justifyContent="center"
position="relative"
>
{hasDenoiseProgress && (shouldShowProgressInViewer || alwaysShowProgress) ? (
{hasDenoiseProgress && shouldShowProgressInViewer ? (
<ProgressImage />
) : (
<IAIDndImage
imageDTO={imageDTO}
droppableData={droppableData}
draggableData={draggableData}
isDragDisabled={isDragDisabled}
isDropDisabled={isDropDisabled}
isDropDisabled={true}
isUploadDisabled={true}
fitContainer
useThumbailFallback
@ -103,13 +85,14 @@ const CurrentImagePreview = ({
dataTestId="image-preview"
/>
)}
{shouldShowImageDetails && imageDTO && withMetadata && (
<IAIDroppable data={droppableData} dropLabel="Select for Compare" />
{shouldShowImageDetails && imageDTO && (
<Box position="absolute" opacity={0.8} top={0} width="full" height="full" borderRadius="base">
<ImageMetadataViewer image={imageDTO} />
</Box>
)}
<AnimatePresence>
{withNextPrevButtons && shouldShowNextPrevButtons && imageDTO && (
{shouldShowNextPrevButtons && imageDTO && (
<Box
as={motion.div}
key="nextPrevButtons"

View File

@ -0,0 +1,59 @@
import type { UseMeasureRect } from '@reactuses/core';
import { useAppSelector } from 'app/store/storeHooks';
import IAIDroppable from 'common/components/IAIDroppable';
import type { SelectForCompareDropData } from 'features/dnd/types';
import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide';
import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
type Props = {
containerSize: UseMeasureRect;
};
export const ImageComparison = memo(({ containerSize }: Props) => {
const comparisonMode = useAppSelector((s) => s.gallery.comparisonMode);
const { firstImage, secondImage } = useAppSelector((s) => {
const firstImage = s.gallery.selection.slice(-1)[0] ?? null;
const secondImage = s.gallery.imageToCompare;
return { firstImage, secondImage };
});
if (!firstImage || !secondImage) {
return <ImageComparisonWrapper>No images to compare</ImageComparisonWrapper>;
}
if (comparisonMode === 'slider') {
return (
<ImageComparisonWrapper>
<ImageComparisonSlider containerSize={containerSize} firstImage={firstImage} secondImage={secondImage} />
</ImageComparisonWrapper>
);
}
if (comparisonMode === 'side-by-side') {
return (
<ImageComparisonWrapper>
<ImageComparisonSideBySide firstImage={firstImage} secondImage={secondImage} />
</ImageComparisonWrapper>
);
}
});
ImageComparison.displayName = 'ImageComparison';
const droppableData: SelectForCompareDropData = {
id: 'image-comparison',
actionType: 'SELECT_FOR_COMPARE',
};
const ImageComparisonWrapper = memo((props: PropsWithChildren) => {
return (
<>
{props.children}
<IAIDroppable data={droppableData} dropLabel="Select for Compare" />
</>
);
});
ImageComparisonWrapper.displayName = 'ImageComparisonWrapper';

View File

@ -0,0 +1,72 @@
import { Flex, Image } from '@invoke-ai/ui-library';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
import { Panel, PanelGroup } from 'react-resizable-panels';
import type { ImageDTO } from 'services/api/types';
type Props = {
/**
* The first image to compare
*/
firstImage: ImageDTO;
/**
* The second image to compare
*/
secondImage: ImageDTO;
};
export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Props) => {
const { t } = useTranslation();
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const onDoubleClickHandle = useCallback(() => {
if (!panelGroupRef.current) {
return;
}
panelGroupRef.current.setLayout([50, 50]);
}, []);
return (
<Flex w="full" h="full" maxW="full" maxH="full" position="relative" alignItems="center" justifyContent="center">
<Flex w="full" h="full" maxW="full" maxH="full" position="absolute" alignItems="center" justifyContent="center">
<PanelGroup ref={panelGroupRef} direction="horizontal" id="image-comparison-side-by-side">
<Panel minSize={20}>
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Image
src={firstImage.image_url}
fallbackSrc={firstImage.thumbnail_url}
objectFit="contain"
w={firstImage.width}
h={firstImage.height}
maxW="full"
maxH="full"
/>
</Flex>
</Panel>
<ResizeHandle
id="image-comparison-side-by-side-handle"
onDoubleClick={onDoubleClickHandle}
orientation="vertical"
/>
<Panel minSize={20}>
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<Image
src={secondImage.image_url}
fallbackSrc={secondImage.thumbnail_url}
objectFit="contain"
w={secondImage.width}
h={secondImage.height}
maxW="full"
maxH="full"
/>
</Flex>
</Panel>
</PanelGroup>
</Flex>
</Flex>
);
});
ImageComparisonSideBySide.displayName = 'ImageComparisonSideBySide';

View File

@ -31,7 +31,7 @@ type Props = {
containerSize: UseMeasureRect;
};
export const ImageSliderComparison = memo(({ firstImage, secondImage, containerSize }: Props) => {
export const ImageComparisonSlider = memo(({ firstImage, secondImage, containerSize }: Props) => {
const { t } = useTranslation();
// How far the handle is from the left - this will be a CSS calculation that takes into account the handle width
const [left, setLeft] = useState(HANDLE_LEFT_INITIAL_PX);
@ -260,4 +260,4 @@ export const ImageSliderComparison = memo(({ firstImage, secondImage, containerS
);
});
ImageSliderComparison.displayName = 'ImageSliderComparison';
ImageComparisonSlider.displayName = 'ImageComparisonSlider';

View File

@ -0,0 +1,39 @@
import { Button, ButtonGroup } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { comparisonModeChanged } from 'features/gallery/store/gallerySlice';
import { memo, useCallback } from 'react';
export const ImageComparisonToolbarButtons = memo(() => {
const dispatch = useAppDispatch();
const comparisonMode = useAppSelector((s) => s.gallery.comparisonMode);
const setComparisonModeSlider = useCallback(() => {
dispatch(comparisonModeChanged('slider'));
}, [dispatch]);
const setComparisonModeSideBySide = useCallback(() => {
dispatch(comparisonModeChanged('side-by-side'));
}, [dispatch]);
const setComparisonModeOverlay = useCallback(() => {
dispatch(comparisonModeChanged('overlay'));
}, [dispatch]);
return (
<>
<ButtonGroup variant="outline">
<Button onClick={setComparisonModeSlider} colorScheme={comparisonMode === 'slider' ? 'invokeBlue' : 'base'}>
Slider
</Button>
<Button
onClick={setComparisonModeSideBySide}
colorScheme={comparisonMode === 'side-by-side' ? 'invokeBlue' : 'base'}
>
Side-by-Side
</Button>
<Button onClick={setComparisonModeOverlay} colorScheme={comparisonMode === 'overlay' ? 'invokeBlue' : 'base'}>
Overlay
</Button>
</ButtonGroup>
</>
);
});
ImageComparisonToolbarButtons.displayName = 'ImageComparisonToolbarButtons';

View File

@ -2,7 +2,8 @@ import { Box, Flex } from '@invoke-ai/ui-library';
import { useMeasure } from '@reactuses/core';
import { useAppSelector } from 'app/store/storeHooks';
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
import { ImageSliderComparison } from 'features/gallery/components/ImageViewer/ImageSliderComparison';
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
import { ImageComparisonToolbarButtons } from 'features/gallery/components/ImageViewer/ImageComparisonToolbarButtons';
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
@ -17,7 +18,7 @@ import { ViewerToggleMenu } from './ViewerToggleMenu';
const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows'];
export const ImageViewer = memo(() => {
const { isOpen, onToggle, onClose } = useImageViewer();
const { viewerMode, onToggle, openEditor } = useImageViewer();
const activeTabName = useAppSelector(activeTabNameSelector);
const isViewerEnabled = useMemo(() => VIEWER_ENABLED_TABS.includes(activeTabName), [activeTabName]);
const containerRef = useRef<HTMLDivElement>(null);
@ -26,16 +27,11 @@ export const ImageViewer = memo(() => {
if (!isViewerEnabled) {
return false;
}
return isOpen;
}, [isOpen, isViewerEnabled]);
return viewerMode === 'view' || viewerMode === 'compare';
}, [viewerMode, isViewerEnabled]);
useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]);
const { firstImage, secondImage } = useAppSelector((s) => {
const images = s.gallery.selection.slice(-2);
return { firstImage: images[0] ?? null, secondImage: images[0] ? images[1] ?? null : null };
});
useHotkeys('esc', openEditor, { enabled: isViewerEnabled }, [isViewerEnabled, openEditor]);
if (!shouldShowViewer) {
return null;
@ -65,7 +61,8 @@ export const ImageViewer = memo(() => {
</Flex>
</Flex>
<Flex flex={1} gap={2} justifyContent="center">
<CurrentImageButtons />
{viewerMode === 'view' && <CurrentImageButtons />}
{viewerMode === 'compare' && <ImageComparisonToolbarButtons />}
</Flex>
<Flex flex={1} justifyContent="center">
<Flex gap={2} marginInlineStart="auto">
@ -74,10 +71,8 @@ export const ImageViewer = memo(() => {
</Flex>
</Flex>
<Box ref={containerRef} w="full" h="full">
{firstImage && !secondImage && <CurrentImagePreview />}
{firstImage && secondImage && (
<ImageSliderComparison containerSize={containerSize} firstImage={firstImage} secondImage={secondImage} />
)}
{viewerMode === 'view' && <CurrentImagePreview />}
{viewerMode === 'compare' && <ImageComparison containerSize={containerSize} />}
</Box>
</Flex>
);

View File

@ -9,22 +9,45 @@ import {
PopoverTrigger,
Text,
} from '@invoke-ai/ui-library';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold, PiCheckBold, PiEyeBold, PiPencilBold } from 'react-icons/pi';
import { PiCaretDownBold, PiCheckBold, PiEyeBold, PiImagesBold, PiPencilBold } from 'react-icons/pi';
import { useImageViewer } from './useImageViewer';
export const ViewerToggleMenu = () => {
const { t } = useTranslation();
const { isOpen, onClose, onOpen } = useImageViewer();
const { viewerMode, openEditor, openViewer, openCompare } = useImageViewer();
const icon = useMemo(() => {
if (viewerMode === 'view') {
return <Icon as={PiEyeBold} />;
}
if (viewerMode === 'edit') {
return <Icon as={PiPencilBold} />;
}
if (viewerMode === 'compare') {
return <Icon as={PiImagesBold} />;
}
}, [viewerMode]);
const label = useMemo(() => {
if (viewerMode === 'view') {
return t('common.viewing');
}
if (viewerMode === 'edit') {
return t('common.editing');
}
if (viewerMode === 'compare') {
return t('common.comparing');
}
}, [t, viewerMode]);
return (
<Popover isLazy>
<PopoverTrigger>
<Button variant="outline" data-testid="toggle-viewer-menu-button">
<Flex gap={3} w="full" alignItems="center">
{isOpen ? <Icon as={PiEyeBold} /> : <Icon as={PiPencilBold} />}
<Text fontSize="md">{isOpen ? t('common.viewing') : t('common.editing')}</Text>
{icon}
<Text fontSize="md">{label}</Text>
<Icon as={PiCaretDownBold} />
</Flex>
</Button>
@ -33,9 +56,9 @@ export const ViewerToggleMenu = () => {
<PopoverArrow />
<PopoverBody>
<Flex flexDir="column">
<Button onClick={onOpen} variant="ghost" h="auto" w="auto" p={2}>
<Button onClick={openViewer} variant="ghost" h="auto" w="auto" p={2}>
<Flex gap={2} w="full">
<Icon as={PiCheckBold} visibility={isOpen ? 'visible' : 'hidden'} />
<Icon as={PiCheckBold} visibility={viewerMode === 'view' ? 'visible' : 'hidden'} />
<Flex flexDir="column" gap={2} alignItems="flex-start">
<Text fontWeight="semibold" color="base.100">
{t('common.viewing')}
@ -46,9 +69,9 @@ export const ViewerToggleMenu = () => {
</Flex>
</Flex>
</Button>
<Button onClick={onClose} variant="ghost" h="auto" w="auto" p={2}>
<Button onClick={openEditor} variant="ghost" h="auto" w="auto" p={2}>
<Flex gap={2} w="full">
<Icon as={PiCheckBold} visibility={isOpen ? 'hidden' : 'visible'} />
<Icon as={PiCheckBold} visibility={viewerMode === 'edit' ? 'visible' : 'hidden'} />
<Flex flexDir="column" gap={2} alignItems="flex-start">
<Text fontWeight="semibold" color="base.100">
{t('common.editing')}
@ -59,6 +82,19 @@ export const ViewerToggleMenu = () => {
</Flex>
</Flex>
</Button>
<Button onClick={openCompare} variant="ghost" h="auto" w="auto" p={2}>
<Flex gap={2} w="full">
<Icon as={PiCheckBold} visibility={viewerMode === 'compare' ? 'visible' : 'hidden'} />
<Flex flexDir="column" gap={2} alignItems="flex-start">
<Text fontWeight="semibold" color="base.100">
{t('common.comparing')}
</Text>
<Text fontWeight="normal" color="base.300">
{t('common.comparingDesc')}
</Text>
</Flex>
</Flex>
</Button>
</Flex>
</PopoverBody>
</PopoverContent>

View File

@ -1,22 +1,26 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import { viewerModeChanged } from 'features/gallery/store/gallerySlice';
import { useCallback } from 'react';
export const useImageViewer = () => {
const dispatch = useAppDispatch();
const isOpen = useAppSelector((s) => s.gallery.isImageViewerOpen);
const viewerMode = useAppSelector((s) => s.gallery.viewerMode);
const onClose = useCallback(() => {
dispatch(isImageViewerOpenChanged(false));
const openEditor = useCallback(() => {
dispatch(viewerModeChanged('edit'));
}, [dispatch]);
const onOpen = useCallback(() => {
dispatch(isImageViewerOpenChanged(true));
const openViewer = useCallback(() => {
dispatch(viewerModeChanged('view'));
}, [dispatch]);
const onToggle = useCallback(() => {
dispatch(isImageViewerOpenChanged(!isOpen));
}, [dispatch, isOpen]);
dispatch(viewerModeChanged(viewerMode === 'view' ? 'edit' : 'view'));
}, [dispatch, viewerMode]);
return { isOpen, onOpen, onClose, onToggle };
const openCompare = useCallback(() => {
dispatch(viewerModeChanged('compare'));
}, [dispatch]);
return { viewerMode, openEditor, openViewer, openCompare, onToggle };
};

View File

@ -6,7 +6,7 @@ import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { BoardId, GalleryState, GalleryView } from './types';
import type { BoardId, ComparisonMode, GalleryState, GalleryView, ViewerMode } from './types';
import { IMAGE_LIMIT, INITIAL_IMAGE_LIMIT } from './types';
const initialGalleryState: GalleryState = {
@ -21,7 +21,9 @@ const initialGalleryState: GalleryState = {
boardSearchText: '',
limit: INITIAL_IMAGE_LIMIT,
offset: 0,
isImageViewerOpen: true,
viewerMode: 'view',
imageToCompare: null,
comparisonMode: 'slider',
};
export const gallerySlice = createSlice({
@ -34,6 +36,15 @@ export const gallerySlice = createSlice({
selectionChanged: (state, action: PayloadAction<ImageDTO[]>) => {
state.selection = uniqBy(action.payload, (i) => i.image_name);
},
imageToCompareChanged: (state, action: PayloadAction<ImageDTO | null>) => {
state.imageToCompare = action.payload;
if (action.payload) {
state.viewerMode = 'compare';
}
},
comparisonModeChanged: (state, action: PayloadAction<ComparisonMode>) => {
state.comparisonMode = action.payload;
},
shouldAutoSwitchChanged: (state, action: PayloadAction<boolean>) => {
state.shouldAutoSwitch = action.payload;
},
@ -76,8 +87,8 @@ export const gallerySlice = createSlice({
alwaysShowImageSizeBadgeChanged: (state, action: PayloadAction<boolean>) => {
state.alwaysShowImageSizeBadge = action.payload;
},
isImageViewerOpenChanged: (state, action: PayloadAction<boolean>) => {
state.isImageViewerOpen = action.payload;
viewerModeChanged: (state, action: PayloadAction<ViewerMode>) => {
state.viewerMode = action.payload;
},
},
extraReducers: (builder) => {
@ -116,7 +127,9 @@ export const {
boardSearchTextChanged,
moreImagesLoaded,
alwaysShowImageSizeBadgeChanged,
isImageViewerOpenChanged,
viewerModeChanged,
imageToCompareChanged,
comparisonModeChanged,
} = gallerySlice.actions;
const isAnyBoardDeleted = isAnyOf(
@ -138,5 +151,5 @@ export const galleryPersistConfig: PersistConfig<GalleryState> = {
name: gallerySlice.name,
initialState: initialGalleryState,
migrate: migrateGalleryState,
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit', 'isImageViewerOpen'],
persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'offset', 'limit', 'viewerMode', 'imageToCompare'],
};

View File

@ -7,6 +7,8 @@ export const IMAGE_LIMIT = 20;
export type GalleryView = 'images' | 'assets';
export type BoardId = 'none' | (string & Record<never, never>);
export type ComparisonMode = 'slider' | 'side-by-side' | 'overlay';
export type ViewerMode = 'edit' | 'view' | 'compare';
export type GalleryState = {
selection: ImageDTO[];
@ -20,5 +22,7 @@ export type GalleryState = {
offset: number;
limit: number;
alwaysShowImageSizeBadge: boolean;
isImageViewerOpen: boolean;
imageToCompare: ImageDTO | null;
comparisonMode: ComparisonMode;
viewerMode: ViewerMode;
};

View File

@ -3,7 +3,7 @@ import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/u
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { ControlLayersPanelContent } from 'features/controlLayers/components/ControlLayersPanelContent';
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
import { viewerModeChanged } from 'features/gallery/store/gallerySlice';
import { Prompts } from 'features/parameters/components/Prompts/Prompts';
import QueueControls from 'features/queue/components/QueueControls';
import { SDXLPrompts } from 'features/sdxl/components/SDXLPrompts/SDXLPrompts';
@ -51,7 +51,7 @@ const ParametersPanelTextToImage = () => {
const onChangeTabs = useCallback(
(i: number) => {
if (i === 1) {
dispatch(isImageViewerOpenChanged(false));
dispatch(viewerModeChanged('edit'));
}
},
[dispatch]