From 449bc4dbe5caea7dd03b77b81fb01066dd59bd1c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 2 Jun 2024 10:02:33 +1000 Subject: [PATCH] feat(ui): abstract out and share logic between comparisons --- invokeai/frontend/web/package.json | 1 - invokeai/frontend/web/pnpm-lock.yaml | 20 -- .../listeners/workflowLoadRequested.ts | 2 +- .../web/src/common/hooks/useBoolean.ts | 21 ++ .../ImageViewer/ImageComparison.tsx | 15 +- .../ImageViewer/ImageComparisonHover.tsx | 179 ++++++++++-------- .../ImageViewer/ImageComparisonLabel.tsx | 33 ++++ .../ImageViewer/ImageComparisonSideBySide.tsx | 49 +---- .../ImageViewer/ImageComparisonSlider.tsx | 111 +++-------- .../components/ImageViewer/ImageViewer.tsx | 9 +- .../gallery/components/ImageViewer/common.ts | 57 ++++++ .../{useImageViewer.tsx => useImageViewer.ts} | 2 - .../web/src/features/gallery/store/types.ts | 3 +- 13 files changed, 260 insertions(+), 242 deletions(-) create mode 100644 invokeai/frontend/web/src/common/hooks/useBoolean.ts create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonLabel.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts rename invokeai/frontend/web/src/features/gallery/components/ImageViewer/{useImageViewer.tsx => useImageViewer.ts} (90%) diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 0211994f22..f2210e4c68 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -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", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index f9a3da4e39..64189f0d82 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -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==} diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts index 9ccd967464..2c0caa0ec9 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts @@ -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 diff --git a/invokeai/frontend/web/src/common/hooks/useBoolean.ts b/invokeai/frontend/web/src/common/hooks/useBoolean.ts new file mode 100644 index 0000000000..123e48cd75 --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useBoolean.ts @@ -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; +}; 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 73148851c3..ca740a5c16 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx @@ -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 ; + return ; } if (comparisonMode === 'side-by-side') { - return ; + return ( + + ); } if (comparisonMode === 'hover') { - return ; + return ; } }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonHover.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonHover.tsx index d00723a36e..a02e94b547 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonHover.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonHover.tsx @@ -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(null); + const mouseOver = useBoolean(false); + const fittedDims = useMemo( + () => fitDimsToContainer(containerDims, firstImage), + [containerDims, firstImage] + ); + const compareImageDims = useMemo( + () => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage), + [comparisonFit, fittedDims, firstImage, secondImage] + ); return ( - - + - - {t('gallery.viewerImage')} - - - + + - {t('gallery.compareImage')} - - - + + + + + + ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonLabel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonLabel.tsx new file mode 100644 index 0000000000..a5a40dfc9c --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonLabel.tsx @@ -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 ( + + {type === 'first' ? t('gallery.viewerImage') : t('gallery.compareImage')} + + ); +}); + +ImageComparisonLabel.displayName = 'ImageComparisonLabel'; 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 49f03fb9c8..8bac2bb45d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSideBySide.tsx @@ -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(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" /> - - {t('gallery.viewerImage')} - + @@ -78,19 +55,9 @@ export const ImageComparisonSideBySide = memo(({ firstImage, secondImage }: Prop src={secondImage.image_url} fallbackSrc={secondImage.thumbnail_url} objectFit="contain" + borderRadius="base" /> - - {t('gallery.compareImage')} - + 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 bda4c12eeb..8972af7d4f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonSlider.tsx @@ -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(null); - // 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); + // To manage aspect ratios, we need to know the size of the container 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 fittedSize = useMemo(() => { - // 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( + () => 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( + () => 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 ( - + - - {t('gallery.compareImage')} - + - - {t('gallery.viewerImage')} - + { } return isOpen; }, [isOpen, isViewerEnabled, workflowsMode, activeTabName]); + const [containerRef, containerDims] = useMeasure(); useHotkeys('z', onToggle, { enabled: isViewerEnabled }, [isViewerEnabled, onToggle]); useHotkeys('esc', onClose, { enabled: isViewerEnabled }, [isViewerEnabled, onClose]); @@ -52,9 +55,9 @@ export const ImageViewer = memo(() => { > {isComparing && } {!isComparing && } - + {!isComparing && } - {isComparing && } + {isComparing && } ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts new file mode 100644 index 0000000000..8d7f02c0fc --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts @@ -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 }; +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts similarity index 90% rename from invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx rename to invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts index 4232499c00..978fbc0cef 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts @@ -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))'; diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index 0b2618be65..a88715b0bd 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -8,6 +8,7 @@ export const IMAGE_LIMIT = 20; export type GalleryView = 'images' | 'assets'; export type BoardId = 'none' | (string & Record); 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; };