feat(ui): hover comparison mode

This commit is contained in:
psychedelicious 2024-06-02 08:52:32 +10:00
parent 8bb9571485
commit 34d68a3663
9 changed files with 197 additions and 56 deletions

View File

@ -385,6 +385,7 @@
"selectAnImageToCompare": "Select an Image to Compare",
"slider": "Slider",
"sideBySide": "Side-by-Side",
"hover": "Hover",
"swapImages": "Swap Images",
"compareOptions": "Comparison Options",
"stretchToFit": "Stretch to Fit",

View File

@ -2,9 +2,9 @@ import { Button, ButtonGroup, Flex, IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
comparedImagesSwapped,
comparisonFitChanged,
comparisonModeChanged,
imageToCompareChanged,
sliderFitChanged,
} from 'features/gallery/store/gallerySlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@ -14,19 +14,22 @@ export const CompareToolbar = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const comparisonMode = useAppSelector((s) => s.gallery.comparisonMode);
const sliderFit = useAppSelector((s) => s.gallery.sliderFit);
const comparisonFit = useAppSelector((s) => s.gallery.comparisonFit);
const setComparisonModeSlider = useCallback(() => {
dispatch(comparisonModeChanged('slider'));
}, [dispatch]);
const setComparisonModeSideBySide = useCallback(() => {
dispatch(comparisonModeChanged('side-by-side'));
}, [dispatch]);
const setComparisonModeHover = useCallback(() => {
dispatch(comparisonModeChanged('hover'));
}, [dispatch]);
const swapImages = useCallback(() => {
dispatch(comparedImagesSwapped());
}, [dispatch]);
const toggleSliderFit = useCallback(() => {
dispatch(sliderFitChanged(sliderFit === 'contain' ? 'fill' : 'contain'));
}, [dispatch, sliderFit]);
const togglecomparisonFit = useCallback(() => {
dispatch(comparisonFitChanged(comparisonFit === 'contain' ? 'fill' : 'contain'));
}, [dispatch, comparisonFit]);
const exitCompare = useCallback(() => {
dispatch(imageToCompareChanged(null));
}, [dispatch]);
@ -41,13 +44,12 @@ export const CompareToolbar = memo(() => {
tooltip={t('gallery.swapImages')}
onClick={swapImages}
/>
{comparisonMode === 'slider' && (
{comparisonMode !== 'side-by-side' && (
<IconButton
aria-label={t('gallery.stretchToFit')}
tooltip={t('gallery.stretchToFit')}
isDisabled={comparisonMode !== 'slider'}
onClick={toggleSliderFit}
colorScheme={sliderFit === 'fill' ? 'invokeBlue' : 'base'}
onClick={togglecomparisonFit}
colorScheme={comparisonFit === 'fill' ? 'invokeBlue' : 'base'}
variant="outline"
icon={<PiArrowsOutBold />}
/>
@ -70,6 +72,13 @@ export const CompareToolbar = memo(() => {
>
{t('gallery.sideBySide')}
</Button>
<Button
flexShrink={0}
onClick={setComparisonModeHover}
colorScheme={comparisonMode === 'hover' ? 'invokeBlue' : 'base'}
>
{t('gallery.hover')}
</Button>
</ButtonGroup>
</Flex>
<Flex flex={1} justifyContent="center">

View File

@ -1,6 +1,7 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { ImageComparisonHover } from 'features/gallery/components/ImageViewer/ImageComparisonHover';
import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide';
import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider';
import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
@ -31,6 +32,10 @@ export const ImageComparison = memo(() => {
if (comparisonMode === 'side-by-side') {
return <ImageComparisonSideBySide firstImage={firstImage} secondImage={secondImage} />;
}
if (comparisonMode === 'hover') {
return <ImageComparisonHover firstImage={firstImage} secondImage={secondImage} />;
}
});
ImageComparison.displayName = 'ImageComparison';

View File

@ -0,0 +1,104 @@
import { Flex, Image, Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { preventDefault } from 'common/util/stopPropagation';
import { DROP_SHADOW } from 'features/gallery/components/ImageViewer/useImageViewer';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
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 ImageComparisonHover = memo(({ firstImage, secondImage }: Props) => {
const { t } = useTranslation();
const comparisonFit = useAppSelector((s) => s.gallery.comparisonFit);
const [isMouseOver, setIsMouseOver] = useState(false);
const onMouseOver = useCallback(() => {
setIsMouseOver(true);
}, []);
const onMouseOut = useCallback(() => {
setIsMouseOver(false);
}, []);
return (
<Flex w="full" h="full" maxW="full" maxH="full" position="relative" alignItems="center" justifyContent="center">
<Flex position="absolute" maxW="full" maxH="full" aspectRatio={firstImage.width / firstImage.height}>
<Image
id="image-comparison-first-image"
w={firstImage.width}
h={firstImage.height}
maxW="full"
maxH="full"
src={firstImage.image_url}
fallbackSrc={firstImage.thumbnail_url}
objectFit="contain"
/>
<Text
position="absolute"
bottom={4}
insetInlineStart={4}
textOverflow="clip"
whiteSpace="nowrap"
filter={DROP_SHADOW}
color="base.50"
>
{t('gallery.viewerImage')}
</Text>
<Flex
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
opacity={isMouseOver ? 1 : 0}
transitionDuration="0.2s"
transitionProperty="common"
>
<Image
id="image-comparison-second-image"
w={comparisonFit === 'fill' ? 'full' : secondImage.width}
h={comparisonFit === 'fill' ? 'full' : secondImage.height}
maxW={comparisonFit === 'contain' ? 'full' : undefined}
maxH={comparisonFit === 'contain' ? 'full' : undefined}
src={secondImage.image_url}
fallbackSrc={secondImage.thumbnail_url}
objectFit={comparisonFit}
objectPosition="top left"
/>
<Text
position="absolute"
bottom={4}
insetInlineStart={4}
textOverflow="clip"
whiteSpace="nowrap"
filter={DROP_SHADOW}
color="base.50"
>
{t('gallery.compareImage')}
</Text>
</Flex>
<Flex
id="image-comparison-interaction-overlay"
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onContextMenu={preventDefault}
userSelect="none"
/>
</Flex>
</Flex>
);
});
ImageComparisonHover.displayName = 'ImageComparisonHover';

View File

@ -1,8 +1,8 @@
import { Flex } from '@invoke-ai/ui-library';
import IAIDndImage from 'common/components/IAIDndImage';
import type { ImageDraggableData } from 'features/dnd/types';
import { Flex, Image, Text } from '@invoke-ai/ui-library';
import { DROP_SHADOW } from 'features/gallery/components/ImageViewer/useImageViewer';
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
import { memo, useCallback, useMemo, useRef } from 'react';
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';
@ -19,6 +19,7 @@ type Props = {
};
export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Props) => {
const { t } = useTranslation();
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const onDoubleClickHandle = useCallback(() => {
if (!panelGroupRef.current) {
@ -27,36 +28,36 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Prop
panelGroupRef.current.setLayout([50, 50]);
}, []);
const firstImageDraggableData = useMemo<ImageDraggableData>(
() => ({
id: 'image-compare-first-image',
payloadType: 'IMAGE_DTO',
payload: { imageDTO: firstImage },
}),
[firstImage]
);
const secondImageDraggableData = useMemo<ImageDraggableData>(
() => ({
id: 'image-compare-second-image',
payloadType: 'IMAGE_DTO',
payload: { imageDTO: secondImage },
}),
[secondImage]
);
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">
<IAIDndImage
imageDTO={firstImage}
isDropDisabled={true}
draggableData={firstImageDraggableData}
useThumbailFallback
/>
<Flex position="relative" w="full" h="full" alignItems="center" justifyContent="center">
<Flex position="absolute" maxW="full" maxH="full" aspectRatio={firstImage.width / firstImage.height}>
<Image
id="image-comparison-side-by-side-first-image"
w={firstImage.width}
h={firstImage.height}
maxW="full"
maxH="full"
src={firstImage.image_url}
fallbackSrc={firstImage.thumbnail_url}
objectFit="contain"
/>
<Text
position="absolute"
bottom={4}
insetInlineStart={4}
textOverflow="clip"
whiteSpace="nowrap"
filter={DROP_SHADOW}
color="base.50"
userSelect="none"
>
{t('gallery.viewerImage')}
</Text>
</Flex>
</Flex>
</Panel>
<ResizeHandle
@ -66,13 +67,31 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Prop
/>
<Panel minSize={20}>
<Flex w="full" h="full" alignItems="center" justifyContent="center">
<IAIDndImage
imageDTO={secondImage}
isDropDisabled={true}
draggableData={secondImageDraggableData}
useThumbailFallback
/>
<Flex position="relative" w="full" h="full" alignItems="center" justifyContent="center">
<Flex position="absolute" maxW="full" maxH="full" aspectRatio={secondImage.width / secondImage.height}>
<Image
id="image-comparison-side-by-side-first-image"
w={secondImage.width}
h={secondImage.height}
maxW="full"
maxH="full"
src={secondImage.image_url}
fallbackSrc={secondImage.thumbnail_url}
objectFit="contain"
/>
<Text
position="absolute"
bottom={4}
insetInlineStart={4}
textOverflow="clip"
whiteSpace="nowrap"
filter={DROP_SHADOW}
color="base.50"
userSelect="none"
>
{t('gallery.compareImage')}
</Text>
</Flex>
</Flex>
</Panel>
</PanelGroup>

View File

@ -9,7 +9,8 @@ import { useTranslation } from 'react-i18next';
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0))';
import { DROP_SHADOW } from './useImageViewer';
const INITIAL_POS = '50%';
const HANDLE_WIDTH = 2;
const HANDLE_WIDTH_PX = `${HANDLE_WIDTH}px`;
@ -31,7 +32,7 @@ type Props = {
export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) => {
const { t } = useTranslation();
const sliderFit = useAppSelector((s) => s.gallery.sliderFit);
const comparisonFit = useAppSelector((s) => s.gallery.comparisonFit);
// 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);
// How wide the first image is
@ -169,11 +170,11 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) =
id="image-comparison-second-image"
src={secondImage.image_url}
fallbackSrc={secondImage.thumbnail_url}
w={sliderFit === 'fill' ? fittedSize.width : (fittedSize.width * secondImage.width) / firstImage.width}
h={sliderFit === 'fill' ? fittedSize.height : (fittedSize.height * secondImage.height) / firstImage.height}
w={comparisonFit === 'fill' ? fittedSize.width : (fittedSize.width * secondImage.width) / firstImage.width}
h={comparisonFit === 'fill' ? fittedSize.height : (fittedSize.height * secondImage.height) / firstImage.height}
maxW={fittedSize.width}
maxH={fittedSize.height}
objectFit={sliderFit}
objectFit={comparisonFit}
objectPosition="top left"
/>
<Text

View File

@ -29,3 +29,5 @@ export const useImageViewer = () => {
return { isOpen, onOpen, onClose, onToggle };
};
export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';

View File

@ -24,7 +24,7 @@ const initialGalleryState: GalleryState = {
isImageViewerOpen: true,
imageToCompare: null,
comparisonMode: 'slider',
sliderFit: 'fill',
comparisonFit: 'fill',
};
export const gallerySlice = createSlice({
@ -98,8 +98,8 @@ export const gallerySlice = createSlice({
state.imageToCompare = oldSelection[0] ?? null;
}
},
sliderFitChanged: (state, action: PayloadAction<'contain' | 'fill'>) => {
state.sliderFit = action.payload;
comparisonFitChanged: (state, action: PayloadAction<'contain' | 'fill'>) => {
state.comparisonFit = action.payload;
},
},
extraReducers: (builder) => {
@ -142,7 +142,7 @@ export const {
imageToCompareChanged,
comparisonModeChanged,
comparedImagesSwapped,
sliderFitChanged,
comparisonFitChanged,
} = gallerySlice.actions;
const isAnyBoardDeleted = isAnyOf(

View File

@ -7,7 +7,7 @@ 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';
export type ComparisonMode = 'slider' | 'side-by-side' | 'hover';
export type GalleryState = {
selection: ImageDTO[];
@ -23,6 +23,6 @@ export type GalleryState = {
alwaysShowImageSizeBadge: boolean;
imageToCompare: ImageDTO | null;
comparisonMode: ComparisonMode;
sliderFit: 'contain' | 'fill';
comparisonFit: 'contain' | 'fill';
isImageViewerOpen: boolean;
};