From 6c8cf99ad287903573cb73553235e7102e577f85 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:25:25 +1000 Subject: [PATCH] feat(ui): revised ref image panel --- .../components/RefImage/RefImage.tsx | 238 ------------------ .../components/RefImage/RefImageHeader.tsx | 42 ++-- .../components/RefImage/RefImageList.tsx | 51 +++- .../components/RefImage/RefImagePreview.tsx | 162 ++++++++++++ .../controlLayers/store/refImagesSlice.ts | 29 ++- .../src/features/controlLayers/store/types.ts | 2 + 6 files changed, 255 insertions(+), 269 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx 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 e4aadb093a..e69de29bb2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImage.tsx @@ -1,238 +0,0 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { - Divider, - Flex, - Icon, - IconButton, - Image, - Popover, - PopoverAnchor, - PopoverArrow, - PopoverBody, - PopoverContent, - Portal, - Skeleton, - Text, -} from '@invoke-ai/ui-library'; -import { skipToken } from '@reduxjs/toolkit/query'; -import { POPPER_MODIFIERS } from 'common/components/InformationalPopover/constants'; -import type { UseDisclosure } from 'common/hooks/useBoolean'; -import { useDisclosure } from 'common/hooks/useBoolean'; -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 { isIPAdapterConfig } from 'features/controlLayers/store/types'; -import { round } from 'lodash-es'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { PiExclamationMarkBold, PiImageBold } from 'react-icons/pi'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; - -// 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); - // 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] - ); - useFilterableOutsideClick({ ref, handler: disclosure.close, filter }); - - return ( - - - - - - - - - - - - - - - - ); -}); -RefImage.displayName = 'RefImage'; - -const baseSx: SystemStyleObject = { - opacity: 0.7, - transitionProperty: 'opacity', - transitionDuration: 'normal', - position: 'relative', - _hover: { - opacity: 1, - }, - '&[data-is-open="true"]': { - opacity: 1, - }, - '&[data-is-error="true"]': { - borderColor: 'error.500', - borderWidth: 2, - }, -}; - -const weightDisplaySx: SystemStyleObject = { - pointerEvents: 'none', - transitionProperty: 'opacity', - transitionDuration: 'normal', - opacity: 0, - '&[data-visible="true"]': { - opacity: 1, - }, -}; - -const getImageSxWithWeight = (weight: number): SystemStyleObject => { - const fillPercentage = Math.max(0, Math.min(100, weight * 100)); - - return { - ...baseSx, - _after: { - content: '""', - position: 'absolute', - inset: 0, - background: `linear-gradient(to top, transparent ${fillPercentage}%, rgba(0, 0, 0, 0.8) ${fillPercentage}%)`, - pointerEvents: 'none', - borderRadius: 'base', - }, - }; -}; - -const Thumbnail = memo(({ disclosure }: { disclosure: UseDisclosure }) => { - const id = useRefImageIdContext(); - const entity = useRefImageEntity(id); - const [showWeightDisplay, setShowWeightDisplay] = useState(false); - const { data: imageDTO } = useGetImageDTOQuery(entity.config.image?.image_name ?? skipToken); - - const sx = useMemo(() => { - if (!isIPAdapterConfig(entity.config)) { - return baseSx; - } - return getImageSxWithWeight(entity.config.weight); - }, [entity.config]); - - useEffect(() => { - if (!isIPAdapterConfig(entity.config)) { - return; - } - setShowWeightDisplay(true); - const timeout = window.setTimeout(() => { - setShowWeightDisplay(false); - }, 1000); - return () => { - window.clearTimeout(timeout); - }; - }, [entity.config]); - - if (!entity.config.image) { - return ( - - } - colorScheme="error" - onClick={disclosure.toggle} - flexShrink={0} - /> - - ); - } - return ( - - - } - maxW="full" - maxH="full" - borderRadius="base" - /> - {isIPAdapterConfig(entity.config) && ( - - - {`${round(entity.config.weight * 100, 2)}%`} - - - )} - {!entity.config.model && ( - - )} - - - ); -}); -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 index 7d75307095..972dc26b89 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageHeader.tsx @@ -1,38 +1,46 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } 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 { refImageDeleted, selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice'; +import { memo, useCallback, useMemo } from 'react'; import { PiTrashBold } from 'react-icons/pi'; +const textSx: SystemStyleObject = { + color: 'base.300', + '&[data-is-error="true"]': { + color: 'error.300', + }, +}; + export const RefImageHeader = memo(() => { - const id = useRefImageIdContext(); const dispatch = useAppDispatch(); + const id = useRefImageIdContext(); + const selectRefImageNumber = useMemo( + () => createSelector(selectRefImageEntityIds, (ids) => ids.indexOf(id) + 1), + [id] + ); + const refImageNumber = useAppSelector(selectRefImageNumber); const entity = useRefImageEntity(id); const deleteRefImage = useCallback(() => { dispatch(refImageDeleted({ id })); }, [dispatch, id]); return ( - - {entity.config.image !== null && ( - - Reference Image - - )} - {entity.config.image === null && ( - - No Reference Image Selected - - )} + + + Reference Image #{refImageNumber} + } + aria-label="Delete ref image" onClick={deleteRefImage} - aria-label="Delete reference image" + icon={} colorScheme="error" /> diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx index 76b1624b11..8c723d1904 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx @@ -1,12 +1,16 @@ -import type { FlexProps } from '@invoke-ai/ui-library'; -import { Button, Flex } from '@invoke-ai/ui-library'; +import { Button, Collapse, Divider, Flex } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; import { useAppSelector } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; -import { RefImage } from 'features/controlLayers/components/RefImage/RefImage'; +import { RefImagePreview } from 'features/controlLayers/components/RefImage/RefImagePreview'; import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; -import { refImageAdded, selectRefImageEntityIds } from 'features/controlLayers/store/refImagesSlice'; +import { + refImageAdded, + selectIsRefImagePanelOpen, + selectRefImageEntityIds, + selectSelectedRefEntityId, +} from 'features/controlLayers/store/refImagesSlice'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { addGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; @@ -14,17 +18,38 @@ import { memo, useMemo } from 'react'; import { PiUploadBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; -export const RefImageList = memo((props: FlexProps) => { +import { RefImageHeader } from './RefImageHeader'; +import { RefImageSettings } from './RefImageSettings'; + +export const RefImageList = memo(() => { const ids = useAppSelector(selectRefImageEntityIds); + const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen); + const selectedEntityId = useAppSelector(selectSelectedRefEntityId); + return ( - - {ids.map((id) => ( - - - - ))} - {ids.length < 5 && } - {ids.length >= 5 && } + + + {ids.map((id) => ( + + + + ))} + {ids.length < 5 && } + {ids.length >= 5 && } + + + + {selectedEntityId !== null && ( + + + + + + + + )} + + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx new file mode 100644 index 0000000000..f01c1fcc74 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx @@ -0,0 +1,162 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex, Icon, IconButton, Image, Skeleton, Text } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity'; +import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext'; +import { + refImageSelected, + selectIsRefImagePanelOpen, + selectSelectedRefEntityId, +} from 'features/controlLayers/store/refImagesSlice'; +import { isIPAdapterConfig } from 'features/controlLayers/store/types'; +import { round } from 'lodash-es'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { PiExclamationMarkBold, PiImageBold } from 'react-icons/pi'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; + +const baseSx: SystemStyleObject = { + '&[data-is-open="true"]': { + borderColor: 'invokeBlue.300', + }, +}; + +const weightDisplaySx: SystemStyleObject = { + pointerEvents: 'none', + transitionProperty: 'opacity', + transitionDuration: 'normal', + opacity: 0, + '&[data-visible="true"]': { + opacity: 1, + }, +}; + +const getImageSxWithWeight = (weight: number): SystemStyleObject => { + const fillPercentage = Math.max(0, Math.min(100, weight * 100)); + + return { + ...baseSx, + _after: { + content: '""', + position: 'absolute', + inset: 0, + background: `linear-gradient(to top, transparent ${fillPercentage}%, rgba(0, 0, 0, 0.8) ${fillPercentage}%)`, + pointerEvents: 'none', + borderRadius: 'base', + }, + }; +}; + +export const RefImagePreview = memo(() => { + const dispatch = useAppDispatch(); + const id = useRefImageIdContext(); + const entity = useRefImageEntity(id); + const selectedEntityId = useAppSelector(selectSelectedRefEntityId); + const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen); + const [showWeightDisplay, setShowWeightDisplay] = useState(false); + const { data: imageDTO } = useGetImageDTOQuery(entity.config.image?.image_name ?? skipToken); + + const sx = useMemo(() => { + if (!isIPAdapterConfig(entity.config)) { + return baseSx; + } + return getImageSxWithWeight(entity.config.weight); + }, [entity.config]); + + useEffect(() => { + if (!isIPAdapterConfig(entity.config)) { + return; + } + setShowWeightDisplay(true); + const timeout = window.setTimeout(() => { + setShowWeightDisplay(false); + }, 1000); + return () => { + window.clearTimeout(timeout); + }; + }, [entity.config]); + + const onClick = useCallback(() => { + dispatch(refImageSelected({ id })); + }, [dispatch, id]); + + if (!entity.config.image) { + return ( + } + colorScheme="error" + onClick={onClick} + flexShrink={0} + data-is-open={selectedEntityId === id && isPanelOpen} + data-is-error={true} + sx={sx} + /> + ); + } + return ( + + } + maxW="full" + maxH="full" + borderRadius="base" + /> + {isIPAdapterConfig(entity.config) && ( + + + {`${round(entity.config.weight * 100, 2)}%`} + + + )} + {!entity.config.model && ( + + )} + + ); +}); +RefImagePreview.displayName = 'RefImagePreview'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts index a2a73645fa..fadb2c41c1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts @@ -39,6 +39,8 @@ export const refImagesSlice = createSlice({ const entityState = getReferenceImageState(id, overrides); state.entities.push(entityState); + state.selectedEntityId = id; + state.isPanelOpen = true; }, prepare: (payload?: { overrides?: PartialDeep }) => ({ payload: { ...payload, id: getPrefixedId('reference_image') }, @@ -187,6 +189,22 @@ export const refImagesSlice = createSlice({ refImageDeleted: (state, action: PayloadActionWithId) => { const { id } = action.payload; state.entities = state.entities.filter((rg) => rg.id !== id); + if (state.selectedEntityId === id) { + state.selectedEntityId = null; + } + }, + refImageSelected: (state, action: PayloadAction<{ id: string }>) => { + const { id } = action.payload; + const entity = selectRefImageEntity(state, id); + if (!entity) { + return; + } + if (state.isPanelOpen && state.selectedEntityId === id) { + state.isPanelOpen = false; + } else { + state.isPanelOpen = true; + } + state.selectedEntityId = id; }, refImagesReset: () => getInitialRefImagesState(), }, @@ -199,6 +217,7 @@ export const refImagesSlice = createSlice({ }); export const { + refImageSelected, refImageAdded, refImageDeleted, refImageImageChanged, @@ -219,17 +238,25 @@ export const refImagesPersistConfig: PersistConfig = { name: refImagesSlice.name, initialState: getInitialRefImagesState(), migrate, - persistDenylist: [], + persistDenylist: ['isPanelOpen'], }; export const selectRefImagesSlice = (state: RootState) => state.refImages; export const selectReferenceImageEntities = createSelector(selectRefImagesSlice, (state) => state.entities); +export const selectSelectedRefEntityId = createSelector(selectRefImagesSlice, (state) => state.selectedEntityId); +export const selectIsRefImagePanelOpen = createSelector(selectRefImagesSlice, (state) => state.isPanelOpen); export const selectRefImageEntityIds = createMemoizedSelector(selectReferenceImageEntities, (entities) => entities.map((e) => e.id) ); export const selectRefImageEntity = (state: RefImagesState, id: string) => state.entities.find((entity) => entity.id === id) ?? null; +export const selectSelectedRefEntity = createSelector(selectRefImagesSlice, (state) => { + if (!state.selectedEntityId) { + return null; + } + return selectRefImageEntity(state, state.selectedEntityId); +}); export function selectRefImageEntityOrThrow(state: RefImagesState, id: string, caller: string): RefImageState { const entity = selectRefImageEntity(state, id); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 558ac423a0..a64fe70f89 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -579,6 +579,8 @@ const zCanvasState = z.object({ export type CanvasState = z.infer; const zRefImagesState = z.object({ + selectedEntityId: z.string().nullable().default(null), + isPanelOpen: z.boolean().default(false), entities: z.array(zRefImageState).default(() => []), }); export type RefImagesState = z.infer;