mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): add comparison modes, side-by-side view
This commit is contained in:
parent
1af53aed60
commit
8f8ddd620b
@ -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",
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 />}
|
||||
|
@ -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(() => {
|
||||
|
@ -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"
|
||||
|
@ -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';
|
@ -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';
|
@ -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';
|
@ -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';
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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 };
|
||||
};
|
||||
|
@ -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'],
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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]
|
||||
|
Loading…
Reference in New Issue
Block a user