Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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));
});
}
}
}
});
});
Original file line number Diff line number Diff line change
@@ -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',
});
};
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<Flex flexDir="column">
Expand Down
Loading
Loading