From 62ae599cefe1fa9f2ee469633fd7590c0b599617 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 20 Apr 2026 20:56:34 -0400 Subject: [PATCH 1/4] 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 918a9acda4b..ef840c98697 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 f0a9948de4d..8dbab1ce369 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 00000000000..37dc60a9bd3 --- /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 00000000000..2565ee16293 --- /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 1ea76262909..b7026b586a8 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 ee648e82ef6..8ed60799407 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, From 1b796ad5b372dbcb8ac8961c9fad472954e55aca Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 20 Apr 2026 21:05:14 -0400 Subject: [PATCH 2/4] fix(ui): give ref image wrapper explicit aspect ratio for iOS WebKit iOS WebKit collapses a flex item to zero width when the width is only implied by a child's aspect ratio. Set aspectRatio on the wrapper Box directly so the thumbnail tile sizes correctly on iPad Chrome/Safari. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/RefImage/RefImagePreview.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 8dbab1ce369..e972f592954 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx @@ -116,6 +116,7 @@ export const RefImagePreview = memo(() => { ref={dndRef} position="relative" h="full" + aspectRatio="1/1" flexShrink={0} opacity={isDragging ? 0.3 : 1} data-ref-image-id={id} @@ -143,7 +144,15 @@ export const RefImagePreview = memo(() => { ); } return ( - + 0 ? : undefined}> Date: Mon, 20 Apr 2026 21:13:04 -0400 Subject: [PATCH 3/4] fix(ui): suppress iOS long-press callout on ref image thumbnails The default iOS "Save Image" / "Copy" callout fires on long-press over the thumbnail, which interferes with drag attempts on iPad. Scope the suppression (WebkitTouchCallout + userSelect) to the ref image wrapper only, leaving gallery and other image views unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/RefImage/RefImagePreview.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) 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 e972f592954..becf7e4c4d1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx @@ -51,6 +51,13 @@ const weightDisplaySx: SystemStyleObject = { }, }; +// Scoped to ref image thumbnails only: prevents the iOS long-press "Save Image" +// callout from hijacking drag attempts on iPad. +const wrapperSx: SystemStyleObject = { + WebkitTouchCallout: 'none', + userSelect: 'none', +}; + const getImageSxWithWeight = (weight: number): SystemStyleObject => { const fillPercentage = Math.max(0, Math.min(100, weight * 100)); @@ -120,6 +127,7 @@ export const RefImagePreview = memo(() => { flexShrink={0} opacity={isDragging ? 0.3 : 1} data-ref-image-id={id} + sx={wrapperSx} > { flexShrink={0} opacity={isDragging ? 0.3 : 1} data-ref-image-id={id} + sx={wrapperSx} > 0 ? : undefined}> Date: Mon, 4 May 2026 22:46:31 -0400 Subject: [PATCH 4/4] test(refImageList): extract reorder helper and cover edge cases Pulls the onDrop decision logic out of RefImageList into a pure getReorderedRefImageIds helper so it can be unit-tested without DOM, and adds coverage for the (sourceIndex, targetIndex, edge) matrix including the closestEdgeOfTarget=null path. Also documents that pragmatic-dnd's onDrop fires on cancel, so the single isDragging reset in useRefImageDnd is sufficient. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../RefImage/RefImageList.helpers.test.ts | 192 ++++++++++++++++++ .../RefImage/RefImageList.helpers.ts | 60 ++++++ .../components/RefImage/RefImageList.tsx | 37 +--- .../components/RefImage/useRefImageDnd.ts | 3 + 4 files changed, 263 insertions(+), 29 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.helpers.test.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.helpers.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.helpers.test.ts b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.helpers.test.ts new file mode 100644 index 00000000000..573aa37b06f --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.helpers.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; + +import { getReorderedRefImageIds } from './RefImageList.helpers'; + +const IDS = ['a', 'b', 'c', 'd']; + +describe('getReorderedRefImageIds', () => { + describe('no-op cases', () => { + it('returns null when sourceId is not in the list', () => { + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'missing', + targetId: 'b', + closestEdgeOfTarget: 'left', + }); + expect(result).toBeNull(); + }); + + it('returns null when targetId is not in the list', () => { + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'a', + targetId: 'missing', + closestEdgeOfTarget: 'left', + }); + expect(result).toBeNull(); + }); + + it('returns null when sourceId and targetId are the same', () => { + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'b', + targetId: 'b', + closestEdgeOfTarget: 'left', + }); + expect(result).toBeNull(); + }); + + it('returns null when source is already immediately to the left of target with edge=left', () => { + // 'a' is at index 0, 'b' is at index 1. Dropping 'a' on the left edge of 'b' is a no-op. + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'a', + targetId: 'b', + closestEdgeOfTarget: 'left', + }); + expect(result).toBeNull(); + }); + + it('returns null when source is already immediately to the right of target with edge=right', () => { + // 'b' is at index 1, 'a' is at index 0. Dropping 'b' on the right edge of 'a' is a no-op. + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'b', + targetId: 'a', + closestEdgeOfTarget: 'right', + }); + expect(result).toBeNull(); + }); + }); + + describe('forward moves (sourceIndex < targetIndex)', () => { + it('moves source after target when edge=right', () => { + // Move 'a' (0) to the right of 'c' (2) → ['b','c','a','d'] + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'a', + targetId: 'c', + closestEdgeOfTarget: 'right', + }); + expect(result).toEqual(['b', 'c', 'a', 'd']); + }); + + it('moves source before target when edge=left', () => { + // Move 'a' (0) to the left of 'c' (2) → ['b','a','c','d'] + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'a', + targetId: 'c', + closestEdgeOfTarget: 'left', + }); + expect(result).toEqual(['b', 'a', 'c', 'd']); + }); + }); + + describe('backward moves (sourceIndex > targetIndex)', () => { + it('moves source after target when edge=right', () => { + // Move 'd' (3) to the right of 'a' (0) → ['a','d','b','c'] + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'd', + targetId: 'a', + closestEdgeOfTarget: 'right', + }); + expect(result).toEqual(['a', 'd', 'b', 'c']); + }); + + it('moves source before target when edge=left', () => { + // Move 'd' (3) to the left of 'b' (1) → ['a','d','b','c'] + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'd', + targetId: 'b', + closestEdgeOfTarget: 'left', + }); + expect(result).toEqual(['a', 'd', 'b', 'c']); + }); + }); + + describe('null edge', () => { + it('moves source to the target index when closestEdgeOfTarget is null (forward)', () => { + // pragmatic-dnd's reorderWithEdge collapses null edge to indexOfTarget destination. + // Move 'a' (0) onto 'c' (2) with no edge → ['b','c','a','d'] + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'a', + targetId: 'c', + closestEdgeOfTarget: null, + }); + expect(result).toEqual(['b', 'c', 'a', 'd']); + }); + + it('moves source to the target index when closestEdgeOfTarget is null (backward)', () => { + // Move 'd' (3) onto 'b' (1) with no edge → ['a','d','b','c'] + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: 'd', + targetId: 'b', + closestEdgeOfTarget: null, + }); + expect(result).toEqual(['a', 'd', 'b', 'c']); + }); + }); + + describe('exhaustive (sourceIndex, targetIndex, edge) matrix', () => { + // For each ordered (source, target) pair where source !== target, verify the result for + // each possible edge. This catches any swap of 'left'/'right' or wrong axis handling. + type Edge = 'left' | 'right' | null; + const edges: Edge[] = ['left', 'right', null]; + + const expectedFor = (sourceIdx: number, targetIdx: number, edge: Edge): string[] | null => { + // Hand-rolled reference implementation — independent of the helper under test. + // Returns null for no-ops that mirror the helper's short-circuits. + if (sourceIdx === targetIdx) { + return null; + } + let edgeIndexDelta = 0; + if (edge === 'right') { + edgeIndexDelta = 1; + } else if (edge === 'left') { + edgeIndexDelta = -1; + } + if (sourceIdx === targetIdx + edgeIndexDelta) { + return null; + } + // Compute destination per pragmatic-dnd's `getReorderDestinationIndex` for axis='horizontal'. + let destination: number; + if (edge === null) { + destination = targetIdx; + } else { + const isGoingAfter = edge === 'right'; + const isMovingForward = sourceIdx < targetIdx; + if (isMovingForward) { + destination = isGoingAfter ? targetIdx : targetIdx - 1; + } else { + destination = isGoingAfter ? targetIdx + 1 : targetIdx; + } + } + const next = IDS.slice(); + const [moved] = next.splice(sourceIdx, 1); + next.splice(destination, 0, moved!); + return next; + }; + + for (let s = 0; s < IDS.length; s++) { + for (let t = 0; t < IDS.length; t++) { + for (const edge of edges) { + const label = `source=${s} target=${t} edge=${edge ?? 'null'}`; + it(label, () => { + const result = getReorderedRefImageIds({ + ids: IDS, + sourceId: IDS[s]!, + targetId: IDS[t]!, + closestEdgeOfTarget: edge, + }); + expect(result).toEqual(expectedFor(s, t, edge)); + }); + } + } + } + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.helpers.ts b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.helpers.ts new file mode 100644 index 00000000000..92c436b0d26 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.helpers.ts @@ -0,0 +1,60 @@ +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge'; + +type GetReorderedRefImageIdsParams = { + ids: string[]; + sourceId: string; + targetId: string; + closestEdgeOfTarget: Edge | null; +}; + +/** + * Computes the reordered id list for a horizontal ref-image drag-and-drop. + * + * Returns `null` for any drop that should be a no-op: + * - Source or target id not present in `ids`. + * - Source and target are the same item. + * - The item is already on the side of the target indicated by `closestEdgeOfTarget`. + * + * Notes on `closestEdgeOfTarget = null`: pragmatic-dnd's `extractClosestEdge` may return `null` + * when the hitbox util cannot determine a side. `reorderWithEdge` then treats the destination + * as `indexOfTarget` (i.e. moves the source onto the target's slot). The no-op short-circuit + * cannot fire in this case (`edgeIndexDelta` is 0, but `indexOfSource === indexOfTarget` was + * already rejected), so the move is forwarded to the util. + */ +export const getReorderedRefImageIds = ({ + ids, + sourceId, + targetId, + closestEdgeOfTarget, +}: GetReorderedRefImageIdsParams): string[] | null => { + const indexOfSource = ids.indexOf(sourceId); + const indexOfTarget = ids.indexOf(targetId); + + if (indexOfTarget < 0 || indexOfSource < 0) { + return null; + } + + if (indexOfSource === indexOfTarget) { + return null; + } + + let edgeIndexDelta = 0; + if (closestEdgeOfTarget === 'right') { + edgeIndexDelta = 1; + } else if (closestEdgeOfTarget === 'left') { + edgeIndexDelta = -1; + } + + if (indexOfSource === indexOfTarget + edgeIndexDelta) { + return null; + } + + return reorderWithEdge({ + list: ids, + startIndex: indexOfSource, + indexOfTarget, + closestEdgeOfTarget, + axis: 'horizontal', + }); +}; 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 ef840c98697..9145ea5f2c8 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,5 @@ 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 { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; @@ -30,6 +29,7 @@ import { PiBoundingBoxBold, PiUploadBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; import { RefImageHeader } from './RefImageHeader'; +import { getReorderedRefImageIds } from './RefImageList.helpers'; import { RefImageSettings } from './RefImageSettings'; export const RefImageList = memo(() => { @@ -56,38 +56,17 @@ export const RefImageList = memo(() => { 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; - } + const nextIds = getReorderedRefImageIds({ + ids, + sourceId: sourceData.payload.id, + targetId: targetData.payload.id, + closestEdgeOfTarget: extractClosestEdge(targetData), + }); - if (indexOfSource === indexOfTarget + edgeIndexDelta) { + if (nextIds === null) { return; } - const nextIds = reorderWithEdge({ - list: ids, - startIndex: indexOfSource, - indexOfTarget, - closestEdgeOfTarget, - axis: 'horizontal', - }); - flushSync(() => { dispatch(refImagesReordered({ ids: nextIds })); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageDnd.ts b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageDnd.ts index 37dc60a9bd3..c7efd1e7eba 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageDnd.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageDnd.ts @@ -27,6 +27,9 @@ export const useRefImageDnd = (ref: RefObject, id: string) => { setDndListState({ type: 'is-dragging' }); setIsDragging(true); }, + // Per pragmatic-dnd's contract, `onDrop` fires regardless of how the drag ended (explicit + // drop, cancel via Esc, drop on no targets, error recovery), so it is safe to use this as + // the single reset point for `isDragging`. onDrop() { setDndListState(idle); setIsDragging(false);