mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): drag-and-drop reordering for reference images
Reference images are already stored as an ordered array and serialized to metadata in order, so graph building and recall automatically respect the new order. This change adds the UI affordance: users can drag reference image thumbnails left/right to reorder them. - Adds `refImagesReordered` reducer with validation against length mismatch, unknown ids, and duplicates. - Adds `singleRefImageDndSource` and `useRefImageDnd` hook using pragmatic-drag-and-drop with horizontal edges. - Wraps `RefImagePreview` in a draggable container with drop indicator. - Disables native `<img>` drag so pragmatic-dnd receives the gesture. - Adds unit tests for the new reducer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge';
|
||||
import { Button, Collapse, Divider, Flex, IconButton } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
|
||||
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
|
||||
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
|
||||
import { RefImagePreview } from 'features/controlLayers/components/RefImage/RefImagePreview';
|
||||
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
|
||||
import { RefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
|
||||
@@ -9,15 +13,18 @@ import { useNewGlobalReferenceImageFromBbox } from 'features/controlLayers/hooks
|
||||
import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy';
|
||||
import {
|
||||
refImageAdded,
|
||||
refImagesReordered,
|
||||
selectIsRefImagePanelOpen,
|
||||
selectRefImageEntityIds,
|
||||
selectSelectedRefEntityId,
|
||||
} from 'features/controlLayers/store/refImagesSlice';
|
||||
import { imageDTOToCroppableImage } from 'features/controlLayers/store/util';
|
||||
import { addGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
|
||||
import { addGlobalReferenceImageDndTarget, singleRefImageDndSource } from 'features/dnd/dnd';
|
||||
import { DndDropTarget } from 'features/dnd/DndDropTarget';
|
||||
import { triggerPostMoveFlash } from 'features/dnd/util';
|
||||
import { selectActiveTab } from 'features/ui/store/uiSelectors';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiBoundingBoxBold, PiUploadBold } from 'react-icons/pi';
|
||||
import type { ImageDTO } from 'services/api/types';
|
||||
@@ -29,6 +36,69 @@ export const RefImageList = memo(() => {
|
||||
const ids = useAppSelector(selectRefImageEntityIds);
|
||||
const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen);
|
||||
const selectedEntityId = useAppSelector(selectSelectedRefEntityId);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
return monitorForElements({
|
||||
canMonitor({ source }) {
|
||||
return singleRefImageDndSource.typeGuard(source.data);
|
||||
},
|
||||
onDrop({ location, source }) {
|
||||
const target = location.current.dropTargets[0];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceData = source.data;
|
||||
const targetData = target.data;
|
||||
|
||||
if (!singleRefImageDndSource.typeGuard(sourceData) || !singleRefImageDndSource.typeGuard(targetData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexOfSource = ids.indexOf(sourceData.payload.id);
|
||||
const indexOfTarget = ids.indexOf(targetData.payload.id);
|
||||
|
||||
if (indexOfTarget < 0 || indexOfSource < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (indexOfSource === indexOfTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closestEdgeOfTarget = extractClosestEdge(targetData);
|
||||
|
||||
let edgeIndexDelta = 0;
|
||||
if (closestEdgeOfTarget === 'right') {
|
||||
edgeIndexDelta = 1;
|
||||
} else if (closestEdgeOfTarget === 'left') {
|
||||
edgeIndexDelta = -1;
|
||||
}
|
||||
|
||||
if (indexOfSource === indexOfTarget + edgeIndexDelta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIds = reorderWithEdge({
|
||||
list: ids,
|
||||
startIndex: indexOfSource,
|
||||
indexOfTarget,
|
||||
closestEdgeOfTarget,
|
||||
axis: 'horizontal',
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
dispatch(refImagesReordered({ ids: nextIds }));
|
||||
});
|
||||
|
||||
const element = document.querySelector(`[data-ref-image-id="${sourceData.payload.id}"]`);
|
||||
if (element instanceof HTMLElement) {
|
||||
triggerPostMoveFlash(element, colorTokenToCssVar('base.700'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [dispatch, ids]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex, Icon, IconButton, Skeleton, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, Icon, IconButton, Skeleton, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { round } from 'es-toolkit/compat';
|
||||
import { useRefImageDnd } from 'features/controlLayers/components/RefImage/useRefImageDnd';
|
||||
import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity';
|
||||
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
|
||||
import { selectMainModelConfig } from 'features/controlLayers/store/paramsSlice';
|
||||
@@ -12,7 +13,8 @@ import {
|
||||
} from 'features/controlLayers/store/refImagesSlice';
|
||||
import { isIPAdapterConfig } from 'features/controlLayers/store/types';
|
||||
import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiExclamationMarkBold, PiEyeSlashBold, PiImageBold } from 'react-icons/pi';
|
||||
import { useImageDTOFromCroppableImage } from 'services/api/endpoints/images';
|
||||
@@ -75,6 +77,8 @@ export const RefImagePreview = memo(() => {
|
||||
const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen);
|
||||
const [showWeightDisplay, setShowWeightDisplay] = useState(false);
|
||||
const isExternalModel = !!mainModelConfig && isExternalApiModelConfig(mainModelConfig);
|
||||
const dndRef = useRef<HTMLDivElement>(null);
|
||||
const [dndListState, isDragging] = useRefImageDnd(dndRef, id);
|
||||
|
||||
const imageDTO = useImageDTOFromCroppableImage(entity.config.image);
|
||||
|
||||
@@ -108,98 +112,113 @@ export const RefImagePreview = memo(() => {
|
||||
|
||||
if (!entity.config.image) {
|
||||
return (
|
||||
<IconButton
|
||||
aria-label={t('controlLayers.selectRefImage')}
|
||||
<Box
|
||||
ref={dndRef}
|
||||
position="relative"
|
||||
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}
|
||||
data-is-disabled={!entity.isEnabled}
|
||||
sx={sx}
|
||||
/>
|
||||
opacity={isDragging ? 0.3 : 1}
|
||||
data-ref-image-id={id}
|
||||
>
|
||||
<IconButton
|
||||
aria-label={t('controlLayers.selectRefImage')}
|
||||
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}
|
||||
data-is-disabled={!entity.isEnabled}
|
||||
sx={sx}
|
||||
/>
|
||||
<DndListDropIndicator dndState={dndListState} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip label={warnings.length > 0 ? <RefImageWarningTooltipContent warnings={warnings} /> : undefined}>
|
||||
<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={warnings.length > 0}
|
||||
data-is-disabled={!entity.isEnabled}
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
cursor="pointer"
|
||||
overflow="hidden"
|
||||
>
|
||||
{imageDTO ? (
|
||||
<img
|
||||
src={imageDTO.image_url}
|
||||
style={{ objectFit: 'contain', aspectRatio: '1 / 1', maxWidth: '100%', maxHeight: '100%' }}
|
||||
height={imageDTO.height}
|
||||
alt={imageDTO.image_name}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton h="full" aspectRatio="1/1" />
|
||||
)}
|
||||
{isIPAdapterConfig(entity.config) && !isExternalModel && (
|
||||
<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.isEnabled && (
|
||||
<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="base.300"
|
||||
boxSize={8}
|
||||
as={PiEyeSlashBold}
|
||||
/>
|
||||
)}
|
||||
{entity.isEnabled && warnings.length > 0 && (
|
||||
<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={12}
|
||||
as={PiExclamationMarkBold}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<Box ref={dndRef} position="relative" h="full" flexShrink={0} opacity={isDragging ? 0.3 : 1} data-ref-image-id={id}>
|
||||
<Tooltip label={warnings.length > 0 ? <RefImageWarningTooltipContent warnings={warnings} /> : undefined}>
|
||||
<Flex
|
||||
position="relative"
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderRadius="base"
|
||||
aspectRatio="1/1"
|
||||
maxW="full"
|
||||
h="full"
|
||||
maxH="full"
|
||||
flexShrink={0}
|
||||
sx={sx}
|
||||
data-is-open={selectedEntityId === id && isPanelOpen}
|
||||
data-is-error={warnings.length > 0}
|
||||
data-is-disabled={!entity.isEnabled}
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
cursor="pointer"
|
||||
overflow="hidden"
|
||||
>
|
||||
{imageDTO ? (
|
||||
<img
|
||||
src={imageDTO.image_url}
|
||||
style={{ objectFit: 'contain', aspectRatio: '1 / 1', maxWidth: '100%', maxHeight: '100%' }}
|
||||
height={imageDTO.height}
|
||||
alt={imageDTO.image_name}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton h="full" aspectRatio="1/1" />
|
||||
)}
|
||||
{isIPAdapterConfig(entity.config) && !isExternalModel && (
|
||||
<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.isEnabled && (
|
||||
<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="base.300"
|
||||
boxSize={8}
|
||||
as={PiEyeSlashBold}
|
||||
/>
|
||||
)}
|
||||
{entity.isEnabled && warnings.length > 0 && (
|
||||
<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={12}
|
||||
as={PiExclamationMarkBold}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<DndListDropIndicator dndState={dndListState} />
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
RefImagePreview.displayName = 'RefImagePreview';
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
import { singleRefImageDndSource } from 'features/dnd/dnd';
|
||||
import { type DndListTargetState, idle } from 'features/dnd/types';
|
||||
import { firefoxDndFix } from 'features/dnd/util';
|
||||
import type { RefObject } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useRefImageDnd = (ref: RefObject<HTMLElement>, id: string) => {
|
||||
const [dndListState, setDndListState] = useState<DndListTargetState>(idle);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
return combine(
|
||||
firefoxDndFix(element),
|
||||
draggable({
|
||||
element,
|
||||
getInitialData() {
|
||||
return singleRefImageDndSource.getData({ id });
|
||||
},
|
||||
onDragStart() {
|
||||
setDndListState({ type: 'is-dragging' });
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop() {
|
||||
setDndListState(idle);
|
||||
setIsDragging(false);
|
||||
},
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop({ source }) {
|
||||
if (!singleRefImageDndSource.typeGuard(source.data)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getData({ input }) {
|
||||
const data = singleRefImageDndSource.getData({ id });
|
||||
return attachClosestEdge(data, {
|
||||
element,
|
||||
input,
|
||||
allowedEdges: ['left', 'right'],
|
||||
});
|
||||
},
|
||||
getIsSticky() {
|
||||
return true;
|
||||
},
|
||||
onDragEnter({ self }) {
|
||||
const closestEdge = extractClosestEdge(self.data);
|
||||
setDndListState({ type: 'is-dragging-over', closestEdge });
|
||||
},
|
||||
onDrag({ self }) {
|
||||
const closestEdge = extractClosestEdge(self.data);
|
||||
|
||||
setDndListState((current) => {
|
||||
if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
|
||||
return current;
|
||||
}
|
||||
return { type: 'is-dragging-over', closestEdge };
|
||||
});
|
||||
},
|
||||
onDragLeave() {
|
||||
setDndListState(idle);
|
||||
},
|
||||
onDrop() {
|
||||
setDndListState(idle);
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [id, ref]);
|
||||
|
||||
return [dndListState, isDragging] as const;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { refImagesReordered, refImagesSliceConfig } from './refImagesSlice';
|
||||
import type { RefImagesState } from './types';
|
||||
import { getReferenceImageState } from './util';
|
||||
|
||||
const buildState = (ids: string[]): RefImagesState => ({
|
||||
selectedEntityId: ids[0] ?? null,
|
||||
isPanelOpen: false,
|
||||
entities: ids.map((id) => getReferenceImageState(id)),
|
||||
});
|
||||
|
||||
describe('refImagesSlice', () => {
|
||||
const { reducer } = refImagesSliceConfig.slice;
|
||||
|
||||
describe('refImagesReordered', () => {
|
||||
it('reorders entities to match the provided id order', () => {
|
||||
const state = buildState(['a', 'b', 'c']);
|
||||
const result = reducer(state, refImagesReordered({ ids: ['c', 'a', 'b'] }));
|
||||
expect(result.entities.map((e) => e.id)).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('swaps two entities', () => {
|
||||
const state = buildState(['a', 'b']);
|
||||
const result = reducer(state, refImagesReordered({ ids: ['b', 'a'] }));
|
||||
expect(result.entities.map((e) => e.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('reverses the list', () => {
|
||||
const state = buildState(['a', 'b', 'c', 'd']);
|
||||
const result = reducer(state, refImagesReordered({ ids: ['d', 'c', 'b', 'a'] }));
|
||||
expect(result.entities.map((e) => e.id)).toEqual(['d', 'c', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('preserves entity config when reordering', () => {
|
||||
const state = buildState(['a', 'b']);
|
||||
state.entities[0]!.isEnabled = false;
|
||||
const result = reducer(state, refImagesReordered({ ids: ['b', 'a'] }));
|
||||
const movedA = result.entities.find((e) => e.id === 'a');
|
||||
expect(movedA?.isEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op when the ids length does not match the entities length', () => {
|
||||
const state = buildState(['a', 'b', 'c']);
|
||||
const result = reducer(state, refImagesReordered({ ids: ['a', 'b'] }));
|
||||
expect(result.entities.map((e) => e.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('is a no-op when ids contain an unknown id', () => {
|
||||
const state = buildState(['a', 'b', 'c']);
|
||||
const result = reducer(state, refImagesReordered({ ids: ['a', 'b', 'x'] }));
|
||||
expect(result.entities.map((e) => e.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('is a no-op when ids contain a duplicate', () => {
|
||||
// Duplicates imply one of the original ids is missing, so length-or-map-lookup fails.
|
||||
const state = buildState(['a', 'b', 'c']);
|
||||
const result = reducer(state, refImagesReordered({ ids: ['a', 'a', 'b'] }));
|
||||
expect(result.entities.map((e) => e.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('handles an empty list', () => {
|
||||
const state = buildState([]);
|
||||
const result = reducer(state, refImagesReordered({ ids: [] }));
|
||||
expect(result.entities).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not change selectedEntityId or isPanelOpen', () => {
|
||||
const state: RefImagesState = {
|
||||
...buildState(['a', 'b', 'c']),
|
||||
selectedEntityId: 'b',
|
||||
isPanelOpen: true,
|
||||
};
|
||||
const result = reducer(state, refImagesReordered({ ids: ['c', 'b', 'a'] }));
|
||||
expect(result.selectedEntityId).toBe('b');
|
||||
expect(result.isPanelOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -284,6 +284,25 @@ const slice = createSlice({
|
||||
entity.config = { ...config, image: entity.config.image };
|
||||
},
|
||||
refImagesReset: () => getInitialRefImagesState(),
|
||||
refImagesReordered: (state, action: PayloadAction<{ ids: string[] }>) => {
|
||||
const { ids } = action.payload;
|
||||
if (ids.length !== state.entities.length) {
|
||||
return;
|
||||
}
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
return;
|
||||
}
|
||||
const byId = new Map(state.entities.map((e) => [e.id, e]));
|
||||
const next: RefImageState[] = [];
|
||||
for (const id of ids) {
|
||||
const entity = byId.get(id);
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
next.push(entity);
|
||||
}
|
||||
state.entities = next;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -301,6 +320,7 @@ export const {
|
||||
refImageFLUXReduxImageInfluenceChanged,
|
||||
refImageIsEnabledToggled,
|
||||
refImagesRecalled,
|
||||
refImagesReordered,
|
||||
} = slice.actions;
|
||||
|
||||
export const refImagesSliceConfig: SliceConfig<typeof slice> = {
|
||||
|
||||
@@ -97,6 +97,16 @@ export const multipleImageDndSource: DndSource<MultipleImageDndSourceData> = {
|
||||
};
|
||||
//#endregion
|
||||
|
||||
//#region Single Reference Image (reorder)
|
||||
const _singleRefImage = buildTypeAndKey('single-ref-image');
|
||||
type SingleRefImageDndSourceData = DndData<typeof _singleRefImage.type, typeof _singleRefImage.key, { id: string }>;
|
||||
export const singleRefImageDndSource: DndSource<SingleRefImageDndSourceData> = {
|
||||
..._singleRefImage,
|
||||
typeGuard: buildTypeGuard(_singleRefImage.key),
|
||||
getData: buildGetData(_singleRefImage.key, _singleRefImage.type),
|
||||
};
|
||||
//#endregion
|
||||
|
||||
const _singleCanvasEntity = buildTypeAndKey('single-canvas-entity');
|
||||
type SingleCanvasEntityDndSourceData = DndData<
|
||||
typeof _singleCanvasEntity.type,
|
||||
|
||||
Reference in New Issue
Block a user