From 62ae599cefe1fa9f2ee469633fd7590c0b599617 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 20 Apr 2026 20:56:34 -0400 Subject: [PATCH] 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 `` drag so pragmatic-dnd receives the gesture. - Adds unit tests for the new reducer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/RefImage/RefImageList.tsx | 76 ++++++- .../components/RefImage/RefImagePreview.tsx | 197 ++++++++++-------- .../components/RefImage/useRefImageDnd.ts | 79 +++++++ .../store/refImagesSlice.test.ts | 79 +++++++ .../controlLayers/store/refImagesSlice.ts | 20 ++ invokeai/frontend/web/src/features/dnd/dnd.ts | 10 + 6 files changed, 369 insertions(+), 92 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageDnd.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.test.ts 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 918a9acda4..ef840c9869 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx @@ -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 ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx index f0a9948de4..8dbab1ce36 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx @@ -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(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 ( - } - 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} + > + } + colorScheme="error" + onClick={onClick} + flexShrink={0} + data-is-open={selectedEntityId === id && isPanelOpen} + data-is-error={true} + data-is-disabled={!entity.isEnabled} + sx={sx} + /> + + ); } return ( - 0 ? : undefined}> - 0} - data-is-disabled={!entity.isEnabled} - role="button" - onClick={onClick} - cursor="pointer" - overflow="hidden" - > - {imageDTO ? ( - {imageDTO.image_name} - ) : ( - - )} - {isIPAdapterConfig(entity.config) && !isExternalModel && ( - - - {`${round(entity.config.weight * 100, 2)}%`} - - - )} - {!entity.isEnabled && ( - - )} - {entity.isEnabled && warnings.length > 0 && ( - - )} - - + + 0 ? : undefined}> + 0} + data-is-disabled={!entity.isEnabled} + role="button" + onClick={onClick} + cursor="pointer" + overflow="hidden" + > + {imageDTO ? ( + {imageDTO.image_name} + ) : ( + + )} + {isIPAdapterConfig(entity.config) && !isExternalModel && ( + + + {`${round(entity.config.weight * 100, 2)}%`} + + + )} + {!entity.isEnabled && ( + + )} + {entity.isEnabled && warnings.length > 0 && ( + + )} + + + + ); }); RefImagePreview.displayName = 'RefImagePreview'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageDnd.ts b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageDnd.ts new file mode 100644 index 0000000000..37dc60a9bd --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageDnd.ts @@ -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, id: string) => { + const [dndListState, setDndListState] = useState(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; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.test.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.test.ts new file mode 100644 index 0000000000..2565ee1629 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.test.ts @@ -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); + }); + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts index 1ea7626290..b7026b586a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts @@ -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 = { diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index ee648e82ef..8ed6079940 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -97,6 +97,16 @@ export const multipleImageDndSource: DndSource = { }; //#endregion +//#region Single Reference Image (reorder) +const _singleRefImage = buildTypeAndKey('single-ref-image'); +type SingleRefImageDndSourceData = DndData; +export const singleRefImageDndSource: DndSource = { + ..._singleRefImage, + typeGuard: buildTypeGuard(_singleRefImage.key), + getData: buildGetData(_singleRefImage.key, _singleRefImage.type), +}; +//#endregion + const _singleCanvasEntity = buildTypeAndKey('single-canvas-entity'); type SingleCanvasEntityDndSourceData = DndData< typeof _singleCanvasEntity.type,