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