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'); * ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx index bb2fe8ff29a..0ab0f17894b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySearch.tsx @@ -2,7 +2,7 @@ import { IconButton, Input, InputGroup, InputRightElement, Spinner } from '@invo import { useAppSelector } from 'app/store/storeHooks'; import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; import type { ChangeEvent, KeyboardEvent } from 'react'; -import { useCallback } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiXBold } from 'react-icons/pi'; import { useListImagesQuery } from 'services/api/endpoints/images'; @@ -13,7 +13,7 @@ type Props = { onResetSearchTerm: () => void; }; -export const GallerySearch = ({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => { +export const GallerySearch = memo(({ searchTerm, onChangeSearchTerm, onResetSearchTerm }: Props) => { const { t } = useTranslation(); const queryArgs = useAppSelector(selectListImagesQueryArgs); const { isPending } = useListImagesQuery(queryArgs, { @@ -64,4 +64,6 @@ export const GallerySearch = ({ searchTerm, onChangeSearchTerm, onResetSearchTer )} ); -}; +}); + +GallerySearch.displayName = 'GallerySearch'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index 01fe12d520d..1ee42cf5cbc 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -2,15 +2,19 @@ import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages'; +import { + selectFirstSelectedImage, + selectSelection, + selectSelectionCount, +} from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ImageDTO } from 'services/api/types'; export const GallerySelectionCountTag = memo(() => { const dispatch = useAppDispatch(); - const { selection } = useAppSelector((s) => s.gallery); + const selection = useAppSelector(selectSelection); const { imageDTOs } = useGalleryImages(); const isGalleryFocused = useIsRegionFocused('gallery'); @@ -30,30 +34,29 @@ export const GallerySelectionCountTag = memo(() => { return null; } - return ; + return ; }); GallerySelectionCountTag.displayName = 'GallerySelectionCountTag'; -const GallerySelectionCountTagContent = memo(({ selection }: { selection: ImageDTO[] }) => { +const GallerySelectionCountTagContent = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const isGalleryFocused = useIsRegionFocused('gallery'); - + const firstImage = useAppSelector(selectFirstSelectedImage); + const selectionCount = useAppSelector(selectSelectionCount); const onClearSelection = useCallback(() => { - const firstImage = selection[0]; if (firstImage) { dispatch(selectionChanged([firstImage])); } - }, [dispatch, selection]); + }, [dispatch, firstImage]); useRegisteredHotkeys({ id: 'clearSelection', category: 'gallery', callback: onClearSelection, - options: { enabled: selection.length > 0 && isGalleryFocused }, - dependencies: [onClearSelection, selection, isGalleryFocused], + options: { enabled: selectionCount > 0 && isGalleryFocused }, + dependencies: [onClearSelection, selectionCount, isGalleryFocused], }); return ( @@ -71,7 +74,7 @@ const GallerySelectionCountTagContent = memo(({ selection }: { selection: ImageD borderColor="whiteAlpha.300" > - {selection.length} {t('common.selected')} + {selectionCount} {t('common.selected')} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx new file mode 100644 index 00000000000..82c4a52d145 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@invoke-ai/ui-library'; +import { memo } from 'react'; + +type Props = { + width: number; + height: number; +}; + +export const SizedSkeletonLoader = memo(({ width, height }: Props) => { + return ; +}); + +SizedSkeletonLoader.displayName = 'SizedSkeletonLoader'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index 6da19a5d6ba..9d6ac1efdf2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -2,17 +2,17 @@ import { Box, Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; -import IAIDndImage from 'common/components/IAIDndImage'; import { CanvasAlertsSendingToCanvas } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo'; -import type { TypesafeDraggableData } from 'features/dnd/types'; +import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import { selectLastSelectedImageName } from 'features/gallery/store/gallerySelectors'; import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; -import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; import { $hasProgressImage, $isProgressFromCanvas } from 'services/events/stores'; import { NoContentForViewer } from './NoContentForViewer'; @@ -21,22 +21,9 @@ import ProgressImage from './ProgressImage'; const CurrentImagePreview = () => { const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); const imageName = useAppSelector(selectLastSelectedImageName); - const hasProgressImage = useStore($hasProgressImage); - const isProgressFromCanvas = useStore($isProgressFromCanvas); - const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken); - const draggableData = useMemo(() => { - if (imageDTO) { - return { - id: 'current-image', - payloadType: 'IMAGE_DTO', - payload: { imageDTO }, - }; - } - }, [imageDTO]); - // Show and hide the next/prev buttons on mouse move const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState(false); const timeoutId = useRef(0); @@ -60,20 +47,7 @@ const CurrentImagePreview = () => { justifyContent="center" position="relative" > - {hasProgressImage && !isProgressFromCanvas && shouldShowProgressInViewer ? ( - - ) : ( - } - dataTestId="image-preview" - /> - )} + @@ -107,6 +81,27 @@ const CurrentImagePreview = () => { export default memo(CurrentImagePreview); +const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { + const hasProgressImage = useStore($hasProgressImage); + const isProgressFromCanvas = useStore($isProgressFromCanvas); + const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); + + if (hasProgressImage && !isProgressFromCanvas && shouldShowProgressInViewer) { + return ; + } + + if (!imageDTO) { + return ; + } + + return ( + + + + ); +}); +ImageContent.displayName = 'ImageContent'; + const initial: AnimationProps['initial'] = { opacity: 0, }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx index 2998c7d7253..f351cd8b74c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx @@ -1,31 +1,19 @@ -import { Flex } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import IAIDroppable from 'common/components/IAIDroppable'; -import type { SelectForCompareDropData } from 'features/dnd/types'; +import type { SetComparisonImageDndTargetData } from 'features/dnd/dnd'; +import { setComparisonImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { selectComparisonImages } from './common'; - export const ImageComparisonDroppable = memo(() => { const { t } = useTranslation(); - const { firstImage, secondImage } = useAppSelector(selectComparisonImages); - const selectForCompareDropData = useMemo( - () => ({ - id: 'image-comparison', - actionType: 'SELECT_FOR_COMPARE', - context: { - firstImageName: firstImage?.image_name, - secondImageName: secondImage?.image_name, - }, - }), - [firstImage?.image_name, secondImage?.image_name] - ); + const dndTargetData = useMemo(() => setComparisonImageDndTarget.getData(), []); return ( - - - + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx index faca5c1396e..7ce28ae494f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx @@ -7,12 +7,12 @@ import { $installModelsTab } from 'features/modelManagerV2/subpanels/InstallMode import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { selectIsLocal } from 'features/system/store/configSlice'; import { setActiveTab } from 'features/ui/store/uiSlice'; -import { useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { PiImageBold } from 'react-icons/pi'; import { useMainModels } from 'services/api/hooks/modelsByType'; -export const NoContentForViewer = () => { +export const NoContentForViewer = memo(() => { const hasImages = useHasImages(); const [mainModels, { data }] = useMainModels(); const isLocal = useAppSelector(selectIsLocal); @@ -113,4 +113,6 @@ export const NoContentForViewer = () => { ); -}; +}); + +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 ( ); -}; +}); + +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;