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:
Lincoln Stein
2026-04-20 20:56:34 -04:00
parent b2d79dc86c
commit 62ae599cef
6 changed files with 369 additions and 92 deletions

View File

@@ -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">

View File

@@ -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';

View File

@@ -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;
};

View File

@@ -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);
});
});
});

View File

@@ -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> = {

View File

@@ -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,