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",
|
||||
"@invoke-ai/ui-library": "^0.0.25",
|
||||
"@nanostores/react": "^0.7.2",
|
||||
"@reactuses/core": "^5.0.14",
|
||||
"@reduxjs/toolkit": "2.2.3",
|
||||
"@roarr/browser-log-writer": "^1.3.0",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
|
@ -35,9 +35,6 @@ dependencies:
|
||||
'@nanostores/react':
|
||||
specifier: ^0.7.2
|
||||
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':
|
||||
specifier: 2.2.3
|
||||
version: 2.2.3(react-redux@9.1.2)(react@18.3.1)
|
||||
@ -3985,18 +3982,6 @@ packages:
|
||||
- immer
|
||||
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):
|
||||
resolution: {integrity: sha512-76dll9EnJXg4EVcI5YNxZA/9hSAmZsFqzMmNRHvIlzw2WS/twfcVX3ysYrWGJMClwEmChQFC4yRq74tn6fdzRA==}
|
||||
peerDependencies:
|
||||
@ -9683,11 +9668,6 @@ packages:
|
||||
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
|
||||
dev: false
|
||||
|
||||
/js-cookie@3.0.5:
|
||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
||||
engines: {node: '>=14'}
|
||||
dev: false
|
||||
|
||||
/js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
|
@ -65,7 +65,7 @@ export const addWorkflowLoadRequestedListener = (startAppListening: AppStartList
|
||||
});
|
||||
}
|
||||
|
||||
$needsFit.set(true)
|
||||
$needsFit.set(true);
|
||||
} catch (e) {
|
||||
if (e instanceof WorkflowVersionError) {
|
||||
// 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 { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
||||
import { ImageComparisonHover } from 'features/gallery/components/ImageViewer/ImageComparisonHover';
|
||||
import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide';
|
||||
import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider';
|
||||
@ -15,7 +16,11 @@ const selector = createMemoizedSelector(selectGallerySlice, (gallerySlice) => {
|
||||
return { firstImage, secondImage };
|
||||
});
|
||||
|
||||
export const ImageComparison = memo(() => {
|
||||
type Props = {
|
||||
containerDims: Dimensions;
|
||||
};
|
||||
|
||||
export const ImageComparison = memo(({ containerDims }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const comparisonMode = useAppSelector((s) => s.gallery.comparisonMode);
|
||||
const { firstImage, secondImage } = useAppSelector(selector);
|
||||
@ -26,15 +31,17 @@ export const ImageComparison = memo(() => {
|
||||
}
|
||||
|
||||
if (comparisonMode === 'slider') {
|
||||
return <ImageComparisonSlider firstImage={firstImage} secondImage={secondImage} />;
|
||||
return <ImageComparisonSlider containerDims={containerDims} firstImage={firstImage} secondImage={secondImage} />;
|
||||
}
|
||||
|
||||
if (comparisonMode === 'side-by-side') {
|
||||
return <ImageComparisonSideBySide firstImage={firstImage} secondImage={secondImage} />;
|
||||
return (
|
||||
<ImageComparisonSideBySide containerDims={containerDims} firstImage={firstImage} secondImage={secondImage} />
|
||||
);
|
||||
}
|
||||
|
||||
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 { useBoolean } from 'common/hooks/useBoolean';
|
||||
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';
|
||||
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
||||
import { STAGE_BG_DATAURL } from 'features/controlLayers/util/renderers';
|
||||
import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel';
|
||||
import { memo, useMemo, useRef } from 'react';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The first image to compare
|
||||
*/
|
||||
firstImage: ImageDTO;
|
||||
/**
|
||||
* The second image to compare
|
||||
*/
|
||||
secondImage: ImageDTO;
|
||||
};
|
||||
import type { ComparisonProps } from './common';
|
||||
import { fitDimsToContainer, getSecondImageDims } from './common';
|
||||
|
||||
export const ImageComparisonHover = memo(({ firstImage, secondImage }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
export const ImageComparisonHover = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
|
||||
const comparisonFit = useAppSelector((s) => s.gallery.comparisonFit);
|
||||
const [isMouseOver, setIsMouseOver] = useState(false);
|
||||
const onMouseOver = useCallback(() => {
|
||||
setIsMouseOver(true);
|
||||
}, []);
|
||||
const onMouseOut = useCallback(() => {
|
||||
setIsMouseOver(false);
|
||||
}, []);
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mouseOver = useBoolean(false);
|
||||
const fittedDims = useMemo<Dimensions>(
|
||||
() => fitDimsToContainer(containerDims, firstImage),
|
||||
[containerDims, firstImage]
|
||||
);
|
||||
const compareImageDims = useMemo<Dimensions>(
|
||||
() => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage),
|
||||
[comparisonFit, fittedDims, firstImage, secondImage]
|
||||
);
|
||||
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}
|
||||
<Flex
|
||||
id="image-comparison-wrapper"
|
||||
w="full"
|
||||
h="full"
|
||||
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"
|
||||
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"
|
||||
userSelect="none"
|
||||
overflow="hidden"
|
||||
borderRadius="base"
|
||||
>
|
||||
<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}
|
||||
id="image-comparison-hover-first-image"
|
||||
src={firstImage.image_url}
|
||||
fallbackSrc={firstImage.thumbnail_url}
|
||||
w={fittedDims.width}
|
||||
h={fittedDims.height}
|
||||
maxW="full"
|
||||
maxH="full"
|
||||
objectFit="cover"
|
||||
objectPosition="top left"
|
||||
/>
|
||||
<Text
|
||||
<ImageComparisonLabel type="first" opacity={mouseOver.isTrue ? 0 : 1} />
|
||||
|
||||
<Box
|
||||
id="image-comparison-hover-second-image-container"
|
||||
position="absolute"
|
||||
bottom={4}
|
||||
insetInlineStart={4}
|
||||
textOverflow="clip"
|
||||
whiteSpace="nowrap"
|
||||
filter={DROP_SHADOW}
|
||||
color="base.50"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
overflow="hidden"
|
||||
opacity={mouseOver.isTrue ? 1 : 0}
|
||||
transitionDuration="0.2s"
|
||||
transitionProperty="common"
|
||||
>
|
||||
{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"
|
||||
/>
|
||||
<Box
|
||||
id="image-comparison-hover-bg"
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
backgroundImage={STAGE_BG_DATAURL}
|
||||
backgroundRepeat="repeat"
|
||||
opacity={0.2}
|
||||
/>
|
||||
<Image
|
||||
position="relative"
|
||||
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>
|
||||
);
|
||||
|
@ -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 { DROP_SHADOW } from 'features/gallery/components/ImageViewer/useImageViewer';
|
||||
import { Flex, Image } from '@invoke-ai/ui-library';
|
||||
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 { memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
|
||||
import { Panel, PanelGroup } from 'react-resizable-panels';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The first image to compare
|
||||
*/
|
||||
firstImage: ImageDTO;
|
||||
/**
|
||||
* The second image to compare
|
||||
*/
|
||||
secondImage: ImageDTO;
|
||||
};
|
||||
|
||||
export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: ComparisonProps) => {
|
||||
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
|
||||
const onDoubleClickHandle = useCallback(() => {
|
||||
if (!panelGroupRef.current) {
|
||||
@ -44,19 +31,9 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Prop
|
||||
src={firstImage.image_url}
|
||||
fallbackSrc={firstImage.thumbnail_url}
|
||||
objectFit="contain"
|
||||
borderRadius="base"
|
||||
/>
|
||||
<Text
|
||||
position="absolute"
|
||||
bottom={4}
|
||||
insetInlineStart={4}
|
||||
textOverflow="clip"
|
||||
whiteSpace="nowrap"
|
||||
filter={DROP_SHADOW}
|
||||
color="base.50"
|
||||
userSelect="none"
|
||||
>
|
||||
{t('gallery.viewerImage')}
|
||||
</Text>
|
||||
<ImageComparisonLabel type="first" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Panel>
|
||||
@ -78,19 +55,9 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Prop
|
||||
src={secondImage.image_url}
|
||||
fallbackSrc={secondImage.thumbnail_url}
|
||||
objectFit="contain"
|
||||
borderRadius="base"
|
||||
/>
|
||||
<Text
|
||||
position="absolute"
|
||||
bottom={4}
|
||||
insetInlineStart={4}
|
||||
textOverflow="clip"
|
||||
whiteSpace="nowrap"
|
||||
filter={DROP_SHADOW}
|
||||
color="base.50"
|
||||
userSelect="none"
|
||||
>
|
||||
{t('gallery.compareImage')}
|
||||
</Text>
|
||||
<ImageComparisonLabel type="second" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Panel>
|
||||
|
@ -1,15 +1,14 @@
|
||||
import { Box, Flex, Icon, Image, Text } from '@invoke-ai/ui-library';
|
||||
import { useMeasure } from '@reactuses/core';
|
||||
import { Box, Flex, Icon, Image } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { preventDefault } from 'common/util/stopPropagation';
|
||||
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
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 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_LEFT_INITIAL_PX = `calc(${INITIAL_POS} - ${HANDLE_HITBOX / 2}px)`;
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The first image to compare
|
||||
*/
|
||||
firstImage: ImageDTO;
|
||||
/**
|
||||
* The second image to compare
|
||||
*/
|
||||
secondImage: ImageDTO;
|
||||
};
|
||||
|
||||
export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
export const ImageComparisonSlider = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
|
||||
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
|
||||
const [width, setWidth] = useState(INITIAL_POS);
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
// If the container size is not provided, use an internal ref and measure - can cause flicker on mount tho
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerSize] = useMeasure(containerRef);
|
||||
// To manage aspect ratios, we need to know the size of the container
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
// To keep things smooth, we use RAF to update the handle position & gate it to 60fps
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastMoveTimeRef = useRef<number>(0);
|
||||
|
||||
const fittedSize = useMemo<Dimensions>(() => {
|
||||
// Fit the first image to the container
|
||||
if (containerSize.width === 0 || containerSize.height === 0) {
|
||||
return { width: firstImage.width, height: firstImage.height };
|
||||
}
|
||||
const targetAspectRatio = containerSize.width / containerSize.height;
|
||||
const imageAspectRatio = firstImage.width / firstImage.height;
|
||||
const fittedDims = useMemo<Dimensions>(
|
||||
() => fitDimsToContainer(containerDims, firstImage),
|
||||
[containerDims, firstImage]
|
||||
);
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
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 compareImageDims = useMemo<Dimensions>(
|
||||
() => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage),
|
||||
[comparisonFit, fittedDims, firstImage, secondImage]
|
||||
);
|
||||
|
||||
const updateHandlePos = useCallback((clientX: number) => {
|
||||
if (!handleRef.current || !imageContainerRef.current) {
|
||||
@ -122,16 +90,7 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) =
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={containerRef}
|
||||
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
|
||||
id="image-comparison-wrapper"
|
||||
w="full"
|
||||
@ -146,8 +105,8 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) =
|
||||
ref={imageContainerRef}
|
||||
position="relative"
|
||||
id="image-comparison-image-container"
|
||||
w={fittedSize.width}
|
||||
h={fittedSize.height}
|
||||
w={fittedDims.width}
|
||||
h={fittedDims.height}
|
||||
maxW="full"
|
||||
maxH="full"
|
||||
userSelect="none"
|
||||
@ -170,24 +129,14 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) =
|
||||
id="image-comparison-second-image"
|
||||
src={secondImage.image_url}
|
||||
fallbackSrc={secondImage.thumbnail_url}
|
||||
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}
|
||||
w={compareImageDims.width}
|
||||
h={compareImageDims.height}
|
||||
maxW={fittedDims.width}
|
||||
maxH={fittedDims.height}
|
||||
objectFit={comparisonFit}
|
||||
objectPosition="top left"
|
||||
/>
|
||||
<Text
|
||||
position="absolute"
|
||||
bottom={4}
|
||||
insetInlineEnd={4}
|
||||
textOverflow="clip"
|
||||
whiteSpace="nowrap"
|
||||
filter={DROP_SHADOW}
|
||||
color="base.50"
|
||||
>
|
||||
{t('gallery.compareImage')}
|
||||
</Text>
|
||||
<ImageComparisonLabel type="second" />
|
||||
<Box
|
||||
id="image-comparison-first-image-container"
|
||||
position="absolute"
|
||||
@ -202,22 +151,12 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) =
|
||||
id="image-comparison-first-image"
|
||||
src={firstImage.image_url}
|
||||
fallbackSrc={firstImage.thumbnail_url}
|
||||
w={fittedSize.width}
|
||||
h={fittedSize.height}
|
||||
w={fittedDims.width}
|
||||
h={fittedDims.height}
|
||||
objectFit="cover"
|
||||
objectPosition="top left"
|
||||
/>
|
||||
<Text
|
||||
position="absolute"
|
||||
bottom={4}
|
||||
insetInlineStart={4}
|
||||
textOverflow="clip"
|
||||
whiteSpace="nowrap"
|
||||
filter={DROP_SHADOW}
|
||||
color="base.50"
|
||||
>
|
||||
{t('gallery.viewerImage')}
|
||||
</Text>
|
||||
<ImageComparisonLabel type="first" />
|
||||
</Box>
|
||||
<Flex
|
||||
id="image-comparison-handle"
|
||||
|
@ -3,12 +3,14 @@ import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { CompareToolbar } from 'features/gallery/components/ImageViewer/CompareToolbar';
|
||||
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
|
||||
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 type { InvokeTabName } from 'features/ui/store/tabMap';
|
||||
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { useImageViewer } from './useImageViewer';
|
||||
|
||||
const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows'];
|
||||
|
||||
@ -27,6 +29,7 @@ export const ImageViewer = memo(() => {
|
||||
}
|
||||
return isOpen;
|
||||
}, [isOpen, isViewerEnabled, workflowsMode, activeTabName]);
|
||||
const [containerRef, containerDims] = useMeasure<HTMLDivElement>();
|
||||
|
||||
useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]);
|
||||
useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]);
|
||||
@ -52,9 +55,9 @@ export const ImageViewer = memo(() => {
|
||||
>
|
||||
{isComparing && <CompareToolbar />}
|
||||
{!isComparing && <ViewerToolbar />}
|
||||
<Box w="full" h="full">
|
||||
<Box ref={containerRef} w="full" h="full">
|
||||
{!isComparing && <CurrentImagePreview />}
|
||||
{isComparing && <ImageComparison />}
|
||||
{isComparing && <ImageComparison containerDims={containerDims} />}
|
||||
</Box>
|
||||
</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 };
|
||||
};
|
||||
|
||||
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 BoardId = 'none' | (string & Record<never, never>);
|
||||
export type ComparisonMode = 'slider' | 'side-by-side' | 'hover';
|
||||
export type ComparisonFit = 'contain' | 'fill';
|
||||
|
||||
export type GalleryState = {
|
||||
selection: ImageDTO[];
|
||||
@ -23,6 +24,6 @@ export type GalleryState = {
|
||||
alwaysShowImageSizeBadge: boolean;
|
||||
imageToCompare: ImageDTO | null;
|
||||
comparisonMode: ComparisonMode;
|
||||
comparisonFit: 'contain' | 'fill';
|
||||
comparisonFit: ComparisonFit;
|
||||
isImageViewerOpen: boolean;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user