Compare commits

...

3 Commits

Author SHA1 Message Date
Lincoln Stein
9536852dc7 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) <noreply@anthropic.com>
2026-04-20 21:13:04 -04:00
Lincoln Stein
1b796ad5b3 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) <noreply@anthropic.com>
2026-04-20 21:05:14 -04:00
Lincoln Stein
62ae599cef 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>
2026-04-20 20:56:34 -04:00
6 changed files with 386 additions and 91 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';
@@ -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<HTMLDivElement>(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 (
<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}
sx={wrapperSx}
>
<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"
aspectRatio="1/1"
flexShrink={0}
opacity={isDragging ? 0.3 : 1}
data-ref-image-id={id}
sx={wrapperSx}
>
<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,