diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 640f7c0958..c476411d3f 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -380,7 +380,11 @@ "problemDeletingImagesDesc": "One or more images could not be deleted", "firstImage": "First Image", "secondImage": "Second Image", - "selectForCompare": "Select for Compare" + "selectForCompare": "Select for Compare", + "selectAnImageToCompare": "Select an Image to Compare", + "slider": "Slider", + "sideBySide": "Side-by-Side", + "swapImages": "Swap" }, "hotkeys": { "searchHotkeys": "Search Hotkeys", diff --git a/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx index 0b4ca90933..aa3a24209c 100644 --- a/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx +++ b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx @@ -19,6 +19,13 @@ function ThemeLocaleProvider({ children }: ThemeLocaleProviderProps) { return extendTheme({ ..._theme, direction, + shadows: { + ..._theme.shadows, + selectedForCompare: + '0px 0px 0px 1px var(--invoke-colors-base-900), 0px 0px 0px 4px var(--invoke-colors-green-400)', + hoverSelectedForCompare: + '0px 0px 0px 1px var(--invoke-colors-base-900), 0px 0px 0px 4px var(--invoke-colors-green-300)', + }, }); }, [direction]); diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx index 2712334e1e..f16aa3d4b4 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx @@ -35,6 +35,7 @@ type IAIDndImageProps = FlexProps & { draggableData?: TypesafeDraggableData; dropLabel?: ReactNode; isSelected?: boolean; + isSelectedForCompare?: boolean; thumbnail?: boolean; noContentFallback?: ReactElement; useThumbailFallback?: boolean; @@ -61,6 +62,7 @@ const IAIDndImage = (props: IAIDndImageProps) => { draggableData, dropLabel, isSelected = false, + isSelectedForCompare = false, thumbnail = false, noContentFallback = defaultNoContentFallback, uploadElement = defaultUploadElement, @@ -165,7 +167,11 @@ const IAIDndImage = (props: IAIDndImageProps) => { data-testid={dataTestId} /> {withMetadataOverlay && } - + )} {!imageDTO && !isUploadDisabled && ( diff --git a/invokeai/frontend/web/src/common/components/SelectionOverlay.tsx b/invokeai/frontend/web/src/common/components/SelectionOverlay.tsx index eb50a6b9d4..3e2ecca4ae 100644 --- a/invokeai/frontend/web/src/common/components/SelectionOverlay.tsx +++ b/invokeai/frontend/web/src/common/components/SelectionOverlay.tsx @@ -3,10 +3,17 @@ import { memo, useMemo } from 'react'; type Props = { isSelected: boolean; + isSelectedForCompare: boolean; isHovered: boolean; }; -const SelectionOverlay = ({ isSelected, isHovered }: Props) => { +const SelectionOverlay = ({ isSelected, isSelectedForCompare, isHovered }: Props) => { const shadow = useMemo(() => { + if (isSelectedForCompare && isHovered) { + return 'hoverSelectedForCompare'; + } + if (isSelectedForCompare && !isHovered) { + return 'selectedForCompare'; + } if (isSelected && isHovered) { return 'hoverSelected'; } @@ -17,7 +24,7 @@ const SelectionOverlay = ({ isSelected, isHovered }: Props) => { return 'hoverUnselected'; } return undefined; - }, [isHovered, isSelected]); + }, [isHovered, isSelected, isSelectedForCompare]); return ( { bottom={0} insetInlineStart={0} borderRadius="base" - opacity={isSelected ? 1 : 0.7} + opacity={isSelected || isSelectedForCompare ? 1 : 0.7} transitionProperty="common" transitionDuration="0.1s" pointerEvents="none" diff --git a/invokeai/frontend/web/src/features/dnd/types/index.ts b/invokeai/frontend/web/src/features/dnd/types/index.ts index f61090b6bd..f66fec0ea1 100644 --- a/invokeai/frontend/web/src/features/dnd/types/index.ts +++ b/invokeai/frontend/web/src/features/dnd/types/index.ts @@ -81,6 +81,10 @@ export type RemoveFromBoardDropData = BaseDropData & { export type SelectForCompareDropData = BaseDropData & { actionType: 'SELECT_FOR_COMPARE'; + context: { + firstImageName?: string | null; + secondImageName?: string | null; + }; }; export type TypesafeDroppableData = @@ -139,7 +143,7 @@ export type UseDraggableTypesafeReturnValue = Omit { +interface TypesafeActive extends Omit { data: React.MutableRefObject; } diff --git a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts index d8e9d98e10..ceca331725 100644 --- a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts +++ b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts @@ -33,7 +33,9 @@ export const isValidDrop = (overData?: TypesafeDroppableData | null, activeData? return ( payloadType === 'IMAGE_DTO' && activeData.id !== 'image-compare-first-image' && - activeData.id !== 'image-compare-second-image' + activeData.id !== 'image-compare-second-image' && + activeData.payload.imageDTO.image_name !== overData.context.firstImageName && + activeData.payload.imageDTO.image_name !== overData.context.secondImageName ); case 'ADD_TO_BOARD': { // If the board is the same, don't allow the drop diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 0509305192..f8c4f5ebcf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -162,7 +162,7 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps )} {isSelectedForAutoAdd && } - + { > {boardName} - + {t('unifiedCanvas.move')}} /> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx index 0c0e3da8bd..bc7e1bdb84 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx @@ -46,6 +46,11 @@ type SingleSelectionMenuItemsProps = { const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { const { imageDTO } = props; const optimalDimension = useAppSelector(selectOptimalDimension); + const maySelectForCompare = useAppSelector( + (s) => + s.gallery.imageToCompare?.image_name !== imageDTO.image_name && + s.gallery.selection.slice(-1)[0]?.image_name !== imageDTO.image_name + ); const dispatch = useAppDispatch(); const { t } = useTranslation(); const isCanvasEnabled = useFeatureStatus('canvas'); @@ -136,7 +141,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { } onClickCapture={handleDownloadImage}> {t('parameters.downloadImage')} - } onClickCapture={handleSelectImageForCompare}> + } isDisabled={!maySelectForCompare} onClickCapture={handleSelectImageForCompare}> {t('gallery.selectForCompare')} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index a43d10e4ca..812a042c8b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -46,6 +46,9 @@ const GalleryImage = (props: HoverableImageProps) => { const { t } = useTranslation(); const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId); const alwaysShowImageSizeBadge = useAppSelector((s) => s.gallery.alwaysShowImageSizeBadge); + const isSelectedForCompare = useAppSelector( + (s) => s.gallery.imageToCompare?.image_name === imageName && s.gallery.viewerMode === 'compare' + ); const { handleClick, isSelected, areMultiplesSelected } = useMultiselect(imageDTO); const customStarUi = useStore($customStarUI); @@ -152,6 +155,7 @@ const GalleryImage = (props: HoverableImageProps) => { imageDTO={imageDTO} draggableData={draggableData} isSelected={isSelected} + isSelectedForCompare={isSelectedForCompare} minSize={0} imageSx={imageSx} isDropDisabled={true} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index fd8d3c5f31..5de4f28d2a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -3,10 +3,10 @@ import { createSelector } from '@reduxjs/toolkit'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import IAIDndImage from 'common/components/IAIDndImage'; -import IAIDroppable from 'common/components/IAIDroppable'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import type { SelectForCompareDropData, TypesafeDraggableData } from 'features/dnd/types'; +import type { TypesafeDraggableData } from 'features/dnd/types'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; +import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewer/ImageComparisonDroppable'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import type { AnimationProps } from 'framer-motion'; @@ -23,11 +23,6 @@ const selectLastSelectedImageName = createSelector( (lastSelectedImage) => lastSelectedImage?.image_name ); -const droppableData: SelectForCompareDropData = { - id: 'current-image', - actionType: 'SELECT_FOR_COMPARE', -}; - const CurrentImagePreview = () => { const { t } = useTranslation(); const shouldShowImageDetails = useAppSelector((s) => s.ui.shouldShowImageDetails); @@ -85,7 +80,7 @@ const CurrentImagePreview = () => { dataTestId="image-preview" /> )} - + {shouldShowImageDetails && imageDTO && ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx index 4377658348..74acdfa13f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx @@ -1,12 +1,16 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import IAIDroppable from 'common/components/IAIDroppable'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import type { SelectForCompareDropData } from 'features/dnd/types'; import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide'; import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { PropsWithChildren } from 'react'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiImagesBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; const selector = createMemoizedSelector(selectGallerySlice, (gallerySlice) => { const firstImage = gallerySlice.selection.slice(-1)[0] ?? null; @@ -15,16 +19,21 @@ const selector = createMemoizedSelector(selectGallerySlice, (gallerySlice) => { }); export const ImageComparison = memo(() => { + const { t } = useTranslation(); const comparisonMode = useAppSelector((s) => s.gallery.comparisonMode); const { firstImage, secondImage } = useAppSelector(selector); if (!firstImage || !secondImage) { - return Select an image to compare; + return ( + + + + ); } if (comparisonMode === 'slider') { return ( - + ); @@ -32,7 +41,7 @@ export const ImageComparison = memo(() => { if (comparisonMode === 'side-by-side') { return ( - + ); @@ -41,12 +50,24 @@ export const ImageComparison = memo(() => { ImageComparison.displayName = 'ImageComparison'; -const droppableData: SelectForCompareDropData = { - id: 'image-comparison', - actionType: 'SELECT_FOR_COMPARE', -}; +type Props = PropsWithChildren<{ + firstImage: ImageDTO | null; + secondImage: ImageDTO | null; +}>; + +const ImageComparisonWrapper = memo((props: Props) => { + const droppableData = useMemo( + () => ({ + id: 'image-comparison', + actionType: 'SELECT_FOR_COMPARE', + context: { + firstImageName: props.firstImage?.image_name, + secondImageName: props.secondImage?.image_name, + }, + }), + [props.firstImage?.image_name, props.secondImage?.image_name] + ); -const ImageComparisonWrapper = memo((props: PropsWithChildren) => { return ( <> {props.children} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx new file mode 100644 index 0000000000..6f163f63cf --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx @@ -0,0 +1,33 @@ +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import IAIDroppable from 'common/components/IAIDroppable'; +import type { SelectForCompareDropData } from 'features/dnd/types'; +import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const selector = createMemoizedSelector(selectGallerySlice, (gallerySlice) => { + const firstImage = gallerySlice.selection.slice(-1)[0] ?? null; + const secondImage = gallerySlice.imageToCompare; + return { firstImage, secondImage }; +}); + +export const ImageComparisonDroppable = memo(() => { + const { t } = useTranslation(); + const { firstImage, secondImage } = useAppSelector(selector); + const droppableData = useMemo( + () => ({ + id: 'image-comparison', + actionType: 'SELECT_FOR_COMPARE', + context: { + firstImageName: firstImage?.image_name, + secondImageName: secondImage?.image_name, + }, + }), + [firstImage?.image_name, secondImage?.image_name] + ); + + return ; +}); + +ImageComparisonDroppable.displayName = 'ImageComparisonDroppable'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx index 0f9636a61c..6cddb175cd 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx @@ -51,7 +51,12 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Prop - + - + diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx index 3eacacf6e5..c7a411c07c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx @@ -9,7 +9,7 @@ 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 = 1; const HANDLE_WIDTH_PX = `${HANDLE_WIDTH}px`; const HANDLE_HITBOX = 20; const HANDLE_HITBOX_PX = `${HANDLE_HITBOX}px`; @@ -37,52 +37,11 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) = // If the container size is not provided, use an internal ref and measure - can cause flicker on mount tho const containerRef = useRef(null); const [containerSize] = useMeasure(containerRef); + const imageContainerRef = useRef(null); // To keep things smooth, we use RAF to update the handle position & gate it to 60fps const rafRef = useRef(null); const lastMoveTimeRef = useRef(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) => { - // 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(() => { // Fit the first image to the container if (containerSize.width === 0 || containerSize.height === 0) { @@ -110,6 +69,45 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) = return { width, height }; }, [containerSize, firstImage.height, firstImage.width]); + const updateHandlePos = useCallback((clientX: number) => { + if (!handleRef.current || !imageContainerRef.current) { + return; + } + lastMoveTimeRef.current = performance.now(); + const { x, width } = imageContainerRef.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)`); + }, []); + + 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) => { + // Update the handle position immediately on click + updateHandlePos(e.clientX); + window.addEventListener('mouseup', onMouseUp, { once: true }); + window.addEventListener('mousemove', onMouseMove); + }, + [onMouseMove, onMouseUp, updateHandlePos] + ); + useEffect( () => () => { if (rafRef.current !== null) { @@ -141,6 +139,7 @@ export const ImageComparisonSlider = memo(({ firstImage, secondImage }: Props) = justifyContent="center" > { + const { t } = useTranslation(); const dispatch = useAppDispatch(); const comparisonMode = useAppSelector((s) => s.gallery.comparisonMode); const setComparisonModeSlider = useCallback(() => { @@ -12,26 +14,24 @@ export const ImageComparisonToolbarButtons = memo(() => { const setComparisonModeSideBySide = useCallback(() => { dispatch(comparisonModeChanged('side-by-side')); }, [dispatch]); - const setComparisonModeOverlay = useCallback(() => { - dispatch(comparisonModeChanged('overlay')); + const swapImages = useCallback(() => { + dispatch(comparedImagesSwapped()); }, [dispatch]); return ( <> - + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToggleMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToggleMenu.tsx index db7b1f7b70..f5b02db2fc 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToggleMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToggleMenu.tsx @@ -8,8 +8,9 @@ import { PopoverContent, PopoverTrigger, Text, + useDisclosure, } from '@invoke-ai/ui-library'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretDownBold, PiCheckBold, PiEyeBold, PiImagesBold, PiPencilBold } from 'react-icons/pi'; @@ -17,6 +18,7 @@ import { useImageViewer } from './useImageViewer'; export const ViewerToggleMenu = () => { const { t } = useTranslation(); + const { isOpen, onOpen, onClose } = useDisclosure(); const { viewerMode, openEditor, openViewer, openCompare } = useImageViewer(); const icon = useMemo(() => { if (viewerMode === 'view') { @@ -40,9 +42,21 @@ export const ViewerToggleMenu = () => { return t('common.comparing'); } }, [t, viewerMode]); + const _openEditor = useCallback(() => { + openEditor(); + onClose(); + }, [onClose, openEditor]); + const _openViewer = useCallback(() => { + openViewer(); + onClose(); + }, [onClose, openViewer]); + const _openCompare = useCallback(() => { + openCompare(); + onClose(); + }, [onClose, openCompare]); return ( - + - -