import { Box, Flex, Icon, Image } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { preventDefault } from 'common/util/stopPropagation'; import { TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern'; import type { Dimensions } from 'features/controlLayers/store/types'; import { ImageComparisonLabel } from 'features/gallery/components/ImageViewer/ImageComparisonLabel'; import { selectComparisonFit } from 'features/gallery/store/gallerySelectors'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; import type { ComparisonProps } from './common'; import { DROP_SHADOW, fitDimsToContainer, getSecondImageDims } from './common'; 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 ImageComparisonSlider = memo(({ firstImage, secondImage, containerDims }: 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 const [left, setLeft] = useState(HANDLE_LEFT_INITIAL_PX); // How wide the first image is const [width, setWidth] = useState(INITIAL_POS); const handleRef = useRef(null); // 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 fittedDims = useMemo( () => fitDimsToContainer(containerDims, firstImage), [containerDims, firstImage] ); const compareImageDims = useMemo( () => getSecondImageDims(comparisonFit, fittedDims, firstImage, secondImage), [comparisonFit, fittedDims, firstImage, secondImage] ); 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) { cancelAnimationFrame(rafRef.current); } }, [] ); return ( ); }); ImageComparisonSlider.displayName = 'ImageComparisonSlider';