mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): hover comparison mode
This commit is contained in:
parent
8bb9571485
commit
34d68a3663
@ -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",
|
||||
|
@ -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">
|
||||
|
@ -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';
|
||||
|
@ -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';
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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))';
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user