mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): abstract out and share logic between comparisons
This commit is contained in:
parent
34d68a3663
commit
449bc4dbe5
@ -61,7 +61,6 @@
|
|||||||
"@fontsource-variable/inter": "^5.0.18",
|
"@fontsource-variable/inter": "^5.0.18",
|
||||||
"@invoke-ai/ui-library": "^0.0.25",
|
"@invoke-ai/ui-library": "^0.0.25",
|
||||||
"@nanostores/react": "^0.7.2",
|
"@nanostores/react": "^0.7.2",
|
||||||
"@reactuses/core": "^5.0.14",
|
|
||||||
"@reduxjs/toolkit": "2.2.3",
|
"@reduxjs/toolkit": "2.2.3",
|
||||||
"@roarr/browser-log-writer": "^1.3.0",
|
"@roarr/browser-log-writer": "^1.3.0",
|
||||||
"chakra-react-select": "^4.7.6",
|
"chakra-react-select": "^4.7.6",
|
||||||
|
20
invokeai/frontend/web/pnpm-lock.yaml
generated
20
invokeai/frontend/web/pnpm-lock.yaml
generated
@ -35,9 +35,6 @@ dependencies:
|
|||||||
'@nanostores/react':
|
'@nanostores/react':
|
||||||
specifier: ^0.7.2
|
specifier: ^0.7.2
|
||||||
version: 0.7.2(nanostores@0.10.3)(react@18.3.1)
|
version: 0.7.2(nanostores@0.10.3)(react@18.3.1)
|
||||||
'@reactuses/core':
|
|
||||||
specifier: ^5.0.14
|
|
||||||
version: 5.0.14(react@18.3.1)
|
|
||||||
'@reduxjs/toolkit':
|
'@reduxjs/toolkit':
|
||||||
specifier: 2.2.3
|
specifier: 2.2.3
|
||||||
version: 2.2.3(react-redux@9.1.2)(react@18.3.1)
|
version: 2.2.3(react-redux@9.1.2)(react@18.3.1)
|
||||||
@ -3985,18 +3982,6 @@ packages:
|
|||||||
- immer
|
- immer
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@reactuses/core@5.0.14(react@18.3.1):
|
|
||||||
resolution: {integrity: sha512-lg640pRPOPT0HZ8XQAA1VRZ47fLIvSd2JrUTtKpzm4t3MtZvza+w2RHBGgPsdmtiLV3GsJJC9x5ge7XOQmiJ/Q==}
|
|
||||||
peerDependencies:
|
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
|
||||||
dependencies:
|
|
||||||
js-cookie: 3.0.5
|
|
||||||
lodash-es: 4.17.21
|
|
||||||
react: 18.3.1
|
|
||||||
screenfull: 5.2.0
|
|
||||||
use-sync-external-store: 1.2.2(react@18.3.1)
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@reduxjs/toolkit@2.2.3(react-redux@9.1.2)(react@18.3.1):
|
/@reduxjs/toolkit@2.2.3(react-redux@9.1.2)(react@18.3.1):
|
||||||
resolution: {integrity: sha512-76dll9EnJXg4EVcI5YNxZA/9hSAmZsFqzMmNRHvIlzw2WS/twfcVX3ysYrWGJMClwEmChQFC4yRq74tn6fdzRA==}
|
resolution: {integrity: sha512-76dll9EnJXg4EVcI5YNxZA/9hSAmZsFqzMmNRHvIlzw2WS/twfcVX3ysYrWGJMClwEmChQFC4yRq74tn6fdzRA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -9683,11 +9668,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
|
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/js-cookie@3.0.5:
|
|
||||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
|
||||||
engines: {node: '>=14'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/js-tokens@4.0.0:
|
/js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ export const addWorkflowLoadRequestedListener = (startAppListening: AppStartList
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$needsFit.set(true)
|
$needsFit.set(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof WorkflowVersionError) {
|
if (e instanceof WorkflowVersionError) {
|
||||||
// The workflow version was not recognized in the valid list of versions
|
// The workflow version was not recognized in the valid list of versions
|
||||||
|
21
invokeai/frontend/web/src/common/hooks/useBoolean.ts
Normal file
21
invokeai/frontend/web/src/common/hooks/useBoolean.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export const useBoolean = (initialValue: boolean) => {
|
||||||
|
const [isTrue, set] = useState(initialValue);
|
||||||
|
const setTrue = useCallback(() => set(true), []);
|
||||||
|
const setFalse = useCallback(() => set(false), []);
|
||||||
|
const toggle = useCallback(() => set((v) => !v), []);
|
||||||
|
|
||||||
|
const api = useMemo(
|
||||||
|
() => ({
|
||||||
|
isTrue,
|
||||||
|
set,
|
||||||
|
setTrue,
|
||||||
|
setFalse,
|
||||||
|
toggle,
|
||||||
|
}),
|
||||||
|
[isTrue, set, setTrue, setFalse, toggle]
|
||||||
|
);
|
||||||
|
|
||||||
|
return api;
|
||||||
|
};
|
@ -1,6 +1,7 @@
|
|||||||
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
|
||||||
import { useAppSelector } from 'app/store/storeHooks';
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||||
|
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
||||||
import { ImageComparisonHover } from 'features/gallery/components/ImageViewer/ImageComparisonHover';
|
import { ImageComparisonHover } from 'features/gallery/components/ImageViewer/ImageComparisonHover';
|
||||||
import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide';
|
import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide';
|
||||||
import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider';
|
import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider';
|
||||||
@ -15,7 +16,11 @@ const selector = createMemoizedSelector(selectGallerySlice, (gallerySlice) => {
|
|||||||
return { firstImage, secondImage };
|
return { firstImage, secondImage };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ImageComparison = memo(() => {
|
type Props = {
|
||||||
|
containerDims: Dimensions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImageComparison = memo(({ containerDims }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const comparisonMode = useAppSelector((s) => s.gallery.comparisonMode);
|
const comparisonMode = useAppSelector((s) => s.gallery.comparisonMode);
|
||||||
const { firstImage, secondImage } = useAppSelector(selector);
|
const { firstImage, secondImage } = useAppSelector(selector);
|
||||||
@ -26,15 +31,17 @@ export const ImageComparison = memo(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (comparisonMode === 'slider') {
|
if (comparisonMode === 'slider') {
|
||||||
return <ImageComparisonSlider firstImage={firstImage} secondImage={secondImage} />;
|
return <ImageComparisonSlider containerDims={containerDims} firstImage={firstImage} secondImage={secondImage} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comparisonMode === 'side-by-side') {
|
if (comparisonMode === 'side-by-side') {
|
||||||
return <ImageComparisonSideBySide firstImage={firstImage} secondImage={secondImage} />;
|
return (
|
||||||
|
<ImageComparisonSideBySide containerDims={containerDims} firstImage={firstImage} secondImage={secondImage} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comparisonMode === 'hover') {
|
if (comparisonMode === 'hover') {
|
||||||
return <ImageComparisonHover firstImage={firstImage} secondImage={secondImage} />;
|
return <ImageComparisonHover containerDims={containerDims} firstImage={firstImage} secondImage={secondImage} />;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,101 +1,114 @@
|
|||||||
import { Flex, Image, Text } from '@invoke-ai/ui-library';
|
import { Box, Flex, Image } from '@invoke-ai/ui-library';
|
||||||
import { useAppSelector } from 'app/store/storeHooks';
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { useBoolean } from 'common/hooks/useBoolean';
|
||||||
import { preventDefault } from 'common/util/stopPropagation';
|
import { preventDefault } from 'common/util/stopPropagation';
|
||||||
import { DROP_SHADOW } from 'features/gallery/components/ImageViewer/useImageViewer';
|
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
||||||
import { memo, useCallback, useState } from 'react';
|
import { STAGE_BG_DATAURL } from 'features/controlLayers/util/renderers';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
|
||||||
import type { ImageDTO } from 'services/api/types';
|
import { memo, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
type Props = {
|
import type { ComparisonProps } from './common';
|
||||||
/**
|
import { fitDimsToContainer, getSecondImageDims } from './common';
|
||||||
* The first image to compare
|
|
||||||
*/
|
|
||||||
firstImage: ImageDTO;
|
|
||||||
/**
|
|
||||||
* The second image to compare
|
|
||||||
*/
|
|
||||||
secondImage: ImageDTO;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ImageComparisonHover = memo(({ firstImage, secondImage }: Props) => {
|
export const ImageComparisonHover = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const comparisonFit = useAppSelector((s) => s.gallery.comparisonFit);
|
const comparisonFit = useAppSelector((s) => s.gallery.comparisonFit);
|
||||||
const [isMouseOver, setIsMouseOver] = useState(false);
|
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const onMouseOver = useCallback(() => {
|
const mouseOver = useBoolean(false);
|
||||||
setIsMouseOver(true);
|
const fittedDims = useMemo<Dimensions>(
|
||||||
}, []);
|
() => fitDimsToContainer(containerDims, firstImage),
|
||||||
const onMouseOut = useCallback(() => {
|
[containerDims, firstImage]
|
||||||
setIsMouseOver(false);
|
);
|
||||||
}, []);
|
const compareImageDims = useMemo<Dimensions>(
|
||||||
|
() => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage),
|
||||||
|
[comparisonFit, fittedDims, firstImage, secondImage]
|
||||||
|
);
|
||||||
return (
|
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="relative" alignItems="center" justifyContent="center">
|
||||||
<Flex position="absolute" maxW="full" maxH="full" aspectRatio={firstImage.width / firstImage.height}>
|
<Flex
|
||||||
<Image
|
id="image-comparison-wrapper"
|
||||||
id="image-comparison-first-image"
|
w="full"
|
||||||
w={firstImage.width}
|
h="full"
|
||||||
h={firstImage.height}
|
maxW="full"
|
||||||
|
maxH="full"
|
||||||
|
position="absolute"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
ref={imageContainerRef}
|
||||||
|
position="relative"
|
||||||
|
id="image-comparison-hover-image-container"
|
||||||
|
w={fittedDims.width}
|
||||||
|
h={fittedDims.height}
|
||||||
maxW="full"
|
maxW="full"
|
||||||
maxH="full"
|
maxH="full"
|
||||||
src={firstImage.image_url}
|
userSelect="none"
|
||||||
fallbackSrc={firstImage.thumbnail_url}
|
overflow="hidden"
|
||||||
objectFit="contain"
|
borderRadius="base"
|
||||||
/>
|
|
||||||
<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
|
<Image
|
||||||
id="image-comparison-second-image"
|
id="image-comparison-hover-first-image"
|
||||||
w={comparisonFit === 'fill' ? 'full' : secondImage.width}
|
src={firstImage.image_url}
|
||||||
h={comparisonFit === 'fill' ? 'full' : secondImage.height}
|
fallbackSrc={firstImage.thumbnail_url}
|
||||||
maxW={comparisonFit === 'contain' ? 'full' : undefined}
|
w={fittedDims.width}
|
||||||
maxH={comparisonFit === 'contain' ? 'full' : undefined}
|
h={fittedDims.height}
|
||||||
src={secondImage.image_url}
|
maxW="full"
|
||||||
fallbackSrc={secondImage.thumbnail_url}
|
maxH="full"
|
||||||
objectFit={comparisonFit}
|
objectFit="cover"
|
||||||
objectPosition="top left"
|
objectPosition="top left"
|
||||||
/>
|
/>
|
||||||
<Text
|
<ImageComparisonLabel type="first" opacity={mouseOver.isTrue ? 0 : 1} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
id="image-comparison-hover-second-image-container"
|
||||||
position="absolute"
|
position="absolute"
|
||||||
bottom={4}
|
top={0}
|
||||||
insetInlineStart={4}
|
left={0}
|
||||||
textOverflow="clip"
|
right={0}
|
||||||
whiteSpace="nowrap"
|
bottom={0}
|
||||||
filter={DROP_SHADOW}
|
overflow="hidden"
|
||||||
color="base.50"
|
opacity={mouseOver.isTrue ? 1 : 0}
|
||||||
|
transitionDuration="0.2s"
|
||||||
|
transitionProperty="common"
|
||||||
>
|
>
|
||||||
{t('gallery.compareImage')}
|
<Box
|
||||||
</Text>
|
id="image-comparison-hover-bg"
|
||||||
</Flex>
|
position="absolute"
|
||||||
<Flex
|
top={0}
|
||||||
id="image-comparison-interaction-overlay"
|
left={0}
|
||||||
position="absolute"
|
right={0}
|
||||||
top={0}
|
bottom={0}
|
||||||
right={0}
|
backgroundImage={STAGE_BG_DATAURL}
|
||||||
bottom={0}
|
backgroundRepeat="repeat"
|
||||||
left={0}
|
opacity={0.2}
|
||||||
onMouseOver={onMouseOver}
|
/>
|
||||||
onMouseOut={onMouseOut}
|
<Image
|
||||||
onContextMenu={preventDefault}
|
position="relative"
|
||||||
userSelect="none"
|
id="image-comparison-hover-second-image"
|
||||||
/>
|
src={secondImage.image_url}
|
||||||
|
fallbackSrc={secondImage.thumbnail_url}
|
||||||
|
w={compareImageDims.width}
|
||||||
|
h={compareImageDims.height}
|
||||||
|
maxW={fittedDims.width}
|
||||||
|
maxH={fittedDims.height}
|
||||||
|
objectFit={comparisonFit}
|
||||||
|
objectPosition="top left"
|
||||||
|
/>
|
||||||
|
<ImageComparisonLabel type="second" opacity={mouseOver.isTrue ? 1 : 0} />
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
id="image-comparison-hover-interaction-overlay"
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
left={0}
|
||||||
|
onMouseOver={mouseOver.setTrue}
|
||||||
|
onMouseOut={mouseOver.setFalse}
|
||||||
|
onContextMenu={preventDefault}
|
||||||
|
userSelect="none"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
import type { TextProps } from '@invoke-ai/ui-library';
|
||||||
|
import { Text } from '@invoke-ai/ui-library';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { DROP_SHADOW } from './common';
|
||||||
|
|
||||||
|
type Props = TextProps & {
|
||||||
|
type: 'first' | 'second';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImageComparisonLabel = memo(({ type, ...rest }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
position="absolute"
|
||||||
|
bottom={4}
|
||||||
|
insetInlineEnd={type === 'first' ? undefined : 4}
|
||||||
|
insetInlineStart={type === 'first' ? 4 : undefined}
|
||||||
|
textOverflow="clip"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
filter={DROP_SHADOW}
|
||||||
|
color="base.50"
|
||||||
|
transitionDuration="0.2s"
|
||||||
|
transitionProperty="common"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{type === 'first' ? t('gallery.viewerImage') : t('gallery.compareImage')}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ImageComparisonLabel.displayName = 'ImageComparisonLabel';
|
@ -1,25 +1,12 @@
|
|||||||
import { Flex, Image, Text } from '@invoke-ai/ui-library';
|
import { Flex, Image } from '@invoke-ai/ui-library';
|
||||||
import { DROP_SHADOW } from 'features/gallery/components/ImageViewer/useImageViewer';
|
import type { ComparisonProps } from 'features/gallery/components/ImageViewer/common';
|
||||||
|
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
|
||||||
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
||||||
import { memo, useCallback, useRef } from 'react';
|
import { memo, useCallback, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
|
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
|
||||||
import { Panel, PanelGroup } from 'react-resizable-panels';
|
import { Panel, PanelGroup } from 'react-resizable-panels';
|
||||||
import type { ImageDTO } from 'services/api/types';
|
|
||||||
|
|
||||||
type Props = {
|
export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: ComparisonProps) => {
|
||||||
/**
|
|
||||||
* 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 panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
|
||||||
const onDoubleClickHandle = useCallback(() => {
|
const onDoubleClickHandle = useCallback(() => {
|
||||||
if (!panelGroupRef.current) {
|
if (!panelGroupRef.current) {
|
||||||
@ -44,19 +31,9 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Prop
|
|||||||
src={firstImage.image_url}
|
src={firstImage.image_url}
|
||||||
fallbackSrc={firstImage.thumbnail_url}
|
fallbackSrc={firstImage.thumbnail_url}
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
|
borderRadius="base"
|
||||||
/>
|
/>
|
||||||
<Text
|
<ImageComparisonLabel type="first" />
|
||||||
position="absolute"
|
|
||||||
bottom={4}
|
|
||||||
insetInlineStart={4}
|
|
||||||
textOverflow="clip"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
filter={DROP_SHADOW}
|
|
||||||
color="base.50"
|
|
||||||
userSelect="none"
|
|
||||||
>
|
|
||||||
{t('gallery.viewerImage')}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Panel>
|
</Panel>
|
||||||
@ -78,19 +55,9 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Prop
|
|||||||
src={secondImage.image_url}
|
src={secondImage.image_url}
|
||||||
fallbackSrc={secondImage.thumbnail_url}
|
fallbackSrc={secondImage.thumbnail_url}
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
|
borderRadius="base"
|
||||||
/>
|
/>
|
||||||
<Text
|
<ImageComparisonLabel type="second" />
|
||||||
position="absolute"
|
|
||||||
bottom={4}
|
|
||||||
insetInlineStart={4}
|
|
||||||
textOverflow="clip"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
filter={DROP_SHADOW}
|
|
||||||
color="base.50"
|
|
||||||
userSelect="none"
|
|
||||||
>
|
|
||||||
{t('gallery.compareImage')}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
@ -1,15 +1,14 @@
|
|||||||
import { Box, Flex, Icon, Image, Text } from '@invoke-ai/ui-library';
|
import { Box, Flex, Icon, Image } from '@invoke-ai/ui-library';
|
||||||
import { useMeasure } from '@reactuses/core';
|
|
||||||
import { useAppSelector } from 'app/store/storeHooks';
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
import { preventDefault } from 'common/util/stopPropagation';
|
import { preventDefault } from 'common/util/stopPropagation';
|
||||||
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
||||||
import { STAGE_BG_DATAURL } from 'features/controlLayers/util/renderers';
|
import { STAGE_BG_DATAURL } from 'features/controlLayers/util/renderers';
|
||||||
|
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
||||||
import type { ImageDTO } from 'services/api/types';
|
|
||||||
|
|
||||||
import { DROP_SHADOW } from './useImageViewer';
|
import type { ComparisonProps } from './common';
|
||||||
|
import { DROP_SHADOW, fitDimsToContainer, getSecondImageDims } from './common';
|
||||||
|
|
||||||
const INITIAL_POS = '50%';
|
const INITIAL_POS = '50%';
|
||||||
const HANDLE_WIDTH = 2;
|
const HANDLE_WIDTH = 2;
|
||||||
@ -19,59 +18,28 @@ const HANDLE_HITBOX_PX = `${HANDLE_HITBOX}px`;
|
|||||||
const HANDLE_INNER_LEFT_PX = `${HANDLE_HITBOX / 2 - HANDLE_WIDTH / 2}px`;
|
const HANDLE_INNER_LEFT_PX = `${HANDLE_HITBOX / 2 - HANDLE_WIDTH / 2}px`;
|
||||||
const HANDLE_LEFT_INITIAL_PX = `calc(${INITIAL_POS} - ${HANDLE_HITBOX / 2}px)`;
|
const HANDLE_LEFT_INITIAL_PX = `calc(${INITIAL_POS} - ${HANDLE_HITBOX / 2}px)`;
|
||||||
|
|
||||||
type Props = {
|
export const ImageComparisonSlider = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
|
||||||
/**
|
|
||||||
* The first image to compare
|
|
||||||
*/
|
|
||||||
firstImage: ImageDTO;
|
|
||||||
/**
|
|
||||||
* The second image to compare
|
|
||||||
*/
|
|
||||||
secondImage: ImageDTO;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const comparisonFit = useAppSelector((s) => s.gallery.comparisonFit);
|
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
|
// 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);
|
const [left, setLeft] = useState(HANDLE_LEFT_INITIAL_PX);
|
||||||
// How wide the first image is
|
// How wide the first image is
|
||||||
const [width, setWidth] = useState(INITIAL_POS);
|
const [width, setWidth] = useState(INITIAL_POS);
|
||||||
const handleRef = useRef<HTMLDivElement>(null);
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
// If the container size is not provided, use an internal ref and measure - can cause flicker on mount tho
|
// To manage aspect ratios, we need to know the size of the container
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [containerSize] = useMeasure(containerRef);
|
|
||||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||||
// To keep things smooth, we use RAF to update the handle position & gate it to 60fps
|
// To keep things smooth, we use RAF to update the handle position & gate it to 60fps
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
const lastMoveTimeRef = useRef<number>(0);
|
const lastMoveTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
const fittedSize = useMemo<Dimensions>(() => {
|
const fittedDims = useMemo<Dimensions>(
|
||||||
// Fit the first image to the container
|
() => fitDimsToContainer(containerDims, firstImage),
|
||||||
if (containerSize.width === 0 || containerSize.height === 0) {
|
[containerDims, firstImage]
|
||||||
return { width: firstImage.width, height: firstImage.height };
|
);
|
||||||
}
|
|
||||||
const targetAspectRatio = containerSize.width / containerSize.height;
|
|
||||||
const imageAspectRatio = firstImage.width / firstImage.height;
|
|
||||||
|
|
||||||
let width: number;
|
const compareImageDims = useMemo<Dimensions>(
|
||||||
let height: number;
|
() => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage),
|
||||||
|
[comparisonFit, fittedDims, firstImage, secondImage]
|
||||||
if (firstImage.width <= containerSize.width && firstImage.height <= containerSize.height) {
|
);
|
||||||
return { width: firstImage.width, height: firstImage.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (imageAspectRatio > targetAspectRatio) {
|
|
||||||
// Image is wider than container's aspect ratio
|
|
||||||
width = containerSize.width;
|
|
||||||
height = width / imageAspectRatio;
|
|
||||||
} else {
|
|
||||||
// Image is taller than container's aspect ratio
|
|
||||||
height = containerSize.height;
|
|
||||||
width = height * imageAspectRatio;
|
|
||||||
}
|
|
||||||
return { width, height };
|
|
||||||
}, [containerSize, firstImage.height, firstImage.width]);
|
|
||||||
|
|
||||||
const updateHandlePos = useCallback((clientX: number) => {
|
const updateHandlePos = useCallback((clientX: number) => {
|
||||||
if (!handleRef.current || !imageContainerRef.current) {
|
if (!handleRef.current || !imageContainerRef.current) {
|
||||||
@ -122,16 +90,7 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) =
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex w="full" h="full" maxW="full" maxH="full" position="relative" alignItems="center" justifyContent="center">
|
||||||
ref={containerRef}
|
|
||||||
w="full"
|
|
||||||
h="full"
|
|
||||||
maxW="full"
|
|
||||||
maxH="full"
|
|
||||||
position="relative"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
>
|
|
||||||
<Flex
|
<Flex
|
||||||
id="image-comparison-wrapper"
|
id="image-comparison-wrapper"
|
||||||
w="full"
|
w="full"
|
||||||
@ -146,8 +105,8 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) =
|
|||||||
ref={imageContainerRef}
|
ref={imageContainerRef}
|
||||||
position="relative"
|
position="relative"
|
||||||
id="image-comparison-image-container"
|
id="image-comparison-image-container"
|
||||||
w={fittedSize.width}
|
w={fittedDims.width}
|
||||||
h={fittedSize.height}
|
h={fittedDims.height}
|
||||||
maxW="full"
|
maxW="full"
|
||||||
maxH="full"
|
maxH="full"
|
||||||
userSelect="none"
|
userSelect="none"
|
||||||
@ -170,24 +129,14 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) =
|
|||||||
id="image-comparison-second-image"
|
id="image-comparison-second-image"
|
||||||
src={secondImage.image_url}
|
src={secondImage.image_url}
|
||||||
fallbackSrc={secondImage.thumbnail_url}
|
fallbackSrc={secondImage.thumbnail_url}
|
||||||
w={comparisonFit === 'fill' ? fittedSize.width : (fittedSize.width * secondImage.width) / firstImage.width}
|
w={compareImageDims.width}
|
||||||
h={comparisonFit === 'fill' ? fittedSize.height : (fittedSize.height * secondImage.height) / firstImage.height}
|
h={compareImageDims.height}
|
||||||
maxW={fittedSize.width}
|
maxW={fittedDims.width}
|
||||||
maxH={fittedSize.height}
|
maxH={fittedDims.height}
|
||||||
objectFit={comparisonFit}
|
objectFit={comparisonFit}
|
||||||
objectPosition="top left"
|
objectPosition="top left"
|
||||||
/>
|
/>
|
||||||
<Text
|
<ImageComparisonLabel type="second" />
|
||||||
position="absolute"
|
|
||||||
bottom={4}
|
|
||||||
insetInlineEnd={4}
|
|
||||||
textOverflow="clip"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
filter={DROP_SHADOW}
|
|
||||||
color="base.50"
|
|
||||||
>
|
|
||||||
{t('gallery.compareImage')}
|
|
||||||
</Text>
|
|
||||||
<Box
|
<Box
|
||||||
id="image-comparison-first-image-container"
|
id="image-comparison-first-image-container"
|
||||||
position="absolute"
|
position="absolute"
|
||||||
@ -202,22 +151,12 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) =
|
|||||||
id="image-comparison-first-image"
|
id="image-comparison-first-image"
|
||||||
src={firstImage.image_url}
|
src={firstImage.image_url}
|
||||||
fallbackSrc={firstImage.thumbnail_url}
|
fallbackSrc={firstImage.thumbnail_url}
|
||||||
w={fittedSize.width}
|
w={fittedDims.width}
|
||||||
h={fittedSize.height}
|
h={fittedDims.height}
|
||||||
objectFit="cover"
|
objectFit="cover"
|
||||||
objectPosition="top left"
|
objectPosition="top left"
|
||||||
/>
|
/>
|
||||||
<Text
|
<ImageComparisonLabel type="first" />
|
||||||
position="absolute"
|
|
||||||
bottom={4}
|
|
||||||
insetInlineStart={4}
|
|
||||||
textOverflow="clip"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
filter={DROP_SHADOW}
|
|
||||||
color="base.50"
|
|
||||||
>
|
|
||||||
{t('gallery.viewerImage')}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Flex
|
<Flex
|
||||||
id="image-comparison-handle"
|
id="image-comparison-handle"
|
||||||
|
@ -3,12 +3,14 @@ import { useAppSelector } from 'app/store/storeHooks';
|
|||||||
import { CompareToolbar } from 'features/gallery/components/ImageViewer/CompareToolbar';
|
import { CompareToolbar } from 'features/gallery/components/ImageViewer/CompareToolbar';
|
||||||
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
|
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
|
||||||
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
|
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
|
||||||
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
|
|
||||||
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
|
import { ViewerToolbar } from 'features/gallery/components/ImageViewer/ViewerToolbar';
|
||||||
import type { InvokeTabName } from 'features/ui/store/tabMap';
|
import type { InvokeTabName } from 'features/ui/store/tabMap';
|
||||||
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
|
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { useMeasure } from 'react-use';
|
||||||
|
|
||||||
|
import { useImageViewer } from './useImageViewer';
|
||||||
|
|
||||||
const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows'];
|
const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows'];
|
||||||
|
|
||||||
@ -27,6 +29,7 @@ export const ImageViewer = memo(() => {
|
|||||||
}
|
}
|
||||||
return isOpen;
|
return isOpen;
|
||||||
}, [isOpen, isViewerEnabled, workflowsMode, activeTabName]);
|
}, [isOpen, isViewerEnabled, workflowsMode, activeTabName]);
|
||||||
|
const [containerRef, containerDims] = useMeasure<HTMLDivElement>();
|
||||||
|
|
||||||
useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
|
useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
|
||||||
useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]);
|
useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]);
|
||||||
@ -52,9 +55,9 @@ export const ImageViewer = memo(() => {
|
|||||||
>
|
>
|
||||||
{isComparing && <CompareToolbar />}
|
{isComparing && <CompareToolbar />}
|
||||||
{!isComparing && <ViewerToolbar />}
|
{!isComparing && <ViewerToolbar />}
|
||||||
<Box w="full" h="full">
|
<Box ref={containerRef} w="full" h="full">
|
||||||
{!isComparing && <CurrentImagePreview />}
|
{!isComparing && <CurrentImagePreview />}
|
||||||
{isComparing && <ImageComparison />}
|
{isComparing && <ImageComparison containerDims={containerDims} />}
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,57 @@
|
|||||||
|
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
||||||
|
import type { ComparisonFit } from 'features/gallery/store/types';
|
||||||
|
import type { ImageDTO } from 'services/api/types';
|
||||||
|
|
||||||
|
export const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.3))';
|
||||||
|
|
||||||
|
export type ComparisonProps = {
|
||||||
|
firstImage: ImageDTO;
|
||||||
|
secondImage: ImageDTO;
|
||||||
|
containerDims: Dimensions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fitDimsToContainer = (containerDims: Dimensions, imageDims: Dimensions): Dimensions => {
|
||||||
|
// Fall back to the image's dimensions if the container has no dimensions
|
||||||
|
if (containerDims.width === 0 || containerDims.height === 0) {
|
||||||
|
return { width: imageDims.width, height: imageDims.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the image's dimensions if the image fits within the container
|
||||||
|
if (imageDims.width <= containerDims.width && imageDims.height <= containerDims.height) {
|
||||||
|
return { width: imageDims.width, height: imageDims.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAspectRatio = containerDims.width / containerDims.height;
|
||||||
|
const imageAspectRatio = imageDims.width / imageDims.height;
|
||||||
|
|
||||||
|
let width: number;
|
||||||
|
let height: number;
|
||||||
|
|
||||||
|
if (imageAspectRatio > targetAspectRatio) {
|
||||||
|
// Image is wider than container's aspect ratio
|
||||||
|
width = containerDims.width;
|
||||||
|
height = width / imageAspectRatio;
|
||||||
|
} else {
|
||||||
|
// Image is taller than container's aspect ratio
|
||||||
|
height = containerDims.height;
|
||||||
|
width = height * imageAspectRatio;
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the dimensions of the second image in a comparison based on the comparison fit mode.
|
||||||
|
*/
|
||||||
|
export const getSecondImageDims = (
|
||||||
|
comparisonFit: ComparisonFit,
|
||||||
|
fittedDims: Dimensions,
|
||||||
|
firstImageDims: Dimensions,
|
||||||
|
secondImageDims: Dimensions
|
||||||
|
): Dimensions => {
|
||||||
|
const width =
|
||||||
|
comparisonFit === 'fill' ? fittedDims.width : (fittedDims.width * secondImageDims.width) / firstImageDims.width;
|
||||||
|
const height =
|
||||||
|
comparisonFit === 'fill' ? fittedDims.height : (fittedDims.height * secondImageDims.height) / firstImageDims.height;
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
};
|
@ -29,5 +29,3 @@ export const useImageViewer = () => {
|
|||||||
|
|
||||||
return { isOpen, onOpen, onClose, onToggle };
|
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))';
|
|
@ -8,6 +8,7 @@ export const IMAGE_LIMIT = 20;
|
|||||||
export type GalleryView = 'images' | 'assets';
|
export type GalleryView = 'images' | 'assets';
|
||||||
export type BoardId = 'none' | (string & Record<never, never>);
|
export type BoardId = 'none' | (string & Record<never, never>);
|
||||||
export type ComparisonMode = 'slider' | 'side-by-side' | 'hover';
|
export type ComparisonMode = 'slider' | 'side-by-side' | 'hover';
|
||||||
|
export type ComparisonFit = 'contain' | 'fill';
|
||||||
|
|
||||||
export type GalleryState = {
|
export type GalleryState = {
|
||||||
selection: ImageDTO[];
|
selection: ImageDTO[];
|
||||||
@ -23,6 +24,6 @@ export type GalleryState = {
|
|||||||
alwaysShowImageSizeBadge: boolean;
|
alwaysShowImageSizeBadge: boolean;
|
||||||
imageToCompare: ImageDTO | null;
|
imageToCompare: ImageDTO | null;
|
||||||
comparisonMode: ComparisonMode;
|
comparisonMode: ComparisonMode;
|
||||||
comparisonFit: 'contain' | 'fill';
|
comparisonFit: ComparisonFit;
|
||||||
isImageViewerOpen: boolean;
|
isImageViewerOpen: boolean;
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user