feat(ui): revised ref image panel

This commit is contained in:
psychedelicious
2025-06-25 15:25:25 +10:00
parent a92ba2542c
commit 6c8cf99ad2
6 changed files with 255 additions and 269 deletions

View File

@@ -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<HTMLDivElement>(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 (
<Popover
// The popover contains a react-select component, which uses a portal to render its options. This portal
// is itself not lazy. As a result, if we do not unmount the popover when it is closed, the react-select
// component still exists but is invisible, and intercepts clicks!
isLazy
lazyBehavior="unmount"
isOpen={disclosure.isOpen}
closeOnBlur={false}
modifiers={POPPER_MODIFIERS}
>
<Thumbnail disclosure={disclosure} />
<Portal>
<PopoverContent ref={ref} w={400}>
<PopoverArrow />
<PopoverBody>
<Flex flexDir="column" gap={2} w="full" h="full">
<RefImageHeader />
<Divider />
<RefImageSettings />
</Flex>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
});
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 (
<PopoverAnchor>
<IconButton
id={getRefImagePopoverTriggerId(id)}
aria-label="Open Reference Image Settings"
h="full"
variant="ghost"
aspectRatio="1/1"
borderWidth="2px !important"
borderStyle="dashed !important"
borderColor="errorAlpha.500"
borderRadius="base"
icon={<PiImageBold />}
colorScheme="error"
onClick={disclosure.toggle}
flexShrink={0}
/>
</PopoverAnchor>
);
}
return (
<PopoverAnchor>
<Flex
position="relative"
borderWidth={1}
borderStyle="solid"
borderRadius="base"
aspectRatio="1/1"
maxW="full"
maxH="full"
flexShrink={0}
sx={sx}
data-is-open={disclosure.isOpen}
data-is-error={!entity.config.model}
id={getRefImagePopoverTriggerId(id)}
role="button"
onClick={disclosure.toggle}
cursor="pointer"
>
<Image
src={imageDTO?.thumbnail_url}
objectFit="contain"
aspectRatio="1/1"
height={imageDTO?.height}
fallback={<Skeleton h="full" aspectRatio="1/1" />}
maxW="full"
maxH="full"
borderRadius="base"
/>
{isIPAdapterConfig(entity.config) && (
<Flex
position="absolute"
inset={0}
fontWeight="semibold"
alignItems="center"
justifyContent="center"
zIndex={1}
data-visible={showWeightDisplay}
sx={weightDisplaySx}
>
<Text filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))">
{`${round(entity.config.weight * 100, 2)}%`}
</Text>
</Flex>
)}
{!entity.config.model && (
<Icon
position="absolute"
top="50%"
left="50%"
transform="translateX(-50%) translateY(-50%)"
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))"
color="error.500"
boxSize={16}
as={PiExclamationMarkBold}
/>
)}
</Flex>
</PopoverAnchor>
);
});
Thumbnail.displayName = 'Thumbnail';

View File

@@ -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 (
<Flex justifyContent="space-between" alignItems="center" w="full">
{entity.config.image !== null && (
<Text fontWeight="semibold" color="base.300">
Reference Image
</Text>
)}
{entity.config.image === null && (
<Text fontWeight="semibold" color="base.300">
No Reference Image Selected
</Text>
)}
<Flex justifyContent="space-between" alignItems="center" w="full" ps={2}>
<Text fontWeight="semibold" sx={textSx} data-is-error={!entity.config.image}>
Reference Image #{refImageNumber}
</Text>
<IconButton
tooltip="Delete Reference Image"
size="xs"
variant="link"
alignSelf="stretch"
icon={<PiTrashBold />}
aria-label="Delete ref image"
onClick={deleteRefImage}
aria-label="Delete reference image"
icon={<PiTrashBold />}
colorScheme="error"
/>
</Flex>

View File

@@ -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 (
<Flex gap={2} h={16} {...props}>
{ids.map((id) => (
<RefImageIdContext.Provider key={id} value={id}>
<RefImage />
</RefImageIdContext.Provider>
))}
{ids.length < 5 && <AddRefImageDropTargetAndButton />}
{ids.length >= 5 && <MaxRefImages />}
<Flex flexDir="column">
<Flex gap={2} h={16}>
{ids.map((id) => (
<RefImageIdContext.Provider key={id} value={id}>
<RefImagePreview />
</RefImageIdContext.Provider>
))}
{ids.length < 5 && <AddRefImageDropTargetAndButton />}
{ids.length >= 5 && <MaxRefImages />}
</Flex>
<Collapse in={isPanelOpen}>
<Flex pt={2} w="full">
{selectedEntityId !== null && (
<RefImageIdContext.Provider value={selectedEntityId}>
<Flex flexDir="column" gap={2} w="full" h="full" borderRadius="base" bg="base.800" p={2}>
<RefImageHeader />
<Divider />
<RefImageSettings />
</Flex>
</RefImageIdContext.Provider>
)}
</Flex>
</Collapse>
</Flex>
);
});

View File

@@ -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 (
<IconButton
aria-label="Select Ref Image"
h="full"
variant="ghost"
aspectRatio="1/1"
borderWidth={1}
borderStyle="solid"
borderColor="error.300"
borderRadius="base"
icon={<PiImageBold />}
colorScheme="error"
onClick={onClick}
flexShrink={0}
data-is-open={selectedEntityId === id && isPanelOpen}
data-is-error={true}
sx={sx}
/>
);
}
return (
<Flex
position="relative"
borderWidth={1}
borderStyle="solid"
borderRadius="base"
aspectRatio="1/1"
maxW="full"
maxH="full"
flexShrink={0}
sx={sx}
data-is-open={selectedEntityId === id && isPanelOpen}
data-is-error={!entity.config.model}
role="button"
onClick={onClick}
cursor="pointer"
>
<Image
src={imageDTO?.thumbnail_url}
objectFit="contain"
aspectRatio="1/1"
height={imageDTO?.height}
fallback={<Skeleton h="full" aspectRatio="1/1" />}
maxW="full"
maxH="full"
borderRadius="base"
/>
{isIPAdapterConfig(entity.config) && (
<Flex
position="absolute"
inset={0}
fontWeight="semibold"
alignItems="center"
justifyContent="center"
zIndex={1}
data-visible={showWeightDisplay}
sx={weightDisplaySx}
>
<Text filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))">
{`${round(entity.config.weight * 100, 2)}%`}
</Text>
</Flex>
)}
{!entity.config.model && (
<Icon
position="absolute"
top="50%"
left="50%"
transform="translateX(-50%) translateY(-50%)"
filter="drop-shadow(0px 0px 4px rgb(0, 0, 0)) drop-shadow(0px 0px 2px rgba(0, 0, 0, 1))"
color="error.500"
boxSize={16}
as={PiExclamationMarkBold}
/>
)}
</Flex>
);
});
RefImagePreview.displayName = 'RefImagePreview';

View File

@@ -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<RefImageState> }) => ({
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<RefImagesState> = {
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);

View File

@@ -579,6 +579,8 @@ const zCanvasState = z.object({
export type CanvasState = z.infer<typeof zCanvasState>;
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<typeof zRefImagesState>;