diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json
index 6e0a6441fae..b8cd3e02000 100644
--- a/invokeai/frontend/web/package.json
+++ b/invokeai/frontend/web/package.json
@@ -52,11 +52,11 @@
}
},
"dependencies": {
+ "@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
+ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0",
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@dagrejs/dagre": "^1.1.4",
"@dagrejs/graphlib": "^2.2.4",
- "@dnd-kit/core": "^6.1.0",
- "@dnd-kit/sortable": "^8.0.0",
- "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.1.0",
"@invoke-ai/ui-library": "^0.0.43",
"@nanostores/react": "^0.7.3",
diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml
index 9178b76d1e4..17ead7a8e59 100644
--- a/invokeai/frontend/web/pnpm-lock.yaml
+++ b/invokeai/frontend/web/pnpm-lock.yaml
@@ -5,21 +5,21 @@ settings:
excludeLinksFromLockfile: false
dependencies:
+ '@atlaskit/pragmatic-drag-and-drop':
+ specifier: ^1.4.0
+ version: 1.4.0
+ '@atlaskit/pragmatic-drag-and-drop-auto-scroll':
+ specifier: ^1.4.0
+ version: 1.4.0
+ '@atlaskit/pragmatic-drag-and-drop-hitbox':
+ specifier: ^1.0.3
+ version: 1.0.3
'@dagrejs/dagre':
specifier: ^1.1.4
version: 1.1.4
'@dagrejs/graphlib':
specifier: ^2.2.4
version: 2.2.4
- '@dnd-kit/core':
- specifier: ^6.1.0
- version: 6.1.0(react-dom@18.3.1)(react@18.3.1)
- '@dnd-kit/sortable':
- specifier: ^8.0.0
- version: 8.0.0(@dnd-kit/core@6.1.0)(react@18.3.1)
- '@dnd-kit/utilities':
- specifier: ^3.2.2
- version: 3.2.2(react@18.3.1)
'@fontsource-variable/inter':
specifier: ^5.1.0
version: 5.1.0
@@ -319,6 +319,28 @@ packages:
'@jridgewell/trace-mapping': 0.3.25
dev: true
+ /@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0:
+ resolution: {integrity: sha512-5GoikoTSW13UX76F9TDeWB8x3jbbGlp/Y+3aRkHe1MOBMkrWkwNpJ42MIVhhX/6NSeaZiPumP0KbGJVs2tOWSQ==}
+ dependencies:
+ '@atlaskit/pragmatic-drag-and-drop': 1.4.0
+ '@babel/runtime': 7.25.7
+ dev: false
+
+ /@atlaskit/pragmatic-drag-and-drop-hitbox@1.0.3:
+ resolution: {integrity: sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==}
+ dependencies:
+ '@atlaskit/pragmatic-drag-and-drop': 1.4.0
+ '@babel/runtime': 7.25.7
+ dev: false
+
+ /@atlaskit/pragmatic-drag-and-drop@1.4.0:
+ resolution: {integrity: sha512-qRY3PTJIcxfl/QB8Gwswz+BRvlmgAC5pB+J2hL6dkIxgqAgVwOhAamMUKsrOcFU/axG2Q7RbNs1xfoLKDuhoPg==}
+ dependencies:
+ '@babel/runtime': 7.25.7
+ bind-event-listener: 3.0.0
+ raf-schd: 4.0.3
+ dev: false
+
/@babel/code-frame@7.25.7:
resolution: {integrity: sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==}
engines: {node: '>=6.9.0'}
@@ -980,49 +1002,6 @@ packages:
engines: {node: '>17.0.0'}
dev: false
- /@dnd-kit/accessibility@3.1.0(react@18.3.1):
- resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==}
- peerDependencies:
- react: '>=16.8.0'
- dependencies:
- react: 18.3.1
- tslib: 2.7.0
- dev: false
-
- /@dnd-kit/core@6.1.0(react-dom@18.3.1)(react@18.3.1):
- resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==}
- peerDependencies:
- react: '>=16.8.0'
- react-dom: '>=16.8.0'
- dependencies:
- '@dnd-kit/accessibility': 3.1.0(react@18.3.1)
- '@dnd-kit/utilities': 3.2.2(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- tslib: 2.7.0
- dev: false
-
- /@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0)(react@18.3.1):
- resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
- peerDependencies:
- '@dnd-kit/core': ^6.1.0
- react: '>=16.8.0'
- dependencies:
- '@dnd-kit/core': 6.1.0(react-dom@18.3.1)(react@18.3.1)
- '@dnd-kit/utilities': 3.2.2(react@18.3.1)
- react: 18.3.1
- tslib: 2.7.0
- dev: false
-
- /@dnd-kit/utilities@3.2.2(react@18.3.1):
- resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
- peerDependencies:
- react: '>=16.8.0'
- dependencies:
- react: 18.3.1
- tslib: 2.7.0
- dev: false
-
/@emotion/babel-plugin@11.12.0:
resolution: {integrity: sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==}
dependencies:
@@ -4313,6 +4292,10 @@ packages:
open: 8.4.2
dev: true
+ /bind-event-listener@3.0.0:
+ resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==}
+ dev: false
+
/bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
dependencies:
@@ -7557,6 +7540,10 @@ packages:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
+ /raf-schd@4.0.3:
+ resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
+ dev: false
+
/raf-throttle@2.0.6:
resolution: {integrity: sha512-C7W6hy78A+vMmk5a/B6C5szjBHrUzWJkVyakjKCK59Uy2CcA7KhO1JUvvH32IXYFIcyJ3FMKP3ZzCc2/71I6Vg==}
dev: false
diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx
index 20f8fe27c14..67003f5b62e 100644
--- a/invokeai/frontend/web/src/app/components/App.tsx
+++ b/invokeai/frontend/web/src/app/components/App.tsx
@@ -8,10 +8,8 @@ import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig';
import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import type { PartialAppConfig } from 'app/types/invokeai';
-import ImageUploadOverlay from 'common/components/ImageUploadOverlay';
import { useFocusRegionWatcher } from 'common/hooks/focus';
import { useClearStorage } from 'common/hooks/useClearStorage';
-import { useFullscreenDropzone } from 'common/hooks/useFullscreenDropzone';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal';
import {
@@ -19,6 +17,7 @@ import {
NewGallerySessionDialog,
} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog';
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
+import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
@@ -62,8 +61,6 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
useGetOpenAPISchemaQuery();
useSyncLoggingConfig();
- const { dropzone, isHandlingUpload, setIsHandlingUpload } = useFullscreenDropzone();
-
const handleReset = useCallback(() => {
clearStorage();
location.reload();
@@ -92,19 +89,8 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
return (
-
-
+
- {dropzone.isDragActive && isHandlingUpload && (
-
- )}
@@ -121,6 +107,7 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
+
);
};
diff --git a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx
index cc38766f4f0..c4826a94419 100644
--- a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx
+++ b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx
@@ -1,4 +1,3 @@
-import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { useIsRegionFocused } from 'common/hooks/focus';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
@@ -8,13 +7,11 @@ import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
-import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
export const GlobalImageHotkeys = memo(() => {
useAssertSingleton('GlobalImageHotkeys');
- const lastSelectedImage = useAppSelector(selectLastSelectedImage);
- const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
+ const imageDTO = useAppSelector(selectLastSelectedImage);
if (!imageDTO) {
return null;
diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
index dbcebd00350..848ce1a4039 100644
--- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
+++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx
@@ -19,7 +19,6 @@ import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import { createStore } from 'app/store/store';
import type { PartialAppConfig } from 'app/types/invokeai';
import Loading from 'common/components/Loading/Loading';
-import AppDndContext from 'features/dnd/components/AppDndContext';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import type { PropsWithChildren, ReactNode } from 'react';
import React, { lazy, memo, useEffect, useLayoutEffect, useMemo } from 'react';
@@ -237,9 +236,7 @@ const InvokeAIUI = ({
}>
-
-
-
+
diff --git a/invokeai/frontend/web/src/app/logging/logger.ts b/invokeai/frontend/web/src/app/logging/logger.ts
index ae5d265d9b2..6b0cb1a298b 100644
--- a/invokeai/frontend/web/src/app/logging/logger.ts
+++ b/invokeai/frontend/web/src/app/logging/logger.ts
@@ -17,6 +17,7 @@ const $logger = atom(Roarr.child(BASE_CONTEXT));
export const zLogNamespace = z.enum([
'canvas',
'config',
+ 'dnd',
'events',
'gallery',
'generation',
diff --git a/invokeai/frontend/web/src/app/store/constants.ts b/invokeai/frontend/web/src/app/store/constants.ts
index bc97091d761..58989b4a54a 100644
--- a/invokeai/frontend/web/src/app/store/constants.ts
+++ b/invokeai/frontend/web/src/app/store/constants.ts
@@ -1,4 +1,3 @@
export const STORAGE_PREFIX = '@@invokeai-';
export const EMPTY_ARRAY = [];
-/** @knipignore */
export const EMPTY_OBJECT = {};
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
index a0a6604ddf8..554a274cf95 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts
@@ -16,7 +16,6 @@ import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMi
import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema';
import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard';
import { addImageDeletionListeners } from 'app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners';
-import { addImageDroppedListener } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard';
import { addImagesStarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesStarred';
import { addImagesUnstarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesUnstarred';
@@ -93,9 +92,6 @@ addGetOpenAPISchemaListener(startAppListening);
addWorkflowLoadRequestedListener(startAppListening);
addUpdateAllNodesRequestedListener(startAppListening);
-// DND
-addImageDroppedListener(startAppListening);
-
// Models
addModelSelectedListener(startAppListening);
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts
index 3c7a8b9ea7a..cc1d2cbbaa6 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts
@@ -1,12 +1,12 @@
import { createAction } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
-import type { SerializableObject } from 'common/types';
import { buildAdHocPostProcessingGraph } from 'features/nodes/util/graph/buildAdHocPostProcessingGraph';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { queueApi } from 'services/api/endpoints/queue';
import type { BatchConfig, ImageDTO } from 'services/api/types';
+import type { JsonObject } from 'type-fest';
const log = logger('queue');
@@ -39,9 +39,9 @@ export const addAdHocPostProcessingRequestedListener = (startAppListening: AppSt
const enqueueResult = await req.unwrap();
req.reset();
- log.debug({ enqueueResult } as SerializableObject, t('queue.graphQueued'));
+ log.debug({ enqueueResult } as JsonObject, t('queue.graphQueued'));
} catch (error) {
- log.error({ enqueueBatchArg } as SerializableObject, t('queue.graphFailedToQueue'));
+ log.error({ enqueueBatchArg } as JsonObject, t('queue.graphFailedToQueue'));
if (error instanceof Object && 'status' in error && error.status === 403) {
return;
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts
index bfc0a014f78..e2fd33ecf30 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts
@@ -1,12 +1,12 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
-import type { SerializableObject } from 'common/types';
import { zPydanticValidationError } from 'features/system/store/zodSchemas';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { truncate, upperFirst } from 'lodash-es';
import { serializeError } from 'serialize-error';
import { queueApi } from 'services/api/endpoints/queue';
+import type { JsonObject } from 'type-fest';
const log = logger('queue');
@@ -17,7 +17,7 @@ export const addBatchEnqueuedListener = (startAppListening: AppStartListening) =
effect: (action) => {
const enqueueResult = action.payload;
const arg = action.meta.arg.originalArgs;
- log.debug({ enqueueResult } as SerializableObject, 'Batch enqueued');
+ log.debug({ enqueueResult } as JsonObject, 'Batch enqueued');
toast({
id: 'QUEUE_BATCH_SUCCEEDED',
@@ -45,7 +45,7 @@ export const addBatchEnqueuedListener = (startAppListening: AppStartListening) =
status: 'error',
description: t('common.unknownError'),
});
- log.error({ batchConfig } as SerializableObject, t('queue.batchFailedToQueue'));
+ log.error({ batchConfig } as JsonObject, t('queue.batchFailedToQueue'));
return;
}
@@ -71,7 +71,7 @@ export const addBatchEnqueuedListener = (startAppListening: AppStartListening) =
description: t('common.unknownError'),
});
}
- log.error({ batchConfig, error: serializeError(response) } as SerializableObject, t('queue.batchFailedToQueue'));
+ log.error({ batchConfig, error: serializeError(response) } as JsonObject, t('queue.batchFailedToQueue'));
},
});
};
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
index 60a8ea814fe..8cc52045b0f 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts
@@ -1,7 +1,6 @@
import { logger } from 'app/logging/logger';
import { enqueueRequested } from 'app/store/actions';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
-import type { SerializableObject } from 'common/types';
import type { Result } from 'common/util/result';
import { withResult, withResultAsync } from 'common/util/result';
import { $canvasManager } from 'features/controlLayers/store/ephemeral';
@@ -14,6 +13,7 @@ import { serializeError } from 'serialize-error';
import { queueApi } from 'services/api/endpoints/queue';
import type { Invocation } from 'services/api/types';
import { assert } from 'tsafe';
+import type { JsonObject } from 'type-fest';
const log = logger('generation');
@@ -88,7 +88,7 @@ export const addEnqueueRequestedLinear = (startAppListening: AppStartListening)
return;
}
- log.debug({ batchConfig: prepareBatchResult.value } as SerializableObject, 'Enqueued batch');
+ log.debug({ batchConfig: prepareBatchResult.value } as JsonObject, 'Enqueued batch');
},
});
};
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema.ts
index 37aed5ac715..bbf2a09b6a7 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema.ts
@@ -1,12 +1,12 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
-import type { SerializableObject } from 'common/types';
import { parseify } from 'common/util/serialize';
import { $templates } from 'features/nodes/store/nodesSlice';
import { parseSchema } from 'features/nodes/util/schema/parseSchema';
import { size } from 'lodash-es';
import { serializeError } from 'serialize-error';
import { appInfoApi } from 'services/api/endpoints/appInfo';
+import type { JsonObject } from 'type-fest';
const log = logger('system');
@@ -16,12 +16,12 @@ export const addGetOpenAPISchemaListener = (startAppListening: AppStartListening
effect: (action, { getState }) => {
const schemaJSON = action.payload;
- log.debug({ schemaJSON: parseify(schemaJSON) } as SerializableObject, 'Received OpenAPI schema');
+ log.debug({ schemaJSON: parseify(schemaJSON) } as JsonObject, 'Received OpenAPI schema');
const { nodesAllowlist, nodesDenylist } = getState().config;
const nodeTemplates = parseSchema(schemaJSON, nodesAllowlist, nodesDenylist);
- log.debug({ nodeTemplates } as SerializableObject, `Built ${size(nodeTemplates)} node templates`);
+ log.debug({ nodeTemplates } as JsonObject, `Built ${size(nodeTemplates)} node templates`);
$templates.set(nodeTemplates);
},
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts
deleted file mode 100644
index 23e7d346afc..00000000000
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts
+++ /dev/null
@@ -1,333 +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 { 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, initialControlNet } from 'features/controlLayers/store/util';
-import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
-import { isValidDrop } from 'features/dnd/util/isValidDrop';
-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';
-
-export const dndDropped = createAction<{
- overData: TypesafeDroppableData;
- activeData: TypesafeDraggableData;
-}>('dnd/dndDropped');
-
-const log = logger('system');
-
-export const addImageDroppedListener = (startAppListening: AppStartListening) => {
- startAppListening({
- actionCreator: dndDropped,
- effect: (action, { dispatch, getState }) => {
- const { activeData, overData } = action.payload;
- if (!isValidDrop(overData, activeData)) {
- return;
- }
-
- if (activeData.payloadType === 'IMAGE_DTO') {
- log.debug({ activeData, overData }, 'Image dropped');
- } else if (activeData.payloadType === 'GALLERY_SELECTION') {
- log.debug({ activeData, overData }, `Images (${getState().gallery.selection.length}) dropped`);
- } else if (activeData.payloadType === 'NODE_FIELD') {
- log.debug({ activeData, overData }, 'Node field dropped');
- } else {
- log.debug({ activeData, overData }, `Unknown payload dropped`);
- }
-
- /**
- * Image dropped on IP Adapter Layer
- */
- if (
- overData.actionType === 'SET_IPA_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const { id } = overData.context;
- dispatch(
- referenceImageIPAdapterImageChanged({
- entityIdentifier: { id, type: 'reference_image' },
- imageDTO: activeData.payload.imageDTO,
- })
- );
- return;
- }
-
- /**
- * Image dropped on RG Layer IP Adapter
- */
- if (
- overData.actionType === 'SET_RG_IP_ADAPTER_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const { id, referenceImageId } = overData.context;
- dispatch(
- rgIPAdapterImageChanged({
- entityIdentifier: { id, type: 'regional_guidance' },
- referenceImageId,
- imageDTO: activeData.payload.imageDTO,
- })
- );
- return;
- }
-
- /**
- * Image dropped on Raster layer
- */
- if (
- overData.actionType === 'ADD_RASTER_LAYER_FROM_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const imageObject = imageDTOToImageObject(activeData.payload.imageDTO);
- const { x, y } = selectCanvasSlice(getState()).bbox.rect;
- const overrides: Partial = {
- objects: [imageObject],
- position: { x, y },
- };
- dispatch(rasterLayerAdded({ overrides, isSelected: true }));
- return;
- }
-
- /**
-
- /**
- * Image dropped on Inpaint Mask
- */
- if (
- overData.actionType === 'ADD_INPAINT_MASK_FROM_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const imageObject = imageDTOToImageObject(activeData.payload.imageDTO);
- const { x, y } = selectCanvasSlice(getState()).bbox.rect;
- const overrides: Partial = {
- objects: [imageObject],
- position: { x, y },
- };
- dispatch(inpaintMaskAdded({ overrides, isSelected: true }));
- return;
- }
-
- /**
-
- /**
- * Image dropped on Regional Guidance
- */
- if (
- overData.actionType === 'ADD_REGIONAL_GUIDANCE_FROM_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const imageObject = imageDTOToImageObject(activeData.payload.imageDTO);
- const { x, y } = selectCanvasSlice(getState()).bbox.rect;
- const overrides: Partial = {
- objects: [imageObject],
- position: { x, y },
- };
- dispatch(rgAdded({ overrides, isSelected: true }));
- return;
- }
-
- /**
- * Image dropped on Raster layer
- */
- if (
- overData.actionType === 'ADD_CONTROL_LAYER_FROM_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const state = getState();
- const imageObject = imageDTOToImageObject(activeData.payload.imageDTO);
- const { x, y } = selectCanvasSlice(state).bbox.rect;
- const overrides: Partial = {
- objects: [imageObject],
- position: { x, y },
- controlAdapter: deepClone(initialControlNet),
- };
- dispatch(controlLayerAdded({ overrides, isSelected: true }));
- return;
- }
-
- if (
- overData.actionType === 'ADD_REGIONAL_REFERENCE_IMAGE_FROM_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const state = getState();
- const ipAdapter = deepClone(selectDefaultIPAdapter(state));
- ipAdapter.image = imageDTOToImageWithDims(activeData.payload.imageDTO);
- const overrides: Partial = {
- referenceImages: [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }],
- };
- dispatch(rgAdded({ overrides, isSelected: true }));
- return;
- }
-
- if (
- overData.actionType === 'ADD_GLOBAL_REFERENCE_IMAGE_FROM_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const state = getState();
- const ipAdapter = deepClone(selectDefaultIPAdapter(state));
- ipAdapter.image = imageDTOToImageWithDims(activeData.payload.imageDTO);
- const overrides: Partial = {
- ipAdapter,
- };
- dispatch(referenceImageAdded({ overrides, isSelected: true }));
- return;
- }
-
- /**
- * Image dropped on Raster layer
- */
- if (overData.actionType === 'REPLACE_LAYER_WITH_IMAGE' && activeData.payloadType === 'IMAGE_DTO') {
- const state = getState();
- const { entityIdentifier } = overData.context;
- const imageObject = imageDTOToImageObject(activeData.payload.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 (
- overData.actionType === 'SET_NODES_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const { fieldName, nodeId } = overData.context;
- dispatch(
- fieldImageValueChanged({
- nodeId,
- fieldName,
- value: activeData.payload.imageDTO,
- })
- );
- return;
- }
-
- /**
- * Image selected for compare
- */
- if (
- overData.actionType === 'SELECT_FOR_COMPARE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const { imageDTO } = activeData.payload;
- dispatch(imageToCompareChanged(imageDTO));
- return;
- }
-
- /**
- * Image dropped on user board
- */
- if (
- overData.actionType === 'ADD_TO_BOARD' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const { imageDTO } = activeData.payload;
- const { boardId } = overData.context;
- dispatch(
- imagesApi.endpoints.addImageToBoard.initiate({
- imageDTO,
- board_id: boardId,
- })
- );
- dispatch(selectionChanged([]));
- return;
- }
-
- /**
- * Image dropped on 'none' board
- */
- if (
- overData.actionType === 'REMOVE_FROM_BOARD' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const { imageDTO } = activeData.payload;
- dispatch(
- imagesApi.endpoints.removeImageFromBoard.initiate({
- imageDTO,
- })
- );
- dispatch(selectionChanged([]));
- return;
- }
-
- /**
- * Image dropped on upscale initial image
- */
- if (
- overData.actionType === 'SET_UPSCALE_INITIAL_IMAGE' &&
- activeData.payloadType === 'IMAGE_DTO' &&
- activeData.payload.imageDTO
- ) {
- const { imageDTO } = activeData.payload;
-
- dispatch(upscaleInitialImageChanged(imageDTO));
- return;
- }
-
- /**
- * Multiple images dropped on user board
- */
- if (overData.actionType === 'ADD_TO_BOARD' && activeData.payloadType === 'GALLERY_SELECTION') {
- const imageDTOs = getState().gallery.selection;
- const { boardId } = overData.context;
- dispatch(
- imagesApi.endpoints.addImagesToBoard.initiate({
- imageDTOs,
- board_id: boardId,
- })
- );
- dispatch(selectionChanged([]));
- return;
- }
-
- /**
- * Multiple images dropped on 'none' board
- */
- if (overData.actionType === 'REMOVE_FROM_BOARD' && activeData.payloadType === 'GALLERY_SELECTION') {
- const imageDTOs = getState().gallery.selection;
- dispatch(
- imagesApi.endpoints.removeImagesFromBoard.initiate({
- imageDTOs,
- })
- );
- dispatch(selectionChanged([]));
- return;
- }
- },
- });
-};
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts
index 7949a5cabc5..77e855e376b 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts
@@ -1,18 +1,8 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { RootState } from 'app/store/store';
-import {
- entityRasterized,
- entitySelected,
- referenceImageIPAdapterImageChanged,
- rgIPAdapterImageChanged,
-} from 'features/controlLayers/store/canvasSlice';
-import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
-import { imageDTOToImageObject } from 'features/controlLayers/store/util';
import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors';
import { boardIdSelected, galleryViewChanged } from 'features/gallery/store/gallerySlice';
-import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
-import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { omit } from 'lodash-es';
@@ -51,12 +41,6 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
log.debug({ imageDTO }, 'Image uploaded');
- const { postUploadAction } = action.meta.arg.originalArgs;
-
- if (!postUploadAction) {
- return;
- }
-
const DEFAULT_UPLOADED_TOAST = {
id: 'IMAGE_UPLOADED',
title: t('toast.imageUploaded'),
@@ -64,80 +48,34 @@ export const addImageUploadedFulfilledListener = (startAppListening: AppStartLis
} as const;
// default action - just upload and alert user
- if (postUploadAction.type === 'TOAST') {
- const boardId = imageDTO.board_id ?? 'none';
- if (lastUploadedToastTimeout !== null) {
- window.clearTimeout(lastUploadedToastTimeout);
- }
- const toastApi = toast({
- ...DEFAULT_UPLOADED_TOAST,
- title: postUploadAction.title || DEFAULT_UPLOADED_TOAST.title,
- description: getUploadedToastDescription(boardId, state),
- duration: null, // we will close the toast manually
- });
- lastUploadedToastTimeout = window.setTimeout(() => {
- toastApi.close();
- }, 3000);
- /**
- * We only want to change the board and view if this is the first upload of a batch, else we end up hijacking
- * the user's gallery board and view selection:
- * - User uploads multiple images
- * - A couple uploads finish, but others are pending still
- * - User changes the board selection
- * - Pending uploads finish and change the board back to the original board
- * - User is confused as to why the board changed
- *
- * Default to true to not require _all_ image upload handlers to set this value
- */
- const isFirstUploadOfBatch = action.meta.arg.originalArgs.isFirstUploadOfBatch ?? true;
- if (isFirstUploadOfBatch) {
- dispatch(boardIdSelected({ boardId }));
- dispatch(galleryViewChanged('assets'));
- }
- return;
- }
-
- if (postUploadAction.type === 'SET_UPSCALE_INITIAL_IMAGE') {
- dispatch(upscaleInitialImageChanged(imageDTO));
- toast({
- ...DEFAULT_UPLOADED_TOAST,
- description: 'set as upscale initial image',
- });
- return;
- }
-
- if (postUploadAction.type === 'SET_IPA_IMAGE') {
- const { id } = postUploadAction;
- dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier: { id, type: 'reference_image' }, imageDTO }));
- toast({ ...DEFAULT_UPLOADED_TOAST, description: t('toast.setControlImage') });
- return;
+ const boardId = imageDTO.board_id ?? 'none';
+ if (lastUploadedToastTimeout !== null) {
+ window.clearTimeout(lastUploadedToastTimeout);
}
-
- if (postUploadAction.type === 'SET_RG_IP_ADAPTER_IMAGE') {
- const { id, referenceImageId } = postUploadAction;
- dispatch(
- rgIPAdapterImageChanged({ entityIdentifier: { id, type: 'regional_guidance' }, referenceImageId, imageDTO })
- );
- toast({ ...DEFAULT_UPLOADED_TOAST, description: t('toast.setControlImage') });
- return;
- }
-
- if (postUploadAction.type === 'SET_NODES_IMAGE') {
- const { nodeId, fieldName } = postUploadAction;
- dispatch(fieldImageValueChanged({ nodeId, fieldName, value: imageDTO }));
- toast({ ...DEFAULT_UPLOADED_TOAST, description: `${t('toast.setNodeField')} ${fieldName}` });
- return;
- }
-
- if (postUploadAction.type === 'REPLACE_LAYER_WITH_IMAGE') {
- const { entityIdentifier } = postUploadAction;
-
- const state = getState();
- const imageObject = imageDTOToImageObject(imageDTO);
- const { x, y } = selectCanvasSlice(state).bbox.rect;
- dispatch(entityRasterized({ entityIdentifier, imageObject, position: { x, y }, replaceObjects: true }));
- dispatch(entitySelected({ entityIdentifier }));
- return;
+ const toastApi = toast({
+ ...DEFAULT_UPLOADED_TOAST,
+ title: DEFAULT_UPLOADED_TOAST.title,
+ description: getUploadedToastDescription(boardId, state),
+ duration: null, // we will close the toast manually
+ });
+ lastUploadedToastTimeout = window.setTimeout(() => {
+ toastApi.close();
+ }, 3000);
+ /**
+ * We only want to change the board and view if this is the first upload of a batch, else we end up hijacking
+ * the user's gallery board and view selection:
+ * - User uploads multiple images
+ * - A couple uploads finish, but others are pending still
+ * - User changes the board selection
+ * - Pending uploads finish and change the board back to the original board
+ * - User is confused as to why the board changed
+ *
+ * Default to true to not require _all_ image upload handlers to set this value
+ */
+ const isFirstUploadOfBatch = action.meta.arg.originalArgs.isFirstUploadOfBatch ?? true;
+ if (isFirstUploadOfBatch) {
+ dispatch(boardIdSelected({ boardId }));
+ dispatch(galleryViewChanged('assets'));
}
},
});
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts
index a3cc9c31ac2..770e3766879 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts
@@ -1,7 +1,6 @@
import { logger } from 'app/logging/logger';
import type { AppStartListening } from 'app/store/middleware/listenerMiddleware';
import type { AppDispatch, RootState } from 'app/store/store';
-import type { SerializableObject } from 'common/types';
import {
controlLayerModelChanged,
referenceImageIPAdapterModelChanged,
@@ -41,6 +40,7 @@ import {
isSpandrelImageToImageModelConfig,
isT5EncoderModelConfig,
} from 'services/api/types';
+import type { JsonObject } from 'type-fest';
const log = logger('models');
@@ -85,7 +85,7 @@ type ModelHandler = (
models: AnyModelConfig[],
state: RootState,
dispatch: AppDispatch,
- log: Logger
+ log: Logger
) => undefined;
const handleMainModels: ModelHandler = (models, state, dispatch, log) => {
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index 7345f47d759..a36300cca98 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -3,7 +3,6 @@ import { autoBatchEnhancer, combineReducers, configureStore } from '@reduxjs/too
import { logger } from 'app/logging/logger';
import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver';
import { errorHandler } from 'app/store/enhancers/reduxRemember/errors';
-import type { SerializableObject } from 'common/types';
import { deepClone } from 'common/util/deepClone';
import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice';
import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
@@ -37,6 +36,7 @@ import undoable from 'redux-undo';
import { serializeError } from 'serialize-error';
import { api } from 'services/api';
import { authToastMiddleware } from 'services/api/authToastMiddleware';
+import type { JsonObject } from 'type-fest';
import { STORAGE_PREFIX } from './constants';
import { actionSanitizer } from './middleware/devtools/actionSanitizer';
@@ -139,7 +139,7 @@ const unserialize: UnserializeFunction = (data, key) => {
{
persistedData: parsed,
rehydratedData: transformed,
- diff: diff(parsed, transformed) as SerializableObject, // this is always serializable
+ diff: diff(parsed, transformed) as JsonObject, // this is always serializable
},
`Rehydrated slice "${key}"`
);
diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
deleted file mode 100644
index f621e4e2076..00000000000
--- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx
+++ /dev/null
@@ -1,251 +0,0 @@
-import type { ChakraProps, FlexProps, SystemStyleObject } from '@invoke-ai/ui-library';
-import { Flex, Icon, Image } from '@invoke-ai/ui-library';
-import { IAILoadingImageFallback, IAINoContentFallback } from 'common/components/IAIImageFallback';
-import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay';
-import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
-import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
-import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
-import type { MouseEvent, ReactElement, ReactNode, SyntheticEvent } from 'react';
-import { memo, useCallback, useMemo, useRef } from 'react';
-import { PiImageBold, PiUploadSimpleBold } from 'react-icons/pi';
-import type { ImageDTO, PostUploadAction } from 'services/api/types';
-
-import IAIDraggable from './IAIDraggable';
-import IAIDroppable from './IAIDroppable';
-
-const defaultUploadElement = ;
-
-const defaultNoContentFallback = ;
-
-const baseStyles: SystemStyleObject = {
- touchAction: 'none',
- userSelect: 'none',
- webkitUserSelect: 'none',
-};
-
-const sx: SystemStyleObject = {
- ...baseStyles,
- '.gallery-image-container::before': {
- content: '""',
- display: 'inline-block',
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- pointerEvents: 'none',
- borderRadius: 'base',
- },
- '&[data-selected="selected"]>.gallery-image-container::before': {
- boxShadow:
- 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
- },
- '&[data-selected="selectedForCompare"]>.gallery-image-container::before': {
- boxShadow:
- 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
- },
- '&:hover>.gallery-image-container::before': {
- boxShadow:
- 'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)',
- },
- '&:hover[data-selected="selected"]>.gallery-image-container::before': {
- boxShadow:
- 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
- },
- '&:hover[data-selected="selectedForCompare"]>.gallery-image-container::before': {
- boxShadow:
- 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
- },
-};
-
-type IAIDndImageProps = FlexProps & {
- imageDTO: ImageDTO | undefined;
- onError?: (event: SyntheticEvent) => void;
- onLoad?: (event: SyntheticEvent) => void;
- onClick?: (event: MouseEvent) => void;
- withMetadataOverlay?: boolean;
- isDragDisabled?: boolean;
- isDropDisabled?: boolean;
- isUploadDisabled?: boolean;
- minSize?: number;
- postUploadAction?: PostUploadAction;
- imageSx?: ChakraProps['sx'];
- fitContainer?: boolean;
- droppableData?: TypesafeDroppableData;
- draggableData?: TypesafeDraggableData;
- dropLabel?: string;
- isSelected?: boolean;
- isSelectedForCompare?: boolean;
- thumbnail?: boolean;
- noContentFallback?: ReactElement;
- useThumbailFallback?: boolean;
- withHoverOverlay?: boolean;
- children?: JSX.Element;
- uploadElement?: ReactNode;
- dataTestId?: string;
-};
-
-const IAIDndImage = (props: IAIDndImageProps) => {
- const {
- imageDTO,
- onError,
- onClick,
- withMetadataOverlay = false,
- isDropDisabled = false,
- isDragDisabled = false,
- isUploadDisabled = false,
- minSize = 24,
- postUploadAction,
- imageSx,
- fitContainer = false,
- droppableData,
- draggableData,
- dropLabel,
- isSelected = false,
- isSelectedForCompare = false,
- thumbnail = false,
- noContentFallback = defaultNoContentFallback,
- uploadElement = defaultUploadElement,
- useThumbailFallback,
- withHoverOverlay = false,
- children,
- dataTestId,
- ...rest
- } = props;
-
- const openInNewTab = useCallback(
- (e: MouseEvent) => {
- if (!imageDTO) {
- return;
- }
- if (e.button !== 1) {
- return;
- }
- window.open(imageDTO.image_url, '_blank');
- },
- [imageDTO]
- );
-
- const ref = useRef(null);
- useImageContextMenu(imageDTO, ref);
-
- return (
-
- {imageDTO && (
-
- }
- onError={onError}
- draggable={false}
- w={imageDTO.width}
- objectFit="contain"
- maxW="full"
- maxH="full"
- borderRadius="base"
- sx={imageSx}
- data-testid={dataTestId}
- />
- {withMetadataOverlay && }
-
- )}
- {!imageDTO && !isUploadDisabled && (
-
- )}
- {!imageDTO && isUploadDisabled && noContentFallback}
- {imageDTO && !isDragDisabled && (
-
- )}
- {children}
- {!isDropDisabled && }
-
- );
-};
-
-export default memo(IAIDndImage);
-
-const UploadButton = memo(
- ({
- isUploadDisabled,
- postUploadAction,
- uploadElement,
- minSize,
- }: {
- isUploadDisabled: boolean;
- postUploadAction?: PostUploadAction;
- uploadElement: ReactNode;
- minSize: number;
- }) => {
- const { getUploadButtonProps, getUploadInputProps } = useImageUploadButton({
- postUploadAction,
- isDisabled: isUploadDisabled,
- });
-
- const uploadButtonStyles = useMemo(() => {
- const styles: SystemStyleObject = {
- minH: minSize,
- w: 'full',
- h: 'full',
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: 'base',
- transitionProperty: 'common',
- transitionDuration: '0.1s',
- color: 'base.500',
- };
- if (!isUploadDisabled) {
- Object.assign(styles, {
- cursor: 'pointer',
- bg: 'base.700',
- _hover: {
- bg: 'base.650',
- color: 'base.300',
- },
- });
- }
- return styles;
- }, [isUploadDisabled, minSize]);
-
- return (
-
-
- {uploadElement}
-
- );
- }
-);
-
-UploadButton.displayName = 'UploadButton';
diff --git a/invokeai/frontend/web/src/common/components/IAIDraggable.tsx b/invokeai/frontend/web/src/common/components/IAIDraggable.tsx
deleted file mode 100644
index 9e0b5206bc8..00000000000
--- a/invokeai/frontend/web/src/common/components/IAIDraggable.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { BoxProps } from '@invoke-ai/ui-library';
-import { Box } from '@invoke-ai/ui-library';
-import { useDraggableTypesafe } from 'features/dnd/hooks/typesafeHooks';
-import type { TypesafeDraggableData } from 'features/dnd/types';
-import { memo, useRef } from 'react';
-import { v4 as uuidv4 } from 'uuid';
-
-type IAIDraggableProps = BoxProps & {
- disabled?: boolean;
- data?: TypesafeDraggableData;
-};
-
-const IAIDraggable = (props: IAIDraggableProps) => {
- const { data, disabled, ...rest } = props;
- const dndId = useRef(uuidv4());
-
- const { attributes, listeners, setNodeRef } = useDraggableTypesafe({
- id: dndId.current,
- disabled,
- data,
- });
-
- return (
-
- );
-};
-
-export default memo(IAIDraggable);
diff --git a/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx b/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx
deleted file mode 100644
index 47162e1ee06..00000000000
--- a/invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { Flex, Text } from '@invoke-ai/ui-library';
-import { memo } from 'react';
-
-type Props = {
- isOver: boolean;
- label?: string;
- withBackdrop?: boolean;
-};
-
-const IAIDropOverlay = (props: Props) => {
- const { isOver, label, withBackdrop = true } = props;
- return (
-
-
-
-
- {label && (
-
- {label}
-
- )}
-
-
- );
-};
-
-export default memo(IAIDropOverlay);
diff --git a/invokeai/frontend/web/src/common/components/IAIDroppable.tsx b/invokeai/frontend/web/src/common/components/IAIDroppable.tsx
deleted file mode 100644
index 790c4200ed5..00000000000
--- a/invokeai/frontend/web/src/common/components/IAIDroppable.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Box } from '@invoke-ai/ui-library';
-import { useDroppableTypesafe } from 'features/dnd/hooks/typesafeHooks';
-import type { TypesafeDroppableData } from 'features/dnd/types';
-import { isValidDrop } from 'features/dnd/util/isValidDrop';
-import { AnimatePresence } from 'framer-motion';
-import { memo, useRef } from 'react';
-import { v4 as uuidv4 } from 'uuid';
-
-import IAIDropOverlay from './IAIDropOverlay';
-
-type IAIDroppableProps = {
- dropLabel?: string;
- disabled?: boolean;
- data?: TypesafeDroppableData;
-};
-
-const IAIDroppable = (props: IAIDroppableProps) => {
- const { dropLabel, data, disabled } = props;
- const dndId = useRef(uuidv4());
-
- const { isOver, setNodeRef, active } = useDroppableTypesafe({
- id: dndId.current,
- disabled,
- data,
- });
-
- return (
-
-
- {isValidDrop(data, active?.data.current) && }
-
-
- );
-};
-
-export default memo(IAIDroppable);
diff --git a/invokeai/frontend/web/src/common/components/IAIFillSkeleton.tsx b/invokeai/frontend/web/src/common/components/IAIFillSkeleton.tsx
deleted file mode 100644
index 20e9fa2c68c..00000000000
--- a/invokeai/frontend/web/src/common/components/IAIFillSkeleton.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { Box, Skeleton } from '@invoke-ai/ui-library';
-import { memo } from 'react';
-
-const skeletonStyles: SystemStyleObject = {
- position: 'relative',
- height: 'full',
- width: 'full',
- '::before': {
- content: "''",
- display: 'block',
- pt: '100%',
- },
-};
-
-const IAIFillSkeleton = () => {
- return (
-
-
-
- );
-};
-
-export default memo(IAIFillSkeleton);
diff --git a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx
index 0c8338f5615..f9e4e11f085 100644
--- a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx
+++ b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx
@@ -6,7 +6,7 @@ import type { ImageDTO } from 'services/api/types';
type Props = { image: ImageDTO | undefined };
-export const IAILoadingImageFallback = memo((props: Props) => {
+const IAILoadingImageFallback = memo((props: Props) => {
if (props.image) {
return (
{
- return (
-
-
- {imageDTO.width} × {imageDTO.height}
-
-
- );
-};
-
-export default memo(ImageMetadataOverlay);
diff --git a/invokeai/frontend/web/src/common/components/ImageUploadOverlay.tsx b/invokeai/frontend/web/src/common/components/ImageUploadOverlay.tsx
deleted file mode 100644
index 710d91549bd..00000000000
--- a/invokeai/frontend/web/src/common/components/ImageUploadOverlay.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Box, Flex, Heading } from '@invoke-ai/ui-library';
-import { useAppSelector } from 'app/store/storeHooks';
-import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
-import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
-import { memo } from 'react';
-import type { DropzoneState } from 'react-dropzone';
-import { useHotkeys } from 'react-hotkeys-hook';
-import { useTranslation } from 'react-i18next';
-import { useBoardName } from 'services/api/hooks/useBoardName';
-
-type ImageUploadOverlayProps = {
- dropzone: DropzoneState;
- setIsHandlingUpload: (isHandlingUpload: boolean) => void;
-};
-
-const ImageUploadOverlay = (props: ImageUploadOverlayProps) => {
- const { dropzone, setIsHandlingUpload } = props;
-
- useHotkeys(
- 'esc',
- () => {
- setIsHandlingUpload(false);
- },
- [setIsHandlingUpload]
- );
-
- return (
-
-
-
- {dropzone.isDragAccept && }
- {!dropzone.isDragAccept && }
-
-
- );
-};
-export default memo(ImageUploadOverlay);
-
-const DragAcceptMessage = () => {
- const { t } = useTranslation();
- const selectedBoardId = useAppSelector(selectSelectedBoardId);
- const boardName = useBoardName(selectedBoardId);
-
- return (
- <>
- {t('gallery.dropToUpload')}
- {t('toast.imagesWillBeAddedTo', { boardName })}
- >
- );
-};
-
-const DragRejectMessage = () => {
- const { t } = useTranslation();
- const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
-
- if (maxImageUploadCount === undefined) {
- return (
- <>
- {t('toast.invalidUpload')}
- {t('toast.uploadFailedInvalidUploadDesc')}
- >
- );
- }
-
- return (
- <>
- {t('toast.invalidUpload')}
- {t('toast.uploadFailedInvalidUploadDesc_withCount', { count: maxImageUploadCount })}
- >
- );
-};
diff --git a/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx b/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx
index c42fb485202..370c85959e0 100644
--- a/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx
+++ b/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx
@@ -1,9 +1,13 @@
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
+import { autoScrollForExternal } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/external';
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Box, Flex } from '@invoke-ai/ui-library';
import { getOverlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
+import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { CSSProperties, PropsWithChildren } from 'react';
-import { memo, useMemo } from 'react';
+import { memo, useEffect, useMemo, useState } from 'react';
type Props = PropsWithChildren & {
maxHeight?: ChakraProps['maxHeight'];
@@ -11,17 +15,38 @@ type Props = PropsWithChildren & {
overflowY?: 'hidden' | 'scroll';
};
-const styles: CSSProperties = { height: '100%', width: '100%' };
+const styles: CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };
const ScrollableContent = ({ children, maxHeight, overflowX = 'hidden', overflowY = 'scroll' }: Props) => {
const overlayscrollbarsOptions = useMemo(
() => getOverlayScrollbarsParams(overflowX, overflowY).options,
[overflowX, overflowY]
);
+ const [os, osRef] = useState(null);
+ useEffect(() => {
+ const osInstance = os?.osInstance();
+
+ if (!osInstance) {
+ return;
+ }
+
+ const element = osInstance.elements().viewport;
+
+ // `pragmatic-drag-and-drop-auto-scroll` requires the element to have `overflow-y: scroll` or `overflow-y: auto`
+ // else it logs an ugly warning. In our case, using a custom scrollbar library, it will be 'hidden' by default.
+ // To prevent the erroneous warning, we temporarily set the overflow-y to 'scroll' and then revert it back.
+ const overflowY = element.style.overflowY; // starts 'hidden'
+ element.style.setProperty('overflow-y', 'scroll', 'important');
+ const cleanup = combine(autoScrollForElements({ element }), autoScrollForExternal({ element }));
+ element.style.setProperty('overflow-y', overflowY);
+
+ return cleanup;
+ }, [os]);
+
return (
-
+
{children}
diff --git a/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts b/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts
deleted file mode 100644
index 5c732daf12e..00000000000
--- a/invokeai/frontend/web/src/common/hooks/useFullscreenDropzone.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { logger } from 'app/logging/logger';
-import { useAppSelector } from 'app/store/storeHooks';
-import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
-import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
-import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
-import { toast } from 'features/toast/toast';
-import { selectActiveTab } from 'features/ui/store/uiSelectors';
-import { useCallback, useEffect, useState } from 'react';
-import type { Accept, FileRejection } from 'react-dropzone';
-import { useDropzone } from 'react-dropzone';
-import { useTranslation } from 'react-i18next';
-import { useUploadImageMutation } from 'services/api/endpoints/images';
-import type { PostUploadAction } from 'services/api/types';
-
-const log = logger('gallery');
-
-const accept: Accept = {
- 'image/png': ['.png'],
- 'image/jpeg': ['.jpg', '.jpeg', '.png'],
-};
-
-export const useFullscreenDropzone = () => {
- useAssertSingleton('useFullscreenDropzone');
- const { t } = useTranslation();
- const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
- const [isHandlingUpload, setIsHandlingUpload] = useState(false);
- const [uploadImage] = useUploadImageMutation();
- const activeTabName = useAppSelector(selectActiveTab);
- const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
-
- const getPostUploadAction = useCallback((): PostUploadAction => {
- if (activeTabName === 'upscaling') {
- return { type: 'SET_UPSCALE_INITIAL_IMAGE' };
- } else {
- return { type: 'TOAST' };
- }
- }, [activeTabName]);
-
- const onDrop = useCallback(
- (acceptedFiles: Array, fileRejections: Array) => {
- if (fileRejections.length > 0) {
- const errors = fileRejections.map((rejection) => ({
- errors: rejection.errors.map(({ message }) => message),
- file: rejection.file.path,
- }));
- log.error({ errors }, 'Invalid upload');
- const description =
- maxImageUploadCount === undefined
- ? t('toast.uploadFailedInvalidUploadDesc')
- : t('toast.uploadFailedInvalidUploadDesc_withCount', { count: maxImageUploadCount });
-
- toast({
- id: 'UPLOAD_FAILED',
- title: t('toast.uploadFailed'),
- description,
- status: 'error',
- });
-
- setIsHandlingUpload(false);
- return;
- }
-
- for (const [i, file] of acceptedFiles.entries()) {
- uploadImage({
- file,
- image_category: 'user',
- is_intermediate: false,
- postUploadAction: getPostUploadAction(),
- board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
- // The `imageUploaded` listener does some extra logic, like switching to the asset view on upload on the
- // first upload of a "batch".
- isFirstUploadOfBatch: i === 0,
- });
- }
-
- setIsHandlingUpload(false);
- },
- [t, maxImageUploadCount, uploadImage, getPostUploadAction, autoAddBoardId]
- );
-
- const onDragOver = useCallback(() => {
- setIsHandlingUpload(true);
- }, []);
-
- const onDragLeave = useCallback(() => {
- setIsHandlingUpload(false);
- }, []);
-
- const dropzone = useDropzone({
- accept,
- noClick: true,
- onDrop,
- onDragOver,
- onDragLeave,
- noKeyboard: true,
- multiple: maxImageUploadCount === undefined || maxImageUploadCount > 1,
- maxFiles: maxImageUploadCount,
- });
-
- useEffect(() => {
- // This is a hack to allow pasting images into the uploader
- const handlePaste = (e: ClipboardEvent) => {
- if (!dropzone.inputRef.current) {
- return;
- }
-
- if (e.clipboardData?.files) {
- // Set the files on the dropzone.inputRef
- dropzone.inputRef.current.files = e.clipboardData.files;
- // Dispatch the change event, dropzone catches this and we get to use its own validation
- dropzone.inputRef.current?.dispatchEvent(new Event('change', { bubbles: true }));
- }
- };
-
- // Add the paste event listener
- document.addEventListener('paste', handlePaste);
-
- return () => {
- document.removeEventListener('paste', handlePaste);
- };
- }, [dropzone.inputRef]);
-
- return { dropzone, isHandlingUpload, setIsHandlingUpload };
-};
diff --git a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx
index 64d1861f9d3..5d2e147aae0 100644
--- a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx
+++ b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx
@@ -1,3 +1,5 @@
+import type { IconButtonProps, SystemStyleObject } from '@invoke-ai/ui-library';
+import { IconButton } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { useAppSelector } from 'app/store/storeHooks';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
@@ -7,14 +9,23 @@ import { useCallback } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
-import { useUploadImageMutation } from 'services/api/endpoints/images';
-import type { PostUploadAction } from 'services/api/types';
+import { PiUploadBold } from 'react-icons/pi';
+import { uploadImages, useUploadImageMutation } from 'services/api/endpoints/images';
+import type { ImageDTO } from 'services/api/types';
+import { assert } from 'tsafe';
+import type { SetOptional } from 'type-fest';
-type UseImageUploadButtonArgs = {
- postUploadAction?: PostUploadAction;
- isDisabled?: boolean;
- allowMultiple?: boolean;
-};
+type UseImageUploadButtonArgs =
+ | {
+ isDisabled?: boolean;
+ allowMultiple: false;
+ onUpload?: (imageDTO: ImageDTO) => void;
+ }
+ | {
+ isDisabled?: boolean;
+ allowMultiple: true;
+ onUpload?: (imageDTOs: ImageDTO[]) => void;
+ };
const log = logger('gallery');
@@ -37,30 +48,46 @@ const log = logger('gallery');
* // will open the file dialog on click
* // hidden, handles native upload functionality
*/
-export const useImageUploadButton = ({
- postUploadAction,
- isDisabled,
- allowMultiple = false,
-}: UseImageUploadButtonArgs) => {
+export const useImageUploadButton = ({ onUpload, isDisabled, allowMultiple }: UseImageUploadButtonArgs) => {
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
- const [uploadImage] = useUploadImageMutation();
+ const [uploadImage, request] = useUploadImageMutation();
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
const { t } = useTranslation();
const onDropAccepted = useCallback(
- (files: File[]) => {
- for (const [i, file] of files.entries()) {
- uploadImage({
+ async (files: File[]) => {
+ if (!allowMultiple) {
+ if (files.length > 1) {
+ log.warn('Multiple files dropped but only one allowed');
+ return;
+ }
+ const file = files[0];
+ assert(file !== undefined); // should never happen
+ const imageDTO = await uploadImage({
file,
image_category: 'user',
is_intermediate: false,
- postUploadAction: postUploadAction ?? { type: 'TOAST' },
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
- isFirstUploadOfBatch: i === 0,
- });
+ }).unwrap();
+ if (onUpload) {
+ onUpload(imageDTO);
+ }
+ } else {
+ //
+ const imageDTOs = await uploadImages(
+ files.map((file) => ({
+ file,
+ image_category: 'user',
+ is_intermediate: false,
+ board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
+ }))
+ );
+ if (onUpload) {
+ onUpload(imageDTOs);
+ }
}
},
- [autoAddBoardId, postUploadAction, uploadImage]
+ [allowMultiple, autoAddBoardId, onUpload, uploadImage]
);
const onDropRejected = useCallback(
@@ -103,5 +130,42 @@ export const useImageUploadButton = ({
maxFiles: maxImageUploadCount,
});
- return { getUploadButtonProps, getUploadInputProps, openUploader };
+ return { getUploadButtonProps, getUploadInputProps, openUploader, request };
+};
+
+const sx = {
+ borderColor: 'error.500',
+ borderStyle: 'solid',
+ borderWidth: 0,
+ borderRadius: 'base',
+ '&[data-error=true]': {
+ borderWidth: 1,
+ },
+} satisfies SystemStyleObject;
+
+export const UploadImageButton = ({
+ isDisabled = false,
+ onUpload,
+ isError = false,
+ ...rest
+}: {
+ onUpload?: (imageDTO: ImageDTO) => void;
+ isError?: boolean;
+} & SetOptional) => {
+ const uploadApi = useImageUploadButton({ isDisabled, allowMultiple: false, onUpload });
+ return (
+ <>
+ }
+ isLoading={uploadApi.request.isLoading}
+ {...rest}
+ {...uploadApi.getUploadButtonProps()}
+ />
+
+ >
+ );
};
diff --git a/invokeai/frontend/web/src/common/hooks/useNanoid.ts b/invokeai/frontend/web/src/common/hooks/useNanoid.ts
deleted file mode 100644
index 4f3cbff7596..00000000000
--- a/invokeai/frontend/web/src/common/hooks/useNanoid.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { getPrefixedId, nanoid } from 'features/controlLayers/konva/util';
-import { useMemo } from 'react';
-
-export const useNanoid = (prefix?: string) => {
- const id = useMemo(() => {
- if (prefix) {
- return getPrefixedId(prefix);
- } else {
- return nanoid();
- }
- }, [prefix]);
-
- return id;
-};
diff --git a/invokeai/frontend/web/src/common/types.ts b/invokeai/frontend/web/src/common/types.ts
deleted file mode 100644
index 52faed590a2..00000000000
--- a/invokeai/frontend/web/src/common/types.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-type SerializableValue =
- | string
- | number
- | boolean
- | null
- | undefined
- | SerializableValue[]
- | readonly SerializableValue[]
- | SerializableObject;
-export type SerializableObject = {
- [k: string | number]: SerializableValue;
-};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
index b5d9caa09fa..e988ecce683 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx
@@ -1,38 +1,26 @@
import { Grid, GridItem } from '@invoke-ai/ui-library';
-import IAIDroppable from 'common/components/IAIDroppable';
-import type {
- AddControlLayerFromImageDropData,
- AddGlobalReferenceImageFromImageDropData,
- AddRasterLayerFromImageDropData,
- AddRegionalReferenceImageFromImageDropData,
-} from 'features/dnd/types';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { newCanvasEntityFromImageDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
-const addRasterLayerFromImageDropData: AddRasterLayerFromImageDropData = {
- id: 'add-raster-layer-from-image-drop-data',
- actionType: 'ADD_RASTER_LAYER_FROM_IMAGE',
-};
-
-const addControlLayerFromImageDropData: AddControlLayerFromImageDropData = {
- id: 'add-control-layer-from-image-drop-data',
- actionType: 'ADD_CONTROL_LAYER_FROM_IMAGE',
-};
-
-const addRegionalReferenceImageFromImageDropData: AddRegionalReferenceImageFromImageDropData = {
- id: 'add-control-layer-from-image-drop-data',
- actionType: 'ADD_REGIONAL_REFERENCE_IMAGE_FROM_IMAGE',
-};
-
-const addGlobalReferenceImageFromImageDropData: AddGlobalReferenceImageFromImageDropData = {
- id: 'add-control-layer-from-image-drop-data',
- actionType: 'ADD_GLOBAL_REFERENCE_IMAGE_FROM_IMAGE',
-};
+const addRasterLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ type: 'raster_layer' });
+const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
+ type: 'control_layer',
+});
+const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
+ type: 'regional_guidance_with_reference_image',
+});
+const addGlobalReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({
+ type: 'reference_image',
+});
export const CanvasDropArea = memo(() => {
const { t } = useTranslation();
const imageViewer = useImageViewer();
+ const isBusy = useCanvasIsBusy();
if (imageViewer.isOpen) {
return null;
@@ -51,28 +39,36 @@ export const CanvasDropArea = memo(() => {
pointerEvents="none"
>
-
-
-
-
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx
new file mode 100644
index 00000000000..f38e78b4448
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx
@@ -0,0 +1,59 @@
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+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 { entitySelected } from 'features/controlLayers/store/canvasSlice';
+import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator';
+import type { PropsWithChildren } from 'react';
+import { memo, useCallback, useRef } from 'react';
+
+const sx = {
+ position: 'relative',
+ flexDir: 'column',
+ w: 'full',
+ bg: 'base.850',
+ borderRadius: 'base',
+ '&[data-selected=true]': {
+ bg: 'base.800',
+ },
+ '&[data-is-dragging=true]': {
+ opacity: 0.3,
+ },
+ transitionProperty: 'common',
+} satisfies SystemStyleObject;
+
+export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
+ const dispatch = useAppDispatch();
+ const entityIdentifier = useEntityIdentifierContext();
+ const isSelected = useEntityIsSelected(entityIdentifier);
+ const onClick = useCallback(() => {
+ if (isSelected) {
+ return;
+ }
+ dispatch(entitySelected({ entityIdentifier }));
+ }, [dispatch, entityIdentifier, isSelected]);
+ const ref = useRef(null);
+
+ const [dndListState, isDragging] = useCanvasEntityListDnd(ref, entityIdentifier);
+
+ return (
+
+
+ {props.children}
+
+
+
+ );
+});
+
+CanvasEntityContainer.displayName = 'CanvasEntityContainer';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx
new file mode 100644
index 00000000000..07ca2093a52
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx
@@ -0,0 +1,181 @@
+import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
+import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge';
+import { Button, Collapse, 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 { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton';
+import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton';
+import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
+import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover';
+import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
+import { entitiesReordered } from 'features/controlLayers/store/canvasSlice';
+import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
+import { isRenderableEntityType } from 'features/controlLayers/store/types';
+import { singleCanvasEntityDndSource } 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';
+import { PiCaretDownBold } from 'react-icons/pi';
+
+type Props = PropsWithChildren<{
+ isSelected: boolean;
+ type: CanvasEntityIdentifier['type'];
+ entityIdentifiers: CanvasEntityIdentifier[];
+}>;
+
+export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityIdentifiers }: Props) => {
+ const title = useEntityTypeTitle(type);
+ const informationalPopoverFeature = useEntityTypeInformationalPopover(type);
+ const collapse = useBoolean(true);
+ const dispatch = useAppDispatch();
+
+ useEffect(() => {
+ return monitorForElements({
+ canMonitor({ source }) {
+ if (!singleCanvasEntityDndSource.typeGuard(source.data)) {
+ return false;
+ }
+ if (source.data.payload.entityIdentifier.type !== type) {
+ return false;
+ }
+ return true;
+ },
+ onDrop({ location, source }) {
+ const target = location.current.dropTargets[0];
+ if (!target) {
+ return;
+ }
+
+ const sourceData = source.data;
+ const targetData = target.data;
+
+ if (!singleCanvasEntityDndSource.typeGuard(sourceData) || !singleCanvasEntityDndSource.typeGuard(targetData)) {
+ return;
+ }
+
+ const indexOfSource = entityIdentifiers.findIndex(
+ (entityIdentifier) => entityIdentifier.id === sourceData.payload.entityIdentifier.id
+ );
+ const indexOfTarget = entityIdentifiers.findIndex(
+ (entityIdentifier) => entityIdentifier.id === targetData.payload.entityIdentifier.id
+ );
+
+ if (indexOfTarget < 0 || indexOfSource < 0) {
+ return;
+ }
+
+ // Don't move if the source and target are the same index, meaning same position in the list
+ if (indexOfSource === indexOfTarget) {
+ return;
+ }
+
+ const closestEdgeOfTarget = extractClosestEdge(targetData);
+
+ // It's possible that the indices are different, but refer to the same position. For example, if the source is
+ // at 2 and the target is at 3, but the target edge is 'top', then the entity is already in the correct position.
+ // We should bail if this is the case.
+ let edgeIndexDelta = 0;
+
+ if (closestEdgeOfTarget === 'bottom') {
+ edgeIndexDelta = 1;
+ } else if (closestEdgeOfTarget === 'top') {
+ edgeIndexDelta = -1;
+ }
+
+ // If the source is already in the correct position, we don't need to move it.
+ if (indexOfSource === indexOfTarget + edgeIndexDelta) {
+ return;
+ }
+
+ // Using `flushSync` so we can query the DOM straight after this line
+ flushSync(() => {
+ dispatch(
+ entitiesReordered({
+ type,
+ entityIdentifiers: reorderWithEdge({
+ list: entityIdentifiers,
+ startIndex: indexOfSource,
+ indexOfTarget,
+ closestEdgeOfTarget,
+ axis: 'vertical',
+ }),
+ })
+ );
+ });
+
+ // Flash the element that was moved
+ const element = document.querySelector(`[data-entity-id="${sourceData.payload.entityIdentifier.id}"]`);
+ if (element instanceof HTMLElement) {
+ triggerPostMoveFlash(element, colorTokenToCssVar('base.700'));
+ }
+ },
+ });
+ }, [dispatch, entityIdentifiers, type]);
+
+ return (
+
+
+
+
+ {informationalPopoverFeature ? (
+
+
+ {title}
+
+
+ ) : (
+
+ {title}
+
+ )}
+
+
+
+ {isRenderableEntityType(type) && }
+ {isRenderableEntityType(type) && }
+
+
+
+
+ {children}
+
+
+
+ );
+});
+
+CanvasEntityGroupList.displayName = 'CanvasEntityGroupList';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts
new file mode 100644
index 00000000000..d47e3c0c781
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts
@@ -0,0 +1,83 @@
+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 { singleCanvasEntityDndSource } from 'features/dnd/dnd';
+import { type DndListTargetState, idle } from 'features/dnd/types';
+import type { RefObject } from 'react';
+import { useEffect, useState } from 'react';
+
+export const useCanvasEntityListDnd = (ref: RefObject, entityIdentifier: CanvasEntityIdentifier) => {
+ const [dndListState, setDndListState] = useState(idle);
+ const [isDragging, setIsDragging] = useState(false);
+
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) {
+ return;
+ }
+ return combine(
+ draggable({
+ element,
+ getInitialData() {
+ return singleCanvasEntityDndSource.getData({ entityIdentifier });
+ },
+ onDragStart() {
+ setDndListState({ type: 'is-dragging' });
+ setIsDragging(true);
+ },
+ onDrop() {
+ setDndListState(idle);
+ setIsDragging(false);
+ },
+ }),
+ dropTargetForElements({
+ element,
+ canDrop({ source }) {
+ if (!singleCanvasEntityDndSource.typeGuard(source.data)) {
+ return false;
+ }
+ if (source.data.payload.entityIdentifier.type !== entityIdentifier.type) {
+ return false;
+ }
+ return true;
+ },
+ getData({ input }) {
+ const data = singleCanvasEntityDndSource.getData({ entityIdentifier });
+ return attachClosestEdge(data, {
+ element,
+ input,
+ allowedEdges: ['top', 'bottom'],
+ });
+ },
+ getIsSticky() {
+ return true;
+ },
+ onDragEnter({ self }) {
+ const closestEdge = extractClosestEdge(self.data);
+ setDndListState({ 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.
+ setDndListState((current) => {
+ if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
+ return current;
+ }
+ return { type: 'is-dragging-over', closestEdge };
+ });
+ },
+ onDragLeave() {
+ setDndListState(idle);
+ },
+ onDrop() {
+ setDndListState(idle);
+ },
+ })
+ );
+ }, [entityIdentifier, ref]);
+
+ return [dndListState, isDragging] as const;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx
index b66c0d8367c..91931980659 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx
@@ -109,7 +109,9 @@ export const CanvasMainPanelContent = memo(() => {
-
+
+
+
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx
index 2fb3a9470fd..ac0c6690057 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx
@@ -1,16 +1,20 @@
-import { useDndContext } from '@dnd-kit/core';
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
import { Box, Button, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import IAIDropOverlay from 'common/components/IAIDropOverlay';
+import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectEntityCountActive } from 'features/controlLayers/store/selectors';
+import { multipleImageDndSource, singleImageDndSource } 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 { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
-import { memo, useCallback, useMemo, useRef, useState } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const CanvasRightPanel = memo(() => {
@@ -79,37 +83,13 @@ CanvasRightPanel.displayName = 'CanvasRightPanel';
const PanelTabs = memo(() => {
const { t } = useTranslation();
- const activeTab = useAppSelector(selectActiveTabCanvasRightPanel);
+ const store = useAppStore();
const activeEntityCount = useAppSelector(selectEntityCountActive);
- const tabTimeout = useRef(null);
- const dndCtx = useDndContext();
- const dispatch = useAppDispatch();
- const [mouseOverTab, setMouseOverTab] = useState<'layers' | 'gallery' | null>(null);
-
- const onOnMouseOverLayersTab = useCallback(() => {
- setMouseOverTab('layers');
- tabTimeout.current = window.setTimeout(() => {
- if (dndCtx.active) {
- dispatch(activeTabCanvasRightPanelChanged('layers'));
- }
- }, 300);
- }, [dndCtx.active, dispatch]);
-
- const onOnMouseOverGalleryTab = useCallback(() => {
- setMouseOverTab('gallery');
- tabTimeout.current = window.setTimeout(() => {
- if (dndCtx.active) {
- dispatch(activeTabCanvasRightPanelChanged('gallery'));
- }
- }, 300);
- }, [dndCtx.active, dispatch]);
-
- const onMouseOut = useCallback(() => {
- setMouseOverTab(null);
- if (tabTimeout.current) {
- clearTimeout(tabTimeout.current);
- }
- }, []);
+ const [layersTabDndState, setLayersTabDndState] = useState('idle');
+ const [galleryTabDndState, setGalleryTabDndState] = useState('idle');
+ const layersTabRef = useRef(null);
+ const galleryTabRef = useRef(null);
+ const timeoutRef = useRef(null);
const layersTabLabel = useMemo(() => {
if (activeEntityCount === 0) {
@@ -118,23 +98,172 @@ const PanelTabs = memo(() => {
return `${t('controlLayers.layer_other')} (${activeEntityCount})`;
}, [activeEntityCount, t]);
+ useEffect(() => {
+ if (!layersTabRef.current) {
+ return;
+ }
+
+ const getIsOnLayersTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'layers';
+
+ const onDragEnter = () => {
+ // If we are already on the layers tab, do nothing
+ if (getIsOnLayersTab()) {
+ return;
+ }
+
+ // Else set the state to active and switch to the layers tab after a timeout
+ setLayersTabDndState('over');
+ timeoutRef.current = window.setTimeout(() => {
+ timeoutRef.current = null;
+ store.dispatch(activeTabCanvasRightPanelChanged('layers'));
+ // When we switch tabs, the other tab should be pending
+ setLayersTabDndState('idle');
+ setGalleryTabDndState('potential');
+ }, 300);
+ };
+ const onDragLeave = () => {
+ // Set the state to idle or pending depending on the current tab
+ if (getIsOnLayersTab()) {
+ setLayersTabDndState('idle');
+ } else {
+ setLayersTabDndState('potential');
+ }
+ // Abort the tab switch if it hasn't happened yet
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ const onDragStart = () => {
+ // Set the state to pending when a drag starts
+ setLayersTabDndState('potential');
+ };
+ return combine(
+ dropTargetForElements({
+ element: layersTabRef.current,
+ onDragEnter,
+ onDragLeave,
+ }),
+ monitorForElements({
+ canMonitor: ({ source }) => {
+ if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) {
+ return false;
+ }
+ // Only monitor if we are not already on the gallery tab
+ return !getIsOnLayersTab();
+ },
+ onDragStart,
+ }),
+ dropTargetForExternal({
+ element: layersTabRef.current,
+ onDragEnter,
+ onDragLeave,
+ }),
+ monitorForExternal({
+ canMonitor: () => !getIsOnLayersTab(),
+ onDragStart,
+ })
+ );
+ }, [store]);
+
+ useEffect(() => {
+ if (!galleryTabRef.current) {
+ return;
+ }
+
+ const getIsOnGalleryTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'gallery';
+
+ const onDragEnter = () => {
+ // If we are already on the gallery tab, do nothing
+ if (getIsOnGalleryTab()) {
+ return;
+ }
+
+ // Else set the state to active and switch to the gallery tab after a timeout
+ setGalleryTabDndState('over');
+ timeoutRef.current = window.setTimeout(() => {
+ timeoutRef.current = null;
+ store.dispatch(activeTabCanvasRightPanelChanged('gallery'));
+ // When we switch tabs, the other tab should be pending
+ setGalleryTabDndState('idle');
+ setLayersTabDndState('potential');
+ }, 300);
+ };
+
+ const onDragLeave = () => {
+ // Set the state to idle or pending depending on the current tab
+ if (getIsOnGalleryTab()) {
+ setGalleryTabDndState('idle');
+ } else {
+ setGalleryTabDndState('potential');
+ }
+ // Abort the tab switch if it hasn't happened yet
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+
+ const onDragStart = () => {
+ // Set the state to pending when a drag starts
+ setGalleryTabDndState('potential');
+ };
+
+ return combine(
+ dropTargetForElements({
+ element: galleryTabRef.current,
+ onDragEnter,
+ onDragLeave,
+ }),
+ monitorForElements({
+ canMonitor: ({ source }) => {
+ if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) {
+ return false;
+ }
+ // Only monitor if we are not already on the gallery tab
+ return !getIsOnGalleryTab();
+ },
+ onDragStart,
+ }),
+ dropTargetForExternal({
+ element: galleryTabRef.current,
+ onDragEnter,
+ onDragLeave,
+ }),
+ monitorForExternal({
+ canMonitor: () => !getIsOnGalleryTab(),
+ onDragStart,
+ })
+ );
+ }, [store]);
+
+ useEffect(() => {
+ const onDrop = () => {
+ // Reset the dnd state when a drop happens
+ setGalleryTabDndState('idle');
+ setLayersTabDndState('idle');
+ };
+ const cleanup = combine(monitorForElements({ onDrop }), monitorForExternal({ onDrop }));
+
+ return () => {
+ cleanup();
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
return (
<>
-
+
{layersTabLabel}
- {dndCtx.active && activeTab !== 'layers' && (
-
- )}
+
-
+
{t('gallery.gallery')}
- {dndCtx.active && activeTab !== 'gallery' && (
-
- )}
+
>
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx
index 1a55d716a22..9c40863166d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx
@@ -1,6 +1,5 @@
import { Spacer } from '@invoke-ai/ui-library';
-import IAIDroppable from 'common/components/IAIDroppable';
-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';
@@ -10,8 +9,11 @@ import { ControlLayerBadges } from 'features/controlLayers/components/ControlLay
import { ControlLayerSettings } from 'features/controlLayers/components/ControlLayer/ControlLayerSettings';
import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
-import type { ReplaceLayerImageDropData } from 'features/dnd/types';
+import type { ReplaceCanvasEntityObjectsWithImageDndTargetData } from 'features/dnd/dnd';
+import { replaceCanvasEntityObjectsWithImageDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -21,14 +23,16 @@ type Props = {
export const ControlLayer = memo(({ id }: Props) => {
const { t } = useTranslation();
+ const isBusy = useCanvasIsBusy();
const entityIdentifier = useMemo>(
() => ({ id, type: 'control_layer' }),
[id]
);
- const dropData = useMemo(
- () => ({ id, actionType: 'REPLACE_LAYER_WITH_IMAGE', context: { entityIdentifier } }),
- [id, entityIdentifier]
+ const dndTargetData = useMemo(
+ () => replaceCanvasEntityObjectsWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id),
+ [entityIdentifier]
);
+
return (
@@ -43,7 +47,12 @@ export const ControlLayer = memo(({ id }: Props) => {
-
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapter.tsx
index 4a3f99885dd..b8ba7173c76 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapter.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapter.tsx
@@ -1,6 +1,7 @@
import { Flex, IconButton } from '@invoke-ai/ui-library';
import { createMemoizedAppSelector } from 'app/store/createMemoizedSelector';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useAppStore } from 'app/store/nanostores/store';
+import { useAppSelector } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct';
import { Weight } from 'features/controlLayers/components/common/Weight';
@@ -21,10 +22,11 @@ import { getFilterForModel } from 'features/controlLayers/store/filters';
import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier, ControlModeV2 } from 'features/controlLayers/store/types';
+import { replaceCanvasEntityObjectsWithImage } from 'features/imageActions/actions';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold, PiShootingStarFill, PiUploadBold } from 'react-icons/pi';
-import type { ControlNetModelConfig, PostUploadAction, T2IAdapterModelConfig } from 'services/api/types';
+import type { ControlNetModelConfig, ImageDTO, T2IAdapterModelConfig } from 'services/api/types';
const useControlLayerControlAdapter = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) => {
const selectControlAdapter = useMemo(
@@ -41,7 +43,7 @@ const useControlLayerControlAdapter = (entityIdentifier: CanvasEntityIdentifier<
export const ControlLayerControlAdapter = memo(() => {
const { t } = useTranslation();
- const dispatch = useAppDispatch();
+ const { dispatch, getState } = useAppStore();
const entityIdentifier = useEntityIdentifierContext('control_layer');
const controlAdapter = useControlLayerControlAdapter(entityIdentifier);
const filter = useEntityFilter(entityIdentifier);
@@ -113,11 +115,17 @@ export const ControlLayerControlAdapter = memo(() => {
const pullBboxIntoLayer = usePullBboxIntoLayer(entityIdentifier);
const isBusy = useCanvasIsBusy();
- const postUploadAction = useMemo(
- () => ({ type: 'REPLACE_LAYER_WITH_IMAGE', entityIdentifier }),
- [entityIdentifier]
+ const uploadOptions = useMemo(
+ () =>
+ ({
+ onUpload: (imageDTO: ImageDTO) => {
+ replaceCanvasEntityObjectsWithImage({ entityIdentifier, imageDTO, dispatch, getState });
+ },
+ allowMultiple: false,
+ }) as const,
+ [dispatch, entityIdentifier, getState]
);
- const uploadApi = useImageUploadButton({ postUploadAction });
+ const uploadApi = useImageUploadButton(uploadOptions);
return (
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx
index 9421804090e..a353ee59f19 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx
@@ -1,14 +1,14 @@
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 { mapId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
+import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
-const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => {
- return canvas.controlLayers.entities.map(mapId).reverse();
+const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
+ return canvas.controlLayers.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
@@ -17,17 +17,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte
export const ControlLayerEntityList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
- const layerIds = useAppSelector(selectEntityIds);
+ const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
- if (layerIds.length === 0) {
+ if (entityIdentifiers.length === 0) {
return null;
}
- if (layerIds.length > 0) {
+ if (entityIdentifiers.length > 0) {
return (
-
- {layerIds.map((id) => (
-
+
+ {entityIdentifiers.map((entityIdentifier) => (
+
))}
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettingsEmptyState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettingsEmptyState.tsx
index 2d475ba4740..e8f0bbaeece 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettingsEmptyState.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettingsEmptyState.tsx
@@ -1,22 +1,25 @@
import { Button, Flex, Text } from '@invoke-ai/ui-library';
-import { useAppDispatch } from 'app/store/storeHooks';
+import { useAppStore } from 'app/store/nanostores/store';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { replaceCanvasEntityObjectsWithImage } from 'features/imageActions/actions';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
-import { memo, useCallback, useMemo } from 'react';
+import { memo, useCallback } from 'react';
import { Trans } from 'react-i18next';
-import type { PostUploadAction } from 'services/api/types';
+import type { ImageDTO } from 'services/api/types';
export const ControlLayerSettingsEmptyState = memo(() => {
const entityIdentifier = useEntityIdentifierContext('control_layer');
- const dispatch = useAppDispatch();
+ const { dispatch, getState } = useAppStore();
const isBusy = useCanvasIsBusy();
- const postUploadAction = useMemo(
- () => ({ type: 'REPLACE_LAYER_WITH_IMAGE', entityIdentifier }),
- [entityIdentifier]
+ const onUpload = useCallback(
+ (imageDTO: ImageDTO) => {
+ replaceCanvasEntityObjectsWithImage({ imageDTO, entityIdentifier, dispatch, getState });
+ },
+ [dispatch, entityIdentifier, getState]
);
- const uploadApi = useImageUploadButton({ postUploadAction });
+ const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false });
const onClickGalleryButton = useCallback(() => {
dispatch(activeTabCanvasRightPanelChanged('gallery'));
}, [dispatch]);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx
index 27c9db0ad10..d0fbc6a105d 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx
@@ -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';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx
index 1941c5ecc09..da24bfc9367 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx
@@ -1,82 +1,80 @@
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { skipToken } from '@reduxjs/toolkit/query';
-import IAIDndImage from 'common/components/IAIDndImage';
-import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
-import { useNanoid } from 'common/hooks/useNanoid';
+import { UploadImageButton } from 'common/hooks/useImageUploadButton';
import type { ImageWithDims } from 'features/controlLayers/store/types';
-import type { ImageDraggableData, TypesafeDroppableData } from 'features/dnd/types';
-import { memo, useCallback, useEffect, useMemo } from 'react';
+import type { setGlobalReferenceImageDndTarget, setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
+import { DndImage } from 'features/dnd/DndImage';
+import { DndImageIcon } from 'features/dnd/DndImageIcon';
+import { memo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
-import type { ImageDTO, PostUploadAction } from 'services/api/types';
+import type { ImageDTO } from 'services/api/types';
import { $isConnected } from 'services/events/stores';
-type Props = {
+type Props = {
image: ImageWithDims | null;
onChangeImage: (imageDTO: ImageDTO | null) => void;
- droppableData: TypesafeDroppableData;
- postUploadAction: PostUploadAction;
+ dndTarget: T;
+ dndTargetData: ReturnType;
};
-export const IPAdapterImagePreview = memo(({ image, onChangeImage, droppableData, postUploadAction }: Props) => {
- const { t } = useTranslation();
- const isConnected = useStore($isConnected);
- const dndId = useNanoid('ip_adapter_image_preview');
+export const IPAdapterImagePreview = memo(
+ ({
+ image,
+ onChangeImage,
+ dndTarget,
+ dndTargetData,
+ }: Props) => {
+ const { t } = useTranslation();
+ const isConnected = useStore($isConnected);
+ const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken);
+ const handleResetControlImage = useCallback(() => {
+ onChangeImage(null);
+ }, [onChangeImage]);
- const { currentData: controlImage, isError: isErrorControlImage } = useGetImageDTOQuery(
- image?.image_name ?? skipToken
- );
- const handleResetControlImage = useCallback(() => {
- onChangeImage(null);
- }, [onChangeImage]);
+ useEffect(() => {
+ if (isConnected && isError) {
+ handleResetControlImage();
+ }
+ }, [handleResetControlImage, isError, isConnected]);
- const draggableData = useMemo(() => {
- if (controlImage) {
- return {
- id: dndId,
- payloadType: 'IMAGE_DTO',
- payload: { imageDTO: controlImage },
- };
- }
- }, [controlImage, dndId]);
+ const onUpload = useCallback(
+ (imageDTO: ImageDTO) => {
+ onChangeImage(imageDTO);
+ },
+ [onChangeImage]
+ );
- useEffect(() => {
- if (isConnected && isErrorControlImage) {
- handleResetControlImage();
- }
- }, [handleResetControlImage, isConnected, isErrorControlImage]);
-
- return (
-
-
-
- {controlImage && (
-
- }
- tooltip={t('common.reset')}
+ return (
+
+ {!imageDTO && (
+
-
- )}
-
- );
-});
+ )}
+ {imageDTO && (
+ <>
+
+
+ }
+ tooltip={t('common.reset')}
+ />
+
+ >
+ )}
+
+
+ );
+ }
+);
IPAdapterImagePreview.displayName = 'IPAdapterImagePreview';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx
index 486391fbb84..38cdbde8c7c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx
@@ -2,14 +2,14 @@
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';
+import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
-const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => {
- return canvas.referenceImages.entities.map(mapId).reverse();
+const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
+ return canvas.referenceImages.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
return selectedEntityIdentifier?.type === 'reference_image';
@@ -17,17 +17,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte
export const IPAdapterList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
- const ipaIds = useAppSelector(selectEntityIds);
+ const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
- if (ipaIds.length === 0) {
+ if (entityIdentifiers.length === 0) {
return null;
}
- if (ipaIds.length > 0) {
+ if (entityIdentifiers.length > 0) {
return (
-
- {ipaIds.map((id) => (
-
+
+ {entityIdentifiers.map((entityIdentifiers) => (
+
))}
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx
index 70d321801ff..218e7571d25 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx
@@ -19,11 +19,12 @@ 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 type { IPAImageDropData } from 'features/dnd/types';
+import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
+import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold } from 'react-icons/pi';
-import type { ImageDTO, IPAdapterModelConfig, IPALayerImagePostUploadAction } from 'services/api/types';
+import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { IPAdapterImagePreview } from './IPAdapterImagePreview';
import { IPAdapterModel } from './IPAdapterModel';
@@ -80,13 +81,9 @@ export const IPAdapterSettings = memo(() => {
[dispatch, entityIdentifier]
);
- const droppableData = useMemo(
- () => ({ actionType: 'SET_IPA_IMAGE', context: { id: entityIdentifier.id }, id: entityIdentifier.id }),
- [entityIdentifier.id]
- );
- const postUploadAction = useMemo(
- () => ({ type: 'SET_IPA_IMAGE', id: entityIdentifier.id }),
- [entityIdentifier.id]
+ const dndTargetData = useMemo(
+ () => setGlobalReferenceImageDndTarget.getData({ entityIdentifier }, ipAdapter.image?.image_name),
+ [entityIdentifier, ipAdapter.image?.image_name]
);
const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier);
const isBusy = useCanvasIsBusy();
@@ -122,10 +119,10 @@ export const IPAdapterSettings = memo(() => {
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx
index 12ee4acbf80..cb5bcb8950f 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx
@@ -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';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx
index 6b04ff511a9..8bbb49a9865 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx
@@ -1,14 +1,14 @@
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 { mapId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
+import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
-const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => {
- return canvas.inpaintMasks.entities.map(mapId).reverse();
+const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
+ return canvas.inpaintMasks.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
@@ -17,17 +17,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte
export const InpaintMaskList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
- const entityIds = useAppSelector(selectEntityIds);
+ const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
- if (entityIds.length === 0) {
+ if (entityIdentifiers.length === 0) {
return null;
}
- if (entityIds.length > 0) {
+ if (entityIdentifiers.length > 0) {
return (
-
- {entityIds.map((id) => (
-
+
+ {entityIdentifiers.map((entityIdentifier) => (
+
))}
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx
index 204de8b4719..bad65ebd92e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx
@@ -1,14 +1,16 @@
import { Spacer } from '@invoke-ai/ui-library';
-import IAIDroppable from 'common/components/IAIDroppable';
-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';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
-import type { ReplaceLayerImageDropData } from 'features/dnd/types';
+import type { ReplaceCanvasEntityObjectsWithImageDndTargetData } from 'features/dnd/dnd';
+import { replaceCanvasEntityObjectsWithImageDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,10 +20,11 @@ type Props = {
export const RasterLayer = memo(({ id }: Props) => {
const { t } = useTranslation();
+ const isBusy = useCanvasIsBusy();
const entityIdentifier = useMemo>(() => ({ id, type: 'raster_layer' }), [id]);
- const dropData = useMemo(
- () => ({ id, actionType: 'REPLACE_LAYER_WITH_IMAGE', context: { entityIdentifier } }),
- [id, entityIdentifier]
+ const dndTargetData = useMemo(
+ () => replaceCanvasEntityObjectsWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id),
+ [entityIdentifier]
);
return (
@@ -34,7 +37,12 @@ export const RasterLayer = memo(({ id }: Props) => {
-
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx
index 4e2cbd581c7..c585a49cc3e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx
@@ -1,14 +1,14 @@
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 { mapId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
+import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
-const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => {
- return canvas.rasterLayers.entities.map(mapId).reverse();
+const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
+ return canvas.rasterLayers.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
return selectedEntityIdentifier?.type === 'raster_layer';
@@ -16,17 +16,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte
export const RasterLayerEntityList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
- const layerIds = useAppSelector(selectEntityIds);
+ const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
- if (layerIds.length === 0) {
+ if (entityIdentifiers.length === 0) {
return null;
}
- if (layerIds.length > 0) {
+ if (entityIdentifiers.length > 0) {
return (
-
- {layerIds.map((id) => (
-
+
+ {entityIdentifiers.map((entityIdentifier) => (
+
))}
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx
index 9a6d75d4082..1d52e1b582b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx
@@ -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';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx
index 870fec1d69f..75224b7689a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx
@@ -1,14 +1,14 @@
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 { mapId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
+import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
-const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => {
- return canvas.regionalGuidance.entities.map(mapId).reverse();
+const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
+ return canvas.regionalGuidance.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
return selectedEntityIdentifier?.type === 'regional_guidance';
@@ -16,17 +16,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte
export const RegionalGuidanceEntityList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
- const rgIds = useAppSelector(selectEntityIds);
+ const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
- if (rgIds.length === 0) {
+ if (entityIdentifiers.length === 0) {
return null;
}
- if (rgIds.length > 0) {
+ if (entityIdentifiers.length > 0) {
return (
-
- {rgIds.map((id) => (
-
+
+ {entityIdentifiers.map((entityIdentifier) => (
+
))}
);
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx
index e6c6f292a35..8ee97d351d2 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx
@@ -20,11 +20,12 @@ import {
} from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors';
import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types';
-import type { RGIPAdapterImageDropData } from 'features/dnd/types';
+import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd/dnd';
+import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi';
-import type { ImageDTO, IPAdapterModelConfig, RGIPAdapterImagePostUploadAction } from 'services/api/types';
+import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { assert } from 'tsafe';
type Props = {
@@ -91,18 +92,15 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro
[dispatch, entityIdentifier, referenceImageId]
);
- const droppableData = useMemo(
- () => ({
- actionType: 'SET_RG_IP_ADAPTER_IMAGE',
- context: { id: entityIdentifier.id, referenceImageId: referenceImageId },
- id: entityIdentifier.id,
- }),
- [entityIdentifier.id, referenceImageId]
- );
- const postUploadAction = useMemo(
- () => ({ type: 'SET_RG_IP_ADAPTER_IMAGE', id: entityIdentifier.id, referenceImageId: referenceImageId }),
- [entityIdentifier.id, referenceImageId]
+ const dndTargetData = useMemo(
+ () =>
+ setRegionalGuidanceReferenceImageDndTarget.getData(
+ { entityIdentifier, referenceImageId },
+ ipAdapter.image?.image_name
+ ),
+ [entityIdentifier, ipAdapter.image?.image_name, referenceImageId]
);
+
const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId);
const isBusy = useCanvasIsBusy();
@@ -151,10 +149,10 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx
deleted file mode 100644
index 4430fc83dc7..00000000000
--- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { 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 { PropsWithChildren } from 'react';
-import { memo, useCallback } 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]);
-
- return (
-
- {props.children}
-
- );
-});
-
-CanvasEntityContainer.displayName = 'CanvasEntityContainer';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx
deleted file mode 100644
index b9d1999a8fc..00000000000
--- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library';
-import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
-import { useBoolean } from 'common/hooks/useBoolean';
-import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles';
-import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton';
-import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton';
-import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
-import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover';
-import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
-import { type CanvasEntityIdentifier, isRenderableEntityType } from 'features/controlLayers/store/types';
-import type { PropsWithChildren } from 'react';
-import { memo } from 'react';
-import { PiCaretDownBold } from 'react-icons/pi';
-
-type Props = PropsWithChildren<{
- isSelected: boolean;
- type: CanvasEntityIdentifier['type'];
-}>;
-
-const _hover: SystemStyleObject = {
- opacity: 1,
-};
-
-export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props) => {
- const title = useEntityTypeTitle(type);
- const informationalPopoverFeature = useEntityTypeInformationalPopover(type);
- const collapse = useBoolean(true);
-
- return (
-
-
-
-
- {informationalPopoverFeature ? (
-
-
- {title}
-
-
- ) : (
-
- {title}
-
- )}
-
-
-
- {isRenderableEntityType(type) && }
- {isRenderableEntityType(type) && }
-
-
-
-
- {children}
-
-
-
- );
-});
-
-CanvasEntityGroupList.displayName = 'CanvasEntityGroupList';
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
index 1115c162b38..61f1c75f952 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
@@ -2,11 +2,8 @@ import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { deepClone } from 'common/util/deepClone';
-import { CanvasEntityAdapterBase } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase';
import { getPrefixedId } from 'features/controlLayers/konva/util';
-import { canvasReset } from 'features/controlLayers/store/actions';
import {
- bboxChangedFromCanvas,
controlLayerAdded,
inpaintMaskAdded,
rasterLayerAdded,
@@ -17,37 +14,20 @@ import {
rgPositivePromptChanged,
} from 'features/controlLayers/store/canvasSlice';
import { selectBase } from 'features/controlLayers/store/paramsSlice';
-import {
- selectBboxModelBase,
- selectBboxRect,
- selectCanvasSlice,
- selectEntityOrThrow,
-} from 'features/controlLayers/store/selectors';
+import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import type {
- CanvasControlLayerState,
CanvasEntityIdentifier,
- CanvasInpaintMaskState,
- CanvasRasterLayerState,
CanvasRegionalGuidanceState,
ControlNetConfig,
IPAdapterConfig,
T2IAdapterConfig,
} from 'features/controlLayers/store/types';
-import {
- imageDTOToImageObject,
- initialControlNet,
- initialIPAdapter,
- initialT2IAdapter,
-} from 'features/controlLayers/store/util';
-import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions';
+import { initialControlNet, initialIPAdapter, initialT2IAdapter } from 'features/controlLayers/store/util';
import { zModelIdentifierField } from 'features/nodes/types/common';
-import { getOptimalDimension } from 'features/parameters/util/optimalDimension';
import { useCallback } from 'react';
import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models';
-import type { ControlNetModelConfig, ImageDTO, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types';
+import type { ControlNetModelConfig, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types';
import { isControlNetOrT2IAdapterModelConfig, isIPAdapterModelConfig } from 'services/api/types';
-import type { Equals } from 'tsafe';
-import { assert } from 'tsafe';
/** @knipignore */
export const selectDefaultControlAdapter = createSelector(
@@ -110,150 +90,6 @@ export const useAddRasterLayer = () => {
return func;
};
-export const useNewRasterLayerFromImage = () => {
- const dispatch = useAppDispatch();
- const bboxRect = useAppSelector(selectBboxRect);
- const func = useCallback(
- (imageDTO: ImageDTO) => {
- const imageObject = imageDTOToImageObject(imageDTO);
- const overrides: Partial = {
- position: { x: bboxRect.x, y: bboxRect.y },
- objects: [imageObject],
- };
- dispatch(rasterLayerAdded({ overrides, isSelected: true }));
- },
- [bboxRect.x, bboxRect.y, dispatch]
- );
-
- return func;
-};
-
-export const useNewControlLayerFromImage = () => {
- const dispatch = useAppDispatch();
- const bboxRect = useAppSelector(selectBboxRect);
- const func = useCallback(
- (imageDTO: ImageDTO) => {
- const imageObject = imageDTOToImageObject(imageDTO);
- const overrides: Partial = {
- position: { x: bboxRect.x, y: bboxRect.y },
- objects: [imageObject],
- };
- dispatch(controlLayerAdded({ overrides, isSelected: true }));
- },
- [bboxRect.x, bboxRect.y, dispatch]
- );
-
- return func;
-};
-
-export const useNewInpaintMaskFromImage = () => {
- const dispatch = useAppDispatch();
- const bboxRect = useAppSelector(selectBboxRect);
- const func = useCallback(
- (imageDTO: ImageDTO) => {
- const imageObject = imageDTOToImageObject(imageDTO);
- const overrides: Partial = {
- position: { x: bboxRect.x, y: bboxRect.y },
- objects: [imageObject],
- };
- dispatch(inpaintMaskAdded({ overrides, isSelected: true }));
- },
- [bboxRect.x, bboxRect.y, dispatch]
- );
-
- return func;
-};
-
-export const useNewRegionalGuidanceFromImage = () => {
- const dispatch = useAppDispatch();
- const bboxRect = useAppSelector(selectBboxRect);
- const func = useCallback(
- (imageDTO: ImageDTO) => {
- const imageObject = imageDTOToImageObject(imageDTO);
- const overrides: Partial = {
- position: { x: bboxRect.x, y: bboxRect.y },
- objects: [imageObject],
- };
- dispatch(rgAdded({ overrides, isSelected: true }));
- },
- [bboxRect.x, bboxRect.y, dispatch]
- );
-
- return func;
-};
-
-/**
- * Returns a function that adds a new canvas with the given image as the initial image, replicating the img2img flow:
- * - Reset the canvas
- * - Resize the bbox to the image's aspect ratio at the optimal size for the selected model
- * - Add the image as a raster layer
- * - Resizes the layer to fit the bbox using the 'fill' strategy
- *
- * This allows the user to immediately generate a new image from the given image without any additional steps.
- */
-export const useNewCanvasFromImage = () => {
- const dispatch = useAppDispatch();
- const bboxRect = useAppSelector(selectBboxRect);
- const base = useAppSelector(selectBboxModelBase);
- const func = useCallback(
- (imageDTO: ImageDTO, type: CanvasRasterLayerState['type'] | CanvasControlLayerState['type']) => {
- // Calculate the new bbox dimensions to fit the image's aspect ratio at the optimal size
- const ratio = imageDTO.width / imageDTO.height;
- const optimalDimension = getOptimalDimension(base);
- const { width, height } = calculateNewSize(ratio, optimalDimension ** 2, base);
-
- // The overrides need to include the layer's ID so we can transform the layer it is initialized
- let overrides: Partial | Partial;
-
- if (type === 'raster_layer') {
- overrides = {
- id: getPrefixedId('raster_layer'),
- position: { x: bboxRect.x, y: bboxRect.y },
- objects: [imageDTOToImageObject(imageDTO)],
- } satisfies Partial;
- } else if (type === 'control_layer') {
- overrides = {
- id: getPrefixedId('control_layer'),
- position: { x: bboxRect.x, y: bboxRect.y },
- objects: [imageDTOToImageObject(imageDTO)],
- } satisfies Partial;
- } else {
- // Catch unhandled types
- assert>(false);
- }
-
- CanvasEntityAdapterBase.registerInitCallback(async (adapter) => {
- // Skip the callback if the adapter is not the one we are creating
- if (adapter.id !== overrides.id) {
- return false;
- }
- // Fit the layer to the bbox w/ fill strategy
- await adapter.transformer.startTransform({ silent: true });
- adapter.transformer.fitToBboxFill();
- await adapter.transformer.applyTransform();
- return true;
- });
-
- dispatch(canvasReset());
- // The `bboxChangedFromCanvas` reducer does no validation! Careful!
- dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height }));
-
- // The type casts are safe because the type is checked above
- if (type === 'raster_layer') {
- dispatch(rasterLayerAdded({ overrides: overrides as Partial, isSelected: true }));
- } else if (type === 'control_layer') {
- dispatch(controlLayerAdded({ overrides: overrides as Partial, isSelected: true }));
- } else {
- // Catch unhandled types
- assert>(false);
- }
- },
- [base, bboxRect.x, bboxRect.y, dispatch]
- );
-
- return func;
-};
-
export const useAddInpaintMask = () => {
const dispatch = useAppDispatch();
const func = useCallback(() => {
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts
index 61313ee3db3..b97a205e49e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts
@@ -1,6 +1,5 @@
import { logger } from 'app/logging/logger';
import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks';
-import type { SerializableObject } from 'common/types';
import { deepClone } from 'common/util/deepClone';
import { withResultAsync } from 'common/util/result';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
@@ -31,6 +30,7 @@ import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
import type { ImageDTO } from 'services/api/types';
+import type { JsonObject } from 'type-fest';
const log = logger('canvas');
@@ -64,7 +64,7 @@ const useSaveCanvas = ({ region, saveToGallery, toastOk, toastError, onSave, wit
return;
}
- let metadata: SerializableObject | undefined = undefined;
+ let metadata: JsonObject | undefined = undefined;
if (withMetadata) {
metadata = selectCanvasMetadata(store.getState());
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntitySelectionColor.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useEntitySelectionColor.ts
deleted file mode 100644
index 9e758d00469..00000000000
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/useEntitySelectionColor.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { createSelector } from '@reduxjs/toolkit';
-import { useAppSelector } from 'app/store/storeHooks';
-import { rgbColorToString } from 'common/util/colorCodeTransformers';
-import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
-import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
-import { useMemo } from 'react';
-
-export const useEntitySelectionColor = (entityIdentifier: CanvasEntityIdentifier) => {
- const selectSelectionColor = useMemo(
- () =>
- createSelector(selectCanvasSlice, (canvas) => {
- const entity = selectEntity(canvas, entityIdentifier);
- if (!entity) {
- return 'base.400';
- } else if (entity.type === 'inpaint_mask') {
- return rgbColorToString(entity.fill.color);
- } else if (entity.type === 'regional_guidance') {
- return rgbColorToString(entity.fill.color);
- } else {
- return 'base.400';
- }
- }),
- [entityIdentifier]
- );
- const selectionColor = useAppSelector(selectSelectionColor);
- return selectionColor;
-};
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useSaveLayerToAssets.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useSaveLayerToAssets.ts
index d4e64aa48df..c9508f24eea 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/useSaveLayerToAssets.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useSaveLayerToAssets.ts
@@ -5,14 +5,10 @@ import type { CanvasEntityAdapterRasterLayer } from 'features/controlLayers/konv
import type { CanvasEntityAdapterRegionalGuidance } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRegionalGuidance';
import { canvasToBlob } from 'features/controlLayers/konva/util';
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
-import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useUploadImageMutation } from 'services/api/endpoints/images';
+import { uploadImage } from 'services/api/endpoints/images';
export const useSaveLayerToAssets = () => {
- const { t } = useTranslation();
- const [uploadImage] = useUploadImageMutation();
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const saveLayerToAssets = useCallback(
@@ -27,30 +23,17 @@ export const useSaveLayerToAssets = () => {
if (!adapter) {
return;
}
- try {
- const canvas = adapter.getCanvas();
- const blob = await canvasToBlob(canvas);
- const file = new File([blob], `layer-${adapter.id}.png`, { type: 'image/png' });
- await uploadImage({
- file,
- image_category: 'user',
- is_intermediate: false,
- postUploadAction: { type: 'TOAST' },
- board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
- });
-
- toast({
- status: 'info',
- title: t('toast.layerSavedToAssets'),
- });
- } catch (error) {
- toast({
- status: 'error',
- title: t('toast.problemSavingLayer'),
- });
- }
+ const canvas = adapter.getCanvas();
+ const blob = await canvasToBlob(canvas);
+ const file = new File([blob], `layer-${adapter.id}.png`, { type: 'image/png' });
+ uploadImage({
+ file,
+ image_category: 'user',
+ is_intermediate: false,
+ board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
+ });
},
- [t, autoAddBoardId, uploadImage]
+ [autoAddBoardId]
);
return saveLayerToAssets;
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts
index 8ec7dce4304..876c94689f3 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts
@@ -1,4 +1,3 @@
-import type { SerializableObject } from 'common/types';
import { withResultAsync } from 'common/util/result';
import { CanvasCacheModule } from 'features/controlLayers/konva/CanvasCacheModule';
import type { CanvasEntityAdapter, CanvasEntityAdapterFromType } from 'features/controlLayers/konva/CanvasEntity/types';
@@ -35,12 +34,13 @@ import { t } from 'i18next';
import { atom, computed } from 'nanostores';
import type { Logger } from 'roarr';
import { serializeError } from 'serialize-error';
-import type { UploadOptions } from 'services/api/endpoints/images';
+import type { UploadImageArg } from 'services/api/endpoints/images';
import { getImageDTOSafe, uploadImage } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import stableHash from 'stable-hash';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
+import type { JsonObject } from 'type-fest';
type CompositingOptions = {
/**
@@ -173,14 +173,14 @@ export class CanvasCompositorModule extends CanvasModuleBase {
return adapters as CanvasEntityAdapterFromType[];
};
- getCompositeHash = (adapters: CanvasEntityAdapter[], extra: SerializableObject): string => {
- const adapterHashes: SerializableObject[] = [];
+ getCompositeHash = (adapters: CanvasEntityAdapter[], extra: JsonObject): string => {
+ const adapterHashes: JsonObject[] = [];
for (const adapter of adapters) {
adapterHashes.push(adapter.getHashableState());
}
- const data: SerializableObject = {
+ const data: JsonObject = {
extra,
adapterHashes,
};
@@ -259,7 +259,7 @@ export class CanvasCompositorModule extends CanvasModuleBase {
getCompositeImageDTO = async (
adapters: CanvasEntityAdapter[],
rect: Rect,
- uploadOptions: Pick,
+ uploadOptions: Pick,
compositingOptions?: CompositingOptions,
forceUpload?: boolean
): Promise => {
@@ -297,8 +297,7 @@ export class CanvasCompositorModule extends CanvasModuleBase {
this.$isUploading.set(true);
const uploadResult = await withResultAsync(() =>
uploadImage({
- blob,
- fileName: 'canvas-composite.png',
+ file: new File([blob], 'canvas-composite.png', { type: 'image/png' }),
image_category: 'general',
is_intermediate: uploadOptions.is_intermediate,
board_id: uploadOptions.is_intermediate ? undefined : selectAutoAddBoardId(this.manager.store.getState()),
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts
index b791fb78c54..ee366068f2c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts
@@ -1,7 +1,6 @@
import type { Selector } from '@reduxjs/toolkit';
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
-import type { SerializableObject } from 'common/types';
import { deepClone } from 'common/util/deepClone';
import type { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer';
import type { CanvasEntityFilterer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer';
@@ -37,6 +36,7 @@ import type { Logger } from 'roarr';
import type { ImageDTO } from 'services/api/types';
import stableHash from 'stable-hash';
import { assert } from 'tsafe';
+import type { Jsonifiable, JsonObject } from 'type-fest';
// Ideally, we'd type `adapter` as `CanvasEntityAdapterBase`, but the generics make this tricky. `CanvasEntityAdapter`
// is a union of all entity adapters and is functionally identical to `CanvasEntityAdapterBase`. We'll need to do a
@@ -111,7 +111,7 @@ export abstract class CanvasEntityAdapterBase<
*
* This is used for caching.
*/
- abstract getHashableState: () => SerializableObject;
+ abstract getHashableState: () => JsonObject;
/**
* Callbacks that are executed when the module is initialized.
@@ -566,7 +566,7 @@ export abstract class CanvasEntityAdapterBase<
* Gets a hash of the entity's state, as provided by `getHashableState`. If `extra` is provided, it will be included in
* the hash.
*/
- hash = (extra?: SerializableObject): string => {
+ hash = (extra?: Jsonifiable): string => {
const arg = {
state: this.getHashableState(),
extra,
@@ -614,8 +614,8 @@ export abstract class CanvasEntityAdapterBase<
transformer: this.transformer.repr(),
renderer: this.renderer.repr(),
bufferRenderer: this.bufferRenderer.repr(),
- segmentAnything: this.segmentAnything?.repr(),
- filterer: this.filterer?.repr(),
+ segmentAnything: this.segmentAnything?.repr() ?? null,
+ filterer: this.filterer?.repr() ?? null,
hasCache: this.$canvasCache.get() !== null,
isLocked: this.$isLocked.get(),
isDisabled: this.$isDisabled.get(),
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts
index ad242d61c82..583f0eff371 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts
@@ -1,4 +1,3 @@
-import type { SerializableObject } from 'common/types';
import { CanvasEntityAdapterBase } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase';
import { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer';
import { CanvasEntityFilterer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer';
@@ -9,6 +8,7 @@ import { CanvasSegmentAnythingModule } from 'features/controlLayers/konva/Canvas
import type { CanvasControlLayerState, CanvasEntityIdentifier, Rect } from 'features/controlLayers/store/types';
import type { GroupConfig } from 'konva/lib/Group';
import { omit } from 'lodash-es';
+import type { JsonObject } from 'type-fest';
export class CanvasEntityAdapterControlLayer extends CanvasEntityAdapterBase<
CanvasControlLayerState,
@@ -77,7 +77,7 @@ export class CanvasEntityAdapterControlLayer extends CanvasEntityAdapterBase<
return canvas;
};
- getHashableState = (): SerializableObject => {
+ getHashableState = (): JsonObject => {
const keysToOmit: (keyof CanvasControlLayerState)[] = [
'name',
'controlAdapter',
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterInpaintMask.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterInpaintMask.ts
index 4fc4d2c4a95..03988f6efe2 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterInpaintMask.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterInpaintMask.ts
@@ -1,4 +1,3 @@
-import type { SerializableObject } from 'common/types';
import { CanvasEntityAdapterBase } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase';
import { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer';
import { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer';
@@ -7,6 +6,7 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { CanvasEntityIdentifier, CanvasInpaintMaskState, Rect } from 'features/controlLayers/store/types';
import type { GroupConfig } from 'konva/lib/Group';
import { omit } from 'lodash-es';
+import type { JsonObject } from 'type-fest';
export class CanvasEntityAdapterInpaintMask extends CanvasEntityAdapterBase<
CanvasInpaintMaskState,
@@ -69,7 +69,7 @@ export class CanvasEntityAdapterInpaintMask extends CanvasEntityAdapterBase<
}
};
- getHashableState = (): SerializableObject => {
+ getHashableState = (): JsonObject => {
const keysToOmit: (keyof CanvasInpaintMaskState)[] = ['fill', 'name', 'opacity', 'isLocked'];
return omit(this.state, keysToOmit);
};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts
index ed582ef6c16..52d39848b54 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts
@@ -1,4 +1,3 @@
-import type { SerializableObject } from 'common/types';
import { CanvasEntityAdapterBase } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase';
import { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer';
import { CanvasEntityFilterer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityFilterer';
@@ -9,6 +8,7 @@ import { CanvasSegmentAnythingModule } from 'features/controlLayers/konva/Canvas
import type { CanvasEntityIdentifier, CanvasRasterLayerState, Rect } from 'features/controlLayers/store/types';
import type { GroupConfig } from 'konva/lib/Group';
import { omit } from 'lodash-es';
+import type { JsonObject } from 'type-fest';
export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase<
CanvasRasterLayerState,
@@ -70,7 +70,7 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase<
return canvas;
};
- getHashableState = (): SerializableObject => {
+ getHashableState = (): JsonObject => {
const keysToOmit: (keyof CanvasRasterLayerState)[] = ['name', 'isLocked'];
return omit(this.state, keysToOmit);
};
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRegionalGuidance.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRegionalGuidance.ts
index df1b90abaa5..a56ea2ce0f7 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRegionalGuidance.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRegionalGuidance.ts
@@ -1,4 +1,3 @@
-import type { SerializableObject } from 'common/types';
import { CanvasEntityAdapterBase } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase';
import { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer';
import { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer';
@@ -7,6 +6,7 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { CanvasEntityIdentifier, CanvasRegionalGuidanceState, Rect } from 'features/controlLayers/store/types';
import type { GroupConfig } from 'konva/lib/Group';
import { omit } from 'lodash-es';
+import type { JsonObject } from 'type-fest';
export class CanvasEntityAdapterRegionalGuidance extends CanvasEntityAdapterBase<
CanvasRegionalGuidanceState,
@@ -69,7 +69,7 @@ export class CanvasEntityAdapterRegionalGuidance extends CanvasEntityAdapterBase
}
};
- getHashableState = (): SerializableObject => {
+ getHashableState = (): JsonObject => {
const keysToOmit: (keyof CanvasRegionalGuidanceState)[] = [
'fill',
'name',
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts
index a6022abf170..2dc5bdab26a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts
@@ -260,7 +260,7 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
path: this.path,
parent: this.parent.id,
bufferState: deepClone(this.state),
- bufferRenderer: this.renderer?.repr(),
+ bufferRenderer: this.renderer?.repr() ?? null,
};
};
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts
index 10ae1831b38..ba9b83a7ca2 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts
@@ -490,8 +490,7 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase {
previewBlob(blob, 'Rasterized entity');
}
imageDTO = await uploadImage({
- blob,
- fileName: `${this.id}_rasterized.png`,
+ file: new File([blob], `${this.id}_rasterized.png`, { type: 'image/png' }),
image_category: 'other',
is_intermediate: true,
});
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
index 59ff083abcb..4145197edfa 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
@@ -1,6 +1,5 @@
import { logger } from 'app/logging/logger';
import type { AppStore } from 'app/store/store';
-import type { SerializableObject } from 'common/types';
import { SyncableMap } from 'common/util/SyncableMap/SyncableMap';
import { CanvasCacheModule } from 'features/controlLayers/konva/CanvasCacheModule';
import { CanvasCompositorModule } from 'features/controlLayers/konva/CanvasCompositorModule';
@@ -35,6 +34,7 @@ import { computed } from 'nanostores';
import type { Logger } from 'roarr';
import type { AppSocket } from 'services/events/types';
import { assert } from 'tsafe';
+import type { JsonObject } from 'type-fest';
import { CanvasBackgroundModule } from './CanvasBackgroundModule';
import { CanvasStateApiModule } from './CanvasStateApiModule';
@@ -294,7 +294,7 @@ export class CanvasManager extends CanvasModuleBase {
};
};
- getLoggingContext = (): SerializableObject => ({ path: this.path });
+ getLoggingContext = (): JsonObject => ({ path: this.path });
buildPath = (canvasModule: CanvasModuleBase): string[] => {
return canvasModule.parent.path.concat(canvasModule.id);
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasModuleBase.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasModuleBase.ts
index 5912d4cfa38..09270e875ff 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasModuleBase.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasModuleBase.ts
@@ -1,6 +1,6 @@
-import type { SerializableObject } from 'common/types';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { Logger } from 'roarr';
+import type { JsonObject } from 'type-fest';
/**
* Base class for all canvas modules.
@@ -81,7 +81,7 @@ export abstract class CanvasModuleBase {
* };
* ```
*/
- getLoggingContext: () => SerializableObject = () => {
+ getLoggingContext: () => JsonObject = () => {
return {
...this.parent.getLoggingContext(),
path: this.path.join(' > '),
@@ -135,7 +135,7 @@ export abstract class CanvasModuleBase {
* };
* ```
*/
- repr: () => SerializableObject = () => {
+ repr: () => JsonObject = () => {
return {
id: this.id,
type: this.type,
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts
index 0ee97212ebe..e1b206245cb 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasSegmentAnythingModule.ts
@@ -891,7 +891,7 @@ export class CanvasSegmentAnythingModule extends CanvasModuleBase {
circle: getKonvaNodeDebugAttrs(konva.circle),
})),
imageState: deepClone(this.$imageState.get()),
- imageModule: this.imageModule?.repr(),
+ imageModule: this.imageModule?.repr() ?? null,
config: deepClone(this.config),
$isSegmenting: this.$isSegmenting.get(),
$lastProcessedHash: this.$lastProcessedHash.get(),
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
index 8635e0ce0d9..2b3613717c4 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts
@@ -686,13 +686,13 @@ export class CanvasStateApiModule extends CanvasModuleBase {
id: this.id,
type: this.type,
path: this.path,
- $filteringAdapter: this.$filteringAdapter.get()?.entityIdentifier,
+ $filteringAdapter: this.$filteringAdapter.get()?.entityIdentifier ?? null,
$isFiltering: this.$isFiltering.get(),
- $transformingAdapter: this.$transformingAdapter.get()?.entityIdentifier,
+ $transformingAdapter: this.$transformingAdapter.get()?.entityIdentifier ?? null,
$isTransforming: this.$isTransforming.get(),
- $rasterizingAdapter: this.$rasterizingAdapter.get()?.entityIdentifier,
+ $rasterizingAdapter: this.$rasterizingAdapter.get()?.entityIdentifier ?? null,
$isRasterizing: this.$isRasterizing.get(),
- $segmentingAdapter: this.$segmentingAdapter.get()?.entityIdentifier,
+ $segmentingAdapter: this.$segmentingAdapter.get()?.entityIdentifier ?? null,
$isSegmenting: this.$isSegmenting.get(),
};
};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
index 07bb5635f47..87feb3f2fd5 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
@@ -14,6 +14,8 @@ import {
selectRegionalGuidanceReferenceImage,
} from 'features/controlLayers/store/selectors';
import type {
+ CanvasEntityStateFromType,
+ CanvasEntityType,
CanvasInpaintMaskState,
CanvasMetadata,
FillStyle,
@@ -1211,7 +1213,7 @@ export const canvasSlice = createSlice({
}
},
entityRasterized: (state, action: PayloadAction) => {
- const { entityIdentifier, imageObject, position, replaceObjects } = action.payload;
+ const { entityIdentifier, imageObject, position, replaceObjects, isSelected } = action.payload;
const entity = selectEntity(state, entityIdentifier);
if (!entity) {
return;
@@ -1223,6 +1225,10 @@ export const canvasSlice = createSlice({
entity.position = position;
}
}
+
+ if (isSelected) {
+ state.selectedEntityIdentifier = entityIdentifier;
+ }
},
entityBrushLineAdded: (state, action: PayloadAction) => {
const { entityIdentifier, brushLine } = action.payload;
@@ -1345,6 +1351,46 @@ export const canvasSlice = createSlice({
}
moveToStart(selectAllEntitiesOfType(state, entity.type), entity);
},
+ entitiesReordered: (
+ state: CanvasState,
+ action: PayloadAction<{ type: T; entityIdentifiers: CanvasEntityIdentifier[] }>
+ ) => {
+ const { type, entityIdentifiers } = action.payload;
+
+ switch (type) {
+ case 'raster_layer': {
+ state.rasterLayers.entities = reorderEntities(
+ state.rasterLayers.entities,
+ entityIdentifiers as CanvasEntityIdentifier<'raster_layer'>[]
+ );
+ break;
+ }
+ case 'control_layer':
+ state.controlLayers.entities = reorderEntities(
+ state.controlLayers.entities,
+ entityIdentifiers as CanvasEntityIdentifier<'control_layer'>[]
+ );
+ break;
+ case 'inpaint_mask':
+ state.inpaintMasks.entities = reorderEntities(
+ state.inpaintMasks.entities,
+ entityIdentifiers as CanvasEntityIdentifier<'inpaint_mask'>[]
+ );
+ break;
+ case 'regional_guidance':
+ state.regionalGuidance.entities = reorderEntities(
+ state.regionalGuidance.entities,
+ entityIdentifiers as CanvasEntityIdentifier<'regional_guidance'>[]
+ );
+ break;
+ case 'reference_image':
+ state.referenceImages.entities = reorderEntities(
+ state.referenceImages.entities,
+ entityIdentifiers as CanvasEntityIdentifier<'reference_image'>[]
+ );
+ break;
+ }
+ },
entityOpacityChanged: (state, action: PayloadAction>) => {
const { entityIdentifier, opacity } = action.payload;
const entity = selectEntity(state, entityIdentifier);
@@ -1471,6 +1517,7 @@ export const {
entityArrangedBackwardOne,
entityArrangedToBack,
entityOpacityChanged,
+ entitiesReordered,
// allEntitiesDeleted, // currently unused
allEntitiesOfTypeIsHiddenToggled,
// bbox
@@ -1604,3 +1651,17 @@ function actionsThrottlingFilter(action: UnknownAction) {
}, THROTTLE_MS);
return true;
}
+
+const reorderEntities = (
+ entities: CanvasEntityStateFromType[],
+ sortedEntityIdentifiers: CanvasEntityIdentifier[]
+) => {
+ const sortedEntities: CanvasEntityStateFromType[] = [];
+ for (const { id } of sortedEntityIdentifiers.toReversed()) {
+ const entity = entities.find((entity) => entity.id === id);
+ if (entity) {
+ sortedEntities.push(entity);
+ }
+ }
+ return sortedEntities;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index efc05d62ae7..307604f7777 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -1,4 +1,3 @@
-import type { SerializableObject } from 'common/types';
import { fetchModelConfigByIdentifier } from 'features/metadata/util/modelFetchingHelpers';
import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common';
import type { ParameterLoRAModel } from 'features/parameters/types/parameterSchemas';
@@ -9,6 +8,7 @@ import {
} from 'features/parameters/types/parameterSchemas';
import { getImageDTOSafe } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
+import type { JsonObject } from 'type-fest';
import { z } from 'zod';
const zId = z.string().min(1);
@@ -429,7 +429,7 @@ export type StageAttrs = {
};
export type EntityIdentifierPayload<
- T extends SerializableObject | void = void,
+ T extends JsonObject | void = void,
U extends CanvasEntityType = CanvasEntityType,
> = T extends void
? {
@@ -451,6 +451,7 @@ export type EntityRasterizedPayload = EntityIdentifierPayload<{
imageObject: CanvasImageState;
position: Coordinate;
replaceObjects: boolean;
+ isSelected?: boolean;
}>;
/**
@@ -466,6 +467,8 @@ export type EntityRasterizedPayload = EntityIdentifierPayload<{
export type GenerationMode = 'txt2img' | 'img2img' | 'inpaint' | 'outpaint';
+export type CanvasEntityStateFromType = Extract;
+
export function isRenderableEntityType(
entityType: CanvasEntityState['type']
): entityType is CanvasRenderableEntityState['type'] {
diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx
new file mode 100644
index 00000000000..7232d0fc2af
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx
@@ -0,0 +1,64 @@
+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 { MultipleImageDndSourceData } from 'features/dnd/dnd';
+import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util';
+import { memo } from 'react';
+import { createPortal } from 'react-dom';
+import { useTranslation } from 'react-i18next';
+import type { ImageDTO } from 'services/api/types';
+import type { Param0 } from 'tsafe';
+
+const DndDragPreviewMultipleImage = memo(({ imageDTOs }: { imageDTOs: ImageDTO[] }) => {
+ const { t } = useTranslation();
+ return (
+
+ {imageDTOs.length}
+ {t('parameters.images')}
+
+ );
+});
+
+DndDragPreviewMultipleImage.displayName = 'DndDragPreviewMultipleImage';
+
+export type DndDragPreviewMultipleImageState = {
+ type: 'multiple-image';
+ container: HTMLElement;
+ imageDTOs: ImageDTO[];
+};
+
+export const createMultipleImageDragPreview = (arg: DndDragPreviewMultipleImageState) =>
+ createPortal(, arg.container);
+
+type SetMultipleDragPreviewArg = {
+ multipleImageDndData: MultipleImageDndSourceData;
+ setDragPreviewState: (dragPreviewState: DndDragPreviewMultipleImageState | null) => void;
+ onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>;
+};
+
+export const setMultipleImageDragPreview = ({
+ multipleImageDndData,
+ onGenerateDragPreviewArgs,
+ setDragPreviewState,
+}: SetMultipleDragPreviewArg) => {
+ const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs;
+ setCustomNativeDragPreview({
+ render({ container }) {
+ setDragPreviewState({ type: 'multiple-image', container, imageDTOs: multipleImageDndData.payload.imageDTOs });
+ return () => setDragPreviewState(null);
+ },
+ nativeSetDragImage,
+ getOffset: preserveOffsetOnSourceFallbackCentered({
+ element: source.element,
+ input: location.current.input,
+ }),
+ });
+};
diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx
new file mode 100644
index 00000000000..d25241a1d91
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx
@@ -0,0 +1,62 @@
+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 { SingleImageDndSourceData } from 'features/dnd/dnd';
+import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util';
+import { memo } from 'react';
+import { createPortal } from 'react-dom';
+import type { ImageDTO } from 'services/api/types';
+import type { Param0 } from 'tsafe';
+
+const ChakraImg = chakra('img');
+
+const DndDragPreviewSingleImage = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
+ return (
+
+
+
+ );
+});
+
+DndDragPreviewSingleImage.displayName = 'DndDragPreviewSingleImage';
+
+export type DndDragPreviewSingleImageState = {
+ type: 'single-image';
+ container: HTMLElement;
+ imageDTO: ImageDTO;
+};
+
+export const createSingleImageDragPreview = (arg: DndDragPreviewSingleImageState) =>
+ createPortal(, arg.container);
+
+type SetSingleDragPreviewArg = {
+ singleImageDndData: SingleImageDndSourceData;
+ setDragPreviewState: (dragPreviewState: DndDragPreviewSingleImageState | null) => void;
+ onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>;
+};
+
+export const setSingleImageDragPreview = ({
+ singleImageDndData,
+ onGenerateDragPreviewArgs,
+ setDragPreviewState,
+}: SetSingleDragPreviewArg) => {
+ const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs;
+ setCustomNativeDragPreview({
+ render({ container }) {
+ setDragPreviewState({ type: 'single-image', container, imageDTO: singleImageDndData.payload.imageDTO });
+ return () => setDragPreviewState(null);
+ },
+ nativeSetDragImage,
+ getOffset: preserveOffsetOnSourceFallbackCentered({
+ element: source.element,
+ input: location.current.input,
+ }),
+ });
+};
diff --git a/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx b/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx
new file mode 100644
index 00000000000..513e7177b3f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx
@@ -0,0 +1,86 @@
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Flex, Text } from '@invoke-ai/ui-library';
+import type { DndTargetState } from 'features/dnd/types';
+import { isNil, isString } from 'lodash-es';
+import type { ReactNode } from 'react';
+import { memo } from 'react';
+
+type Props = {
+ dndState: DndTargetState;
+ label?: string | ReactNode;
+ withBackdrop?: boolean;
+};
+
+const sx = {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ color: 'base.300',
+ borderColor: 'base.300',
+ transitionProperty: 'common',
+ transitionDuration: '0.1s',
+ '.dnd-drop-overlay-backdrop': {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ bg: 'base.900',
+ opacity: 0.7,
+ borderRadius: 'base',
+ alignItems: 'center',
+ justifyContent: 'center',
+ transitionProperty: 'inherit',
+ transitionDuration: 'inherit',
+ },
+ '.dnd-drop-overlay-content': {
+ position: 'absolute',
+ top: 0.5,
+ right: 0.5,
+ bottom: 0.5,
+ left: 0.5,
+ opacity: 1,
+ borderWidth: 1.5,
+ borderRadius: 'base',
+ borderStyle: 'dashed',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderColor: 'inherit',
+ transitionProperty: 'inherit',
+ transitionDuration: 'inherit',
+ },
+ '.dnd-drop-overlay-label': {
+ fontSize: 'lg',
+ fontWeight: 'semibold',
+ textAlign: 'center',
+ color: 'inherit',
+ transitionProperty: 'inherit',
+ transitionDuration: 'inherit',
+ },
+ '&[data-dnd-state="over"]': {
+ color: 'invokeYellow.300',
+ borderColor: 'invokeYellow.300',
+ },
+} satisfies SystemStyleObject;
+
+export const DndDropOverlay = memo((props: Props) => {
+ const { dndState, label, withBackdrop = true } = props;
+
+ if (dndState === 'idle') {
+ return null;
+ }
+
+ return (
+
+ {withBackdrop && }
+
+ {isString(label) && {label}}
+ {!isNil(label) && !isString(label) && label}
+
+
+ );
+});
+
+DndDropOverlay.displayName = 'DndDropOverlay';
diff --git a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx
new file mode 100644
index 00000000000..25588cbe3ad
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx
@@ -0,0 +1,94 @@
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+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 type { AnyDndTarget } from 'features/dnd/dnd';
+import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
+import type { DndTargetState } from 'features/dnd/types';
+import { memo, useEffect, useRef, useState } from 'react';
+
+const sx = {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ w: 'full',
+ h: 'full',
+ pointerEvents: 'auto',
+ // We must disable pointer events when idle to prevent the overlay from blocking clicks
+ '&[data-dnd-state="idle"]': {
+ pointerEvents: 'none',
+ },
+} satisfies SystemStyleObject;
+
+type Props = {
+ dndTarget: T;
+ dndTargetData: ReturnType;
+ label: string;
+ isDisabled?: boolean;
+};
+
+export const DndDropTarget = memo((props: Props) => {
+ const { dndTarget, dndTargetData, label, isDisabled } = props;
+ const [dndState, setDndState] = useState('idle');
+ const ref = useRef(null);
+ const dispatch = useAppDispatch();
+
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) {
+ return;
+ }
+ if (isDisabled) {
+ return;
+ }
+
+ const { dispatch, getState } = getStore();
+
+ return combine(
+ dropTargetForElements({
+ element,
+ canDrop: ({ source }) => {
+ // TS cannot infer `dndTargetData` but we've just checked it.
+ // TODO(psyche): Figure out how to satisfy TS.
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ const arg = { sourceData: source.data, targetData: dndTargetData, dispatch, getState } as any;
+ return dndTarget.isValid(arg);
+ },
+ onDragEnter: () => {
+ setDndState('over');
+ },
+ onDragLeave: () => {
+ setDndState('potential');
+ },
+ getData: () => dndTargetData,
+ }),
+ monitorForElements({
+ canMonitor: ({ source }) => {
+ // TS cannot infer `dndTargetData` but we've just checked it.
+ // TODO(psyche): Figure out how to satisfy TS.
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ const arg = { sourceData: source.data, targetData: dndTargetData, dispatch, getState } as any;
+ return dndTarget.isValid(arg);
+ },
+ onDragStart: () => {
+ setDndState('potential');
+ },
+ onDrop: () => {
+ setDndState('idle');
+ },
+ })
+ );
+ }, [dispatch, isDisabled, dndTarget, dndTargetData]);
+
+ return (
+
+
+
+ );
+});
+
+DndDropTarget.displayName = 'DndDropTarget';
diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx
new file mode 100644
index 00000000000..e8b5a0f48cc
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx
@@ -0,0 +1,78 @@
+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 { singleImageDndSource } 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 { memo, useEffect, useState } from 'react';
+import type { ImageDTO } from 'services/api/types';
+
+const sx = {
+ objectFit: 'contain',
+ maxW: 'full',
+ maxH: 'full',
+ borderRadius: 'base',
+ cursor: 'grab',
+ '&[data-is-dragging=true]': {
+ opacity: 0.3,
+ },
+} satisfies SystemStyleObject;
+
+type Props = ImageProps & {
+ imageDTO: ImageDTO;
+ asThumbnail?: boolean;
+};
+
+export const DndImage = memo(({ imageDTO, asThumbnail, ...rest }: Props) => {
+ const store = useAppStore();
+ const [isDragging, setIsDragging] = useState(false);
+ const [element, ref] = useState(null);
+ const [dragPreviewState, setDragPreviewState] = useState(null);
+
+ useEffect(() => {
+ if (!element) {
+ return;
+ }
+ return draggable({
+ element,
+ getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name),
+ onDragStart: () => {
+ setIsDragging(true);
+ },
+ onDrop: () => {
+ setIsDragging(false);
+ },
+ onGenerateDragPreview: (args) => {
+ if (singleImageDndSource.typeGuard(args.source.data)) {
+ setSingleImageDragPreview({
+ singleImageDndData: args.source.data,
+ onGenerateDragPreviewArgs: args,
+ setDragPreviewState,
+ });
+ }
+ },
+ });
+ }, [imageDTO, element, store]);
+
+ useImageContextMenu(imageDTO, element);
+
+ return (
+ <>
+
+ {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null}
+ >
+ );
+});
+
+DndImage.displayName = 'DndImage';
diff --git a/invokeai/frontend/web/src/common/components/IAIDndImageIcon.tsx b/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx
similarity index 90%
rename from invokeai/frontend/web/src/common/components/IAIDndImageIcon.tsx
rename to invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx
index 8c24c2be554..d9b63f1ec87 100644
--- a/invokeai/frontend/web/src/common/components/IAIDndImageIcon.tsx
+++ b/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx
@@ -21,7 +21,7 @@ type Props = Omit & {
tooltip: string;
};
-const IAIDndImageIcon = (props: Props) => {
+export const DndImageIcon = memo((props: Props) => {
const { onClick, tooltip, icon, ...rest } = props;
return (
@@ -35,6 +35,6 @@ const IAIDndImageIcon = (props: Props) => {
{...rest}
/>
);
-};
+});
-export default memo(IAIDndImageIcon);
+DndImageIcon.displayName = 'DndImageIcon';
diff --git a/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx
new file mode 100644
index 00000000000..3a7152c0f73
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx
@@ -0,0 +1,123 @@
+// Adapted from https://github.com/alexreardon/pdnd-react-tailwind/blob/main/src/drop-indicator.tsx
+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 { DndListTargetState } from 'features/dnd/types';
+
+/**
+ * Design decisions for the drop indicator's main line
+ */
+const line = {
+ thickness: 2,
+ backgroundColor: 'base.500',
+};
+
+type DropIndicatorProps = {
+ /**
+ * The `edge` to draw a drop indicator on.
+ *
+ * `edge` is required as for the best possible performance
+ * outcome you should only render this component when it needs to do something
+ *
+ * @example {closestEdge && }
+ */
+ edge: Edge;
+ /**
+ * `gap` allows you to position the drop indicator further away from the drop target.
+ * `gap` should be the distance between your drop targets
+ * a drop indicator will be rendered halfway between the drop targets
+ * (the drop indicator will be offset by half of the `gap`)
+ *
+ * `gap` should be a valid CSS length.
+ * @example "8px"
+ * @example "var(--gap)"
+ */
+ gap?: string;
+};
+
+const lineStyles: SystemStyleObject = {
+ display: 'block',
+ position: 'absolute',
+ zIndex: 1,
+ borderRadius: 'full',
+ // Blocking pointer events to prevent the line from triggering drag events
+ // Dragging over the line should count as dragging over the element behind it
+ pointerEvents: 'none',
+ background: line.backgroundColor,
+};
+
+type Orientation = 'horizontal' | 'vertical';
+
+const orientationStyles: Record = {
+ horizontal: {
+ height: `${line.thickness}px`,
+ left: 2,
+ right: 2,
+ },
+ vertical: {
+ width: `${line.thickness}px`,
+ top: 2,
+ bottom: 2,
+ },
+};
+
+const edgeToOrientationMap: Record = {
+ top: 'horizontal',
+ bottom: 'horizontal',
+ left: 'vertical',
+ right: 'vertical',
+};
+
+const edgeStyles: Record = {
+ top: {
+ top: 'var(--local-line-offset)',
+ },
+ right: {
+ right: 'var(--local-line-offset)',
+ },
+ bottom: {
+ bottom: 'var(--local-line-offset)',
+ },
+ left: {
+ left: 'var(--local-line-offset)',
+ },
+};
+
+/**
+ * __Drop indicator__
+ *
+ * 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.
+ */
+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.
+ */
+ const lineOffset = `calc(-0.5 * (${gap} + ${line.thickness}px))`;
+
+ const orientation = edgeToOrientationMap[edge];
+
+ return (
+
+ );
+}
+
+export const DndListDropIndicator = ({ dndState }: { dndState: DndListTargetState }) => {
+ if (dndState.type !== 'is-dragging-over') {
+ return null;
+ }
+
+ if (!dndState.closestEdge) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx b/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx
new file mode 100644
index 00000000000..e4e7f693484
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx
@@ -0,0 +1,158 @@
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
+import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/external/file';
+import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled';
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Box, Flex, Heading } from '@invoke-ai/ui-library';
+import { getStore } from 'app/store/nanostores/store';
+import { useAppSelector } from 'app/store/storeHooks';
+import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
+import type { DndTargetState } from 'features/dnd/types';
+import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
+import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
+import { toast } from 'features/toast/toast';
+import { memo, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { type UploadImageArg, uploadImages } from 'services/api/endpoints/images';
+import { useBoardName } from 'services/api/hooks/useBoardName';
+import { z } from 'zod';
+
+const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpg', 'image/jpeg'];
+const ACCEPTED_FILE_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
+
+// const MAX_IMAGE_SIZE = 4; //In MegaBytes
+// const sizeInMB = (sizeInBytes: number, decimalsNum = 2) => {
+// const result = sizeInBytes / (1024 * 1024);
+// return +result.toFixed(decimalsNum);
+// };
+
+const zUploadFile = z
+ .custom()
+ // .refine(
+ // (file) => {
+ // return sizeInMB(file.size) <= MAX_IMAGE_SIZE;
+ // },
+ // () => ({ message: `The maximum image size is ${MAX_IMAGE_SIZE}MB` })
+ // )
+ .refine(
+ (file) => {
+ return ACCEPTED_IMAGE_TYPES.includes(file.type);
+ },
+ (file) => ({ message: `File type ${file.type} is not supported` })
+ )
+ .refine(
+ (file) => {
+ return ACCEPTED_FILE_EXTENSIONS.some((ext) => file.name.endsWith(ext));
+ },
+ (file) => ({ message: `File extension .${file.name.split('.').at(-1)} is not supported` })
+ );
+
+const getFilesSchema = (max?: number) => {
+ if (max === undefined) {
+ return z.array(zUploadFile);
+ }
+ return z.array(zUploadFile).max(max);
+};
+
+const sx = {
+ position: 'absolute',
+ top: 2,
+ right: 2,
+ bottom: 2,
+ left: 2,
+ '&[data-dnd-state="idle"]': {
+ pointerEvents: 'none',
+ },
+} satisfies SystemStyleObject;
+
+export const FullscreenDropzone = memo(() => {
+ const { t } = useTranslation();
+ const ref = useRef(null);
+ const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
+ const [dndState, setDndState] = useState('idle');
+
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) {
+ return;
+ }
+ const { getState } = getStore();
+ const uploadFilesSchema = getFilesSchema(maxImageUploadCount);
+
+ return combine(
+ dropTargetForExternal({
+ element,
+ canDrop: containsFiles,
+ onDrop: ({ source }) => {
+ const files = getFiles({ source });
+ const parseResult = uploadFilesSchema.safeParse(files);
+
+ if (!parseResult.success) {
+ const description =
+ maxImageUploadCount === undefined
+ ? t('toast.uploadFailedInvalidUploadDesc')
+ : t('toast.uploadFailedInvalidUploadDesc_withCount', { count: maxImageUploadCount });
+
+ toast({
+ id: 'UPLOAD_FAILED',
+ title: t('toast.uploadFailed'),
+ description,
+ status: 'error',
+ });
+ return;
+ }
+ const autoAddBoardId = selectAutoAddBoardId(getState());
+
+ const uploadArgs: UploadImageArg[] = files.map((file) => ({
+ file,
+ image_category: 'user',
+ is_intermediate: false,
+ board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
+ }));
+
+ uploadImages(uploadArgs);
+ },
+ onDragEnter: () => {
+ setDndState('over');
+ },
+ onDragLeave: () => {
+ setDndState('idle');
+ },
+ }),
+ monitorForExternal({
+ canMonitor: containsFiles,
+ onDragStart: () => {
+ setDndState('potential');
+ preventUnhandled.start();
+ },
+ onDrop: () => {
+ setDndState('idle');
+ preventUnhandled.stop();
+ },
+ })
+ );
+ }, [maxImageUploadCount, t]);
+
+ return (
+
+ } />
+
+ );
+});
+
+FullscreenDropzone.displayName = 'FullscreenDropzone';
+
+const DropLabel = memo(() => {
+ const { t } = useTranslation();
+ const boardId = useAppSelector(selectAutoAddBoardId);
+ const boardName = useBoardName(boardId);
+
+ return (
+
+ {t('gallery.dropToUpload')}
+ {t('toast.imagesWillBeAddedTo', { boardName })}
+
+ );
+});
+
+DropLabel.displayName = 'DropLabel';
diff --git a/invokeai/frontend/web/src/features/dnd/components/AppDndContext.tsx b/invokeai/frontend/web/src/features/dnd/components/AppDndContext.tsx
deleted file mode 100644
index bd3e0474fde..00000000000
--- a/invokeai/frontend/web/src/features/dnd/components/AppDndContext.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { MouseSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core';
-import { logger } from 'app/logging/logger';
-import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped';
-import { useAppDispatch } from 'app/store/storeHooks';
-import DndOverlay from 'features/dnd/components/DndOverlay';
-import type { DragEndEvent, DragStartEvent, TypesafeDraggableData } from 'features/dnd/types';
-import { customPointerWithin } from 'features/dnd/util/customPointerWithin';
-import type { PropsWithChildren } from 'react';
-import { memo, useCallback, useState } from 'react';
-
-import { DndContextTypesafe } from './DndContextTypesafe';
-
-const log = logger('system');
-
-const AppDndContext = (props: PropsWithChildren) => {
- const [activeDragData, setActiveDragData] = useState(null);
-
- const dispatch = useAppDispatch();
-
- const handleDragStart = useCallback((event: DragStartEvent) => {
- log.trace({ dragData: event.active.data.current }, 'Drag started');
- const activeData = event.active.data.current;
- if (!activeData) {
- return;
- }
- setActiveDragData(activeData);
- }, []);
-
- const handleDragEnd = useCallback(
- (event: DragEndEvent) => {
- log.trace({ dragData: event.active.data.current }, 'Drag ended');
- const overData = event.over?.data.current;
- if (!activeDragData || !overData) {
- return;
- }
- dispatch(dndDropped({ overData, activeData: activeDragData }));
- setActiveDragData(null);
- },
- [activeDragData, dispatch]
- );
-
- const mouseSensor = useSensor(MouseSensor, {
- activationConstraint: { distance: 10 },
- });
-
- const touchSensor = useSensor(TouchSensor, {
- activationConstraint: { distance: 10 },
- });
-
- // TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos
- // Alternatively, fix `rectIntersection` collection detection to work with the drag overlay
- // (currently the drag element collision rect is not correctly calculated)
- // const keyboardSensor = useSensor(KeyboardSensor);
-
- const sensors = useSensors(mouseSensor, touchSensor);
-
- return (
-
- {props.children}
-
-
- );
-};
-
-export default memo(AppDndContext);
diff --git a/invokeai/frontend/web/src/features/dnd/components/DndContextTypesafe.tsx b/invokeai/frontend/web/src/features/dnd/components/DndContextTypesafe.tsx
deleted file mode 100644
index e278e648a17..00000000000
--- a/invokeai/frontend/web/src/features/dnd/components/DndContextTypesafe.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import { DndContext } from '@dnd-kit/core';
-import type { DndContextTypesafeProps } from 'features/dnd/types';
-
-export function DndContextTypesafe(props: DndContextTypesafeProps) {
- return ;
-}
diff --git a/invokeai/frontend/web/src/features/dnd/components/DndOverlay.tsx b/invokeai/frontend/web/src/features/dnd/components/DndOverlay.tsx
deleted file mode 100644
index 883d79c87f6..00000000000
--- a/invokeai/frontend/web/src/features/dnd/components/DndOverlay.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { DragOverlay } from '@dnd-kit/core';
-import { useScaledModifer } from 'features/dnd/hooks/useScaledCenteredModifer';
-import type { TypesafeDraggableData } from 'features/dnd/types';
-import type { AnimationProps } from 'framer-motion';
-import { AnimatePresence, motion } from 'framer-motion';
-import type { CSSProperties } from 'react';
-import { memo, useMemo } from 'react';
-
-import DragPreview from './DragPreview';
-
-type DndOverlayProps = {
- activeDragData: TypesafeDraggableData | null;
-};
-
-const DndOverlay = (props: DndOverlayProps) => {
- const scaledModifier = useScaledModifer();
- const modifiers = useMemo(() => [scaledModifier], [scaledModifier]);
-
- return (
-
-
- {props.activeDragData && (
-
-
-
- )}
-
-
- );
-};
-
-export default memo(DndOverlay);
-
-const dragOverlayStyles: CSSProperties = {
- width: 'min-content',
- height: 'min-content',
- cursor: 'grabbing',
- pointerEvents: 'none',
- userSelect: 'none',
- // expand overlay to prevent cursor from going outside it and displaying
- padding: '10rem',
-};
-
-const initial: AnimationProps['initial'] = {
- opacity: 0,
- scale: 0.7,
-};
-const animate: AnimationProps['animate'] = {
- opacity: 1,
- scale: 1,
- transition: { duration: 0.1 },
-};
diff --git a/invokeai/frontend/web/src/features/dnd/components/DndSortable.tsx b/invokeai/frontend/web/src/features/dnd/components/DndSortable.tsx
deleted file mode 100644
index 786e1ce9c22..00000000000
--- a/invokeai/frontend/web/src/features/dnd/components/DndSortable.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { DragEndEvent } from '@dnd-kit/core';
-import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
-import type { PropsWithChildren } from 'react';
-import { memo } from 'react';
-
-import { DndContextTypesafe } from './DndContextTypesafe';
-
-type Props = PropsWithChildren & {
- items: string[];
- onDragEnd(event: DragEndEvent): void;
-};
-
-const DndSortable = (props: Props) => {
- return (
-
-
- {props.children}
-
-
- );
-};
-
-export default memo(DndSortable);
diff --git a/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx b/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx
deleted file mode 100644
index a73f0ba04e9..00000000000
--- a/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import type { ChakraProps } from '@invoke-ai/ui-library';
-import { Box, Flex, Heading, Image, Text } from '@invoke-ai/ui-library';
-import { useAppSelector } from 'app/store/storeHooks';
-import type { TypesafeDraggableData } from 'features/dnd/types';
-import { selectSelectionCount } from 'features/gallery/store/gallerySelectors';
-import { memo } from 'react';
-import { useTranslation } from 'react-i18next';
-
-type OverlayDragImageProps = {
- dragData: TypesafeDraggableData | null;
-};
-
-const BOX_SIZE = 28;
-
-const imageStyles: ChakraProps['sx'] = {
- w: BOX_SIZE,
- h: BOX_SIZE,
- maxW: BOX_SIZE,
- maxH: BOX_SIZE,
- shadow: 'dark-lg',
- borderRadius: 'lg',
- opacity: 0.3,
- borderColor: 'base.200',
- bg: 'base.900',
- color: 'base.100',
-};
-
-const multiImageStyles: ChakraProps['sx'] = {
- position: 'relative',
- alignItems: 'center',
- justifyContent: 'center',
- flexDir: 'column',
- ...imageStyles,
-};
-
-const DragPreview = (props: OverlayDragImageProps) => {
- const { t } = useTranslation();
- const selectionCount = useAppSelector(selectSelectionCount);
- if (!props.dragData) {
- return null;
- }
-
- if (props.dragData.payloadType === 'NODE_FIELD') {
- const { field, fieldTemplate } = props.dragData.payload;
- return (
-
- {field.label || fieldTemplate.title}
-
- );
- }
-
- if (props.dragData.payloadType === 'IMAGE_DTO') {
- const { thumbnail_url, width, height } = props.dragData.payload.imageDTO;
- return (
-
-
-
- );
- }
-
- if (props.dragData.payloadType === 'GALLERY_SELECTION') {
- return (
-
- {selectionCount}
- {t('parameters.images')}
-
- );
- }
-
- return null;
-};
-
-export default memo(DragPreview);
diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts
new file mode 100644
index 00000000000..4a684f3d159
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/dnd.ts
@@ -0,0 +1,435 @@
+import type { AppDispatch, RootState } from 'app/store/store';
+import { getPrefixedId } from 'features/controlLayers/konva/util';
+import type {
+ CanvasEntityIdentifier,
+ CanvasEntityType,
+ CanvasRenderableEntityIdentifier,
+} from 'features/controlLayers/store/types';
+import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common';
+import type { BoardId } from 'features/gallery/store/types';
+import {
+ addImagesToBoard,
+ createNewCanvasEntityFromImage,
+ removeImagesFromBoard,
+ replaceCanvasEntityObjectsWithImage,
+ setComparisonImage,
+ setGlobalReferenceImage,
+ setNodeImageFieldImage,
+ setRegionalGuidanceReferenceImage,
+ setUpscaleInitialImage,
+} from 'features/imageActions/actions';
+import type { FieldIdentifier } from 'features/nodes/types/field';
+import type { ImageDTO } from 'services/api/types';
+import type { JsonObject } from 'type-fest';
+
+type RecordUnknown = Record;
+
+type DndData<
+ Type extends string = string,
+ PrivateKey extends symbol = symbol,
+ Payload extends JsonObject | void = JsonObject | void,
+> = {
+ [key in PrivateKey]: true;
+} & {
+ id: string;
+ type: Type;
+ payload: Payload;
+};
+
+const buildTypeAndKey = (type: T) => {
+ const key = Symbol(type);
+ return { type, key } as const;
+};
+
+const buildTypeGuard = (key: symbol) => {
+ const typeGuard = (val: RecordUnknown): val is T => Boolean(val[key]);
+ return typeGuard;
+};
+
+const buildGetData = (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;
+};
+
+type DndSource = {
+ key: symbol;
+ type: SourceData['type'];
+ typeGuard: ReturnType>;
+ getData: ReturnType>;
+};
+//#region Single Image
+const _singleImage = buildTypeAndKey('single-image');
+export type SingleImageDndSourceData = DndData<
+ typeof _singleImage.type,
+ typeof _singleImage.key,
+ { imageDTO: ImageDTO }
+>;
+export const singleImageDndSource: DndSource = {
+ ..._singleImage,
+ typeGuard: buildTypeGuard(_singleImage.key),
+ getData: buildGetData(_singleImage.key, _singleImage.type),
+};
+//#endregion
+
+//#region Multiple Image
+const _multipleImage = buildTypeAndKey('multiple-image');
+export type MultipleImageDndSourceData = DndData<
+ typeof _multipleImage.type,
+ typeof _multipleImage.key,
+ { imageDTOs: ImageDTO[]; boardId: BoardId }
+>;
+export const multipleImageDndSource: DndSource = {
+ ..._multipleImage,
+ typeGuard: buildTypeGuard(_multipleImage.key),
+ getData: buildGetData(_multipleImage.key, _multipleImage.type),
+};
+//#endregion
+
+const _singleCanvasEntity = buildTypeAndKey('single-canvas-entity');
+type SingleCanvasEntityDndSourceData = DndData<
+ typeof _singleCanvasEntity.type,
+ typeof _singleCanvasEntity.key,
+ { entityIdentifier: CanvasEntityIdentifier }
+>;
+export const singleCanvasEntityDndSource: DndSource = {
+ ..._singleCanvasEntity,
+ typeGuard: buildTypeGuard(_singleCanvasEntity.key),
+ getData: buildGetData(_singleCanvasEntity.key, _singleCanvasEntity.type),
+};
+
+const _singleWorkflowField = buildTypeAndKey('single-workflow-field');
+type SingleWorkflowFieldDndSourceData = DndData<
+ typeof _singleWorkflowField.type,
+ typeof _singleWorkflowField.key,
+ { fieldIdentifier: FieldIdentifier }
+>;
+export const singleWorkflowFieldDndSource: DndSource = {
+ ..._singleWorkflowField,
+ typeGuard: buildTypeGuard(_singleWorkflowField.key),
+ getData: buildGetData(_singleWorkflowField.key, _singleWorkflowField.type),
+};
+
+type DndTarget = {
+ key: symbol;
+ type: TargetData['type'];
+ typeGuard: ReturnType>;
+ getData: ReturnType>;
+ isValid: (arg: {
+ sourceData: RecordUnknown;
+ targetData: TargetData;
+ dispatch: AppDispatch;
+ getState: () => RootState;
+ }) => boolean;
+ handler: (arg: {
+ sourceData: SourceData;
+ targetData: TargetData;
+ dispatch: AppDispatch;
+ getState: () => RootState;
+ }) => void;
+};
+
+//#region Set Global Reference Image
+const _setGlobalReferenceImage = buildTypeAndKey('set-global-reference-image');
+export type SetGlobalReferenceImageDndTargetData = DndData<
+ typeof _setGlobalReferenceImage.type,
+ typeof _setGlobalReferenceImage.key,
+ { entityIdentifier: CanvasEntityIdentifier<'reference_image'> }
+>;
+export const setGlobalReferenceImageDndTarget: DndTarget<
+ SetGlobalReferenceImageDndTargetData,
+ SingleImageDndSourceData
+> = {
+ ..._setGlobalReferenceImage,
+ typeGuard: buildTypeGuard(_setGlobalReferenceImage.key),
+ getData: buildGetData(_setGlobalReferenceImage.key, _setGlobalReferenceImage.type),
+ isValid: ({ sourceData }) => {
+ if (singleImageDndSource.typeGuard(sourceData)) {
+ return true;
+ }
+ return false;
+ },
+ handler: ({ sourceData, targetData, dispatch }) => {
+ const { imageDTO } = sourceData.payload;
+ const { entityIdentifier } = targetData.payload;
+ setGlobalReferenceImage({ entityIdentifier, imageDTO, dispatch });
+ },
+};
+//#endregion
+
+//#region Set Regional Guidance Reference Image
+const _setRegionalGuidanceReferenceImage = buildTypeAndKey('set-regional-guidance-reference-image');
+export type SetRegionalGuidanceReferenceImageDndTargetData = DndData<
+ typeof _setRegionalGuidanceReferenceImage.type,
+ typeof _setRegionalGuidanceReferenceImage.key,
+ { entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>; referenceImageId: string }
+>;
+export const setRegionalGuidanceReferenceImageDndTarget: DndTarget<
+ SetRegionalGuidanceReferenceImageDndTargetData,
+ SingleImageDndSourceData
+> = {
+ ..._setRegionalGuidanceReferenceImage,
+ typeGuard: buildTypeGuard(_setRegionalGuidanceReferenceImage.key),
+ getData: buildGetData(_setRegionalGuidanceReferenceImage.key, _setRegionalGuidanceReferenceImage.type),
+ isValid: ({ sourceData }) => {
+ if (singleImageDndSource.typeGuard(sourceData)) {
+ return true;
+ }
+ return false;
+ },
+ handler: ({ sourceData, targetData, dispatch }) => {
+ const { imageDTO } = sourceData.payload;
+ const { entityIdentifier, referenceImageId } = targetData.payload;
+ setRegionalGuidanceReferenceImage({ imageDTO, entityIdentifier, referenceImageId, dispatch });
+ },
+};
+//#endregion
+
+//# Set Upscale Initial Image
+const _setUpscaleInitialImage = buildTypeAndKey('set-upscale-initial-image');
+export type SetUpscaleInitialImageDndTargetData = DndData<
+ typeof _setUpscaleInitialImage.type,
+ typeof _setUpscaleInitialImage.key,
+ void
+>;
+export const setUpscaleInitialImageDndTarget: DndTarget =
+ {
+ ..._setUpscaleInitialImage,
+ typeGuard: buildTypeGuard(_setUpscaleInitialImage.key),
+ getData: buildGetData(_setUpscaleInitialImage.key, _setUpscaleInitialImage.type),
+ isValid: ({ sourceData }) => {
+ if (singleImageDndSource.typeGuard(sourceData)) {
+ return true;
+ }
+ return false;
+ },
+ handler: ({ sourceData, dispatch }) => {
+ const { imageDTO } = sourceData.payload;
+ setUpscaleInitialImage({ imageDTO, dispatch });
+ },
+ };
+//#endregion
+
+//#region Set Node Image Field Image
+const _setNodeImageFieldImage = buildTypeAndKey('set-node-image-field-image');
+export type SetNodeImageFieldImageDndTargetData = DndData<
+ typeof _setNodeImageFieldImage.type,
+ typeof _setNodeImageFieldImage.key,
+ { fieldIdentifer: FieldIdentifier }
+>;
+export const setNodeImageFieldImageDndTarget: DndTarget =
+ {
+ ..._setNodeImageFieldImage,
+ typeGuard: buildTypeGuard(_setNodeImageFieldImage.key),
+ getData: buildGetData(_setNodeImageFieldImage.key, _setNodeImageFieldImage.type),
+ isValid: ({ sourceData }) => {
+ if (singleImageDndSource.typeGuard(sourceData)) {
+ return true;
+ }
+ return false;
+ },
+ handler: ({ sourceData, targetData, dispatch }) => {
+ const { imageDTO } = sourceData.payload;
+ const { fieldIdentifer } = targetData.payload;
+ setNodeImageFieldImage({ fieldIdentifer, imageDTO, dispatch });
+ },
+ };
+//#endregion
+
+//# Set Comparison Image
+const _setComparisonImage = buildTypeAndKey('set-comparison-image');
+export type SetComparisonImageDndTargetData = DndData<
+ typeof _setComparisonImage.type,
+ typeof _setComparisonImage.key,
+ void
+>;
+export const setComparisonImageDndTarget: DndTarget = {
+ ..._setComparisonImage,
+ typeGuard: buildTypeGuard(_setComparisonImage.key),
+ getData: buildGetData(_setComparisonImage.key, _setComparisonImage.type),
+ isValid: ({ sourceData, getState }) => {
+ if (!singleImageDndSource.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, dispatch }) => {
+ const { imageDTO } = sourceData.payload;
+ setComparisonImage({ imageDTO, dispatch });
+ },
+};
+//#endregion
+
+//#region New Canvas Entity from Image
+const _newCanvasEntity = buildTypeAndKey('new-canvas-entity-from-image');
+type NewCanvasEntityFromImageDndTargetData = DndData<
+ typeof _newCanvasEntity.type,
+ typeof _newCanvasEntity.key,
+ { type: CanvasEntityType | 'regional_guidance_with_reference_image' }
+>;
+export const newCanvasEntityFromImageDndTarget: DndTarget<
+ NewCanvasEntityFromImageDndTargetData,
+ SingleImageDndSourceData
+> = {
+ ..._newCanvasEntity,
+ typeGuard: buildTypeGuard(_newCanvasEntity.key),
+ getData: buildGetData(_newCanvasEntity.key, _newCanvasEntity.type),
+ isValid: ({ sourceData }) => {
+ if (!singleImageDndSource.typeGuard(sourceData)) {
+ return false;
+ }
+ return true;
+ },
+ handler: ({ sourceData, targetData, dispatch, getState }) => {
+ const { type } = targetData.payload;
+ const { imageDTO } = sourceData.payload;
+ createNewCanvasEntityFromImage({ type, imageDTO, dispatch, getState });
+ },
+};
+
+//#endregion
+
+//#region Replace Canvas Entity Objects With Image
+const _replaceCanvasEntityObjectsWithImage = buildTypeAndKey('replace-canvas-entity-objects-with-image');
+export type ReplaceCanvasEntityObjectsWithImageDndTargetData = DndData<
+ typeof _replaceCanvasEntityObjectsWithImage.type,
+ typeof _replaceCanvasEntityObjectsWithImage.key,
+ { entityIdentifier: CanvasRenderableEntityIdentifier }
+>;
+export const replaceCanvasEntityObjectsWithImageDndTarget: DndTarget<
+ ReplaceCanvasEntityObjectsWithImageDndTargetData,
+ SingleImageDndSourceData
+> = {
+ ..._replaceCanvasEntityObjectsWithImage,
+ typeGuard: buildTypeGuard(_replaceCanvasEntityObjectsWithImage.key),
+ getData: buildGetData(_replaceCanvasEntityObjectsWithImage.key, _replaceCanvasEntityObjectsWithImage.type),
+ isValid: ({ sourceData }) => {
+ if (!singleImageDndSource.typeGuard(sourceData)) {
+ return false;
+ }
+ return true;
+ },
+ handler: ({ sourceData, targetData, dispatch, getState }) => {
+ const { imageDTO } = sourceData.payload;
+ const { entityIdentifier } = targetData.payload;
+ replaceCanvasEntityObjectsWithImage({ imageDTO, entityIdentifier, dispatch, getState });
+ },
+};
+//#endregion
+
+//#region Add To Board
+const _addToBoard = buildTypeAndKey('add-to-board');
+export type AddImageToBoardDndTargetData = DndData<
+ typeof _addToBoard.type,
+ typeof _addToBoard.key,
+ { boardId: BoardId }
+>;
+export const addImageToBoardDndTarget: DndTarget<
+ AddImageToBoardDndTargetData,
+ SingleImageDndSourceData | MultipleImageDndSourceData
+> = {
+ ..._addToBoard,
+ typeGuard: buildTypeGuard(_addToBoard.key),
+ getData: buildGetData(_addToBoard.key, _addToBoard.type),
+ isValid: ({ sourceData, targetData }) => {
+ if (singleImageDndSource.typeGuard(sourceData)) {
+ const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
+ const destinationBoard = targetData.payload.boardId;
+ return currentBoard !== destinationBoard;
+ }
+ if (multipleImageDndSource.typeGuard(sourceData)) {
+ const currentBoard = sourceData.payload.boardId;
+ const destinationBoard = targetData.payload.boardId;
+ return currentBoard !== destinationBoard;
+ }
+ return false;
+ },
+ handler: ({ sourceData, targetData, dispatch }) => {
+ if (singleImageDndSource.typeGuard(sourceData)) {
+ const { imageDTO } = sourceData.payload;
+ const { boardId } = targetData.payload;
+ addImagesToBoard({ imageDTOs: [imageDTO], boardId, dispatch });
+ }
+
+ if (multipleImageDndSource.typeGuard(sourceData)) {
+ const { imageDTOs } = sourceData.payload;
+ const { boardId } = targetData.payload;
+ addImagesToBoard({ imageDTOs, boardId, dispatch });
+ }
+ },
+};
+
+//#endregion
+
+//#region Remove From Board
+const _removeFromBoard = buildTypeAndKey('remove-from-board');
+export type RemoveImageFromBoardDndTargetData = DndData<
+ typeof _removeFromBoard.type,
+ typeof _removeFromBoard.key,
+ void
+>;
+export const removeImageFromBoardDndTarget: DndTarget<
+ RemoveImageFromBoardDndTargetData,
+ SingleImageDndSourceData | MultipleImageDndSourceData
+> = {
+ ..._removeFromBoard,
+ typeGuard: buildTypeGuard(_removeFromBoard.key),
+ getData: buildGetData(_removeFromBoard.key, _removeFromBoard.type),
+ isValid: ({ sourceData }) => {
+ if (singleImageDndSource.typeGuard(sourceData)) {
+ const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none';
+ return currentBoard !== 'none';
+ }
+
+ if (multipleImageDndSource.typeGuard(sourceData)) {
+ const currentBoard = sourceData.payload.boardId;
+ return currentBoard !== 'none';
+ }
+
+ return false;
+ },
+ handler: ({ sourceData, dispatch }) => {
+ if (singleImageDndSource.typeGuard(sourceData)) {
+ const { imageDTO } = sourceData.payload;
+ removeImagesFromBoard({ imageDTOs: [imageDTO], dispatch });
+ }
+
+ if (multipleImageDndSource.typeGuard(sourceData)) {
+ const { imageDTOs } = sourceData.payload;
+ removeImagesFromBoard({ imageDTOs, dispatch });
+ }
+ },
+};
+
+//#endregion
+
+export const dndTargets = [
+ // Single Image
+ setGlobalReferenceImageDndTarget,
+ setRegionalGuidanceReferenceImageDndTarget,
+ setUpscaleInitialImageDndTarget,
+ setNodeImageFieldImageDndTarget,
+ setComparisonImageDndTarget,
+ newCanvasEntityFromImageDndTarget,
+ replaceCanvasEntityObjectsWithImageDndTarget,
+ addImageToBoardDndTarget,
+ removeImageFromBoardDndTarget,
+ // Single or Multiple Image
+ addImageToBoardDndTarget,
+ removeImageFromBoardDndTarget,
+] as const;
+
+export type AnyDndTarget = (typeof dndTargets)[number];
diff --git a/invokeai/frontend/web/src/features/dnd/hooks/typesafeHooks.ts b/invokeai/frontend/web/src/features/dnd/hooks/typesafeHooks.ts
deleted file mode 100644
index 8c5a63cee3f..00000000000
--- a/invokeai/frontend/web/src/features/dnd/hooks/typesafeHooks.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useDraggable, useDroppable } from '@dnd-kit/core';
-import type {
- UseDraggableTypesafeArguments,
- UseDraggableTypesafeReturnValue,
- UseDroppableTypesafeArguments,
- UseDroppableTypesafeReturnValue,
-} from 'features/dnd/types';
-
-export function useDroppableTypesafe(props: UseDroppableTypesafeArguments) {
- return useDroppable(props) as UseDroppableTypesafeReturnValue;
-}
-
-export function useDraggableTypesafe(props: UseDraggableTypesafeArguments) {
- return useDraggable(props) as UseDraggableTypesafeReturnValue;
-}
diff --git a/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts b/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts
deleted file mode 100644
index da3a2a88b70..00000000000
--- a/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { Modifier } from '@dnd-kit/core';
-import { getEventCoordinates } from '@dnd-kit/utilities';
-import { useStore } from '@nanostores/react';
-import { useAppSelector } from 'app/store/storeHooks';
-import { $viewport } from 'features/nodes/store/nodesSlice';
-import { selectActiveTab } from 'features/ui/store/uiSelectors';
-import { useCallback } from 'react';
-
-/**
- * Applies scaling to the drag transform (if on node editor tab) and centers it on cursor.
- */
-export const useScaledModifer = () => {
- const activeTabName = useAppSelector(selectActiveTab);
- const workflowsViewport = useStore($viewport);
- const modifier: Modifier = useCallback(
- ({ activatorEvent, draggingNodeRect, transform }) => {
- if (draggingNodeRect && activatorEvent) {
- const zoom = activeTabName === 'workflows' ? workflowsViewport.zoom : 1;
- const activatorCoordinates = getEventCoordinates(activatorEvent);
-
- if (!activatorCoordinates) {
- return transform;
- }
-
- const offsetX = activatorCoordinates.x - draggingNodeRect.left;
- const offsetY = activatorCoordinates.y - draggingNodeRect.top;
-
- const x = transform.x + offsetX - draggingNodeRect.width / 2;
- const y = transform.y + offsetY - draggingNodeRect.height / 2;
- const scaleX = transform.scaleX * zoom;
- const scaleY = transform.scaleY * zoom;
-
- return {
- x,
- y,
- scaleX,
- scaleY,
- };
- }
-
- return transform;
- },
- [activeTabName, workflowsViewport.zoom]
- );
-
- return modifier;
-};
diff --git a/invokeai/frontend/web/src/features/dnd/types.ts b/invokeai/frontend/web/src/features/dnd/types.ts
new file mode 100644
index 00000000000..0ede75650ad
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/types.ts
@@ -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' };
diff --git a/invokeai/frontend/web/src/features/dnd/types/index.ts b/invokeai/frontend/web/src/features/dnd/types/index.ts
deleted file mode 100644
index 7742719079a..00000000000
--- a/invokeai/frontend/web/src/features/dnd/types/index.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-// type-safe dnd from https://github.com/clauderic/dnd-kit/issues/935
-import type {
- Active,
- Collision,
- DndContextProps,
- Over,
- Translate,
- useDraggable as useOriginalDraggable,
- UseDraggableArguments,
- useDroppable as useOriginalDroppable,
- UseDroppableArguments,
-} from '@dnd-kit/core';
-import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
-import type { BoardId } from 'features/gallery/store/types';
-import type { FieldInputInstance, FieldInputTemplate } from 'features/nodes/types/field';
-import type { ImageDTO } from 'services/api/types';
-
-type BaseDropData = {
- id: string;
-};
-
-export type IPAImageDropData = BaseDropData & {
- actionType: 'SET_IPA_IMAGE';
- context: {
- id: string;
- };
-};
-
-export type RGIPAdapterImageDropData = BaseDropData & {
- actionType: 'SET_RG_IP_ADAPTER_IMAGE';
- context: {
- id: string;
- referenceImageId: string;
- };
-};
-
-export type AddRasterLayerFromImageDropData = BaseDropData & {
- actionType: 'ADD_RASTER_LAYER_FROM_IMAGE';
-};
-
-export type AddControlLayerFromImageDropData = BaseDropData & {
- actionType: 'ADD_CONTROL_LAYER_FROM_IMAGE';
-};
-
-type AddInpaintMaskFromImageDropData = BaseDropData & {
- actionType: 'ADD_INPAINT_MASK_FROM_IMAGE';
-};
-
-type AddRegionalGuidanceFromImageDropData = BaseDropData & {
- actionType: 'ADD_REGIONAL_GUIDANCE_FROM_IMAGE';
-};
-
-export type AddRegionalReferenceImageFromImageDropData = BaseDropData & {
- actionType: 'ADD_REGIONAL_REFERENCE_IMAGE_FROM_IMAGE';
-};
-
-export type AddGlobalReferenceImageFromImageDropData = BaseDropData & {
- actionType: 'ADD_GLOBAL_REFERENCE_IMAGE_FROM_IMAGE';
-};
-
-export type ReplaceLayerImageDropData = BaseDropData & {
- actionType: 'REPLACE_LAYER_WITH_IMAGE';
- context: {
- entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>;
- };
-};
-
-type UpscaleInitialImageDropData = BaseDropData & {
- actionType: 'SET_UPSCALE_INITIAL_IMAGE';
-};
-
-type NodesImageDropData = BaseDropData & {
- actionType: 'SET_NODES_IMAGE';
- context: {
- nodeId: string;
- fieldName: string;
- };
-};
-
-export type AddToBoardDropData = BaseDropData & {
- actionType: 'ADD_TO_BOARD';
- context: { boardId: string };
-};
-
-export type RemoveFromBoardDropData = BaseDropData & {
- actionType: 'REMOVE_FROM_BOARD';
-};
-
-export type SelectForCompareDropData = BaseDropData & {
- actionType: 'SELECT_FOR_COMPARE';
- context: {
- firstImageName?: string | null;
- secondImageName?: string | null;
- };
-};
-
-export type TypesafeDroppableData =
- | NodesImageDropData
- | AddToBoardDropData
- | RemoveFromBoardDropData
- | IPAImageDropData
- | RGIPAdapterImageDropData
- | SelectForCompareDropData
- | UpscaleInitialImageDropData
- | AddRasterLayerFromImageDropData
- | AddControlLayerFromImageDropData
- | ReplaceLayerImageDropData
- | AddRegionalReferenceImageFromImageDropData
- | AddGlobalReferenceImageFromImageDropData
- | AddInpaintMaskFromImageDropData
- | AddRegionalGuidanceFromImageDropData;
-
-type BaseDragData = {
- id: string;
-};
-
-type NodeFieldDraggableData = BaseDragData & {
- payloadType: 'NODE_FIELD';
- payload: {
- nodeId: string;
- field: FieldInputInstance;
- fieldTemplate: FieldInputTemplate;
- };
-};
-
-export type ImageDraggableData = BaseDragData & {
- payloadType: 'IMAGE_DTO';
- payload: { imageDTO: ImageDTO };
-};
-
-export type GallerySelectionDraggableData = BaseDragData & {
- payloadType: 'GALLERY_SELECTION';
- payload: { boardId: BoardId };
-};
-
-export type TypesafeDraggableData = NodeFieldDraggableData | ImageDraggableData | GallerySelectionDraggableData;
-
-export interface UseDroppableTypesafeArguments extends Omit {
- data?: TypesafeDroppableData;
-}
-
-export type UseDroppableTypesafeReturnValue = Omit, 'active' | 'over'> & {
- active: TypesafeActive | null;
- over: TypesafeOver | null;
-};
-
-export interface UseDraggableTypesafeArguments extends Omit {
- data?: TypesafeDraggableData;
-}
-
-export type UseDraggableTypesafeReturnValue = Omit, 'active' | 'over'> & {
- active: TypesafeActive | null;
- over: TypesafeOver | null;
-};
-
-interface TypesafeActive extends Omit {
- data: React.MutableRefObject;
-}
-
-interface TypesafeOver extends Omit {
- data: React.MutableRefObject;
-}
-
-interface DragEvent {
- activatorEvent: Event;
- active: TypesafeActive;
- collisions: Collision[] | null;
- delta: Translate;
- over: TypesafeOver | null;
-}
-
-export interface DragStartEvent extends Pick {}
-interface DragMoveEvent extends DragEvent {}
-interface DragOverEvent extends DragMoveEvent {}
-export interface DragEndEvent extends DragEvent {}
-interface DragCancelEvent extends DragEndEvent {}
-
-export interface DndContextTypesafeProps
- extends Omit {
- onDragStart?(event: DragStartEvent): void;
- onDragMove?(event: DragMoveEvent): void;
- onDragOver?(event: DragOverEvent): void;
- onDragEnd?(event: DragEndEvent): void;
- onDragCancel?(event: DragCancelEvent): void;
-}
diff --git a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts
new file mode 100644
index 00000000000..24d6bea1680
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts
@@ -0,0 +1,66 @@
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+import { logger } from 'app/logging/logger';
+import { getStore } from 'app/store/nanostores/store';
+import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
+import { parseify } from 'common/util/serialize';
+import { dndTargets, multipleImageDndSource, singleImageDndSource } 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 (!singleImageDndSource.typeGuard(sourceData) && !multipleImageDndSource.typeGuard(sourceData)) {
+ return false;
+ }
+
+ return true;
+ },
+ onDrop: ({ source, location }) => {
+ const target = location.current.dropTargets[0];
+ if (!target) {
+ log.warn('No dnd target');
+ return;
+ }
+
+ const sourceData = source.data;
+ const targetData = target.data;
+
+ const { dispatch, getState } = getStore();
+
+ for (const dndTarget of dndTargets) {
+ if (!dndTarget.typeGuard(targetData)) {
+ continue;
+ }
+ const arg = { sourceData, targetData, dispatch, getState };
+ // TS cannot infer `arg.targetData` but we've just checked it.
+ // TODO(psyche): Figure out how to satisfy TS.
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ if (!dndTarget.isValid(arg as any)) {
+ continue;
+ }
+
+ log.debug(parseify({ sourceData, targetData }), 'Handling dnd drop');
+
+ // TS cannot infer `arg.targetData` but we've just checked it.
+ // TODO(psyche): Figure out how to satisfy TS.
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ dndTarget.handler(arg as any);
+ return;
+ }
+
+ log.warn(parseify({ sourceData, targetData }), 'Invalid drop');
+ },
+ })
+ );
+ }, []);
+};
diff --git a/invokeai/frontend/web/src/features/dnd/util.ts b/invokeai/frontend/web/src/features/dnd/util.ts
new file mode 100644
index 00000000000..f20614ad773
--- /dev/null
+++ b/invokeai/frontend/web/src/features/dnd/util.ts
@@ -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,
+ });
+}
diff --git a/invokeai/frontend/web/src/features/dnd/util/customPointerWithin.ts b/invokeai/frontend/web/src/features/dnd/util/customPointerWithin.ts
deleted file mode 100644
index 5de12483249..00000000000
--- a/invokeai/frontend/web/src/features/dnd/util/customPointerWithin.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { CollisionDetection } from '@dnd-kit/core';
-import { pointerWithin } from '@dnd-kit/core';
-
-/**
- * Filters out droppable elements that are overflowed, then applies the pointerWithin collision detection.
- *
- * Fixes collision detection firing on droppables that are not visible, having been scrolled out of view.
- *
- * See https://github.com/clauderic/dnd-kit/issues/1198
- */
-export const customPointerWithin: CollisionDetection = (arg) => {
- if (!arg.pointerCoordinates) {
- // sanity check
- return [];
- }
-
- // Get all elements at the pointer coordinates. This excludes elements which are overflowed,
- // so it won't include the droppable elements that are scrolled out of view.
- const targetElements = document.elementsFromPoint(arg.pointerCoordinates.x, arg.pointerCoordinates.y);
-
- const filteredDroppableContainers = arg.droppableContainers.filter((container) => {
- if (!container.node.current) {
- return false;
- }
- // Only include droppable elements that are in the list of elements at the pointer coordinates.
- return targetElements.includes(container.node.current);
- });
-
- // Run the provided collision detection with the filtered droppable elements.
- return pointerWithin({
- ...arg,
- droppableContainers: filteredDroppableContainers,
- });
-};
diff --git a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
deleted file mode 100644
index 8072d62c9df..00000000000
--- a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
-
-export const isValidDrop = (overData?: TypesafeDroppableData | null, activeData?: TypesafeDraggableData | null) => {
- if (!overData || !activeData) {
- return false;
- }
-
- const { actionType } = overData;
- const { payloadType } = activeData;
-
- if (overData.id === activeData.id) {
- return false;
- }
-
- switch (actionType) {
- case 'SET_IPA_IMAGE':
- case 'SET_RG_IP_ADAPTER_IMAGE':
- case 'ADD_RASTER_LAYER_FROM_IMAGE':
- case 'ADD_CONTROL_LAYER_FROM_IMAGE':
- case 'ADD_INPAINT_MASK_FROM_IMAGE':
- case 'ADD_REGIONAL_GUIDANCE_FROM_IMAGE':
- case 'SET_UPSCALE_INITIAL_IMAGE':
- case 'SET_NODES_IMAGE':
- case 'SELECT_FOR_COMPARE':
- case 'REPLACE_LAYER_WITH_IMAGE':
- case 'ADD_GLOBAL_REFERENCE_IMAGE_FROM_IMAGE':
- case 'ADD_REGIONAL_REFERENCE_IMAGE_FROM_IMAGE':
- return payloadType === 'IMAGE_DTO';
- case 'ADD_TO_BOARD': {
- // If the board is the same, don't allow the drop
-
- // Check the payload types
- const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(payloadType);
- if (!isPayloadValid) {
- return false;
- }
-
- // Check if the image's board is the board we are dragging onto
- if (payloadType === 'IMAGE_DTO') {
- const { imageDTO } = activeData.payload;
- const currentBoard = imageDTO.board_id ?? 'none';
- const destinationBoard = overData.context.boardId;
-
- return currentBoard !== destinationBoard;
- }
-
- if (payloadType === 'GALLERY_SELECTION') {
- // Assume all images are on the same board - this is true for the moment
- const currentBoard = activeData.payload.boardId;
- const destinationBoard = overData.context.boardId;
- return currentBoard !== destinationBoard;
- }
-
- return false;
- }
- case 'REMOVE_FROM_BOARD': {
- // If the board is the same, don't allow the drop
-
- // Check the payload types
- const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(payloadType);
- if (!isPayloadValid) {
- return false;
- }
-
- // Check if the image's board is the board we are dragging onto
- if (payloadType === 'IMAGE_DTO') {
- const { imageDTO } = activeData.payload;
- const currentBoard = imageDTO.board_id ?? 'none';
-
- return currentBoard !== 'none';
- }
-
- if (payloadType === 'GALLERY_SELECTION') {
- const currentBoard = activeData.payload.boardId;
- return currentBoard !== 'none';
- }
-
- return false;
- }
- default:
- return false;
- }
-};
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx
index d3932564313..ca944e8981e 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx
@@ -1,10 +1,14 @@
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
+import { autoScrollForExternal } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/external';
import { Box } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { selectAllowPrivateBoards } from 'features/system/store/configSelectors';
+import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
-import { memo } from 'react';
+import { memo, useEffect, useState } from 'react';
import { BoardsList } from './BoardsList';
@@ -15,11 +19,35 @@ const overlayScrollbarsStyles: CSSProperties = {
const BoardsListWrapper = () => {
const allowPrivateBoards = useAppSelector(selectAllowPrivateBoards);
+ const [os, osRef] = useState(null);
+ useEffect(() => {
+ const osInstance = os?.osInstance();
+
+ if (!osInstance) {
+ return;
+ }
+
+ const element = osInstance.elements().viewport;
+
+ // `pragmatic-drag-and-drop-auto-scroll` requires the element to have `overflow-y: scroll` or `overflow-y: auto`
+ // else it logs an ugly warning. In our case, using a custom scrollbar library, it will be 'hidden' by default.
+ // To prevent the erroneous warning, we temporarily set the overflow-y to 'scroll' and then revert it back.
+ const overflowY = element.style.overflowY; // starts 'hidden'
+ element.style.setProperty('overflow-y', 'scroll', 'important');
+ const cleanup = combine(autoScrollForElements({ element }), autoScrollForExternal({ element }));
+ element.style.setProperty('overflow-y', overflowY);
+
+ return cleanup;
+ }, [os]);
return (
-
+
{allowPrivateBoards && }
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx
index da5469d20f0..19c4a7d1858 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx
@@ -2,8 +2,9 @@ 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 IAIDroppable from 'common/components/IAIDroppable';
-import type { AddToBoardDropData } from 'features/dnd/types';
+import type { AddImageToBoardDndTargetData } from 'features/dnd/dnd';
+import { addImageToBoardDndTarget } 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';
import { BoardEditableTitle } from 'features/gallery/components/Boards/BoardsList/BoardEditableTitle';
@@ -35,7 +36,6 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick);
const selectedBoardId = useAppSelector(selectSelectedBoardId);
-
const onClick = useCallback(() => {
if (selectedBoardId !== board.board_id) {
dispatch(boardIdSelected({ boardId: board.board_id }));
@@ -45,12 +45,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
}
}, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]);
- const droppableData: AddToBoardDropData = useMemo(
- () => ({
- id: board.board_id,
- actionType: 'ADD_TO_BOARD',
- context: { boardId: board.board_id },
- }),
+ const dndTargetData = useMemo(
+ () => addImageToBoardDndTarget.getData({ boardId: board.board_id }),
[board.board_id]
);
@@ -85,7 +81,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
)}
-
+
);
};
diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx
index 8026320a975..2192c935241 100644
--- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx
@@ -1,8 +1,9 @@
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 IAIDroppable from 'common/components/IAIDroppable';
-import type { RemoveFromBoardDropData } from 'features/dnd/types';
+import type { RemoveImageFromBoardDndTargetData } from 'features/dnd/dnd';
+import { removeImageFromBoardDndTarget } 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';
import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardBoardContextMenu';
@@ -43,13 +44,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
}
}, [dispatch, autoAssignBoardOnClick]);
- const droppableData: RemoveFromBoardDropData = useMemo(
- () => ({
- id: 'no_board',
- actionType: 'REMOVE_FROM_BOARD',
- }),
- []
- );
+ const dndTargetData = useMemo(() => removeImageFromBoardDndTarget.getData(), []);
const { t } = useTranslation();
@@ -102,7 +97,11 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
)}
-
+
);
});
diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryUploadButton.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryUploadButton.tsx
index b0a7c25816e..ba9c87a90e6 100644
--- a/invokeai/frontend/web/src/features/gallery/components/GalleryUploadButton.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/GalleryUploadButton.tsx
@@ -3,13 +3,13 @@ import { useAppSelector } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { selectMaxImageUploadCount } from 'features/system/store/configSlice';
import { t } from 'i18next';
+import { useMemo } from 'react';
import { PiUploadBold } from 'react-icons/pi';
-const options = { postUploadAction: { type: 'TOAST' }, allowMultiple: true } as const;
-
export const GalleryUploadButton = () => {
- const uploadApi = useImageUploadButton(options);
const maxImageUploadCount = useAppSelector(selectMaxImageUploadCount);
+ const uploadOptions = useMemo(() => ({ allowMultiple: maxImageUploadCount !== 1 }), [maxImageUploadCount]);
+ const uploadApi = useImageUploadButton(uploadOptions);
return (
<>
{
* @param imageDTO The image DTO to register the context menu for.
* @param targetRef The ref of the target element that should trigger the context menu.
*/
-export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: RefObject) => {
+export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: HTMLDivElement | null) => {
useEffect(() => {
- if (!targetRef.current || !imageDTO) {
+ if (!targetRef || !imageDTO) {
return;
}
- const el = targetRef.current;
+ const el = targetRef;
elToImageMap.set(el, imageDTO);
return () => {
elToImageMap.delete(el);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx
index cec5f1c6a44..86cdcbc5382 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx
@@ -1,18 +1,12 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library';
-import { useAppDispatch } from 'app/store/storeHooks';
+import { useAppStore } from 'app/store/nanostores/store';
import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu';
import { NewLayerIcon } from 'features/controlLayers/components/common/icons';
-import {
- useNewCanvasFromImage,
- useNewControlLayerFromImage,
- useNewInpaintMaskFromImage,
- useNewRasterLayerFromImage,
- useNewRegionalGuidanceFromImage,
-} from 'features/controlLayers/hooks/addLayerHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { sentImageToCanvas } from 'features/gallery/store/actions';
+import { createNewCanvasEntityFromImage, newCanvasFromImage } from 'features/imageActions/actions';
import { toast } from 'features/toast/toast';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
@@ -22,18 +16,14 @@ import { PiFileBold, PiPlusBold } from 'react-icons/pi';
export const ImageMenuItemNewFromImageSubMenu = memo(() => {
const { t } = useTranslation();
const subMenu = useSubMenu();
- const dispatch = useAppDispatch();
+ const store = useAppStore();
const imageDTO = useImageDTOContext();
const imageViewer = useImageViewer();
const isBusy = useCanvasIsBusy();
- const newRasterLayerFromImage = useNewRasterLayerFromImage();
- const newControlLayerFromImage = useNewControlLayerFromImage();
- const newInpaintMaskFromImage = useNewInpaintMaskFromImage();
- const newRegionalGuidanceFromImage = useNewRegionalGuidanceFromImage();
- const newCanvasFromImage = useNewCanvasFromImage();
const onClickNewCanvasWithRasterLayerFromImage = useCallback(() => {
- newCanvasFromImage(imageDTO, 'raster_layer');
+ const { dispatch, getState } = store;
+ newCanvasFromImage({ imageDTO, type: 'raster_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
@@ -41,10 +31,11 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => {
title: t('toast.sentToCanvas'),
status: 'success',
});
- }, [dispatch, imageDTO, imageViewer, newCanvasFromImage, t]);
+ }, [imageDTO, imageViewer, store, t]);
const onClickNewCanvasWithControlLayerFromImage = useCallback(() => {
- newCanvasFromImage(imageDTO, 'control_layer');
+ const { dispatch, getState } = store;
+ newCanvasFromImage({ imageDTO, type: 'control_layer', dispatch, getState });
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
@@ -52,11 +43,12 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => {
title: t('toast.sentToCanvas'),
status: 'success',
});
- }, [dispatch, imageDTO, imageViewer, newCanvasFromImage, t]);
+ }, [imageDTO, imageViewer, store, t]);
const onClickNewRasterLayerFromImage = useCallback(() => {
+ const { dispatch, getState } = store;
+ createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState });
dispatch(sentImageToCanvas());
- newRasterLayerFromImage(imageDTO);
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
@@ -64,11 +56,12 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => {
title: t('toast.sentToCanvas'),
status: 'success',
});
- }, [dispatch, imageDTO, imageViewer, newRasterLayerFromImage, t]);
+ }, [imageDTO, imageViewer, store, t]);
const onClickNewControlLayerFromImage = useCallback(() => {
+ const { dispatch, getState } = store;
+ createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState });
dispatch(sentImageToCanvas());
- newControlLayerFromImage(imageDTO);
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
@@ -76,11 +69,12 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => {
title: t('toast.sentToCanvas'),
status: 'success',
});
- }, [dispatch, imageDTO, imageViewer, newControlLayerFromImage, t]);
+ }, [imageDTO, imageViewer, store, t]);
const onClickNewInpaintMaskFromImage = useCallback(() => {
+ const { dispatch, getState } = store;
+ createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState });
dispatch(sentImageToCanvas());
- newInpaintMaskFromImage(imageDTO);
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
@@ -88,11 +82,12 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => {
title: t('toast.sentToCanvas'),
status: 'success',
});
- }, [dispatch, imageDTO, imageViewer, newInpaintMaskFromImage, t]);
+ }, [imageDTO, imageViewer, store, t]);
const onClickNewRegionalGuidanceFromImage = useCallback(() => {
+ const { dispatch, getState } = store;
+ createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState });
dispatch(sentImageToCanvas());
- newRegionalGuidanceFromImage(imageDTO);
dispatch(setActiveTab('canvas'));
imageViewer.close();
toast({
@@ -100,7 +95,7 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => {
title: t('toast.sentToCanvas'),
status: 'success',
});
- }, [dispatch, imageDTO, imageViewer, newRegionalGuidanceFromImage, t]);
+ }, [imageDTO, imageViewer, store, t]);
return (
}>
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
index 191d466d6e7..b27025de898 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
@@ -1,286 +1,241 @@
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { Box, Flex, Text, useShiftModifier } from '@invoke-ai/ui-library';
-import { useStore } from '@nanostores/react';
+import { Box, Flex, Image } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
-import { $customStarUI } from 'app/store/nanostores/customStarUI';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import IAIDndImage from 'common/components/IAIDndImage';
-import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
-import IAIFillSkeleton from 'common/components/IAIFillSkeleton';
-import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
-import type { GallerySelectionDraggableData, ImageDraggableData, TypesafeDraggableData } from 'features/dnd/types';
+import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
+import { useAppStore } from 'app/store/nanostores/store';
+import { useAppSelector } from 'app/store/storeHooks';
+import { useBoolean } from 'common/hooks/useBoolean';
+import { multipleImageDndSource, singleImageDndSource } 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';
+import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
+import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
+import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
-import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
-import { useMultiselect } from 'features/gallery/hooks/useMultiselect';
-import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView';
-import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
+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 type { MouseEvent } from 'react';
-import { memo, useCallback, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { PiArrowsOutBold, PiStarBold, PiStarFill, PiTrashSimpleFill } from 'react-icons/pi';
-import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images';
+import type { MouseEventHandler } from 'react';
+import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react';
import type { ImageDTO } from 'services/api/types';
// This class name is used to calculate the number of images that fit in the gallery
-export const GALLERY_IMAGE_CLASS_NAME = 'gallery-image';
+export const GALLERY_IMAGE_CONTAINER_CLASS_NAME = 'gallery-image-container';
-const imageSx: SystemStyleObject = { w: 'full', h: 'full' };
-const boxSx: SystemStyleObject = {
+const galleryImageContainerSX = {
containerType: 'inline-size',
-};
-
-const badgeSx: SystemStyleObject = {
- '@container (max-width: 80px)': {
- '&': { display: 'none' },
+ w: 'full',
+ h: 'full',
+ '.gallery-image-size-badge': {
+ '@container (max-width: 80px)': {
+ '&': { display: 'none' },
+ },
+ },
+ '&[data-is-dragging=true]': {
+ opacity: 0.3,
+ },
+ '.gallery-image': {
+ touchAction: 'none',
+ userSelect: 'none',
+ webkitUserSelect: 'none',
+ position: 'relative',
+ justifyContent: 'center',
+ alignItems: 'center',
+ aspectRatio: '1/1',
+ '::before': {
+ content: '""',
+ display: 'inline-block',
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ pointerEvents: 'none',
+ borderRadius: 'base',
+ },
+ '&[data-selected=true]::before': {
+ boxShadow:
+ 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
+ },
+ '&[data-selected-for-compare=true]::before': {
+ boxShadow:
+ 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
+ },
+ '&:hover::before': {
+ boxShadow:
+ 'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)',
+ },
+ '&:hover[data-selected=true]::before': {
+ boxShadow:
+ 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
+ },
+ '&:hover[data-selected-for-compare=true]::before': {
+ boxShadow:
+ 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
+ },
},
-};
+} satisfies SystemStyleObject;
-interface HoverableImageProps {
+interface Props {
imageDTO: ImageDTO;
- index: number;
}
-const selectAlwaysShouldImageSizeBadge = createSelector(
- selectGallerySlice,
- (gallery) => gallery.alwaysShowImageSizeBadge
-);
-
-export const GalleryImage = memo(({ index, imageDTO }: HoverableImageProps) => {
- if (!imageDTO) {
- return ;
- }
-
- return ;
-});
-
-GalleryImage.displayName = 'GalleryImage';
-
-const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => {
- const dispatch = useAppDispatch();
- const selectedBoardId = useAppSelector(selectSelectedBoardId);
+export const GalleryImage = memo(({ imageDTO }: Props) => {
+ const store = useAppStore();
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPreviewState, setDragPreviewState] = useState<
+ DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null
+ >(null);
+ const [element, ref] = useState(null);
+ const dndId = useId();
const selectIsSelectedForCompare = useMemo(
() => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name),
[imageDTO.image_name]
);
const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare);
- const { handleClick, isSelected, areMultiplesSelected } = useMultiselect(imageDTO);
-
- const imageContainerRef = useScrollIntoView(isSelected, index, areMultiplesSelected);
-
- const draggableData = useMemo(() => {
- if (areMultiplesSelected) {
- const data: GallerySelectionDraggableData = {
- id: 'gallery-image',
- payloadType: 'GALLERY_SELECTION',
- payload: { boardId: selectedBoardId },
- };
- return data;
- }
+ const selectIsSelected = useMemo(
+ () =>
+ createSelector(selectGallerySlice, (gallery) => {
+ for (const selectedImage of gallery.selection) {
+ if (selectedImage.image_name === imageDTO.image_name) {
+ return true;
+ }
+ }
+ return false;
+ }),
+ [imageDTO.image_name]
+ );
+ const isSelected = useAppSelector(selectIsSelected);
- if (imageDTO) {
- const data: ImageDraggableData = {
- id: 'gallery-image',
- payloadType: 'IMAGE_DTO',
- payload: { imageDTO },
- };
- return data;
+ useEffect(() => {
+ if (!element) {
+ return;
}
- }, [imageDTO, selectedBoardId, areMultiplesSelected]);
-
- const [isHovered, setIsHovered] = useState(false);
-
- const handleMouseOver = useCallback(() => {
- setIsHovered(true);
- }, []);
-
- const imageViewer = useImageViewer();
- const onDoubleClick = useCallback(() => {
- imageViewer.open();
- dispatch(imageToCompareChanged(null));
- }, [dispatch, imageViewer]);
+ return combine(
+ draggable({
+ element,
+ getInitialData: () => {
+ const { gallery } = store.getState();
+ // 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 multipleImageDndSource.getData({
+ imageDTOs: gallery.selection,
+ boardId: gallery.selectedBoardId,
+ });
+ }
+
+ // Otherwise, initiate a single-image drag
+ return singleImageDndSource.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 (singleImageDndSource.typeGuard(source.data)) {
+ setIsDragging(true);
+ return;
+ }
+ },
+ onGenerateDragPreview: (args) => {
+ if (multipleImageDndSource.typeGuard(args.source.data)) {
+ setMultipleImageDragPreview({
+ multipleImageDndData: args.source.data,
+ onGenerateDragPreviewArgs: args,
+ setDragPreviewState,
+ });
+ } else if (singleImageDndSource.typeGuard(args.source.data)) {
+ setSingleImageDragPreview({
+ singleImageDndData: args.source.data,
+ onGenerateDragPreviewArgs: args,
+ setDragPreviewState,
+ });
+ }
+ },
+ }),
+ monitorForElements({
+ // This is a "global" drag start event, meaning that it is called for all drag events.
+ 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 (multipleImageDndSource.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) {
+ setIsDragging(true);
+ }
+ },
+ onDrop: () => {
+ // Always set the dragging state to false when a drop event occurs.
+ setIsDragging(false);
+ },
+ })
+ );
+ }, [imageDTO, element, store, dndId]);
+
+ const isHovered = useBoolean(false);
+
+ const onClick = useCallback>(
+ (e) => {
+ store.dispatch(
+ galleryImageClicked({
+ imageDTO,
+ shiftKey: e.shiftKey,
+ ctrlKey: e.ctrlKey,
+ metaKey: e.metaKey,
+ altKey: e.altKey,
+ })
+ );
+ },
+ [imageDTO, store]
+ );
- const handleMouseOut = useCallback(() => {
- setIsHovered(false);
- }, []);
+ const onDoubleClick = useCallback>(() => {
+ // Use the atom here directly instead of the `useImageViewer` to avoid re-rendering the gallery when the viewer
+ // opened state changes.
+ $imageViewer.set(true);
+ store.dispatch(imageToCompareChanged(null));
+ }, [store]);
const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]);
- if (!imageDTO) {
- return ;
- }
+ useImageContextMenu(imageDTO, element);
return (
-
-
+
-
-
-
-
-
- );
-});
-
-GalleryImageContent.displayName = 'GalleryImageContent';
-
-const HoverIcons = memo(({ imageDTO, isHovered }: { imageDTO: ImageDTO; isHovered: boolean }) => {
- const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
-
- return (
- <>
- {(isHovered || alwaysShowImageSizeBadge) && }
- {(isHovered || imageDTO.starred) && }
- {isHovered && }
- {isHovered && }
+ }
+ w={imageDTO.width}
+ objectFit="contain"
+ maxW="full"
+ maxH="full"
+ borderRadius="base"
+ />
+
+
+
+ {dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null}
+ {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null}
>
);
});
-HoverIcons.displayName = 'HoverIcons';
-const DeleteIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
- const shift = useShiftModifier();
- const { t } = useTranslation();
- const dispatch = useAppDispatch();
- const onClick = useCallback(
- (e: MouseEvent) => {
- e.stopPropagation();
- if (!imageDTO) {
- return;
- }
- dispatch(imagesToDeleteSelected([imageDTO]));
- },
- [dispatch, imageDTO]
- );
-
- if (!shift) {
- return null;
- }
-
- return (
- }
- tooltip={t('gallery.deleteImage_one')}
- position="absolute"
- bottom={2}
- insetInlineEnd={2}
- />
- );
-});
-
-DeleteIcon.displayName = 'DeleteIcon';
-
-const OpenInViewerIconButton = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
- const imageViewer = useImageViewer();
- const { t } = useTranslation();
-
- const onClick = useCallback(() => {
- imageViewer.openImageInViewer(imageDTO);
- }, [imageDTO, imageViewer]);
-
- return (
- }
- tooltip={t('gallery.openInViewer')}
- position="absolute"
- insetBlockStart={2}
- insetInlineStart={2}
- />
- );
-});
-
-OpenInViewerIconButton.displayName = 'OpenInViewerIconButton';
-
-const StarIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
- const customStarUi = useStore($customStarUI);
- const [starImages] = useStarImagesMutation();
- const [unstarImages] = useUnstarImagesMutation();
-
- const toggleStarredState = useCallback(() => {
- if (imageDTO) {
- if (imageDTO.starred) {
- unstarImages({ imageDTOs: [imageDTO] });
- }
- if (!imageDTO.starred) {
- starImages({ imageDTOs: [imageDTO] });
- }
- }
- }, [starImages, unstarImages, imageDTO]);
-
- const starIcon = useMemo(() => {
- if (imageDTO.starred) {
- return customStarUi ? customStarUi.on.icon : ;
- }
- if (!imageDTO.starred) {
- return customStarUi ? customStarUi.off.icon : ;
- }
- }, [imageDTO.starred, customStarUi]);
-
- const starTooltip = useMemo(() => {
- if (imageDTO.starred) {
- return customStarUi ? customStarUi.off.text : 'Unstar';
- }
- if (!imageDTO.starred) {
- return customStarUi ? customStarUi.on.text : 'Star';
- }
- return '';
- }, [imageDTO.starred, customStarUi]);
-
- return (
-
- );
-});
-
-StarIcon.displayName = 'StarIcon';
-
-const SizeBadge = memo(({ imageDTO }: { imageDTO: ImageDTO }) => {
- return (
- {`${imageDTO.width}x${imageDTO.height}`}
- );
-});
-
-SizeBadge.displayName = 'SizeBadge';
+GalleryImage.displayName = 'GalleryImage';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx
new file mode 100644
index 00000000000..93953381a9f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx
@@ -0,0 +1,46 @@
+import { useShiftModifier } from '@invoke-ai/ui-library';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice';
+import { DndImageIcon } from 'features/dnd/DndImageIcon';
+import type { MouseEvent } from 'react';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiTrashSimpleFill } from 'react-icons/pi';
+import type { ImageDTO } from 'services/api/types';
+
+type Props = {
+ imageDTO: ImageDTO;
+};
+
+export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => {
+ const shift = useShiftModifier();
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const onClick = useCallback(
+ (e: MouseEvent) => {
+ e.stopPropagation();
+ if (!imageDTO) {
+ return;
+ }
+ dispatch(imagesToDeleteSelected([imageDTO]));
+ },
+ [dispatch, imageDTO]
+ );
+
+ if (!shift) {
+ return null;
+ }
+
+ return (
+ }
+ tooltip={t('gallery.deleteImage_one')}
+ position="absolute"
+ bottom={2}
+ insetInlineEnd={2}
+ />
+ );
+});
+
+GalleryImageDeleteIconButton.displayName = 'GalleryImageDeleteIconButton';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx
index c9aa0a3b73d..76ace602b60 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx
@@ -1,11 +1,14 @@
import { Box, Flex, Grid } from '@invoke-ai/ui-library';
-import { skipToken } from '@reduxjs/toolkit/query';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { GallerySelectionCountTag } from 'features/gallery/components/ImageGrid/GallerySelectionCountTag';
import { useGalleryHotkeys } from 'features/gallery/hooks/useGalleryHotkeys';
-import { selectGalleryImageMinimumWidth, selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors';
+import {
+ selectGalleryImageMinimumWidth,
+ selectGalleryLimit,
+ selectListImagesQueryArgs,
+} from 'features/gallery/store/gallerySelectors';
import { limitChanged } from 'features/gallery/store/gallerySlice';
import { debounce } from 'lodash-es';
import { memo, useEffect, useMemo, useState } from 'react';
@@ -14,15 +17,15 @@ import { PiImageBold, PiWarningCircleBold } from 'react-icons/pi';
import { useListImagesQuery } from 'services/api/endpoints/images';
import { GALLERY_GRID_CLASS_NAME } from './constants';
-import { GALLERY_IMAGE_CLASS_NAME, GalleryImage } from './GalleryImage';
+import { GALLERY_IMAGE_CONTAINER_CLASS_NAME, GalleryImage } from './GalleryImage';
const GalleryImageGrid = () => {
useGalleryHotkeys();
const { t } = useTranslation();
const queryArgs = useAppSelector(selectListImagesQueryArgs);
- const { imageDTOs, isLoading, isError } = useListImagesQuery(queryArgs, {
+ const { hasImages, isLoading, isError } = useListImagesQuery(queryArgs, {
selectFromResult: ({ data, isLoading, isSuccess, isError }) => ({
- imageDTOs: data?.items ?? EMPTY_ARRAY,
+ hasImages: data && data.items.length > 0,
isLoading,
isSuccess,
isError,
@@ -45,7 +48,7 @@ const GalleryImageGrid = () => {
);
}
- if (imageDTOs.length === 0) {
+ if (!hasImages) {
return (
@@ -53,19 +56,16 @@ const GalleryImageGrid = () => {
);
}
- return ;
+ return ;
};
export default memo(GalleryImageGrid);
-const Content = () => {
+const GalleryImageGridContent = memo(() => {
const dispatch = useAppDispatch();
const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth);
+ const limit = useAppSelector(selectGalleryLimit);
- const queryArgs = useAppSelector(selectListImagesQueryArgs);
- const { imageDTOs } = useListImagesQuery(queryArgs, {
- selectFromResult: ({ data }) => ({ imageDTOs: data?.items ?? EMPTY_ARRAY }),
- });
// Use a callback ref to get reactivity on the container element because it is conditionally rendered
const [container, containerRef] = useState(null);
@@ -79,7 +79,7 @@ const Content = () => {
// Managing refs for dynamically rendered components is a bit tedious:
// - https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback
// As a easy workaround, we can just grab the first gallery image element directly.
- const imageEl = document.querySelector(`.${GALLERY_IMAGE_CLASS_NAME}`);
+ const imageEl = document.querySelector(`.${GALLERY_IMAGE_CONTAINER_CLASS_NAME}`);
if (!imageEl) {
// No images in gallery?
return;
@@ -130,19 +130,19 @@ const Content = () => {
}
// Always load at least 1 row of images
- const limit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn);
+ const newLimit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn);
- if (queryArgs === skipToken || queryArgs.limit === limit) {
+ if (limit === 0 || limit === newLimit) {
return;
}
- dispatch(limitChanged(limit));
+ dispatch(limitChanged(newLimit));
}, 300);
- }, [container, dispatch, queryArgs]);
+ }, [container, dispatch, limit]);
useEffect(() => {
// We want to recalculate the limit when image size changes
calculateNewLimit();
- }, [calculateNewLimit, galleryImageMinimumWidth, imageDTOs]);
+ }, [calculateNewLimit, galleryImageMinimumWidth]);
useEffect(() => {
if (!container) {
@@ -178,12 +178,27 @@ const Content = () => {
gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`}
gap={1}
>
- {imageDTOs.map((imageDTO, index) => (
-
- ))}
+
);
-};
+});
+
+GalleryImageGridContent.displayName = 'GalleryImageGridContent';
+
+const GalleryImageGridImages = memo(() => {
+ const queryArgs = useAppSelector(selectListImagesQueryArgs);
+ const { imageDTOs } = useListImagesQuery(queryArgs, {
+ selectFromResult: ({ data }) => ({ imageDTOs: data?.items ?? EMPTY_ARRAY }),
+ });
+ return (
+ <>
+ {imageDTOs.map((imageDTO) => (
+
+ ))}
+ >
+ );
+});
+GalleryImageGridImages.displayName = 'GalleryImageGridImages';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx
new file mode 100644
index 00000000000..dcaa5729d13
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx
@@ -0,0 +1,28 @@
+import { useAppSelector } from 'app/store/storeHooks';
+import { GalleryImageDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryImageDeleteIconButton';
+import { GalleryImageOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton';
+import { GalleryImageSizeBadge } from 'features/gallery/components/ImageGrid/GalleryImageSizeBadge';
+import { GalleryImageStarIconButton } from 'features/gallery/components/ImageGrid/GalleryImageStarIconButton';
+import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors';
+import { memo } from 'react';
+import type { ImageDTO } from 'services/api/types';
+
+type Props = {
+ imageDTO: ImageDTO;
+ isHovered: boolean;
+};
+
+export const GalleryImageHoverIcons = memo(({ imageDTO, isHovered }: Props) => {
+ const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
+
+ return (
+ <>
+ {(isHovered || alwaysShowImageSizeBadge) && }
+ {(isHovered || imageDTO.starred) && }
+ {isHovered && }
+ {isHovered && }
+ >
+ );
+});
+
+GalleryImageHoverIcons.displayName = 'GalleryImageHoverIcons';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx
new file mode 100644
index 00000000000..55c4a68e823
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx
@@ -0,0 +1,32 @@
+import { DndImageIcon } from 'features/dnd/DndImageIcon';
+import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiArrowsOutBold } from 'react-icons/pi';
+import type { ImageDTO } from 'services/api/types';
+
+type Props = {
+ imageDTO: ImageDTO;
+};
+
+export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => {
+ const imageViewer = useImageViewer();
+ const { t } = useTranslation();
+
+ const onClick = useCallback(() => {
+ imageViewer.openImageInViewer(imageDTO);
+ }, [imageDTO, imageViewer]);
+
+ return (
+ }
+ tooltip={t('gallery.openInViewer')}
+ position="absolute"
+ insetBlockStart={2}
+ insetInlineStart={2}
+ />
+ );
+});
+
+GalleryImageOpenInViewerIconButton.displayName = 'GalleryImageOpenInViewerIconButton';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx
new file mode 100644
index 00000000000..e7e473d86a6
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx
@@ -0,0 +1,29 @@
+import { Text } from '@invoke-ai/ui-library';
+import { memo } from 'react';
+import type { ImageDTO } from 'services/api/types';
+
+type Props = {
+ imageDTO: ImageDTO;
+};
+
+export const GalleryImageSizeBadge = memo(({ imageDTO }: Props) => {
+ return (
+ {`${imageDTO.width}x${imageDTO.height}`}
+ );
+});
+
+GalleryImageSizeBadge.displayName = 'GalleryImageSizeBadge';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx
new file mode 100644
index 00000000000..60eb4971068
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx
@@ -0,0 +1,51 @@
+import { useStore } from '@nanostores/react';
+import { $customStarUI } from 'app/store/nanostores/customStarUI';
+import { DndImageIcon } from 'features/dnd/DndImageIcon';
+import { memo, useCallback } from 'react';
+import { PiStarBold, PiStarFill } from 'react-icons/pi';
+import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images';
+import type { ImageDTO } from 'services/api/types';
+
+type Props = {
+ imageDTO: ImageDTO;
+};
+
+export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => {
+ const customStarUi = useStore($customStarUI);
+ const [starImages] = useStarImagesMutation();
+ const [unstarImages] = useUnstarImagesMutation();
+
+ const toggleStarredState = useCallback(() => {
+ if (imageDTO.starred) {
+ unstarImages({ imageDTOs: [imageDTO] });
+ } else {
+ starImages({ imageDTOs: [imageDTO] });
+ }
+ }, [starImages, unstarImages, imageDTO]);
+
+ if (customStarUi) {
+ return (
+
+ );
+ }
+
+ return (
+ : }
+ tooltip={imageDTO.starred ? 'Unstar' : 'Star'}
+ position="absolute"
+ top={2}
+ insetInlineEnd={2}
+ />
+ );
+});
+
+GalleryImageStarIconButton.displayName = 'GalleryImageStarIconButton';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx
index 3ccb475209d..eaca653b17c 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx
@@ -58,6 +58,13 @@ type PageButtonProps = {
};
const PageButton = memo(({ page, currentPage, goToPage }: PageButtonProps) => {
+ const onClick = useCallback(() => {
+ if (page === ELLIPSIS) {
+ return;
+ }
+ goToPage(page - 1);
+ }, [goToPage, page]);
+
if (page === ELLIPSIS) {
return (
);
-};
+});
+
+NoContentForViewer.displayName = 'NoContentForViewer';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx
index 31d454c2f47..5d552e57d99 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx
@@ -1,5 +1,4 @@
import { IconButton } from '@invoke-ai/ui-library';
-import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
@@ -8,16 +7,13 @@ import { setShouldShowImageDetails } from 'features/ui/store/uiSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiInfoBold } from 'react-icons/pi';
-import { useGetImageDTOQuery } from 'services/api/endpoints/images';
export const ToggleMetadataViewerButton = memo(() => {
const dispatch = useAppDispatch();
const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails);
- const lastSelectedImage = useAppSelector(selectLastSelectedImage);
+ const imageDTO = useAppSelector(selectLastSelectedImage);
const { t } = useTranslation();
- const { currentData: imageDTO } = useGetImageDTOQuery(lastSelectedImage?.image_name ?? skipToken);
-
const toggleMetadataViewer = useCallback(
() => dispatch(setShouldShowImageDetails(!shouldShowImageDetails)),
[dispatch, shouldShowImageDetails]
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts
index 7a9ba719881..d3ec86025e0 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/useImageViewer.ts
@@ -56,7 +56,6 @@ export const useImageViewer = () => {
open: imageViewerState.setTrue,
close,
toggle: imageViewerState.toggle,
- $state: $imageViewer,
openImageInViewer,
};
};
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts
index fddd3fd6ea0..41e3db8cc1c 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useGalleryNavigation.ts
@@ -2,7 +2,7 @@ import { useAltModifier } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { GALLERY_GRID_CLASS_NAME } from 'features/gallery/components/ImageGrid/constants';
-import { GALLERY_IMAGE_CLASS_NAME } from 'features/gallery/components/ImageGrid/GalleryImage';
+import { GALLERY_IMAGE_CONTAINER_CLASS_NAME } from 'features/gallery/components/ImageGrid/GalleryImage';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
@@ -29,7 +29,7 @@ import type { ImageDTO } from 'services/api/types';
* Gets the number of images per row in the gallery by grabbing their DOM elements.
*/
const getImagesPerRow = (): number => {
- const imageEl = document.querySelector(`.${GALLERY_IMAGE_CLASS_NAME}`);
+ const imageEl = document.querySelector(`.${GALLERY_IMAGE_CONTAINER_CLASS_NAME}`);
const gridEl = document.querySelector(`.${GALLERY_GRID_CLASS_NAME}`);
if (!imageEl || !gridEl) {
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts b/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts
deleted file mode 100644
index 56710697285..00000000000
--- a/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { createSelector } from '@reduxjs/toolkit';
-import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { selectHasMultipleImagesSelected } from 'features/gallery/store/gallerySelectors';
-import { selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice';
-import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
-import type { MouseEvent } from 'react';
-import { useCallback, useMemo } from 'react';
-import type { ImageDTO } from 'services/api/types';
-
-export const useMultiselect = (imageDTO?: ImageDTO) => {
- const dispatch = useAppDispatch();
- const areMultiplesSelected = useAppSelector(selectHasMultipleImagesSelected);
- const selectIsSelected = useMemo(
- () =>
- createSelector(selectGallerySlice, (gallery) =>
- gallery.selection.some((i) => i.image_name === imageDTO?.image_name)
- ),
- [imageDTO?.image_name]
- );
- const isSelected = useAppSelector(selectIsSelected);
- const isMultiSelectEnabled = useFeatureStatus('multiselect');
-
- const handleClick = useCallback(
- (e: MouseEvent) => {
- if (!imageDTO) {
- return;
- }
- if (!isMultiSelectEnabled) {
- dispatch(selectionChanged([imageDTO]));
- return;
- }
-
- dispatch(
- galleryImageClicked({
- imageDTO,
- shiftKey: e.shiftKey,
- ctrlKey: e.ctrlKey,
- metaKey: e.metaKey,
- altKey: e.altKey,
- })
- );
- },
- [dispatch, imageDTO, isMultiSelectEnabled]
- );
-
- return {
- areMultiplesSelected,
- isSelected,
- handleClick,
- };
-};
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts b/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts
deleted file mode 100644
index 9947a4d78ca..00000000000
--- a/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
-import { getIsVisible } from 'features/gallery/util/getIsVisible';
-import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
-import { useEffect, useRef } from 'react';
-
-/**
- * Scrolls an image into view when it is selected. This is necessary because
- * the image grid is virtualized, so the image may not be visible when it is
- * selected.
- *
- * Also handles when an image is selected programmatically - for example, when
- * auto-switching the new gallery images.
- *
- * @param isSelected Whether the image is selected.
- * @param index The index of the image in the gallery.
- * @param selectionCount The number of images selected.
- * @returns
- */
-export const useScrollIntoView = (isSelected: boolean, index: number, areMultiplesSelected: boolean) => {
- const imageContainerRef = useRef(null);
-
- useEffect(() => {
- if (!isSelected || areMultiplesSelected) {
- return;
- }
-
- const virtuosoContext = virtuosoGridRefs.get();
- const range = virtuosoContext.virtuosoRangeRef?.current;
- const root = virtuosoContext.rootRef?.current;
- const virtuoso = virtuosoContext.virtuosoRef?.current;
-
- if (!range || !virtuoso || !root) {
- return;
- }
-
- const itemRect = imageContainerRef.current?.getBoundingClientRect();
- const rootRect = root.getBoundingClientRect();
-
- if (!itemRect || !getIsVisible(itemRect, rootRect)) {
- virtuoso.scrollToIndex({
- index,
- align: getScrollToIndexAlign(index, range),
- });
- }
- }, [isSelected, index, areMultiplesSelected]);
-
- return imageContainerRef;
-};
diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts
index b4cebd5eab5..6a5ff113512 100644
--- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts
@@ -6,12 +6,14 @@ import { selectGallerySlice } from 'features/gallery/store/gallerySlice';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import type { ListBoardsArgs, ListImagesArgs } from 'services/api/types';
-export const selectLastSelectedImage = createSelector(
+export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0));
+export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1));
+export const selectLastSelectedImageName = createSelector(
selectGallerySlice,
- (gallery) => gallery.selection[gallery.selection.length - 1]
+ (gallery) => gallery.selection.at(-1)?.image_name
);
-export const selectLastSelectedImageName = createSelector(selectLastSelectedImage, (image) => image?.image_name);
+export const selectGalleryLimit = createSelector(selectGallerySlice, (gallery) => gallery.limit);
export const selectListImagesQueryArgs = createMemoizedSelector(
selectGallerySlice,
(gallery): ListImagesArgs | SkipToken =>
@@ -50,7 +52,7 @@ export const selectBoardsListOrderBy = createSelector(selectGallerySlice, (galle
export const selectBoardsListOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.boardsListOrderDir);
export const selectSelectionCount = createSelector(selectGallerySlice, (gallery) => gallery.selection.length);
-export const selectHasMultipleImagesSelected = createSelector(selectSelectionCount, (count) => count > 1);
+export const selectSelection = createSelector(selectGallerySlice, (gallery) => gallery.selection);
export const selectGalleryImageMinimumWidth = createSelector(
selectGallerySlice,
(gallery) => gallery.galleryImageMinimumWidth
@@ -59,6 +61,8 @@ export const selectGalleryImageMinimumWidth = createSelector(
export const selectComparisonMode = createSelector(selectGallerySlice, (gallery) => gallery.comparisonMode);
export const selectComparisonFit = createSelector(selectGallerySlice, (gallery) => gallery.comparisonFit);
export const selectImageToCompare = createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare);
-export const selectHasImageToCompare = createSelector(selectImageToCompare, (imageToCompare) =>
- Boolean(imageToCompare)
+export const selectHasImageToCompare = createSelector(selectGallerySlice, (gallery) => Boolean(gallery.imageToCompare));
+export const selectAlwaysShouldImageSizeBadge = createSelector(
+ selectGallerySlice,
+ (gallery) => gallery.alwaysShowImageSizeBadge
);
diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts
new file mode 100644
index 00000000000..d1b24005014
--- /dev/null
+++ b/invokeai/frontend/web/src/features/imageActions/actions.ts
@@ -0,0 +1,276 @@
+import type { AppDispatch, RootState } from 'app/store/store';
+import { deepClone } from 'common/util/deepClone';
+import { selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks';
+import { CanvasEntityAdapterBase } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase';
+import { getPrefixedId } from 'features/controlLayers/konva/util';
+import { canvasReset } from 'features/controlLayers/store/actions';
+import {
+ bboxChangedFromCanvas,
+ controlLayerAdded,
+ entityRasterized,
+ inpaintMaskAdded,
+ rasterLayerAdded,
+ referenceImageAdded,
+ referenceImageIPAdapterImageChanged,
+ rgAdded,
+ rgIPAdapterImageChanged,
+} from 'features/controlLayers/store/canvasSlice';
+import { selectBboxModelBase, selectBboxRect } from 'features/controlLayers/store/selectors';
+import type {
+ CanvasControlLayerState,
+ CanvasEntityIdentifier,
+ CanvasEntityType,
+ CanvasInpaintMaskState,
+ CanvasRasterLayerState,
+ CanvasRegionalGuidanceState,
+ CanvasRenderableEntityIdentifier,
+} from 'features/controlLayers/store/types';
+import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } from 'features/controlLayers/store/util';
+import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions';
+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 { getOptimalDimension } from 'features/parameters/util/optimalDimension';
+import { imagesApi } from 'services/api/endpoints/images';
+import type { ImageDTO } from 'services/api/types';
+import type { Equals } from 'tsafe';
+import { assert } from 'tsafe';
+
+export const setGlobalReferenceImage = (arg: {
+ imageDTO: ImageDTO;
+ entityIdentifier: CanvasEntityIdentifier<'reference_image'>;
+ dispatch: AppDispatch;
+}) => {
+ const { imageDTO, entityIdentifier, dispatch } = arg;
+ dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO }));
+};
+
+export const setRegionalGuidanceReferenceImage = (arg: {
+ imageDTO: ImageDTO;
+ entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>;
+ referenceImageId: string;
+ dispatch: AppDispatch;
+}) => {
+ const { imageDTO, entityIdentifier, referenceImageId, dispatch } = arg;
+ dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO }));
+};
+
+export const setUpscaleInitialImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => {
+ const { imageDTO, dispatch } = arg;
+ dispatch(upscaleInitialImageChanged(imageDTO));
+};
+
+export const setNodeImageFieldImage = (arg: {
+ imageDTO: ImageDTO;
+ fieldIdentifer: FieldIdentifier;
+ dispatch: AppDispatch;
+}) => {
+ const { imageDTO, fieldIdentifer, dispatch } = arg;
+ dispatch(fieldImageValueChanged({ ...fieldIdentifer, value: imageDTO }));
+};
+
+export const setComparisonImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => {
+ const { imageDTO, dispatch } = arg;
+ dispatch(imageToCompareChanged(imageDTO));
+};
+
+export const createNewCanvasEntityFromImage = (arg: {
+ imageDTO: ImageDTO;
+ type: CanvasEntityType | 'regional_guidance_with_reference_image';
+ dispatch: AppDispatch;
+ getState: () => RootState;
+}) => {
+ const { type, imageDTO, dispatch, getState } = arg;
+ 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': {
+ dispatch(
+ controlLayerAdded({
+ overrides: { ...overrides, controlAdapter: deepClone(initialControlNet) },
+ 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 }));
+ break;
+ }
+ }
+};
+
+/**
+ * Creates a new canvas with the given image as the initial image, replicating the img2img flow:
+ * - Reset the canvas
+ * - Resize the bbox to the image's aspect ratio at the optimal size for the selected model
+ * - Add the image as a raster layer
+ * - Resizes the layer to fit the bbox using the 'fill' strategy
+ *
+ * This allows the user to immediately generate a new image from the given image without any additional steps.
+ */
+export const newCanvasFromImage = (arg: {
+ imageDTO: ImageDTO;
+ type: CanvasEntityType | 'regional_guidance_with_reference_image';
+ dispatch: AppDispatch;
+ getState: () => RootState;
+}) => {
+ const { type, imageDTO, dispatch, getState } = arg;
+ const state = getState();
+
+ const base = selectBboxModelBase(state);
+ // Calculate the new bbox dimensions to fit the image's aspect ratio at the optimal size
+ const ratio = imageDTO.width / imageDTO.height;
+ const optimalDimension = getOptimalDimension(base);
+ const { width, height } = calculateNewSize(ratio, optimalDimension ** 2, base);
+
+ const imageObject = imageDTOToImageObject(imageDTO);
+ const { x, y } = selectBboxRect(state);
+
+ const addInitCallback = (id: string) => {
+ CanvasEntityAdapterBase.registerInitCallback(async (adapter) => {
+ // Skip the callback if the adapter is not the one we are creating
+ if (adapter.id !== id) {
+ return false;
+ }
+ // Fit the layer to the bbox w/ fill strategy
+ await adapter.transformer.startTransform({ silent: true });
+ adapter.transformer.fitToBboxFill();
+ await adapter.transformer.applyTransform();
+ return true;
+ });
+ };
+
+ switch (type) {
+ case 'raster_layer': {
+ const overrides = {
+ id: getPrefixedId('raster_layer'),
+ objects: [imageObject],
+ position: { x, y },
+ } satisfies Partial;
+ addInitCallback(overrides.id);
+ dispatch(canvasReset());
+ // The `bboxChangedFromCanvas` reducer does no validation! Careful!
+ dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height }));
+ dispatch(rasterLayerAdded({ overrides, isSelected: true }));
+ break;
+ }
+ case 'control_layer': {
+ const overrides = {
+ id: getPrefixedId('control_layer'),
+ objects: [imageObject],
+ position: { x, y },
+ controlAdapter: deepClone(initialControlNet),
+ } satisfies Partial;
+ addInitCallback(overrides.id);
+ dispatch(canvasReset());
+ // The `bboxChangedFromCanvas` reducer does no validation! Careful!
+ dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height }));
+ dispatch(controlLayerAdded({ overrides, isSelected: true }));
+ break;
+ }
+ case 'inpaint_mask': {
+ const overrides = {
+ id: getPrefixedId('inpaint_mask'),
+ objects: [imageObject],
+ position: { x, y },
+ } satisfies Partial;
+ addInitCallback(overrides.id);
+ dispatch(canvasReset());
+ // The `bboxChangedFromCanvas` reducer does no validation! Careful!
+ dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height }));
+ dispatch(inpaintMaskAdded({ overrides, isSelected: true }));
+ break;
+ }
+ case 'regional_guidance': {
+ const overrides = {
+ id: getPrefixedId('regional_guidance'),
+ objects: [imageObject],
+ position: { x, y },
+ } satisfies Partial;
+ addInitCallback(overrides.id);
+ dispatch(canvasReset());
+ // The `bboxChangedFromCanvas` reducer does no validation! Careful!
+ dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height }));
+ dispatch(rgAdded({ overrides, isSelected: true }));
+ break;
+ }
+ case 'reference_image': {
+ const ipAdapter = selectDefaultIPAdapter(getState());
+ ipAdapter.image = imageDTOToImageWithDims(imageDTO);
+ dispatch(canvasReset());
+ 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(canvasReset());
+ dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true }));
+ break;
+ }
+ default:
+ assert>(false);
+ }
+};
+
+export const replaceCanvasEntityObjectsWithImage = (arg: {
+ imageDTO: ImageDTO;
+ entityIdentifier: CanvasRenderableEntityIdentifier;
+ dispatch: AppDispatch;
+ getState: () => RootState;
+}) => {
+ const { imageDTO, entityIdentifier, dispatch, getState } = arg;
+ const imageObject = imageDTOToImageObject(imageDTO);
+ const { x, y } = selectBboxRect(getState());
+ dispatch(
+ entityRasterized({
+ entityIdentifier,
+ imageObject,
+ position: { x, y },
+ replaceObjects: true,
+ isSelected: true,
+ })
+ );
+};
+
+export const addImagesToBoard = (arg: { imageDTOs: ImageDTO[]; boardId: BoardId; dispatch: AppDispatch }) => {
+ const { imageDTOs, boardId, dispatch } = arg;
+ dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false }));
+ dispatch(selectionChanged([]));
+};
+
+export const removeImagesFromBoard = (arg: { imageDTOs: ImageDTO[]; dispatch: AppDispatch }) => {
+ const { imageDTOs, dispatch } = arg;
+ dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false }));
+ dispatch(selectionChanged([]));
+};
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelImageUpload.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelImageUpload.tsx
index 292835a7b7c..4d2a3fe3200 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelImageUpload.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/Fields/ModelImageUpload.tsx
@@ -1,10 +1,10 @@
-import { Box, Button, Flex, Icon, IconButton, Image, Tooltip } from '@invoke-ai/ui-library';
+import { Box, IconButton, Image } from '@invoke-ai/ui-library';
import { typedMemo } from 'common/util/typedMemo';
import { toast } from 'features/toast/toast';
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
-import { PiArrowCounterClockwiseBold, PiUploadSimpleBold } from 'react-icons/pi';
+import { PiArrowCounterClockwiseBold, PiUploadBold } from 'react-icons/pi';
import { useDeleteModelImageMutation, useUpdateModelImageMutation } from 'services/api/endpoints/models';
type Props = {
@@ -16,7 +16,7 @@ const ModelImageUpload = ({ model_key, model_image }: Props) => {
const [image, setImage] = useState(model_image || null);
const { t } = useTranslation();
- const [updateModelImage] = useUpdateModelImageMutation();
+ const [updateModelImage, request] = useUpdateModelImageMutation();
const [deleteModelImage] = useDeleteModelImageMutation();
const onDropAccepted = useCallback(
@@ -107,21 +107,17 @@ const ModelImageUpload = ({ model_key, model_image }: Props) => {
return (
<>
-
-
-
-
-
+ }
+ isLoading={request.isLoading}
+ {...getRootProps()}
+ />
>
);
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx
index 23607e6e89f..f7587252569 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx
@@ -1,8 +1,8 @@
import { Flex, Image, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
-import IAIDndImage from 'common/components/IAIDndImage';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
+import { DndImage } from 'features/dnd/DndImage';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
@@ -30,7 +30,7 @@ const CurrentImageNode = (props: NodeProps) => {
if (imageDTO) {
return (
-
+
);
}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx
index ef466b28826..a6d36c00389 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx
@@ -1,109 +1,114 @@
-import { useSortable } from '@dnd-kit/sortable';
-import { CSS } from '@dnd-kit/utilities';
-import { Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Box, Circle, Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
-import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay';
+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 } from 'react';
+import type { FieldIdentifier } from 'features/nodes/types/field';
+import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import { PiArrowCounterClockwiseBold, PiDotsSixVerticalBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
+import { PiArrowCounterClockwiseBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
import EditableFieldTitle from './EditableFieldTitle';
import FieldTooltipContent from './FieldTooltipContent';
import InputFieldRenderer from './InputFieldRenderer';
type Props = {
- nodeId: string;
- fieldName: string;
+ fieldIdentifier: FieldIdentifier;
};
-const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => {
+const sx = {
+ layerStyle: 'second',
+ alignItems: 'center',
+ position: 'relative',
+ borderRadius: 'base',
+ w: 'full',
+ p: 2,
+ '&[data-is-dragging=true]': {
+ opacity: 0.3,
+ },
+ transitionProperty: 'common',
+} satisfies SystemStyleObject;
+
+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]);
-
- const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: `${nodeId}.${fieldName}` });
+ dispatch(workflowExposedFieldRemoved(fieldIdentifier));
+ }, [dispatch, fieldIdentifier]);
- const style = {
- transform: CSS.Translate.toString(transform),
- transition,
- };
+ const ref = useRef(null);
+ const [dndListState, isDragging] = useLinearViewFieldDnd(ref, fieldIdentifier);
return (
-
- }
- {...listeners}
- {...attributes}
- mx={2}
- height="full"
- />
-
-
-
-
- {isValueChanged && (
+
+
+
+
+
+
+ {isMouseOverNode && }
+ {isValueChanged && (
+ }
+ />
+ )}
+
+ }
+ openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
+ placement="top"
+ >
+
+
+
+
}
+ onClick={handleRemoveField}
+ icon={}
/>
- )}
- }
- openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
- placement="top"
- >
-
-
-
-
- }
- />
+
+
-
-
-
+
+
);
};
-const LinearViewField = ({ nodeId, fieldName }: Props) => {
+const LinearViewField = ({ fieldIdentifier }: Props) => {
return (
-
-
+
+
);
};
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx
index c5539d902b4..8935d284fa9 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx
@@ -2,26 +2,29 @@ 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 IAIDndImage from 'common/components/IAIDndImage';
-import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
-import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
+import { UploadImageButton } from 'common/hooks/useImageUploadButton';
+import type { SetNodeImageFieldImageDndTargetData } from 'features/dnd/dnd';
+import { setNodeImageFieldImageDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
+import { DndImage } from 'features/dnd/DndImage';
+import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
-import type { PostUploadAction } from 'services/api/types';
+import type { ImageDTO } from 'services/api/types';
import { $isConnected } from 'services/events/stores';
import type { FieldComponentProps } from './types';
const ImageFieldInputComponent = (props: FieldComponentProps) => {
+ const { t } = useTranslation();
const { nodeId, field, fieldTemplate } = props;
const dispatch = useAppDispatch();
const isConnected = useStore($isConnected);
const { currentData: imageDTO, isError } = useGetImageDTOQuery(field.value?.image_name ?? skipToken);
-
const handleReset = useCallback(() => {
dispatch(
fieldImageValueChanged({
@@ -32,32 +35,13 @@ const ImageFieldInputComponent = (props: FieldComponentProps(() => {
- if (imageDTO) {
- return {
- id: `node-${nodeId}-${field.name}`,
- payloadType: 'IMAGE_DTO',
- payload: { imageDTO },
- };
- }
- }, [field.name, imageDTO, nodeId]);
-
- const droppableData = useMemo(
- () => ({
- id: `node-${nodeId}-${field.name}`,
- actionType: 'SET_NODES_IMAGE',
- context: { nodeId, fieldName: field.name },
- }),
- [field.name, nodeId]
- );
-
- const postUploadAction = useMemo(
- () => ({
- type: 'SET_NODES_IMAGE',
- nodeId,
- fieldName: field.name,
- }),
- [nodeId, field.name]
+ const dndTargetData = useMemo(
+ () =>
+ setNodeImageFieldImageDndTarget.getData(
+ { fieldIdentifer: { nodeId, fieldName: field.name } },
+ field.value?.image_name
+ ),
+ [field, nodeId]
);
useEffect(() => {
@@ -66,33 +50,55 @@ const ImageFieldInputComponent = (props: FieldComponentProps {
+ dispatch(
+ fieldImageValueChanged({
+ nodeId,
+ fieldName: field.name,
+ value: imageDTO,
+ })
+ );
+ },
+ [dispatch, field.name, nodeId]
+ );
+
return (
- }
- minSize={8}
- >
- : undefined}
- tooltip="Reset Image"
+ {!imageDTO && (
+
-
+ )}
+ {imageDTO && (
+ <>
+
+
+ : undefined}
+ tooltip="Reset Image"
+ />
+
+ >
+ )}
+
);
};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/outputs/ImageOutputPreview.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/outputs/ImageOutputPreview.tsx
index e2a808be6f9..e91a389a813 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/outputs/ImageOutputPreview.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/outputs/ImageOutputPreview.tsx
@@ -1,4 +1,4 @@
-import IAIDndImage from 'common/components/IAIDndImage';
+import { DndImage } from 'features/dnd/DndImage';
import { memo } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { ImageOutput } from 'services/api/types';
@@ -9,9 +9,12 @@ type Props = {
const ImageOutputPreview = ({ output }: Props) => {
const { image } = output;
- const { data: imageDTO } = useGetImageDTOQuery(image.image_name);
+ const { currentData: imageDTO } = useGetImageDTOQuery(image.image_name);
+ if (!imageDTO) {
+ return null;
+ }
- return ;
+ return ;
};
export default memo(ImageOutputPreview);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLinearTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLinearTab.tsx
index 9b0e5bb9d6c..2547b9a7749 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLinearTab.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLinearTab.tsx
@@ -1,66 +1,155 @@
-import { arrayMove } from '@dnd-kit/sortable';
+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 { Box, Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
-import DndSortable from 'features/dnd/components/DndSortable';
-import type { DragEndEvent } from 'features/dnd/types';
+import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
+import { deepClone } from 'common/util/deepClone';
+import { singleWorkflowFieldDndSource } from 'features/dnd/dnd';
+import { triggerPostMoveFlash } from 'features/dnd/util';
import LinearViewFieldInternal from 'features/nodes/components/flow/nodes/Invocation/fields/LinearViewField';
import { selectWorkflowSlice, workflowExposedFieldsReordered } from 'features/nodes/store/workflowSlice';
import type { FieldIdentifier } from 'features/nodes/types/field';
-import { memo, useCallback, useMemo } from 'react';
+import { isEqual } from 'lodash-es';
+import { memo, useEffect } from 'react';
+import { flushSync } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => workflow.exposedFields);
const WorkflowLinearTab = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default memo(WorkflowLinearTab);
+
+const FieldListContent = memo(() => {
const fields = useAppSelector(selector);
const { isLoading } = useGetOpenAPISchemaQuery();
const { t } = useTranslation();
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (fields.length === 0) {
+ ;
+ }
+
+ return ;
+});
+
+FieldListContent.displayName = 'FieldListContent';
+
+const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) => {
const dispatch = useAppDispatch();
- const handleDragEnd = useCallback(
- (event: DragEndEvent) => {
- const { active, over } = event;
- const fieldsStrings = fields.map((field) => `${field.nodeId}.${field.fieldName}`);
+ useEffect(() => {
+ return monitorForElements({
+ canMonitor({ source }) {
+ if (!singleWorkflowFieldDndSource.typeGuard(source.data)) {
+ return false;
+ }
+ return true;
+ },
+ onDrop({ location, source }) {
+ const target = location.current.dropTargets[0];
+ if (!target) {
+ return;
+ }
- if (over && active.id !== over.id) {
- const oldIndex = fieldsStrings.indexOf(active.id as string);
- const newIndex = fieldsStrings.indexOf(over.id as string);
+ const sourceData = source.data;
+ const targetData = target.data;
- const newFields = arrayMove(fieldsStrings, oldIndex, newIndex)
- .map((field) => fields.find((obj) => `${obj.nodeId}.${obj.fieldName}` === field))
- .filter((field) => field) as FieldIdentifier[];
+ if (
+ !singleWorkflowFieldDndSource.typeGuard(sourceData) ||
+ !singleWorkflowFieldDndSource.typeGuard(targetData)
+ ) {
+ return;
+ }
- dispatch(workflowExposedFieldsReordered(newFields));
- }
- },
- [dispatch, fields]
- );
+ const fieldsClone = deepClone(fields);
- const items = useMemo(() => fields.map((field) => `${field.nodeId}.${field.fieldName}`), [fields]);
+ const indexOfSource = fieldsClone.findIndex((fieldIdentifier) =>
+ isEqual(fieldIdentifier, sourceData.payload.fieldIdentifier)
+ );
+ const indexOfTarget = fieldsClone.findIndex((fieldIdentifier) =>
+ isEqual(fieldIdentifier, targetData.payload.fieldIdentifier)
+ );
+
+ if (indexOfTarget < 0 || indexOfSource < 0) {
+ return;
+ }
+
+ // Don't move if the source and target are the same index, meaning same position in the list
+ if (indexOfSource === indexOfTarget) {
+ return;
+ }
+
+ const closestEdgeOfTarget = extractClosestEdge(targetData);
+
+ // It's possible that the indices are different, but refer to the same position. For example, if the source is
+ // at 2 and the target is at 3, but the target edge is 'top', then the entity is already in the correct position.
+ // We should bail if this is the case.
+ let edgeIndexDelta = 0;
+
+ if (closestEdgeOfTarget === 'bottom') {
+ edgeIndexDelta = 1;
+ } else if (closestEdgeOfTarget === 'top') {
+ edgeIndexDelta = -1;
+ }
+
+ // If the source is already in the correct position, we don't need to move it.
+ if (indexOfSource === indexOfTarget + edgeIndexDelta) {
+ return;
+ }
+
+ const reorderedFields = reorderWithEdge({
+ list: fieldsClone,
+ startIndex: indexOfSource,
+ indexOfTarget,
+ closestEdgeOfTarget,
+ axis: 'vertical',
+ });
+
+ // Using `flushSync` so we can query the DOM straight after this line
+ flushSync(() => {
+ dispatch(workflowExposedFieldsReordered(reorderedFields));
+ });
+
+ // Flash the element that was moved
+ const element = document.querySelector(
+ `[data-field-name="${sourceData.payload.fieldIdentifier.nodeId}-${sourceData.payload.fieldIdentifier.fieldName}"]`
+ );
+ if (element instanceof HTMLElement) {
+ triggerPostMoveFlash(element, colorTokenToCssVar('base.700'));
+ }
+ },
+ });
+ }, [dispatch, fields]);
return (
-
-
-
-
- {isLoading ? (
-
- ) : fields.length ? (
- fields.map(({ nodeId, fieldName }) => (
-
- ))
- ) : (
-
- )}
-
-
-
-
+ <>
+ {fields.map((fieldIdentifier) => (
+
+ ))}
+ >
);
-};
+});
-export default memo(WorkflowLinearTab);
+FieldListInnerContent.displayName = 'FieldListInnerContent';
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts
new file mode 100644
index 00000000000..745c8ecdd33
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts
@@ -0,0 +1,81 @@
+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 { singleWorkflowFieldDndSource } from 'features/dnd/dnd';
+import type { DndListTargetState } from 'features/dnd/types';
+import { idle } from 'features/dnd/types';
+import type { FieldIdentifier } from 'features/nodes/types/field';
+import type { RefObject } from 'react';
+import { useEffect, useState } from 'react';
+
+export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifier: FieldIdentifier) => {
+ const [dndListState, setListDndState] = useState(idle);
+ const [isDragging, setIsDragging] = useState(false);
+
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) {
+ return;
+ }
+ return combine(
+ draggable({
+ element,
+ getInitialData() {
+ return singleWorkflowFieldDndSource.getData({ fieldIdentifier });
+ },
+ onDragStart() {
+ setListDndState({ type: 'is-dragging' });
+ setIsDragging(true);
+ },
+ onDrop() {
+ setListDndState(idle);
+ setIsDragging(false);
+ },
+ }),
+ dropTargetForElements({
+ element,
+ canDrop({ source }) {
+ if (!singleWorkflowFieldDndSource.typeGuard(source.data)) {
+ return false;
+ }
+ return true;
+ },
+ getData({ input }) {
+ const data = singleWorkflowFieldDndSource.getData({ fieldIdentifier });
+ return attachClosestEdge(data, {
+ element,
+ input,
+ allowedEdges: ['top', 'bottom'],
+ });
+ },
+ getIsSticky() {
+ return true;
+ },
+ onDragEnter({ self }) {
+ const closestEdge = extractClosestEdge(self.data);
+ setListDndState({ 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.
+ setListDndState((current) => {
+ if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
+ return current;
+ }
+ return { type: 'is-dragging-over', closestEdge };
+ });
+ },
+ onDragLeave() {
+ setListDndState(idle);
+ },
+ onDrop() {
+ setListDndState(idle);
+ },
+ })
+ );
+ }, [fieldIdentifier, ref]);
+
+ return [dndListState, isDragging] as const;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts
index e412aee77a0..a09b341c286 100644
--- a/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts
@@ -1,5 +1,4 @@
import { logger } from 'app/logging/logger';
-import type { SerializableObject } from 'common/types';
import { deepClone } from 'common/util/deepClone';
import { parseify } from 'common/util/serialize';
import type { Templates } from 'features/nodes/store/types';
@@ -21,6 +20,7 @@ import { t } from 'i18next';
import { isEqual, reduce } from 'lodash-es';
import type { OpenAPIV3_1 } from 'openapi-types';
import { serializeError } from 'serialize-error';
+import type { JsonObject } from 'type-fest';
import { buildFieldInputTemplate } from './buildFieldInputTemplate';
import { buildFieldOutputTemplate } from './buildFieldOutputTemplate';
@@ -89,7 +89,7 @@ export const parseSchema = (
(inputsAccumulator: Record, property, propertyName) => {
if (isReservedInputField(type, propertyName)) {
log.trace(
- { node: type, field: propertyName, schema: property } as SerializableObject,
+ { node: type, field: propertyName, schema: property } as JsonObject,
'Skipped reserved input field'
);
return inputsAccumulator;
diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts
index 39a1b03aa69..801f7d1fbf4 100644
--- a/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts
@@ -1,4 +1,3 @@
-import type { SerializableObject } from 'common/types';
import { parseify } from 'common/util/serialize';
import type { Templates } from 'features/nodes/store/types';
import {
@@ -11,13 +10,14 @@ import { isWorkflowInvocationNode } from 'features/nodes/types/workflow';
import { getNeedsUpdate } from 'features/nodes/util/node/nodeUpdate';
import { t } from 'i18next';
import { keyBy } from 'lodash-es';
+import type { JsonObject } from 'type-fest';
import { parseAndMigrateWorkflow } from './migrations';
type WorkflowWarning = {
message: string;
issues?: string[];
- data: SerializableObject;
+ data: JsonObject;
};
type ValidateWorkflowResult = {
diff --git a/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx b/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx
index 702dca504b7..f4de748c91b 100644
--- a/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx
+++ b/invokeai/frontend/web/src/features/queue/components/SendToToggle.tsx
@@ -18,7 +18,7 @@ import {
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectSendToCanvas, settingsSendToCanvasChanged } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice';
-import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
+import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
import type { ChangeEvent, PropsWithChildren } from 'react';
import { memo, useCallback, useMemo } from 'react';
@@ -142,7 +142,7 @@ export const SendToToggle = memo(() => {
transitionDuration="0.2s"
/>
-
+
@@ -150,10 +150,12 @@ export const SendToToggle = memo(() => {
);
});
-SendToToggle.displayName = 'CanvasSendToToggle';
+SendToToggle.displayName = 'SendToToggle';
-const TooltipContent = memo(({ sendToCanvas, isStaging }: { sendToCanvas: boolean; isStaging: boolean }) => {
+const TooltipContent = memo(() => {
const { t } = useTranslation();
+ const sendToCanvas = useAppSelector(selectSendToCanvas);
+ const isStaging = useAppSelector(selectIsStaging);
if (isStaging) {
return (
@@ -180,14 +182,13 @@ const TooltipContent = memo(({ sendToCanvas, isStaging }: { sendToCanvas: boolea
TooltipContent.displayName = 'TooltipContent';
-const ActivateCanvasButton = (props: PropsWithChildren) => {
+const ActivateCanvasButton = memo((props: PropsWithChildren) => {
const dispatch = useAppDispatch();
- const imageViewer = useImageViewer();
const onClick = useCallback(() => {
dispatch(setActiveTab('canvas'));
dispatch(activeTabCanvasRightPanelChanged('layers'));
- imageViewer.close();
- }, [dispatch, imageViewer]);
+ $imageViewer.set(false);
+ }, [dispatch]);
return (
{
{props.children}
);
-};
+});
+
+ActivateCanvasButton.displayName = 'ActivateCanvasButton';
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx
index b4bacc1ad05..b27b7b2de4b 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx
@@ -1,30 +1,22 @@
import { Flex, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import IAIDndImage from 'common/components/IAIDndImage';
-import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
-import type { TypesafeDroppableData } from 'features/dnd/types';
+import { UploadImageButton } from 'common/hooks/useImageUploadButton';
+import type { SetUpscaleInitialImageDndTargetData } from 'features/dnd/dnd';
+import { setUpscaleInitialImageDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
+import { DndImage } from 'features/dnd/DndImage';
+import { DndImageIcon } from 'features/dnd/DndImageIcon';
import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import { t } from 'i18next';
import { useCallback, useMemo } from 'react';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
-import type { PostUploadAction } from 'services/api/types';
+import type { ImageDTO } from 'services/api/types';
export const UpscaleInitialImage = () => {
const dispatch = useAppDispatch();
const imageDTO = useAppSelector(selectUpscaleInitialImage);
-
- const droppableData = useMemo(
- () => ({
- actionType: 'SET_UPSCALE_INITIAL_IMAGE',
- id: 'upscale-intial-image',
- }),
- []
- );
-
- const postUploadAction = useMemo(
- () => ({
- type: 'SET_UPSCALE_INITIAL_IMAGE',
- }),
+ const dndTargetData = useMemo(
+ () => setUpscaleInitialImageDndTarget.getData(),
[]
);
@@ -32,18 +24,22 @@ export const UpscaleInitialImage = () => {
dispatch(upscaleInitialImageChanged(null));
}, [dispatch]);
+ const onUpload = useCallback(
+ (imageDTO: ImageDTO) => {
+ dispatch(upscaleInitialImageChanged(imageDTO));
+ },
+ [dispatch]
+ );
+
return (
-
+ {!imageDTO && }
{imageDTO && (
<>
+
- }
tooltip={t('common.reset')}
@@ -66,6 +62,11 @@ export const UpscaleInitialImage = () => {
>{`${imageDTO.width}x${imageDTO.height}`}
>
)}
+
);
diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx
index a22cd95d9e4..d624175fd0d 100644
--- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx
+++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx
@@ -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(null);
+ useDndMonitor();
const withLeftPanel = useAppSelector(selectWithLeftPanel);
const leftPanelUsePanelOptions = useMemo(
diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts
index 215808dd01d..c830eddf61d 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/images.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts
@@ -1,6 +1,5 @@
import type { StartQueryActionCreatorOptions } from '@reduxjs/toolkit/dist/query/core/buildInitiate';
import { getStore } from 'app/store/nanostores/store';
-import type { SerializableObject } from 'common/types';
import type { BoardId } from 'features/gallery/store/types';
import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types';
import type { components, paths } from 'services/api/schema';
@@ -11,9 +10,9 @@ import type {
ImageDTO,
ListImagesArgs,
ListImagesResponse,
- PostUploadAction,
} from 'services/api/types';
import { getCategories, getListImagesUrl } from 'services/api/util';
+import type { JsonObject } from 'type-fest';
import type { ApiTagDescription } from '..';
import { api, buildV1Url, LIST_TAG } from '..';
@@ -76,7 +75,7 @@ export const imagesApi = api.injectEndpoints({
query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}`) }),
providesTags: (result, error, image_name) => [{ type: 'Image', id: image_name }],
}),
- getImageMetadata: build.query({
+ getImageMetadata: build.query({
query: (image_name) => ({ url: buildImagesUrl(`i/${image_name}/metadata`) }),
providesTags: (result, error, image_name) => [{ type: 'ImageMetadata', id: image_name }],
}),
@@ -267,11 +266,10 @@ export const imagesApi = api.injectEndpoints({
file: File;
image_category: ImageCategory;
is_intermediate: boolean;
- postUploadAction?: PostUploadAction;
session_id?: string;
board_id?: string;
crop_visible?: boolean;
- metadata?: SerializableObject;
+ metadata?: JsonObject;
isFirstUploadOfBatch?: boolean;
}
>({
@@ -614,7 +612,7 @@ export const getImageDTO = (image_name: string, options?: StartQueryActionCreato
export const getImageMetadata = (
image_name: string,
options?: StartQueryActionCreatorOptions
-): Promise => {
+): Promise => {
const _options = {
subscribe: false,
...options,
@@ -623,30 +621,60 @@ export const getImageMetadata = (
return req.unwrap();
};
-export type UploadOptions = {
- blob: Blob;
- fileName: string;
+export type UploadImageArg = {
+ file: File;
image_category: ImageCategory;
is_intermediate: boolean;
+ session_id?: string;
+ board_id?: string;
crop_visible?: boolean;
- board_id?: BoardId;
- metadata?: SerializableObject;
+ metadata?: JsonObject;
};
-export const uploadImage = (arg: UploadOptions): Promise => {
- const { blob, fileName, image_category, is_intermediate, crop_visible = false, board_id, metadata } = arg;
+
+export const uploadImage = (arg: UploadImageArg): Promise => {
+ const { file, image_category, is_intermediate, crop_visible = false, board_id, metadata, session_id } = arg;
const { dispatch } = getStore();
- const file = new File([blob], fileName, { type: 'image/png' });
+
const req = dispatch(
- imagesApi.endpoints.uploadImage.initiate({
- file,
- image_category,
- is_intermediate,
- crop_visible,
- board_id,
- metadata,
- })
+ imagesApi.endpoints.uploadImage.initiate(
+ {
+ file,
+ image_category,
+ is_intermediate,
+ crop_visible,
+ board_id,
+ metadata,
+ session_id,
+ },
+ { track: false }
+ )
);
- req.reset();
return req.unwrap();
};
+
+export const uploadImages = async (args: UploadImageArg[]): Promise => {
+ const { dispatch } = getStore();
+ const results = await Promise.allSettled(
+ args.map((arg, i) => {
+ const { file, image_category, is_intermediate, crop_visible = false, board_id, metadata, session_id } = arg;
+ const req = dispatch(
+ imagesApi.endpoints.uploadImage.initiate(
+ {
+ file,
+ image_category,
+ is_intermediate,
+ crop_visible,
+ board_id,
+ metadata,
+ session_id,
+ isFirstUploadOfBatch: i === 0,
+ },
+ { track: false }
+ )
+ );
+ return req.unwrap();
+ })
+ );
+ return results.filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled').map((r) => r.value);
+};
diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts
index 8f35f636dfb..9273606cbbe 100644
--- a/invokeai/frontend/web/src/services/api/types.ts
+++ b/invokeai/frontend/web/src/services/api/types.ts
@@ -1,4 +1,3 @@
-import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import type { components, paths } from 'services/api/schema';
import type { SetRequired } from 'type-fest';
@@ -281,50 +280,6 @@ export type OutputFields = Extract<
// Node Outputs
export type ImageOutput = S['ImageOutput'];
-export type IPALayerImagePostUploadAction = {
- type: 'SET_IPA_IMAGE';
- id: string;
-};
-
-export type RGIPAdapterImagePostUploadAction = {
- type: 'SET_RG_IP_ADAPTER_IMAGE';
- id: string;
- referenceImageId: string;
-};
-
-type NodesAction = {
- type: 'SET_NODES_IMAGE';
- nodeId: string;
- fieldName: string;
-};
-
-type UpscaleInitialImageAction = {
- type: 'SET_UPSCALE_INITIAL_IMAGE';
-};
-
-type ToastAction = {
- type: 'TOAST';
- title?: string;
-};
-
-type AddToBatchAction = {
- type: 'ADD_TO_BATCH';
-};
-
-type ReplaceLayerWithImagePostUploadAction = {
- type: 'REPLACE_LAYER_WITH_IMAGE';
- entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer'>;
-};
-
-export type PostUploadAction =
- | NodesAction
- | ToastAction
- | AddToBatchAction
- | IPALayerImagePostUploadAction
- | RGIPAdapterImagePostUploadAction
- | UpscaleInitialImageAction
- | ReplaceLayerWithImagePostUploadAction;
-
export type BoardRecordOrderBy = S['BoardRecordOrderBy'];
export type StarterModel = S['StarterModel'];
diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx
index 617528e2258..70b127aa41c 100644
--- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx
+++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx
@@ -1,6 +1,5 @@
import { logger } from 'app/logging/logger';
import type { AppDispatch, RootState } from 'app/store/store';
-import type { SerializableObject } from 'common/types';
import { deepClone } from 'common/util/deepClone';
import { stagingAreaImageStaged } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice';
@@ -12,6 +11,7 @@ import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO, S } from 'services/api/types';
import { getCategories, getListImagesUrl } from 'services/api/util';
import { $lastProgressEvent } from 'services/events/stores';
+import type { JsonObject } from 'type-fest';
const log = logger('events');
@@ -144,10 +144,7 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A
};
return async (data: S['InvocationCompleteEvent']) => {
- log.debug(
- { data } as SerializableObject,
- `Invocation complete (${data.invocation.type}, ${data.invocation_source_id})`
- );
+ log.debug({ data } as JsonObject, `Invocation complete (${data.invocation.type}, ${data.invocation_source_id})`);
if (data.origin === 'workflows') {
await handleOriginWorkflows(data);
diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
index d181ebb0a35..ac1de8c4be6 100644
--- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx
+++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx
@@ -5,7 +5,6 @@ import { $baseUrl } from 'app/store/nanostores/baseUrl';
import { $bulkDownloadId } from 'app/store/nanostores/bulkDownloadId';
import { $queueId } from 'app/store/nanostores/queueId';
import type { AppStore } from 'app/store/store';
-import type { SerializableObject } from 'common/types';
import { deepClone } from 'common/util/deepClone';
import { $isHFForbiddenToastOpen } from 'features/modelManagerV2/hooks/useHFForbiddenToast';
import { $isHFLoginToastOpen } from 'features/modelManagerV2/hooks/useHFLoginToast';
@@ -22,6 +21,7 @@ import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue';
import { buildOnInvocationComplete } from 'services/events/onInvocationComplete';
import type { ClientToServerEvents, ServerToClientEvents } from 'services/events/types';
import type { Socket } from 'socket.io-client';
+import type { JsonObject } from 'type-fest';
import { $lastProgressEvent } from './stores';
@@ -76,7 +76,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
socket.on('invocation_started', (data) => {
const { invocation_source_id, invocation } = data;
- log.debug({ data } as SerializableObject, `Invocation started (${invocation.type}, ${invocation_source_id})`);
+ log.debug({ data } as JsonObject, `Invocation started (${invocation.type}, ${invocation_source_id})`);
const nes = deepClone($nodeExecutionStates.get()[invocation_source_id]);
if (nes) {
nes.status = zNodeStatus.enum.IN_PROGRESS;
@@ -96,7 +96,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
}
_message += ` (${invocation.type}, ${invocation_source_id})`;
- log.trace({ data } as SerializableObject, _message);
+ log.trace({ data } as JsonObject, _message);
$lastProgressEvent.set(data);
@@ -113,7 +113,7 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
socket.on('invocation_error', (data) => {
const { invocation_source_id, invocation, error_type, error_message, error_traceback } = data;
- log.error({ data } as SerializableObject, `Invocation error (${invocation.type}, ${invocation_source_id})`);
+ log.error({ data } as JsonObject, `Invocation error (${invocation.type}, ${invocation_source_id})`);
const nes = deepClone($nodeExecutionStates.get()[invocation_source_id]);
if (nes) {
nes.status = zNodeStatus.enum.FAILED;