diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index f2210e4c68..f5189f23df 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -59,8 +59,10 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/inter": "^5.0.18", + "@img-comparison-slider/react": "^8.0.2", "@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", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 64189f0d82..d805591721 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -29,12 +29,18 @@ dependencies: '@fontsource-variable/inter': specifier: ^5.0.18 version: 5.0.18 + '@img-comparison-slider/react': + specifier: ^8.0.2 + version: 8.0.2 '@invoke-ai/ui-library': specifier: ^0.0.25 version: 0.0.25(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.0.18)(@internationalized/date@3.5.3)(@types/react@18.3.1)(i18next@23.11.3)(react-dom@18.3.1)(react@18.3.1) '@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) @@ -3544,6 +3550,12 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} dev: true + /@img-comparison-slider/react@8.0.2: + resolution: {integrity: sha512-Him0yhbXpMXdnV6R3XE3LiXcMRhSXFMsbk6I7ct5HxO2YpK/BAGz3ub+7+akJRnK2XI7c3vQqvoIE507N1K4SA==} + dependencies: + img-comparison-slider: 8.0.6 + dev: false + /@internationalized/date@3.5.3: resolution: {integrity: sha512-X9bi8NAEHAjD8yzmPYT2pdJsbe+tYSEBAfowtlxJVJdZR3aK8Vg7ZUT1Fm5M47KLzp/M1p1VwAaeSma3RT7biw==} dependencies: @@ -3982,6 +3994,18 @@ 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: @@ -9223,6 +9247,10 @@ packages: engines: {node: '>= 4'} dev: true + /img-comparison-slider@8.0.6: + resolution: {integrity: sha512-ej4de7mWyjcXZvDgHq8K2a/dG8Vv+qYTdUjZa3cVILf316rLtDrHyGbh9fPvixmAFgbs30zTLfmaRDa7abjtzw==} + dev: false + /immer@10.1.1: resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} dev: false @@ -9668,6 +9696,11 @@ 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==} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageSliderComparison.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageSliderComparison.tsx new file mode 100644 index 0000000000..2cb5034b30 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageSliderComparison.tsx @@ -0,0 +1,77 @@ +import { ImgComparisonSlider } from '@img-comparison-slider/react'; +import { Flex, Icon, Image, Text } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { atom } from 'nanostores'; +import { memo } from 'react'; +import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; +import { useMeasure } from 'react-use'; +import type { ImageDTO } from 'services/api/types'; + +const $compareWith = atom(null); + +export const ImageSliderComparison = memo(() => { + const [containerRef, containerDims] = useMeasure(); + const lastSelectedImage = useAppSelector(selectLastSelectedImage); + const imageToCompare = useAppSelector((s) => s.gallery.selection[0]); + // const imageToCompare = useStore($imageToCompare); + 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 ( + + Select images to compare + + ); + } + + return ( + + + + {imageA.image_name} + {imageB.image_name} + + + + + + + + ); +}); + +ImageSliderComparison.displayName = 'ImageSliderComparison'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageSliderComparison2.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageSliderComparison2.tsx new file mode 100644 index 0000000000..a6f441c7a4 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageSliderComparison2.tsx @@ -0,0 +1,148 @@ +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(null); + const imageAContainerRef = useRef(null); + const handleRef = useRef(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) => { + 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 ; + } + + if (!imageA || !imageB) { + return null; + } + + return ( + + + + + + + + + + + + + + + + + ); +}); + +ImageSliderComparison.displayName = 'ImageSliderComparison'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageSliderComparison3.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageSliderComparison3.tsx new file mode 100644 index 0000000000..a69a31e2c5 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageSliderComparison3.tsx @@ -0,0 +1,162 @@ +import { Box, Flex, Icon } from '@invoke-ai/ui-library'; +import { useMeasure } from '@reactuses/core'; +import { memo, useCallback, useMemo, useRef } from 'react'; +import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +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`; + +type Props = { + firstImage: ImageDTO; + secondImage: ImageDTO; +}; + +export const ImageSliderComparison = memo(({ firstImage, secondImage }: Props) => { + const secondImageContainerRef = useRef(null); + const handleRef = useRef(null); + const containerRef = useRef(null); + const [containerSize] = useMeasure(containerRef); + + const updateHandlePos = useCallback((clientX: number) => { + if (!secondImageContainerRef.current || !handleRef.current || !containerRef.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)); + secondImageContainerRef.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) => { + updateHandlePos(e.clientX); + window.addEventListener('mouseup', onMouseUp, { once: true }); + window.addEventListener('mousemove', onMouseMove); + }, + [onMouseMove, onMouseUp, updateHandlePos] + ); + + const fittedSize = useMemo(() => { + let width = containerSize.width; + let height = containerSize.height; + const aspectRatio = firstImage.width / firstImage.height; + if (firstImage.width > firstImage.height) { + width = firstImage.width; + height = width / aspectRatio; + } else { + height = firstImage.height; + width = height * aspectRatio; + } + return { width, height }; + }, [containerSize.height, containerSize.width, firstImage.height, firstImage.width]); + + console.log({ containerSize, fittedSize }); + + return ( + + + + + + + + + + + + + + + + ); +}); + +ImageSliderComparison.displayName = 'ImageSliderComparison'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index 7064e553dc..4de793ea43 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -1,5 +1,7 @@ import { Flex } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; +import CurrentImagePreview from 'features/gallery/components/ImageViewer/CurrentImagePreview'; +import { ImageSliderComparison } from 'features/gallery/components/ImageViewer/ImageSliderComparison3'; import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton'; import { ToggleProgressButton } from 'features/gallery/components/ImageViewer/ToggleProgressButton'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; @@ -9,7 +11,6 @@ import { memo, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import CurrentImageButtons from './CurrentImageButtons'; -import CurrentImagePreview from './CurrentImagePreview'; import { ViewerToggleMenu } from './ViewerToggleMenu'; const VIEWER_ENABLED_TABS: InvokeTabName[] = ['canvas', 'generation', 'workflows']; @@ -28,6 +29,11 @@ export const ImageViewer = memo(() => { useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]); useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]); + const { firstImage, secondImage } = useAppSelector((s) => { + const images = s.gallery.selection.slice(-2); + return { firstImage: images[0] ?? null, secondImage: images[0] ? images[1] ?? null : null }; + }); + if (!shouldShowViewer) { return null; } @@ -64,7 +70,8 @@ export const ImageViewer = memo(() => { - + {firstImage && !secondImage && } + {firstImage && secondImage && } ); });