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 918a9acda4b..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,9 @@ +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-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,26 +12,72 @@ 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'; import { RefImageHeader } from './RefImageHeader'; +import { getReorderedRefImageIds } from './RefImageList.helpers'; import { RefImageSettings } from './RefImageSettings'; 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 nextIds = getReorderedRefImageIds({ + ids, + sourceId: sourceData.payload.id, + targetId: targetData.payload.id, + closestEdgeOfTarget: extractClosestEdge(targetData), + }); + + if (nextIds === null) { + return; + } + + 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..becf7e4c4d1 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'; @@ -49,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)); @@ -75,6 +84,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 +119,124 @@ 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} + sx={wrapperSx} + > + } + 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..c7efd1e7eba --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/useRefImageDnd.ts @@ -0,0 +1,82 @@ +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); + }, + // 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); + }, + }), + 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,