mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-12 13:25:22 -05:00
feat(ui): revised ref image panel
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user