tidy(ui): dnd stuff

This commit is contained in:
psychedelicious
2024-10-31 06:56:10 +10:00
parent 8da9e7c1f6
commit f0c80a8d7a
27 changed files with 676 additions and 614 deletions

View File

@@ -17,6 +17,7 @@ const $logger = atom<Logger>(Roarr.child(BASE_CONTEXT));
export const zLogNamespace = z.enum([
'canvas',
'config',
'dnd',
'events',
'gallery',
'generation',

View File

@@ -9,7 +9,6 @@ import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddlewar
import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted';
import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected';
import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload';
import { addDndDroppedListener } from 'app/store/middleware/listenerMiddleware/listeners/dnd';
import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear';
import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes';
import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
@@ -93,9 +92,6 @@ addGetOpenAPISchemaListener(startAppListening);
addWorkflowLoadRequestedListener(startAppListening);
addUpdateAllNodesRequestedListener(startAppListening);
// DND
addDndDroppedListener(startAppListening);
// Models
addModelSelectedListener(startAppListening);

View File

@@ -1,284 +0,0 @@
import { createAction } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import { deepClone } from 'common/util/deepClone';
import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import {
controlLayerAdded,
entityRasterized,
entitySelected,
inpaintMaskAdded,
rasterLayerAdded,
referenceImageAdded,
referenceImageIPAdapterImageChanged,
rgAdded,
rgIPAdapterImageChanged,
} from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type {
CanvasControlLayerState,
CanvasInpaintMaskState,
CanvasRasterLayerState,
CanvasReferenceImageState,
CanvasRegionalGuidanceState,
} from 'features/controlLayers/store/types';
import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { Dnd } from 'features/dnd/dnd';
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import { imagesApi } from 'services/api/endpoints/images';
const log = logger('system');
export const dndDropped = createAction<{
sourceData: Dnd.types['SourceDataUnion'];
targetData: Dnd.types['TargetDataUnion'];
}>('dnd/dndDropped2');
export const addDndDroppedListener = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: dndDropped,
effect: (action, { dispatch, getState }) => {
const { sourceData, targetData } = action.payload;
// Single image dropped
if (Dnd.Source.singleImage.typeGuard(sourceData)) {
log.debug({ sourceData, targetData }, 'Image dropped');
const { imageDTO } = sourceData.payload;
// Image dropped on IP Adapter
if (
Dnd.Target.setGlobalReferenceImage.typeGuard(targetData) &&
Dnd.Target.setGlobalReferenceImage.validateDrop(sourceData, targetData)
) {
const { globalReferenceImageId } = targetData.payload;
dispatch(
referenceImageIPAdapterImageChanged({
entityIdentifier: { id: globalReferenceImageId, type: 'reference_image' },
imageDTO,
})
);
return;
}
//Image dropped on Regional Guidance IP Adapter
if (
Dnd.Target.setRegionalGuidanceReferenceImage.typeGuard(targetData) &&
Dnd.Target.setRegionalGuidanceReferenceImage.validateDrop(sourceData, targetData)
) {
const { regionalGuidanceId, referenceImageId } = targetData.payload;
dispatch(
rgIPAdapterImageChanged({
entityIdentifier: { id: regionalGuidanceId, type: 'regional_guidance' },
referenceImageId,
imageDTO,
})
);
return;
}
// Add raster layer from image
if (
Dnd.Target.newRasterLayerFromImage.typeGuard(targetData) &&
Dnd.Target.newRasterLayerFromImage.validateDrop(sourceData, targetData)
) {
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectCanvasSlice(getState()).bbox.rect;
const overrides: Partial<CanvasRasterLayerState> = {
objects: [imageObject],
position: { x, y },
};
dispatch(rasterLayerAdded({ overrides, isSelected: true }));
return;
}
// Add inpaint mask from image
if (
Dnd.Target.newInpaintMaskFromImage.typeGuard(targetData) &&
Dnd.Target.newInpaintMaskFromImage.validateDrop(sourceData, targetData)
) {
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectCanvasSlice(getState()).bbox.rect;
const overrides: Partial<CanvasInpaintMaskState> = {
objects: [imageObject],
position: { x, y },
};
dispatch(inpaintMaskAdded({ overrides, isSelected: true }));
return;
}
// Add regional guidance from image
if (
Dnd.Target.newRegionalGuidanceFromImage.typeGuard(targetData) &&
Dnd.Target.newRegionalGuidanceFromImage.validateDrop(sourceData, targetData)
) {
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectCanvasSlice(getState()).bbox.rect;
const overrides: Partial<CanvasRegionalGuidanceState> = {
objects: [imageObject],
position: { x, y },
};
dispatch(rgAdded({ overrides, isSelected: true }));
return;
}
// Add control layer from image
if (
Dnd.Target.newControlLayerFromImage.typeGuard(targetData) &&
Dnd.Target.newControlLayerFromImage.validateDrop(sourceData, targetData)
) {
const state = getState();
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectCanvasSlice(state).bbox.rect;
const defaultControlAdapter = selectDefaultControlAdapter(state);
const overrides: Partial<CanvasControlLayerState> = {
objects: [imageObject],
position: { x, y },
controlAdapter: defaultControlAdapter,
};
dispatch(controlLayerAdded({ overrides, isSelected: true }));
return;
}
// Add regional guidance layer w/ reference image from image
if (
Dnd.Target.newRegionalGuidanceReferenceImageFromImage.typeGuard(targetData) &&
Dnd.Target.newRegionalGuidanceReferenceImageFromImage.validateDrop(sourceData, targetData)
) {
const state = getState();
const ipAdapter = deepClone(selectDefaultIPAdapter(state));
ipAdapter.image = imageDTOToImageWithDims(imageDTO);
const overrides: Partial<CanvasRegionalGuidanceState> = {
referenceImages: [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }],
};
dispatch(rgAdded({ overrides, isSelected: true }));
return;
}
// Add global reference image from image
if (
Dnd.Target.newGlobalReferenceImageFromImage.typeGuard(targetData) &&
Dnd.Target.newGlobalReferenceImageFromImage.validateDrop(sourceData, targetData)
) {
const state = getState();
const ipAdapter = deepClone(selectDefaultIPAdapter(state));
ipAdapter.image = imageDTOToImageWithDims(imageDTO);
const overrides: Partial<CanvasReferenceImageState> = { ipAdapter };
dispatch(referenceImageAdded({ overrides, isSelected: true }));
return;
}
// Replace layer with image
if (
Dnd.Target.replaceLayerWithImage.typeGuard(targetData) &&
Dnd.Target.replaceLayerWithImage.validateDrop(sourceData, targetData)
) {
const state = getState();
const { entityIdentifier } = targetData.payload;
const imageObject = imageDTOToImageObject(imageDTO);
const { x, y } = selectCanvasSlice(state).bbox.rect;
dispatch(entityRasterized({ entityIdentifier, imageObject, position: { x, y }, replaceObjects: true }));
dispatch(entitySelected({ entityIdentifier }));
return;
}
// Image dropped on node image field
if (
Dnd.Target.setNodeImageField.typeGuard(targetData) &&
Dnd.Target.setNodeImageField.validateDrop(sourceData, targetData)
) {
const { fieldName, nodeId } = targetData.payload;
dispatch(
fieldImageValueChanged({
nodeId,
fieldName,
value: imageDTO,
})
);
return;
}
// Image selected for compare
if (
Dnd.Target.selectForCompare.typeGuard(targetData) &&
Dnd.Target.selectForCompare.validateDrop(sourceData, targetData)
) {
dispatch(imageToCompareChanged(imageDTO));
return;
}
// Image added to board
if (Dnd.Target.addToBoard.typeGuard(targetData) && Dnd.Target.addToBoard.validateDrop(sourceData, targetData)) {
const { boardId } = targetData.payload;
dispatch(
imagesApi.endpoints.addImageToBoard.initiate({
imageDTO,
board_id: boardId,
})
);
dispatch(selectionChanged([]));
return;
}
// Image removed from board
if (
Dnd.Target.removeFromBoard.typeGuard(targetData) &&
Dnd.Target.removeFromBoard.validateDrop(sourceData, targetData)
) {
dispatch(
imagesApi.endpoints.removeImageFromBoard.initiate({
imageDTO,
})
);
dispatch(selectionChanged([]));
return;
}
// Image dropped on upscale initial image
if (
Dnd.Target.setUpscaleInitialImageFromImage.typeGuard(targetData) &&
Dnd.Target.setUpscaleInitialImageFromImage.validateDrop(sourceData, targetData)
) {
dispatch(upscaleInitialImageChanged(imageDTO));
return;
}
}
if (Dnd.Source.multipleImage.typeGuard(sourceData)) {
log.debug({ sourceData, targetData }, 'Multiple images dropped');
const { imageDTOs } = sourceData.payload;
// Multiple images dropped on user board
if (Dnd.Target.addToBoard.typeGuard(targetData) && Dnd.Target.addToBoard.validateDrop(sourceData, targetData)) {
const { boardId } = targetData.payload;
dispatch(
imagesApi.endpoints.addImagesToBoard.initiate({
imageDTOs,
board_id: boardId,
})
);
dispatch(selectionChanged([]));
return;
}
// Multiple images dropped on Uncategorized board (e.g. removed from board)
if (
Dnd.Target.removeFromBoard.typeGuard(targetData) &&
Dnd.Target.removeFromBoard.validateDrop(sourceData, targetData)
) {
dispatch(
imagesApi.endpoints.removeImagesFromBoard.initiate({
imageDTOs,
})
);
dispatch(selectionChanged([]));
return;
}
}
log.error({ sourceData, targetData }, 'Invalid dnd drop');
},
});
};

View File

@@ -0,0 +1,49 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useCanvasEntityListDnd } from 'features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected';
import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor';
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator';
import type { PropsWithChildren } from 'react';
import { memo, useCallback, useRef } from 'react';
export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const isSelected = useEntityIsSelected(entityIdentifier);
const selectionColor = useEntitySelectionColor(entityIdentifier);
const onClick = useCallback(() => {
if (isSelected) {
return;
}
dispatch(entitySelected({ entityIdentifier }));
}, [dispatch, entityIdentifier, isSelected]);
const ref = useRef<HTMLDivElement>(null);
const dndState = useCanvasEntityListDnd(ref, entityIdentifier);
return (
<Box position="relative">
<Flex
// This is used to trigger the post-move flash animation
data-entity-id={entityIdentifier.id}
ref={ref}
position="relative"
flexDir="column"
w="full"
bg={isSelected ? 'base.800' : 'base.850'}
onClick={onClick}
borderInlineStartWidth={5}
borderColor={isSelected ? selectionColor : 'base.800'}
borderRadius="base"
>
{props.children}
</Flex>
<DndListDropIndicator dndState={dndState} />
</Box>
);
});
CanvasEntityContainer.displayName = 'CanvasEntityContainer';

View File

@@ -1,13 +1,13 @@
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 type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { useBoolean } from 'common/hooks/useBoolean';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles';
import { singleCanvasEntity } from 'features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd';
import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton';
import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton';
import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
@@ -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 { Dnd, triggerPostMoveFlash } from 'features/dnd/dnd';
import { triggerPostMoveFlash } from 'features/dnd/dnd';
import type { PropsWithChildren } from 'react';
import { memo, useEffect } from 'react';
import { flushSync } from 'react-dom';
@@ -28,10 +28,6 @@ type Props = PropsWithChildren<{
entityIdentifiers: CanvasEntityIdentifier[];
}>;
const _hover: SystemStyleObject = {
opacity: 1,
};
export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityIdentifiers }: Props) => {
const title = useEntityTypeTitle(type);
const informationalPopoverFeature = useEntityTypeInformationalPopover(type);
@@ -41,7 +37,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI
useEffect(() => {
return monitorForElements({
canMonitor({ source }) {
if (!Dnd.Source.singleCanvasEntity.typeGuard(source.data)) {
if (!singleCanvasEntity.typeGuard(source.data)) {
return false;
}
if (source.data.payload.entityIdentifier.type !== type) {
@@ -58,10 +54,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI
const sourceData = source.data;
const targetData = target.data;
if (
!Dnd.Source.singleCanvasEntity.typeGuard(sourceData) ||
!Dnd.Source.singleCanvasEntity.typeGuard(targetData)
) {
if (!singleCanvasEntity.typeGuard(sourceData) || !singleCanvasEntity.typeGuard(targetData)) {
return;
}

View File

@@ -0,0 +1,85 @@
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 { RefObject } from 'react';
import { useEffect, useState } from 'react';
/**
* Dnd source API for a single canvas entity.
*/
export const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity');
export const useCanvasEntityListDnd = (ref: RefObject<HTMLElement>, entityIdentifier: CanvasEntityIdentifier) => {
const [dndState, setDndState] = useState<DndListState>(idle);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
return combine(
draggable({
element,
getInitialData() {
return singleCanvasEntity.getData({ entityIdentifier });
},
onDragStart() {
setDndState({ type: 'is-dragging' });
},
onDrop() {
setDndState(idle);
},
}),
dropTargetForElements({
element,
canDrop({ source }) {
if (!singleCanvasEntity.typeGuard(source.data)) {
return false;
}
if (source.data.payload.entityIdentifier.type !== entityIdentifier.type) {
return false;
}
return true;
},
getData({ input }) {
const data = singleCanvasEntity.getData({ entityIdentifier });
return attachClosestEdge(data, {
element,
input,
allowedEdges: ['top', 'bottom'],
});
},
getIsSticky() {
return true;
},
onDragEnter({ self }) {
const closestEdge = extractClosestEdge(self.data);
setDndState({ type: 'is-dragging-over', closestEdge });
},
onDrag({ self }) {
const closestEdge = extractClosestEdge(self.data);
// Only need to update react state if nothing has changed.
// Prevents re-rendering.
setDndState((current) => {
if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
return current;
}
return { type: 'is-dragging-over', closestEdge };
});
},
onDragLeave() {
setDndState(idle);
},
onDrop() {
setDndState(idle);
},
})
);
}, [entityIdentifier, ref]);
return dndState;
};

View File

@@ -1,5 +1,5 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';

View File

@@ -1,7 +1,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList';
import { ControlLayer } from 'features/controlLayers/components/ControlLayer/ControlLayer';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';

View File

@@ -1,5 +1,5 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';

View File

@@ -2,7 +2,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList';
import { IPAdapter } from 'features/controlLayers/components/IPAdapter/IPAdapter';
import { mapId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';

View File

@@ -1,5 +1,5 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';

View File

@@ -1,7 +1,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList';
import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';

View File

@@ -1,5 +1,5 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';

View File

@@ -1,7 +1,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList';
import { RasterLayer } from 'features/controlLayers/components/RasterLayer/RasterLayer';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';

View File

@@ -1,5 +1,5 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer';
import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';

View File

@@ -1,7 +1,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList';
import { RegionalGuidance } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidance';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';

View File

@@ -1,124 +0,0 @@
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 { Box, Flex } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected';
import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor';
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
import type { DndState } from 'features/dnd/dnd';
import { Dnd, idle } from 'features/dnd/dnd';
import { DndDropIndicator } from 'features/dnd/DndDropIndicator';
import type { PropsWithChildren } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext();
const isSelected = useEntityIsSelected(entityIdentifier);
const selectionColor = useEntitySelectionColor(entityIdentifier);
const onClick = useCallback(() => {
if (isSelected) {
return;
}
dispatch(entitySelected({ entityIdentifier }));
}, [dispatch, entityIdentifier, isSelected]);
const ref = useRef<HTMLDivElement>(null);
const [dndState, setDndState] = useState<DndState>(idle);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
return combine(
draggable({
element,
getInitialData() {
return Dnd.Source.singleCanvasEntity.getData({ entityIdentifier });
},
onDragStart() {
setDndState({ type: 'is-dragging' });
},
onDrop() {
setDndState(idle);
},
}),
dropTargetForElements({
element,
canDrop({ source }) {
if (!Dnd.Source.singleCanvasEntity.typeGuard(source.data)) {
return false;
}
if (source.data.payload.entityIdentifier.type !== entityIdentifier.type) {
return false;
}
return true;
},
getData({ input }) {
const data = Dnd.Source.singleCanvasEntity.getData({ entityIdentifier });
return attachClosestEdge(data, {
element,
input,
allowedEdges: ['top', 'bottom'],
});
},
getIsSticky() {
return true;
},
onDragEnter({ self }) {
const closestEdge = extractClosestEdge(self.data);
setDndState({ type: 'is-dragging-over', closestEdge });
},
onDrag({ self }) {
const closestEdge = extractClosestEdge(self.data);
// Only need to update react state if nothing has changed.
// Prevents re-rendering.
setDndState((current) => {
if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
return current;
}
return { type: 'is-dragging-over', closestEdge };
});
},
onDragLeave() {
setDndState(idle);
},
onDrop() {
setDndState(idle);
},
})
);
}, [entityIdentifier]);
return (
<Box position="relative">
<Flex
// This is used to trigger the post-move flash animation
data-entity-id={entityIdentifier.id}
ref={ref}
position="relative"
flexDir="column"
w="full"
bg={isSelected ? 'base.800' : 'base.850'}
onClick={onClick}
borderInlineStartWidth={5}
borderColor={isSelected ? selectionColor : 'base.800'}
borderRadius="base"
>
{props.children}
</Flex>
{dndState.type === 'is-dragging-over' && dndState.closestEdge ? (
<DndDropIndicator
edge={dndState.closestEdge}
// This is the gap between items in the list
gap="var(--invoke-space-2)"
/>
) : null}
</Box>
);
});
CanvasEntityContainer.displayName = 'CanvasEntityContainer';

View File

@@ -1213,7 +1213,7 @@ export const canvasSlice = createSlice({
}
},
entityRasterized: (state, action: PayloadAction<EntityRasterizedPayload>) => {
const { entityIdentifier, imageObject, position, replaceObjects } = action.payload;
const { entityIdentifier, imageObject, position, replaceObjects, isSelected } = action.payload;
const entity = selectEntity(state, entityIdentifier);
if (!entity) {
return;
@@ -1225,6 +1225,10 @@ export const canvasSlice = createSlice({
entity.position = position;
}
}
if (isSelected) {
state.selectedEntityIdentifier = entityIdentifier;
}
},
entityBrushLineAdded: (state, action: PayloadAction<EntityBrushLineAddedPayload>) => {
const { entityIdentifier, brushLine } = action.payload;

View File

@@ -451,6 +451,7 @@ export type EntityRasterizedPayload = EntityIdentifierPayload<{
imageObject: CanvasImageState;
position: Coordinate;
replaceObjects: boolean;
isSelected?: boolean;
}>;
/**

View File

@@ -5,7 +5,6 @@ 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 { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/dnd';
import { useAppDispatch } from 'app/store/storeHooks';
import { Dnd } from 'features/dnd/dnd';
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
@@ -103,13 +102,13 @@ export const DndDropTarget = memo((props: Props) => {
setDndState('potential');
},
getData: () => targetData,
onDrop: (args) => {
const sourceData = args.source.data;
if (!Dnd.Util.isDndSourceData(sourceData)) {
return;
}
dispatch(dndDropped({ sourceData, targetData }));
},
// onDrop: (args) => {
// const sourceData = args.source.data;
// if (!Dnd.Util.isDndSourceData(sourceData)) {
// return;
// }
// dispatch(dndDropped({ sourceData, targetData }));
// },
}),
monitorForElements({
canMonitor: (args) => {
@@ -174,12 +173,7 @@ export const DndDropTarget = memo((props: Props) => {
image_category: 'user',
is_intermediate: false,
});
dispatch(
dndDropped({
sourceData: Dnd.Source.singleImage.getData({ imageDTO }),
targetData,
})
);
Dnd.Util.handleDrop(Dnd.Source.singleImage.getData({ imageDTO }), targetData);
}
},
}),

View File

@@ -2,6 +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 { CSSProperties } from 'react';
/**
@@ -88,7 +89,7 @@ const edgeStyles: Record<Edge, SystemStyleObject> = {
*
* A drop indicator is used to communicate the intended resting place of the draggable item. The orientation of the drop indicator should always match the direction of the content flow.
*/
export function DndDropIndicator({ edge, gap = '0px' }: DropIndicatorProps) {
function DndDropIndicatorInternal({ edge, gap = '0px' }: DropIndicatorProps) {
/**
* To clearly communicate the resting place of a draggable item during a drag operation,
* the drop indicator should be positioned half way between draggable items.
@@ -104,3 +105,21 @@ export function DndDropIndicator({ edge, gap = '0px' }: DropIndicatorProps) {
/>
);
}
export const DndListDropIndicator = ({ dndState }: { dndState: DndListState }) => {
if (dndState.type !== 'is-dragging-over') {
return null;
}
if (!dndState.closestEdge) {
return null;
}
return (
<DndDropIndicatorInternal
edge={dndState.closestEdge}
// This is the gap between items in the list, used to calculate the position of the drop indicator
gap="var(--invoke-space-2)"
/>
);
};

View File

@@ -4,11 +4,35 @@ import type { Input } from '@atlaskit/pragmatic-drag-and-drop/dist/types/entry-p
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 type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
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 type { FieldIdentifier } from 'features/nodes/types/field';
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';
@@ -151,7 +175,7 @@ type DndSourceAPI<T extends DndData> = {
* @template P The optional payload of the Dnd source.
* @param type The type of the Dnd source.
*/
const buildDndSourceApi = <P extends Jsonifiable | undefined = undefined>(type: string) => {
export const buildDndSourceApi = <P extends Jsonifiable | undefined = undefined>(type: string) => {
return {
type,
kind: 'source',
@@ -169,20 +193,10 @@ const singleImage = buildDndSourceApi<{ imageDTO: ImageDTO }>('SingleImage');
* Dnd source API for multiple image source.
*/
const multipleImage = buildDndSourceApi<{ imageDTOs: ImageDTO[]; boardId: BoardId }>('MultipleImage');
/**
* Dnd source API for a single canvas entity.
*/
const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity');
/**
* Dnd source API for a single workflow field.
*/
const singleWorkflowField = buildDndSourceApi<{ fieldIdentifier: FieldIdentifier }>('SingleWorkflowField');
const DndSource = {
singleImage,
multipleImage,
singleCanvasEntity,
singleWorkflowField,
} as const;
type SourceDataTypeMap = {
@@ -207,6 +221,7 @@ type DndTargetApi<T extends DndData> = DndSourceAPI<T> & {
* @returns Whether the drop is valid.
*/
validateDrop: (sourceData: DndData<string, 'source', Jsonifiable>, targetData: T) => boolean;
handleDrop: (sourceData: DndData<string, 'source', Jsonifiable>, targetData: T) => void;
};
/**
@@ -220,7 +235,11 @@ const buildDndTargetApi = <P extends Jsonifiable | undefined = undefined>(
validateDrop: (
sourceData: DndData<string, 'source', Jsonifiable>,
targetData: DndData<typeof type, 'target', P>
) => boolean
) => boolean,
handleDrop: (
sourceData: DndData<string, 'source', Jsonifiable>,
targetData: DndData<typeof type, 'target', P>
) => void
) => {
return {
type,
@@ -228,56 +247,172 @@ const buildDndTargetApi = <P extends Jsonifiable | undefined = undefined>(
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<{ globalReferenceImageId: string }>(
const setGlobalReferenceImage = buildDndTargetApi<{ entityIdentifier: CanvasEntityIdentifier<'reference_image'> }>(
'SetGlobalReferenceImage',
singleImage.typeGuard
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<{
regionalGuidanceId: string;
entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>;
referenceImageId: string;
}>('SetRegionalGuidanceReferenceImage', singleImage.typeGuard);
}>('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);
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);
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);
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);
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);
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
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 }));
}
);
/**
@@ -286,19 +421,57 @@ const newRegionalGuidanceReferenceImageFromImage = buildDndTargetApi(
*/
const replaceLayerWithImage = buildDndTargetApi<{
entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>;
}>('ReplaceLayerWithImage', singleImage.typeGuard);
}>('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);
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
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 }));
}
);
/**
@@ -307,55 +480,104 @@ const setNodeImageField = buildDndTargetApi<{ nodeId: string; fieldName: string
const selectForCompare = buildDndTargetApi<{
firstImageName?: string | null;
secondImageName?: string | null;
}>('SelectForCompare', (sourceData, targetData) => {
if (!singleImage.typeGuard(sourceData)) {
return false;
}>(
'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));
}
// 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;
});
);
/**
* 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;
}
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;
}
if (multipleImage.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
const destinationBoard = targetData.payload.boardId;
return currentBoard !== destinationBoard;
}
return false;
});
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';
}
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';
}
if (multipleImage.typeGuard(sourceData)) {
const currentBoard = sourceData.payload.boardId;
return currentBoard !== 'none';
}
return false;
});
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 = {
/**
@@ -491,6 +713,25 @@ export const Dnd = {
}
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;
}
}
},
},
};
@@ -536,7 +777,7 @@ export function triggerPostMoveFlash(element: HTMLElement, backgroundColor: CSSP
});
}
export type DndState =
export type DndListState =
| {
type: 'idle';
}
@@ -551,4 +792,4 @@ export type DndState =
type: 'is-dragging-over';
closestEdge: Edge | null;
};
export const idle: DndState = { type: 'idle' };
export const idle: DndListState = { type: 'idle' };

View File

@@ -0,0 +1,69 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
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 { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { Dnd } from 'features/dnd/dnd';
import { useEffect } from 'react';
const log = logger('dnd');
export const useDndMonitor = () => {
useAssertSingleton('useDropMonitor');
useEffect(() => {
return combine(
monitorForElements({
canMonitor: ({ source }) => {
const sourceData = source.data;
// Check for allowed sources
if (!Dnd.Source.singleImage.typeGuard(sourceData) && !Dnd.Source.multipleImage.typeGuard(sourceData)) {
return false;
}
return true;
},
onDrop: ({ source, location }) => {
const target = location.current.dropTargets[0];
if (!target) {
return;
}
const sourceData = source.data;
const targetData = target.data;
// Check for allowed sources
if (!Dnd.Source.singleImage.typeGuard(sourceData) && !Dnd.Source.multipleImage.typeGuard(sourceData)) {
return;
}
// Check for allowed targets
if (!Dnd.Util.isDndTargetData(targetData)) {
return;
}
log.debug({ sourceData, targetData }, 'Dropped image');
Dnd.Util.handleDrop(sourceData, targetData);
},
}),
monitorForExternal({
canMonitor: (args) => {
if (!containsFiles(args)) {
return false;
}
return true;
},
onDragStart: () => {
preventUnhandled.start();
},
onDrop: () => {
preventUnhandled.stop();
},
})
);
}, []);
};

View File

@@ -1,17 +1,14 @@
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 { Box, Circle, Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import type { DndState } from 'features/dnd/dnd';
import { Dnd, idle } from 'features/dnd/dnd';
import { DndDropIndicator } from 'features/dnd/DndDropIndicator';
import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator';
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
import { useLinearViewFieldDnd } from 'features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd';
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
@@ -20,92 +17,28 @@ import FieldTooltipContent from './FieldTooltipContent';
import InputFieldRenderer from './InputFieldRenderer';
type Props = {
nodeId: string;
fieldName: string;
fieldIdentifier: FieldIdentifier;
};
const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => {
const LinearViewFieldInternal = ({ fieldIdentifier }: Props) => {
const dispatch = useAppDispatch();
const { isValueChanged, onReset } = useFieldOriginalValue(nodeId, fieldName);
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(nodeId);
const { isValueChanged, onReset } = useFieldOriginalValue(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(fieldIdentifier.nodeId);
const { t } = useTranslation();
const handleRemoveField = useCallback(() => {
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
}, [dispatch, fieldName, nodeId]);
dispatch(workflowExposedFieldRemoved(fieldIdentifier));
}, [dispatch, fieldIdentifier]);
const ref = useRef<HTMLDivElement>(null);
const [dndState, setDndState] = useState<DndState>(idle);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
return combine(
draggable({
element,
getInitialData() {
return Dnd.Source.singleWorkflowField.getData({ fieldIdentifier: { nodeId, fieldName } });
},
onDragStart() {
setDndState({ type: 'is-dragging' });
},
onDrop() {
setDndState(idle);
},
}),
dropTargetForElements({
element,
canDrop({ source }) {
if (!Dnd.Source.singleWorkflowField.typeGuard(source.data)) {
return false;
}
return true;
},
getData({ input }) {
const data = Dnd.Source.singleWorkflowField.getData({ fieldIdentifier: { nodeId, fieldName } });
return attachClosestEdge(data, {
element,
input,
allowedEdges: ['top', 'bottom'],
});
},
getIsSticky() {
return true;
},
onDragEnter({ self }) {
const closestEdge = extractClosestEdge(self.data);
setDndState({ type: 'is-dragging-over', closestEdge });
},
onDrag({ self }) {
const closestEdge = extractClosestEdge(self.data);
// Only need to update react state if nothing has changed.
// Prevents re-rendering.
setDndState((current) => {
if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
return current;
}
return { type: 'is-dragging-over', closestEdge };
});
},
onDragLeave() {
setDndState(idle);
},
onDrop() {
setDndState(idle);
},
})
);
}, [fieldName, nodeId]);
const dndState = useLinearViewFieldDnd(ref, fieldIdentifier);
return (
<Box position="relative" w="full">
<Flex
ref={ref}
// This is used to trigger the post-move flash animation
data-field-name={fieldName}
data-field-name={fieldIdentifier.fieldName}
onMouseEnter={handleMouseOver}
onMouseLeave={handleMouseOut}
layerStyle="second"
@@ -117,7 +50,7 @@ const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => {
>
<Flex flexDir="column" w="full">
<Flex alignItems="center" gap={2}>
<EditableFieldTitle nodeId={nodeId} fieldName={fieldName} kind="inputs" />
<EditableFieldTitle nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} kind="inputs" />
<Spacer />
{isMouseOverNode && <Circle size={2} borderRadius="full" bg="invokeBlue.500" />}
{isValueChanged && (
@@ -131,7 +64,13 @@ const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => {
/>
)}
<Tooltip
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="inputs" />}
label={
<FieldTooltipContent
nodeId={fieldIdentifier.nodeId}
fieldName={fieldIdentifier.fieldName}
kind="inputs"
/>
}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top"
>
@@ -148,24 +87,18 @@ const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => {
icon={<PiTrashSimpleBold />}
/>
</Flex>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
<InputFieldRenderer nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} />
</Flex>
</Flex>
{dndState.type === 'is-dragging-over' && dndState.closestEdge ? (
<DndDropIndicator
edge={dndState.closestEdge}
// This is the gap between items in the list
gap="var(--invoke-space-2)"
/>
) : null}
<DndListDropIndicator dndState={dndState} />
</Box>
);
};
const LinearViewField = ({ nodeId, fieldName }: Props) => {
const LinearViewField = ({ fieldIdentifier }: Props) => {
return (
<InvocationInputFieldCheck nodeId={nodeId} fieldName={fieldName}>
<LinearViewFieldInternal nodeId={nodeId} fieldName={fieldName} />
<InvocationInputFieldCheck nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
<LinearViewFieldInternal fieldIdentifier={fieldIdentifier} />
</InvocationInputFieldCheck>
);
};

View File

@@ -7,8 +7,9 @@ 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 { Dnd, triggerPostMoveFlash } from 'features/dnd/dnd';
import { triggerPostMoveFlash } from 'features/dnd/dnd';
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';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { memo, useEffect } from 'react';
@@ -56,7 +57,7 @@ const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) =
useEffect(() => {
return monitorForElements({
canMonitor({ source }) {
if (!Dnd.Source.singleWorkflowField.typeGuard(source.data)) {
if (!singleWorkflowField.typeGuard(source.data)) {
return false;
}
return true;
@@ -70,10 +71,7 @@ const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) =
const sourceData = source.data;
const targetData = target.data;
if (
!Dnd.Source.singleWorkflowField.typeGuard(sourceData) ||
!Dnd.Source.singleWorkflowField.typeGuard(targetData)
) {
if (!singleWorkflowField.typeGuard(sourceData) || !singleWorkflowField.typeGuard(targetData)) {
return;
}
@@ -135,8 +133,11 @@ const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) =
return (
<>
{fields.map(({ nodeId, fieldName }) => (
<LinearViewFieldInternal key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName} />
{fields.map((fieldIdentifier) => (
<LinearViewFieldInternal
key={`${fieldIdentifier.nodeId}.${fieldIdentifier.fieldName}`}
fieldIdentifier={fieldIdentifier}
/>
))}
</>
);

View File

@@ -0,0 +1,82 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import type { DndListState } from 'features/dnd/dnd';
import { buildDndSourceApi, idle } from 'features/dnd/dnd';
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');
export const useLinearViewFieldDnd = (ref: RefObject<HTMLElement>, fieldIdentifier: FieldIdentifier) => {
const [dndState, setDndState] = useState<DndListState>(idle);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
return combine(
draggable({
element,
getInitialData() {
return singleWorkflowField.getData({ fieldIdentifier });
},
onDragStart() {
setDndState({ type: 'is-dragging' });
},
onDrop() {
setDndState(idle);
},
}),
dropTargetForElements({
element,
canDrop({ source }) {
if (!singleWorkflowField.typeGuard(source.data)) {
return false;
}
return true;
},
getData({ input }) {
const data = singleWorkflowField.getData({ fieldIdentifier });
return attachClosestEdge(data, {
element,
input,
allowedEdges: ['top', 'bottom'],
});
},
getIsSticky() {
return true;
},
onDragEnter({ self }) {
const closestEdge = extractClosestEdge(self.data);
setDndState({ type: 'is-dragging-over', closestEdge });
},
onDrag({ self }) {
const closestEdge = extractClosestEdge(self.data);
// Only need to update react state if nothing has changed.
// Prevents re-rendering.
setDndState((current) => {
if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
return current;
}
return { type: 'is-dragging-over', closestEdge };
});
},
onDragLeave() {
setDndState(idle);
},
onDrop() {
setDndState(idle);
},
})
);
}, [fieldIdentifier, ref]);
return dndState;
};

View File

@@ -2,6 +2,7 @@ import { Box, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasMainPanelContent } from 'features/controlLayers/components/CanvasMainPanelContent';
import { CanvasRightPanel } from 'features/controlLayers/components/CanvasRightPanel';
import { useDndMonitor } from 'features/dnd/useDndMonitor';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
@@ -40,6 +41,7 @@ const onRightPanelCollapse = (isCollapsed: boolean) => $isRightPanelOpen.set(!is
export const AppContent = memo(() => {
const imperativePanelGroupRef = useRef<ImperativePanelGroupHandle>(null);
useDndMonitor();
const withLeftPanel = useAppSelector(selectWithLeftPanel);
const leftPanelUsePanelOptions = useMemo<UsePanelOptions>(