feat(ui): zhoosh image comparison ui

This commit is contained in:
psychedelicious
2025-07-30 21:01:29 +10:00
committed by Kent Keirsey
parent 6f8746040c
commit 3835fd2f72
7 changed files with 114 additions and 88 deletions

View File

@@ -60,7 +60,7 @@ export const CompareToolbar = memo(() => {
useRegisteredHotkeys({ id: 'nextComparisonMode', category: 'viewer', callback: nextMode, dependencies: [nextMode] });
return (
<Flex w="full" px={2} gap={2} bg="base.750" borderTopRadius="base" h={12}>
<Flex w="full" justifyContent="center" h={8}>
<Flex flex={1} justifyContent="center">
<Flex marginInlineEnd="auto" alignItems="center">
<IconButton
@@ -85,7 +85,7 @@ export const CompareToolbar = memo(() => {
</Flex>
</Flex>
<Flex flex={1} justifyContent="center">
<ButtonGroup variant="outline" alignItems="center">
<ButtonGroup size="sm" variant="outline" alignItems="center">
<Button
flexShrink={0}
onClick={setComparisonModeSlider}
@@ -117,6 +117,7 @@ export const CompareToolbar = memo(() => {
</Flex>
</Tooltip>
<Button
size="sm"
variant="link"
alignSelf="stretch"
px={2}

View File

@@ -1,32 +1,36 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { Box, Divider, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import type { ComparisonProps } from 'features/gallery/components/ImageViewer/common';
import { debounce } from 'es-toolkit';
import type { ComparisonWrapperProps } from 'features/gallery/components/ImageViewer/common';
import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common';
import { CompareToolbar } from 'features/gallery/components/ImageViewer/CompareToolbar';
import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewer/ImageComparisonDroppable';
import { ImageComparisonHover } from 'features/gallery/components/ImageViewer/ImageComparisonHover';
import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide';
import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider';
import { selectComparisonMode } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import { useMeasure } from 'react-use';
import { selectComparisonMode, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useImageDTO } from 'services/api/endpoints/images';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
const ImageComparisonContent = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
const ImageComparisonContent = memo(({ firstImage, secondImage, rect }: ComparisonWrapperProps) => {
const comparisonMode = useAppSelector(selectComparisonMode);
if (!firstImage || !secondImage) {
return null;
}
if (comparisonMode === 'slider') {
return <ImageComparisonSlider firstImage={firstImage} secondImage={secondImage} containerDims={containerDims} />;
return <ImageComparisonSlider firstImage={firstImage} secondImage={secondImage} rect={rect} />;
}
if (comparisonMode === 'side-by-side') {
return (
<ImageComparisonSideBySide firstImage={firstImage} secondImage={secondImage} containerDims={containerDims} />
);
return <ImageComparisonSideBySide firstImage={firstImage} secondImage={secondImage} rect={rect} />;
}
if (comparisonMode === 'hover') {
return <ImageComparisonHover firstImage={firstImage} secondImage={secondImage} containerDims={containerDims} />;
return <ImageComparisonHover firstImage={firstImage} secondImage={secondImage} rect={rect} />;
}
assert<Equals<never, typeof comparisonMode>>(false);
@@ -34,16 +38,51 @@ const ImageComparisonContent = memo(({ firstImage, secondImage, containerDims }:
ImageComparisonContent.displayName = 'ImageComparisonContent';
export const ImageComparison = memo(({ firstImage, secondImage }: Omit<ComparisonProps, 'containerDims'>) => {
const [containerRef, containerDims] = useMeasure<HTMLDivElement>();
export const ImageComparison = memo(() => {
const lastSelectedImageName = useAppSelector(selectLastSelectedImage);
const lastSelectedImageDTO = useImageDTO(lastSelectedImageName);
const comparisonImageName = useAppSelector(selectImageToCompare);
const comparisonImageDTO = useImageDTO(comparisonImageName);
const [rect, setRect] = useState<DOMRect | null>(null);
const ref = useRef<HTMLDivElement | null>(null);
// Ref callback runs synchronously when the DOM node is attached, ensuring we have a measurement before
// the comparison content is rendered.
const measureNode = useCallback((node: HTMLDivElement) => {
if (node) {
ref.current = node;
const boundingRect = node.getBoundingClientRect();
setRect(boundingRect);
}
}, []);
useLayoutEffect(() => {
const el = ref.current;
if (!el) {
return;
}
const measureRect = debounce(() => {
const boundingRect = el.getBoundingClientRect();
setRect(boundingRect);
}, 300);
const observer = new ResizeObserver(measureRect);
observer.observe(el);
return () => {
observer.disconnect();
};
}, []);
return (
<Flex flexDir="column" w="full" h="full" position="relative">
<Flex flexDir="column" w="full" h="full" overflow="hidden" gap={2} position="relative">
<CompareToolbar />
<Box ref={containerRef} w="full" h="full" p={2} overflow="hidden">
<ImageComparisonContent firstImage={firstImage} secondImage={secondImage} containerDims={containerDims} />
</Box>
<ImageComparisonDroppable />
<Divider />
<Flex w="full" h="full" position="relative">
<Box ref={measureNode} w="full" h="full" overflow="hidden">
<ImageComparisonContent firstImage={lastSelectedImageDTO} secondImage={comparisonImageDTO} rect={rect} />
</Box>
<ImageComparisonDroppable />
</Flex>
</Flex>
);
});

View File

@@ -11,14 +11,16 @@ import { memo, useMemo, useRef } from 'react';
import type { ComparisonProps } from './common';
import { fitDimsToContainer, getSecondImageDims } from './common';
export const ImageComparisonHover = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
export const ImageComparisonHover = memo(({ firstImage, secondImage, rect }: ComparisonProps) => {
const comparisonFit = useAppSelector(selectComparisonFit);
const imageContainerRef = useRef<HTMLDivElement>(null);
const mouseOver = useBoolean(false);
const fittedDims = useMemo<Dimensions>(
() => fitDimsToContainer(containerDims, firstImage),
[containerDims, firstImage]
);
const fittedDims = useMemo<Dimensions>(() => {
if (!rect) {
return { width: 0, height: 0 };
}
return fitDimsToContainer(rect, firstImage);
}, [firstImage, rect]);
const compareImageDims = useMemo<Dimensions>(
() => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage),
[comparisonFit, fittedDims, firstImage, secondImage]

View File

@@ -19,7 +19,7 @@ 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 ImageComparisonSlider = memo(({ firstImage, secondImage, containerDims }: ComparisonProps) => {
export const ImageComparisonSlider = memo(({ firstImage, secondImage, rect }: ComparisonProps) => {
const comparisonFit = useAppSelector(selectComparisonFit);
// How far the handle is from the left - this will be a CSS calculation that takes into account the handle width
@@ -33,10 +33,12 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage, containerD
const rafRef = useRef<number | null>(null);
const lastMoveTimeRef = useRef<number>(0);
const fittedDims = useMemo<Dimensions>(
() => fitDimsToContainer(containerDims, firstImage),
[containerDims, firstImage]
);
const fittedDims = useMemo<Dimensions>(() => {
if (!rect) {
return { width: 0, height: 0 };
}
return fitDimsToContainer(rect, firstImage);
}, [firstImage, rect]);
const compareImageDims = useMemo<Dimensions>(
() => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage),

View File

@@ -1,42 +1,36 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectImageToCompare } from 'features/gallery/components/ImageViewer/common';
import { setComparisonImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview';
import { ImageComparison } from 'features/gallery/components/ImageViewer/ImageComparison';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useImageDTO } from 'services/api/endpoints/images';
// type Props = {
// closeButton?: ReactNode;
// };
import { ViewerToolbar } from './ViewerToolbar';
// const useFocusRegionOptions = {
// focusOnMount: true,
// };
// const FOCUS_REGION_STYLES: SystemStyleObject = {
// display: 'flex',
// width: 'full',
// height: 'full',
// position: 'absolute',
// flexDirection: 'column',
// inset: 0,
// alignItems: 'center',
// justifyContent: 'center',
// overflow: 'hidden',
// };
const dndTargetData = setComparisonImageDndTarget.getData();
export const ImageViewer = memo(() => {
const { t } = useTranslation();
const lastSelectedImageName = useAppSelector(selectLastSelectedImage);
const lastSelectedImageDTO = useImageDTO(lastSelectedImageName);
const comparisonImageName = useAppSelector(selectImageToCompare);
const comparisonImageDTO = useImageDTO(comparisonImageName);
if (lastSelectedImageDTO && comparisonImageDTO) {
return <ImageComparison firstImage={lastSelectedImageDTO} secondImage={comparisonImageDTO} />;
}
return <CurrentImagePreview imageDTO={lastSelectedImageDTO} />;
return (
<Flex flexDir="column" w="full" h="full" overflow="hidden" gap={2} position="relative">
<ViewerToolbar />
<Divider />
<Flex w="full" h="full" position="relative">
<CurrentImagePreview imageDTO={lastSelectedImageDTO} />
<DndDropTarget
dndTarget={setComparisonImageDndTarget}
dndTargetData={dndTargetData}
label={t('gallery.selectForCompare')}
/>
</Flex>
</Flex>
);
});
ImageViewer.displayName = 'ImageViewer';

View File

@@ -1,42 +1,24 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import type { SetComparisonImageDndTargetData } from 'features/dnd/dnd';
import { setComparisonImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { selectImageToCompare, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { memo } from 'react';
import { ImageViewerContextProvider } from './context';
import { ImageComparison } from './ImageComparison';
import { ImageViewer } from './ImageViewer';
import { ViewerToolbar } from './ViewerToolbar';
const selectIsComparing = createSelector(
[selectLastSelectedImage, selectImageToCompare],
(lastSelectedImage, imageToCompare) => !!lastSelectedImage && !!imageToCompare
);
export const ImageViewerPanel = memo(() => {
const { t } = useTranslation();
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const imageToCompare = useAppSelector(selectImageToCompare);
// Only show drop target when we have a selected image but no comparison image yet
const shouldShowDropTarget = lastSelectedImage && !imageToCompare;
const dndTargetData = useMemo<SetComparisonImageDndTargetData>(() => setComparisonImageDndTarget.getData(), []);
const isComparing = useAppSelector(selectIsComparing);
return (
<ImageViewerContextProvider>
<Flex flexDir="column" w="full" h="full" overflow="hidden" gap={2} position="relative">
<ViewerToolbar />
<Divider />
<Flex w="full" h="full" position="relative">
<ImageViewer />
{shouldShowDropTarget && (
<DndDropTarget
dndTarget={setComparisonImageDndTarget}
dndTargetData={dndTargetData}
label={t('gallery.selectForCompare')}
/>
)}
</Flex>
</Flex>
{!isComparing && <ImageViewer />}
{isComparing && <ImageComparison />}
</ImageViewerContextProvider>
);
});

View File

@@ -7,10 +7,16 @@ 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 ComparisonWrapperProps = {
firstImage: ImageDTO | null;
secondImage: ImageDTO | null;
rect: DOMRect | null;
};
export type ComparisonProps = {
firstImage: ImageDTO;
secondImage: ImageDTO;
containerDims: Dimensions;
rect: DOMRect | null;
};
export const fitDimsToContainer = (containerDims: Dimensions, imageDims: Dimensions): Dimensions => {