mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): fix image comparison slider resizing/aspect ratio jank
This commit is contained in:
parent
7a4bbd092e
commit
1af53aed60
@ -1,74 +1,260 @@
|
|||||||
import { ImgComparisonSlider } from '@img-comparison-slider/react';
|
import { Box, Flex, Icon, Image, Text } from '@invoke-ai/ui-library';
|
||||||
import { Flex, Icon, Image, Text } from '@invoke-ai/ui-library';
|
import type { UseMeasureRect } from '@reactuses/core';
|
||||||
import { useAppSelector } from 'app/store/storeHooks';
|
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
||||||
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
|
import { STAGE_BG_DATAURL } from 'features/controlLayers/util/renderers';
|
||||||
import { atom } from 'nanostores';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { memo } from 'react';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
||||||
import { useMeasure } from 'react-use';
|
|
||||||
import type { ImageDTO } from 'services/api/types';
|
import type { ImageDTO } from 'services/api/types';
|
||||||
|
|
||||||
const $compareWith = atom<ImageDTO | null>(null);
|
const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0))';
|
||||||
|
const INITIAL_POS = '50%';
|
||||||
|
const HANDLE_WIDTH = 2;
|
||||||
|
const HANDLE_WIDTH_PX = `${HANDLE_WIDTH}px`;
|
||||||
|
const HANDLE_HITBOX = 20;
|
||||||
|
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)`;
|
||||||
|
|
||||||
export const ImageSliderComparison = memo(() => {
|
type Props = {
|
||||||
const [containerRef, containerDims] = useMeasure<HTMLDivElement>();
|
/**
|
||||||
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
|
* The first image to compare
|
||||||
const imageToCompare = useAppSelector((s) => s.gallery.selection[0]);
|
*/
|
||||||
// const imageToCompare = useStore($imageToCompare);
|
firstImage: ImageDTO;
|
||||||
const { imageA, imageB } = useAppSelector((s) => {
|
/**
|
||||||
const images = s.gallery.selection.slice(-2);
|
* The second image to compare
|
||||||
return { imageA: images[0] ?? null, imageB: images[1] ?? null };
|
*/
|
||||||
});
|
secondImage: ImageDTO;
|
||||||
|
/**
|
||||||
|
* The size of the container, required to fit the component correctly and manage aspect ratios.
|
||||||
|
*/
|
||||||
|
containerSize: UseMeasureRect;
|
||||||
|
};
|
||||||
|
|
||||||
if (!imageA || !imageB) {
|
export const ImageSliderComparison = memo(({ firstImage, secondImage, containerSize }: Props) => {
|
||||||
return (
|
const { t } = useTranslation();
|
||||||
<Flex w="full" h="full" maxW="full" maxH="full" alignItems="center" justifyContent="center" position="relative">
|
// How far the handle is from the left - this will be a CSS calculation that takes into account the handle width
|
||||||
<Text>Select images to compare</Text>
|
const [left, setLeft] = useState(HANDLE_LEFT_INITIAL_PX);
|
||||||
</Flex>
|
// 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);
|
||||||
|
// 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 updateHandlePos = useCallback(
|
||||||
|
(clientX: number) => {
|
||||||
|
if (!handleRef.current || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMoveTimeRef.current = performance.now();
|
||||||
|
const { x, width } = containerRef.current.getBoundingClientRect();
|
||||||
|
const rawHandlePos = ((clientX - x) * 100) / width;
|
||||||
|
const handleWidthPct = (HANDLE_WIDTH * 100) / width;
|
||||||
|
const newHandlePos = Math.min(100 - handleWidthPct, Math.max(0, rawHandlePos));
|
||||||
|
setWidth(`${newHandlePos}%`);
|
||||||
|
setLeft(`calc(${newHandlePos}% - ${HANDLE_HITBOX / 2}px)`);
|
||||||
|
},
|
||||||
|
[containerRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onMouseMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (rafRef.current === null && performance.now() > lastMoveTimeRef.current + 1000 / 60) {
|
||||||
|
rafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
updateHandlePos(e.clientX);
|
||||||
|
rafRef.current = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateHandlePos]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMouseUp = useCallback(() => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
}, [onMouseMove]);
|
||||||
|
|
||||||
|
const onMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
// Update the handle position immediately on click
|
||||||
|
updateHandlePos(e.clientX);
|
||||||
|
window.addEventListener('mouseup', onMouseUp, { once: true });
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
},
|
||||||
|
[onMouseMove, onMouseUp, updateHandlePos]
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (firstImage.width <= containerSize.width && firstImage.height <= containerSize.height) {
|
||||||
|
return { width: firstImage.width, height: firstImage.height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let width: number;
|
||||||
|
let height: number;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (rafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Flex w="full" h="full" maxW="full" maxH="full" position="relative" alignItems="center" justifyContent="center">
|
||||||
<Flex
|
<Flex
|
||||||
ref={containerRef}
|
id="image-comparison-container"
|
||||||
w="full"
|
w="full"
|
||||||
h="full"
|
h="full"
|
||||||
maxW="full"
|
maxW="full"
|
||||||
maxH="full"
|
maxH="full"
|
||||||
|
position="absolute"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
position="relative"
|
|
||||||
>
|
>
|
||||||
<Flex top={0} right={0} bottom={0} left={0} position="absolute" alignItems="center" justifyContent="center">
|
<Box
|
||||||
<ImgComparisonSlider>
|
ref={containerRef}
|
||||||
<Image
|
position="relative"
|
||||||
slot="first"
|
id="image-comparison-second-image-container"
|
||||||
src={imageA.image_url}
|
w={fittedSize.width}
|
||||||
alt={imageA.image_name}
|
h={fittedSize.height}
|
||||||
w="full"
|
maxW="full"
|
||||||
h="full"
|
maxH="full"
|
||||||
maxW={containerDims.width}
|
userSelect="none"
|
||||||
maxH={containerDims.height}
|
overflow="hidden"
|
||||||
backdropFilter="blur(20%)"
|
borderRadius="base"
|
||||||
objectPosition="top left"
|
>
|
||||||
objectFit="contain"
|
<Box
|
||||||
|
id="image-comparison-bg"
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
backgroundImage={STAGE_BG_DATAURL}
|
||||||
|
backgroundRepeat="repeat"
|
||||||
|
opacity={0.2}
|
||||||
|
zIndex={-1}
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
slot="second"
|
src={secondImage.image_url}
|
||||||
src={imageB.image_url}
|
fallbackSrc={secondImage.thumbnail_url}
|
||||||
alt={imageB.image_name}
|
w={secondImage.width}
|
||||||
w="full"
|
h={secondImage.height}
|
||||||
h="full"
|
maxW={fittedSize.width}
|
||||||
maxW={containerDims.width}
|
maxH={fittedSize.height}
|
||||||
maxH={containerDims.height}
|
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
objectPosition="top left"
|
objectPosition="top left"
|
||||||
/>
|
/>
|
||||||
<Flex slot="handle" gap={4}>
|
<Text
|
||||||
|
position="absolute"
|
||||||
|
bottom={4}
|
||||||
|
insetInlineEnd={4}
|
||||||
|
textOverflow="clip"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
filter={DROP_SHADOW}
|
||||||
|
color="base.50"
|
||||||
|
>
|
||||||
|
{t('gallery.secondImage')}
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
id="image-comparison-first-image-container"
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
w={width}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={firstImage.image_url}
|
||||||
|
fallbackSrc={firstImage.thumbnail_url}
|
||||||
|
w={fittedSize.width}
|
||||||
|
h={fittedSize.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.firstImage')}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Flex
|
||||||
|
id="image-comparison-handle"
|
||||||
|
ref={handleRef}
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
bottom={0}
|
||||||
|
left={left}
|
||||||
|
w={HANDLE_HITBOX_PX}
|
||||||
|
tabIndex={-1}
|
||||||
|
cursor="ew-resize"
|
||||||
|
filter={DROP_SHADOW}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
id="image-comparison-handle-divider"
|
||||||
|
w={HANDLE_WIDTH_PX}
|
||||||
|
h="full"
|
||||||
|
bg="base.50"
|
||||||
|
shadow="dark-lg"
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={HANDLE_INNER_LEFT_PX}
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
id="image-comparison-handle-icons"
|
||||||
|
gap={4}
|
||||||
|
position="absolute"
|
||||||
|
left="50%"
|
||||||
|
top="50%"
|
||||||
|
transform="translate(-50%, 0)"
|
||||||
|
filter={DROP_SHADOW}
|
||||||
|
>
|
||||||
<Icon as={PiCaretLeftBold} />
|
<Icon as={PiCaretLeftBold} />
|
||||||
<Icon as={PiCaretRightBold} />
|
<Icon as={PiCaretRightBold} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</ImgComparisonSlider>
|
</Flex>
|
||||||
|
<Box
|
||||||
|
id="image-comparison-interaction-overlay"
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
left={0}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
userSelect="none"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
@ -1,148 +0,0 @@
|
|||||||
import { Box, Flex, Icon, Image } from '@invoke-ai/ui-library';
|
|
||||||
import { useAppSelector } from 'app/store/storeHooks';
|
|
||||||
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
|
|
||||||
import { memo, useCallback, useRef } from 'react';
|
|
||||||
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
|
||||||
|
|
||||||
const INITIAL_POS = '50%';
|
|
||||||
const HANDLE_WIDTH = 2;
|
|
||||||
const HANDLE_WIDTH_PX = `${HANDLE_WIDTH}px`;
|
|
||||||
const HANDLE_HITBOX = 20;
|
|
||||||
const HANDLE_HITBOX_PX = `${HANDLE_HITBOX}px`;
|
|
||||||
const HANDLE_LEFT_INITIAL_PX = `calc(${INITIAL_POS} - ${HANDLE_HITBOX / 2}px)`;
|
|
||||||
const HANDLE_INNER_LEFT_INITIAL_PX = `${HANDLE_HITBOX / 2 - HANDLE_WIDTH / 2}px`;
|
|
||||||
|
|
||||||
export const ImageSliderComparison = memo(() => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const imageAContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const handleRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const updateHandlePos = useCallback((clientX: number) => {
|
|
||||||
if (!containerRef.current || !imageAContainerRef.current || !handleRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { x, width } = containerRef.current.getBoundingClientRect();
|
|
||||||
const rawHandlePos = ((clientX - x) * 100) / width;
|
|
||||||
const handleWidthPct = (HANDLE_WIDTH * 100) / width;
|
|
||||||
const newHandlePos = Math.min(100 - handleWidthPct, Math.max(0, rawHandlePos));
|
|
||||||
imageAContainerRef.current.style.width = `${newHandlePos}%`;
|
|
||||||
handleRef.current.style.left = `calc(${newHandlePos}% - ${HANDLE_HITBOX / 2}px)`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onMouseMove = useCallback(
|
|
||||||
(e: MouseEvent) => {
|
|
||||||
updateHandlePos(e.clientX);
|
|
||||||
},
|
|
||||||
[updateHandlePos]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onMouseUp = useCallback(() => {
|
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
|
||||||
}, [onMouseMove]);
|
|
||||||
|
|
||||||
const onMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
updateHandlePos(e.clientX);
|
|
||||||
window.addEventListener('mouseup', onMouseUp, { once: true });
|
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
|
||||||
},
|
|
||||||
[onMouseMove, onMouseUp, updateHandlePos]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { imageA, imageB } = useAppSelector((s) => {
|
|
||||||
const images = s.gallery.selection.slice(-2);
|
|
||||||
return { imageA: images[0] ?? null, imageB: images[1] ?? null };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (imageA && !imageB) {
|
|
||||||
return <CurrentImagePreview />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!imageA || !imageB) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
w="full"
|
|
||||||
h="full"
|
|
||||||
maxW="full"
|
|
||||||
maxH="full"
|
|
||||||
position="relative"
|
|
||||||
border="1px solid cyan"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
>
|
|
||||||
<Flex
|
|
||||||
ref={containerRef}
|
|
||||||
w={imageA.width}
|
|
||||||
h={imageA.height}
|
|
||||||
maxW="full"
|
|
||||||
maxH="full"
|
|
||||||
position="relative"
|
|
||||||
userSelect="none"
|
|
||||||
border="1px solid magenta"
|
|
||||||
objectFit="contain"
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
|
||||||
<Image src={imageB.image_url} objectFit="contain" objectPosition="top left" />
|
|
||||||
<Flex
|
|
||||||
ref={imageAContainerRef}
|
|
||||||
w={INITIAL_POS}
|
|
||||||
h={imageA.height}
|
|
||||||
position="absolute"
|
|
||||||
overflow="hidden"
|
|
||||||
objectFit="contain"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={imageA.image_url}
|
|
||||||
width={`${imageA.width}px`}
|
|
||||||
height={`${imageA.height}px`}
|
|
||||||
maxW="full"
|
|
||||||
maxH="full"
|
|
||||||
objectFit="none"
|
|
||||||
objectPosition="left"
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
<Flex
|
|
||||||
id="image-comparison-handle"
|
|
||||||
ref={handleRef}
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
bottom={0}
|
|
||||||
left={HANDLE_LEFT_INITIAL_PX}
|
|
||||||
w={HANDLE_HITBOX_PX}
|
|
||||||
tabIndex={-1}
|
|
||||||
cursor="ew-resize"
|
|
||||||
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0))"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
w={HANDLE_WIDTH_PX}
|
|
||||||
h="full"
|
|
||||||
bg="base.50"
|
|
||||||
shadow="dark-lg"
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
left={HANDLE_INNER_LEFT_INITIAL_PX}
|
|
||||||
/>
|
|
||||||
<Flex gap={4} position="absolute" left="50%" top="50%" transform="translate(-50%, 0)">
|
|
||||||
<Icon as={PiCaretLeftBold} />
|
|
||||||
<Icon as={PiCaretRightBold} />
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
right={0}
|
|
||||||
bottom={0}
|
|
||||||
left={0}
|
|
||||||
id="image-comparison-overlay"
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
userSelect="none"
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ImageSliderComparison.displayName = 'ImageSliderComparison';
|
|
@ -1,274 +0,0 @@
|
|||||||
import { Box, Flex, Icon, Image, Text } from '@invoke-ai/ui-library';
|
|
||||||
import { useMeasure, type UseMeasureRect } from '@reactuses/core';
|
|
||||||
import type { Dimensions } from 'features/canvas/store/canvasTypes';
|
|
||||||
import { STAGE_BG_DATAURL } from 'features/controlLayers/util/renderers';
|
|
||||||
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';
|
|
||||||
|
|
||||||
const DROP_SHADOW = 'drop-shadow(0px 0px 4px rgb(0, 0, 0))';
|
|
||||||
const INITIAL_POS = '50%';
|
|
||||||
const HANDLE_WIDTH = 2;
|
|
||||||
const HANDLE_WIDTH_PX = `${HANDLE_WIDTH}px`;
|
|
||||||
const HANDLE_HITBOX = 20;
|
|
||||||
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;
|
|
||||||
/**
|
|
||||||
* The size of the container, used for sizing.
|
|
||||||
* If not provided, an internal container will be used, but this can cause a flicker effect as the component is first rendered.
|
|
||||||
*/
|
|
||||||
containerSize?: UseMeasureRect;
|
|
||||||
/**
|
|
||||||
* The ref of the container, used for sizing.
|
|
||||||
* If not provided, an internal container will be used, but this can cause a flicker effect as the component is first rendered.
|
|
||||||
*/
|
|
||||||
containerRef?: React.RefObject<HTMLDivElement>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ImageSliderComparison = memo(
|
|
||||||
({ firstImage, secondImage, containerSize: containerSizeProp, containerRef: containerRefProp }: Props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// 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);
|
|
||||||
const containerRef = useMemo(() => containerRefProp ?? _containerRef, [containerRefProp, _containerRef]);
|
|
||||||
const containerSize = useMemo(() => containerSizeProp ?? _containerSize, [containerSizeProp, _containerSize]);
|
|
||||||
// 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 updateHandlePos = useCallback(
|
|
||||||
(clientX: number) => {
|
|
||||||
if (!handleRef.current || !containerRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastMoveTimeRef.current = performance.now();
|
|
||||||
const { x, width } = containerRef.current.getBoundingClientRect();
|
|
||||||
const rawHandlePos = ((clientX - x) * 100) / width;
|
|
||||||
const handleWidthPct = (HANDLE_WIDTH * 100) / width;
|
|
||||||
const newHandlePos = Math.min(100 - handleWidthPct, Math.max(0, rawHandlePos));
|
|
||||||
setWidth(`${newHandlePos}%`);
|
|
||||||
setLeft(`calc(${newHandlePos}% - ${HANDLE_HITBOX / 2}px)`);
|
|
||||||
},
|
|
||||||
[containerRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onMouseMove = useCallback(
|
|
||||||
(e: MouseEvent) => {
|
|
||||||
if (rafRef.current === null && performance.now() > lastMoveTimeRef.current + 1000 / 60) {
|
|
||||||
rafRef.current = window.requestAnimationFrame(() => {
|
|
||||||
updateHandlePos(e.clientX);
|
|
||||||
rafRef.current = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updateHandlePos]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onMouseUp = useCallback(() => {
|
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
|
||||||
}, [onMouseMove]);
|
|
||||||
|
|
||||||
const onMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
// Update the handle position immediately on click
|
|
||||||
updateHandlePos(e.clientX);
|
|
||||||
window.addEventListener('mouseup', onMouseUp, { once: true });
|
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
|
||||||
},
|
|
||||||
[onMouseMove, onMouseUp, updateHandlePos]
|
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (firstImage.width <= containerSize.width && firstImage.height <= containerSize.height) {
|
|
||||||
return { width: firstImage.width, height: firstImage.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
let width: number;
|
|
||||||
let height: number;
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (rafRef.current !== null) {
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex w="full" h="full" maxW="full" maxH="full" position="relative" alignItems="center" justifyContent="center">
|
|
||||||
<Flex
|
|
||||||
id="image-comparison-container"
|
|
||||||
ref={_containerRef}
|
|
||||||
w="full"
|
|
||||||
h="full"
|
|
||||||
maxW="full"
|
|
||||||
maxH="full"
|
|
||||||
position="relative"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
position="relative"
|
|
||||||
id="image-comparison-second-image-container"
|
|
||||||
w={fittedSize.width}
|
|
||||||
h={fittedSize.height}
|
|
||||||
maxW="full"
|
|
||||||
maxH="full"
|
|
||||||
userSelect="none"
|
|
||||||
overflow="hidden"
|
|
||||||
borderRadius="base"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
id="image-comparison-bg"
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
left={0}
|
|
||||||
right={0}
|
|
||||||
bottom={0}
|
|
||||||
backgroundImage={STAGE_BG_DATAURL}
|
|
||||||
backgroundRepeat="repeat"
|
|
||||||
opacity={0.2}
|
|
||||||
zIndex={-1}
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
src={secondImage.image_url}
|
|
||||||
fallbackSrc={secondImage.thumbnail_url}
|
|
||||||
w={secondImage.width}
|
|
||||||
h={secondImage.height}
|
|
||||||
maxW="full"
|
|
||||||
maxH="full"
|
|
||||||
objectFit="contain"
|
|
||||||
objectPosition="top left"
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
position="absolute"
|
|
||||||
bottom={4}
|
|
||||||
insetInlineEnd={4}
|
|
||||||
textOverflow="clip"
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
filter={DROP_SHADOW}
|
|
||||||
color="base.50"
|
|
||||||
>
|
|
||||||
{t('gallery.secondImage')}
|
|
||||||
</Text>
|
|
||||||
<Box
|
|
||||||
id="image-comparison-first-image-container"
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
left={0}
|
|
||||||
right={0}
|
|
||||||
bottom={0}
|
|
||||||
w={width}
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={firstImage.image_url}
|
|
||||||
fallbackSrc={firstImage.thumbnail_url}
|
|
||||||
w={fittedSize.width}
|
|
||||||
h={fittedSize.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.firstImage')}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Flex
|
|
||||||
id="image-comparison-handle"
|
|
||||||
ref={handleRef}
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
bottom={0}
|
|
||||||
left={left}
|
|
||||||
w={HANDLE_HITBOX_PX}
|
|
||||||
tabIndex={-1}
|
|
||||||
cursor="ew-resize"
|
|
||||||
filter={DROP_SHADOW}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
id="image-comparison-handle-divider"
|
|
||||||
w={HANDLE_WIDTH_PX}
|
|
||||||
h="full"
|
|
||||||
bg="base.50"
|
|
||||||
shadow="dark-lg"
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
left={HANDLE_INNER_LEFT_PX}
|
|
||||||
/>
|
|
||||||
<Flex
|
|
||||||
id="image-comparison-handle-icons"
|
|
||||||
gap={4}
|
|
||||||
position="absolute"
|
|
||||||
left="50%"
|
|
||||||
top="50%"
|
|
||||||
transform="translate(-50%, 0)"
|
|
||||||
filter={DROP_SHADOW}
|
|
||||||
>
|
|
||||||
<Icon as={PiCaretLeftBold} />
|
|
||||||
<Icon as={PiCaretRightBold} />
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
<Box
|
|
||||||
id="image-comparison-interaction-overlay"
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
right={0}
|
|
||||||
bottom={0}
|
|
||||||
left={0}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
userSelect="none"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
ImageSliderComparison.displayName = 'ImageSliderComparison';
|
|
@ -1,8 +1,8 @@
|
|||||||
import { Flex } from '@invoke-ai/ui-library';
|
import { Box, Flex } from '@invoke-ai/ui-library';
|
||||||
import { useMeasure } from '@reactuses/core';
|
import { useMeasure } from '@reactuses/core';
|
||||||
import { useAppSelector } from 'app/store/storeHooks';
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
|
import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview';
|
||||||
import { ImageSliderComparison } from 'features/gallery/components/ImageViewer/ImageSliderComparison3';
|
import { ImageSliderComparison } from 'features/gallery/components/ImageViewer/ImageSliderComparison';
|
||||||
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
|
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
|
||||||
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
|
import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton';
|
||||||
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
|
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
|
||||||
@ -43,7 +43,6 @@ export const ImageViewer = memo(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
ref={containerRef}
|
|
||||||
layerStyle="first"
|
layerStyle="first"
|
||||||
borderRadius="base"
|
borderRadius="base"
|
||||||
position="absolute"
|
position="absolute"
|
||||||
@ -74,15 +73,12 @@ export const ImageViewer = memo(() => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Box ref={containerRef} w="full" h="full">
|
||||||
{firstImage && !secondImage && <CurrentImagePreview />}
|
{firstImage && !secondImage && <CurrentImagePreview />}
|
||||||
{firstImage && secondImage && (
|
{firstImage && secondImage && (
|
||||||
<ImageSliderComparison
|
<ImageSliderComparison containerSize={containerSize} firstImage={firstImage} secondImage={secondImage} />
|
||||||
containerSize={containerSize}
|
|
||||||
containerRef={containerRef}
|
|
||||||
firstImage={firstImage}
|
|
||||||
secondImage={secondImage}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user