From 8d1ab0a2e565708dcbab8fee1ed09e946ccea888 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 13 Jun 2025 15:19:02 +1000
Subject: [PATCH] refactor(ui): ref images (WIP)
---
invokeai/frontend/web/public/locales/en.json | 3 +-
.../web/src/common/hooks/useBoolean.ts | 2 +-
.../common/hooks/useFilterableOutsideClick.ts | 20 ++-
.../components/RefImage/IPAdapterList.tsx | 37 -----
.../components/RefImage/RefImage.tsx | 137 +++++++++++++-----
.../components/RefImage/RefImageHeader.tsx | 41 ++++++
.../components/RefImage/RefImageList.tsx | 77 ++++++++++
.../RefImage/RefImageNoImageState.tsx | 18 +--
.../RefImageNoImageStateWithCanvasOptions.tsx | 69 +++++++++
...apterSettings.tsx => RefImageSettings.tsx} | 57 +++++---
.../components/RefImage/useRefImageEntity.ts | 16 ++
...nalGuidanceIPAdapterSettingsEmptyState.tsx | 2 +-
.../common/PullBboxIntoRefImageIconButton.tsx | 28 ++++
.../controlLayers/hooks/addLayerHooks.ts | 2 +-
.../controlLayers/store/refImagesSlice.ts | 3 +-
.../web/src/features/imageActions/actions.ts | 2 +-
.../components/Core/ParamPositivePrompt.tsx | 2 -
.../parameters/components/Prompts/Prompts.tsx | 2 +
.../features/ui/components/TabMountGate.tsx | 3 +
19 files changed, 394 insertions(+), 127 deletions(-)
delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterList.tsx
create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx
create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx
create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageStateWithCanvasOptions.tsx
rename invokeai/frontend/web/src/features/controlLayers/components/RefImage/{IPAdapterSettings.tsx => RefImageSettings.tsx} (80%)
create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageEntity.ts
create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/common/PullBboxIntoRefImageIconButton.tsx
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) => (
+
+
+
+ ))}
+
+ }
+ onClick={addRefImage}
+ isDisabled={ids.length >= 5} // Limit to 5 reference images
+ >
+ Ref Image
+
+
+ );
+});
+
+RefImageList.displayName = 'RefImageList';
+
+const AddRefImageIconButton = memo(() => {
+ const addRefImage = useAddGlobalReferenceImage();
+ return (
+ }
+ />
+ );
+});
+AddRefImageIconButton.displayName = 'AddRefImageIconButton';
+
+const AddRefImageButton = memo((props) => {
+ const addRefImage = useAddGlobalReferenceImage();
+ return (
+ }
+ onClick={addRefImage}
+ >
+ Ref Image
+
+ );
+});
+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: (
-
- ),
- GalleryButton: (
-
- ),
- PullBboxButton: (
-
- ),
+ UploadButton: ,
+ GalleryButton: ,
}),
- [isBusy, onClickGalleryButton, pullBboxIntoIPAdapter, uploadApi]
+ [onClickGalleryButton, uploadApi]
);
return (
@@ -60,7 +49,6 @@ export const RefImageNoImageState = memo(() => {
dndTarget={setGlobalReferenceImageDndTarget}
dndTargetData={dndTargetData}
label={t('controlLayers.useImage')}
- isDisabled={isBusy}
/>
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageStateWithCanvasOptions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageStateWithCanvasOptions.tsx
new file mode 100644
index 0000000000..329c6411b2
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageNoImageStateWithCanvasOptions.tsx
@@ -0,0 +1,69 @@
+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';
+import { setGlobalReferenceImage } from 'features/imageActions/actions';
+import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
+import { memo, useCallback, useMemo } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+import type { ImageDTO } from 'services/api/types';
+
+export const RefImageNoImageStateWithCanvasOptions = memo(() => {
+ const { t } = useTranslation();
+ const id = useRefImageIdContext();
+ const dispatch = useAppDispatch();
+ const isBusy = useCanvasIsBusy();
+ const onUpload = useCallback(
+ (imageDTO: ImageDTO) => {
+ setGlobalReferenceImage({ imageDTO, id, dispatch });
+ },
+ [dispatch, id]
+ );
+ const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
+ const onClickGalleryButton = useCallback(() => {
+ dispatch(activeTabCanvasRightPanelChanged('gallery'));
+ }, [dispatch]);
+ const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id);
+
+ const dndTargetData = useMemo(
+ () => setGlobalReferenceImageDndTarget.getData({ id }),
+ [id]
+ );
+
+ const components = useMemo(
+ () => ({
+ UploadButton: (
+
+ ),
+ GalleryButton: (
+
+ ),
+ PullBboxButton: (
+
+ ),
+ }),
+ [isBusy, onClickGalleryButton, pullBboxIntoIPAdapter, uploadApi]
+ );
+
+ return (
+
+
+
+
+
+
+
+ );
+});
+
+RefImageNoImageStateWithCanvasOptions.displayName = 'RefImageNoImageStateWithCanvasOptions';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageSettings.tsx
similarity index 80%
rename from invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterSettings.tsx
rename to invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageSettings.tsx
index 8afacb0d2d..540a9723e0 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/IPAdapterSettings.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageSettings.tsx
@@ -4,10 +4,16 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct';
import { FLUXReduxImageInfluence } from 'features/controlLayers/components/common/FLUXReduxImageInfluence';
import { IPAdapterCLIPVisionModel } from 'features/controlLayers/components/common/IPAdapterCLIPVisionModel';
+import { PullBboxIntoRefImageIconButton } from 'features/controlLayers/components/common/PullBboxIntoRefImageIconButton';
import { Weight } from 'features/controlLayers/components/common/Weight';
import { IPAdapterMethod } from 'features/controlLayers/components/RefImage/IPAdapterMethod';
import { RefImageModel } from 'features/controlLayers/components/RefImage/RefImageModel';
import { RefImageNoImageState } from 'features/controlLayers/components/RefImage/RefImageNoImageState';
+import { RefImageNoImageStateWithCanvasOptions } from 'features/controlLayers/components/RefImage/RefImageNoImageStateWithCanvasOptions';
+import {
+ CanvasManagerProviderGate,
+ useCanvasManagerSafe,
+} from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
import {
@@ -22,17 +28,16 @@ import {
selectRefImageEntityOrThrow,
selectRefImagesSlice,
} from 'features/controlLayers/store/refImagesSlice';
-import {
- type CLIPVisionModelV2,
- type FLUXReduxImageInfluence as FLUXReduxImageInfluenceType,
- type IPMethodV2,
- isFLUXReduxConfig,
- isIPAdapterConfig,
+import type {
+ CLIPVisionModelV2,
+ FLUXReduxImageInfluence as FLUXReduxImageInfluenceType,
+ IPMethodV2,
} from 'features/controlLayers/store/types';
+import { isFLUXReduxConfig, isIPAdapterConfig } from 'features/controlLayers/store/types';
import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
+import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
import type { ApiModelConfig, FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { RefImageImage } from './RefImageImage';
@@ -43,12 +48,12 @@ const buildSelectConfig = (id: string) =>
(refImages) => selectRefImageEntityOrThrow(refImages, id, 'IPAdapterSettings').config
);
-const IPAdapterSettingsContent = memo(() => {
- const { t } = useTranslation();
+const RefImageSettingsContent = memo(() => {
const dispatch = useAppDispatch();
const id = useRefImageIdContext();
const selectConfig = useMemo(() => buildSelectConfig(id), [id]);
const config = useAppSelector(selectConfig);
+ const tab = useAppSelector(selectActiveTab);
const onChangeBeginEndStepPct = useCallback(
(beginEndStepPct: [number, number]) => {
@@ -103,8 +108,6 @@ const IPAdapterSettingsContent = memo(() => {
() => setGlobalReferenceImageDndTarget.getData({ id }, config.image?.image_name),
[id, config.image?.image_name]
);
- // const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id);
- // const isBusy = useCanvasIsBusy();
const isFLUX = useAppSelector(selectIsFLUX);
@@ -115,14 +118,11 @@ const IPAdapterSettingsContent = memo(() => {
{isIPAdapterConfig(config) && (
)}
- {/* }
- /> */}
+ {tab === 'canvas' && (
+
+
+
+ )}
{isIPAdapterConfig(config) && (
@@ -153,7 +153,7 @@ const IPAdapterSettingsContent = memo(() => {
);
});
-IPAdapterSettingsContent.displayName = 'IPAdapterSettingsContent';
+RefImageSettingsContent.displayName = 'RefImageSettingsContent';
const buildSelectIPAdapterHasImage = (id: string) =>
createSelector(selectRefImagesSlice, (refImages) => {
@@ -161,17 +161,26 @@ const buildSelectIPAdapterHasImage = (id: string) =>
return !!referenceImage && referenceImage.config.image !== null;
});
-export const IPAdapterSettings = memo(() => {
+export const RefImageSettings = memo(() => {
const id = useRefImageIdContext();
-
+ const tab = useAppSelector(selectActiveTab);
+ const canvasManager = useCanvasManagerSafe();
const selectIPAdapterHasImage = useMemo(() => buildSelectIPAdapterHasImage(id), [id]);
const hasImage = useAppSelector(selectIPAdapterHasImage);
+ if (!hasImage && canvasManager && tab === 'canvas') {
+ return (
+
+
+
+ );
+ }
+
if (!hasImage) {
return ;
}
- return ;
+ return ;
});
-IPAdapterSettings.displayName = 'IPAdapterSettings';
+RefImageSettings.displayName = 'RefImageSettings';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageEntity.ts b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageEntity.ts
new file mode 100644
index 0000000000..eda05ecee6
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageEntity.ts
@@ -0,0 +1,16 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectRefImageEntityOrThrow, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice';
+import { useMemo } from 'react';
+
+export const useRefImageEntity = (id: string) => {
+ const selectEntity = useMemo(
+ () =>
+ createSelector(selectRefImagesSlice, (refImages) =>
+ selectRefImageEntityOrThrow(refImages, id, `useRefImageState(${id})`)
+ ),
+ [id]
+ );
+ const entity = useAppSelector(selectEntity);
+ return entity;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState.tsx
index e7e3282a64..7e00664f03 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState.tsx
@@ -83,7 +83,7 @@ export const RegionalGuidanceIPAdapterSettingsEmptyState = memo(({ referenceImag
-
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/PullBboxIntoRefImageIconButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/PullBboxIntoRefImageIconButton.tsx
new file mode 100644
index 0000000000..f14b87d5fc
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/common/PullBboxIntoRefImageIconButton.tsx
@@ -0,0 +1,28 @@
+import { IconButton } from '@invoke-ai/ui-library';
+import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
+import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiBoundingBoxBold } from 'react-icons/pi';
+
+export const PullBboxIntoRefImageIconButton = memo(() => {
+ const { t } = useTranslation();
+ const id = useRefImageIdContext();
+
+ const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(id);
+ const isBusy = useCanvasIsBusy();
+
+ return (
+ }
+ />
+ );
+});
+
+PullBboxIntoRefImageIconButton.displayName = 'PullBboxIntoRefImageIconButton';
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
index f8860e210c..3c4eacc1f8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
@@ -192,7 +192,7 @@ export const useAddGlobalReferenceImage = () => {
const func = useCallback(() => {
const config = getDefaultRefImageConfig(getState);
const overrides = { config };
- dispatch(refImageAdded({ isSelected: true, overrides }));
+ dispatch(refImageAdded({ overrides }));
}, [dispatch, getState]);
return func;
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts
index c37a6d9f56..a2a73645fa 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts
@@ -40,7 +40,7 @@ export const refImagesSlice = createSlice({
state.entities.push(entityState);
},
- prepare: (payload?: { overrides?: PartialDeep; isSelected?: boolean }) => ({
+ prepare: (payload?: { overrides?: PartialDeep }) => ({
payload: { ...payload, id: getPrefixedId('reference_image') },
}),
},
@@ -200,6 +200,7 @@ export const refImagesSlice = createSlice({
export const {
refImageAdded,
+ refImageDeleted,
refImageImageChanged,
refImageIPAdapterMethodChanged,
refImageModelChanged,
diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts
index 52db1a7c09..65335fb55f 100644
--- a/invokeai/frontend/web/src/features/imageActions/actions.ts
+++ b/invokeai/frontend/web/src/features/imageActions/actions.ts
@@ -248,7 +248,7 @@ export const newCanvasFromImage = async (arg: {
const config = deepClone(getDefaultRefImageConfig(getState));
config.image = imageDTOToImageWithDims(imageDTO);
dispatch(canvasSessionTypeChanged({ type: 'advanced' }));
- dispatch(refImageAdded({ overrides: { config }, isSelected: true }));
+ dispatch(refImageAdded({ overrides: { config } }));
if (withInpaintMask) {
dispatch(inpaintMaskAdded({ isSelected: true, isBookmarked: true }));
}
diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx
index aa65205470..037c1021fb 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamPositivePrompt.tsx
@@ -1,7 +1,6 @@
import { Box, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { usePersistedTextAreaSize } from 'common/hooks/usePersistedTextareaSize';
-import { RefImageList } from 'features/controlLayers/components/RefImage/IPAdapterList';
import { positivePromptChanged, selectBase, selectPositivePrompt } from 'features/controlLayers/store/paramsSlice';
import { ShowDynamicPromptsPreviewButton } from 'features/dynamicPrompts/components/ShowDynamicPromptsPreviewButton';
import { PromptLabel } from 'features/parameters/components/Prompts/PromptLabel';
@@ -108,7 +107,6 @@ export const ParamPositivePrompt = memo(() => {
label={`${t('parameters.positivePromptPlaceholder')} (${t('stylePresets.preview')})`}
/>
)}
-
);
diff --git a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx
index c16268a5c6..c0f4f2959b 100644
--- a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx
@@ -1,5 +1,6 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
+import { RefImageList } from 'features/controlLayers/components/RefImage/RefImageList';
import { createParamsSelector, selectIsChatGTP4o, selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
import { ParamNegativePrompt } from 'features/parameters/components/Core/ParamNegativePrompt';
import { ParamPositivePrompt } from 'features/parameters/components/Core/ParamPositivePrompt';
@@ -21,6 +22,7 @@ export const Prompts = memo(() => {
{withStylePrompts && }
+
{!isFLUX && !isChatGPT4o && }
{withStylePrompts && }
diff --git a/invokeai/frontend/web/src/features/ui/components/TabMountGate.tsx b/invokeai/frontend/web/src/features/ui/components/TabMountGate.tsx
index 5d8224e8f5..ff4bbcd4ca 100644
--- a/invokeai/frontend/web/src/features/ui/components/TabMountGate.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/TabMountGate.tsx
@@ -5,6 +5,9 @@ import type { TabName } from 'features/ui/store/uiTypes';
import type { PropsWithChildren } from 'react';
import { memo, useMemo } from 'react';
+/**
+ * TabMountGate is a component that conditionally renders its children based on whether the specified tab is enabled.
+ */
export const TabMountGate = memo(({ tab, children }: PropsWithChildren<{ tab: TabName }>) => {
const selectIsTabEnabled = useMemo(
() => createSelector(selectConfigSlice, (config) => !config.disabledTabs.includes(tab)),