refactor(ui): dnd actions to image actions

We don't need a "dnd" image system. We need a "image action" system. We need to execute specific flows with images from various "origins":
- internal dnd e.g. from gallery
- external dnd e.g. user drags an image file into the browser
- direct file upload e.g. user clicks an upload button
- some other internal app button e.g. a context menu

The actions are now generalized to better support these various use-cases.
This commit is contained in:
psychedelicious
2024-11-02 08:59:35 +10:00
parent 1afc2cba4e
commit 92f660018b
29 changed files with 726 additions and 923 deletions

View File

@@ -1,16 +1,17 @@
import { Grid, GridItem } from '@invoke-ai/ui-library';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { Dnd } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { newCanvasEntityFromImageActionApi } from 'features/imageActions/actions';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const addRasterLayerFromImageDndTargetData = Dnd.Target.newRasterLayerFromImage.getData();
const addControlLayerFromImageDndTargetData = Dnd.Target.newControlLayerFromImage.getData();
const addRegionalGuidanceReferenceImageFromImageDndTargetData =
Dnd.Target.newRegionalGuidanceReferenceImageFromImage.getData();
const addGlobalReferenceImageFromImageDndTargetData = Dnd.Target.newGlobalReferenceImageFromImage.getData();
const addRasterLayerFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ type: 'raster_layer' });
const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ type: 'control_layer' });
const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({
type: 'regional_guidance_with_reference_image',
});
const addGlobalReferenceImageFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ type: 'reference_image' });
export const CanvasDropArea = memo(() => {
const { t } = useTranslation();

View File

@@ -16,7 +16,7 @@ import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTi
import { entitiesReordered } from 'features/controlLayers/store/canvasSlice';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { isRenderableEntityType } from 'features/controlLayers/store/types';
import { triggerPostMoveFlash } from 'features/dnd/dnd';
import { triggerPostMoveFlash } from 'features/dnd/util';
import type { PropsWithChildren } from 'react';
import { memo, useEffect } from 'react';
import { flushSync } from 'react-dom';

View File

@@ -2,18 +2,26 @@ 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 type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import type { DndListState } from 'features/dnd/dnd';
import { buildDndSourceApi, idle } from 'features/dnd/dnd';
import { type DndListTargetState, idle } from 'features/dnd/types';
import type { ActionData, ActionSourceApi } from 'features/imageActions/actions';
import { buildGetData, buildTypeAndKey, buildTypeGuard } from 'features/imageActions/actions';
import type { RefObject } from 'react';
import { useEffect, useState } from 'react';
/**
* Dnd source API for a single canvas entity.
*/
export const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity');
const _singleCanvasEntity = buildTypeAndKey('single-canvas-entity');
type SingleCanvasEntitySourceData = ActionData<
typeof _singleCanvasEntity.type,
typeof _singleCanvasEntity.key,
{ entityIdentifier: CanvasEntityIdentifier }
>;
export const singleCanvasEntity: ActionSourceApi<SingleCanvasEntitySourceData> = {
..._singleCanvasEntity,
typeGuard: buildTypeGuard(_singleCanvasEntity.key),
getData: buildGetData(_singleCanvasEntity.key, _singleCanvasEntity.type),
};
export const useCanvasEntityListDnd = (ref: RefObject<HTMLElement>, entityIdentifier: CanvasEntityIdentifier) => {
const [dndListState, setDndListState] = useState<DndListState>(idle);
const [dndListState, setDndListState] = useState<DndListTargetState>(idle);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {

View File

@@ -109,7 +109,9 @@ export const CanvasMainPanelContent = memo(() => {
<SelectObject />
</CanvasManagerProviderGate>
</Flex>
<CanvasDropArea />
<CanvasManagerProviderGate>
<CanvasDropArea />
</CanvasManagerProviderGate>
<GatedImageViewer />
</Flex>
);

View File

@@ -6,10 +6,11 @@ import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHook
import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectEntityCountActive } from 'features/controlLayers/store/selectors';
import { Dnd } from 'features/dnd/dnd';
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
import type { DndTargetState } from 'features/dnd/types';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { multipleImageSourceApi, singleImageSourceApi } from 'features/imageActions/actions';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
@@ -84,8 +85,8 @@ const PanelTabs = memo(() => {
const { t } = useTranslation();
const store = useAppStore();
const activeEntityCount = useAppSelector(selectEntityCountActive);
const [layersTabDndState, setLayersTabDndState] = useState<Dnd.types['DndState']>('idle');
const [galleryTabDndState, setGalleryTabDndState] = useState<Dnd.types['DndState']>('idle');
const [layersTabDndState, setLayersTabDndState] = useState<DndTargetState>('idle');
const [galleryTabDndState, setGalleryTabDndState] = useState<DndTargetState>('idle');
const layersTabRef = useRef<HTMLDivElement>(null);
const galleryTabRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<number | null>(null);
@@ -208,7 +209,7 @@ const PanelTabs = memo(() => {
}),
monitorForElements({
canMonitor: ({ source }) => {
if (!Dnd.Source.singleImage.typeGuard(source.data) || !Dnd.Source.multipleImage.typeGuard(source.data)) {
if (!singleImageSourceApi.typeGuard(source.data) || !multipleImageSourceApi.typeGuard(source.data)) {
return false;
}
// Only monitor if we are not already on the gallery tab

View File

@@ -11,8 +11,9 @@ import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityA
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { Dnd } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import type { ReplaceCanvasEntityObjectsWithImageActionData} from 'features/imageActions/actions';
import {replaceCanvasEntityObjectsWithImageActionApi } from 'features/imageActions/actions';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,8 +28,8 @@ export const ControlLayer = memo(({ id }: Props) => {
() => ({ id, type: 'control_layer' }),
[id]
);
const targetData = useMemo<Dnd.types['TargetDataTypeMap']['replaceLayerWithImage']>(
() => Dnd.Target.replaceLayerWithImage.getData({ entityIdentifier }, entityIdentifier.id),
const targetData = useMemo<ReplaceCanvasEntityObjectsWithImageActionData>(
() => replaceCanvasEntityObjectsWithImageActionApi.getData({ entityIdentifier }, entityIdentifier.id),
[entityIdentifier]
);

View File

@@ -3,10 +3,10 @@ import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import type { ImageWithDims } from 'features/controlLayers/store/types';
import type { Dnd } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { DndImage } from 'features/dnd/DndImage';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import type { SetGlobalReferenceImageActionData, SetRegionalGuidanceReferenceImageActionData } from 'features/imageActions/actions';
import { memo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
@@ -31,7 +31,7 @@ const sx = {
type Props = {
image: ImageWithDims | null;
onChangeImage: (imageDTO: ImageDTO | null) => void;
targetData: Dnd.types['TargetDataUnion'];
targetData: SetGlobalReferenceImageActionData | SetRegionalGuidanceReferenceImageActionData;
};
export const IPAdapterImagePreview = memo(({ image, onChangeImage, targetData }: Props) => {

View File

@@ -19,7 +19,8 @@ import {
import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types';
import { Dnd } from 'features/dnd/dnd';
import type { SetGlobalReferenceImageActionData} from 'features/imageActions/actions';
import {setGlobalReferenceImageActionApi } from 'features/imageActions/actions';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold } from 'react-icons/pi';
@@ -80,8 +81,8 @@ export const IPAdapterSettings = memo(() => {
[dispatch, entityIdentifier]
);
const targetData = useMemo<Dnd.types['TargetDataTypeMap']['setGlobalReferenceImage']>(
() => Dnd.Target.setGlobalReferenceImage.getData({ entityIdentifier }, ipAdapter.image?.image_name),
const targetData = useMemo<SetGlobalReferenceImageActionData>(
() => setGlobalReferenceImageActionApi.getData({ entityIdentifier }, ipAdapter.image?.image_name),
[entityIdentifier, ipAdapter.image?.image_name]
);
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier);

View File

@@ -8,8 +8,9 @@ import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAd
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { Dnd } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import type { ReplaceCanvasEntityObjectsWithImageActionData} from 'features/imageActions/actions';
import {replaceCanvasEntityObjectsWithImageActionApi } from 'features/imageActions/actions';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -21,8 +22,8 @@ export const RasterLayer = memo(({ id }: Props) => {
const { t } = useTranslation();
const isBusy = useCanvasIsBusy();
const entityIdentifier = useMemo<CanvasEntityIdentifier<'raster_layer'>>(() => ({ id, type: 'raster_layer' }), [id]);
const targetData = useMemo<Dnd.types['TargetDataTypeMap']['replaceLayerWithImage']>(
() => Dnd.Target.replaceLayerWithImage.getData({ entityIdentifier }, entityIdentifier.id),
const targetData = useMemo<ReplaceCanvasEntityObjectsWithImageActionData>(
() => replaceCanvasEntityObjectsWithImageActionApi.getData({ entityIdentifier }, entityIdentifier.id),
[entityIdentifier]
);

View File

@@ -20,7 +20,8 @@ import {
} from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors';
import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types';
import { Dnd } from 'features/dnd/dnd';
import type { SetRegionalGuidanceReferenceImageActionData} from 'features/imageActions/actions';
import {setRegionalGuidanceReferenceImageActionApi } from 'features/imageActions/actions';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi';
@@ -91,12 +92,9 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro
[dispatch, entityIdentifier, referenceImageId]
);
const targetData = useMemo<Dnd.types['TargetDataTypeMap']['setRegionalGuidanceReferenceImage']>(
const targetData = useMemo<SetRegionalGuidanceReferenceImageActionData>(
() =>
Dnd.Target.setRegionalGuidanceReferenceImage.getData(
{ entityIdentifier, referenceImageId },
ipAdapter.image?.image_name
),
setRegionalGuidanceReferenceImageActionApi.getData({ entityIdentifier, referenceImageId }, ipAdapter.image?.image_name),
[entityIdentifier, ipAdapter.image?.image_name, referenceImageId]
);

View File

@@ -1,8 +1,8 @@
import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import { Flex, Heading } from '@invoke-ai/ui-library';
import type { Dnd } from 'features/dnd/dnd';
import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/dnd';
import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util';
import type { MultipleImageSourceData } from 'features/imageActions/actions';
import { memo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
@@ -39,7 +39,7 @@ export const createMultipleImageDragPreview = (arg: DndDragPreviewMultipleImageS
createPortal(<DndDragPreviewMultipleImage imageDTOs={arg.imageDTOs} />, arg.container);
type SetMultipleDragPreviewArg = {
multipleImageDndData: Dnd.types['SourceDataTypeMap']['multipleImage'];
multipleImageDndData: MultipleImageSourceData;
setDragPreviewState: (dragPreviewState: DndDragPreviewMultipleImageState | null) => void;
onGenerateDragPreviewArgs: Param0<Param0<typeof draggable>['onGenerateDragPreview']>;
};

View File

@@ -1,8 +1,8 @@
import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import { chakra, Flex } from '@invoke-ai/ui-library';
import type { Dnd } from 'features/dnd/dnd';
import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/dnd';
import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util';
import type { SingleImageSourceData } from 'features/imageActions/actions';
import { memo } from 'react';
import { createPortal } from 'react-dom';
import type { ImageDTO } from 'services/api/types';
@@ -37,7 +37,7 @@ export const createSingleImageDragPreview = (arg: DndDragPreviewSingleImageState
createPortal(<DndDragPreviewSingleImage imageDTO={arg.imageDTO} />, arg.container);
type SetSingleDragPreviewArg = {
singleImageDndData: Dnd.types['SourceDataTypeMap']['singleImage'];
singleImageDndData: SingleImageSourceData;
setDragPreviewState: (dragPreviewState: DndDragPreviewSingleImageState | null) => void;
onGenerateDragPreviewArgs: Param0<Param0<typeof draggable>['onGenerateDragPreview']>;
};

View File

@@ -1,10 +1,10 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, Text } from '@invoke-ai/ui-library';
import type { Dnd } from 'features/dnd/dnd';
import type { DndTargetState } from 'features/dnd/types';
import { memo } from 'react';
type Props = {
dndState: Dnd.types['DndState'];
dndState: DndTargetState;
label?: string;
withBackdrop?: boolean;
};

View File

@@ -5,9 +5,17 @@ import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/exter
import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box } from '@invoke-ai/ui-library';
import { getStore } from 'app/store/nanostores/store';
import { useAppDispatch } from 'app/store/storeHooks';
import { Dnd } from 'features/dnd/dnd';
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
import type { DndTargetState } from 'features/dnd/types';
import type { MultipleImageAction, RecordUnknown, SingleImageAction } from 'features/imageActions/actions';
import {
multipleImageActions,
multipleImageSourceApi,
singleImageActions,
singleImageSourceApi,
} from 'features/imageActions/actions';
import { memo, useEffect, useRef, useState } from 'react';
import { uploadImage } from 'services/api/endpoints/images';
import { z } from 'zod';
@@ -58,7 +66,7 @@ const zUploadFile = z
);
type Props = {
targetData: Dnd.types['TargetDataUnion'];
targetData: SingleImageAction | MultipleImageAction;
label: string;
externalLabel?: string;
isDisabled?: boolean;
@@ -66,31 +74,59 @@ type Props = {
export const DndDropTarget = memo((props: Props) => {
const { targetData, label, externalLabel = label, isDisabled } = props;
const [dndState, setDndState] = useState<Dnd.types['DndState']>('idle');
const [dndState, setDndState] = useState<DndTargetState>('idle');
const [dndOrigin, setDndOrigin] = useState<'element' | 'external' | null>(null);
const ref = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
useEffect(() => {
if (!ref.current) {
const element = ref.current;
if (!element) {
return;
}
if (isDisabled) {
return;
}
const { dispatch, getState } = getStore();
const isValidDrop = (sourceData: RecordUnknown, targetData: RecordUnknown) => {
if (singleImageSourceApi.typeGuard(sourceData)) {
for (const target of singleImageActions) {
if (target.typeGuard(targetData)) {
// TS cannot infer `targetData` but we've just checked it. This is safe.
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
if (target.isValid(sourceData, targetData as any, dispatch, getState)) {
return true;
}
}
}
}
if (multipleImageSourceApi.typeGuard(sourceData)) {
for (const target of multipleImageActions) {
if (target.typeGuard(targetData)) {
// TS cannot infer `targetData` but we've just checked it. This is safe.
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
if (target.isValid(sourceData, targetData as any, dispatch, getState)) {
return true;
}
}
}
}
return false;
};
return combine(
dropTargetForElements({
element: ref.current,
canDrop: (args) => {
const sourceData = args.source.data;
if (!Dnd.Util.isDndSourceData(sourceData)) {
element,
canDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.id === targetData.id) {
return false;
}
if (Dnd.Util.getDndId(targetData) === Dnd.Util.getDndId(sourceData)) {
return false;
}
return Dnd.Util.isValidDrop(sourceData, targetData);
return isValidDrop(sourceData, targetData);
},
onDragEnter: () => {
setDndState('over');
@@ -101,15 +137,12 @@ export const DndDropTarget = memo((props: Props) => {
getData: () => targetData,
}),
monitorForElements({
canMonitor: (args) => {
const sourceData = args.source.data;
if (!Dnd.Util.isDndSourceData(sourceData)) {
canMonitor: ({ source }) => {
const sourceData = source.data;
if (sourceData.id === targetData.id) {
return false;
}
if (Dnd.Util.getDndId(targetData) === Dnd.Util.getDndId(sourceData)) {
return false;
}
return Dnd.Util.isValidDrop(sourceData, targetData);
return isValidDrop(sourceData, targetData);
},
onDragStart: () => {
setDndOrigin('element');
@@ -124,17 +157,17 @@ export const DndDropTarget = memo((props: Props) => {
}, [targetData, dispatch, isDisabled]);
useEffect(() => {
if (!ref.current) {
const element = ref.current;
if (!element) {
return;
}
if (isDisabled) {
return;
}
return combine(
dropTargetForExternal({
element: ref.current,
element,
canDrop: (args) => {
if (!containsFiles(args)) {
return false;
@@ -162,7 +195,7 @@ export const DndDropTarget = memo((props: Props) => {
image_category: 'user',
is_intermediate: false,
});
Dnd.Util.handleDrop(Dnd.Source.singleImage.getData({ imageDTO }), targetData);
// Dnd.Util.handleDrop(Dnd.Source.singleImage.getData({ imageDTO }), targetData);
}
},
}),

View File

@@ -2,10 +2,10 @@ import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Image } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/nanostores/store';
import { Dnd } from 'features/dnd/dnd';
import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage';
import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
import { singleImageSourceApi } from 'features/imageActions/actions';
import { memo, useEffect, useState } from 'react';
import type { ImageDTO } from 'services/api/types';
@@ -36,7 +36,7 @@ export const DndImage = memo(({ imageDTO, ...rest }: Props) => {
}
return draggable({
element,
getInitialData: () => Dnd.Source.singleImage.getData({ imageDTO }, imageDTO.image_name),
getInitialData: () => singleImageSourceApi.getData({ imageDTO }, imageDTO.image_name),
onDragStart: () => {
setIsDragging(true);
},
@@ -44,7 +44,7 @@ export const DndImage = memo(({ imageDTO, ...rest }: Props) => {
setIsDragging(false);
},
onGenerateDragPreview: (args) => {
if (Dnd.Source.singleImage.typeGuard(args.source.data)) {
if (singleImageSourceApi.typeGuard(args.source.data)) {
setSingleImageDragPreview({
singleImageDndData: args.source.data,
onGenerateDragPreviewArgs: args,

View File

@@ -2,7 +2,7 @@
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box } from '@invoke-ai/ui-library';
import type { DndListState } from 'features/dnd/dnd';
import type { DndListTargetState } from 'features/dnd/types';
/**
* Design decisions for the drop indicator's main line
@@ -104,7 +104,7 @@ function DndDropIndicatorInternal({ edge, gap = '0px' }: DropIndicatorProps) {
);
}
export const DndListDropIndicator = ({ dndState }: { dndState: DndListState }) => {
export const DndListDropIndicator = ({ dndState }: { dndState: DndListTargetState }) => {
if (dndState.type !== 'is-dragging-over') {
return null;
}

View File

@@ -1,795 +0,0 @@
/* eslint-disable @typescript-eslint/no-namespace */ // We will use namespaces to organize the Dnd types
import type { Input } from '@atlaskit/pragmatic-drag-and-drop/dist/types/entry-point/types';
import type { GetOffsetFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/public-utils/element/custom-native-drag-preview/types';
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/closest-edge';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { getStore } from 'app/store/nanostores/store';
import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import {
controlLayerAdded,
entityRasterized,
inpaintMaskAdded,
rasterLayerAdded,
referenceImageAdded,
referenceImageIPAdapterImageChanged,
rgAdded,
rgIPAdapterImageChanged,
} from 'features/controlLayers/store/canvasSlice';
import { selectBboxRect } from 'features/controlLayers/store/selectors';
import type {
CanvasControlLayerState,
CanvasEntityIdentifier,
CanvasInpaintMaskState,
CanvasRasterLayerState,
CanvasReferenceImageState,
CanvasRegionalGuidanceState,
} from 'features/controlLayers/store/types';
import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import type { BoardId } from 'features/gallery/store/types';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import type { CSSProperties } from 'react';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { ValueOf } from 'type-fest';
import type { Jsonifiable } from 'type-fest/source/jsonifiable';
/**
* This file contains types, APIs, and utilities for Dnd functionality, as provided by pragmatic-drag-and-drop:
* - Source and target data types
* - Builders for source and target data types, which create type guards, data-getters and validation functions
* - Other utilities for working with Dnd data
* - A function to validate whether a drop is valid, given the source and target data
*
* See:
* - https://github.com/atlassian/pragmatic-drag-and-drop
* - https://atlassian.design/components/pragmatic-drag-and-drop/about
*/
/**
* A type for unknown Dnd data. `pragmatic-drag-and-drop` types all data as this type.
*/
type UnknownDndData = Record<string | symbol, unknown>;
/**
* A Dnd kind, which can be either a source or a target.
*/
type DndKind = 'source' | 'target';
/**
* Data for a given Dnd source or target, which contains metadata and payload.
* @template T The type string of the Dnd data. This should be unique for each type of Dnd data.
* @template K The kind of the Dnd data ('source' or 'target').
* @template P The optional payload of the Dnd data. This can be any "Jsonifiable" data - that is, data that can be
* serialized to JSON. This ensures the data can be safely stored in Redux, logged, etc.
*/
type DndData<
T extends string = string,
K extends DndKind = DndKind,
P extends Jsonifiable | undefined = Jsonifiable | undefined,
> = {
/**
* Metadata about the DndData.
*/
meta: {
/**
* An identifier for this data. This may or may not be unique. This is primarily used to prevent a source from
* dropping on itself.
*
* A consumer may be both a Dnd source and target of the same type. For example, the upscaling initial image is
* a Dnd target and may contain an image, which is itself a Dnd source. In this case, the Dnd ID is used to prevent
* the upscaling initial image (and other instances of that same image) from being dropped onto itself.
*
* This is accomplished by checking the Dnd ID of the source against the Dnd ID of the target. If they match, the
* drop is rejected.
*/
id: string;
/**
* The type of the DndData.
*/
type: T;
/**
* The kind of the DndData (source or target).
*/
kind: K;
};
/**
* The arbitrarily-shaped payload of the DndData.
*/
payload: P;
};
/**
* Builds a type guard for a specific DndData type.
* @template T The Dnd data type.
* @param type The type of the Dnd source or target data.
* @param kind The kind of the Dnd source or target data.
* @returns A type guard for the Dnd data.
*/
const _buildDataTypeGuard = <T extends DndData>(type: T['meta']['type'], kind: T['meta']['kind']) => {
// pragmatic-drag-and-drop types all data as unknown, so we need to cast it to the expected type
return (data: UnknownDndData): data is T => {
try {
return (data as DndData).meta.type === type && (data as DndData).meta.kind === kind;
} catch {
return false;
}
};
};
/**
* Builds a getter for a specific DndData type.
*
* The getter accepts arbitrary data and an optional Dnd ID. If no Dnd ID is provided, a unique one is generated.
*
* @template T The Dnd data type.
* @param type The type of the Dnd source or target data.
* @param kind The kind of the Dnd source or target data.
* @returns A getter for the DndData type.
*/
const _buildDataGetter =
<T extends DndData>(type: T['meta']['type'], kind: T['meta']['kind']) =>
(payload: T['payload'] extends undefined ? void : T['payload'], dndId?: string | null): T => {
return {
meta: {
id: dndId ?? getPrefixedId(`dnd-${kind}-${type}`),
type,
kind,
},
payload,
} as T;
};
/**
* The API for a Dnd source.
*/
type DndSourceAPI<T extends DndData> = {
/**
* The type of the Dnd source.
*/
type: string;
/**
* The kind of the Dnd source. It is always 'source'.
*/
kind: 'source';
/**
* A type guard for the DndData type.
* @param data The data to check.
* @returns Whether the data is of the DndData type.
*/
typeGuard: ReturnType<typeof _buildDataTypeGuard<T>>;
/**
* Gets a typed DndData object for the parent type.
* @param payload The payload for this DndData.
* @param dndId The Dnd ID to use. If not provided, a unique one is generated.
* @returns The DndData.
*/
getData: ReturnType<typeof _buildDataGetter<T>>;
};
/**
* Builds a Dnd source API.
* @template P The optional payload of the Dnd source.
* @param type The type of the Dnd source.
*/
export const buildDndSourceApi = <P extends Jsonifiable | undefined = undefined>(type: string) => {
return {
type,
kind: 'source',
typeGuard: _buildDataTypeGuard<DndData<typeof type, 'source', P>>(type, 'source'),
getData: _buildDataGetter<DndData<typeof type, 'source', P>>(type, 'source'),
} satisfies DndSourceAPI<DndData<typeof type, 'source', P>>;
};
//#region DndSourceData
/**
* Dnd source API for single image source.
*/
const singleImage = buildDndSourceApi<{ imageDTO: ImageDTO }>('SingleImage');
/**
* Dnd source API for multiple image source.
*/
const multipleImage = buildDndSourceApi<{ imageDTOs: ImageDTO[]; boardId: BoardId }>('MultipleImage');
const DndSource = {
singleImage,
multipleImage,
} as const;
type SourceDataTypeMap = {
[K in keyof typeof DndSource]: ReturnType<(typeof DndSource)[K]['getData']>;
};
/**
* A union of all possible DndSourceData types.
*/
type SourceDataUnion = ValueOf<SourceDataTypeMap>;
//#endregion
//#region DndTargetData
/**
* The API for a Dnd target.
*/
type DndTargetApi<T extends DndData> = DndSourceAPI<T> & {
/**
* Validates whether a drop is valid, give the source and target data.
* @param sourceData The source data (i.e. the data being dragged)
* @param targetData The target data (i.e. the data being dragged onto)
* @returns Whether the drop is valid.
*/
validateDrop: (sourceData: DndData<string, 'source', Jsonifiable>, targetData: T) => boolean;
handleDrop: (sourceData: DndData<string, 'source', Jsonifiable>, targetData: T) => void;
};
/**
* Builds a Dnd target API.
* @template P The optional payload of the Dnd target.
* @param type The type of the Dnd target.
* @param validateDrop A function that validates whether a drop is valid.
*/
const buildDndTargetApi = <P extends Jsonifiable | undefined = undefined>(
type: string,
validateDrop: (
sourceData: DndData<string, 'source', Jsonifiable>,
targetData: DndData<typeof type, 'target', P>
) => boolean,
handleDrop: (
sourceData: DndData<string, 'source', Jsonifiable>,
targetData: DndData<typeof type, 'target', P>
) => void
) => {
return {
type,
kind: 'source',
typeGuard: _buildDataTypeGuard<DndData<typeof type, 'target', P>>(type, 'target'),
getData: _buildDataGetter<DndData<typeof type, 'target', P>>(type, 'target'),
validateDrop,
handleDrop,
} satisfies DndTargetApi<DndData<typeof type, 'target', P>>;
};
/**
* Dnd target API for setting the image on an existing Global Reference Image layer.
*/
const setGlobalReferenceImage = buildDndTargetApi<{ entityIdentifier: CanvasEntityIdentifier<'reference_image'> }>(
'SetGlobalReferenceImage',
singleImage.typeGuard,
(sourceData, targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch } = getStore();
const { imageDTO } = sourceData.payload;
const { entityIdentifier } = targetData.payload;
dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO }));
}
);
/**
* Dnd target API for setting the image on an existing Regional Guidance layer's Reference Image.
*/
const setRegionalGuidanceReferenceImage = buildDndTargetApi<{
entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>;
referenceImageId: string;
}>('SetRegionalGuidanceReferenceImage', singleImage.typeGuard, (sourceData, targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch } = getStore();
const { imageDTO } = sourceData.payload;
const { entityIdentifier, referenceImageId } = targetData.payload;
dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO }));
});
/**
* Dnd target API for creating a new a Raster Layer from an image.
*/
const newRasterLayerFromImage = buildDndTargetApi(
'NewRasterLayerFromImage',
singleImage.typeGuard,
(sourceData, _targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch, getState } = getStore();
const { imageDTO } = sourceData.payload;
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectBboxRect(getState());
const overrides: Partial<CanvasRasterLayerState> = {
objects: [imageObject],
position: { x, y },
};
dispatch(rasterLayerAdded({ overrides, isSelected: true }));
}
);
/**
* Dnd target API for creating a new a Control Layer from an image.
*/
const newControlLayerFromImage = buildDndTargetApi(
'NewControlLayerFromImage',
singleImage.typeGuard,
(sourceData, _targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { imageDTO } = sourceData.payload;
const { dispatch, getState } = getStore();
const state = getState();
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectBboxRect(state);
const controlAdapter = selectDefaultControlAdapter(state);
const overrides: Partial<CanvasControlLayerState> = {
objects: [imageObject],
position: { x, y },
controlAdapter,
};
dispatch(controlLayerAdded({ overrides, isSelected: true }));
}
);
/**
* Dnd target API for adding an Inpaint Mask from an image.
*/
const newInpaintMaskFromImage = buildDndTargetApi(
'NewInpaintMaskFromImage',
singleImage.typeGuard,
(sourceData, _targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch, getState } = getStore();
const { imageDTO } = sourceData.payload;
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectBboxRect(getState());
const overrides: Partial<CanvasInpaintMaskState> = {
objects: [imageObject],
position: { x, y },
};
dispatch(inpaintMaskAdded({ overrides, isSelected: true }));
}
);
/**
* Dnd target API for adding a new Global Reference Image layer with a pre-set Reference Image from an image.
*/
const newGlobalReferenceImageFromImage = buildDndTargetApi(
'NewGlobalReferenceImageFromImage',
singleImage.typeGuard,
(sourceData, _targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch, getState } = getStore();
const { imageDTO } = sourceData.payload;
const ipAdapter = selectDefaultIPAdapter(getState());
ipAdapter.image = imageDTOToImageWithDims(imageDTO);
const overrides: Partial<CanvasReferenceImageState> = { ipAdapter };
dispatch(referenceImageAdded({ overrides, isSelected: true }));
}
);
/**
* Dnd target API for adding a new Regional Guidance layer from an image.
*/
const newRegionalGuidanceFromImage = buildDndTargetApi(
'NewRegionalGuidanceFromImage',
singleImage.typeGuard,
(sourceData, _targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch, getState } = getStore();
const { imageDTO } = sourceData.payload;
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectBboxRect(getState());
const overrides: Partial<CanvasRegionalGuidanceState> = {
objects: [imageObject],
position: { x, y },
};
dispatch(rgAdded({ overrides, isSelected: true }));
}
);
/**
* Dnd target API for adding a new Regional Guidance layer with a pre-set Reference Image from an image.
*/
const newRegionalGuidanceReferenceImageFromImage = buildDndTargetApi(
'NewRegionalGuidanceReferenceImageFromImage',
singleImage.typeGuard,
(sourceData, _targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch, getState } = getStore();
const { imageDTO } = sourceData.payload;
const ipAdapter = selectDefaultIPAdapter(getState());
ipAdapter.image = imageDTOToImageWithDims(imageDTO);
const overrides: Partial<CanvasRegionalGuidanceState> = {
referenceImages: [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }],
};
dispatch(rgAdded({ overrides, isSelected: true }));
}
);
/**
* Dnd target API for replacing the content of a layer with an image. This works for Control Layers, Raster Layers,
* Inpaint Masks, and Regional Guidance layers.
*/
const replaceLayerWithImage = buildDndTargetApi<{
entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>;
}>('ReplaceLayerWithImage', singleImage.typeGuard, (sourceData, targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch, getState } = getStore();
const { imageDTO } = sourceData.payload;
const { entityIdentifier } = targetData.payload;
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectBboxRect(getState());
dispatch(
entityRasterized({
entityIdentifier,
imageObject,
position: { x, y },
replaceObjects: true,
isSelected: true,
})
);
});
/**
* Dnd target API for setting the initial image on the upscaling tab.
*/
const setUpscaleInitialImageFromImage = buildDndTargetApi(
'SetUpscaleInitialImageFromImage',
singleImage.typeGuard,
(sourceData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch } = getStore();
const { imageDTO } = sourceData.payload;
dispatch(upscaleInitialImageChanged(imageDTO));
}
);
/**
* Dnd target API for setting an image field on a node.
*/
const setNodeImageField = buildDndTargetApi<{ nodeId: string; fieldName: string }>(
'SetNodeImageField',
singleImage.typeGuard,
(sourceData, targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch } = getStore();
const { imageDTO } = sourceData.payload;
const { fieldName, nodeId } = targetData.payload;
dispatch(fieldImageValueChanged({ nodeId, fieldName, value: imageDTO }));
}
);
/**
* Dnd target API for selecting images for comparison.
*/
const selectForCompare = buildDndTargetApi<{
firstImageName?: string | null;
secondImageName?: string | null;
}>(
'SelectForCompare',
(sourceData, targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
// Do not allow the same images to be selected for comparison
if (sourceData.payload.imageDTO.image_name === targetData.payload.firstImageName) {
return false;
}
if (sourceData.payload.imageDTO.image_name === targetData.payload.secondImageName) {
return false;
}
return true;
},
(sourceData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}
const { dispatch } = getStore();
const { imageDTO } = sourceData.payload;
dispatch(imageToCompareChanged(imageDTO));
}
);
/**
* Dnd target API for adding an image to a board.
*/
const addToBoard = buildDndTargetApi<{ boardId: string }>(
'AddToBoard',
(sourceData, targetData) => {
if (singleImage.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
if (multipleImage.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
return false;
},
(sourceData, targetData) => {
if (singleImage.typeGuard(sourceData)) {
const { dispatch } = getStore();
const { imageDTO } = sourceData.payload;
const { boardId } = targetData.payload;
dispatch(imagesApi.endpoints.addImageToBoard.initiate({ imageDTO, board_id: boardId }, { track: false }));
dispatch(selectionChanged([]));
}
if (multipleImage.typeGuard(sourceData)) {
const { dispatch } = getStore();
const { imageDTOs } = sourceData.payload;
const { boardId } = targetData.payload;
dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false }));
dispatch(selectionChanged([]));
}
}
);
/**
* Dnd target API for removing an image from a board.
*/
const removeFromBoard = buildDndTargetApi(
'RemoveFromBoard',
(sourceData) => {
if (singleImage.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
return currentBoard !== 'none';
}
if (multipleImage.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
return currentBoard !== 'none';
}
return false;
},
(sourceData) => {
if (singleImage.typeGuard(sourceData)) {
const { dispatch } = getStore();
const { imageDTO } = sourceData.payload;
dispatch(imagesApi.endpoints.removeImageFromBoard.initiate({ imageDTO }, { track: false }));
dispatch(selectionChanged([]));
}
if (multipleImage.typeGuard(sourceData)) {
const { dispatch } = getStore();
const { imageDTOs } = sourceData.payload;
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false }));
dispatch(selectionChanged([]));
}
}
);
const DndTarget = {
/**
* Set the image on an existing Global Reference Image layer.
*/
setGlobalReferenceImage,
setRegionalGuidanceReferenceImage,
// Add layer from image
newRasterLayerFromImage,
newControlLayerFromImage,
// Add a layer w/ ref image preset
newGlobalReferenceImageFromImage,
newRegionalGuidanceReferenceImageFromImage,
// Replace layer content w/ image
replaceLayerWithImage,
// Set the upscale image
setUpscaleInitialImageFromImage,
// Set a field on a node
setNodeImageField,
// Select images for comparison
selectForCompare,
// Add an image to a board
addToBoard,
// Remove an image from a board - essentially add to Uncategorized
removeFromBoard,
// These are currently unused
newRegionalGuidanceFromImage,
newInpaintMaskFromImage,
} as const;
type TargetDataTypeMap = {
[K in keyof typeof DndTarget]: ReturnType<(typeof DndTarget)[K]['getData']>;
};
type TargetDataUnion = ValueOf<TargetDataTypeMap>;
const targetApisArray = Object.values(DndTarget);
//#endregion
/**
* The Dnd namespace, providing types and APIs for Dnd functionality.
*/
export declare namespace Dnd {
export type types = {
/**
* A union of all Dnd states.
* - `idle`: No drag is occurring, or the drag is not valid for the current drop target.
* - `potential`: A drag is occurring, and the drag is valid for the current drop target, but the drag is not over the
* drop target.
* - `over`: A drag is occurring, and the drag is valid for the current drop target, and the drag is over the drop target.
*/
DndState: 'idle' | 'potential' | 'over';
/**
* A Dnd kind, which can be either a source or a target.
*/
DndKind: DndKind;
/**
* A type for unknown Dnd data. `pragmatic-drag-and-drop` types all data as this type.
*/
UnknownDndData: UnknownDndData;
/**
* A map of target APIs to their data types.
*/
SourceDataTypeMap: SourceDataTypeMap;
/**
* A union of all possible source data types.
*/
SourceDataUnion: SourceDataUnion;
/**
* A map of target APIs to their data types.
*/
TargetDataTypeMap: TargetDataTypeMap;
/**
* A union of all possible target data types.
*/
TargetDataUnion: TargetDataUnion;
};
}
export const Dnd = {
Source: DndSource,
Target: DndTarget,
Util: {
/**
* Gets the Dnd ID from a DndData object.
* @param data The DndData object.
* @returns The Dnd ID.
*/
getDndId: (data: DndData): string => {
return data.meta.id;
},
/**
* Checks if the data is a Dnd source data object.
* @param data The data to check.
*/
isDndSourceData: (data: UnknownDndData): data is SourceDataUnion => {
try {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
return (data as DndData).meta.kind === 'source';
} catch {
return false;
}
},
/**
* Checks if the data is a Dnd target data object.
* @param data The data to check.
*/
isDndTargetData: (data: UnknownDndData): data is TargetDataUnion => {
try {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
return (data as DndData).meta.kind === 'target';
} catch {
return false;
}
},
/**
* Validates whether a drop is valid.
* @param sourceData The data being dragged.
* @param targetData The data of the target being dragged onto.
* @returns Whether the drop is valid.
*/
isValidDrop: (sourceData: SourceDataUnion, targetData: TargetDataUnion): boolean => {
for (const targetApi of targetApisArray) {
if (targetApi.typeGuard(targetData)) {
/**
* TS cannot narrow the type of the targetApi and will error in the validator call.
* We've just checked that targetData is of the right type, though, so this cast to `any` is safe.
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
return targetApi.validateDrop(sourceData, targetData as any);
}
}
return false;
},
/**
* Validates whether a drop is valid.
* @param sourceData The data being dragged.
* @param targetData The data of the target being dragged onto.
* @returns Whether the drop is valid.
*/
handleDrop: (sourceData: SourceDataUnion, targetData: TargetDataUnion): void => {
for (const targetApi of targetApisArray) {
if (targetApi.typeGuard(targetData)) {
/**
* TS cannot narrow the type of the targetApi and will error in the handleDrop call.
* We've just checked that targetData is of the right type, though, so this cast to `any` is safe.
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
targetApi.handleDrop(sourceData, targetData as any);
return;
}
}
},
},
};
/**
* The size of the image drag preview in theme units.
*/
export const DND_IMAGE_DRAG_PREVIEW_SIZE = 32 satisfies SystemStyleObject['w'];
/**
* A drag preview offset function that works like the provided `preserveOffsetOnSource`, except when either the X or Y
* offset is outside the container, in which case it centers the preview in the container.
*/
export function preserveOffsetOnSourceFallbackCentered({
element,
input,
}: {
element: HTMLElement;
input: Input;
}): GetOffsetFn {
return ({ container }) => {
const sourceRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
let offsetX = input.clientX - sourceRect.x;
let offsetY = input.clientY - sourceRect.y;
if (offsetY > containerRect.height || offsetX > containerRect.width) {
offsetX = containerRect.width / 2;
offsetY = containerRect.height / 2;
}
return { x: offsetX, y: offsetY };
};
}
// Based on https://github.com/atlassian/pragmatic-drag-and-drop/blob/main/packages/flourish/src/trigger-post-move-flash.tsx
// That package has a lot of extra deps so we just copied the function here
export function triggerPostMoveFlash(element: HTMLElement, backgroundColor: CSSProperties['backgroundColor']) {
element.animate([{ backgroundColor }, {}], {
duration: 700,
easing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)',
iterations: 1,
});
}
export type DndListState =
| {
type: 'idle';
}
| {
type: 'preview';
container: HTMLElement;
}
| {
type: 'is-dragging';
}
| {
type: 'is-dragging-over';
closestEdge: Edge | null;
};
export const idle: DndListState = { type: 'idle' };

View File

@@ -0,0 +1,30 @@
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
/**
* States for a dnd target.
* - `idle`: No drag is occurring, or the drag is not valid for the current drop target.
* - `potential`: A drag is occurring, and the drag is valid for the current drop target, but the drag is not over the
* drop target.
* - `over`: A drag is occurring, and the drag is valid for the current drop target, and the drag is over the drop target.
*/
export type DndTargetState = 'idle' | 'potential' | 'over';
/**
* States for a dnd list.
*/
export type DndListTargetState =
| {
type: 'idle';
}
| {
type: 'preview';
container: HTMLElement;
}
| {
type: 'is-dragging';
}
| {
type: 'is-dragging-over';
closestEdge: Edge | null;
};
export const idle: DndListTargetState = { type: 'idle' };

View File

@@ -4,8 +4,10 @@ import { monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/a
import { containsFiles } from '@atlaskit/pragmatic-drag-and-drop/external/file';
import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled';
import { logger } from 'app/logging/logger';
import { getStore } from 'app/store/nanostores/store';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { Dnd } from 'features/dnd/dnd';
import { parseify } from 'common/util/serialize';
import { multipleImageSourceApi, multipleImageActions, singleImageSourceApi, singleImageActions } from 'features/imageActions/actions';
import { useEffect } from 'react';
const log = logger('dnd');
@@ -20,7 +22,7 @@ export const useDndMonitor = () => {
const sourceData = source.data;
// Check for allowed sources
if (!Dnd.Source.singleImage.typeGuard(sourceData) && !Dnd.Source.multipleImage.typeGuard(sourceData)) {
if (!singleImageSourceApi.typeGuard(sourceData) && !multipleImageSourceApi.typeGuard(sourceData)) {
return false;
}
@@ -35,19 +37,40 @@ export const useDndMonitor = () => {
const sourceData = source.data;
const targetData = target.data;
const { dispatch, getState } = getStore();
// Check for allowed sources
if (!Dnd.Source.singleImage.typeGuard(sourceData) && !Dnd.Source.multipleImage.typeGuard(sourceData)) {
return;
if (singleImageSourceApi.typeGuard(sourceData)) {
for (const target of singleImageActions) {
if (target.typeGuard(targetData)) {
// TS cannot infer `targetData` but we've just checked it. This is safe.
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
if (target.isValid(sourceData, targetData as any, dispatch, getState)) {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
target.handler(sourceData, targetData as any, dispatch, getState);
log.debug(parseify({ sourceData, targetData }), 'Dropped single image');
return;
}
}
}
}
// Check for allowed targets
if (!Dnd.Util.isDndTargetData(targetData)) {
return;
if (multipleImageSourceApi.typeGuard(sourceData)) {
for (const target of multipleImageActions) {
if (target.typeGuard(targetData)) {
// TS cannot infer `targetData` but we've just checked it. This is safe.
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
if (target.isValid(sourceData, targetData as any, dispatch, getState)) {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
target.handler(sourceData, targetData as any, dispatch, getState);
log.debug(parseify({ sourceData, targetData }), 'Dropped multiple images');
return;
}
}
}
}
log.debug({ sourceData, targetData }, 'Dropped image');
Dnd.Util.handleDrop(sourceData, targetData);
log.warn(parseify({ sourceData, targetData }), 'Invalid image drop');
},
}),
monitorForExternal({

View File

@@ -0,0 +1,46 @@
import type { GetOffsetFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/public-utils/element/custom-native-drag-preview/types';
import type { Input } from '@atlaskit/pragmatic-drag-and-drop/types';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import type { CSSProperties } from 'react';
/**
* The size of the image drag preview in theme units.
*/
export const DND_IMAGE_DRAG_PREVIEW_SIZE = 32 satisfies SystemStyleObject['w'];
/**
* A drag preview offset function that works like the provided `preserveOffsetOnSource`, except when either the X or Y
* offset is outside the container, in which case it centers the preview in the container.
*/
export function preserveOffsetOnSourceFallbackCentered({
element,
input,
}: {
element: HTMLElement;
input: Input;
}): GetOffsetFn {
return ({ container }) => {
const sourceRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
let offsetX = input.clientX - sourceRect.x;
let offsetY = input.clientY - sourceRect.y;
if (offsetY > containerRect.height || offsetX > containerRect.width) {
offsetX = containerRect.width / 2;
offsetY = containerRect.height / 2;
}
return { x: offsetX, y: offsetY };
};
}
// Based on https://github.com/atlassian/pragmatic-drag-and-drop/blob/main/packages/flourish/src/trigger-post-move-flash.tsx
// That package has a lot of extra deps so we just copied the function here
export function triggerPostMoveFlash(element: HTMLElement, backgroundColor: CSSProperties['backgroundColor']) {
element.animate([{ backgroundColor }, {}], {
duration: 700,
easing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)',
iterations: 1,
});
}

View File

@@ -2,7 +2,6 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { Dnd } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge';
import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu';
@@ -14,6 +13,8 @@ import {
selectSelectedBoardId,
} from 'features/gallery/store/gallerySelectors';
import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice';
import type { AddImageToBoardActionData} from 'features/imageActions/actions';
import {addImageToBoardActionApi } from 'features/imageActions/actions';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArchiveBold, PiImageSquare } from 'react-icons/pi';
@@ -44,10 +45,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
}
}, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]);
const targetData = useMemo<Dnd.types['TargetDataTypeMap']['addToBoard']>(
() => Dnd.Target.addToBoard.getData({ boardId: board.board_id }),
[board.board_id]
);
const targetData = useMemo<AddImageToBoardActionData>(() => addImageToBoardActionApi.getData({ boardId: board.board_id }), [board.board_id]);
return (
<Box position="relative" w="full" h={12}>

View File

@@ -1,7 +1,6 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { Dnd } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge';
import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip';
@@ -12,6 +11,8 @@ import {
selectBoardSearchText,
} from 'features/gallery/store/gallerySelectors';
import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice';
import type { RemoveImageFromBoardActionData} from 'features/imageActions/actions';
import {removeImageFromBoardActionApi } from 'features/imageActions/actions';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards';
@@ -43,10 +44,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
}
}, [dispatch, autoAssignBoardOnClick]);
const targetData = useMemo<Dnd.types['TargetDataTypeMap']['removeFromBoard']>(
() => Dnd.Target.removeFromBoard.getData(),
[]
);
const targetData = useMemo<RemoveImageFromBoardActionData>(() => removeImageFromBoardActionApi.getData(), []);
const { t } = useTranslation();

View File

@@ -7,7 +7,6 @@ import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/lis
import { useAppStore } from 'app/store/nanostores/store';
import { useAppSelector } from 'app/store/storeHooks';
import { useBoolean } from 'common/hooks/useBoolean';
import { Dnd } from 'features/dnd/dnd';
import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage';
import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage';
import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage';
@@ -18,6 +17,7 @@ import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid
import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/SizedSkeletonLoader';
import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { multipleImageSourceApi, singleImageSourceApi } from 'features/imageActions/actions';
import type { MouseEventHandler } from 'react';
import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react';
import type { ImageDTO } from 'services/api/types';
@@ -122,32 +122,32 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
// When we have multiple images selected, and the dragged image is part of the selection, initiate a
// multi-image drag.
if (gallery.selection.length > 1 && gallery.selection.includes(imageDTO)) {
return Dnd.Source.multipleImage.getData({
return multipleImageSourceApi.getData({
imageDTOs: gallery.selection,
boardId: gallery.selectedBoardId,
});
}
// Otherwise, initiate a single-image drag
return Dnd.Source.singleImage.getData({ imageDTO }, imageDTO.image_name);
return singleImageSourceApi.getData({ imageDTO }, imageDTO.image_name);
},
// This is a "local" drag start event, meaning that it is only called when this specific image is dragged.
onDragStart: ({ source }) => {
// When we start dragging a single image, set the dragging state to true. This is only called when this
// specific image is dragged.
if (Dnd.Source.singleImage.typeGuard(source.data)) {
if (singleImageSourceApi.typeGuard(source.data)) {
setIsDragging(true);
return;
}
},
onGenerateDragPreview: (args) => {
if (Dnd.Source.multipleImage.typeGuard(args.source.data)) {
if (multipleImageSourceApi.typeGuard(args.source.data)) {
setMultipleImageDragPreview({
multipleImageDndData: args.source.data,
onGenerateDragPreviewArgs: args,
setDragPreviewState,
});
} else if (Dnd.Source.singleImage.typeGuard(args.source.data)) {
} else if (singleImageSourceApi.typeGuard(args.source.data)) {
setSingleImageDragPreview({
singleImageDndData: args.source.data,
onGenerateDragPreviewArgs: args,
@@ -161,7 +161,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => {
onDragStart: ({ source }) => {
// When we start dragging multiple images, set the dragging state to true if the dragged image is part of the
// selection. This is called for all drag events.
if (Dnd.Source.multipleImage.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) {
if (multipleImageSourceApi.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) {
setIsDragging(true);
}
},

View File

@@ -1,21 +1,11 @@
import { useAppSelector } from 'app/store/storeHooks';
import { Dnd } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { type SetComparisonImageActionData, setComparisonImageActionApi } from 'features/imageActions/actions';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { selectComparisonImages } from './common';
export const ImageComparisonDroppable = memo(() => {
const { t } = useTranslation();
const comparisonImages = useAppSelector(selectComparisonImages);
const targetData = useMemo<Dnd.types['TargetDataTypeMap']['selectForCompare']>(() => {
const { firstImage, secondImage } = comparisonImages;
return Dnd.Target.selectForCompare.getData({
firstImageName: firstImage?.image_name,
secondImageName: secondImage?.image_name,
});
}, [comparisonImages]);
const targetData = useMemo<SetComparisonImageActionData>(() => setComparisonImageActionApi.getData(), []);
return <DndDropTarget targetData={targetData} label={t('gallery.selectForCompare')} />;
});

View File

@@ -0,0 +1,458 @@
import type { AppDispatch, RootState } from 'app/store/store';
import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import {
controlLayerAdded,
entityRasterized,
inpaintMaskAdded,
rasterLayerAdded,
referenceImageAdded,
referenceImageIPAdapterImageChanged,
rgAdded,
rgIPAdapterImageChanged,
} from 'features/controlLayers/store/canvasSlice';
import { selectBboxRect } from 'features/controlLayers/store/selectors';
import type {
CanvasEntityIdentifier,
CanvasEntityType,
CanvasRenderableEntityIdentifier,
} from 'features/controlLayers/store/types';
import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common';
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import type { BoardId } from 'features/gallery/store/types';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import type { JsonObject } from 'type-fest';
export type RecordUnknown = Record<string | symbol, unknown>;
export type ActionData<
Type extends string = string,
PrivateKey extends symbol = symbol,
Payload extends JsonObject | void = JsonObject | void,
> = {
[key in PrivateKey]: true;
} & {
id: string;
type: Type;
payload: Payload;
};
export const buildTypeAndKey = <T extends string>(type: T) => {
const key = Symbol(type);
return { type, key } as const;
};
export const buildTypeGuard = <T extends ActionData>(key: symbol) => {
const typeGuard = (val: RecordUnknown): val is T => Boolean(val[key]);
return typeGuard;
};
export const buildGetData = <T extends ActionData>(key: symbol, type: T['type']) => {
const getData = (payload: T['payload'] extends undefined ? void : T['payload'], id?: string): T =>
({
[key]: true,
id: id ?? getPrefixedId(type),
type,
payload,
}) as T;
return getData;
};
export type ActionSourceApi<SourceData extends ActionData> = {
key: symbol;
type: SourceData['type'];
typeGuard: ReturnType<typeof buildTypeGuard<SourceData>>;
getData: ReturnType<typeof buildGetData<SourceData>>;
};
//#region Single Image
const _singleImage = buildTypeAndKey('single-image');
export type SingleImageSourceData = ActionData<
typeof _singleImage.type,
typeof _singleImage.key,
{ imageDTO: ImageDTO }
>;
export const singleImageSourceApi: ActionSourceApi<SingleImageSourceData> = {
..._singleImage,
typeGuard: buildTypeGuard(_singleImage.key),
getData: buildGetData(_singleImage.key, _singleImage.type),
};
//#endregion
//#region Multiple Image
const _multipleImage = buildTypeAndKey('multiple-image');
export type MultipleImageSourceData = ActionData<
typeof _multipleImage.type,
typeof _multipleImage.key,
{ imageDTOs: ImageDTO[]; boardId: BoardId }
>;
export const multipleImageSourceApi: ActionSourceApi<MultipleImageSourceData> = {
..._multipleImage,
typeGuard: buildTypeGuard(_multipleImage.key),
getData: buildGetData(_multipleImage.key, _multipleImage.type),
};
//#endregion
type ActionTargetApi<TargetData extends ActionData, SourceData extends ActionData> = {
key: symbol;
type: TargetData['type'];
typeGuard: ReturnType<typeof buildTypeGuard<TargetData>>;
getData: ReturnType<typeof buildGetData<TargetData>>;
isValid: (
sourceData: RecordUnknown,
targetData: TargetData,
dispatch: AppDispatch,
getState: () => RootState
) => boolean;
handler: (sourceData: SourceData, targetData: TargetData, dispatch: AppDispatch, getState: () => RootState) => void;
};
//#region Set Global Reference Image
const _setGlobalReferenceImage = buildTypeAndKey('set-global-reference-image');
export type SetGlobalReferenceImageActionData = ActionData<
typeof _setGlobalReferenceImage.type,
typeof _setGlobalReferenceImage.key,
{ entityIdentifier: CanvasEntityIdentifier<'reference_image'> }
>;
export const setGlobalReferenceImageActionApi: ActionTargetApi<
SetGlobalReferenceImageActionData,
SingleImageSourceData
> = {
..._setGlobalReferenceImage,
typeGuard: buildTypeGuard(_setGlobalReferenceImage.key),
getData: buildGetData(_setGlobalReferenceImage.key, _setGlobalReferenceImage.type),
isValid: (sourceData, _targetData, _dispatch, _getState) => {
if (singleImageSourceApi.typeGuard(sourceData)) {
return true;
}
return false;
},
handler: (sourceData, targetData, dispatch, _getState) => {
const { imageDTO } = sourceData.payload;
const { entityIdentifier } = targetData.payload;
dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO }));
},
};
//#endregion
//#region Set Regional Guidance Reference Image
const _setRegionalGuidanceReferenceImage = buildTypeAndKey('set-regional-guidance-reference-image');
export type SetRegionalGuidanceReferenceImageActionData = ActionData<
typeof _setRegionalGuidanceReferenceImage.type,
typeof _setRegionalGuidanceReferenceImage.key,
{ entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>; referenceImageId: string }
>;
export const setRegionalGuidanceReferenceImageActionApi: ActionTargetApi<
SetRegionalGuidanceReferenceImageActionData,
SingleImageSourceData
> = {
..._setRegionalGuidanceReferenceImage,
typeGuard: buildTypeGuard(_setRegionalGuidanceReferenceImage.key),
getData: buildGetData(_setRegionalGuidanceReferenceImage.key, _setRegionalGuidanceReferenceImage.type),
isValid: (sourceData, _targetData, _dispatch, _getState) => {
if (singleImageSourceApi.typeGuard(sourceData)) {
return true;
}
return false;
},
handler: (sourceData, targetData, dispatch, _getState) => {
const { imageDTO } = sourceData.payload;
const { entityIdentifier, referenceImageId } = targetData.payload;
dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO }));
},
};
//#endregion
//# Set Upscale Initial Image
const _setUpscaleInitialImage = buildTypeAndKey('set-upscale-initial-image');
export type SetUpscaleInitialImageActionData = ActionData<
typeof _setUpscaleInitialImage.type,
typeof _setUpscaleInitialImage.key,
void
>;
export const setUpscaleInitialImageActionApi: ActionTargetApi<SetUpscaleInitialImageActionData, SingleImageSourceData> =
{
..._setUpscaleInitialImage,
typeGuard: buildTypeGuard(_setUpscaleInitialImage.key),
getData: buildGetData(_setUpscaleInitialImage.key, _setUpscaleInitialImage.type),
isValid: (sourceData, _targetData, _dispatch, _getState) => {
if (singleImageSourceApi.typeGuard(sourceData)) {
return true;
}
return false;
},
handler: (sourceData, _targetData, dispatch, _getState) => {
const { imageDTO } = sourceData.payload;
dispatch(upscaleInitialImageChanged(imageDTO));
},
};
//#endregion
//#region Set Node Image Field Image
const _setNodeImageFieldImage = buildTypeAndKey('set-node-image-field-image');
export type SetNodeImageFieldImageActionData = ActionData<
typeof _setNodeImageFieldImage.type,
typeof _setNodeImageFieldImage.key,
{ fieldIdentifer: FieldIdentifier }
>;
export const setNodeImageFieldImageActionApi: ActionTargetApi<SetNodeImageFieldImageActionData, SingleImageSourceData> =
{
..._setNodeImageFieldImage,
typeGuard: buildTypeGuard(_setNodeImageFieldImage.key),
getData: buildGetData(_setNodeImageFieldImage.key, _setNodeImageFieldImage.type),
isValid: (sourceData, _targetData, _dispatch, _getState) => {
if (singleImageSourceApi.typeGuard(sourceData)) {
return true;
}
return false;
},
handler: (sourceData, targetData, dispatch, _getState) => {
const { imageDTO } = sourceData.payload;
const { fieldIdentifer } = targetData.payload;
dispatch(fieldImageValueChanged({ ...fieldIdentifer, value: imageDTO }));
},
};
//#endregion
//# Set Comparison Image
const _setComparisonImage = buildTypeAndKey('set-comparison-image');
export type SetComparisonImageActionData = ActionData<
typeof _setComparisonImage.type,
typeof _setComparisonImage.key,
void
>;
export const setComparisonImageActionApi: ActionTargetApi<SetComparisonImageActionData, SingleImageSourceData> = {
..._setComparisonImage,
typeGuard: buildTypeGuard(_setComparisonImage.key),
getData: buildGetData(_setComparisonImage.key, _setComparisonImage.type),
isValid: (sourceData, _targetData, _dispatch, getState) => {
if (!singleImageSourceApi.typeGuard(sourceData)) {
return false;
}
const { firstImage, secondImage } = selectComparisonImages(getState());
// Do not allow the same images to be selected for comparison
if (sourceData.payload.imageDTO.image_name === firstImage?.image_name) {
return false;
}
if (sourceData.payload.imageDTO.image_name === secondImage?.image_name) {
return false;
}
return true;
},
handler: (sourceData, _targetData, dispatch, _getState) => {
const { imageDTO } = sourceData.payload;
dispatch(imageToCompareChanged(imageDTO));
},
};
//#endregion
//#region New Canvas Entity
const _newCanvasEntity = buildTypeAndKey('new-canvas-entity');
export type NewCanvasEntityFromImageActionData = ActionData<
typeof _newCanvasEntity.type,
typeof _newCanvasEntity.key,
{ type: CanvasEntityType | 'regional_guidance_with_reference_image' }
>;
export const newCanvasEntityFromImageActionApi: ActionTargetApi<
NewCanvasEntityFromImageActionData,
SingleImageSourceData
> = {
..._newCanvasEntity,
typeGuard: buildTypeGuard(_newCanvasEntity.key),
getData: buildGetData(_newCanvasEntity.key, _newCanvasEntity.type),
isValid: (sourceData, _targetData, _dispatch, _getState) => {
if (!singleImageSourceApi.typeGuard(sourceData)) {
return false;
}
return true;
},
handler: (sourceData, targetData, dispatch, getState) => {
const { type } = targetData.payload;
const { imageDTO } = sourceData.payload;
const state = getState();
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectBboxRect(state);
const overrides = {
objects: [imageObject],
position: { x, y },
};
switch (type) {
case 'raster_layer': {
dispatch(rasterLayerAdded({ overrides, isSelected: true }));
break;
}
case 'control_layer': {
const controlAdapter = selectDefaultControlAdapter(state);
dispatch(controlLayerAdded({ overrides: { ...overrides, controlAdapter }, isSelected: true }));
break;
}
case 'inpaint_mask': {
dispatch(inpaintMaskAdded({ overrides, isSelected: true }));
break;
}
case 'regional_guidance': {
dispatch(rgAdded({ overrides, isSelected: true }));
break;
}
case 'reference_image': {
const ipAdapter = selectDefaultIPAdapter(getState());
ipAdapter.image = imageDTOToImageWithDims(imageDTO);
dispatch(referenceImageAdded({ overrides: { ipAdapter }, isSelected: true }));
break;
}
case 'regional_guidance_with_reference_image': {
const ipAdapter = selectDefaultIPAdapter(getState());
ipAdapter.image = imageDTOToImageWithDims(imageDTO);
const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }];
dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true }));
}
}
},
};
//#endregion
//#region Replace Canvas Entity Objects With Image
const _replaceCanvasEntityObjectsWithImage = buildTypeAndKey('replace-canvas-entity-objects-with-image');
export type ReplaceCanvasEntityObjectsWithImageActionData = ActionData<
typeof _replaceCanvasEntityObjectsWithImage.type,
typeof _replaceCanvasEntityObjectsWithImage.key,
{ entityIdentifier: CanvasRenderableEntityIdentifier }
>;
export const replaceCanvasEntityObjectsWithImageActionApi: ActionTargetApi<
ReplaceCanvasEntityObjectsWithImageActionData,
SingleImageSourceData
> = {
..._replaceCanvasEntityObjectsWithImage,
typeGuard: buildTypeGuard(_replaceCanvasEntityObjectsWithImage.key),
getData: buildGetData(_replaceCanvasEntityObjectsWithImage.key, _replaceCanvasEntityObjectsWithImage.type),
isValid: (sourceData, _targetData, _dispatch, _getState) => {
if (!singleImageSourceApi.typeGuard(sourceData)) {
return false;
}
return true;
},
handler: (sourceData, targetData, dispatch, getState) => {
const { imageDTO } = sourceData.payload;
const { entityIdentifier } = targetData.payload;
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectBboxRect(getState());
dispatch(
entityRasterized({
entityIdentifier,
imageObject,
position: { x, y },
replaceObjects: true,
isSelected: true,
})
);
},
};
//#endregion
//#region Add To Board
const _addToBoard = buildTypeAndKey('add-to-board');
export type AddImageToBoardActionData = ActionData<
typeof _addToBoard.type,
typeof _addToBoard.key,
{ boardId: BoardId }
>;
export const addImageToBoardActionApi: ActionTargetApi<
AddImageToBoardActionData,
SingleImageSourceData | MultipleImageSourceData
> = {
..._addToBoard,
typeGuard: buildTypeGuard(_addToBoard.key),
getData: buildGetData(_addToBoard.key, _addToBoard.type),
isValid: (sourceData, targetData, _dispatch, _getState) => {
if (singleImageSourceApi.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
if (multipleImageSourceApi.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
return false;
},
handler: (sourceData, targetData, dispatch, _getState) => {
if (singleImageSourceApi.typeGuard(sourceData)) {
const { imageDTO } = sourceData.payload;
const { boardId } = targetData.payload;
dispatch(imagesApi.endpoints.addImageToBoard.initiate({ imageDTO, board_id: boardId }, { track: false }));
dispatch(selectionChanged([]));
}
if (multipleImageSourceApi.typeGuard(sourceData)) {
const { imageDTOs } = sourceData.payload;
const { boardId } = targetData.payload;
dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false }));
dispatch(selectionChanged([]));
}
},
};
//#endregion
//#region Remove From Board
const _removeFromBoard = buildTypeAndKey('add-to-board');
export type RemoveImageFromBoardActionData = ActionData<
typeof _removeFromBoard.type,
typeof _removeFromBoard.key,
void
>;
export const removeImageFromBoardActionApi: ActionTargetApi<
RemoveImageFromBoardActionData,
SingleImageSourceData | MultipleImageSourceData
> = {
..._removeFromBoard,
typeGuard: buildTypeGuard(_removeFromBoard.key),
getData: buildGetData(_removeFromBoard.key, _removeFromBoard.type),
isValid: (sourceData, _targetData, _dispatch, _getState) => {
if (singleImageSourceApi.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
return currentBoard !== 'none';
}
if (multipleImageSourceApi.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
return currentBoard !== 'none';
}
return false;
},
handler: (sourceData, _targetData, dispatch, _getState) => {
if (singleImageSourceApi.typeGuard(sourceData)) {
const { imageDTO } = sourceData.payload;
dispatch(imagesApi.endpoints.removeImageFromBoard.initiate({ imageDTO }, { track: false }));
dispatch(selectionChanged([]));
}
if (multipleImageSourceApi.typeGuard(sourceData)) {
const { imageDTOs } = sourceData.payload;
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false }));
dispatch(selectionChanged([]));
}
},
};
//#endregion
export const singleImageActions = [
setGlobalReferenceImageActionApi,
setRegionalGuidanceReferenceImageActionApi,
setUpscaleInitialImageActionApi,
setNodeImageFieldImageActionApi,
setComparisonImageActionApi,
newCanvasEntityFromImageActionApi,
replaceCanvasEntityObjectsWithImageActionApi,
addImageToBoardActionApi,
removeImageFromBoardActionApi,
] as const;
export type SingleImageAction = ReturnType<(typeof singleImageActions)[number]['getData']>;
export const multipleImageActions = [addImageToBoardActionApi, removeImageFromBoardActionApi] as const;
export type MultipleImageAction = ReturnType<(typeof multipleImageActions)[number]['getData']>;

View File

@@ -2,10 +2,11 @@ import { Flex, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch } from 'app/store/storeHooks';
import { Dnd } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { DndImage } from 'features/dnd/DndImage';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import type { SetNodeImageFieldImageActionData } from 'features/imageActions/actions';
import { setNodeImageFieldImageActionApi } from 'features/imageActions/actions';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useEffect, useMemo } from 'react';
@@ -32,9 +33,10 @@ const ImageFieldInputComponent = (props: FieldComponentProps<ImageFieldInputInst
);
}, [dispatch, field.name, nodeId]);
const targetData = useMemo<Dnd.types['TargetDataTypeMap']['setNodeImageField']>(
() => Dnd.Target.setNodeImageField.getData({ nodeId, fieldName: field.name }, field.value?.image_name),
[field.name, field.value?.image_name, nodeId]
const targetData = useMemo<SetNodeImageFieldImageActionData>(
() =>
setNodeImageFieldImageActionApi.getData({ fieldIdentifer: { nodeId, fieldName: field.name } }, field.value?.image_name),
[field, nodeId]
);
useEffect(() => {

View File

@@ -7,7 +7,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { triggerPostMoveFlash } from 'features/dnd/dnd';
import { triggerPostMoveFlash } from 'features/dnd/util';
import LinearViewFieldInternal from 'features/nodes/components/flow/nodes/Invocation/fields/LinearViewField';
import { singleWorkflowField } from 'features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd';
import { selectWorkflowSlice, workflowExposedFieldsReordered } from 'features/nodes/store/workflowSlice';

View File

@@ -1,19 +1,28 @@
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 type { DndListState } from 'features/dnd/dnd';
import { buildDndSourceApi, idle } from 'features/dnd/dnd';
import type { DndListTargetState } from 'features/dnd/types';
import { idle } from 'features/dnd/types';
import type { ActionData, ActionSourceApi } from 'features/imageActions/actions';
import { buildGetData, buildTypeAndKey, buildTypeGuard } from 'features/imageActions/actions';
import type { FieldIdentifier } from 'features/nodes/types/field';
import type { RefObject } from 'react';
import { useEffect, useState } from 'react';
/**
* Dnd source API for a single workflow field.
*/
export const singleWorkflowField = buildDndSourceApi<{ fieldIdentifier: FieldIdentifier }>('SingleWorkflowField');
const _singleWorkflowField = buildTypeAndKey('single-workflow-field');
type SingleWorkflowFieldSourceData = ActionData<
typeof _singleWorkflowField.type,
typeof _singleWorkflowField.key,
{ fieldIdentifier: FieldIdentifier }
>;
export const singleWorkflowField: ActionSourceApi<SingleWorkflowFieldSourceData> = {
..._singleWorkflowField,
typeGuard: buildTypeGuard(_singleWorkflowField.key),
getData: buildGetData(_singleWorkflowField.key, _singleWorkflowField.type),
};
export const useLinearViewFieldDnd = (ref: RefObject<HTMLElement>, fieldIdentifier: FieldIdentifier) => {
const [dndListState, setListDndState] = useState<DndListState>(idle);
const [dndListState, setListDndState] = useState<DndListTargetState>(idle);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {

View File

@@ -1,9 +1,10 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { Dnd } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { DndImage } from 'features/dnd/DndImage';
import { DndImageIcon } from 'features/dnd/DndImageIcon';
import type { SetUpscaleInitialImageActionData } from 'features/imageActions/actions';
import { setUpscaleInitialImageActionApi } from 'features/imageActions/actions';
import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import { t } from 'i18next';
import { useCallback, useMemo } from 'react';
@@ -12,10 +13,7 @@ import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
export const UpscaleInitialImage = () => {
const dispatch = useAppDispatch();
const imageDTO = useAppSelector(selectUpscaleInitialImage);
const targetData = useMemo<Dnd.types['TargetDataTypeMap']['setUpscaleInitialImageFromImage']>(
() => Dnd.Target.setUpscaleInitialImageFromImage.getData(),
[]
);
const targetData = useMemo<SetUpscaleInitialImageActionData>(() => setUpscaleInitialImageActionApi.getData(), []);
const onReset = useCallback(() => {
dispatch(upscaleInitialImageChanged(null));