diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 0375f0f456..ce14674926 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2017,7 +2017,8 @@ "resetGenerationSettings": "Reset Generation Settings", "replaceCurrent": "Replace Current", "controlLayerEmptyState": "Upload an image, drag an image from the gallery onto this layer, pull the bounding box into this layer, or draw on the canvas to get started.", - "referenceImageEmptyState": "Upload an image, drag an image from the gallery onto this layer, or pull the bounding box into this layer to get started.", + "referenceImageEmptyStateWithCanvasOptions": "Upload an image, drag an image from the gallery onto this Reference Image or pull the bounding box into this Reference Image to get started.", + "referenceImageEmptyState": "Upload an image or drag an image from the gallery onto this Reference Image to get started.", "uploadOrDragAnImage": "Drag an image from the gallery or upload an image.", "imageNoise": "Image Noise", "denoiseLimit": "Denoise Limit", diff --git a/invokeai/frontend/web/src/common/hooks/useBoolean.ts b/invokeai/frontend/web/src/common/hooks/useBoolean.ts index ec68457ecd..9b571d31bc 100644 --- a/invokeai/frontend/web/src/common/hooks/useBoolean.ts +++ b/invokeai/frontend/web/src/common/hooks/useBoolean.ts @@ -73,7 +73,7 @@ export const useBoolean = (initialValue: boolean): UseBoolean => { }; }; -type UseDisclosure = { +export type UseDisclosure = { isOpen: boolean; open: () => void; close: () => void; diff --git a/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts b/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts index 6b45cb8554..4317fc01d9 100644 --- a/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts +++ b/invokeai/frontend/web/src/common/hooks/useFilterableOutsideClick.ts @@ -22,6 +22,8 @@ import { useCallback, useEffect, useRef } from 'react'; +type FilterFunction = (el: HTMLElement | SVGElement) => boolean; + export function useCallbackRef any>( callback: T | undefined, deps: React.DependencyList = [] @@ -54,10 +56,17 @@ export interface UseOutsideClickProps { * * If omitted, a default filter function that ignores clicks in Chakra UI portals and react-select components is used. */ - filter?: (el: HTMLElement) => boolean; + filter?: FilterFunction; } -const DEFAULT_FILTER = (el: HTMLElement) => el.className.includes('chakra-portal') || el.id.includes('react-select'); +export const DEFAULT_FILTER: FilterFunction = (el) => { + if (el instanceof SVGElement) { + // SVGElement's type appears to be incorrect. Its className is not a string, which causes `includes` to fail. + // Let's assume that SVG elements with a class name are not part of the portal and should not be filtered. + return false; + } + return el.className.includes('chakra-portal') || el.id.includes('react-select'); +}; /** * Example, used in components like Dialogs and Popovers, so they can close @@ -119,11 +128,7 @@ export function useFilterableOutsideClick(props: UseOutsideClickProps) { }, [handler, ref, savedHandler, state, enabled, filter]); } -function isValidEvent( - event: Event, - ref: React.RefObject, - filter?: (el: HTMLElement) => boolean -): boolean { +function isValidEvent(event: Event, ref: React.RefObject, filter?: FilterFunction): boolean { const target = (event.composedPath?.()[0] ?? event.target) as HTMLElement; if (target) { @@ -137,6 +142,7 @@ function isValidEvent( return false; } + // This is the main logic change from the original hook. if (filter) { // Check if the click is inside an element matching the filter. // This is used for portal-awareness or other general exclusion cases. diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx deleted file mode 100644 index 6cda069930..0000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable i18next/no-literal-string */ -import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library'; -import { Flex } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { RefImage } from 'features/controlLayers/components/RefImage/RefImage'; -import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; -import { selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice'; -import { memo } from 'react'; - -const sx: SystemStyleObject = { - opacity: 0.3, - _hover: { - opacity: 1, - }, - transitionProperty: 'opacity', - transitionDuration: '0.2s', -}; - -export const RefImageList = memo((props: FlexProps) => { - const ids = useAppSelector(selectRefImageEntityIds); - - if (ids.length === 0) { - return null; - } - - return ( - - {ids.map((id) => ( - - - - ))} - - ); -}); - -RefImageList.displayName = 'RefImageList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx index 1212fbe3fa..ca21d6a84a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx @@ -1,62 +1,77 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import type { + SystemStyleObject} from '@invoke-ai/ui-library'; import { + Divider, Flex, + IconButton, Image, Popover, PopoverAnchor, PopoverArrow, PopoverBody, PopoverContent, - Portal, + Portal } from '@invoke-ai/ui-library'; -import { createSelector } from '@reduxjs/toolkit'; import { skipToken } from '@reduxjs/toolkit/query'; -import { useAppSelector } from 'app/store/storeHooks'; +import { POPPER_MODIFIERS } from 'common/components/InformationalPopover/constants'; +import type { UseDisclosure } from 'common/hooks/useBoolean'; import { useDisclosure } from 'common/hooks/useBoolean'; -import { useFilterableOutsideClick } from 'common/hooks/useFilterableOutsideClick'; -import { IPAdapterSettings } from 'features/controlLayers/components/RefImage/IPAdapterSettings'; +import { DEFAULT_FILTER, useFilterableOutsideClick } from 'common/hooks/useFilterableOutsideClick'; +import { RefImageHeader } from 'features/controlLayers/components/RefImage/RefImageHeader'; +import { RefImageSettings } from 'features/controlLayers/components/RefImage/RefImageSettings'; +import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity'; import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; -import { selectRefImageEntityOrThrow, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; -import type { ImageWithDims } from 'features/controlLayers/store/types'; -import { memo, useMemo, useRef } from 'react'; +import { memo, useCallback, useRef } from 'react'; +import { PiImageBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -const sx: SystemStyleObject = { - opacity: 0.5, - _hover: { - opacity: 1, - }, - "&[data-is-open='true']": { - opacity: 1, - pointerEvents: 'none', - }, - transitionProperty: 'opacity', - transitionDuration: '0.2s', -}; +// There is some awkwardness here with closing the popover when clicking outside of it, related to Chakra's +// handling of refs, portals, outside clicks, and a race condition with framer-motion animations that can leave +// the popover closed when its internal state is still open. +// +// We have to manually manage the popover open state to work around the race condition, and then have to do special +// handling to close the popover when clicking outside of it. + +// We have to reach outside react to identify the popover trigger element instead of using refs, thanks to how Chakra +// handles refs for PopoverAnchor internally. Maybe there is some way to merge them but I couldn't figure it out. +const getRefImagePopoverTriggerId = (id: string) => `ref-image-popover-trigger-${id}`; export const RefImage = memo(() => { const id = useRefImageIdContext(); const ref = useRef(null); const disclosure = useDisclosure(false); - const selectEntity = useMemo( - () => createSelector(selectRefImagesSlice, (refImages) => selectRefImageEntityOrThrow(refImages, id, 'RefImage')), + // This filter prevents the popover from closing when clicking on a sibling portal element, like the dropdown menu + // inside the ref image settings popover. It also prevents the popover from closing when clicking on the popover's + // own trigger element. + const filter = useCallback( + (el: HTMLElement | SVGElement) => { + return DEFAULT_FILTER(el) || el.id === getRefImagePopoverTriggerId(id); + }, [id] ); - const entity = useAppSelector(selectEntity); - useFilterableOutsideClick({ ref, handler: disclosure.close }); + useFilterableOutsideClick({ ref, handler: disclosure.close, filter }); return ( - - - - - - + + - + - + + + + + @@ -65,8 +80,58 @@ export const RefImage = memo(() => { }); RefImage.displayName = 'RefImage'; -const Thumbnail = memo(({ image }: { image: ImageWithDims | null }) => { - const { data: imageDTO } = useGetImageDTOQuery(image?.image_name ?? skipToken); - return ; +const imageSx: SystemStyleObject = { + opacity: 0.5, + _hover: { + opacity: 1, + }, + "&[data-is-open='true']": { + opacity: 1, + }, + transitionProperty: 'opacity', + transitionDuration: '0.2s', +}; + +const Thumbnail = memo(({ disclosure }: { disclosure: UseDisclosure }) => { + const id = useRefImageIdContext(); + const entity = useRefImageEntity(id); + const { data: imageDTO } = useGetImageDTOQuery(entity.config.image?.image_name ?? skipToken); + + if (!imageDTO || !entity.config.image) { + return ( + + } + colorScheme="error" + onClick={disclosure.toggle} + /> + + ); + } + return ( + + + + ); }); Thumbnail.displayName = 'Thumbnail'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx new file mode 100644 index 0000000000..1a2eb27310 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx @@ -0,0 +1,41 @@ +import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity'; +import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; +import { refImageDeleted } from 'features/controlLayers/store/refImagesSlice'; +import { memo, useCallback } from 'react'; +import { PiTrashBold } from 'react-icons/pi'; + +export const RefImageHeader = memo(() => { + const id = useRefImageIdContext(); + const dispatch = useAppDispatch(); + const entity = useRefImageEntity(id); + const deleteRefImage = useCallback(() => { + dispatch(refImageDeleted({ id })); + }, [dispatch, id]); + + return ( + + {entity.config.image !== null && ( + + Reference Image + + )} + {entity.config.image === null && ( + + Reference Image - No Image Selected + + )} + } + onClick={deleteRefImage} + aria-label="Delete reference image" + colorScheme="error" + /> + + ); +}); +RefImageHeader.displayName = 'RefImageHeader'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx new file mode 100644 index 0000000000..723237fee5 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx @@ -0,0 +1,77 @@ +/* eslint-disable i18next/no-literal-string */ +import type { FlexProps } from '@invoke-ai/ui-library'; +import { Button, Flex, IconButton, Spacer } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { RefImage } from 'features/controlLayers/components/RefImage/RefImage'; +import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; +import { useAddGlobalReferenceImage } from 'features/controlLayers/hooks/addLayerHooks'; +import { selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice'; +import { memo } from 'react'; +import { PiPlusBold } from 'react-icons/pi'; + +export const RefImageList = memo((props: FlexProps) => { + const ids = useAppSelector(selectRefImageEntityIds); + const addRefImage = useAddGlobalReferenceImage(); + return ( + + {ids.map((id) => ( + + + + ))} + + + + ); +}); + +RefImageList.displayName = 'RefImageList'; + +const AddRefImageIconButton = memo(() => { + const addRefImage = useAddGlobalReferenceImage(); + return ( + } + /> + ); +}); +AddRefImageIconButton.displayName = 'AddRefImageIconButton'; + +const AddRefImageButton = memo((props) => { + const addRefImage = useAddGlobalReferenceImage(); + return ( + + ); +}); +AddRefImageButton.displayName = 'AddRefImageButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx index 3a82fd16f9..81aec80fec 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageState.tsx @@ -2,8 +2,6 @@ import { Button, Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; -import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; -import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd'; import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; @@ -17,7 +15,6 @@ export const RefImageNoImageState = memo(() => { const { t } = useTranslation(); const id = useRefImageIdContext(); const dispatch = useAppDispatch(); - const isBusy = useCanvasIsBusy(); const onUpload = useCallback( (imageDTO: ImageDTO) => { setGlobalReferenceImage({ imageDTO, id, dispatch }); @@ -28,7 +25,6 @@ export const RefImageNoImageState = memo(() => { const onClickGalleryButton = useCallback(() => { dispatch(activeTabCanvasRightPanelChanged('gallery')); }, [dispatch]); - const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id); const dndTargetData = useMemo( () => setGlobalReferenceImageDndTarget.getData({ id }), @@ -37,17 +33,10 @@ export const RefImageNoImageState = memo(() => { const components = useMemo( () => ({ - UploadButton: ( -