feat(ui): abstract out and share logic between comparisons

This commit is contained in:
psychedelicious 2024-06-02 10:02:33 +10:00
parent 34d68a3663
commit 449bc4dbe5
13 changed files with 260 additions and 242 deletions

View File

@ -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",

View File

@ -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==}

View File

@ -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

View 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;
};

View File

@ -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} />;
} }
}); });

View File

@ -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>
); );

View File

@ -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';

View File

@ -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>

View File

@ -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"

View File

@ -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>
); );

View File

@ -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 };
};

View File

@ -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))';

View File

@ -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;
}; };