From 248447337fe77866c9bb513b466f657107f4b66c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 27 Oct 2024 12:59:10 +1000 Subject: [PATCH 01/39] feat(ui): migrate to `pragmatic-drag-and-drop` (wip) --- invokeai/frontend/web/package.json | 1 + invokeai/frontend/web/pnpm-lock.yaml | 19 ++ .../middleware/listenerMiddleware/index.ts | 2 + .../listenerMiddleware/listeners/dnd.ts | 303 ++++++++++++++++++ .../src/common/components/DraggableImage.tsx | 0 .../web/src/common/components/IAIDndImage.tsx | 4 +- .../src/common/components/IAIDroppable.tsx | 96 +++++- .../components/CanvasDropArea.tsx | 62 ++-- .../components/CanvasRightPanel.tsx | 169 +++++++--- .../components/ControlLayer/ControlLayer.tsx | 13 +- .../components/RasterLayer/RasterLayer.tsx | 13 +- .../web/src/features/dnd2/DndDropOverlay.tsx | 68 ++++ .../web/src/features/dnd2/DndDropTarget.tsx | 94 ++++++ .../frontend/web/src/features/dnd2/types.ts | 297 +++++++++++++++++ .../Boards/BoardsList/GalleryBoard.tsx | 15 +- .../Boards/BoardsList/NoBoardBoard.tsx | 15 +- .../ImageContextMenu/ImageContextMenu.tsx | 8 +- .../components/ImageGrid/GalleryImage.tsx | 269 ++++++++++------ .../components/ImageGrid/GalleryImageGrid.tsx | 4 +- .../ImageViewer/ImageComparisonDroppable.tsx | 29 +- .../gallery/hooks/useGalleryNavigation.ts | 4 +- .../features/gallery/hooks/useMultiselect.ts | 13 +- .../gallery/hooks/useScrollIntoView.ts | 16 +- 23 files changed, 1259 insertions(+), 255 deletions(-) create mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts create mode 100644 invokeai/frontend/web/src/common/components/DraggableImage.tsx create mode 100644 invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx create mode 100644 invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx create mode 100644 invokeai/frontend/web/src/features/dnd2/types.ts diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 6e0a6441fae..0b902b048da 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -52,6 +52,7 @@ } }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.4.0", "@dagrejs/dagre": "^1.1.4", "@dagrejs/graphlib": "^2.2.4", "@dnd-kit/core": "^6.1.0", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 9178b76d1e4..433442c295f 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@atlaskit/pragmatic-drag-and-drop': + specifier: ^1.4.0 + version: 1.4.0 '@dagrejs/dagre': specifier: ^1.1.4 version: 1.1.4 @@ -319,6 +322,14 @@ packages: '@jridgewell/trace-mapping': 0.3.25 dev: true + /@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'} @@ -4313,6 +4324,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 +7572,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/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index a0a6604ddf8..98edff0575e 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -9,6 +9,7 @@ import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddlewar import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted'; import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected'; import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload'; +import { addDndDroppedListener } from 'app/store/middleware/listenerMiddleware/listeners/dnd'; import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear'; import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes'; import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; @@ -95,6 +96,7 @@ addUpdateAllNodesRequestedListener(startAppListening); // DND addImageDroppedListener(startAppListening); +addDndDroppedListener(startAppListening); // Models addModelSelectedListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts new file mode 100644 index 00000000000..3905be2b1dc --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts @@ -0,0 +1,303 @@ +import { createAction } from '@reduxjs/toolkit'; +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { deepClone } from 'common/util/deepClone'; +import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { + controlLayerAdded, + entityRasterized, + entitySelected, + inpaintMaskAdded, + rasterLayerAdded, + referenceImageAdded, + referenceImageIPAdapterImageChanged, + rgAdded, + rgIPAdapterImageChanged, +} from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import type { + CanvasControlLayerState, + CanvasInpaintMaskState, + CanvasRasterLayerState, + CanvasReferenceImageState, + CanvasRegionalGuidanceState, +} from 'features/controlLayers/store/types'; +import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { + addControlLayerFromImageDndTarget, + addGlobalReferenceImageFromImageDndTarget, + addInpaintMaskFromImageDndTarget, + addRasterLayerFromImageDndTarget, + addRegionalGuidanceFromImageDndTarget, + addRegionalGuidanceReferenceImageFromImageDndTarget, + addToBoardDndTarget, + type DndSourceData, + type DndTargetData, + multipleImageDndSource, + removeFromBoardDndTarget, + replaceLayerWithImageDndTarget, + selectForCompareDndTarget, + setGlobalReferenceImageDndTarget, + setNodeImageFieldDndTarget, + setRegionalGuidanceReferenceImageDndTarget, + setUpscaleInitialImageFromImageDndTarget, + singleImageDndSource, +} from 'features/dnd2/types'; +import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; +import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; +import { imagesApi } from 'services/api/endpoints/images'; + +const log = logger('system'); + +export const dndDropped = createAction<{ + sourceData: DndSourceData; + targetData: DndTargetData; +}>('dnd/dndDropped2'); + +export const addDndDroppedListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: dndDropped, + effect: (action, { dispatch, getState }) => { + const { sourceData, targetData } = action.payload; + + // Single image dropped + if (singleImageDndSource.typeGuard(sourceData)) { + log.debug({ sourceData, targetData }, 'Image dropped'); + const { imageDTO } = sourceData; + + // Image dropped on IP Adapter + if ( + setGlobalReferenceImageDndTarget.typeGuard(targetData) && + setGlobalReferenceImageDndTarget.validateDrop(sourceData, targetData) + ) { + const { globalReferenceImageId } = targetData; + dispatch( + referenceImageIPAdapterImageChanged({ + entityIdentifier: { id: globalReferenceImageId, type: 'reference_image' }, + imageDTO, + }) + ); + return; + } + + //Image dropped on Regional Guidance IP Adapter + if ( + setRegionalGuidanceReferenceImageDndTarget.typeGuard(targetData) && + setRegionalGuidanceReferenceImageDndTarget.validateDrop(sourceData, targetData) + ) { + const { regionalGuidanceId, referenceImageId } = targetData; + dispatch( + rgIPAdapterImageChanged({ + entityIdentifier: { id: regionalGuidanceId, type: 'regional_guidance' }, + referenceImageId, + imageDTO, + }) + ); + return; + } + + // Add raster layer from image + if ( + addRasterLayerFromImageDndTarget.typeGuard(targetData) && + addRasterLayerFromImageDndTarget.validateDrop(sourceData, targetData) + ) { + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectCanvasSlice(getState()).bbox.rect; + const overrides: Partial = { + objects: [imageObject], + position: { x, y }, + }; + dispatch(rasterLayerAdded({ overrides, isSelected: true })); + return; + } + + // Add inpaint mask from image + if ( + addInpaintMaskFromImageDndTarget.typeGuard(targetData) && + addInpaintMaskFromImageDndTarget.validateDrop(sourceData, targetData) + ) { + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectCanvasSlice(getState()).bbox.rect; + const overrides: Partial = { + objects: [imageObject], + position: { x, y }, + }; + dispatch(inpaintMaskAdded({ overrides, isSelected: true })); + return; + } + + // Add regional guidance from image + if ( + addRegionalGuidanceFromImageDndTarget.typeGuard(targetData) && + addRegionalGuidanceFromImageDndTarget.validateDrop(sourceData, targetData) + ) { + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectCanvasSlice(getState()).bbox.rect; + const overrides: Partial = { + objects: [imageObject], + position: { x, y }, + }; + dispatch(rgAdded({ overrides, isSelected: true })); + return; + } + + // Add control layer from image + if ( + addControlLayerFromImageDndTarget.typeGuard(targetData) && + addControlLayerFromImageDndTarget.validateDrop(sourceData, targetData) + ) { + const state = getState(); + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectCanvasSlice(state).bbox.rect; + const defaultControlAdapter = selectDefaultControlAdapter(state); + const overrides: Partial = { + objects: [imageObject], + position: { x, y }, + controlAdapter: defaultControlAdapter, + }; + dispatch(controlLayerAdded({ overrides, isSelected: true })); + return; + } + + // Add regional guidance layer w/ reference image from image + if ( + addRegionalGuidanceReferenceImageFromImageDndTarget.typeGuard(targetData) && + addRegionalGuidanceReferenceImageFromImageDndTarget.validateDrop(sourceData, targetData) + ) { + const state = getState(); + const ipAdapter = deepClone(selectDefaultIPAdapter(state)); + ipAdapter.image = imageDTOToImageWithDims(imageDTO); + const overrides: Partial = { + referenceImages: [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }], + }; + dispatch(rgAdded({ overrides, isSelected: true })); + return; + } + + // Add global reference image from image + if ( + addGlobalReferenceImageFromImageDndTarget.typeGuard(targetData) && + addGlobalReferenceImageFromImageDndTarget.validateDrop(sourceData, targetData) + ) { + const state = getState(); + const ipAdapter = deepClone(selectDefaultIPAdapter(state)); + ipAdapter.image = imageDTOToImageWithDims(imageDTO); + const overrides: Partial = { ipAdapter }; + dispatch(referenceImageAdded({ overrides, isSelected: true })); + return; + } + + // Replace layer with image + if ( + replaceLayerWithImageDndTarget.typeGuard(targetData) && + replaceLayerWithImageDndTarget.validateDrop(sourceData, targetData) + ) { + const state = getState(); + const { entityIdentifier } = targetData; + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectCanvasSlice(state).bbox.rect; + dispatch(entityRasterized({ entityIdentifier, imageObject, position: { x, y }, replaceObjects: true })); + dispatch(entitySelected({ entityIdentifier })); + return; + } + + // Image dropped on node image field + if ( + setNodeImageFieldDndTarget.typeGuard(targetData) && + setNodeImageFieldDndTarget.validateDrop(sourceData, targetData) + ) { + const { fieldName, nodeId } = targetData; + dispatch( + fieldImageValueChanged({ + nodeId, + fieldName, + value: imageDTO, + }) + ); + return; + } + + // Image selected for compare + if ( + selectForCompareDndTarget.typeGuard(targetData) && + selectForCompareDndTarget.validateDrop(sourceData, targetData) + ) { + dispatch(imageToCompareChanged(imageDTO)); + return; + } + + // Image added to board + if (addToBoardDndTarget.typeGuard(targetData) && addToBoardDndTarget.validateDrop(sourceData, targetData)) { + const { boardId } = targetData; + dispatch( + imagesApi.endpoints.addImageToBoard.initiate({ + imageDTO, + board_id: boardId, + }) + ); + dispatch(selectionChanged([])); + return; + } + + // Image removed from board + if ( + removeFromBoardDndTarget.typeGuard(targetData) && + removeFromBoardDndTarget.validateDrop(sourceData, targetData) + ) { + dispatch( + imagesApi.endpoints.removeImageFromBoard.initiate({ + imageDTO, + }) + ); + dispatch(selectionChanged([])); + return; + } + + // Image dropped on upscale initial image + if ( + setUpscaleInitialImageFromImageDndTarget.typeGuard(targetData) && + setUpscaleInitialImageFromImageDndTarget.validateDrop(sourceData, targetData) + ) { + dispatch(upscaleInitialImageChanged(imageDTO)); + return; + } + } + + if (multipleImageDndSource.typeGuard(sourceData)) { + log.debug({ sourceData, targetData }, 'Multiple images dropped'); + const { imageDTOs } = sourceData; + + // Multiple images dropped on user board + if (addToBoardDndTarget.typeGuard(targetData) && addToBoardDndTarget.validateDrop(sourceData, targetData)) { + const { boardId } = targetData; + dispatch( + imagesApi.endpoints.addImagesToBoard.initiate({ + imageDTOs, + board_id: boardId, + }) + ); + dispatch(selectionChanged([])); + return; + } + + // Multiple images dropped on Uncategorized board (e.g. removed from board) + if ( + removeFromBoardDndTarget.typeGuard(targetData) && + removeFromBoardDndTarget.validateDrop(sourceData, targetData) + ) { + dispatch( + imagesApi.endpoints.removeImagesFromBoard.initiate({ + imageDTOs, + }) + ); + dispatch(selectionChanged([])); + return; + } + } + + log.error({ sourceData, targetData }, 'Invalid dnd drop'); + }, + }); +}; diff --git a/invokeai/frontend/web/src/common/components/DraggableImage.tsx b/invokeai/frontend/web/src/common/components/DraggableImage.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx index f621e4e2076..a7dae9ac822 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx @@ -141,13 +141,13 @@ const IAIDndImage = (props: IAIDndImageProps) => { minH={minSize ? minSize : undefined} userSelect="none" cursor={isDragDisabled || !imageDTO ? 'default' : 'pointer'} - sx={withHoverOverlay ? sx : baseStyles} + // sx={withHoverOverlay ? sx : baseStyles} data-selected={isSelectedForCompare ? 'selectedForCompare' : isSelected ? 'selected' : undefined} {...rest} > {imageDTO && ( { const { dropLabel, data, disabled } = props; + const [dndState, setDndState] = useState<'idle' | 'pending' | 'active'>('idle'); const dndId = useRef(uuidv4()); + const ref = useRef(null); + const dispatch = useAppDispatch(); const { isOver, setNodeRef, active } = useDroppableTypesafe({ id: dndId.current, @@ -24,9 +30,85 @@ const IAIDroppable = (props: IAIDroppableProps) => { data, }); + useEffect(() => { + if (!ref.current) { + console.log('no ref'); + return; + } + + if (!data) { + console.log('no data'); + return; + } + + console.log('setting up droppable'); + + return combine( + dropTargetForElements({ + element: ref.current, + canDrop: (args) => singleImageDndSource.typeGuard(args.source.data), + onDragEnter: (args) => { + console.log('onDragEnter', args); + setDndState('active'); + }, + onDragLeave: (args) => { + console.log('onDragEnter', args); + setDndState('pending'); + }, + getData: (args) => data, + onDrop: (args) => { + if (!singleImageDndSource.typeGuard(args.source.data)) { + return; + } + + if (args.source.data.imageDTOs.length === 0) { + return; + } + + if (args.source.data.imageDTOs.length > 1) { + dispatch( + dndDropped({ + overData: data, + activeData: { payloadType: 'IMAGE_DTO', id: 'asdf', payload: { imageDTO } }, + }) + ); + return; + } + + const imageDTO = args.source.data.imageDTOs.at(0); + + if (!imageDTO) { + return; + } + + dispatch( + dndDropped({ + overData: data, + activeData: { payloadType: 'IMAGE_DTO', id: 'asdf', payload: { imageDTO } }, + }) + ); + }, + }), + monitorForElements({ + canMonitor: (args) => singleImageDndSource.typeGuard(args.source.data), + onDragStart: (args) => { + console.log('onDragStart', args); + if (!singleImageDndSource.typeGuard(args.source.data)) { + return; + } + setDndState('pending'); + }, + onDrop: (args) => { + console.log('onDrop', args); + setDndState('idle'); + }, + }) + ); + }, [data, dispatch]); + return ( { left={0} w="full" h="full" - pointerEvents={active ? 'auto' : 'none'} + pointerEvents={dndState === 'idle' ? 'none' : 'auto'} > - - {isValidDrop(data, active?.data.current) && } - + {dndState !== 'idle' && } ); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index b5d9caa09fa..3834d425128 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -1,34 +1,20 @@ import { Grid, GridItem } from '@invoke-ai/ui-library'; -import IAIDroppable from 'common/components/IAIDroppable'; -import type { - AddControlLayerFromImageDropData, - AddGlobalReferenceImageFromImageDropData, - AddRasterLayerFromImageDropData, - AddRegionalReferenceImageFromImageDropData, -} from 'features/dnd/types'; +import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { + addControlLayerFromImageDndTarget, + addGlobalReferenceImageFromImageDndTarget, + addRasterLayerFromImageDndTarget, + addRegionalGuidanceReferenceImageFromImageDndTarget, +} from 'features/dnd2/types'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -const addRasterLayerFromImageDropData: AddRasterLayerFromImageDropData = { - id: 'add-raster-layer-from-image-drop-data', - actionType: 'ADD_RASTER_LAYER_FROM_IMAGE', -}; - -const addControlLayerFromImageDropData: AddControlLayerFromImageDropData = { - id: 'add-control-layer-from-image-drop-data', - actionType: 'ADD_CONTROL_LAYER_FROM_IMAGE', -}; - -const addRegionalReferenceImageFromImageDropData: AddRegionalReferenceImageFromImageDropData = { - id: 'add-control-layer-from-image-drop-data', - actionType: 'ADD_REGIONAL_REFERENCE_IMAGE_FROM_IMAGE', -}; - -const addGlobalReferenceImageFromImageDropData: AddGlobalReferenceImageFromImageDropData = { - id: 'add-control-layer-from-image-drop-data', - actionType: 'ADD_GLOBAL_REFERENCE_IMAGE_FROM_IMAGE', -}; +const addRasterLayerFromImageDndTargetData = addRasterLayerFromImageDndTarget.getData({}); +const addControlLayerFromImageDndTargetData = addControlLayerFromImageDndTarget.getData({}); +const addRegionalGuidanceReferenceImageFromImageDndTargetData = + addRegionalGuidanceReferenceImageFromImageDndTarget.getData({}); +const addGlobalReferenceImageFromImageDndTargetData = addGlobalReferenceImageFromImageDndTarget.getData({}); export const CanvasDropArea = memo(() => { const { t } = useTranslation(); @@ -51,28 +37,28 @@ export const CanvasDropArea = memo(() => { pointerEvents="none" > - - - - diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index 2fb3a9470fd..7a11a2070b5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -1,16 +1,18 @@ -import { useDndContext } from '@dnd-kit/core'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { Box, Button, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIDropOverlay from 'common/components/IAIDropOverlay'; +import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; +import { DndDropOverlay } from 'features/dnd2/DndDropOverlay'; +import type { DndState } from 'features/dnd2/types'; import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; -import { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; export const CanvasRightPanel = memo(() => { @@ -79,37 +81,12 @@ CanvasRightPanel.displayName = 'CanvasRightPanel'; const PanelTabs = memo(() => { const { t } = useTranslation(); - const activeTab = useAppSelector(selectActiveTabCanvasRightPanel); + const store = useAppStore(); const activeEntityCount = useAppSelector(selectEntityCountActive); - const tabTimeout = useRef(null); - const dndCtx = useDndContext(); - const dispatch = useAppDispatch(); - const [mouseOverTab, setMouseOverTab] = useState<'layers' | 'gallery' | null>(null); - - const onOnMouseOverLayersTab = useCallback(() => { - setMouseOverTab('layers'); - tabTimeout.current = window.setTimeout(() => { - if (dndCtx.active) { - dispatch(activeTabCanvasRightPanelChanged('layers')); - } - }, 300); - }, [dndCtx.active, dispatch]); - - const onOnMouseOverGalleryTab = useCallback(() => { - setMouseOverTab('gallery'); - tabTimeout.current = window.setTimeout(() => { - if (dndCtx.active) { - dispatch(activeTabCanvasRightPanelChanged('gallery')); - } - }, 300); - }, [dndCtx.active, dispatch]); - - const onMouseOut = useCallback(() => { - setMouseOverTab(null); - if (tabTimeout.current) { - clearTimeout(tabTimeout.current); - } - }, []); + const [layersTabDndState, setLayersTabDndState] = useState('idle'); + const [galleryTabDndState, setGalleryTabDndState] = useState('idle'); + const layersTabRef = useRef(null); + const galleryTabRef = useRef(null); const layersTabLabel = useMemo(() => { if (activeEntityCount === 0) { @@ -118,23 +95,131 @@ const PanelTabs = memo(() => { return `${t('controlLayers.layer_other')} (${activeEntityCount})`; }, [activeEntityCount, t]); + /** + * Handle dnd events for the tabs. When a tab is hovered for a certain amount of time, switch to that tab. + */ + useEffect(() => { + if (!layersTabRef.current || !galleryTabRef.current) { + return; + } + + let tabTimeout: number | null = null; + + const layersTabCleanup = combine( + dropTargetForElements({ + element: layersTabRef.current, + onDragEnter: () => { + // If we are already on the layers tab, do nothing + if (selectActiveTabCanvasRightPanel(store.getState()) === 'layers') { + return; + } + + // Else set the state to active and switch to the layers tab after a timeout + setLayersTabDndState('active'); + tabTimeout = window.setTimeout(() => { + tabTimeout = null; + store.dispatch(activeTabCanvasRightPanelChanged('layers')); + // When we switch tabs, the other tab should be pending + setLayersTabDndState('idle'); + setGalleryTabDndState('pending'); + }, 300); + }, + onDragLeave: () => { + // Set the state to idle or pending depending on the current tab + if (selectActiveTabCanvasRightPanel(store.getState()) === 'layers') { + setLayersTabDndState('idle'); + } else { + setLayersTabDndState('pending'); + } + // Abort the tab switch if it hasn't happened yet + if (tabTimeout !== null) { + clearTimeout(tabTimeout); + } + }, + }), + monitorForElements({ + // Only monitor if we are not already on the layers tab + canMonitor: () => selectActiveTabCanvasRightPanel(store.getState()) !== 'layers', + onDragStart: () => { + // Set the state to pending when a drag starts + setLayersTabDndState('pending'); + }, + }) + ); + + const galleryTabCleanup = combine( + dropTargetForElements({ + element: galleryTabRef.current, + onDragEnter: () => { + // If we are already on the gallery tab, do nothing + if (selectActiveTabCanvasRightPanel(store.getState()) === 'gallery') { + return; + } + + // Else set the state to active and switch to the gallery tab after a timeout + setGalleryTabDndState('active'); + tabTimeout = window.setTimeout(() => { + tabTimeout = null; + store.dispatch(activeTabCanvasRightPanelChanged('gallery')); + // When we switch tabs, the other tab should be pending + setGalleryTabDndState('idle'); + setLayersTabDndState('pending'); + }, 300); + }, + onDragLeave: () => { + // Set the state to idle or pending depending on the current tab + if (selectActiveTabCanvasRightPanel(store.getState()) === 'gallery') { + setGalleryTabDndState('idle'); + } else { + setGalleryTabDndState('pending'); + } + // Abort the tab switch if it hasn't happened yet + if (tabTimeout !== null) { + clearTimeout(tabTimeout); + } + }, + }), + monitorForElements({ + // Only monitor if we are not already on the gallery tab + canMonitor: () => selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery', + onDragStart: () => { + // Set the state to pending when a drag starts + setGalleryTabDndState('pending'); + }, + }) + ); + + const sharedCleanup = monitorForElements({ + onDrop: () => { + // Reset the dnd state when a drop happens + setGalleryTabDndState('idle'); + setLayersTabDndState('idle'); + }, + }); + + return () => { + layersTabCleanup(); + galleryTabCleanup(); + sharedCleanup(); + if (tabTimeout !== null) { + clearTimeout(tabTimeout); + } + }; + }, [store]); + return ( <> - + {layersTabLabel} - {dndCtx.active && activeTab !== 'layers' && ( - - )} + - + {t('gallery.gallery')} - {dndCtx.active && activeTab !== 'gallery' && ( - - )} + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index 1a55d716a22..02f94321b67 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -1,5 +1,4 @@ import { Spacer } from '@invoke-ai/ui-library'; -import IAIDroppable from 'common/components/IAIDroppable'; import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; @@ -11,7 +10,8 @@ import { ControlLayerSettings } from 'features/controlLayers/components/ControlL import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import type { ReplaceLayerImageDropData } from 'features/dnd/types'; +import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { replaceLayerWithImageDndTarget, type ReplaceLayerWithImageDndTargetData } from 'features/dnd2/types'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -25,10 +25,11 @@ export const ControlLayer = memo(({ id }: Props) => { () => ({ id, type: 'control_layer' }), [id] ); - const dropData = useMemo( - () => ({ id, actionType: 'REPLACE_LAYER_WITH_IMAGE', context: { entityIdentifier } }), - [id, entityIdentifier] + const targetData = useMemo( + () => replaceLayerWithImageDndTarget.getData({ entityIdentifier }), + [entityIdentifier] ); + return ( @@ -43,7 +44,7 @@ export const ControlLayer = memo(({ id }: Props) => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 204de8b4719..6919888035c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -1,5 +1,4 @@ import { Spacer } from '@invoke-ai/ui-library'; -import IAIDroppable from 'common/components/IAIDroppable'; import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; @@ -8,7 +7,9 @@ import { CanvasEntityEditableTitle } from 'features/controlLayers/components/com import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import type { ReplaceLayerImageDropData } from 'features/dnd/types'; +import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import type { ReplaceLayerWithImageDndTargetData } from 'features/dnd2/types'; +import { replaceLayerWithImageDndTarget } from 'features/dnd2/types'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -19,9 +20,9 @@ type Props = { export const RasterLayer = memo(({ id }: Props) => { const { t } = useTranslation(); const entityIdentifier = useMemo>(() => ({ id, type: 'raster_layer' }), [id]); - const dropData = useMemo( - () => ({ id, actionType: 'REPLACE_LAYER_WITH_IMAGE', context: { entityIdentifier } }), - [id, entityIdentifier] + const targetData = useMemo( + () => replaceLayerWithImageDndTarget.getData({ entityIdentifier }), + [entityIdentifier] ); return ( @@ -34,7 +35,7 @@ export const RasterLayer = memo(({ id }: Props) => { - + diff --git a/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx b/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx new file mode 100644 index 00000000000..c119478f0c5 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx @@ -0,0 +1,68 @@ +import { Flex, Text } from '@invoke-ai/ui-library'; +import type { DndState } from 'features/dnd2/types'; +import { memo } from 'react'; + +type Props = { + dndState: DndState; + label?: string; + withBackdrop?: boolean; +}; + +export const DndDropOverlay = memo((props: Props) => { + const { dndState, label, withBackdrop = true } = props; + + if (dndState === 'idle') { + return null; + } + + return ( + + + + + {label && ( + + {label} + + )} + + + ); +}); + +DndDropOverlay.displayName = 'DndDropOverlay'; diff --git a/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx new file mode 100644 index 00000000000..3aa5e618c16 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx @@ -0,0 +1,94 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { Box } from '@invoke-ai/ui-library'; +import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/dnd'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { DndDropOverlay } from 'features/dnd2/DndDropOverlay'; +import type { DndState, DndTargetData } from 'features/dnd2/types'; +import { isDndSourceData, isValidDrop } from 'features/dnd2/types'; +import { memo, useEffect, useRef, useState } from 'react'; + +type Props = { + label?: string; + disabled?: boolean; + targetData: DndTargetData; +}; + +export const DndDropTarget = memo((props: Props) => { + const { label, targetData, disabled } = props; + const [dndState, setDndState] = useState('idle'); + const ref = useRef(null); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (!ref.current) { + return; + } + + return combine( + dropTargetForElements({ + element: ref.current, + canDrop: (args) => { + if (disabled) { + return false; + } + const sourceData = args.source.data; + if (!isDndSourceData(sourceData)) { + return false; + } + return isValidDrop(sourceData, targetData); + }, + onDragEnter: () => { + setDndState('active'); + }, + onDragLeave: () => { + setDndState('pending'); + }, + getData: () => targetData, + onDrop: (args) => { + const sourceData = args.source.data; + if (!isDndSourceData(sourceData)) { + return; + } + dispatch(dndDropped({ sourceData, targetData })); + }, + }), + monitorForElements({ + canMonitor: (args) => { + if (disabled) { + return false; + } + const sourceData = args.source.data; + if (!isDndSourceData(sourceData)) { + return false; + } + return isValidDrop(sourceData, targetData); + }, + onDragStart: () => { + setDndState('pending'); + }, + onDrop: () => { + setDndState('idle'); + }, + }) + ); + }, [targetData, disabled, dispatch]); + + return ( + + + + ); +}); + +DndDropTarget.displayName = 'DndDropTarget'; diff --git a/invokeai/frontend/web/src/features/dnd2/types.ts b/invokeai/frontend/web/src/features/dnd2/types.ts new file mode 100644 index 00000000000..69ad02060af --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd2/types.ts @@ -0,0 +1,297 @@ +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import type { BoardId } from 'features/gallery/store/types'; +import type { ImageDTO } from 'services/api/types'; + +export type DndData = Record; +const _buildDataTypeGuard = + (key: symbol) => + (data: DndData): data is T => { + return Boolean(data[key]); + }; +const _buildDataGetter = + (key: symbol) => + (data: Omit): T => { + return { + [key]: true, + ...data, + } as T; + }; +const buildDndSourceApi = (key: symbol) => + ({ key, typeGuard: _buildDataTypeGuard(key), getData: _buildDataGetter(key) }) as const; + +//#region DndSourceData +const _SingleImageDndSourceDataKey = Symbol('SingleImageDndSourceData'); +export type SingleImageDndSourceData = { + [_SingleImageDndSourceDataKey]: true; + imageDTO: ImageDTO; +}; +export const singleImageDndSource = buildDndSourceApi(_SingleImageDndSourceDataKey); + +const _MultipleImageDndSourceDataKey = Symbol('MultipleImageDndSourceData'); +export type MultipleImageDndSourceData = { + [_MultipleImageDndSourceDataKey]: true; + imageDTOs: ImageDTO[]; + boardId: BoardId; +}; +export const multipleImageDndSource = buildDndSourceApi(_MultipleImageDndSourceDataKey); + +/** + * A union of all possible DndSourceData types. + */ +const sourceApis = [singleImageDndSource, multipleImageDndSource] as const; +export type DndSourceData = SingleImageDndSourceData | MultipleImageDndSourceData; +export const isDndSourceData = (data: DndData): data is DndSourceData => { + for (const sourceApi of sourceApis) { + if (sourceApi.typeGuard(data)) { + return true; + } + } + return false; +}; + +//#endregion + +//#region DndTargetData +const buildDndTargetApi = ( + key: symbol, + validateDrop: (sourceData: DndSourceData, targetData: T) => boolean +) => ({ key, typeGuard: _buildDataTypeGuard(key), getData: _buildDataGetter(key), validateDrop }) as const; + +const _SetGlobalReferenceImageDndTargetDataKey = Symbol('SetGlobalReferenceImageDndTargetData'); +export type SetGlobalReferenceImageDndTargetData = { + [_SetGlobalReferenceImageDndTargetDataKey]: true; + globalReferenceImageId: string; +}; +export const setGlobalReferenceImageDndTarget = buildDndTargetApi( + _SetGlobalReferenceImageDndTargetDataKey, + singleImageDndSource.typeGuard +); + +const _SetRegionalGuidanceReferenceImageDndTargetDataKey = Symbol('SetRegionalGuidanceReferenceImageDndTargetData'); +export type SetRegionalGuidanceReferenceImageDndTargetData = { + [_SetRegionalGuidanceReferenceImageDndTargetDataKey]: true; + regionalGuidanceId: string; + referenceImageId: string; +}; +export const setRegionalGuidanceReferenceImageDndTarget = + buildDndTargetApi( + _SetRegionalGuidanceReferenceImageDndTargetDataKey, + singleImageDndSource.typeGuard + ); + +const _AddRasterLayerFromImageDndTargetDataKey = Symbol('AddRasterLayerFromImageDndTargetData'); +export type AddRasterLayerFromImageDndTargetData = { + [_AddRasterLayerFromImageDndTargetDataKey]: true; +}; +export const addRasterLayerFromImageDndTarget = buildDndTargetApi( + _AddRasterLayerFromImageDndTargetDataKey, + singleImageDndSource.typeGuard +); + +const _AddControlLayerFromImageDndTargetDataKey = Symbol('AddControlLayerFromImageDndTargetData'); +export type AddControlLayerFromImageDndTargetData = { + [_AddControlLayerFromImageDndTargetDataKey]: true; +}; +export const addControlLayerFromImageDndTarget = buildDndTargetApi( + _AddControlLayerFromImageDndTargetDataKey, + singleImageDndSource.typeGuard +); + +const _AddInpaintMaskFromImageDndTargetDataKey = Symbol('AddInpaintMaskFromImageDndTargetData'); +export type AddInpaintMaskFromImageDndTargetData = { + [_AddInpaintMaskFromImageDndTargetDataKey]: true; +}; +export const addInpaintMaskFromImageDndTarget = buildDndTargetApi( + _AddInpaintMaskFromImageDndTargetDataKey, + singleImageDndSource.typeGuard +); + +const _AddRegionalGuidanceFromImageDndTargetDataKey = Symbol('AddRegionalGuidanceFromImageDndTargetData'); +export type AddRegionalGuidanceFromImageDndTargetData = { + [_AddRegionalGuidanceFromImageDndTargetDataKey]: true; +}; +export const addRegionalGuidanceFromImageDndTarget = buildDndTargetApi( + _AddRegionalGuidanceFromImageDndTargetDataKey, + singleImageDndSource.typeGuard +); + +const _AddRegionalGuidanceReferenceImageFromImageDndTargetDataKey = Symbol( + 'AddRegionalGuidanceReferenceImageFromImageDndTargetData' +); +export type AddRegionalGuidanceReferenceImageFromImageDndTargetData = { + [_AddRegionalGuidanceReferenceImageFromImageDndTargetDataKey]: true; +}; +export const addRegionalGuidanceReferenceImageFromImageDndTarget = + buildDndTargetApi( + _AddRegionalGuidanceReferenceImageFromImageDndTargetDataKey, + singleImageDndSource.typeGuard + ); + +const _AddGlobalReferenceImageFromImageDndTargetDataKey = Symbol('AddGlobalReferenceImageFromImageDndTargetData'); +export type AddGlobalReferenceImageFromImageDndTargetData = { + [_AddGlobalReferenceImageFromImageDndTargetDataKey]: true; +}; +export const addGlobalReferenceImageFromImageDndTarget = + buildDndTargetApi( + _AddGlobalReferenceImageFromImageDndTargetDataKey, + singleImageDndSource.typeGuard + ); + +const _ReplaceLayerWithImageDndTargetDataKey = Symbol('ReplaceLayerWithImageDndTargetData'); +export type ReplaceLayerWithImageDndTargetData = { + [_ReplaceLayerWithImageDndTargetDataKey]: true; + entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>; +}; +export const replaceLayerWithImageDndTarget = buildDndTargetApi( + _ReplaceLayerWithImageDndTargetDataKey, + singleImageDndSource.typeGuard +); + +const _SetUpscaleInitialImageFromImageDndTargetDataKey = Symbol('SetUpscaleInitialImageFromImageDndTargetData'); +export type SetUpscaleInitialImageFromImageDndTargetData = { + [_SetUpscaleInitialImageFromImageDndTargetDataKey]: true; +}; +export const setUpscaleInitialImageFromImageDndTarget = buildDndTargetApi( + _SetUpscaleInitialImageFromImageDndTargetDataKey, + singleImageDndSource.typeGuard +); + +const _SetNodeImageFieldDndTargetDataKey = Symbol('SetNodeImageFieldDndTargetData'); +export type SetNodeImageFieldDndTargetData = { + [_SetNodeImageFieldDndTargetDataKey]: true; + nodeId: string; + fieldName: string; +}; +export const setNodeImageFieldDndTarget = buildDndTargetApi( + _SetNodeImageFieldDndTargetDataKey, + singleImageDndSource.typeGuard +); + +const _SelectForCompareDndTargetDataKey = Symbol('SelectForCompareDndTargetData'); +export type SelectForCompareDndTargetData = { + [_SelectForCompareDndTargetDataKey]: true; + firstImageName?: string | null; + secondImageName?: string | null; +}; +export const selectForCompareDndTarget = buildDndTargetApi( + _SelectForCompareDndTargetDataKey, + singleImageDndSource.typeGuard +); + +const _AddToBoardDndTargetDataKey = Symbol('AddToBoardDndTargetData'); +export type AddToBoardDndTargetData = { + [_AddToBoardDndTargetDataKey]: true; + boardId: string; +}; +export const addToBoardDndTarget = buildDndTargetApi( + _AddToBoardDndTargetDataKey, + (sourceData, targetData) => { + if (singleImageDndSource.typeGuard(sourceData)) { + const { imageDTO } = sourceData; + const currentBoard = imageDTO.board_id ?? 'none'; + const destinationBoard = targetData.boardId; + return currentBoard !== destinationBoard; + } + + if (multipleImageDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.boardId; + const destinationBoard = targetData.boardId; + return currentBoard !== destinationBoard; + } + + return false; + } +); + +const _RemoveFromBoardDndTargetDataKey = Symbol('RemoveFromBoardDndTargetData'); +export type RemoveFromBoardDndTargetData = { + [_RemoveFromBoardDndTargetDataKey]: true; +}; +export const removeFromBoardDndTarget = buildDndTargetApi( + _RemoveFromBoardDndTargetDataKey, + (sourceData) => { + if (singleImageDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.imageDTO.board_id ?? 'none'; + return currentBoard !== 'none'; + } + + if (multipleImageDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.boardId; + return currentBoard !== 'none'; + } + + return false; + } +); + +const targetApis = [ + setGlobalReferenceImageDndTarget, + setRegionalGuidanceReferenceImageDndTarget, + // Add layer from image + addRasterLayerFromImageDndTarget, + addControlLayerFromImageDndTarget, + addGlobalReferenceImageFromImageDndTarget, + addRegionalGuidanceReferenceImageFromImageDndTarget, + // + addRegionalGuidanceFromImageDndTarget, + addInpaintMaskFromImageDndTarget, + // + replaceLayerWithImageDndTarget, + setUpscaleInitialImageFromImageDndTarget, + setNodeImageFieldDndTarget, + selectForCompareDndTarget, + // Board ops + addToBoardDndTarget, + removeFromBoardDndTarget, +] as const; + +/** + * A union of all possible DndTargetData types. + */ +export type DndTargetData = + | SetGlobalReferenceImageDndTargetData + | SetRegionalGuidanceReferenceImageDndTargetData + | AddRasterLayerFromImageDndTargetData + | AddControlLayerFromImageDndTargetData + | AddInpaintMaskFromImageDndTargetData + | AddRegionalGuidanceFromImageDndTargetData + | AddRegionalGuidanceReferenceImageFromImageDndTargetData + | AddGlobalReferenceImageFromImageDndTargetData + | ReplaceLayerWithImageDndTargetData + | SetUpscaleInitialImageFromImageDndTargetData + | SetNodeImageFieldDndTargetData + | AddToBoardDndTargetData + | RemoveFromBoardDndTargetData + | SelectForCompareDndTargetData; + +export const isDndTargetData = (data: DndData): data is DndTargetData => { + for (const targetApi of targetApis) { + if (targetApi.typeGuard(data)) { + return true; + } + } + return false; +}; +//#endregion + +/** + * Validates whether a drop is valid. + * @param sourceData The data being dragged. + * @param targetData The data of the target being dragged onto. + * @returns Whether the drop is valid. + */ +export const isValidDrop = (sourceData: DndSourceData, targetData: DndTargetData): boolean => { + for (const targetApi of targetApis) { + if (targetApi.typeGuard(targetData)) { + /** + * TS cannot narrow the type of the targetApi and will error in the validator call. + * We've just checked that targetData is of the right type, though, so this cast to `any` is safe. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + return targetApi.validateDrop(sourceData, targetData as any); + } + } + return false; +}; + +export type DndState = 'idle' | 'pending' | 'active'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index da5469d20f0..bb53f37cb42 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -2,8 +2,9 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIDroppable from 'common/components/IAIDroppable'; -import type { AddToBoardDropData } from 'features/dnd/types'; +import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import type { AddToBoardDndTargetData } from 'features/dnd2/types'; +import { addToBoardDndTarget } from 'features/dnd2/types'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; import { BoardEditableTitle } from 'features/gallery/components/Boards/BoardsList/BoardEditableTitle'; @@ -45,12 +46,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { } }, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]); - const droppableData: AddToBoardDropData = useMemo( - () => ({ - id: board.board_id, - actionType: 'ADD_TO_BOARD', - context: { boardId: board.board_id }, - }), + const targetData: AddToBoardDndTargetData = useMemo( + () => addToBoardDndTarget.getData({ boardId: board.board_id }), [board.board_id] ); @@ -85,7 +82,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { )} - + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index 8026320a975..e2289df7b92 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -1,8 +1,9 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIDroppable from 'common/components/IAIDroppable'; -import type { RemoveFromBoardDropData } from 'features/dnd/types'; +import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import type { RemoveFromBoardDndTargetData } from 'features/dnd2/types'; +import { removeFromBoardDndTarget } from 'features/dnd2/types'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardBoardContextMenu'; @@ -43,13 +44,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { } }, [dispatch, autoAssignBoardOnClick]); - const droppableData: RemoveFromBoardDropData = useMemo( - () => ({ - id: 'no_board', - actionType: 'REMOVE_FROM_BOARD', - }), - [] - ); + const targetData: RemoveFromBoardDndTargetData = useMemo(() => removeFromBoardDndTarget.getData({}), []); const { t } = useTranslation(); @@ -102,7 +97,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { )} - + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx index 934868e7cc6..f1740cead9e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx @@ -7,7 +7,6 @@ import MultipleSelectionMenuItems from 'features/gallery/components/ImageContext import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems'; import { selectSelectionCount } from 'features/gallery/store/gallerySelectors'; import { map } from 'nanostores'; -import type { RefObject } from 'react'; import { memo, useCallback, useEffect, useRef } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -60,12 +59,13 @@ const getImageDTOFromMap = (target: Node): ImageDTO | undefined => { * @param imageDTO The image DTO to register the context menu for. * @param targetRef The ref of the target element that should trigger the context menu. */ -export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: RefObject) => { +export const useImageContextMenu = (imageDTO: ImageDTO | undefined, targetRef: HTMLDivElement | null +) => { useEffect(() => { - if (!targetRef.current || !imageDTO) { + if (!targetRef || !imageDTO) { return; } - const el = targetRef.current; + const el = targetRef; elToImageMap.set(el, imageDTO); return () => { elToImageMap.delete(el); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 191d466d6e7..668ae388c2e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,40 +1,83 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Box, Flex, Text, useShiftModifier } from '@invoke-ai/ui-library'; +import { Box, Flex, Image, Skeleton, Text, useShiftModifier } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { createSelector } from '@reduxjs/toolkit'; +import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; import { $customStarUI } from 'app/store/nanostores/customStarUI'; +import { useAppStore } from 'app/store/nanostores/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; import IAIFillSkeleton from 'common/components/IAIFillSkeleton'; +import { useBoolean } from 'common/hooks/useBoolean'; import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; -import type { GallerySelectionDraggableData, ImageDraggableData, TypesafeDraggableData } from 'features/dnd/types'; +import { multipleImageDndSource, singleImageDndSource } from 'features/dnd2/types'; +import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; -import { useMultiselect } from 'features/gallery/hooks/useMultiselect'; import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView'; -import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; -import type { MouseEvent } from 'react'; -import { memo, useCallback, useMemo, useState } from 'react'; +import type { MouseEvent, MouseEventHandler } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsOutBold, PiStarBold, PiStarFill, PiTrashSimpleFill } from 'react-icons/pi'; import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; // This class name is used to calculate the number of images that fit in the gallery -export const GALLERY_IMAGE_CLASS_NAME = 'gallery-image'; +export const GALLERY_IMAGE_CONTAINER_CLASS_NAME = 'gallery-image-container'; -const imageSx: SystemStyleObject = { w: 'full', h: 'full' }; -const boxSx: SystemStyleObject = { +const galleryImageContainerSX = { containerType: 'inline-size', -}; - -const badgeSx: SystemStyleObject = { - '@container (max-width: 80px)': { - '&': { display: 'none' }, + w: 'full', + h: 'full', + '.gallery-image-size-badge': { + '@container (max-width: 80px)': { + '&': { display: 'none' }, + }, + }, + '.gallery-image': { + touchAction: 'none', + userSelect: 'none', + webkitUserSelect: 'none', + position: 'relative', + justifyContent: 'center', + alignItems: 'center', + aspectRatio: '1/1', + '::before': { + content: '""', + display: 'inline-block', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + pointerEvents: 'none', + borderRadius: 'base', + }, + '&[data-selected=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + }, + '&[data-selected-for-compare=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + }, + '&:hover::before': { + boxShadow: + 'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)', + }, + '&:hover[data-selected=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + }, + '&:hover[data-selected-for-compare=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + }, }, -}; +} satisfies SystemStyleObject; interface HoverableImageProps { imageDTO: ImageDTO; @@ -57,87 +100,125 @@ export const GalleryImage = memo(({ index, imageDTO }: HoverableImageProps) => { GalleryImage.displayName = 'GalleryImage'; const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => { - const dispatch = useAppDispatch(); - const selectedBoardId = useAppSelector(selectSelectedBoardId); + const store = useAppStore(); + const [isDragging, setIsDragging] = useState(false); + const [element, ref] = useState(null); + const imageViewer = useImageViewer(); const selectIsSelectedForCompare = useMemo( () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name), [imageDTO.image_name] ); const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare); - const { handleClick, isSelected, areMultiplesSelected } = useMultiselect(imageDTO); - - const imageContainerRef = useScrollIntoView(isSelected, index, areMultiplesSelected); - - const draggableData = useMemo(() => { - if (areMultiplesSelected) { - const data: GallerySelectionDraggableData = { - id: 'gallery-image', - payloadType: 'GALLERY_SELECTION', - payload: { boardId: selectedBoardId }, - }; - return data; - } - - if (imageDTO) { - const data: ImageDraggableData = { - id: 'gallery-image', - payloadType: 'IMAGE_DTO', - payload: { imageDTO }, - }; - return data; - } - }, [imageDTO, selectedBoardId, areMultiplesSelected]); + const selectIsSelected = useMemo( + () => + createSelector(selectGallerySlice, (gallery) => + gallery.selection.some((i) => i.image_name === imageDTO.image_name) + ), + [imageDTO.image_name] + ); + const isSelected = useAppSelector(selectIsSelected); - const [isHovered, setIsHovered] = useState(false); + useScrollIntoView(element, isSelected, index); - const handleMouseOver = useCallback(() => { - setIsHovered(true); - }, []); + useEffect(() => { + if (!element) { + return; + } + return combine( + draggable({ + element, + getInitialData: () => { + const { gallery } = store.getState(); + // When we have multiple images selected, and the dragged image is part of the selection, initiate a + // multi-image drag. + if (gallery.selection.length > 1 && gallery.selection.includes(imageDTO)) { + return multipleImageDndSource.getData({ imageDTOs: gallery.selection, boardId: gallery.selectedBoardId }); + } + + // Otherwise, initiate a single-image drag + return singleImageDndSource.getData({ imageDTO }); + }, + // This is a "local" drag start event, meaning that it is only called when this specific image is dragged. + onDragStart: (args) => { + // When we start dragging a single image, set the dragging state to true. This is only called when this + // specific image is dragged. + if (singleImageDndSource.typeGuard(args.source.data)) { + setIsDragging(true); + return; + } + }, + }), + monitorForElements({ + // This is a "global" drag start event, meaning that it is called for all drag events. + onDragStart: (args) => { + // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the + // selection. This is called for all drag events. + if (multipleImageDndSource.typeGuard(args.source.data) && args.source.data.imageDTOs.includes(imageDTO)) { + setIsDragging(true); + } + }, + onDrop: () => { + // Always set the dragging state to false when a drop event occurs. + setIsDragging(false); + }, + }) + ); + }, [imageDTO, element, store]); + + const isHovered = useBoolean(false); + + const onClick = useCallback>( + (e) => { + store.dispatch( + galleryImageClicked({ + imageDTO, + shiftKey: e.shiftKey, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + altKey: e.altKey, + }) + ); + }, + [imageDTO, store] + ); - const imageViewer = useImageViewer(); - const onDoubleClick = useCallback(() => { + const onDoubleClick = useCallback>(() => { imageViewer.open(); - dispatch(imageToCompareChanged(null)); - }, [dispatch, imageViewer]); - - const handleMouseOut = useCallback(() => { - setIsHovered(false); - }, []); + store.dispatch(imageToCompareChanged(null)); + }, [imageViewer, store]); const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]); - if (!imageDTO) { - return ; - } + useImageContextMenu(imageDTO, element); return ( - + - - - + } + w={imageDTO.width} + objectFit="contain" + maxW="full" + maxH="full" + borderRadius="base" + /> + ); @@ -220,21 +301,17 @@ const StarIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { const [unstarImages] = useUnstarImagesMutation(); const toggleStarredState = useCallback(() => { - if (imageDTO) { - if (imageDTO.starred) { - unstarImages({ imageDTOs: [imageDTO] }); - } - if (!imageDTO.starred) { - starImages({ imageDTOs: [imageDTO] }); - } + if (imageDTO.starred) { + unstarImages({ imageDTOs: [imageDTO] }); + } else { + starImages({ imageDTOs: [imageDTO] }); } }, [starImages, unstarImages, imageDTO]); const starIcon = useMemo(() => { if (imageDTO.starred) { return customStarUi ? customStarUi.on.icon : ; - } - if (!imageDTO.starred) { + } else { return customStarUi ? customStarUi.off.icon : ; } }, [imageDTO.starred, customStarUi]); @@ -242,11 +319,9 @@ const StarIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { const starTooltip = useMemo(() => { if (imageDTO.starred) { return customStarUi ? customStarUi.off.text : 'Unstar'; - } - if (!imageDTO.starred) { + } else { return customStarUi ? customStarUi.on.text : 'Star'; } - return ''; }, [imageDTO.starred, customStarUi]); return ( @@ -266,6 +341,7 @@ StarIcon.displayName = 'StarIcon'; const SizeBadge = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { return ( { px={2} lineHeight={1.25} borderTopEndRadius="base" - sx={badgeSx} pointerEvents="none" >{`${imageDTO.width}x${imageDTO.height}`} ); }); SizeBadge.displayName = 'SizeBadge'; + +const SizedSkeleton = memo(({ width, height }: { width: number; height: number }) => { + return ; +}); + +SizedSkeleton.displayName = 'SizedSkeleton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx index c9aa0a3b73d..79fb1856fd8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx @@ -14,7 +14,7 @@ import { PiImageBold, PiWarningCircleBold } from 'react-icons/pi'; import { useListImagesQuery } from 'services/api/endpoints/images'; import { GALLERY_GRID_CLASS_NAME } from './constants'; -import { GALLERY_IMAGE_CLASS_NAME, GalleryImage } from './GalleryImage'; +import { GALLERY_IMAGE_CONTAINER_CLASS_NAME, GalleryImage } from './GalleryImage'; const GalleryImageGrid = () => { useGalleryHotkeys(); @@ -79,7 +79,7 @@ const Content = () => { // Managing refs for dynamically rendered components is a bit tedious: // - https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback // As a easy workaround, we can just grab the first gallery image element directly. - const imageEl = document.querySelector(`.${GALLERY_IMAGE_CLASS_NAME}`); + const imageEl = document.querySelector(`.${GALLERY_IMAGE_CONTAINER_CLASS_NAME}`); if (!imageEl) { // No images in gallery? return; 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..2189ae6c9b7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx @@ -1,7 +1,8 @@ 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 { useAppStore } from 'app/store/storeHooks'; +import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import type { SelectForCompareDndTargetData } from 'features/dnd2/types'; +import { selectForCompareDndTarget } from 'features/dnd2/types'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -9,22 +10,18 @@ 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 store = useAppStore(); + const targetData = useMemo(() => { + const { firstImage, secondImage } = selectComparisonImages(store.getState()); + return selectForCompareDndTarget.getData({ + firstImageName: firstImage?.image_name, + secondImageName: secondImage?.image_name, + }); + }, [store]); return ( - + ); }); 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 index 56710697285..2f885030dda 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useMultiselect.ts @@ -8,24 +8,21 @@ import type { MouseEvent } from 'react'; import { useCallback, useMemo } from 'react'; import type { ImageDTO } from 'services/api/types'; -export const useMultiselect = (imageDTO?: ImageDTO) => { +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) + gallery.selection.some((i) => i.image_name === imageDTO.image_name) ), - [imageDTO?.image_name] + [imageDTO.image_name] ); const isSelected = useAppSelector(selectIsSelected); const isMultiSelectEnabled = useFeatureStatus('multiselect'); - const handleClick = useCallback( + const onClick = useCallback( (e: MouseEvent) => { - if (!imageDTO) { - return; - } if (!isMultiSelectEnabled) { dispatch(selectionChanged([imageDTO])); return; @@ -47,6 +44,6 @@ export const useMultiselect = (imageDTO?: ImageDTO) => { return { areMultiplesSelected, isSelected, - handleClick, + onClick, }; }; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts b/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts index 9947a4d78ca..3753f044138 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts @@ -1,7 +1,9 @@ +import { useAppSelector } from 'app/store/storeHooks'; import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types'; +import { selectHasMultipleImagesSelected } from 'features/gallery/store/gallerySelectors'; import { getIsVisible } from 'features/gallery/util/getIsVisible'; import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; /** * Scrolls an image into view when it is selected. This is necessary because @@ -11,13 +13,13 @@ import { useEffect, useRef } from 'react'; * Also handles when an image is selected programmatically - for example, when * auto-switching the new gallery images. * + * @param imageContainerRef The ref to the image container. * @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); +export const useScrollIntoView = (imageContainerRef: HTMLElement | null, isSelected: boolean, index: number) => { + const areMultiplesSelected = useAppSelector(selectHasMultipleImagesSelected); useEffect(() => { if (!isSelected || areMultiplesSelected) { @@ -33,7 +35,7 @@ export const useScrollIntoView = (isSelected: boolean, index: number, areMultipl return; } - const itemRect = imageContainerRef.current?.getBoundingClientRect(); + const itemRect = imageContainerRef?.getBoundingClientRect(); const rootRect = root.getBoundingClientRect(); if (!itemRect || !getIsVisible(itemRect, rootRect)) { @@ -42,7 +44,5 @@ export const useScrollIntoView = (isSelected: boolean, index: number, areMultipl align: getScrollToIndexAlign(index, range), }); } - }, [isSelected, index, areMultiplesSelected]); - - return imageContainerRef; + }, [isSelected, index, areMultiplesSelected, imageContainerRef]); }; From 2ecd2b6587b63ce4f691c235906342a7424ada94 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 27 Oct 2024 15:29:55 +1000 Subject: [PATCH 02/39] feat(ui): migrate to `pragmatic-drag-and-drop` (wip 2) --- invokeai/frontend/web/package.json | 1 + invokeai/frontend/web/pnpm-lock.yaml | 10 + .../frontend/web/src/app/components/App.tsx | 17 +- .../src/common/components/IAIDroppable.tsx | 116 +----- .../components/CanvasRightPanel.tsx | 212 ++++++----- .../web/src/features/dnd2/DndDropOverlay.tsx | 4 +- .../web/src/features/dnd2/DndDropTarget.tsx | 127 ++++++- .../frontend/web/src/features/dnd2/test.tsx | 335 ++++++++++++++++++ .../frontend/web/src/features/dnd2/types.ts | 18 +- .../Boards/BoardsList/BoardsList.tsx | 16 +- .../Boards/BoardsList/BoardsListWrapper.tsx | 24 +- 11 files changed, 638 insertions(+), 242 deletions(-) create mode 100644 invokeai/frontend/web/src/features/dnd2/test.tsx diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 0b902b048da..fe603177e2f 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -53,6 +53,7 @@ }, "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.4.0", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", "@dagrejs/dagre": "^1.1.4", "@dagrejs/graphlib": "^2.2.4", "@dnd-kit/core": "^6.1.0", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 433442c295f..8f0ffeb1597 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ 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 '@dagrejs/dagre': specifier: ^1.1.4 version: 1.1.4 @@ -322,6 +325,13 @@ 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@1.4.0: resolution: {integrity: sha512-qRY3PTJIcxfl/QB8Gwswz+BRvlmgAC5pB+J2hL6dkIxgqAgVwOhAamMUKsrOcFU/axG2Q7RbNs1xfoLKDuhoPg==} dependencies: diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 20f8fe27c14..43d67b59836 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 { @@ -62,8 +60,6 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => { useGetOpenAPISchemaQuery(); useSyncLoggingConfig(); - const { dropzone, isHandlingUpload, setIsHandlingUpload } = useFullscreenDropzone(); - const handleReset = useCallback(() => { clearStorage(); location.reload(); @@ -92,19 +88,8 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => { return ( - - + - {dropzone.isDragActive && isHandlingUpload && ( - - )} diff --git a/invokeai/frontend/web/src/common/components/IAIDroppable.tsx b/invokeai/frontend/web/src/common/components/IAIDroppable.tsx index 5b548b833ef..9b28a2dc3da 100644 --- a/invokeai/frontend/web/src/common/components/IAIDroppable.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDroppable.tsx @@ -1,15 +1,5 @@ -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { Box } from '@invoke-ai/ui-library'; -import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { useDroppableTypesafe } from 'features/dnd/hooks/typesafeHooks'; import type { TypesafeDroppableData } from 'features/dnd/types'; -import { singleImageDndSource } from 'features/dnd2/types'; -import { memo, useEffect, useRef, useState } from 'react'; -import { v4 as uuidv4 } from 'uuid'; - -import IAIDropOverlay from './IAIDropOverlay'; +import { memo } from 'react'; type IAIDroppableProps = { dropLabel?: string; @@ -18,109 +8,7 @@ type IAIDroppableProps = { }; const IAIDroppable = (props: IAIDroppableProps) => { - const { dropLabel, data, disabled } = props; - const [dndState, setDndState] = useState<'idle' | 'pending' | 'active'>('idle'); - const dndId = useRef(uuidv4()); - const ref = useRef(null); - const dispatch = useAppDispatch(); - - const { isOver, setNodeRef, active } = useDroppableTypesafe({ - id: dndId.current, - disabled, - data, - }); - - useEffect(() => { - if (!ref.current) { - console.log('no ref'); - return; - } - - if (!data) { - console.log('no data'); - return; - } - - console.log('setting up droppable'); - - return combine( - dropTargetForElements({ - element: ref.current, - canDrop: (args) => singleImageDndSource.typeGuard(args.source.data), - onDragEnter: (args) => { - console.log('onDragEnter', args); - setDndState('active'); - }, - onDragLeave: (args) => { - console.log('onDragEnter', args); - setDndState('pending'); - }, - getData: (args) => data, - onDrop: (args) => { - if (!singleImageDndSource.typeGuard(args.source.data)) { - return; - } - - if (args.source.data.imageDTOs.length === 0) { - return; - } - - if (args.source.data.imageDTOs.length > 1) { - dispatch( - dndDropped({ - overData: data, - activeData: { payloadType: 'IMAGE_DTO', id: 'asdf', payload: { imageDTO } }, - }) - ); - return; - } - - const imageDTO = args.source.data.imageDTOs.at(0); - - if (!imageDTO) { - return; - } - - dispatch( - dndDropped({ - overData: data, - activeData: { payloadType: 'IMAGE_DTO', id: 'asdf', payload: { imageDTO } }, - }) - ); - }, - }), - monitorForElements({ - canMonitor: (args) => singleImageDndSource.typeGuard(args.source.data), - onDragStart: (args) => { - console.log('onDragStart', args); - if (!singleImageDndSource.typeGuard(args.source.data)) { - return; - } - setDndState('pending'); - }, - onDrop: (args) => { - console.log('onDrop', args); - setDndState('idle'); - }, - }) - ); - }, [data, dispatch]); - - return ( - - {dndState !== 'idle' && } - - ); + return null; }; export default memo(IAIDroppable); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index 7a11a2070b5..8d641e04956 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -1,5 +1,6 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter'; import { Box, Button, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; @@ -87,6 +88,7 @@ const PanelTabs = memo(() => { const [galleryTabDndState, setGalleryTabDndState] = useState('idle'); const layersTabRef = useRef(null); const galleryTabRef = useRef(null); + const timeoutRef = useRef(null); const layersTabLabel = useMemo(() => { if (activeEntityCount === 0) { @@ -95,117 +97,151 @@ const PanelTabs = memo(() => { return `${t('controlLayers.layer_other')} (${activeEntityCount})`; }, [activeEntityCount, t]); - /** - * Handle dnd events for the tabs. When a tab is hovered for a certain amount of time, switch to that tab. - */ useEffect(() => { - if (!layersTabRef.current || !galleryTabRef.current) { + if (!layersTabRef.current) { return; } - let tabTimeout: number | null = null; + const onDragEnter = () => { + // If we are already on the layers tab, do nothing + if (selectActiveTabCanvasRightPanel(store.getState()) === 'layers') { + return; + } - const layersTabCleanup = combine( + // Else set the state to active and switch to the layers tab after a timeout + setLayersTabDndState('over'); + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + store.dispatch(activeTabCanvasRightPanelChanged('layers')); + // When we switch tabs, the other tab should be pending + setLayersTabDndState('idle'); + setGalleryTabDndState('potential'); + }, 300); + }; + const onDragLeave = () => { + // Set the state to idle or pending depending on the current tab + if (selectActiveTabCanvasRightPanel(store.getState()) === 'layers') { + setLayersTabDndState('idle'); + } else { + setLayersTabDndState('potential'); + } + // Abort the tab switch if it hasn't happened yet + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + const canMonitor = () => { + // Only monitor if we are not already on the layers tab + return selectActiveTabCanvasRightPanel(store.getState()) !== 'layers'; + }; + const onDragStart = () => { + // Set the state to pending when a drag starts + setLayersTabDndState('potential'); + }; + return combine( dropTargetForElements({ element: layersTabRef.current, - onDragEnter: () => { - // If we are already on the layers tab, do nothing - if (selectActiveTabCanvasRightPanel(store.getState()) === 'layers') { - return; - } - - // Else set the state to active and switch to the layers tab after a timeout - setLayersTabDndState('active'); - tabTimeout = window.setTimeout(() => { - tabTimeout = null; - store.dispatch(activeTabCanvasRightPanelChanged('layers')); - // When we switch tabs, the other tab should be pending - setLayersTabDndState('idle'); - setGalleryTabDndState('pending'); - }, 300); - }, - onDragLeave: () => { - // Set the state to idle or pending depending on the current tab - if (selectActiveTabCanvasRightPanel(store.getState()) === 'layers') { - setLayersTabDndState('idle'); - } else { - setLayersTabDndState('pending'); - } - // Abort the tab switch if it hasn't happened yet - if (tabTimeout !== null) { - clearTimeout(tabTimeout); - } - }, + onDragEnter, + onDragLeave, }), monitorForElements({ - // Only monitor if we are not already on the layers tab - canMonitor: () => selectActiveTabCanvasRightPanel(store.getState()) !== 'layers', - onDragStart: () => { - // Set the state to pending when a drag starts - setLayersTabDndState('pending'); - }, + canMonitor, + onDragStart, + }), + dropTargetForExternal({ + element: layersTabRef.current, + onDragEnter, + onDragLeave, + }), + monitorForExternal({ + canMonitor, + onDragStart, }) ); + }, [store]); + + useEffect(() => { + if (!galleryTabRef.current) { + return; + } + + const onDragEnter = () => { + // If we are already on the gallery tab, do nothing + if (selectActiveTabCanvasRightPanel(store.getState()) === 'gallery') { + return; + } + + // Else set the state to active and switch to the gallery tab after a timeout + setGalleryTabDndState('over'); + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + store.dispatch(activeTabCanvasRightPanelChanged('gallery')); + // When we switch tabs, the other tab should be pending + setGalleryTabDndState('idle'); + setLayersTabDndState('potential'); + }, 300); + }; - const galleryTabCleanup = combine( + const onDragLeave = () => { + // Set the state to idle or pending depending on the current tab + if (selectActiveTabCanvasRightPanel(store.getState()) === 'gallery') { + setGalleryTabDndState('idle'); + } else { + setGalleryTabDndState('potential'); + } + // Abort the tab switch if it hasn't happened yet + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + + const canMonitor = () => { + // Only monitor if we are not already on the gallery tab + return selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery'; + }; + + const onDragStart = () => { + // Set the state to pending when a drag starts + setGalleryTabDndState('potential'); + }; + + return combine( dropTargetForElements({ element: galleryTabRef.current, - onDragEnter: () => { - // If we are already on the gallery tab, do nothing - if (selectActiveTabCanvasRightPanel(store.getState()) === 'gallery') { - return; - } - - // Else set the state to active and switch to the gallery tab after a timeout - setGalleryTabDndState('active'); - tabTimeout = window.setTimeout(() => { - tabTimeout = null; - store.dispatch(activeTabCanvasRightPanelChanged('gallery')); - // When we switch tabs, the other tab should be pending - setGalleryTabDndState('idle'); - setLayersTabDndState('pending'); - }, 300); - }, - onDragLeave: () => { - // Set the state to idle or pending depending on the current tab - if (selectActiveTabCanvasRightPanel(store.getState()) === 'gallery') { - setGalleryTabDndState('idle'); - } else { - setGalleryTabDndState('pending'); - } - // Abort the tab switch if it hasn't happened yet - if (tabTimeout !== null) { - clearTimeout(tabTimeout); - } - }, + onDragEnter, + onDragLeave, }), monitorForElements({ - // Only monitor if we are not already on the gallery tab - canMonitor: () => selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery', - onDragStart: () => { - // Set the state to pending when a drag starts - setGalleryTabDndState('pending'); - }, + canMonitor, + onDragStart, + }), + dropTargetForExternal({ + element: galleryTabRef.current, + onDragEnter, + onDragLeave, + }), + monitorForExternal({ + canMonitor, + onDragStart, }) ); + }, [store]); - const sharedCleanup = monitorForElements({ - onDrop: () => { - // Reset the dnd state when a drop happens - setGalleryTabDndState('idle'); - setLayersTabDndState('idle'); - }, - }); + useEffect(() => { + const onDrop = () => { + // Reset the dnd state when a drop happens + setGalleryTabDndState('idle'); + setLayersTabDndState('idle'); + }; + const cleanup = combine(monitorForElements({ onDrop }), monitorForExternal({ onDrop })); return () => { - layersTabCleanup(); - galleryTabCleanup(); - sharedCleanup(); - if (tabTimeout !== null) { - clearTimeout(tabTimeout); + cleanup(); + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); } }; - }, [store]); + }, []); return ( <> diff --git a/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx b/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx index c119478f0c5..c5ac9ea873b 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx +++ b/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx @@ -40,7 +40,7 @@ export const DndDropOverlay = memo((props: Props) => { left={0.5} opacity={1} borderWidth={1.5} - borderColor={dndState === 'active' ? 'invokeYellow.300' : 'base.500'} + borderColor={dndState === 'over' ? 'invokeYellow.300' : 'base.500'} borderRadius="base" borderStyle="dashed" transitionProperty="common" @@ -52,7 +52,7 @@ export const DndDropOverlay = memo((props: Props) => { { + const result = sizeInBytes / (1024 * 1024); + return +result.toFixed(decimalsNum); +}; + +const zUploadFile = z + .custom() + .refine( + (file) => { + return sizeInMB(file.size) <= MAX_IMAGE_SIZE; + }, + () => ({ message: `The maximum image size is ${MAX_IMAGE_SIZE}MB` }) + ) + .refine( + (file) => { + return ACCEPTED_IMAGE_TYPES.includes(file.type); + }, + (file) => ({ message: `File type ${file.type} is not supported` }) + ) + .refine( + (file) => { + return ACCEPTED_FILE_EXTENSIONS.some((ext) => file.name.endsWith(ext)); + }, + (file) => ({ message: `File extension .${file.name.split('.').at(-1)} is not supported` }) + ); type Props = { - label?: string; - disabled?: boolean; + label: string; targetData: DndTargetData; + elementDropEnabled?: boolean; + externalDropEnabled?: boolean; }; export const DndDropTarget = memo((props: Props) => { - const { label, targetData, disabled } = props; + const { label, targetData, elementDropEnabled = true, externalDropEnabled = true } = props; const [dndState, setDndState] = useState('idle'); const ref = useRef(null); const dispatch = useAppDispatch(); @@ -25,11 +61,15 @@ export const DndDropTarget = memo((props: Props) => { return; } + if (!elementDropEnabled) { + return; + } + return combine( dropTargetForElements({ element: ref.current, canDrop: (args) => { - if (disabled) { + if (!elementDropEnabled) { return false; } const sourceData = args.source.data; @@ -39,10 +79,10 @@ export const DndDropTarget = memo((props: Props) => { return isValidDrop(sourceData, targetData); }, onDragEnter: () => { - setDndState('active'); + setDndState('over'); }, onDragLeave: () => { - setDndState('pending'); + setDndState('potential'); }, getData: () => targetData, onDrop: (args) => { @@ -55,9 +95,6 @@ export const DndDropTarget = memo((props: Props) => { }), monitorForElements({ canMonitor: (args) => { - if (disabled) { - return false; - } const sourceData = args.source.data; if (!isDndSourceData(sourceData)) { return false; @@ -65,14 +102,79 @@ export const DndDropTarget = memo((props: Props) => { return isValidDrop(sourceData, targetData); }, onDragStart: () => { - setDndState('pending'); + setDndState('potential'); + }, + onDrop: () => { + setDndState('idle'); + }, + }) + ); + }, [targetData, dispatch, elementDropEnabled]); + + useEffect(() => { + if (!ref.current) { + return; + } + + if (!externalDropEnabled) { + return; + } + + return combine( + dropTargetForExternal({ + element: ref.current, + canDrop: (args) => { + if (!externalDropEnabled) { + return false; + } + if (!containsFiles(args)) { + return false; + } + return true; + }, + onDragEnter: () => { + setDndState('over'); + }, + onDragLeave: () => { + setDndState('potential'); + }, + onDrop: async ({ source }) => { + const files = await getFiles({ source }); + for (const file of files) { + if (file === null) { + continue; + } + if (!zUploadFile.safeParse(file).success) { + continue; + } + const imageDTO = await uploadImage({ + blob: file, + fileName: file.name, + image_category: 'user', + is_intermediate: false, + }); + dispatch(dndDropped({ sourceData: singleImageDndSource.getData({ imageDTO }), targetData })); + } + }, + }), + monitorForExternal({ + canMonitor: (args) => { + if (!containsFiles(args)) { + return false; + } + return true; + }, + onDragStart: () => { + setDndState('potential'); + preventUnhandled.start(); }, onDrop: () => { setDndState('idle'); + preventUnhandled.stop(); }, }) ); - }, [targetData, disabled, dispatch]); + }, [targetData, dispatch, externalDropEnabled]); return ( { left={0} w="full" h="full" + // We must disable pointer events when idle to prevent the overlay from blocking clicks pointerEvents={dndState === 'idle' ? 'none' : 'auto'} > diff --git a/invokeai/frontend/web/src/features/dnd2/test.tsx b/invokeai/frontend/web/src/features/dnd2/test.tsx new file mode 100644 index 00000000000..365be27e8f1 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd2/test.tsx @@ -0,0 +1,335 @@ +/** + * @jsxRuntime classic + * @jsx jsx + */ +import Button from '@atlaskit/button/new'; +import ImageIcon from '@atlaskit/icon/core/migration/image'; +import { easeInOut } from '@atlaskit/motion/curves'; +import { largeDurationMs, mediumDurationMs } from '@atlaskit/motion/durations'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter'; +import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/external/file'; +import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; +import { token } from '@atlaskit/tokens'; +// eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766 +import { css } from '@emotion/react'; +import { bind } from 'bind-event-listener'; +import { Fragment, memo, useCallback, useEffect, useRef, useState } from 'react'; +import invariant from 'tiny-invariant'; + +import { GlobalStyles } from './util/global-styles'; + +const galleryStyles = css({ + display: 'flex', + width: '70vw', + alignItems: 'center', + justifyContent: 'center', + gap: 'var(--grid)', + flexWrap: 'wrap', +}); +const imageStyles = css({ + display: 'block', + // borrowing values from pinterest + // ratio: 0.6378378378 + width: '216px', + height: '340px', + objectFit: 'cover', +}); +const uploadStyles = css({ + // overflow: 'hidden', + position: 'relative', + // using these to hide the details + borderRadius: 'calc(var(--grid) * 2)', + overflow: 'hidden', + transition: `opacity ${largeDurationMs}ms ${easeInOut}, filter ${largeDurationMs}ms ${easeInOut}`, +}); +const loadingStyles = css({ + opacity: '0', + filter: 'blur(1.5rem)', +}); +const readyStyles = css({ + opacity: '1', + filter: 'blur(0)', +}); + +const uploadDetailStyles = css({ + display: 'flex', + boxSizing: 'border-box', + width: '100%', + padding: 'var(--grid)', + position: 'absolute', + bottom: 0, + gap: 'var(--grid)', + flexDirection: 'row', + // background: token('color.background.sunken', fallbackColor), + backgroundColor: 'rgba(255,255,255,0.5)', +}); + +const uploadFilenameStyles = css({ + flexGrow: '1', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +type UserUpload = { + type: 'image'; + dataUrl: string; + name: string; + size: number; +}; + +const Upload = memo(function Upload({ upload }: { upload: UserUpload }) { + const [state, setState] = useState<'loading' | 'ready'>('loading'); + const clearTimeout = useRef<() => void>(() => {}); + + useEffect(function mount() { + return function unmount() { + clearTimeout.current(); + }; + }, []); + + return ( +
+ { + // this is the _only_ way I could find to get the animation to run + // correctly every time in all browsers + // setTimeout(fn, 0) -> sometimes wouldn't work in chrome (event nesting two) + // requestAnimationFrame -> nope (event nesting two) + // requestIdleCallback -> nope (doesn't work in safari) + // I can find no reliable hook for applying the `ready` state, + // this is the best I could manage 😩 + const timerId = setTimeout(() => setState('ready'), 100); + clearTimeout.current = () => window.clearTimeout(timerId); + }} + /> +
+ {upload.name} + {Math.round(upload.size / 1000)}kB +
+
+ ); +}); + +const Gallery = memo(function Gallery({ uploads: uploads }: { uploads: UserUpload[] }) { + if (!uploads.length) { + return null; + } + + return ( +
+ {uploads.map((upload, index) => ( + + ))} +
+ ); +}); + +const fileStyles = css({ + display: 'flex', + flexDirection: 'column', + padding: 'calc(var(--grid) * 6) calc(var(--grid) * 4)', + boxSizing: 'border-box', + alignItems: 'center', + justifyContent: 'center', + background: token('elevation.surface.sunken', '#091E4208'), + borderRadius: 'var(--border-radius)', + transition: `all ${mediumDurationMs}ms ${easeInOut}`, + border: '2px dashed transparent', + width: '100%', + gap: token('space.300', '24px'), +}); + +const textStyles = css({ + color: token('color.text.disabled', '#091E424F'), + fontSize: '1.4rem', + display: 'flex', + alignItems: 'center', + gap: token('space.075'), +}); + +const overStyles = css({ + background: token('color.background.selected.hovered', '#CCE0FF'), + color: token('color.text.selected', '#0C66E4'), + borderColor: token('color.border.brand', '#0C66E4'), +}); + +const potentialStyles = css({ + borderColor: token('color.border.brand', '#0C66E4'), +}); + +const appStyles = css({ + display: 'flex', + alignItems: 'center', + gap: 'calc(var(--grid) * 2)', + flexDirection: 'column', +}); + +const displayNoneStyles = css({ display: 'none' }); + +function Uploader() { + const ref = useRef(null); + const [state, setState] = useState<'idle' | 'potential' | 'over'>('idle'); + const [uploads, setUploads] = useState([]); + + /** + * Creating a stable reference so that we can use it in our unmount effect. + * + * If we used uploads as a dependency in the second `useEffect` it would run + * every time the uploads changed, which is not desirable. + */ + const stableUploadsRef = useRef(uploads); + useEffect(() => { + stableUploadsRef.current = uploads; + }, [uploads]); + + useEffect(() => { + return () => { + /** + * MDN recommends explicitly releasing the object URLs when possible, + * instead of relying just on the browser's garbage collection. + */ + stableUploadsRef.current.forEach((upload) => { + URL.revokeObjectURL(upload.dataUrl); + }); + }; + }, []); + + const addUpload = useCallback((file: File | null) => { + if (!file) { + return; + } + + if (!file.type.startsWith('image/')) { + return; + } + + const upload: UserUpload = { + type: 'image', + dataUrl: URL.createObjectURL(file), + name: file.name, + size: file.size, + }; + setUploads((current) => [...current, upload]); + }, []); + + const onFileInputChange = useCallback( + (event: React.ChangeEvent) => { + const files = Array.from(event.currentTarget.files ?? []); + files.forEach(addUpload); + }, + [addUpload] + ); + + useEffect(() => { + const el = ref.current; + invariant(el); + return combine( + dropTargetForExternal({ + element: el, + canDrop: containsFiles, + onDragEnter: () => setState('over'), + onDragLeave: () => setState('potential'), + onDrop: async ({ source }) => { + const files = await getFiles({ source }); + + files.forEach((file) => { + if (file == null) { + return; + } + if (!file.type.startsWith('image/')) { + return; + } + const reader = new FileReader(); + reader.readAsDataURL(file); + + // for simplicity: + // - not handling errors + // - not aborting the + // - not unbinding the event listener when the effect is removed + bind(reader, { + type: 'load', + listener(event) { + const result = reader.result; + if (typeof result === 'string') { + const upload: UserUpload = { + type: 'image', + dataUrl: result, + name: file.name, + size: file.size, + }; + setUploads((current) => [...current, upload]); + } + }, + }); + }); + }, + }), + monitorForExternal({ + canMonitor: containsFiles, + onDragStart: () => { + setState('potential'); + preventUnhandled.start(); + }, + onDrop: () => { + setState('idle'); + preventUnhandled.stop(); + }, + }) + ); + }); + + /** + * We trigger the file input manually when clicking the button. This also + * works when selecting the button using a keyboard. + * + * We do this for two reasons: + * + * 1. Styling file inputs is very limited. + * 2. Associating the button as a label for the input only gives us pointer + * support, but does not work for keyboard. + */ + const inputRef = useRef(null); + const onInputTriggerClick = useCallback(() => { + inputRef.current?.click(); + }, []); + + return ( +
+
+ + Drop some images on me! + + + + + +
+ +
+ ); +} + +export default function Example() { + return ( + + + + + ); +} diff --git a/invokeai/frontend/web/src/features/dnd2/types.ts b/invokeai/frontend/web/src/features/dnd2/types.ts index 69ad02060af..21ea0c73eac 100644 --- a/invokeai/frontend/web/src/features/dnd2/types.ts +++ b/invokeai/frontend/web/src/features/dnd2/types.ts @@ -225,24 +225,30 @@ export const removeFromBoardDndTarget = buildDndTargetApi { const { t } = useTranslation(); + const boardsListRef = useRef(null); const selectedBoardId = useAppSelector(selectSelectedBoardId); const boardSearchText = useAppSelector(selectBoardSearchText); const queryArgs = useAppSelector(selectListBoardsQueryArgs); @@ -71,6 +75,14 @@ export const BoardsList = ({ isPrivate }: Props) => { } }, [isPrivate, allowPrivateBoards, t]); + useEffect(() => { + const element = boardsListRef.current; + if (!element) { + return; + } + return combine(autoScrollForElements({ element }), autoScrollForExternal({ element })); + }, []); + return ( { - + {boardElements.length ? ( boardElements ) : ( diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx index d3932564313..77d80c26b33 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsListWrapper.tsx @@ -1,10 +1,14 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import { autoScrollForExternal } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/external'; import { Box } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; import { selectAllowPrivateBoards } from 'features/system/store/configSelectors'; +import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import type { CSSProperties } from 'react'; -import { memo } from 'react'; +import { memo, useEffect, useRef } from 'react'; import { BoardsList } from './BoardsList'; @@ -15,11 +19,27 @@ const overlayScrollbarsStyles: CSSProperties = { const BoardsListWrapper = () => { const allowPrivateBoards = useAppSelector(selectAllowPrivateBoards); + const osRef = useRef(null); + useEffect(() => { + const elements = osRef.current?.osInstance()?.elements(); + if (!elements) { + return; + } + return combine( + autoScrollForElements({ element: elements.viewport }), + autoScrollForExternal({ element: elements.viewport }) + ); + }, []); return ( - + {allowPrivateBoards && } From 806dc9ea66cf4bb8b1a132512371faff0f87bb78 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 27 Oct 2024 19:22:07 +1000 Subject: [PATCH 03/39] perf(ui): improved gallery perf --- .../components/ImageGrid/GalleryImage.tsx | 19 ++----------------- .../components/ImageGrid/GalleryImageGrid.tsx | 18 ++++++++++-------- .../gallery/hooks/useScrollIntoView.ts | 2 ++ 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 668ae388c2e..385fe925744 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -9,14 +9,12 @@ import { $customStarUI } from 'app/store/nanostores/customStarUI'; import { useAppStore } from 'app/store/nanostores/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; -import IAIFillSkeleton from 'common/components/IAIFillSkeleton'; import { useBoolean } from 'common/hooks/useBoolean'; import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import { multipleImageDndSource, singleImageDndSource } from 'features/dnd2/types'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; -import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { MouseEvent, MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react'; @@ -81,7 +79,6 @@ const galleryImageContainerSX = { interface HoverableImageProps { imageDTO: ImageDTO; - index: number; } const selectAlwaysShouldImageSizeBadge = createSelector( @@ -89,17 +86,7 @@ const selectAlwaysShouldImageSizeBadge = createSelector( (gallery) => gallery.alwaysShowImageSizeBadge ); -export const GalleryImage = memo(({ index, imageDTO }: HoverableImageProps) => { - if (!imageDTO) { - return ; - } - - return ; -}); - -GalleryImage.displayName = 'GalleryImage'; - -const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => { +export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); const [element, ref] = useState(null); @@ -118,8 +105,6 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => { ); const isSelected = useAppSelector(selectIsSelected); - useScrollIntoView(element, isSelected, index); - useEffect(() => { if (!element) { return; @@ -224,7 +209,7 @@ const GalleryImageContent = memo(({ index, imageDTO }: HoverableImageProps) => { ); }); -GalleryImageContent.displayName = 'GalleryImageContent'; +GalleryImage.displayName = 'GalleryImage'; const HoverIcons = memo(({ imageDTO, isHovered }: { imageDTO: ImageDTO; isHovered: boolean }) => { const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx index 79fb1856fd8..fba719520ad 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx @@ -20,9 +20,9 @@ const GalleryImageGrid = () => { useGalleryHotkeys(); const { t } = useTranslation(); const queryArgs = useAppSelector(selectListImagesQueryArgs); - const { imageDTOs, isLoading, isError } = useListImagesQuery(queryArgs, { + const { hasImages, isLoading, isError } = useListImagesQuery(queryArgs, { selectFromResult: ({ data, isLoading, isSuccess, isError }) => ({ - imageDTOs: data?.items ?? EMPTY_ARRAY, + hasImages: data && data.items.length > 0, isLoading, isSuccess, isError, @@ -45,7 +45,7 @@ const GalleryImageGrid = () => { ); } - if (imageDTOs.length === 0) { + if (!hasImages) { return ( @@ -53,12 +53,12 @@ const GalleryImageGrid = () => { ); } - return ; + return ; }; export default memo(GalleryImageGrid); -const Content = () => { +const GalleryImageGridContent = memo(() => { const dispatch = useAppDispatch(); const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); @@ -178,12 +178,14 @@ const Content = () => { gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`} gap={1} > - {imageDTOs.map((imageDTO, index) => ( - + {imageDTOs.map((imageDTO) => ( + ))} ); -}; +}); + +GalleryImageGridContent.displayName = 'GalleryImageGridContent'; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts b/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts index 3753f044138..e999beb2957 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useScrollIntoView.ts @@ -17,6 +17,8 @@ import { useEffect } from 'react'; * @param isSelected Whether the image is selected. * @param index The index of the image in the gallery. * @returns + * + * @knip-ignore */ export const useScrollIntoView = (imageContainerRef: HTMLElement | null, isSelected: boolean, index: number) => { const areMultiplesSelected = useAppSelector(selectHasMultipleImagesSelected); From f0903d93be2e6b9c565ddb0ae66eb246438d8361 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 27 Oct 2024 20:30:37 +1000 Subject: [PATCH 04/39] perf(ui): more gallery perf improvements --- .../src/app/components/GlobalImageHotkeys.tsx | 5 +- .../components/ImageGrid/GalleryImage.tsx | 195 +++--------------- .../GalleryImageDeleteIconButton.tsx | 46 +++++ .../components/ImageGrid/GalleryImageGrid.tsx | 41 ++-- .../ImageGrid/GalleryImageHoverIcons.tsx | 28 +++ .../GalleryImageOpenInViewerIconButton.tsx | 32 +++ .../ImageGrid/GalleryImageSizeBadge.tsx | 29 +++ .../ImageGrid/GalleryImageStarIconButton.tsx | 51 +++++ .../ImageGrid/GalleryPagination.tsx | 9 +- .../components/ImageGrid/GallerySearch.tsx | 8 +- .../ImageGrid/GallerySelectionCountTag.tsx | 25 ++- .../ImageGrid/SizedSkeletonLoader.tsx | 13 ++ .../ToggleMetadataViewerButton.tsx | 6 +- .../components/ImageViewer/useImageViewer.ts | 1 - .../gallery/store/gallerySelectors.ts | 15 +- .../queue/components/SendToToggle.tsx | 21 +- 16 files changed, 304 insertions(+), 221 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/SizedSkeletonLoader.tsx 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/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 385fe925744..f60ae35fba8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,26 +1,21 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Box, Flex, Image, Skeleton, Text, useShiftModifier } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; +import { Box, Flex, Image } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; -import { $customStarUI } from 'app/store/nanostores/customStarUI'; import { useAppStore } from 'app/store/nanostores/store'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { useAppSelector } from 'app/store/storeHooks'; import { useBoolean } from 'common/hooks/useBoolean'; -import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import { multipleImageDndSource, singleImageDndSource } from 'features/dnd2/types'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; -import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/SizedSkeletonLoader'; +import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; -import type { MouseEvent, MouseEventHandler } from 'react'; +import type { MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiArrowsOutBold, PiStarBold, PiStarFill, PiTrashSimpleFill } from 'react-icons/pi'; -import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; // This class name is used to calculate the number of images that fit in the gallery @@ -35,6 +30,9 @@ const galleryImageContainerSX = { '&': { display: 'none' }, }, }, + '&[data-is-dragging=true]': { + opacity: 0.3, + }, '.gallery-image': { touchAction: 'none', userSelect: 'none', @@ -77,20 +75,14 @@ const galleryImageContainerSX = { }, } satisfies SystemStyleObject; -interface HoverableImageProps { +interface Props { imageDTO: ImageDTO; } -const selectAlwaysShouldImageSizeBadge = createSelector( - selectGallerySlice, - (gallery) => gallery.alwaysShowImageSizeBadge -); - -export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => { +export const GalleryImage = memo(({ imageDTO }: Props) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); const [element, ref] = useState(null); - const imageViewer = useImageViewer(); const selectIsSelectedForCompare = useMemo( () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name), [imageDTO.image_name] @@ -98,9 +90,14 @@ export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => { const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare); const selectIsSelected = useMemo( () => - createSelector(selectGallerySlice, (gallery) => - gallery.selection.some((i) => i.image_name === imageDTO.image_name) - ), + createSelector(selectGallerySlice, (gallery) => { + for (const selectedImage of gallery.selection) { + if (selectedImage.image_name === imageDTO.image_name) { + return true; + } + } + return false; + }), [imageDTO.image_name] ); const isSelected = useAppSelector(selectIsSelected); @@ -168,9 +165,11 @@ export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => { ); const onDoubleClick = useCallback>(() => { - imageViewer.open(); + // Use the atom here directly instead of the `useImageViewer` to avoid re-rendering the gallery when the viewer + // opened state changes. + $imageViewer.set(true); store.dispatch(imageToCompareChanged(null)); - }, [imageViewer, store]); + }, [store]); const dataTestId = useMemo(() => getGalleryImageDataTestId(imageDTO.image_name), [imageDTO.image_name]); @@ -179,9 +178,9 @@ export const GalleryImage = memo(({ imageDTO }: HoverableImageProps) => { return ( { } + fallback={} w={imageDTO.width} objectFit="contain" maxW="full" maxH="full" borderRadius="base" /> - + ); }); GalleryImage.displayName = 'GalleryImage'; - -const HoverIcons = memo(({ imageDTO, isHovered }: { imageDTO: ImageDTO; isHovered: boolean }) => { - const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge); - - return ( - <> - {(isHovered || alwaysShowImageSizeBadge) && } - {(isHovered || imageDTO.starred) && } - {isHovered && } - {isHovered && } - - ); -}); -HoverIcons.displayName = 'HoverIcons'; - -const DeleteIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { - const shift = useShiftModifier(); - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const onClick = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - if (!imageDTO) { - return; - } - dispatch(imagesToDeleteSelected([imageDTO])); - }, - [dispatch, imageDTO] - ); - - if (!shift) { - return null; - } - - return ( - } - tooltip={t('gallery.deleteImage_one')} - position="absolute" - bottom={2} - insetInlineEnd={2} - /> - ); -}); - -DeleteIcon.displayName = 'DeleteIcon'; - -const OpenInViewerIconButton = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { - const imageViewer = useImageViewer(); - const { t } = useTranslation(); - - const onClick = useCallback(() => { - imageViewer.openImageInViewer(imageDTO); - }, [imageDTO, imageViewer]); - - return ( - } - tooltip={t('gallery.openInViewer')} - position="absolute" - insetBlockStart={2} - insetInlineStart={2} - /> - ); -}); - -OpenInViewerIconButton.displayName = 'OpenInViewerIconButton'; - -const StarIcon = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { - const customStarUi = useStore($customStarUI); - const [starImages] = useStarImagesMutation(); - const [unstarImages] = useUnstarImagesMutation(); - - const toggleStarredState = useCallback(() => { - if (imageDTO.starred) { - unstarImages({ imageDTOs: [imageDTO] }); - } else { - starImages({ imageDTOs: [imageDTO] }); - } - }, [starImages, unstarImages, imageDTO]); - - const starIcon = useMemo(() => { - if (imageDTO.starred) { - return customStarUi ? customStarUi.on.icon : ; - } else { - return customStarUi ? customStarUi.off.icon : ; - } - }, [imageDTO.starred, customStarUi]); - - const starTooltip = useMemo(() => { - if (imageDTO.starred) { - return customStarUi ? customStarUi.off.text : 'Unstar'; - } else { - return customStarUi ? customStarUi.on.text : 'Star'; - } - }, [imageDTO.starred, customStarUi]); - - return ( - - ); -}); - -StarIcon.displayName = 'StarIcon'; - -const SizeBadge = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { - return ( - {`${imageDTO.width}x${imageDTO.height}`} - ); -}); - -SizeBadge.displayName = 'SizeBadge'; - -const SizedSkeleton = memo(({ width, height }: { width: number; height: number }) => { - return ; -}); - -SizedSkeleton.displayName = 'SizedSkeleton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx new file mode 100644 index 00000000000..600afbb1cf7 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx @@ -0,0 +1,46 @@ +import { useShiftModifier } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; +import type { MouseEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashSimpleFill } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; +}; + +export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => { + const shift = useShiftModifier(); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const onClick = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + if (!imageDTO) { + return; + } + dispatch(imagesToDeleteSelected([imageDTO])); + }, + [dispatch, imageDTO] + ); + + if (!shift) { + return null; + } + + return ( + } + tooltip={t('gallery.deleteImage_one')} + position="absolute" + bottom={2} + insetInlineEnd={2} + /> + ); +}); + +GalleryImageDeleteIconButton.displayName = 'GalleryImageDeleteIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx index fba719520ad..76ace602b60 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageGrid.tsx @@ -1,11 +1,14 @@ import { Box, Flex, Grid } from '@invoke-ai/ui-library'; -import { skipToken } from '@reduxjs/toolkit/query'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { GallerySelectionCountTag } from 'features/gallery/components/ImageGrid/GallerySelectionCountTag'; import { useGalleryHotkeys } from 'features/gallery/hooks/useGalleryHotkeys'; -import { selectGalleryImageMinimumWidth, selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { + selectGalleryImageMinimumWidth, + selectGalleryLimit, + selectListImagesQueryArgs, +} from 'features/gallery/store/gallerySelectors'; import { limitChanged } from 'features/gallery/store/gallerySlice'; import { debounce } from 'lodash-es'; import { memo, useEffect, useMemo, useState } from 'react'; @@ -61,11 +64,8 @@ export default memo(GalleryImageGrid); const GalleryImageGridContent = memo(() => { const dispatch = useAppDispatch(); const galleryImageMinimumWidth = useAppSelector(selectGalleryImageMinimumWidth); + const limit = useAppSelector(selectGalleryLimit); - const queryArgs = useAppSelector(selectListImagesQueryArgs); - const { imageDTOs } = useListImagesQuery(queryArgs, { - selectFromResult: ({ data }) => ({ imageDTOs: data?.items ?? EMPTY_ARRAY }), - }); // Use a callback ref to get reactivity on the container element because it is conditionally rendered const [container, containerRef] = useState(null); @@ -130,19 +130,19 @@ const GalleryImageGridContent = memo(() => { } // Always load at least 1 row of images - const limit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn); + const newLimit = Math.max(imagesPerRow, imagesPerRow * imagesPerColumn); - if (queryArgs === skipToken || queryArgs.limit === limit) { + if (limit === 0 || limit === newLimit) { return; } - dispatch(limitChanged(limit)); + dispatch(limitChanged(newLimit)); }, 300); - }, [container, dispatch, queryArgs]); + }, [container, dispatch, limit]); useEffect(() => { // We want to recalculate the limit when image size changes calculateNewLimit(); - }, [calculateNewLimit, galleryImageMinimumWidth, imageDTOs]); + }, [calculateNewLimit, galleryImageMinimumWidth]); useEffect(() => { if (!container) { @@ -178,9 +178,7 @@ const GalleryImageGridContent = memo(() => { gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`} gap={1} > - {imageDTOs.map((imageDTO) => ( - - ))} +
@@ -189,3 +187,18 @@ const GalleryImageGridContent = memo(() => { }); GalleryImageGridContent.displayName = 'GalleryImageGridContent'; + +const GalleryImageGridImages = memo(() => { + const queryArgs = useAppSelector(selectListImagesQueryArgs); + const { imageDTOs } = useListImagesQuery(queryArgs, { + selectFromResult: ({ data }) => ({ imageDTOs: data?.items ?? EMPTY_ARRAY }), + }); + return ( + <> + {imageDTOs.map((imageDTO) => ( + + ))} + + ); +}); +GalleryImageGridImages.displayName = 'GalleryImageGridImages'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx new file mode 100644 index 00000000000..dcaa5729d13 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx @@ -0,0 +1,28 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { GalleryImageDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryImageDeleteIconButton'; +import { GalleryImageOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton'; +import { GalleryImageSizeBadge } from 'features/gallery/components/ImageGrid/GalleryImageSizeBadge'; +import { GalleryImageStarIconButton } from 'features/gallery/components/ImageGrid/GalleryImageStarIconButton'; +import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors'; +import { memo } from 'react'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; + isHovered: boolean; +}; + +export const GalleryImageHoverIcons = memo(({ imageDTO, isHovered }: Props) => { + const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge); + + return ( + <> + {(isHovered || alwaysShowImageSizeBadge) && } + {(isHovered || imageDTO.starred) && } + {isHovered && } + {isHovered && } + + ); +}); + +GalleryImageHoverIcons.displayName = 'GalleryImageHoverIcons'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx new file mode 100644 index 00000000000..792248bce18 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx @@ -0,0 +1,32 @@ +import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArrowsOutBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; +}; + +export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => { + const imageViewer = useImageViewer(); + const { t } = useTranslation(); + + const onClick = useCallback(() => { + imageViewer.openImageInViewer(imageDTO); + }, [imageDTO, imageViewer]); + + return ( + } + tooltip={t('gallery.openInViewer')} + position="absolute" + insetBlockStart={2} + insetInlineStart={2} + /> + ); +}); + +GalleryImageOpenInViewerIconButton.displayName = 'GalleryImageOpenInViewerIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx new file mode 100644 index 00000000000..e7e473d86a6 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx @@ -0,0 +1,29 @@ +import { Text } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; +}; + +export const GalleryImageSizeBadge = memo(({ imageDTO }: Props) => { + return ( + {`${imageDTO.width}x${imageDTO.height}`} + ); +}); + +GalleryImageSizeBadge.displayName = 'GalleryImageSizeBadge'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx new file mode 100644 index 00000000000..7ba787afab1 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx @@ -0,0 +1,51 @@ +import { useStore } from '@nanostores/react'; +import { $customStarUI } from 'app/store/nanostores/customStarUI'; +import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { memo, useCallback } from 'react'; +import { PiStarBold, PiStarFill } from 'react-icons/pi'; +import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { + imageDTO: ImageDTO; +}; + +export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => { + const customStarUi = useStore($customStarUI); + const [starImages] = useStarImagesMutation(); + const [unstarImages] = useUnstarImagesMutation(); + + const toggleStarredState = useCallback(() => { + if (imageDTO.starred) { + unstarImages({ imageDTOs: [imageDTO] }); + } else { + starImages({ imageDTOs: [imageDTO] }); + } + }, [starImages, unstarImages, imageDTO]); + + if (customStarUi) { + return ( + + ); + } + + return ( + : } + tooltip={imageDTO.starred ? 'Unstar' : 'Star'} + position="absolute" + top={2} + insetInlineEnd={2} + /> + ); +}); + +GalleryImageStarIconButton.displayName = 'GalleryImageStarIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx index 3ccb475209d..eaca653b17c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryPagination.tsx @@ -58,6 +58,13 @@ type PageButtonProps = { }; const PageButton = memo(({ page, currentPage, goToPage }: PageButtonProps) => { + const onClick = useCallback(() => { + if (page === ELLIPSIS) { + return; + } + goToPage(page - 1); + }, [goToPage, page]); + if (page === ELLIPSIS) { return ( ); 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/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/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index b4cebd5eab5..c229374df9f 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,6 +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 selectSelection = createSelector(selectGallerySlice, (gallery) => gallery.selection); export const selectHasMultipleImagesSelected = createSelector(selectSelectionCount, (count) => count > 1); export const selectGalleryImageMinimumWidth = createSelector( selectGallerySlice, @@ -59,6 +62,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/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'; From 80f6f3e94c742e0c8977b87515906c6f0d1e74e9 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sun, 27 Oct 2024 22:26:09 +1000 Subject: [PATCH 05/39] feat(ui): migrate to `pragmatic-drag-and-drop` (wip 3) --- .../components/ControlLayer/ControlLayer.tsx | 7 +- .../IPAdapter/IPAdapterImagePreview.tsx | 85 +++++----- .../IPAdapter/IPAdapterSettings.tsx | 18 ++- .../components/RasterLayer/RasterLayer.tsx | 7 +- .../RegionalGuidanceIPAdapterSettings.tsx | 26 +-- .../web/src/features/dnd2/DndDropOverlay.tsx | 4 +- .../web/src/features/dnd2/DndDropTarget.tsx | 42 +++-- .../web/src/features/dnd2/DndImage.tsx | 65 ++++++++ .../frontend/web/src/features/dnd2/types.ts | 149 ++++++++++-------- .../Boards/BoardsList/BoardsList.tsx | 14 +- .../Boards/BoardsList/BoardsListWrapper.tsx | 16 +- .../Boards/BoardsList/GalleryBoard.tsx | 8 +- .../components/ImageGrid/GalleryImage.tsx | 14 +- .../ImageViewer/CurrentImagePreview.tsx | 55 +++---- .../ImageViewer/ImageComparisonDroppable.tsx | 8 +- .../ImageViewer/NoContentForViewer.tsx | 8 +- .../nodes/CurrentImage/CurrentImageNode.tsx | 7 +- .../inputs/ImageFieldInputComponent.tsx | 60 +++---- .../inspector/outputs/ImageOutputPreview.tsx | 12 +- .../UpscaleInitialImage.tsx | 26 ++- 20 files changed, 348 insertions(+), 283 deletions(-) create mode 100644 invokeai/frontend/web/src/features/dnd2/DndImage.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index 02f94321b67..7d59fd03760 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -12,7 +12,7 @@ import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityI import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import { replaceLayerWithImageDndTarget, type ReplaceLayerWithImageDndTargetData } from 'features/dnd2/types'; -import { memo, useMemo } from 'react'; +import { memo, useId, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; type Props = { @@ -25,9 +25,10 @@ export const ControlLayer = memo(({ id }: Props) => { () => ({ id, type: 'control_layer' }), [id] ); + const dndId = useId(); const targetData = useMemo( - () => replaceLayerWithImageDndTarget.getData({ entityIdentifier }), - [entityIdentifier] + () => replaceLayerWithImageDndTarget.getData({ dndId, entityIdentifier }), + [dndId, entityIdentifier] ); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx index 1941c5ecc09..2e2aff0f77e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx @@ -1,80 +1,69 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; -import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; -import { useNanoid } from 'common/hooks/useNanoid'; import type { ImageWithDims } from 'features/controlLayers/store/types'; -import type { ImageDraggableData, TypesafeDroppableData } from 'features/dnd/types'; -import { memo, useCallback, useEffect, useMemo } from 'react'; +import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { DndImage } from 'features/dnd2/DndImage'; +import type { DndTargetData } from 'features/dnd2/types'; +import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageDTO, PostUploadAction } from 'services/api/types'; import { $isConnected } from 'services/events/stores'; +const sx = { + position: 'relative', + w: 'full', + h: 'full', + alignItems: 'center', + borderColor: 'error.500', + borderStyle: 'solid', + borderWidth: 0, + borderRadius: 'base', + '&[data-error=true]': { + borderWidth: 1, + }, +} satisfies SystemStyleObject; + type Props = { image: ImageWithDims | null; onChangeImage: (imageDTO: ImageDTO | null) => void; - droppableData: TypesafeDroppableData; + targetData: DndTargetData; postUploadAction: PostUploadAction; }; -export const IPAdapterImagePreview = memo(({ image, onChangeImage, droppableData, postUploadAction }: Props) => { +export const IPAdapterImagePreview = memo(({ image, onChangeImage, targetData, postUploadAction }: Props) => { const { t } = useTranslation(); const isConnected = useStore($isConnected); - const dndId = useNanoid('ip_adapter_image_preview'); - - const { currentData: controlImage, isError: isErrorControlImage } = useGetImageDTOQuery( - image?.image_name ?? skipToken - ); + const imageDTOQueryResult = useGetImageDTOQuery(image?.image_name ?? skipToken); const handleResetControlImage = useCallback(() => { onChangeImage(null); }, [onChangeImage]); - const draggableData = useMemo(() => { - if (controlImage) { - return { - id: dndId, - payloadType: 'IMAGE_DTO', - payload: { imageDTO: controlImage }, - }; - } - }, [controlImage, dndId]); - useEffect(() => { - if (isConnected && isErrorControlImage) { + if (isConnected && imageDTOQueryResult.isError) { handleResetControlImage(); } - }, [handleResetControlImage, isConnected, isErrorControlImage]); + }, [handleResetControlImage, imageDTOQueryResult.isError, isConnected]); return ( - - - - {controlImage && ( - - } - tooltip={t('common.reset')} - /> - + + {imageDTOQueryResult.currentData && ( + <> + + + } + tooltip={t('common.reset')} + /> + + )} + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx index 70d321801ff..19d2e87bd91 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -19,8 +19,8 @@ import { import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import type { IPAImageDropData } from 'features/dnd/types'; -import { memo, useCallback, useMemo } from 'react'; +import { setGlobalReferenceImageDndTarget, type SetGlobalReferenceImageDndTargetData } from 'features/dnd2/types'; +import { memo, useCallback, useId, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold } from 'react-icons/pi'; import type { ImageDTO, IPAdapterModelConfig, IPALayerImagePostUploadAction } from 'services/api/types'; @@ -80,14 +80,16 @@ export const IPAdapterSettings = memo(() => { [dispatch, entityIdentifier] ); - const droppableData = useMemo( - () => ({ actionType: 'SET_IPA_IMAGE', context: { id: entityIdentifier.id }, id: entityIdentifier.id }), - [entityIdentifier.id] - ); + const dndId = useId(); + const postUploadAction = useMemo( () => ({ type: 'SET_IPA_IMAGE', id: entityIdentifier.id }), [entityIdentifier.id] ); + const targetData = useMemo( + () => setGlobalReferenceImageDndTarget.getData({ dndId, globalReferenceImageId: entityIdentifier.id }), + [dndId, entityIdentifier.id] + ); const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); const isBusy = useCanvasIsBusy(); @@ -122,9 +124,9 @@ export const IPAdapterSettings = memo(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 6919888035c..277a009a390 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -10,7 +10,7 @@ import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types' import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import type { ReplaceLayerWithImageDndTargetData } from 'features/dnd2/types'; import { replaceLayerWithImageDndTarget } from 'features/dnd2/types'; -import { memo, useMemo } from 'react'; +import { memo, useId, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; type Props = { @@ -19,10 +19,11 @@ type Props = { export const RasterLayer = memo(({ id }: Props) => { const { t } = useTranslation(); + const dndId = useId(); const entityIdentifier = useMemo>(() => ({ id, type: 'raster_layer' }), [id]); const targetData = useMemo( - () => replaceLayerWithImageDndTarget.getData({ entityIdentifier }), - [entityIdentifier] + () => replaceLayerWithImageDndTarget.getData({ dndId, entityIdentifier }), + [dndId, entityIdentifier] ); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index e6c6f292a35..68b615e7338 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -20,8 +20,9 @@ import { } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import type { RGIPAdapterImageDropData } from 'features/dnd/types'; -import { memo, useCallback, useMemo } from 'react'; +import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd2/types'; +import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd2/types'; +import { memo, useCallback, useId, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi'; import type { ImageDTO, IPAdapterModelConfig, RGIPAdapterImagePostUploadAction } from 'services/api/types'; @@ -34,6 +35,7 @@ type Props = { export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Props) => { const entityIdentifier = useEntityIdentifierContext('regional_guidance'); const { t } = useTranslation(); + const dndId = useId(); const dispatch = useAppDispatch(); const onDeleteIPAdapter = useCallback(() => { dispatch(rgIPAdapterDeleted({ entityIdentifier, referenceImageId })); @@ -91,14 +93,16 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro [dispatch, entityIdentifier, referenceImageId] ); - const droppableData = useMemo( - () => ({ - actionType: 'SET_RG_IP_ADAPTER_IMAGE', - context: { id: entityIdentifier.id, referenceImageId: referenceImageId }, - id: entityIdentifier.id, - }), - [entityIdentifier.id, referenceImageId] + const targetData = useMemo( + () => + setRegionalGuidanceReferenceImageDndTarget.getData({ + dndId, + regionalGuidanceId: entityIdentifier.id, + referenceImageId, + }), + [dndId, entityIdentifier.id, referenceImageId] ); + const postUploadAction = useMemo( () => ({ type: 'SET_RG_IP_ADAPTER_IMAGE', id: entityIdentifier.id, referenceImageId: referenceImageId }), [entityIdentifier.id, referenceImageId] @@ -151,9 +155,9 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro
diff --git a/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx b/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx index c5ac9ea873b..205795a0333 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx +++ b/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx @@ -40,7 +40,7 @@ export const DndDropOverlay = memo((props: Props) => { left={0.5} opacity={1} borderWidth={1.5} - borderColor={dndState === 'over' ? 'invokeYellow.300' : 'base.500'} + borderColor={dndState === 'over' ? 'invokeYellow.300' : 'base.300'} borderRadius="base" borderStyle="dashed" transitionProperty="common" @@ -52,7 +52,7 @@ export const DndDropOverlay = memo((props: Props) => { { return +result.toFixed(decimalsNum); }; +const sx = { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + w: 'full', + h: 'full', + // We must disable pointer events when idle to prevent the overlay from blocking clicks + '&[data-dnd-state="idle"]': { + pointerEvents: 'none', + }, +} satisfies SystemStyleObject; + const zUploadFile = z .custom() .refine( @@ -76,6 +92,9 @@ export const DndDropTarget = memo((props: Props) => { if (!isDndSourceData(sourceData)) { return false; } + if (targetData.dndId === sourceData.dndId) { + return false; + } return isValidDrop(sourceData, targetData); }, onDragEnter: () => { @@ -99,6 +118,9 @@ export const DndDropTarget = memo((props: Props) => { if (!isDndSourceData(sourceData)) { return false; } + if (targetData.dndId === sourceData.dndId) { + return false; + } return isValidDrop(sourceData, targetData); }, onDragStart: () => { @@ -153,7 +175,12 @@ export const DndDropTarget = memo((props: Props) => { image_category: 'user', is_intermediate: false, }); - dispatch(dndDropped({ sourceData: singleImageDndSource.getData({ imageDTO }), targetData })); + dispatch( + dndDropped({ + sourceData: singleImageDndSource.getData({ dndId: getPrefixedId('random-dnd-id'), imageDTO }), + targetData, + }) + ); } }, }), @@ -177,18 +204,7 @@ export const DndDropTarget = memo((props: Props) => { }, [targetData, dispatch, externalDropEnabled]); return ( - + ); diff --git a/invokeai/frontend/web/src/features/dnd2/DndImage.tsx b/invokeai/frontend/web/src/features/dnd2/DndImage.tsx new file mode 100644 index 00000000000..1474c7e8460 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd2/DndImage.tsx @@ -0,0 +1,65 @@ +import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library'; +import { Image } from '@invoke-ai/ui-library'; +import { useAppStore } from 'app/store/nanostores/store'; +import { singleImageDndSource } from 'features/dnd2/types'; +import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { memo, useEffect, useState } from 'react'; +import type { ImageDTO } from 'services/api/types'; + +const sx = { + objectFit: 'contain', + maxW: 'full', + maxH: 'full', + borderRadius: 'base', + cursor: 'grab', + '&[data-is-dragging=true]': { + opacity: 0.3, + }, +} satisfies SystemStyleObject; + +type Props = ImageProps & { + imageDTO: ImageDTO; + dndId: string; +}; + +export const DndImage = memo(({ imageDTO, dndId, ...rest }: Props) => { + const store = useAppStore(); + const [isDragging, setIsDragging] = useState(false); + const [element, ref] = useState(null); + + useEffect(() => { + if (!element) { + return; + } + return draggable({ + element, + getInitialData: () => { + return singleImageDndSource.getData({ dndId, imageDTO }); + }, + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + }); + }, [imageDTO, element, store, dndId]); + + useImageContextMenu(imageDTO, element); + + return ( + + ); +}); + +DndImage.displayName = 'DndImage'; diff --git a/invokeai/frontend/web/src/features/dnd2/types.ts b/invokeai/frontend/web/src/features/dnd2/types.ts index 21ea0c73eac..703def6a523 100644 --- a/invokeai/frontend/web/src/features/dnd2/types.ts +++ b/invokeai/frontend/web/src/features/dnd2/types.ts @@ -2,37 +2,47 @@ import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types' import type { BoardId } from 'features/gallery/store/types'; import type { ImageDTO } from 'services/api/types'; -export type DndData = Record; +type AnyRecord = Record; +export type BaseDndData = { dndId: string } & Record; const _buildDataTypeGuard = - (key: symbol) => - (data: DndData): data is T => { + (key: symbol) => + (data: AnyRecord): data is T => { return Boolean(data[key]); }; const _buildDataGetter = - (key: symbol) => + (key: symbol) => (data: Omit): T => { return { [key]: true, ...data, } as T; }; -const buildDndSourceApi = (key: symbol) => +const buildDndSourceApi = (key: symbol) => ({ key, typeGuard: _buildDataTypeGuard(key), getData: _buildDataGetter(key) }) as const; +type WithDndId = T & { dndId: string }; + +type DndData = Record> = { + [k in PrivateKey]: true; +} & WithDndId; + //#region DndSourceData const _SingleImageDndSourceDataKey = Symbol('SingleImageDndSourceData'); -export type SingleImageDndSourceData = { +export type SingleImageDndSourceDataPrev = { [_SingleImageDndSourceDataKey]: true; imageDTO: ImageDTO; }; +export type SingleImageDndSourceData = DndData; export const singleImageDndSource = buildDndSourceApi(_SingleImageDndSourceDataKey); const _MultipleImageDndSourceDataKey = Symbol('MultipleImageDndSourceData'); -export type MultipleImageDndSourceData = { - [_MultipleImageDndSourceDataKey]: true; - imageDTOs: ImageDTO[]; - boardId: BoardId; -}; +export type MultipleImageDndSourceData = DndData< + typeof _MultipleImageDndSourceDataKey, + { + imageDTOs: ImageDTO[]; + boardId: BoardId; + } +>; export const multipleImageDndSource = buildDndSourceApi(_MultipleImageDndSourceDataKey); /** @@ -40,7 +50,7 @@ export const multipleImageDndSource = buildDndSourceApi { +export const isDndSourceData = (data: AnyRecord): data is DndSourceData => { for (const sourceApi of sourceApis) { if (sourceApi.typeGuard(data)) { return true; @@ -52,27 +62,31 @@ export const isDndSourceData = (data: DndData): data is DndSourceData => { //#endregion //#region DndTargetData -const buildDndTargetApi = ( +const buildDndTargetApi = ( key: symbol, validateDrop: (sourceData: DndSourceData, targetData: T) => boolean ) => ({ key, typeGuard: _buildDataTypeGuard(key), getData: _buildDataGetter(key), validateDrop }) as const; const _SetGlobalReferenceImageDndTargetDataKey = Symbol('SetGlobalReferenceImageDndTargetData'); -export type SetGlobalReferenceImageDndTargetData = { - [_SetGlobalReferenceImageDndTargetDataKey]: true; - globalReferenceImageId: string; -}; +export type SetGlobalReferenceImageDndTargetData = DndData< + typeof _SetGlobalReferenceImageDndTargetDataKey, + { + globalReferenceImageId: string; + } +>; export const setGlobalReferenceImageDndTarget = buildDndTargetApi( _SetGlobalReferenceImageDndTargetDataKey, singleImageDndSource.typeGuard ); const _SetRegionalGuidanceReferenceImageDndTargetDataKey = Symbol('SetRegionalGuidanceReferenceImageDndTargetData'); -export type SetRegionalGuidanceReferenceImageDndTargetData = { - [_SetRegionalGuidanceReferenceImageDndTargetDataKey]: true; - regionalGuidanceId: string; - referenceImageId: string; -}; +export type SetRegionalGuidanceReferenceImageDndTargetData = DndData< + typeof _SetRegionalGuidanceReferenceImageDndTargetDataKey, + { + regionalGuidanceId: string; + referenceImageId: string; + } +>; export const setRegionalGuidanceReferenceImageDndTarget = buildDndTargetApi( _SetRegionalGuidanceReferenceImageDndTargetDataKey, @@ -80,36 +94,28 @@ export const setRegionalGuidanceReferenceImageDndTarget = ); const _AddRasterLayerFromImageDndTargetDataKey = Symbol('AddRasterLayerFromImageDndTargetData'); -export type AddRasterLayerFromImageDndTargetData = { - [_AddRasterLayerFromImageDndTargetDataKey]: true; -}; +export type AddRasterLayerFromImageDndTargetData = DndData; export const addRasterLayerFromImageDndTarget = buildDndTargetApi( _AddRasterLayerFromImageDndTargetDataKey, singleImageDndSource.typeGuard ); const _AddControlLayerFromImageDndTargetDataKey = Symbol('AddControlLayerFromImageDndTargetData'); -export type AddControlLayerFromImageDndTargetData = { - [_AddControlLayerFromImageDndTargetDataKey]: true; -}; +export type AddControlLayerFromImageDndTargetData = DndData; export const addControlLayerFromImageDndTarget = buildDndTargetApi( _AddControlLayerFromImageDndTargetDataKey, singleImageDndSource.typeGuard ); const _AddInpaintMaskFromImageDndTargetDataKey = Symbol('AddInpaintMaskFromImageDndTargetData'); -export type AddInpaintMaskFromImageDndTargetData = { - [_AddInpaintMaskFromImageDndTargetDataKey]: true; -}; +export type AddInpaintMaskFromImageDndTargetData = DndData; export const addInpaintMaskFromImageDndTarget = buildDndTargetApi( _AddInpaintMaskFromImageDndTargetDataKey, singleImageDndSource.typeGuard ); const _AddRegionalGuidanceFromImageDndTargetDataKey = Symbol('AddRegionalGuidanceFromImageDndTargetData'); -export type AddRegionalGuidanceFromImageDndTargetData = { - [_AddRegionalGuidanceFromImageDndTargetDataKey]: true; -}; +export type AddRegionalGuidanceFromImageDndTargetData = DndData; export const addRegionalGuidanceFromImageDndTarget = buildDndTargetApi( _AddRegionalGuidanceFromImageDndTargetDataKey, singleImageDndSource.typeGuard @@ -118,9 +124,9 @@ export const addRegionalGuidanceFromImageDndTarget = buildDndTargetApi; export const addRegionalGuidanceReferenceImageFromImageDndTarget = buildDndTargetApi( _AddRegionalGuidanceReferenceImageFromImageDndTargetDataKey, @@ -128,9 +134,9 @@ export const addRegionalGuidanceReferenceImageFromImageDndTarget = ); const _AddGlobalReferenceImageFromImageDndTargetDataKey = Symbol('AddGlobalReferenceImageFromImageDndTargetData'); -export type AddGlobalReferenceImageFromImageDndTargetData = { - [_AddGlobalReferenceImageFromImageDndTargetDataKey]: true; -}; +export type AddGlobalReferenceImageFromImageDndTargetData = DndData< + typeof _AddGlobalReferenceImageFromImageDndTargetDataKey +>; export const addGlobalReferenceImageFromImageDndTarget = buildDndTargetApi( _AddGlobalReferenceImageFromImageDndTargetDataKey, @@ -138,51 +144,66 @@ export const addGlobalReferenceImageFromImageDndTarget = ); const _ReplaceLayerWithImageDndTargetDataKey = Symbol('ReplaceLayerWithImageDndTargetData'); -export type ReplaceLayerWithImageDndTargetData = { - [_ReplaceLayerWithImageDndTargetDataKey]: true; - entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>; -}; +export type ReplaceLayerWithImageDndTargetData = DndData< + typeof _ReplaceLayerWithImageDndTargetDataKey, + { + entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>; + } +>; export const replaceLayerWithImageDndTarget = buildDndTargetApi( _ReplaceLayerWithImageDndTargetDataKey, singleImageDndSource.typeGuard ); const _SetUpscaleInitialImageFromImageDndTargetDataKey = Symbol('SetUpscaleInitialImageFromImageDndTargetData'); -export type SetUpscaleInitialImageFromImageDndTargetData = { - [_SetUpscaleInitialImageFromImageDndTargetDataKey]: true; -}; +export type SetUpscaleInitialImageFromImageDndTargetData = DndData< + typeof _SetUpscaleInitialImageFromImageDndTargetDataKey +>; export const setUpscaleInitialImageFromImageDndTarget = buildDndTargetApi( _SetUpscaleInitialImageFromImageDndTargetDataKey, singleImageDndSource.typeGuard ); const _SetNodeImageFieldDndTargetDataKey = Symbol('SetNodeImageFieldDndTargetData'); -export type SetNodeImageFieldDndTargetData = { - [_SetNodeImageFieldDndTargetDataKey]: true; - nodeId: string; - fieldName: string; -}; +export type SetNodeImageFieldDndTargetData = DndData< + typeof _SetNodeImageFieldDndTargetDataKey, + { + nodeId: string; + fieldName: string; + } +>; export const setNodeImageFieldDndTarget = buildDndTargetApi( _SetNodeImageFieldDndTargetDataKey, singleImageDndSource.typeGuard ); const _SelectForCompareDndTargetDataKey = Symbol('SelectForCompareDndTargetData'); -export type SelectForCompareDndTargetData = { - [_SelectForCompareDndTargetDataKey]: true; - firstImageName?: string | null; - secondImageName?: string | null; -}; +export type SelectForCompareDndTargetData = DndData< + typeof _SelectForCompareDndTargetDataKey, + { + firstImageName?: string | null; + secondImageName?: string | null; + } +>; export const selectForCompareDndTarget = buildDndTargetApi( _SelectForCompareDndTargetDataKey, singleImageDndSource.typeGuard ); +const _ToastDndTargetDataKey = Symbol('ToastDndTargetData'); +export type ToastDndTargetData = DndData; +export const ToastDndTarget = buildDndTargetApi( + _ToastDndTargetDataKey, + singleImageDndSource.typeGuard +); + const _AddToBoardDndTargetDataKey = Symbol('AddToBoardDndTargetData'); -export type AddToBoardDndTargetData = { - [_AddToBoardDndTargetDataKey]: true; - boardId: string; -}; +export type AddToBoardDndTargetData = DndData< + typeof _AddToBoardDndTargetDataKey, + { + boardId: string; + } +>; export const addToBoardDndTarget = buildDndTargetApi( _AddToBoardDndTargetDataKey, (sourceData, targetData) => { @@ -204,9 +225,7 @@ export const addToBoardDndTarget = buildDndTargetApi( ); const _RemoveFromBoardDndTargetDataKey = Symbol('RemoveFromBoardDndTargetData'); -export type RemoveFromBoardDndTargetData = { - [_RemoveFromBoardDndTargetDataKey]: true; -}; +export type RemoveFromBoardDndTargetData = DndData; export const removeFromBoardDndTarget = buildDndTargetApi( _RemoveFromBoardDndTargetDataKey, (sourceData) => { @@ -270,7 +289,7 @@ export type DndTargetData = | RemoveFromBoardDndTargetData | SelectForCompareDndTargetData; -export const isDndTargetData = (data: DndData): data is DndTargetData => { +export const isDndTargetData = (data: BaseDndData): data is DndTargetData => { for (const targetApi of targetApis) { if (targetApi.typeGuard(data)) { return true; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx index 809c2bd4666..f24b09b0e4b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx @@ -1,6 +1,3 @@ -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 { Button, Collapse, Flex, Icon, Text, useDisclosure } from '@invoke-ai/ui-library'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; @@ -11,7 +8,7 @@ import { selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; import { selectAllowPrivateBoards } from 'features/system/store/configSelectors'; -import { useEffect, useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretDownBold } from 'react-icons/pi'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; @@ -26,7 +23,6 @@ type Props = { export const BoardsList = ({ isPrivate }: Props) => { const { t } = useTranslation(); - const boardsListRef = useRef(null); const selectedBoardId = useAppSelector(selectSelectedBoardId); const boardSearchText = useAppSelector(selectBoardSearchText); const queryArgs = useAppSelector(selectListBoardsQueryArgs); @@ -75,14 +71,6 @@ export const BoardsList = ({ isPrivate }: Props) => { } }, [isPrivate, allowPrivateBoards, t]); - useEffect(() => { - const element = boardsListRef.current; - if (!element) { - return; - } - return combine(autoScrollForElements({ element }), autoScrollForExternal({ element })); - }, []); - return ( { const allowPrivateBoards = useAppSelector(selectAllowPrivateBoards); - const osRef = useRef(null); + const [os, osRef] = useState(null); useEffect(() => { - const elements = osRef.current?.osInstance()?.elements(); - if (!elements) { + const element = os?.osInstance()?.elements().viewport; + if (!element) { return; } - return combine( - autoScrollForElements({ element: elements.viewport }), - autoScrollForExternal({ element: elements.viewport }) - ); - }, []); + return combine(autoScrollForElements({ element }), autoScrollForExternal({ element })); + }, [os]); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index bb53f37cb42..c9de5f1fc92 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -15,7 +15,7 @@ import { selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useId, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArchiveBold, PiImageSquare } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; @@ -36,7 +36,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); const selectedBoardId = useAppSelector(selectSelectedBoardId); - + const dndId = useId(); const onClick = useCallback(() => { if (selectedBoardId !== board.board_id) { dispatch(boardIdSelected({ boardId: board.board_id })); @@ -47,8 +47,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { }, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]); const targetData: AddToBoardDndTargetData = useMemo( - () => addToBoardDndTarget.getData({ boardId: board.board_id }), - [board.board_id] + () => addToBoardDndTarget.getData({ dndId, boardId: board.board_id }), + [board.board_id, dndId] ); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index f60ae35fba8..5b5e5b25433 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -7,6 +7,7 @@ import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/lis import { useAppStore } from 'app/store/nanostores/store'; import { useAppSelector } from 'app/store/storeHooks'; import { useBoolean } from 'common/hooks/useBoolean'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; import { multipleImageDndSource, singleImageDndSource } from 'features/dnd2/types'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; @@ -15,7 +16,7 @@ import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/Sized import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { MouseEventHandler } from 'react'; -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; // This class name is used to calculate the number of images that fit in the gallery @@ -83,6 +84,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); const [element, ref] = useState(null); + const dndId = useId(); const selectIsSelectedForCompare = useMemo( () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare?.image_name === imageDTO.image_name), [imageDTO.image_name] @@ -114,11 +116,15 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { // When we have multiple images selected, and the dragged image is part of the selection, initiate a // multi-image drag. if (gallery.selection.length > 1 && gallery.selection.includes(imageDTO)) { - return multipleImageDndSource.getData({ imageDTOs: gallery.selection, boardId: gallery.selectedBoardId }); + return multipleImageDndSource.getData({ + dndId: getPrefixedId('dnd-gallery-selection'), + imageDTOs: gallery.selection, + boardId: gallery.selectedBoardId, + }); } // Otherwise, initiate a single-image drag - return singleImageDndSource.getData({ imageDTO }); + return singleImageDndSource.getData({ dndId, imageDTO }); }, // This is a "local" drag start event, meaning that it is only called when this specific image is dragged. onDragStart: (args) => { @@ -145,7 +151,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { }, }) ); - }, [imageDTO, element, store]); + }, [imageDTO, element, store, dndId]); const isHovered = useBoolean(false); 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..073a5a7588a 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/dnd2/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 2189ae6c9b7..5177eb48537 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx @@ -1,4 +1,3 @@ -import { Flex } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/storeHooks'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import type { SelectForCompareDndTargetData } from 'features/dnd2/types'; @@ -14,16 +13,13 @@ export const ImageComparisonDroppable = memo(() => { const targetData = useMemo(() => { const { firstImage, secondImage } = selectComparisonImages(store.getState()); return selectForCompareDndTarget.getData({ + dndId: 'current-image', firstImageName: firstImage?.image_name, secondImageName: secondImage?.image_name, }); }, [store]); - return ( - - - - ); + return ; }); ImageComparisonDroppable.displayName = 'ImageComparisonDroppable'; 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/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx index 23607e6e89f..c6f440618b0 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/dnd2/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'; @@ -10,7 +10,7 @@ import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants'; import type { AnimationProps } from 'framer-motion'; import { motion } from 'framer-motion'; import type { CSSProperties, PropsWithChildren } from 'react'; -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { NodeProps } from 'reactflow'; import { $lastProgressEvent } from 'services/events/stores'; @@ -18,6 +18,7 @@ import { $lastProgressEvent } from 'services/events/stores'; const CurrentImageNode = (props: NodeProps) => { const imageDTO = useAppSelector(selectLastSelectedImage); const lastProgressEvent = useStore($lastProgressEvent); + const dndId = useId(); if (lastProgressEvent?.image) { return ( @@ -30,7 +31,7 @@ const CurrentImageNode = (props: NodeProps) => { if (imageDTO) { 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..43a924b45bc 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,12 +2,14 @@ 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 { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { DndImage } from 'features/dnd2/DndImage'; +import type { SetNodeImageFieldDndTargetData } from 'features/dnd2/types'; +import { setNodeImageFieldDndTarget } from 'features/dnd2/types'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field'; -import { memo, useCallback, useEffect, useMemo } from 'react'; +import { memo, useCallback, useEffect, useId, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; @@ -17,11 +19,12 @@ 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 dndId = useId(); const handleReset = useCallback(() => { dispatch( fieldImageValueChanged({ @@ -32,23 +35,9 @@ 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 targetData = useMemo( + () => setNodeImageFieldDndTarget.getData({ dndId, nodeId, fieldName: field.name }), + [dndId, field.name, nodeId] ); const postUploadAction = useMemo( @@ -68,6 +57,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps - } - minSize={8} - > - : undefined} - tooltip="Reset Image" - /> - + {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..00dd746581b 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,5 +1,5 @@ -import IAIDndImage from 'common/components/IAIDndImage'; -import { memo } from 'react'; +import { DndImage } from 'features/dnd2/DndImage'; +import { memo, useId } from 'react'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageOutput } from 'services/api/types'; @@ -9,9 +9,13 @@ type Props = { const ImageOutputPreview = ({ output }: Props) => { const { image } = output; - const { data: imageDTO } = useGetImageDTOQuery(image.image_name); + const { currentData: imageDTO } = useGetImageDTOQuery(image.image_name); + const dndId = useId(); + if (!imageDTO) { + return null; + } - return ; + return ; }; export default memo(ImageOutputPreview); 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..baaaeea0c92 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx @@ -1,26 +1,21 @@ 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 { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { DndImage } from 'features/dnd2/DndImage'; +import { setUpscaleInitialImageFromImageDndTarget } from 'features/dnd2/types'; import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { t } from 'i18next'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useId, useMemo } from 'react'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; import type { PostUploadAction } from 'services/api/types'; +const targetData = setUpscaleInitialImageFromImageDndTarget.getData({}); + export const UpscaleInitialImage = () => { const dispatch = useAppDispatch(); const imageDTO = useAppSelector(selectUpscaleInitialImage); - - const droppableData = useMemo( - () => ({ - actionType: 'SET_UPSCALE_INITIAL_IMAGE', - id: 'upscale-intial-image', - }), - [] - ); - + const dndId = useId(); const postUploadAction = useMemo( () => ({ type: 'SET_UPSCALE_INITIAL_IMAGE', @@ -35,13 +30,9 @@ export const UpscaleInitialImage = () => { return ( - {imageDTO && ( <> + { >{`${imageDTO.width}x${imageDTO.height}`} )} + ); From 200c4a76899f2c948606e83a3c763ce61bcdfe3e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 28 Oct 2024 08:14:12 +1000 Subject: [PATCH 06/39] feat(ui): migrate to `pragmatic-drag-and-drop` (wip 4) --- .../listenerMiddleware/listeners/dnd.ts | 12 +- .../components/CanvasDropArea.tsx | 8 +- .../components/ControlLayer/ControlLayer.tsx | 7 +- .../IPAdapter/IPAdapterImagePreview.tsx | 12 +- .../IPAdapter/IPAdapterSettings.tsx | 12 +- .../components/RasterLayer/RasterLayer.tsx | 7 +- .../RegionalGuidanceIPAdapterSettings.tsx | 17 +- .../web/src/features/dnd2/DndDropTarget.tsx | 8 +- .../web/src/features/dnd2/DndImage.tsx | 9 +- .../frontend/web/src/features/dnd2/types.ts | 203 +++++++++++++++--- .../Boards/BoardsList/GalleryBoard.tsx | 7 +- .../components/ImageGrid/GalleryImage.tsx | 15 +- .../ImageViewer/CurrentImagePreview.tsx | 2 +- .../ImageViewer/ImageComparisonDroppable.tsx | 9 +- .../nodes/CurrentImage/CurrentImageNode.tsx | 5 +- .../inputs/ImageFieldInputComponent.tsx | 9 +- .../inspector/outputs/ImageOutputPreview.tsx | 5 +- .../UpscaleInitialImage.tsx | 5 +- 18 files changed, 244 insertions(+), 108 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts index 3905be2b1dc..26081cf6113 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts @@ -25,10 +25,10 @@ import type { } from 'features/controlLayers/store/types'; import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { - addControlLayerFromImageDndTarget, + newControlLayerFromImageDndTarget, addGlobalReferenceImageFromImageDndTarget, addInpaintMaskFromImageDndTarget, - addRasterLayerFromImageDndTarget, + newRasterLayerFromImageDndTarget, addRegionalGuidanceFromImageDndTarget, addRegionalGuidanceReferenceImageFromImageDndTarget, addToBoardDndTarget, @@ -100,8 +100,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Add raster layer from image if ( - addRasterLayerFromImageDndTarget.typeGuard(targetData) && - addRasterLayerFromImageDndTarget.validateDrop(sourceData, targetData) + newRasterLayerFromImageDndTarget.typeGuard(targetData) && + newRasterLayerFromImageDndTarget.validateDrop(sourceData, targetData) ) { const imageObject = imageDTOToImageObject(imageDTO); const { x, y } = selectCanvasSlice(getState()).bbox.rect; @@ -145,8 +145,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Add control layer from image if ( - addControlLayerFromImageDndTarget.typeGuard(targetData) && - addControlLayerFromImageDndTarget.validateDrop(sourceData, targetData) + newControlLayerFromImageDndTarget.typeGuard(targetData) && + newControlLayerFromImageDndTarget.validateDrop(sourceData, targetData) ) { const state = getState(); const imageObject = imageDTOToImageObject(imageDTO); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index 3834d425128..3fdbe4d0bff 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -1,17 +1,17 @@ import { Grid, GridItem } from '@invoke-ai/ui-library'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import { - addControlLayerFromImageDndTarget, + newControlLayerFromImageDndTarget, addGlobalReferenceImageFromImageDndTarget, - addRasterLayerFromImageDndTarget, + newRasterLayerFromImageDndTarget, addRegionalGuidanceReferenceImageFromImageDndTarget, } from 'features/dnd2/types'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -const addRasterLayerFromImageDndTargetData = addRasterLayerFromImageDndTarget.getData({}); -const addControlLayerFromImageDndTargetData = addControlLayerFromImageDndTarget.getData({}); +const addRasterLayerFromImageDndTargetData = newRasterLayerFromImageDndTarget.getData({}); +const addControlLayerFromImageDndTargetData = newControlLayerFromImageDndTarget.getData({}); const addRegionalGuidanceReferenceImageFromImageDndTargetData = addRegionalGuidanceReferenceImageFromImageDndTarget.getData({}); const addGlobalReferenceImageFromImageDndTargetData = addGlobalReferenceImageFromImageDndTarget.getData({}); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index 7d59fd03760..69e3240a192 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -12,7 +12,7 @@ import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityI import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import { replaceLayerWithImageDndTarget, type ReplaceLayerWithImageDndTargetData } from 'features/dnd2/types'; -import { memo, useId, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; type Props = { @@ -25,10 +25,9 @@ export const ControlLayer = memo(({ id }: Props) => { () => ({ id, type: 'control_layer' }), [id] ); - const dndId = useId(); const targetData = useMemo( - () => replaceLayerWithImageDndTarget.getData({ dndId, entityIdentifier }), - [dndId, entityIdentifier] + () => replaceLayerWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id), + [entityIdentifier] ); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx index 2e2aff0f77e..ee55c4fb767 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx @@ -38,22 +38,22 @@ type Props = { export const IPAdapterImagePreview = memo(({ image, onChangeImage, targetData, postUploadAction }: Props) => { const { t } = useTranslation(); const isConnected = useStore($isConnected); - const imageDTOQueryResult = useGetImageDTOQuery(image?.image_name ?? skipToken); + const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken); const handleResetControlImage = useCallback(() => { onChangeImage(null); }, [onChangeImage]); useEffect(() => { - if (isConnected && imageDTOQueryResult.isError) { + if (isConnected && isError) { handleResetControlImage(); } - }, [handleResetControlImage, imageDTOQueryResult.isError, isConnected]); + }, [handleResetControlImage, isError, isConnected]); return ( - - {imageDTOQueryResult.currentData && ( + + {imageDTO && ( <> - + { [dispatch, entityIdentifier] ); - const dndId = useId(); - const postUploadAction = useMemo( () => ({ type: 'SET_IPA_IMAGE', id: entityIdentifier.id }), [entityIdentifier.id] ); const targetData = useMemo( - () => setGlobalReferenceImageDndTarget.getData({ dndId, globalReferenceImageId: entityIdentifier.id }), - [dndId, entityIdentifier.id] + () => + setGlobalReferenceImageDndTarget.getData( + { globalReferenceImageId: entityIdentifier.id }, + ipAdapter.image?.image_name + ), + [entityIdentifier.id, ipAdapter.image?.image_name] ); const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); const isBusy = useCanvasIsBusy(); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 277a009a390..249cccbdea2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -10,7 +10,7 @@ import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types' import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import type { ReplaceLayerWithImageDndTargetData } from 'features/dnd2/types'; import { replaceLayerWithImageDndTarget } from 'features/dnd2/types'; -import { memo, useId, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; type Props = { @@ -19,11 +19,10 @@ type Props = { export const RasterLayer = memo(({ id }: Props) => { const { t } = useTranslation(); - const dndId = useId(); const entityIdentifier = useMemo>(() => ({ id, type: 'raster_layer' }), [id]); const targetData = useMemo( - () => replaceLayerWithImageDndTarget.getData({ dndId, entityIdentifier }), - [dndId, entityIdentifier] + () => replaceLayerWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id), + [entityIdentifier] ); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index 68b615e7338..9aa7c8f4160 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -22,7 +22,7 @@ import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'feature import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd2/types'; import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd2/types'; -import { memo, useCallback, useId, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi'; import type { ImageDTO, IPAdapterModelConfig, RGIPAdapterImagePostUploadAction } from 'services/api/types'; @@ -35,7 +35,6 @@ type Props = { export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Props) => { const entityIdentifier = useEntityIdentifierContext('regional_guidance'); const { t } = useTranslation(); - const dndId = useId(); const dispatch = useAppDispatch(); const onDeleteIPAdapter = useCallback(() => { dispatch(rgIPAdapterDeleted({ entityIdentifier, referenceImageId })); @@ -95,12 +94,14 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro const targetData = useMemo( () => - setRegionalGuidanceReferenceImageDndTarget.getData({ - dndId, - regionalGuidanceId: entityIdentifier.id, - referenceImageId, - }), - [dndId, entityIdentifier.id, referenceImageId] + setRegionalGuidanceReferenceImageDndTarget.getData( + { + regionalGuidanceId: entityIdentifier.id, + referenceImageId, + }, + ipAdapter.image?.image_name + ), + [entityIdentifier.id, ipAdapter.image?.image_name, referenceImageId] ); const postUploadAction = useMemo( diff --git a/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx index 5e5594b84d8..8315fb09caf 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx +++ b/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx @@ -10,7 +10,7 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { DndDropOverlay } from 'features/dnd2/DndDropOverlay'; import type { DndState, DndTargetData } from 'features/dnd2/types'; -import { isDndSourceData, isValidDrop, singleImageDndSource } from 'features/dnd2/types'; +import { getDndId, isDndSourceData, isValidDrop, singleImageDndSource } from 'features/dnd2/types'; import { memo, useEffect, useRef, useState } from 'react'; import { uploadImage } from 'services/api/endpoints/images'; import { z } from 'zod'; @@ -92,7 +92,7 @@ export const DndDropTarget = memo((props: Props) => { if (!isDndSourceData(sourceData)) { return false; } - if (targetData.dndId === sourceData.dndId) { + if (getDndId(targetData) === getDndId(sourceData)) { return false; } return isValidDrop(sourceData, targetData); @@ -118,7 +118,7 @@ export const DndDropTarget = memo((props: Props) => { if (!isDndSourceData(sourceData)) { return false; } - if (targetData.dndId === sourceData.dndId) { + if (getDndId(targetData) === getDndId(sourceData)) { return false; } return isValidDrop(sourceData, targetData); @@ -177,7 +177,7 @@ export const DndDropTarget = memo((props: Props) => { }); dispatch( dndDropped({ - sourceData: singleImageDndSource.getData({ dndId: getPrefixedId('random-dnd-id'), imageDTO }), + sourceData: singleImageDndSource.getData({ imageDTO }, getPrefixedId('dnd-upload-image')), targetData, }) ); diff --git a/invokeai/frontend/web/src/features/dnd2/DndImage.tsx b/invokeai/frontend/web/src/features/dnd2/DndImage.tsx index 1474c7e8460..4cf563b8eed 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd2/DndImage.tsx @@ -20,10 +20,9 @@ const sx = { type Props = ImageProps & { imageDTO: ImageDTO; - dndId: string; }; -export const DndImage = memo(({ imageDTO, dndId, ...rest }: Props) => { +export const DndImage = memo(({ imageDTO, ...rest }: Props) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); const [element, ref] = useState(null); @@ -34,9 +33,7 @@ export const DndImage = memo(({ imageDTO, dndId, ...rest }: Props) => { } return draggable({ element, - getInitialData: () => { - return singleImageDndSource.getData({ dndId, imageDTO }); - }, + getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name), onDragStart: () => { setIsDragging(true); }, @@ -44,7 +41,7 @@ export const DndImage = memo(({ imageDTO, dndId, ...rest }: Props) => { setIsDragging(false); }, }); - }, [imageDTO, element, store, dndId]); + }, [imageDTO, element, store]); useImageContextMenu(imageDTO, element); diff --git a/invokeai/frontend/web/src/features/dnd2/types.ts b/invokeai/frontend/web/src/features/dnd2/types.ts index 703def6a523..9c99247b8bd 100644 --- a/invokeai/frontend/web/src/features/dnd2/types.ts +++ b/invokeai/frontend/web/src/features/dnd2/types.ts @@ -1,41 +1,118 @@ +import { getPrefixedId } from 'features/controlLayers/konva/util'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import type { BoardId } from 'features/gallery/store/types'; import type { ImageDTO } from 'services/api/types'; -type AnyRecord = Record; -export type BaseDndData = { dndId: string } & Record; +/** + * A unique symbol key for a DndData object's ID. + */ +const _dndIdKey = Symbol('DndId'); +/** + * The base DndData type. It consists of an ID, keyed by the _dndIdKey symbol, and any arbitrary data. + */ +export type BaseDndData = { [_dndIdKey]: string } & Record; + +/** + * Builds a type guard for a specific DndData type. + * @param key The unique symbol key for the DndData type. + * @returns A type guard for the DndData type. + */ const _buildDataTypeGuard = - (key: symbol) => - (data: AnyRecord): data is T => { + (key: symbol) => + (data: Record): data is T => { return Boolean(data[key]); }; + +/** + * Builds a getter for a specific DndData type. + * + * The getter accepts arbitrary data and an optional Dnd ID. If no Dnd ID is provided, a unique one is generated. + * + * @param key The unique symbol key for the DndData type. + * @returns A getter for the DndData type. + */ const _buildDataGetter = - (key: symbol) => - (data: Omit): T => { + (key: symbol) => + (data: Omit, dndId?: string | null): T => { return { [key]: true, + [_dndIdKey]: dndId ?? getPrefixedId(`dnd-${key.toString()}`), ...data, } as T; }; -const buildDndSourceApi = (key: symbol) => - ({ key, typeGuard: _buildDataTypeGuard(key), getData: _buildDataGetter(key) }) as const; -type WithDndId = T & { dndId: string }; +/** + * An API for a Dnd source. It provides a type guard, a getter, and a unique symbol key for the DndData type. + */ +type DndSourceAPI = { + /** + * The unique symbol key for the DndData type. This is used to identify the type of data. + */ + key: symbol; + /** + * A type guard for the DndData type. + * @param data The data to check. + * @returns Whether the data is of the DndData type. + */ + typeGuard: ReturnType>; + /** + * A getter for the DndData type. + * @param data The data to get. + * @param dndId The Dnd ID to use. If not provided, a unique one is generated. + * @returns The DndData. + */ + getData: ReturnType>; +}; + +/** + * Builds a DndSourceAPI object. + * @param key The unique symbol key for the DndData type. + */ +const buildDndSourceApi = (key: symbol): DndSourceAPI => ({ + key, + typeGuard: _buildDataTypeGuard(key), + getData: _buildDataGetter(key), +}); +/** + * A helper type that adds a Dnd ID to a record type. + */ +type WithDndId> = T & { [_dndIdKey]: string }; + +/** + * A DndData object. It has three parts: + * - A unique symbol key, PrivateKey, that identifies the type of data. + * - A Dnd ID, which is a unique string that identifies the data. This is keyed to the _dndIdKey symbol. + * - Arbitrary data + */ type DndData = Record> = { [k in PrivateKey]: true; } & WithDndId; +/** + * Gets the Dnd ID from a DndData object. + * @param data The DndData object. + * @returns The Dnd ID. + */ +export const getDndId = (data: BaseDndData): string => { + return data[_dndIdKey]; +}; + //#region DndSourceData const _SingleImageDndSourceDataKey = Symbol('SingleImageDndSourceData'); -export type SingleImageDndSourceDataPrev = { - [_SingleImageDndSourceDataKey]: true; - imageDTO: ImageDTO; -}; +/** + * Dnd source data for a single image being dragged. + */ export type SingleImageDndSourceData = DndData; +/** + * Dnd source API for single image source. + */ export const singleImageDndSource = buildDndSourceApi(_SingleImageDndSourceDataKey); const _MultipleImageDndSourceDataKey = Symbol('MultipleImageDndSourceData'); +/** + * Dnd source data for multiple images being dragged. + */ export type MultipleImageDndSourceData = DndData< typeof _MultipleImageDndSourceDataKey, { @@ -43,14 +120,21 @@ export type MultipleImageDndSourceData = DndData< boardId: BoardId; } >; +/** + * Dnd source API for multiple image source. + */ export const multipleImageDndSource = buildDndSourceApi(_MultipleImageDndSourceDataKey); +const sourceApis = [singleImageDndSource, multipleImageDndSource] as const; /** * A union of all possible DndSourceData types. */ -const sourceApis = [singleImageDndSource, multipleImageDndSource] as const; export type DndSourceData = SingleImageDndSourceData | MultipleImageDndSourceData; -export const isDndSourceData = (data: AnyRecord): data is DndSourceData => { +/** + * Checks if the data is a DndSourceData object. + * @param data The data to check. + */ +export const isDndSourceData = (data: Record): data is DndSourceData => { for (const sourceApi of sourceApis) { if (sourceApi.typeGuard(data)) { return true; @@ -62,24 +146,56 @@ export const isDndSourceData = (data: AnyRecord): data is DndSourceData => { //#endregion //#region DndTargetData +/** + * An API for a Dnd target. It extends the DndSourceAPI with a validateDrop function. + */ +type DndTargetApi = DndSourceAPI & { + /** + * Validates whether a drop is valid, give the source and target data. + * @param sourceData The source data (i.e. the data being dragged) + * @param targetData The target data (i.e. the data being dragged onto) + * @returns Whether the drop is valid. + */ + validateDrop: (sourceData: DndSourceData, targetData: T) => boolean; +}; + +/** + * Builds a DndTargetApi object. + * @param key The unique symbol key for the DndData type. + * @param validateDrop A function that validates whether a drop is valid. + */ const buildDndTargetApi = ( key: symbol, - validateDrop: (sourceData: DndSourceData, targetData: T) => boolean -) => ({ key, typeGuard: _buildDataTypeGuard(key), getData: _buildDataGetter(key), validateDrop }) as const; + validateDrop: DndTargetApi['validateDrop'] +): DndTargetApi => ({ + key, + typeGuard: _buildDataTypeGuard(key), + getData: _buildDataGetter(key), + validateDrop, +}); const _SetGlobalReferenceImageDndTargetDataKey = Symbol('SetGlobalReferenceImageDndTargetData'); +/** + * Dnd target data for setting the image on an existing Global Reference Image layer. + */ export type SetGlobalReferenceImageDndTargetData = DndData< typeof _SetGlobalReferenceImageDndTargetDataKey, { globalReferenceImageId: string; } >; +/** + * Dnd target API for setting the image on an existing Global Reference Image layer. + */ export const setGlobalReferenceImageDndTarget = buildDndTargetApi( _SetGlobalReferenceImageDndTargetDataKey, singleImageDndSource.typeGuard ); const _SetRegionalGuidanceReferenceImageDndTargetDataKey = Symbol('SetRegionalGuidanceReferenceImageDndTargetData'); +/** + * Dnd target data for setting the image on an existing Regional Guidance layer's Reference Image. + */ export type SetRegionalGuidanceReferenceImageDndTargetData = DndData< typeof _SetRegionalGuidanceReferenceImageDndTargetDataKey, { @@ -87,23 +203,38 @@ export type SetRegionalGuidanceReferenceImageDndTargetData = DndData< referenceImageId: string; } >; +/** + * Dnd target API for setting the image on an existing Regional Guidance layer's Reference Image. + */ export const setRegionalGuidanceReferenceImageDndTarget = buildDndTargetApi( _SetRegionalGuidanceReferenceImageDndTargetDataKey, singleImageDndSource.typeGuard ); -const _AddRasterLayerFromImageDndTargetDataKey = Symbol('AddRasterLayerFromImageDndTargetData'); -export type AddRasterLayerFromImageDndTargetData = DndData; -export const addRasterLayerFromImageDndTarget = buildDndTargetApi( - _AddRasterLayerFromImageDndTargetDataKey, +const _NewRasterLayerFromImageDndTargetDataKey = Symbol('NewRasterLayerFromImageDndTargetData'); +/** + * Dnd target data for creating a new a Raster Layer from an image. + */ +export type NewRasterLayerFromImageDndTargetData = DndData; +/** + * Dnd target API for creating a new a Raster Layer from an image. + */ +export const newRasterLayerFromImageDndTarget = buildDndTargetApi( + _NewRasterLayerFromImageDndTargetDataKey, singleImageDndSource.typeGuard ); -const _AddControlLayerFromImageDndTargetDataKey = Symbol('AddControlLayerFromImageDndTargetData'); -export type AddControlLayerFromImageDndTargetData = DndData; -export const addControlLayerFromImageDndTarget = buildDndTargetApi( - _AddControlLayerFromImageDndTargetDataKey, +const _NewControlLayerFromImageDndTargetDataKey = Symbol('NewControlLayerFromImageDndTargetData'); +/** + * Dnd target data for creating a new a Control Layer from an image. + */ +export type NewControlLayerFromImageDndTargetData = DndData; +/** + * Dnd target API for creating a new a Control Layer from an image. + */ +export const newControlLayerFromImageDndTarget = buildDndTargetApi( + _NewControlLayerFromImageDndTargetDataKey, singleImageDndSource.typeGuard ); @@ -187,7 +318,19 @@ export type SelectForCompareDndTargetData = DndData< >; export const selectForCompareDndTarget = buildDndTargetApi( _SelectForCompareDndTargetDataKey, - singleImageDndSource.typeGuard + (sourceData, targetData) => { + if (!singleImageDndSource.typeGuard(sourceData)) { + return false; + } + // Do not allow the same images to be selected for comparison + if (sourceData.imageDTO.image_name === targetData.firstImageName) { + return false; + } + if (sourceData.imageDTO.image_name === targetData.secondImageName) { + return false; + } + return true; + } ); const _ToastDndTargetDataKey = Symbol('ToastDndTargetData'); @@ -248,8 +391,8 @@ const targetApis = [ setGlobalReferenceImageDndTarget, setRegionalGuidanceReferenceImageDndTarget, // Add layer from image - addRasterLayerFromImageDndTarget, - addControlLayerFromImageDndTarget, + newRasterLayerFromImageDndTarget, + newControlLayerFromImageDndTarget, // Add a layer w/ ref image preset addGlobalReferenceImageFromImageDndTarget, addRegionalGuidanceReferenceImageFromImageDndTarget, @@ -276,8 +419,8 @@ const targetApis = [ export type DndTargetData = | SetGlobalReferenceImageDndTargetData | SetRegionalGuidanceReferenceImageDndTargetData - | AddRasterLayerFromImageDndTargetData - | AddControlLayerFromImageDndTargetData + | NewRasterLayerFromImageDndTargetData + | NewControlLayerFromImageDndTargetData | AddInpaintMaskFromImageDndTargetData | AddRegionalGuidanceFromImageDndTargetData | AddRegionalGuidanceReferenceImageFromImageDndTargetData diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index c9de5f1fc92..77c619a5b77 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -15,7 +15,7 @@ import { selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useId, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArchiveBold, PiImageSquare } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; @@ -36,7 +36,6 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); const selectedBoardId = useAppSelector(selectSelectedBoardId); - const dndId = useId(); const onClick = useCallback(() => { if (selectedBoardId !== board.board_id) { dispatch(boardIdSelected({ boardId: board.board_id })); @@ -47,8 +46,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { }, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]); const targetData: AddToBoardDndTargetData = useMemo( - () => addToBoardDndTarget.getData({ dndId, boardId: board.board_id }), - [board.board_id, dndId] + () => addToBoardDndTarget.getData({ boardId: board.board_id }), + [board.board_id] ); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 5b5e5b25433..41f2b2f143f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -7,7 +7,6 @@ import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/lis import { useAppStore } from 'app/store/nanostores/store'; import { useAppSelector } from 'app/store/storeHooks'; import { useBoolean } from 'common/hooks/useBoolean'; -import { getPrefixedId } from 'features/controlLayers/konva/util'; import { multipleImageDndSource, singleImageDndSource } from 'features/dnd2/types'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; @@ -116,15 +115,17 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { // When we have multiple images selected, and the dragged image is part of the selection, initiate a // multi-image drag. if (gallery.selection.length > 1 && gallery.selection.includes(imageDTO)) { - return multipleImageDndSource.getData({ - dndId: getPrefixedId('dnd-gallery-selection'), - imageDTOs: gallery.selection, - boardId: gallery.selectedBoardId, - }); + return multipleImageDndSource.getData( + { + imageDTOs: gallery.selection, + boardId: gallery.selectedBoardId, + }, + 'gallery-selection' + ); } // Otherwise, initiate a single-image drag - return singleImageDndSource.getData({ dndId, imageDTO }); + return singleImageDndSource.getData({ imageDTO }, imageDTO.image_name); }, // This is a "local" drag start event, meaning that it is only called when this specific image is dragged. onDragStart: (args) => { 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 073a5a7588a..ed954cc7127 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -96,7 +96,7 @@ const ImageContent = memo(({ imageDTO }: { imageDTO?: ImageDTO }) => { return ( - + ); }); 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 5177eb48537..993e37479e2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx @@ -1,4 +1,4 @@ -import { useAppStore } from 'app/store/storeHooks'; +import { useAppSelector } from 'app/store/storeHooks'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import type { SelectForCompareDndTargetData } from 'features/dnd2/types'; import { selectForCompareDndTarget } from 'features/dnd2/types'; @@ -9,15 +9,14 @@ import { selectComparisonImages } from './common'; export const ImageComparisonDroppable = memo(() => { const { t } = useTranslation(); - const store = useAppStore(); + const comparisonImages = useAppSelector(selectComparisonImages); const targetData = useMemo(() => { - const { firstImage, secondImage } = selectComparisonImages(store.getState()); + const { firstImage, secondImage } = comparisonImages; return selectForCompareDndTarget.getData({ - dndId: 'current-image', firstImageName: firstImage?.image_name, secondImageName: secondImage?.image_name, }); - }, [store]); + }, [comparisonImages]); return ; }); 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 c6f440618b0..e0c947d6940 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 @@ -10,7 +10,7 @@ import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants'; import type { AnimationProps } from 'framer-motion'; import { motion } from 'framer-motion'; import type { CSSProperties, PropsWithChildren } from 'react'; -import { memo, useCallback, useId, useState } from 'react'; +import { memo, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { NodeProps } from 'reactflow'; import { $lastProgressEvent } from 'services/events/stores'; @@ -18,7 +18,6 @@ import { $lastProgressEvent } from 'services/events/stores'; const CurrentImageNode = (props: NodeProps) => { const imageDTO = useAppSelector(selectLastSelectedImage); const lastProgressEvent = useStore($lastProgressEvent); - const dndId = useId(); if (lastProgressEvent?.image) { return ( @@ -31,7 +30,7 @@ const CurrentImageNode = (props: NodeProps) => { if (imageDTO) { 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 43a924b45bc..7c3e6294270 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 @@ -9,7 +9,7 @@ import type { SetNodeImageFieldDndTargetData } from 'features/dnd2/types'; import { setNodeImageFieldDndTarget } from 'features/dnd2/types'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field'; -import { memo, useCallback, useEffect, useId, useMemo } from 'react'; +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'; @@ -24,7 +24,6 @@ const ImageFieldInputComponent = (props: FieldComponentProps { dispatch( fieldImageValueChanged({ @@ -36,8 +35,8 @@ const ImageFieldInputComponent = (props: FieldComponentProps( - () => setNodeImageFieldDndTarget.getData({ dndId, nodeId, fieldName: field.name }), - [dndId, field.name, nodeId] + () => setNodeImageFieldDndTarget.getData({ nodeId, fieldName: field.name }, field.value?.image_name), + [field.name, field.value?.image_name, nodeId] ); const postUploadAction = useMemo( @@ -70,7 +69,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps {imageDTO && ( <> - + { const { image } = output; const { currentData: imageDTO } = useGetImageDTOQuery(image.image_name); - const dndId = useId(); if (!imageDTO) { return null; } - return ; + return ; }; export default memo(ImageOutputPreview); 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 baaaeea0c92..9f204076cf6 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx @@ -6,7 +6,7 @@ import { DndImage } from 'features/dnd2/DndImage'; import { setUpscaleInitialImageFromImageDndTarget } from 'features/dnd2/types'; import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { t } from 'i18next'; -import { useCallback, useId, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; import type { PostUploadAction } from 'services/api/types'; @@ -15,7 +15,6 @@ const targetData = setUpscaleInitialImageFromImageDndTarget.getData({}); export const UpscaleInitialImage = () => { const dispatch = useAppDispatch(); const imageDTO = useAppSelector(selectUpscaleInitialImage); - const dndId = useId(); const postUploadAction = useMemo( () => ({ type: 'SET_UPSCALE_INITIAL_IMAGE', @@ -32,7 +31,7 @@ export const UpscaleInitialImage = () => { {imageDTO && ( <> - + Date: Mon, 28 Oct 2024 15:10:54 +1000 Subject: [PATCH 07/39] feat(ui): simpler dnd typing implementation --- .../listenerMiddleware/listeners/dnd.ts | 105 ++-- .../components/CanvasDropArea.tsx | 15 +- .../components/CanvasRightPanel.tsx | 6 +- .../components/ControlLayer/ControlLayer.tsx | 6 +- .../IPAdapter/IPAdapterImagePreview.tsx | 4 +- .../IPAdapter/IPAdapterSettings.tsx | 6 +- .../components/RasterLayer/RasterLayer.tsx | 7 +- .../RegionalGuidanceIPAdapterSettings.tsx | 12 +- .../web/src/features/dnd2/DndDropOverlay.tsx | 4 +- .../web/src/features/dnd2/DndDropTarget.tsx | 25 +- .../web/src/features/dnd2/DndImage.tsx | 4 +- .../frontend/web/src/features/dnd2/dnd.ts | 414 ++++++++++++++++ .../frontend/web/src/features/dnd2/types.ts | 465 ------------------ .../Boards/BoardsList/GalleryBoard.tsx | 7 +- .../Boards/BoardsList/NoBoardBoard.tsx | 8 +- .../components/ImageGrid/GalleryImage.tsx | 15 +- .../ImageViewer/ImageComparisonDroppable.tsx | 7 +- .../inputs/ImageFieldInputComponent.tsx | 7 +- .../UpscaleInitialImage.tsx | 8 +- 19 files changed, 523 insertions(+), 602 deletions(-) create mode 100644 invokeai/frontend/web/src/features/dnd2/dnd.ts delete mode 100644 invokeai/frontend/web/src/features/dnd2/types.ts diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts index 26081cf6113..2bc76b1d1b9 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts @@ -24,26 +24,7 @@ import type { CanvasRegionalGuidanceState, } from 'features/controlLayers/store/types'; import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import { - newControlLayerFromImageDndTarget, - addGlobalReferenceImageFromImageDndTarget, - addInpaintMaskFromImageDndTarget, - newRasterLayerFromImageDndTarget, - addRegionalGuidanceFromImageDndTarget, - addRegionalGuidanceReferenceImageFromImageDndTarget, - addToBoardDndTarget, - type DndSourceData, - type DndTargetData, - multipleImageDndSource, - removeFromBoardDndTarget, - replaceLayerWithImageDndTarget, - selectForCompareDndTarget, - setGlobalReferenceImageDndTarget, - setNodeImageFieldDndTarget, - setRegionalGuidanceReferenceImageDndTarget, - setUpscaleInitialImageFromImageDndTarget, - singleImageDndSource, -} from 'features/dnd2/types'; +import { Dnd } from 'features/dnd2/dnd'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; @@ -52,8 +33,8 @@ import { imagesApi } from 'services/api/endpoints/images'; const log = logger('system'); export const dndDropped = createAction<{ - sourceData: DndSourceData; - targetData: DndTargetData; + sourceData: Dnd.types['SourceDataUnion']; + targetData: Dnd.types['TargetDataUnion']; }>('dnd/dndDropped2'); export const addDndDroppedListener = (startAppListening: AppStartListening) => { @@ -63,16 +44,16 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { const { sourceData, targetData } = action.payload; // Single image dropped - if (singleImageDndSource.typeGuard(sourceData)) { + if (Dnd.Source.singleImage.typeGuard(sourceData)) { log.debug({ sourceData, targetData }, 'Image dropped'); - const { imageDTO } = sourceData; + const { imageDTO } = sourceData.payload; // Image dropped on IP Adapter if ( - setGlobalReferenceImageDndTarget.typeGuard(targetData) && - setGlobalReferenceImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.setGlobalReferenceImage.typeGuard(targetData) && + Dnd.Target.setGlobalReferenceImage.validateDrop(sourceData, targetData) ) { - const { globalReferenceImageId } = targetData; + const { globalReferenceImageId } = targetData.payload; dispatch( referenceImageIPAdapterImageChanged({ entityIdentifier: { id: globalReferenceImageId, type: 'reference_image' }, @@ -84,10 +65,10 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { //Image dropped on Regional Guidance IP Adapter if ( - setRegionalGuidanceReferenceImageDndTarget.typeGuard(targetData) && - setRegionalGuidanceReferenceImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.setRegionalGuidanceReferenceImage.typeGuard(targetData) && + Dnd.Target.setRegionalGuidanceReferenceImage.validateDrop(sourceData, targetData) ) { - const { regionalGuidanceId, referenceImageId } = targetData; + const { regionalGuidanceId, referenceImageId } = targetData.payload; dispatch( rgIPAdapterImageChanged({ entityIdentifier: { id: regionalGuidanceId, type: 'regional_guidance' }, @@ -100,8 +81,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Add raster layer from image if ( - newRasterLayerFromImageDndTarget.typeGuard(targetData) && - newRasterLayerFromImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.newRasterLayerFromImage.typeGuard(targetData) && + Dnd.Target.newRasterLayerFromImage.validateDrop(sourceData, targetData) ) { const imageObject = imageDTOToImageObject(imageDTO); const { x, y } = selectCanvasSlice(getState()).bbox.rect; @@ -115,8 +96,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Add inpaint mask from image if ( - addInpaintMaskFromImageDndTarget.typeGuard(targetData) && - addInpaintMaskFromImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.newInpaintMaskFromImage.typeGuard(targetData) && + Dnd.Target.newInpaintMaskFromImage.validateDrop(sourceData, targetData) ) { const imageObject = imageDTOToImageObject(imageDTO); const { x, y } = selectCanvasSlice(getState()).bbox.rect; @@ -130,8 +111,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Add regional guidance from image if ( - addRegionalGuidanceFromImageDndTarget.typeGuard(targetData) && - addRegionalGuidanceFromImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.newRegionalGuidanceFromImage.typeGuard(targetData) && + Dnd.Target.newRegionalGuidanceFromImage.validateDrop(sourceData, targetData) ) { const imageObject = imageDTOToImageObject(imageDTO); const { x, y } = selectCanvasSlice(getState()).bbox.rect; @@ -145,8 +126,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Add control layer from image if ( - newControlLayerFromImageDndTarget.typeGuard(targetData) && - newControlLayerFromImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.newControlLayerFromImage.typeGuard(targetData) && + Dnd.Target.newControlLayerFromImage.validateDrop(sourceData, targetData) ) { const state = getState(); const imageObject = imageDTOToImageObject(imageDTO); @@ -163,8 +144,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Add regional guidance layer w/ reference image from image if ( - addRegionalGuidanceReferenceImageFromImageDndTarget.typeGuard(targetData) && - addRegionalGuidanceReferenceImageFromImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.newRegionalGuidanceReferenceImageFromImage.typeGuard(targetData) && + Dnd.Target.newRegionalGuidanceReferenceImageFromImage.validateDrop(sourceData, targetData) ) { const state = getState(); const ipAdapter = deepClone(selectDefaultIPAdapter(state)); @@ -178,8 +159,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Add global reference image from image if ( - addGlobalReferenceImageFromImageDndTarget.typeGuard(targetData) && - addGlobalReferenceImageFromImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.newGlobalReferenceImageFromImage.typeGuard(targetData) && + Dnd.Target.newGlobalReferenceImageFromImage.validateDrop(sourceData, targetData) ) { const state = getState(); const ipAdapter = deepClone(selectDefaultIPAdapter(state)); @@ -191,11 +172,11 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Replace layer with image if ( - replaceLayerWithImageDndTarget.typeGuard(targetData) && - replaceLayerWithImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.replaceLayerWithImage.typeGuard(targetData) && + Dnd.Target.replaceLayerWithImage.validateDrop(sourceData, targetData) ) { const state = getState(); - const { entityIdentifier } = targetData; + const { entityIdentifier } = targetData.payload; const imageObject = imageDTOToImageObject(imageDTO); const { x, y } = selectCanvasSlice(state).bbox.rect; dispatch(entityRasterized({ entityIdentifier, imageObject, position: { x, y }, replaceObjects: true })); @@ -205,10 +186,10 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Image dropped on node image field if ( - setNodeImageFieldDndTarget.typeGuard(targetData) && - setNodeImageFieldDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.setNodeImageField.typeGuard(targetData) && + Dnd.Target.setNodeImageField.validateDrop(sourceData, targetData) ) { - const { fieldName, nodeId } = targetData; + const { fieldName, nodeId } = targetData.payload; dispatch( fieldImageValueChanged({ nodeId, @@ -221,16 +202,16 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Image selected for compare if ( - selectForCompareDndTarget.typeGuard(targetData) && - selectForCompareDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.selectForCompare.typeGuard(targetData) && + Dnd.Target.selectForCompare.validateDrop(sourceData, targetData) ) { dispatch(imageToCompareChanged(imageDTO)); return; } // Image added to board - if (addToBoardDndTarget.typeGuard(targetData) && addToBoardDndTarget.validateDrop(sourceData, targetData)) { - const { boardId } = targetData; + if (Dnd.Target.addToBoard.typeGuard(targetData) && Dnd.Target.addToBoard.validateDrop(sourceData, targetData)) { + const { boardId } = targetData.payload; dispatch( imagesApi.endpoints.addImageToBoard.initiate({ imageDTO, @@ -243,8 +224,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Image removed from board if ( - removeFromBoardDndTarget.typeGuard(targetData) && - removeFromBoardDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.removeFromBoard.typeGuard(targetData) && + Dnd.Target.removeFromBoard.validateDrop(sourceData, targetData) ) { dispatch( imagesApi.endpoints.removeImageFromBoard.initiate({ @@ -257,21 +238,21 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Image dropped on upscale initial image if ( - setUpscaleInitialImageFromImageDndTarget.typeGuard(targetData) && - setUpscaleInitialImageFromImageDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.setUpscaleInitialImageFromImage.typeGuard(targetData) && + Dnd.Target.setUpscaleInitialImageFromImage.validateDrop(sourceData, targetData) ) { dispatch(upscaleInitialImageChanged(imageDTO)); return; } } - if (multipleImageDndSource.typeGuard(sourceData)) { + if (Dnd.Source.multipleImage.typeGuard(sourceData)) { log.debug({ sourceData, targetData }, 'Multiple images dropped'); - const { imageDTOs } = sourceData; + const { imageDTOs } = sourceData.payload; // Multiple images dropped on user board - if (addToBoardDndTarget.typeGuard(targetData) && addToBoardDndTarget.validateDrop(sourceData, targetData)) { - const { boardId } = targetData; + if (Dnd.Target.addToBoard.typeGuard(targetData) && Dnd.Target.addToBoard.validateDrop(sourceData, targetData)) { + const { boardId } = targetData.payload; dispatch( imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, @@ -284,8 +265,8 @@ export const addDndDroppedListener = (startAppListening: AppStartListening) => { // Multiple images dropped on Uncategorized board (e.g. removed from board) if ( - removeFromBoardDndTarget.typeGuard(targetData) && - removeFromBoardDndTarget.validateDrop(sourceData, targetData) + Dnd.Target.removeFromBoard.typeGuard(targetData) && + Dnd.Target.removeFromBoard.validateDrop(sourceData, targetData) ) { dispatch( imagesApi.endpoints.removeImagesFromBoard.initiate({ diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index 3fdbe4d0bff..69a0847ec93 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -1,20 +1,15 @@ import { Grid, GridItem } from '@invoke-ai/ui-library'; +import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; -import { - newControlLayerFromImageDndTarget, - addGlobalReferenceImageFromImageDndTarget, - newRasterLayerFromImageDndTarget, - addRegionalGuidanceReferenceImageFromImageDndTarget, -} from 'features/dnd2/types'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -const addRasterLayerFromImageDndTargetData = newRasterLayerFromImageDndTarget.getData({}); -const addControlLayerFromImageDndTargetData = newControlLayerFromImageDndTarget.getData({}); +const addRasterLayerFromImageDndTargetData = Dnd.Target.newRasterLayerFromImage.getData(); +const addControlLayerFromImageDndTargetData = Dnd.Target.newControlLayerFromImage.getData(); const addRegionalGuidanceReferenceImageFromImageDndTargetData = - addRegionalGuidanceReferenceImageFromImageDndTarget.getData({}); -const addGlobalReferenceImageFromImageDndTargetData = addGlobalReferenceImageFromImageDndTarget.getData({}); + Dnd.Target.newRegionalGuidanceReferenceImageFromImage.getData(); +const addGlobalReferenceImageFromImageDndTargetData = Dnd.Target.newGlobalReferenceImageFromImage.getData(); export const CanvasDropArea = memo(() => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index 8d641e04956..78a30b38c1d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -6,8 +6,8 @@ import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHook import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; +import type { Dnd } from 'features/dnd2/dnd'; import { DndDropOverlay } from 'features/dnd2/DndDropOverlay'; -import type { DndState } from 'features/dnd2/types'; import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; @@ -84,8 +84,8 @@ const PanelTabs = memo(() => { const { t } = useTranslation(); const store = useAppStore(); const activeEntityCount = useAppSelector(selectEntityCountActive); - const [layersTabDndState, setLayersTabDndState] = useState('idle'); - const [galleryTabDndState, setGalleryTabDndState] = useState('idle'); + const [layersTabDndState, setLayersTabDndState] = useState('idle'); + const [galleryTabDndState, setGalleryTabDndState] = useState('idle'); const layersTabRef = useRef(null); const galleryTabRef = useRef(null); const timeoutRef = useRef(null); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index 69e3240a192..8df32b0b6bf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -10,8 +10,8 @@ import { ControlLayerSettings } from 'features/controlLayers/components/ControlL import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; -import { replaceLayerWithImageDndTarget, type ReplaceLayerWithImageDndTargetData } from 'features/dnd2/types'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -25,8 +25,8 @@ export const ControlLayer = memo(({ id }: Props) => { () => ({ id, type: 'control_layer' }), [id] ); - const targetData = useMemo( - () => replaceLayerWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id), + const targetData = useMemo( + () => Dnd.Target.replaceLayerWithImage.getData({ entityIdentifier }, entityIdentifier.id), [entityIdentifier] ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx index ee55c4fb767..a07ec7b96f3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx @@ -4,9 +4,9 @@ import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; import type { ImageWithDims } from 'features/controlLayers/store/types'; +import type { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import { DndImage } from 'features/dnd2/DndImage'; -import type { DndTargetData } from 'features/dnd2/types'; import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; @@ -31,7 +31,7 @@ const sx = { type Props = { image: ImageWithDims | null; onChangeImage: (imageDTO: ImageDTO | null) => void; - targetData: DndTargetData; + targetData: Dnd.types['TargetDataUnion']; postUploadAction: PostUploadAction; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx index 60aeb89f60d..88f11f1feec 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -19,7 +19,7 @@ import { import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import { setGlobalReferenceImageDndTarget, type SetGlobalReferenceImageDndTargetData } from 'features/dnd2/types'; +import { Dnd } from 'features/dnd2/dnd'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold } from 'react-icons/pi'; @@ -84,9 +84,9 @@ export const IPAdapterSettings = memo(() => { () => ({ type: 'SET_IPA_IMAGE', id: entityIdentifier.id }), [entityIdentifier.id] ); - const targetData = useMemo( + const targetData = useMemo( () => - setGlobalReferenceImageDndTarget.getData( + Dnd.Target.setGlobalReferenceImage.getData( { globalReferenceImageId: entityIdentifier.id }, ipAdapter.image?.image_name ), diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 249cccbdea2..f70aca7cf13 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -7,9 +7,8 @@ import { CanvasEntityEditableTitle } from 'features/controlLayers/components/com import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; -import type { ReplaceLayerWithImageDndTargetData } from 'features/dnd2/types'; -import { replaceLayerWithImageDndTarget } from 'features/dnd2/types'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -20,8 +19,8 @@ type Props = { export const RasterLayer = memo(({ id }: Props) => { const { t } = useTranslation(); const entityIdentifier = useMemo>(() => ({ id, type: 'raster_layer' }), [id]); - const targetData = useMemo( - () => replaceLayerWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id), + const targetData = useMemo( + () => Dnd.Target.replaceLayerWithImage.getData({ entityIdentifier }, entityIdentifier.id), [entityIdentifier] ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index 9aa7c8f4160..1cd73cb4bdb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -20,8 +20,7 @@ import { } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd2/types'; -import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd2/types'; +import { Dnd } from 'features/dnd2/dnd'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi'; @@ -92,13 +91,10 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro [dispatch, entityIdentifier, referenceImageId] ); - const targetData = useMemo( + const targetData = useMemo( () => - setRegionalGuidanceReferenceImageDndTarget.getData( - { - regionalGuidanceId: entityIdentifier.id, - referenceImageId, - }, + Dnd.Target.setRegionalGuidanceReferenceImage.getData( + { regionalGuidanceId: entityIdentifier.id, referenceImageId }, ipAdapter.image?.image_name ), [entityIdentifier.id, ipAdapter.image?.image_name, referenceImageId] diff --git a/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx b/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx index 205795a0333..511f7a09809 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx +++ b/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx @@ -1,9 +1,9 @@ import { Flex, Text } from '@invoke-ai/ui-library'; -import type { DndState } from 'features/dnd2/types'; +import type { Dnd } from 'features/dnd2/dnd'; import { memo } from 'react'; type Props = { - dndState: DndState; + dndState: Dnd.types['DndState']; label?: string; withBackdrop?: boolean; }; diff --git a/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx index 8315fb09caf..0f4cb77291f 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx +++ b/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx @@ -7,10 +7,8 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box } from '@invoke-ai/ui-library'; import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/dnd'; import { useAppDispatch } from 'app/store/storeHooks'; -import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { Dnd } from 'features/dnd2/dnd'; import { DndDropOverlay } from 'features/dnd2/DndDropOverlay'; -import type { DndState, DndTargetData } from 'features/dnd2/types'; -import { getDndId, isDndSourceData, isValidDrop, singleImageDndSource } from 'features/dnd2/types'; import { memo, useEffect, useRef, useState } from 'react'; import { uploadImage } from 'services/api/endpoints/images'; import { z } from 'zod'; @@ -32,6 +30,7 @@ const sx = { left: 0, w: 'full', h: 'full', + pointerEvents: 'auto', // We must disable pointer events when idle to prevent the overlay from blocking clicks '&[data-dnd-state="idle"]': { pointerEvents: 'none', @@ -61,14 +60,14 @@ const zUploadFile = z type Props = { label: string; - targetData: DndTargetData; + targetData: Dnd.types['TargetDataUnion']; elementDropEnabled?: boolean; externalDropEnabled?: boolean; }; export const DndDropTarget = memo((props: Props) => { const { label, targetData, elementDropEnabled = true, externalDropEnabled = true } = props; - const [dndState, setDndState] = useState('idle'); + const [dndState, setDndState] = useState('idle'); const ref = useRef(null); const dispatch = useAppDispatch(); @@ -89,13 +88,13 @@ export const DndDropTarget = memo((props: Props) => { return false; } const sourceData = args.source.data; - if (!isDndSourceData(sourceData)) { + if (!Dnd.Util.isDndSourceData(sourceData)) { return false; } - if (getDndId(targetData) === getDndId(sourceData)) { + if (Dnd.Util.getDndId(targetData) === Dnd.Util.getDndId(sourceData)) { return false; } - return isValidDrop(sourceData, targetData); + return Dnd.Util.isValidDrop(sourceData, targetData); }, onDragEnter: () => { setDndState('over'); @@ -106,7 +105,7 @@ export const DndDropTarget = memo((props: Props) => { getData: () => targetData, onDrop: (args) => { const sourceData = args.source.data; - if (!isDndSourceData(sourceData)) { + if (!Dnd.Util.isDndSourceData(sourceData)) { return; } dispatch(dndDropped({ sourceData, targetData })); @@ -115,13 +114,13 @@ export const DndDropTarget = memo((props: Props) => { monitorForElements({ canMonitor: (args) => { const sourceData = args.source.data; - if (!isDndSourceData(sourceData)) { + if (!Dnd.Util.isDndSourceData(sourceData)) { return false; } - if (getDndId(targetData) === getDndId(sourceData)) { + if (Dnd.Util.getDndId(targetData) === Dnd.Util.getDndId(sourceData)) { return false; } - return isValidDrop(sourceData, targetData); + return Dnd.Util.isValidDrop(sourceData, targetData); }, onDragStart: () => { setDndState('potential'); @@ -177,7 +176,7 @@ export const DndDropTarget = memo((props: Props) => { }); dispatch( dndDropped({ - sourceData: singleImageDndSource.getData({ imageDTO }, getPrefixedId('dnd-upload-image')), + sourceData: Dnd.Source.singleImage.getData({ imageDTO }), targetData, }) ); diff --git a/invokeai/frontend/web/src/features/dnd2/DndImage.tsx b/invokeai/frontend/web/src/features/dnd2/DndImage.tsx index 4cf563b8eed..378a9a236d0 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd2/DndImage.tsx @@ -2,7 +2,7 @@ import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Image } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; -import { singleImageDndSource } from 'features/dnd2/types'; +import { Dnd } from 'features/dnd2/dnd'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { memo, useEffect, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -33,7 +33,7 @@ export const DndImage = memo(({ imageDTO, ...rest }: Props) => { } return draggable({ element, - getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name), + getInitialData: () => Dnd.Source.singleImage.getData({ imageDTO }, imageDTO.image_name), onDragStart: () => { setIsDragging(true); }, diff --git a/invokeai/frontend/web/src/features/dnd2/dnd.ts b/invokeai/frontend/web/src/features/dnd2/dnd.ts new file mode 100644 index 00000000000..46310b7d8f1 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd2/dnd.ts @@ -0,0 +1,414 @@ +/* eslint-disable @typescript-eslint/no-namespace */ // We will use namespaces to organize the Dnd types + +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import type { BoardId } from 'features/gallery/store/types'; +import type { ImageDTO } from 'services/api/types'; +import type { ValueOf } from 'type-fest'; +import type { Jsonifiable } from 'type-fest/source/jsonifiable'; + +type EmptyObject = Record; +type RecordUnknown = Record; + +/** + * This file contains types, APIs, and utilities for Dnd functionality, as provided by pragmatic-drag-and-drop: + * - Source and target data types + * - Builders for source and target data types, which create type guards, data-getters and validation functions + * - Other utilities for working with Dnd data + * - A function to validate whether a drop is valid, given the source and target data + * + * See: + * - https://github.com/atlassian/pragmatic-drag-and-drop + * - https://atlassian.design/components/pragmatic-drag-and-drop/about + */ + +type DndKind = 'source' | 'target'; + +type Data = { + meta: { + id: string; + type: T; + kind: K; + }; + payload: P; +}; + +/** + * Builds a type guard for a specific DndData type. + * @param key The unique symbol key for the DndData type. + * @returns A type guard for the DndData type. + */ +const _buildDataTypeGuard = (type: string, kind: DndKind) => { + // pragmatic-drag-and-drop types all data as unknown, so we need to cast it to the expected type + return (data: RecordUnknown): data is T => { + try { + return (data as Data).meta.type === type && (data as Data).meta.kind === kind; + } catch { + return false; + } + }; +}; + +/** + * Builds a getter for a specific DndData type. + * + * The getter accepts arbitrary data and an optional Dnd ID. If no Dnd ID is provided, a unique one is generated. + * + * @param key The unique symbol key for the DndData type. + * @returns A getter for the DndData type. + */ +const _buildDataGetter = + (type: T['meta']['type'], kind: T['meta']['kind']) => + (payload: T['payload'] extends EmptyObject ? void : T['payload'], dndId?: string | null): T => { + return { + meta: { + id: dndId ?? getPrefixedId(`dnd-${kind}-${type}`), + type, + kind, + }, + payload, + } as T; + }; + +/** + * An API for a Dnd source. It provides a type guard, a getter, and a unique symbol key for the DndData type. + */ +type DndSourceAPI = { + type: string; + kind: 'source'; + /** + * A type guard for the DndData type. + * @param data The data to check. + * @returns Whether the data is of the DndData type. + */ + typeGuard: ReturnType>; + /** + * A getter for the DndData type. + * @param data The data to get. + * @param dndId The Dnd ID to use. If not provided, a unique one is generated. + * @returns The DndData. + */ + getData: ReturnType>; +}; + +/** + * Builds a DndSourceAPI object. + * @param key The unique symbol key for the DndData type. + */ +const buildDndSourceApi =

(type: string) => { + return { + type, + kind: 'source', + typeGuard: _buildDataTypeGuard>(type, 'source'), + getData: _buildDataGetter>(type, 'source'), + } satisfies DndSourceAPI>; +}; + +//#region DndSourceData +/** + * Dnd source API for single image source. + */ +const singleImage = buildDndSourceApi<{ imageDTO: ImageDTO }>('SingleImage'); +/** + * Dnd source API for multiple image source. + */ +const multipleImage = buildDndSourceApi<{ imageDTOs: ImageDTO[]; boardId: BoardId }>('MultipleImage'); + +const DndSource = { + singleImage, + multipleImage, +} as const; + +type SourceDataTypeMap = { + [K in keyof typeof DndSource]: ReturnType<(typeof DndSource)[K]['getData']>; +}; + +/** + * A union of all possible DndSourceData types. + */ +type SourceDataUnion = ValueOf; +//#endregion + +//#region DndTargetData +/** + * An API for a Dnd target. It extends the DndSourceAPI with a validateDrop function. + */ +type DndTargetApi = DndSourceAPI & { + /** + * Validates whether a drop is valid, give the source and target data. + * @param sourceData The source data (i.e. the data being dragged) + * @param targetData The target data (i.e. the data being dragged onto) + * @returns Whether the drop is valid. + */ + validateDrop: (sourceData: Data, targetData: T) => boolean; +}; + +/** + * Builds a DndTargetApi object. + * @param key The unique symbol key for the DndData type. + * @param validateDrop A function that validates whether a drop is valid. + */ +const buildDndTargetApi =

( + type: string, + validateDrop: (sourceData: Data, targetData: Data) => boolean +) => { + return { + type, + kind: 'source', + typeGuard: _buildDataTypeGuard>(type, 'target'), + getData: _buildDataGetter>(type, 'target'), + validateDrop, + } satisfies DndTargetApi>; +}; + +/** + * Dnd target API for setting the image on an existing Global Reference Image layer. + */ +const setGlobalReferenceImage = buildDndTargetApi<{ globalReferenceImageId: string }>( + 'SetGlobalReferenceImage', + singleImage.typeGuard +); + +/** + * Dnd target API for setting the image on an existing Regional Guidance layer's Reference Image. + */ +const setRegionalGuidanceReferenceImage = buildDndTargetApi<{ + regionalGuidanceId: string; + referenceImageId: string; +}>('SetRegionalGuidanceReferenceImage', singleImage.typeGuard); + +/** + * Dnd target API for creating a new a Raster Layer from an image. + */ +const newRasterLayerFromImage = buildDndTargetApi('NewRasterLayerFromImage', singleImage.typeGuard); + +/** + * Dnd target API for creating a new a Control Layer from an image. + */ +const newControlLayerFromImage = buildDndTargetApi('NewControlLayerFromImage', singleImage.typeGuard); + +/** + * Dnd target API for adding an Inpaint Mask from an image. + */ +const newInpaintMaskFromImage = buildDndTargetApi('NewInpaintMaskFromImage', singleImage.typeGuard); + +/** + * Dnd target API for adding a new Global Reference Image layer with a pre-set Reference Image from an image. + */ +const newGlobalReferenceImageFromImage = buildDndTargetApi('NewGlobalReferenceImageFromImage', singleImage.typeGuard); + +/** + * Dnd target API for adding a new Regional Guidance layer from an image. + */ +const newRegionalGuidanceFromImage = buildDndTargetApi('NewRegionalGuidanceFromImage', singleImage.typeGuard); + +/** + * Dnd target API for adding a new Regional Guidance layer with a pre-set Reference Image from an image. + */ +const newRegionalGuidanceReferenceImageFromImage = buildDndTargetApi( + 'NewRegionalGuidanceReferenceImageFromImage', + singleImage.typeGuard +); + +/** + * Dnd target API for replacing the content of a layer with an image. This works for Control Layers, Raster Layers, + * Inpaint Masks, and Regional Guidance layers. + */ +const replaceLayerWithImage = buildDndTargetApi<{ + entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>; +}>('ReplaceLayerWithImage', singleImage.typeGuard); + +/** + * Dnd target API for setting the initial image on the upscaling tab. + */ +const setUpscaleInitialImageFromImage = buildDndTargetApi('SetUpscaleInitialImageFromImage', singleImage.typeGuard); + +/** + * Dnd target API for setting an image field on a node. + */ +const setNodeImageField = buildDndTargetApi<{ nodeId: string; fieldName: string }>( + 'SetNodeImageField', + singleImage.typeGuard +); + +/** + * Dnd target API for selecting images for comparison. + */ +const selectForCompare = buildDndTargetApi<{ + firstImageName?: string | null; + secondImageName?: string | null; +}>('SelectForCompare', (sourceData, targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + // Do not allow the same images to be selected for comparison + if (sourceData.payload.imageDTO.image_name === targetData.payload.firstImageName) { + return false; + } + if (sourceData.payload.imageDTO.image_name === targetData.payload.secondImageName) { + return false; + } + return true; +}); + +/** + * Dnd target API for adding an image to a board. + */ +const addToBoard = buildDndTargetApi<{ boardId: string }>('AddToBoard', (sourceData, targetData) => { + if (singleImage.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } + + if (multipleImage.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.boardId; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } + + return false; +}); + +/** + * Dnd target API for removing an image from a board. + */ +const removeFromBoard = buildDndTargetApi('RemoveFromBoard', (sourceData) => { + if (singleImage.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; + return currentBoard !== 'none'; + } + + if (multipleImage.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.boardId; + return currentBoard !== 'none'; + } + + return false; +}); + +const DndTarget = { + /** + * Set the image on an existing Global Reference Image layer. + */ + setGlobalReferenceImage, + setRegionalGuidanceReferenceImage, + // Add layer from image + newRasterLayerFromImage, + newControlLayerFromImage, + // Add a layer w/ ref image preset + newGlobalReferenceImageFromImage, + newRegionalGuidanceReferenceImageFromImage, + // Replace layer content w/ image + replaceLayerWithImage, + // Set the upscale image + setUpscaleInitialImageFromImage, + // Set a field on a node + setNodeImageField, + // Select images for comparison + selectForCompare, + // Add an image to a board + addToBoard, + // Remove an image from a board - essentially add to Uncategorized + removeFromBoard, + // These are currently unused + newRegionalGuidanceFromImage, + newInpaintMaskFromImage, +} as const; + +type TargetDataTypeMap = { + [K in keyof typeof DndTarget]: ReturnType<(typeof DndTarget)[K]['getData']>; +}; + +type TargetDataUnion = ValueOf; + +const targetApisArray = Object.values(DndTarget); + +//#endregion + +export declare namespace Dnd { + export type types = { + /** + * A union of all Dnd states. + * - `idle`: No drag is occurring, or the drag is not valid for the current drop target. + * - `potential`: A drag is occurring, and the drag is valid for the current drop target, but the drag is not over the + * drop target. + * - `over`: A drag is occurring, and the drag is valid for the current drop target, and the drag is over the drop target. + */ + DndState: 'idle' | 'potential' | 'over'; + /** + * A map of target APIs to their data types. + */ + SourceDataTypeMap: SourceDataTypeMap; + /** + * A union of all possible source data types. + */ + SourceDataUnion: SourceDataUnion; + /** + * A map of target APIs to their data types. + */ + TargetDataTypeMap: TargetDataTypeMap; + /** + * A union of all possible target data types. + */ + TargetDataUnion: TargetDataUnion; + }; +} + +export const Dnd = { + Source: DndSource, + Target: DndTarget, + Util: { + /** + * Gets the Dnd ID from a DndData object. + * @param data The DndData object. + * @returns The Dnd ID. + */ + getDndId: (data: Data): string => { + return data.meta.id; + }, + /** + * Checks if the data is a Dnd source data object. + * @param data The data to check. + */ + isDndSourceData: (data: RecordUnknown): data is SourceDataUnion => { + try { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + return (data as Data).meta.kind === 'source'; + } catch { + return false; + } + }, + /** + * Checks if the data is a Dnd target data object. + * @param data The data to check. + */ + isDndTargetData: (data: RecordUnknown): data is TargetDataUnion => { + try { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + return (data as Data).meta.kind === 'target'; + } catch { + return false; + } + }, + /** + * Validates whether a drop is valid. + * @param sourceData The data being dragged. + * @param targetData The data of the target being dragged onto. + * @returns Whether the drop is valid. + */ + isValidDrop: (sourceData: SourceDataUnion, targetData: TargetDataUnion): boolean => { + for (const targetApi of targetApisArray) { + if (targetApi.typeGuard(targetData)) { + /** + * TS cannot narrow the type of the targetApi and will error in the validator call. + * We've just checked that targetData is of the right type, though, so this cast to `any` is safe. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + return targetApi.validateDrop(sourceData, targetData as any); + } + } + return false; + }, + }, +}; diff --git a/invokeai/frontend/web/src/features/dnd2/types.ts b/invokeai/frontend/web/src/features/dnd2/types.ts deleted file mode 100644 index 9c99247b8bd..00000000000 --- a/invokeai/frontend/web/src/features/dnd2/types.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { getPrefixedId } from 'features/controlLayers/konva/util'; -import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import type { BoardId } from 'features/gallery/store/types'; -import type { ImageDTO } from 'services/api/types'; - -/** - * A unique symbol key for a DndData object's ID. - */ -const _dndIdKey = Symbol('DndId'); -/** - * The base DndData type. It consists of an ID, keyed by the _dndIdKey symbol, and any arbitrary data. - */ -export type BaseDndData = { [_dndIdKey]: string } & Record; - -/** - * Builds a type guard for a specific DndData type. - * @param key The unique symbol key for the DndData type. - * @returns A type guard for the DndData type. - */ -const _buildDataTypeGuard = - (key: symbol) => - (data: Record): data is T => { - return Boolean(data[key]); - }; - -/** - * Builds a getter for a specific DndData type. - * - * The getter accepts arbitrary data and an optional Dnd ID. If no Dnd ID is provided, a unique one is generated. - * - * @param key The unique symbol key for the DndData type. - * @returns A getter for the DndData type. - */ -const _buildDataGetter = - (key: symbol) => - (data: Omit, dndId?: string | null): T => { - return { - [key]: true, - [_dndIdKey]: dndId ?? getPrefixedId(`dnd-${key.toString()}`), - ...data, - } as T; - }; - -/** - * An API for a Dnd source. It provides a type guard, a getter, and a unique symbol key for the DndData type. - */ -type DndSourceAPI = { - /** - * The unique symbol key for the DndData type. This is used to identify the type of data. - */ - key: symbol; - /** - * A type guard for the DndData type. - * @param data The data to check. - * @returns Whether the data is of the DndData type. - */ - typeGuard: ReturnType>; - /** - * A getter for the DndData type. - * @param data The data to get. - * @param dndId The Dnd ID to use. If not provided, a unique one is generated. - * @returns The DndData. - */ - getData: ReturnType>; -}; - -/** - * Builds a DndSourceAPI object. - * @param key The unique symbol key for the DndData type. - */ -const buildDndSourceApi = (key: symbol): DndSourceAPI => ({ - key, - typeGuard: _buildDataTypeGuard(key), - getData: _buildDataGetter(key), -}); - -/** - * A helper type that adds a Dnd ID to a record type. - */ -type WithDndId> = T & { [_dndIdKey]: string }; - -/** - * A DndData object. It has three parts: - * - A unique symbol key, PrivateKey, that identifies the type of data. - * - A Dnd ID, which is a unique string that identifies the data. This is keyed to the _dndIdKey symbol. - * - Arbitrary data - */ -type DndData = Record> = { - [k in PrivateKey]: true; -} & WithDndId; - -/** - * Gets the Dnd ID from a DndData object. - * @param data The DndData object. - * @returns The Dnd ID. - */ -export const getDndId = (data: BaseDndData): string => { - return data[_dndIdKey]; -}; - -//#region DndSourceData -const _SingleImageDndSourceDataKey = Symbol('SingleImageDndSourceData'); -/** - * Dnd source data for a single image being dragged. - */ -export type SingleImageDndSourceData = DndData; -/** - * Dnd source API for single image source. - */ -export const singleImageDndSource = buildDndSourceApi(_SingleImageDndSourceDataKey); - -const _MultipleImageDndSourceDataKey = Symbol('MultipleImageDndSourceData'); -/** - * Dnd source data for multiple images being dragged. - */ -export type MultipleImageDndSourceData = DndData< - typeof _MultipleImageDndSourceDataKey, - { - imageDTOs: ImageDTO[]; - boardId: BoardId; - } ->; -/** - * Dnd source API for multiple image source. - */ -export const multipleImageDndSource = buildDndSourceApi(_MultipleImageDndSourceDataKey); - -const sourceApis = [singleImageDndSource, multipleImageDndSource] as const; -/** - * A union of all possible DndSourceData types. - */ -export type DndSourceData = SingleImageDndSourceData | MultipleImageDndSourceData; -/** - * Checks if the data is a DndSourceData object. - * @param data The data to check. - */ -export const isDndSourceData = (data: Record): data is DndSourceData => { - for (const sourceApi of sourceApis) { - if (sourceApi.typeGuard(data)) { - return true; - } - } - return false; -}; - -//#endregion - -//#region DndTargetData -/** - * An API for a Dnd target. It extends the DndSourceAPI with a validateDrop function. - */ -type DndTargetApi = DndSourceAPI & { - /** - * Validates whether a drop is valid, give the source and target data. - * @param sourceData The source data (i.e. the data being dragged) - * @param targetData The target data (i.e. the data being dragged onto) - * @returns Whether the drop is valid. - */ - validateDrop: (sourceData: DndSourceData, targetData: T) => boolean; -}; - -/** - * Builds a DndTargetApi object. - * @param key The unique symbol key for the DndData type. - * @param validateDrop A function that validates whether a drop is valid. - */ -const buildDndTargetApi = ( - key: symbol, - validateDrop: DndTargetApi['validateDrop'] -): DndTargetApi => ({ - key, - typeGuard: _buildDataTypeGuard(key), - getData: _buildDataGetter(key), - validateDrop, -}); - -const _SetGlobalReferenceImageDndTargetDataKey = Symbol('SetGlobalReferenceImageDndTargetData'); -/** - * Dnd target data for setting the image on an existing Global Reference Image layer. - */ -export type SetGlobalReferenceImageDndTargetData = DndData< - typeof _SetGlobalReferenceImageDndTargetDataKey, - { - globalReferenceImageId: string; - } ->; -/** - * Dnd target API for setting the image on an existing Global Reference Image layer. - */ -export const setGlobalReferenceImageDndTarget = buildDndTargetApi( - _SetGlobalReferenceImageDndTargetDataKey, - singleImageDndSource.typeGuard -); - -const _SetRegionalGuidanceReferenceImageDndTargetDataKey = Symbol('SetRegionalGuidanceReferenceImageDndTargetData'); -/** - * Dnd target data for setting the image on an existing Regional Guidance layer's Reference Image. - */ -export type SetRegionalGuidanceReferenceImageDndTargetData = DndData< - typeof _SetRegionalGuidanceReferenceImageDndTargetDataKey, - { - regionalGuidanceId: string; - referenceImageId: string; - } ->; -/** - * Dnd target API for setting the image on an existing Regional Guidance layer's Reference Image. - */ -export const setRegionalGuidanceReferenceImageDndTarget = - buildDndTargetApi( - _SetRegionalGuidanceReferenceImageDndTargetDataKey, - singleImageDndSource.typeGuard - ); - -const _NewRasterLayerFromImageDndTargetDataKey = Symbol('NewRasterLayerFromImageDndTargetData'); -/** - * Dnd target data for creating a new a Raster Layer from an image. - */ -export type NewRasterLayerFromImageDndTargetData = DndData; -/** - * Dnd target API for creating a new a Raster Layer from an image. - */ -export const newRasterLayerFromImageDndTarget = buildDndTargetApi( - _NewRasterLayerFromImageDndTargetDataKey, - singleImageDndSource.typeGuard -); - -const _NewControlLayerFromImageDndTargetDataKey = Symbol('NewControlLayerFromImageDndTargetData'); -/** - * Dnd target data for creating a new a Control Layer from an image. - */ -export type NewControlLayerFromImageDndTargetData = DndData; -/** - * Dnd target API for creating a new a Control Layer from an image. - */ -export const newControlLayerFromImageDndTarget = buildDndTargetApi( - _NewControlLayerFromImageDndTargetDataKey, - singleImageDndSource.typeGuard -); - -const _AddInpaintMaskFromImageDndTargetDataKey = Symbol('AddInpaintMaskFromImageDndTargetData'); -export type AddInpaintMaskFromImageDndTargetData = DndData; -export const addInpaintMaskFromImageDndTarget = buildDndTargetApi( - _AddInpaintMaskFromImageDndTargetDataKey, - singleImageDndSource.typeGuard -); - -const _AddRegionalGuidanceFromImageDndTargetDataKey = Symbol('AddRegionalGuidanceFromImageDndTargetData'); -export type AddRegionalGuidanceFromImageDndTargetData = DndData; -export const addRegionalGuidanceFromImageDndTarget = buildDndTargetApi( - _AddRegionalGuidanceFromImageDndTargetDataKey, - singleImageDndSource.typeGuard -); - -const _AddRegionalGuidanceReferenceImageFromImageDndTargetDataKey = Symbol( - 'AddRegionalGuidanceReferenceImageFromImageDndTargetData' -); -export type AddRegionalGuidanceReferenceImageFromImageDndTargetData = DndData< - typeof _AddRegionalGuidanceReferenceImageFromImageDndTargetDataKey ->; -export const addRegionalGuidanceReferenceImageFromImageDndTarget = - buildDndTargetApi( - _AddRegionalGuidanceReferenceImageFromImageDndTargetDataKey, - singleImageDndSource.typeGuard - ); - -const _AddGlobalReferenceImageFromImageDndTargetDataKey = Symbol('AddGlobalReferenceImageFromImageDndTargetData'); -export type AddGlobalReferenceImageFromImageDndTargetData = DndData< - typeof _AddGlobalReferenceImageFromImageDndTargetDataKey ->; -export const addGlobalReferenceImageFromImageDndTarget = - buildDndTargetApi( - _AddGlobalReferenceImageFromImageDndTargetDataKey, - singleImageDndSource.typeGuard - ); - -const _ReplaceLayerWithImageDndTargetDataKey = Symbol('ReplaceLayerWithImageDndTargetData'); -export type ReplaceLayerWithImageDndTargetData = DndData< - typeof _ReplaceLayerWithImageDndTargetDataKey, - { - entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>; - } ->; -export const replaceLayerWithImageDndTarget = buildDndTargetApi( - _ReplaceLayerWithImageDndTargetDataKey, - singleImageDndSource.typeGuard -); - -const _SetUpscaleInitialImageFromImageDndTargetDataKey = Symbol('SetUpscaleInitialImageFromImageDndTargetData'); -export type SetUpscaleInitialImageFromImageDndTargetData = DndData< - typeof _SetUpscaleInitialImageFromImageDndTargetDataKey ->; -export const setUpscaleInitialImageFromImageDndTarget = buildDndTargetApi( - _SetUpscaleInitialImageFromImageDndTargetDataKey, - singleImageDndSource.typeGuard -); - -const _SetNodeImageFieldDndTargetDataKey = Symbol('SetNodeImageFieldDndTargetData'); -export type SetNodeImageFieldDndTargetData = DndData< - typeof _SetNodeImageFieldDndTargetDataKey, - { - nodeId: string; - fieldName: string; - } ->; -export const setNodeImageFieldDndTarget = buildDndTargetApi( - _SetNodeImageFieldDndTargetDataKey, - singleImageDndSource.typeGuard -); - -const _SelectForCompareDndTargetDataKey = Symbol('SelectForCompareDndTargetData'); -export type SelectForCompareDndTargetData = DndData< - typeof _SelectForCompareDndTargetDataKey, - { - firstImageName?: string | null; - secondImageName?: string | null; - } ->; -export const selectForCompareDndTarget = buildDndTargetApi( - _SelectForCompareDndTargetDataKey, - (sourceData, targetData) => { - if (!singleImageDndSource.typeGuard(sourceData)) { - return false; - } - // Do not allow the same images to be selected for comparison - if (sourceData.imageDTO.image_name === targetData.firstImageName) { - return false; - } - if (sourceData.imageDTO.image_name === targetData.secondImageName) { - return false; - } - return true; - } -); - -const _ToastDndTargetDataKey = Symbol('ToastDndTargetData'); -export type ToastDndTargetData = DndData; -export const ToastDndTarget = buildDndTargetApi( - _ToastDndTargetDataKey, - singleImageDndSource.typeGuard -); - -const _AddToBoardDndTargetDataKey = Symbol('AddToBoardDndTargetData'); -export type AddToBoardDndTargetData = DndData< - typeof _AddToBoardDndTargetDataKey, - { - boardId: string; - } ->; -export const addToBoardDndTarget = buildDndTargetApi( - _AddToBoardDndTargetDataKey, - (sourceData, targetData) => { - if (singleImageDndSource.typeGuard(sourceData)) { - const { imageDTO } = sourceData; - const currentBoard = imageDTO.board_id ?? 'none'; - const destinationBoard = targetData.boardId; - return currentBoard !== destinationBoard; - } - - if (multipleImageDndSource.typeGuard(sourceData)) { - const currentBoard = sourceData.boardId; - const destinationBoard = targetData.boardId; - return currentBoard !== destinationBoard; - } - - return false; - } -); - -const _RemoveFromBoardDndTargetDataKey = Symbol('RemoveFromBoardDndTargetData'); -export type RemoveFromBoardDndTargetData = DndData; -export const removeFromBoardDndTarget = buildDndTargetApi( - _RemoveFromBoardDndTargetDataKey, - (sourceData) => { - if (singleImageDndSource.typeGuard(sourceData)) { - const currentBoard = sourceData.imageDTO.board_id ?? 'none'; - return currentBoard !== 'none'; - } - - if (multipleImageDndSource.typeGuard(sourceData)) { - const currentBoard = sourceData.boardId; - return currentBoard !== 'none'; - } - - return false; - } -); - -const targetApis = [ - // Set a reference image on existing layer - setGlobalReferenceImageDndTarget, - setRegionalGuidanceReferenceImageDndTarget, - // Add layer from image - newRasterLayerFromImageDndTarget, - newControlLayerFromImageDndTarget, - // Add a layer w/ ref image preset - addGlobalReferenceImageFromImageDndTarget, - addRegionalGuidanceReferenceImageFromImageDndTarget, - // Replace layer content w/ image - replaceLayerWithImageDndTarget, - // Set the upscale image - setUpscaleInitialImageFromImageDndTarget, - // Set a field on a node - setNodeImageFieldDndTarget, - // Select images for comparison - selectForCompareDndTarget, - // Add an image to a board - addToBoardDndTarget, - // Remove an image from a board - essentially add to Uncategorized - removeFromBoardDndTarget, - // These are currently unused - addRegionalGuidanceFromImageDndTarget, - addInpaintMaskFromImageDndTarget, -] as const; - -/** - * A union of all possible DndTargetData types. - */ -export type DndTargetData = - | SetGlobalReferenceImageDndTargetData - | SetRegionalGuidanceReferenceImageDndTargetData - | NewRasterLayerFromImageDndTargetData - | NewControlLayerFromImageDndTargetData - | AddInpaintMaskFromImageDndTargetData - | AddRegionalGuidanceFromImageDndTargetData - | AddRegionalGuidanceReferenceImageFromImageDndTargetData - | AddGlobalReferenceImageFromImageDndTargetData - | ReplaceLayerWithImageDndTargetData - | SetUpscaleInitialImageFromImageDndTargetData - | SetNodeImageFieldDndTargetData - | AddToBoardDndTargetData - | RemoveFromBoardDndTargetData - | SelectForCompareDndTargetData; - -export const isDndTargetData = (data: BaseDndData): data is DndTargetData => { - for (const targetApi of targetApis) { - if (targetApi.typeGuard(data)) { - return true; - } - } - return false; -}; -//#endregion - -/** - * Validates whether a drop is valid. - * @param sourceData The data being dragged. - * @param targetData The data of the target being dragged onto. - * @returns Whether the drop is valid. - */ -export const isValidDrop = (sourceData: DndSourceData, targetData: DndTargetData): boolean => { - for (const targetApi of targetApis) { - if (targetApi.typeGuard(targetData)) { - /** - * TS cannot narrow the type of the targetApi and will error in the validator call. - * We've just checked that targetData is of the right type, though, so this cast to `any` is safe. - */ - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - return targetApi.validateDrop(sourceData, targetData as any); - } - } - return false; -}; - -export type DndState = 'idle' | 'potential' | 'over'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 77c619a5b77..b686cecd5e8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -2,9 +2,8 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; -import type { AddToBoardDndTargetData } from 'features/dnd2/types'; -import { addToBoardDndTarget } from 'features/dnd2/types'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; import { BoardEditableTitle } from 'features/gallery/components/Boards/BoardsList/BoardEditableTitle'; @@ -45,8 +44,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { } }, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]); - const targetData: AddToBoardDndTargetData = useMemo( - () => addToBoardDndTarget.getData({ boardId: board.board_id }), + const targetData = useMemo( + () => Dnd.Target.addToBoard.getData({ boardId: board.board_id }), [board.board_id] ); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index e2289df7b92..b8515c40a18 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -1,9 +1,8 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; -import type { RemoveFromBoardDndTargetData } from 'features/dnd2/types'; -import { removeFromBoardDndTarget } from 'features/dnd2/types'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardBoardContextMenu'; @@ -44,7 +43,10 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { } }, [dispatch, autoAssignBoardOnClick]); - const targetData: RemoveFromBoardDndTargetData = useMemo(() => removeFromBoardDndTarget.getData({}), []); + const targetData = useMemo( + () => Dnd.Target.removeFromBoard.getData(), + [] + ); const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 41f2b2f143f..c9cd501edd2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -7,7 +7,7 @@ import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/lis import { useAppStore } from 'app/store/nanostores/store'; import { useAppSelector } from 'app/store/storeHooks'; import { useBoolean } from 'common/hooks/useBoolean'; -import { multipleImageDndSource, singleImageDndSource } from 'features/dnd2/types'; +import { Dnd } from 'features/dnd2/dnd'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; @@ -115,7 +115,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { // When we have multiple images selected, and the dragged image is part of the selection, initiate a // multi-image drag. if (gallery.selection.length > 1 && gallery.selection.includes(imageDTO)) { - return multipleImageDndSource.getData( + return Dnd.Source.multipleImage.getData( { imageDTOs: gallery.selection, boardId: gallery.selectedBoardId, @@ -125,13 +125,13 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { } // Otherwise, initiate a single-image drag - return singleImageDndSource.getData({ imageDTO }, imageDTO.image_name); + return Dnd.Source.singleImage.getData({ imageDTO }, imageDTO.image_name); }, // This is a "local" drag start event, meaning that it is only called when this specific image is dragged. - onDragStart: (args) => { + onDragStart: ({ source }) => { // When we start dragging a single image, set the dragging state to true. This is only called when this // specific image is dragged. - if (singleImageDndSource.typeGuard(args.source.data)) { + if (Dnd.Source.singleImage.typeGuard(source.data)) { setIsDragging(true); return; } @@ -139,10 +139,11 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { }), monitorForElements({ // This is a "global" drag start event, meaning that it is called for all drag events. - onDragStart: (args) => { + onDragStart: ({ source }) => { + console.log(source); // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the // selection. This is called for all drag events. - if (multipleImageDndSource.typeGuard(args.source.data) && args.source.data.imageDTOs.includes(imageDTO)) { + if (Dnd.Source.multipleImage.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) { setIsDragging(true); } }, 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 993e37479e2..13bf6eddad8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx @@ -1,7 +1,6 @@ import { useAppSelector } from 'app/store/storeHooks'; +import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; -import type { SelectForCompareDndTargetData } from 'features/dnd2/types'; -import { selectForCompareDndTarget } from 'features/dnd2/types'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,9 +9,9 @@ import { selectComparisonImages } from './common'; export const ImageComparisonDroppable = memo(() => { const { t } = useTranslation(); const comparisonImages = useAppSelector(selectComparisonImages); - const targetData = useMemo(() => { + const targetData = useMemo(() => { const { firstImage, secondImage } = comparisonImages; - return selectForCompareDndTarget.getData({ + return Dnd.Target.selectForCompare.getData({ firstImageName: firstImage?.image_name, secondImageName: secondImage?.image_name, }); 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 7c3e6294270..1718cbc05a4 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 @@ -3,10 +3,9 @@ import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch } from 'app/store/storeHooks'; import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import { DndImage } from 'features/dnd2/DndImage'; -import type { SetNodeImageFieldDndTargetData } from 'features/dnd2/types'; -import { setNodeImageFieldDndTarget } from 'features/dnd2/types'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field'; import { memo, useCallback, useEffect, useMemo } from 'react'; @@ -34,8 +33,8 @@ const ImageFieldInputComponent = (props: FieldComponentProps( - () => setNodeImageFieldDndTarget.getData({ nodeId, fieldName: field.name }, field.value?.image_name), + const targetData = useMemo( + () => Dnd.Target.setNodeImageField.getData({ nodeId, fieldName: field.name }, field.value?.image_name), [field.name, field.value?.image_name, nodeId] ); 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 9f204076cf6..9edcc6756dd 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx @@ -1,17 +1,15 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import { DndImage } from 'features/dnd2/DndImage'; -import { setUpscaleInitialImageFromImageDndTarget } from 'features/dnd2/types'; 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'; -const targetData = setUpscaleInitialImageFromImageDndTarget.getData({}); - export const UpscaleInitialImage = () => { const dispatch = useAppDispatch(); const imageDTO = useAppSelector(selectUpscaleInitialImage); @@ -21,6 +19,10 @@ export const UpscaleInitialImage = () => { }), [] ); + const targetData = useMemo( + () => Dnd.Target.setUpscaleInitialImageFromImage.getData(), + [] + ); const onReset = useCallback(() => { dispatch(upscaleInitialImageChanged(null)); From 18c46b9c80f4772cf93e90d7964c53808461e113 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:12:27 +1000 Subject: [PATCH 08/39] feat(ui): better type name --- invokeai/frontend/web/src/features/dnd2/dnd.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/dnd2/dnd.ts b/invokeai/frontend/web/src/features/dnd2/dnd.ts index 46310b7d8f1..c3bbd3a3f1d 100644 --- a/invokeai/frontend/web/src/features/dnd2/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd2/dnd.ts @@ -8,7 +8,7 @@ import type { ValueOf } from 'type-fest'; import type { Jsonifiable } from 'type-fest/source/jsonifiable'; type EmptyObject = Record; -type RecordUnknown = Record; +type UnknownDndData = Record; /** * This file contains types, APIs, and utilities for Dnd functionality, as provided by pragmatic-drag-and-drop: @@ -40,7 +40,7 @@ type Data(type: string, kind: DndKind) => { // pragmatic-drag-and-drop types all data as unknown, so we need to cast it to the expected type - return (data: RecordUnknown): data is T => { + return (data: UnknownDndData): data is T => { try { return (data as Data).meta.type === type && (data as Data).meta.kind === kind; } catch { @@ -371,7 +371,7 @@ export const Dnd = { * Checks if the data is a Dnd source data object. * @param data The data to check. */ - isDndSourceData: (data: RecordUnknown): data is SourceDataUnion => { + isDndSourceData: (data: UnknownDndData): data is SourceDataUnion => { try { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ return (data as Data).meta.kind === 'source'; @@ -383,7 +383,7 @@ export const Dnd = { * Checks if the data is a Dnd target data object. * @param data The data to check. */ - isDndTargetData: (data: RecordUnknown): data is TargetDataUnion => { + isDndTargetData: (data: UnknownDndData): data is TargetDataUnion => { try { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ return (data as Data).meta.kind === 'target'; From 2b55462c5482a2c4a0cebdad7f33e50f2b6af356 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:19:08 +1000 Subject: [PATCH 09/39] feat(ui): better types for getData --- invokeai/frontend/web/src/features/dnd2/dnd.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/src/features/dnd2/dnd.ts b/invokeai/frontend/web/src/features/dnd2/dnd.ts index c3bbd3a3f1d..cc6a5df4a1e 100644 --- a/invokeai/frontend/web/src/features/dnd2/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd2/dnd.ts @@ -7,7 +7,6 @@ import type { ImageDTO } from 'services/api/types'; import type { ValueOf } from 'type-fest'; import type { Jsonifiable } from 'type-fest/source/jsonifiable'; -type EmptyObject = Record; type UnknownDndData = Record; /** @@ -24,7 +23,11 @@ type UnknownDndData = Record; type DndKind = 'source' | 'target'; -type Data = { +type Data< + T extends string = string, + K extends DndKind = DndKind, + P extends Jsonifiable | undefined = Jsonifiable | undefined, +> = { meta: { id: string; type: T; @@ -59,7 +62,7 @@ const _buildDataTypeGuard = (type: string, kind: DndKind) => { */ const _buildDataGetter = (type: T['meta']['type'], kind: T['meta']['kind']) => - (payload: T['payload'] extends EmptyObject ? void : T['payload'], dndId?: string | null): T => { + (payload: T['payload'] extends undefined ? void : T['payload'], dndId?: string | null): T => { return { meta: { id: dndId ?? getPrefixedId(`dnd-${kind}-${type}`), @@ -95,7 +98,7 @@ type DndSourceAPI = { * Builds a DndSourceAPI object. * @param key The unique symbol key for the DndData type. */ -const buildDndSourceApi =

(type: string) => { +const buildDndSourceApi =

(type: string) => { return { type, kind: 'source', @@ -148,7 +151,7 @@ type DndTargetApi = DndSourceAPI & { * @param key The unique symbol key for the DndData type. * @param validateDrop A function that validates whether a drop is valid. */ -const buildDndTargetApi =

( +const buildDndTargetApi =

( type: string, validateDrop: (sourceData: Data, targetData: Data) => boolean ) => { From 98d966131b7a3d6331d3ea43c5156d1f6da9753b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:40:21 +1000 Subject: [PATCH 10/39] tidy(ui): document & clean up dnd --- .../frontend/web/src/features/dnd2/dnd.ts | 122 +++++++++++++----- 1 file changed, 92 insertions(+), 30 deletions(-) diff --git a/invokeai/frontend/web/src/features/dnd2/dnd.ts b/invokeai/frontend/web/src/features/dnd2/dnd.ts index cc6a5df4a1e..234e6b5fb58 100644 --- a/invokeai/frontend/web/src/features/dnd2/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd2/dnd.ts @@ -7,8 +7,6 @@ import type { ImageDTO } from 'services/api/types'; import type { ValueOf } from 'type-fest'; import type { Jsonifiable } from 'type-fest/source/jsonifiable'; -type UnknownDndData = Record; - /** * This file contains types, APIs, and utilities for Dnd functionality, as provided by pragmatic-drag-and-drop: * - Source and target data types @@ -21,31 +19,71 @@ type UnknownDndData = Record; * - https://atlassian.design/components/pragmatic-drag-and-drop/about */ +/** + * A type for unknown Dnd data. `pragmatic-drag-and-drop` types all data as this type. + */ +type UnknownDndData = Record; + +/** + * A Dnd kind, which can be either a source or a target. + */ type DndKind = 'source' | 'target'; -type Data< +/** + * Data for a given Dnd source or target, which contains metadata and payload. + * @template T The type string of the Dnd data. This should be unique for each type of Dnd data. + * @template K The kind of the Dnd data ('source' or 'target'). + * @template P The optional payload of the Dnd data. This can be any "Jsonifiable" data - that is, data that can be + * serialized to JSON. This ensures the data can be safely stored in Redux, logged, etc. + */ +type DndData< T extends string = string, K extends DndKind = DndKind, P extends Jsonifiable | undefined = Jsonifiable | undefined, > = { + /** + * Metadata about the DndData. + */ meta: { + /** + * An identifier for this data. This may or may not be unique. This is primarily used to prevent a source from + * dropping on itself. + * + * A consumer may be both a Dnd source and target of the same type. For example, the upscaling initial image is + * a Dnd target and may contain an image, which is itself a Dnd source. In this case, the Dnd ID is used to prevent + * the upscaling initial image (and other instances of that same image) from being dropped onto itself. + * + * This is accomplished by checking the Dnd ID of the source against the Dnd ID of the target. If they match, the + * drop is rejected. + */ id: string; + /** + * The type of the DndData. + */ type: T; + /** + * The kind of the DndData (source or target). + */ kind: K; }; + /** + * The arbitrarily-shaped payload of the DndData. + */ payload: P; }; /** * Builds a type guard for a specific DndData type. - * @param key The unique symbol key for the DndData type. - * @returns A type guard for the DndData type. + * @template T The Dnd data type. + * @param type The type of the Dnd source or target data. + * @param kind The kind of the Dnd source or target data. + * @returns A type guard for the Dnd data. */ -const _buildDataTypeGuard = (type: string, kind: DndKind) => { +const _buildDataTypeGuard = (type: T['meta']['type'], kind: T['meta']['kind']) => { // pragmatic-drag-and-drop types all data as unknown, so we need to cast it to the expected type return (data: UnknownDndData): data is T => { try { - return (data as Data).meta.type === type && (data as Data).meta.kind === kind; + return (data as DndData).meta.type === type && (data as DndData).meta.kind === kind; } catch { return false; } @@ -57,11 +95,13 @@ const _buildDataTypeGuard = (type: string, kind: DndKind) => { * * The getter accepts arbitrary data and an optional Dnd ID. If no Dnd ID is provided, a unique one is generated. * - * @param key The unique symbol key for the DndData type. + * @template T The Dnd data type. + * @param type The type of the Dnd source or target data. + * @param kind The kind of the Dnd source or target data. * @returns A getter for the DndData type. */ const _buildDataGetter = - (type: T['meta']['type'], kind: T['meta']['kind']) => + (type: T['meta']['type'], kind: T['meta']['kind']) => (payload: T['payload'] extends undefined ? void : T['payload'], dndId?: string | null): T => { return { meta: { @@ -74,10 +114,16 @@ const _buildDataGetter = }; /** - * An API for a Dnd source. It provides a type guard, a getter, and a unique symbol key for the DndData type. + * The API for a Dnd source. */ -type DndSourceAPI = { +type DndSourceAPI = { + /** + * The type of the Dnd source. + */ type: string; + /** + * The kind of the Dnd source. It is always 'source'. + */ kind: 'source'; /** * A type guard for the DndData type. @@ -86,8 +132,8 @@ type DndSourceAPI = { */ typeGuard: ReturnType>; /** - * A getter for the DndData type. - * @param data The data to get. + * Gets a typed DndData object for the parent type. + * @param payload The payload for this DndData. * @param dndId The Dnd ID to use. If not provided, a unique one is generated. * @returns The DndData. */ @@ -95,16 +141,17 @@ type DndSourceAPI = { }; /** - * Builds a DndSourceAPI object. - * @param key The unique symbol key for the DndData type. + * Builds a Dnd source API. + * @template P The optional payload of the Dnd source. + * @param type The type of the Dnd source. */ const buildDndSourceApi =

(type: string) => { return { type, kind: 'source', - typeGuard: _buildDataTypeGuard>(type, 'source'), - getData: _buildDataGetter>(type, 'source'), - } satisfies DndSourceAPI>; + typeGuard: _buildDataTypeGuard>(type, 'source'), + getData: _buildDataGetter>(type, 'source'), + } satisfies DndSourceAPI>; }; //#region DndSourceData @@ -134,34 +181,38 @@ type SourceDataUnion = ValueOf; //#region DndTargetData /** - * An API for a Dnd target. It extends the DndSourceAPI with a validateDrop function. + * The API for a Dnd target. */ -type DndTargetApi = DndSourceAPI & { +type DndTargetApi = DndSourceAPI & { /** * Validates whether a drop is valid, give the source and target data. * @param sourceData The source data (i.e. the data being dragged) * @param targetData The target data (i.e. the data being dragged onto) * @returns Whether the drop is valid. */ - validateDrop: (sourceData: Data, targetData: T) => boolean; + validateDrop: (sourceData: DndData, targetData: T) => boolean; }; /** - * Builds a DndTargetApi object. - * @param key The unique symbol key for the DndData type. + * Builds a Dnd target API. + * @template P The optional payload of the Dnd target. + * @param type The type of the Dnd target. * @param validateDrop A function that validates whether a drop is valid. */ const buildDndTargetApi =

( type: string, - validateDrop: (sourceData: Data, targetData: Data) => boolean + validateDrop: ( + sourceData: DndData, + targetData: DndData + ) => boolean ) => { return { type, kind: 'source', - typeGuard: _buildDataTypeGuard>(type, 'target'), - getData: _buildDataGetter>(type, 'target'), + typeGuard: _buildDataTypeGuard>(type, 'target'), + getData: _buildDataGetter>(type, 'target'), validateDrop, - } satisfies DndTargetApi>; + } satisfies DndTargetApi>; }; /** @@ -329,6 +380,9 @@ const targetApisArray = Object.values(DndTarget); //#endregion +/** + * The Dnd namespace, providing types and APIs for Dnd functionality. + */ export declare namespace Dnd { export type types = { /** @@ -339,6 +393,14 @@ export declare namespace Dnd { * - `over`: A drag is occurring, and the drag is valid for the current drop target, and the drag is over the drop target. */ DndState: 'idle' | 'potential' | 'over'; + /** + * A Dnd kind, which can be either a source or a target. + */ + DndKind: DndKind; + /** + * A type for unknown Dnd data. `pragmatic-drag-and-drop` types all data as this type. + */ + UnknownDndData: UnknownDndData; /** * A map of target APIs to their data types. */ @@ -367,7 +429,7 @@ export const Dnd = { * @param data The DndData object. * @returns The Dnd ID. */ - getDndId: (data: Data): string => { + getDndId: (data: DndData): string => { return data.meta.id; }, /** @@ -377,7 +439,7 @@ export const Dnd = { isDndSourceData: (data: UnknownDndData): data is SourceDataUnion => { try { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - return (data as Data).meta.kind === 'source'; + return (data as DndData).meta.kind === 'source'; } catch { return false; } @@ -389,7 +451,7 @@ export const Dnd = { isDndTargetData: (data: UnknownDndData): data is TargetDataUnion => { try { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - return (data as Data).meta.kind === 'target'; + return (data as DndData).meta.kind === 'target'; } catch { return false; } From 93b7cee76a44f88406fbe98b09995a49c67c4249 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:53:34 +1000 Subject: [PATCH 11/39] tidy(ui): clean up old dnd stuff --- invokeai/frontend/web/package.json | 3 - invokeai/frontend/web/pnpm-lock.yaml | 52 --- .../web/src/app/components/InvokeAIUI.tsx | 5 +- .../middleware/listenerMiddleware/index.ts | 2 - .../listeners/imageDropped.ts | 333 ----------------- .../src/common/components/DraggableImage.tsx | 0 .../web/src/common/components/IAIDndImage.tsx | 251 ------------- .../src/common/components/IAIDraggable.tsx | 38 -- .../src/common/components/IAIDropOverlay.tsx | 64 ---- .../src/common/components/IAIDroppable.tsx | 14 - .../IPAdapter/IPAdapterImagePreview.tsx | 4 +- .../features/dnd/components/AppDndContext.tsx | 71 ---- .../dnd/components/DndContextTypesafe.tsx | 6 - .../features/dnd/components/DndOverlay.tsx | 52 --- .../features/dnd/components/DndSortable.tsx | 23 -- .../features/dnd/components/DragPreview.tsx | 83 ----- .../src/features/dnd/hooks/typesafeHooks.ts | 15 - .../dnd/hooks/useScaledCenteredModifer.ts | 47 --- .../web/src/features/dnd/types/index.ts | 185 ---------- .../features/dnd/util/customPointerWithin.ts | 34 -- .../web/src/features/dnd/util/isValidDrop.ts | 83 ----- .../dnd2/DndImageIcon.tsx} | 6 +- .../frontend/web/src/features/dnd2/test.tsx | 335 ------------------ .../GalleryImageDeleteIconButton.tsx | 4 +- .../GalleryImageOpenInViewerIconButton.tsx | 4 +- .../ImageGrid/GalleryImageStarIconButton.tsx | 6 +- .../Invocation/fields/LinearViewField.tsx | 13 - .../inputs/ImageFieldInputComponent.tsx | 4 +- .../sidePanel/workflow/WorkflowLinearTab.tsx | 56 +-- .../UpscaleInitialImage.tsx | 4 +- 30 files changed, 31 insertions(+), 1766 deletions(-) delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDropped.ts delete mode 100644 invokeai/frontend/web/src/common/components/DraggableImage.tsx delete mode 100644 invokeai/frontend/web/src/common/components/IAIDndImage.tsx delete mode 100644 invokeai/frontend/web/src/common/components/IAIDraggable.tsx delete mode 100644 invokeai/frontend/web/src/common/components/IAIDropOverlay.tsx delete mode 100644 invokeai/frontend/web/src/common/components/IAIDroppable.tsx delete mode 100644 invokeai/frontend/web/src/features/dnd/components/AppDndContext.tsx delete mode 100644 invokeai/frontend/web/src/features/dnd/components/DndContextTypesafe.tsx delete mode 100644 invokeai/frontend/web/src/features/dnd/components/DndOverlay.tsx delete mode 100644 invokeai/frontend/web/src/features/dnd/components/DndSortable.tsx delete mode 100644 invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx delete mode 100644 invokeai/frontend/web/src/features/dnd/hooks/typesafeHooks.ts delete mode 100644 invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts delete mode 100644 invokeai/frontend/web/src/features/dnd/types/index.ts delete mode 100644 invokeai/frontend/web/src/features/dnd/util/customPointerWithin.ts delete mode 100644 invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts rename invokeai/frontend/web/src/{common/components/IAIDndImageIcon.tsx => features/dnd2/DndImageIcon.tsx} (90%) delete mode 100644 invokeai/frontend/web/src/features/dnd2/test.tsx diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index fe603177e2f..98665a63411 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -56,9 +56,6 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", "@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 8f0ffeb1597..832a3e8ab44 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -17,15 +17,6 @@ dependencies: '@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 @@ -1001,49 +992,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: 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/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 98edff0575e..d40818e09e9 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -17,7 +17,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'; @@ -95,7 +94,6 @@ addWorkflowLoadRequestedListener(startAppListening); addUpdateAllNodesRequestedListener(startAppListening); // DND -addImageDroppedListener(startAppListening); addDndDroppedListener(startAppListening); // Models 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/common/components/DraggableImage.tsx b/invokeai/frontend/web/src/common/components/DraggableImage.tsx deleted file mode 100644 index e69de29bb2d..00000000000 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 a7dae9ac822..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 9b28a2dc3da..00000000000 --- a/invokeai/frontend/web/src/common/components/IAIDroppable.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { TypesafeDroppableData } from 'features/dnd/types'; -import { memo } from 'react'; - -type IAIDroppableProps = { - dropLabel?: string; - disabled?: boolean; - data?: TypesafeDroppableData; -}; - -const IAIDroppable = (props: IAIDroppableProps) => { - return null; -}; - -export default memo(IAIDroppable); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx index a07ec7b96f3..b19696bbd73 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx @@ -2,11 +2,11 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; -import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; import type { ImageWithDims } from 'features/controlLayers/store/types'; import type { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import { DndImage } from 'features/dnd2/DndImage'; +import { DndImageIcon } from 'features/dnd2/DndImageIcon'; import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; @@ -55,7 +55,7 @@ export const IPAdapterImagePreview = memo(({ image, onChangeImage, targetData, p <> - } tooltip={t('common.reset')} diff --git a/invokeai/frontend/web/src/features/dnd/components/AppDndContext.tsx b/invokeai/frontend/web/src/features/dnd/components/AppDndContext.tsx deleted file mode 100644 index bd3e0474fde..00000000000 --- a/invokeai/frontend/web/src/features/dnd/components/AppDndContext.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { MouseSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core'; -import { logger } from 'app/logging/logger'; -import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/imageDropped'; -import { useAppDispatch } from 'app/store/storeHooks'; -import DndOverlay from 'features/dnd/components/DndOverlay'; -import type { DragEndEvent, DragStartEvent, TypesafeDraggableData } from 'features/dnd/types'; -import { customPointerWithin } from 'features/dnd/util/customPointerWithin'; -import type { PropsWithChildren } from 'react'; -import { memo, useCallback, useState } from 'react'; - -import { DndContextTypesafe } from './DndContextTypesafe'; - -const log = logger('system'); - -const AppDndContext = (props: PropsWithChildren) => { - const [activeDragData, setActiveDragData] = useState(null); - - const dispatch = useAppDispatch(); - - const handleDragStart = useCallback((event: DragStartEvent) => { - log.trace({ dragData: event.active.data.current }, 'Drag started'); - const activeData = event.active.data.current; - if (!activeData) { - return; - } - setActiveDragData(activeData); - }, []); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - log.trace({ dragData: event.active.data.current }, 'Drag ended'); - const overData = event.over?.data.current; - if (!activeDragData || !overData) { - return; - } - dispatch(dndDropped({ overData, activeData: activeDragData })); - setActiveDragData(null); - }, - [activeDragData, dispatch] - ); - - const mouseSensor = useSensor(MouseSensor, { - activationConstraint: { distance: 10 }, - }); - - const touchSensor = useSensor(TouchSensor, { - activationConstraint: { distance: 10 }, - }); - - // TODO: Use KeyboardSensor - needs composition of multiple collisionDetection algos - // Alternatively, fix `rectIntersection` collection detection to work with the drag overlay - // (currently the drag element collision rect is not correctly calculated) - // const keyboardSensor = useSensor(KeyboardSensor); - - const sensors = useSensors(mouseSensor, touchSensor); - - return ( - - {props.children} - - - ); -}; - -export default memo(AppDndContext); diff --git a/invokeai/frontend/web/src/features/dnd/components/DndContextTypesafe.tsx b/invokeai/frontend/web/src/features/dnd/components/DndContextTypesafe.tsx deleted file mode 100644 index e278e648a17..00000000000 --- a/invokeai/frontend/web/src/features/dnd/components/DndContextTypesafe.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { DndContext } from '@dnd-kit/core'; -import type { DndContextTypesafeProps } from 'features/dnd/types'; - -export function DndContextTypesafe(props: DndContextTypesafeProps) { - return ; -} diff --git a/invokeai/frontend/web/src/features/dnd/components/DndOverlay.tsx b/invokeai/frontend/web/src/features/dnd/components/DndOverlay.tsx deleted file mode 100644 index 883d79c87f6..00000000000 --- a/invokeai/frontend/web/src/features/dnd/components/DndOverlay.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { DragOverlay } from '@dnd-kit/core'; -import { useScaledModifer } from 'features/dnd/hooks/useScaledCenteredModifer'; -import type { TypesafeDraggableData } from 'features/dnd/types'; -import type { AnimationProps } from 'framer-motion'; -import { AnimatePresence, motion } from 'framer-motion'; -import type { CSSProperties } from 'react'; -import { memo, useMemo } from 'react'; - -import DragPreview from './DragPreview'; - -type DndOverlayProps = { - activeDragData: TypesafeDraggableData | null; -}; - -const DndOverlay = (props: DndOverlayProps) => { - const scaledModifier = useScaledModifer(); - const modifiers = useMemo(() => [scaledModifier], [scaledModifier]); - - return ( - - - {props.activeDragData && ( - - - - )} - - - ); -}; - -export default memo(DndOverlay); - -const dragOverlayStyles: CSSProperties = { - width: 'min-content', - height: 'min-content', - cursor: 'grabbing', - pointerEvents: 'none', - userSelect: 'none', - // expand overlay to prevent cursor from going outside it and displaying - padding: '10rem', -}; - -const initial: AnimationProps['initial'] = { - opacity: 0, - scale: 0.7, -}; -const animate: AnimationProps['animate'] = { - opacity: 1, - scale: 1, - transition: { duration: 0.1 }, -}; diff --git a/invokeai/frontend/web/src/features/dnd/components/DndSortable.tsx b/invokeai/frontend/web/src/features/dnd/components/DndSortable.tsx deleted file mode 100644 index 786e1ce9c22..00000000000 --- a/invokeai/frontend/web/src/features/dnd/components/DndSortable.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { DragEndEvent } from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import type { PropsWithChildren } from 'react'; -import { memo } from 'react'; - -import { DndContextTypesafe } from './DndContextTypesafe'; - -type Props = PropsWithChildren & { - items: string[]; - onDragEnd(event: DragEndEvent): void; -}; - -const DndSortable = (props: Props) => { - return ( - - - {props.children} - - - ); -}; - -export default memo(DndSortable); diff --git a/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx b/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx deleted file mode 100644 index a73f0ba04e9..00000000000 --- a/invokeai/frontend/web/src/features/dnd/components/DragPreview.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { ChakraProps } from '@invoke-ai/ui-library'; -import { Box, Flex, Heading, Image, Text } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import type { TypesafeDraggableData } from 'features/dnd/types'; -import { selectSelectionCount } from 'features/gallery/store/gallerySelectors'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; - -type OverlayDragImageProps = { - dragData: TypesafeDraggableData | null; -}; - -const BOX_SIZE = 28; - -const imageStyles: ChakraProps['sx'] = { - w: BOX_SIZE, - h: BOX_SIZE, - maxW: BOX_SIZE, - maxH: BOX_SIZE, - shadow: 'dark-lg', - borderRadius: 'lg', - opacity: 0.3, - borderColor: 'base.200', - bg: 'base.900', - color: 'base.100', -}; - -const multiImageStyles: ChakraProps['sx'] = { - position: 'relative', - alignItems: 'center', - justifyContent: 'center', - flexDir: 'column', - ...imageStyles, -}; - -const DragPreview = (props: OverlayDragImageProps) => { - const { t } = useTranslation(); - const selectionCount = useAppSelector(selectSelectionCount); - if (!props.dragData) { - return null; - } - - if (props.dragData.payloadType === 'NODE_FIELD') { - const { field, fieldTemplate } = props.dragData.payload; - return ( - - {field.label || fieldTemplate.title} - - ); - } - - if (props.dragData.payloadType === 'IMAGE_DTO') { - const { thumbnail_url, width, height } = props.dragData.payload.imageDTO; - return ( - - - - ); - } - - if (props.dragData.payloadType === 'GALLERY_SELECTION') { - return ( - - {selectionCount} - {t('parameters.images')} - - ); - } - - return null; -}; - -export default memo(DragPreview); diff --git a/invokeai/frontend/web/src/features/dnd/hooks/typesafeHooks.ts b/invokeai/frontend/web/src/features/dnd/hooks/typesafeHooks.ts deleted file mode 100644 index 8c5a63cee3f..00000000000 --- a/invokeai/frontend/web/src/features/dnd/hooks/typesafeHooks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useDraggable, useDroppable } from '@dnd-kit/core'; -import type { - UseDraggableTypesafeArguments, - UseDraggableTypesafeReturnValue, - UseDroppableTypesafeArguments, - UseDroppableTypesafeReturnValue, -} from 'features/dnd/types'; - -export function useDroppableTypesafe(props: UseDroppableTypesafeArguments) { - return useDroppable(props) as UseDroppableTypesafeReturnValue; -} - -export function useDraggableTypesafe(props: UseDraggableTypesafeArguments) { - return useDraggable(props) as UseDraggableTypesafeReturnValue; -} diff --git a/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts b/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts deleted file mode 100644 index da3a2a88b70..00000000000 --- a/invokeai/frontend/web/src/features/dnd/hooks/useScaledCenteredModifer.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Modifier } from '@dnd-kit/core'; -import { getEventCoordinates } from '@dnd-kit/utilities'; -import { useStore } from '@nanostores/react'; -import { useAppSelector } from 'app/store/storeHooks'; -import { $viewport } from 'features/nodes/store/nodesSlice'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { useCallback } from 'react'; - -/** - * Applies scaling to the drag transform (if on node editor tab) and centers it on cursor. - */ -export const useScaledModifer = () => { - const activeTabName = useAppSelector(selectActiveTab); - const workflowsViewport = useStore($viewport); - const modifier: Modifier = useCallback( - ({ activatorEvent, draggingNodeRect, transform }) => { - if (draggingNodeRect && activatorEvent) { - const zoom = activeTabName === 'workflows' ? workflowsViewport.zoom : 1; - const activatorCoordinates = getEventCoordinates(activatorEvent); - - if (!activatorCoordinates) { - return transform; - } - - const offsetX = activatorCoordinates.x - draggingNodeRect.left; - const offsetY = activatorCoordinates.y - draggingNodeRect.top; - - const x = transform.x + offsetX - draggingNodeRect.width / 2; - const y = transform.y + offsetY - draggingNodeRect.height / 2; - const scaleX = transform.scaleX * zoom; - const scaleY = transform.scaleY * zoom; - - return { - x, - y, - scaleX, - scaleY, - }; - } - - return transform; - }, - [activeTabName, workflowsViewport.zoom] - ); - - return modifier; -}; diff --git a/invokeai/frontend/web/src/features/dnd/types/index.ts b/invokeai/frontend/web/src/features/dnd/types/index.ts deleted file mode 100644 index 7742719079a..00000000000 --- a/invokeai/frontend/web/src/features/dnd/types/index.ts +++ /dev/null @@ -1,185 +0,0 @@ -// type-safe dnd from https://github.com/clauderic/dnd-kit/issues/935 -import type { - Active, - Collision, - DndContextProps, - Over, - Translate, - useDraggable as useOriginalDraggable, - UseDraggableArguments, - useDroppable as useOriginalDroppable, - UseDroppableArguments, -} from '@dnd-kit/core'; -import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import type { BoardId } from 'features/gallery/store/types'; -import type { FieldInputInstance, FieldInputTemplate } from 'features/nodes/types/field'; -import type { ImageDTO } from 'services/api/types'; - -type BaseDropData = { - id: string; -}; - -export type IPAImageDropData = BaseDropData & { - actionType: 'SET_IPA_IMAGE'; - context: { - id: string; - }; -}; - -export type RGIPAdapterImageDropData = BaseDropData & { - actionType: 'SET_RG_IP_ADAPTER_IMAGE'; - context: { - id: string; - referenceImageId: string; - }; -}; - -export type AddRasterLayerFromImageDropData = BaseDropData & { - actionType: 'ADD_RASTER_LAYER_FROM_IMAGE'; -}; - -export type AddControlLayerFromImageDropData = BaseDropData & { - actionType: 'ADD_CONTROL_LAYER_FROM_IMAGE'; -}; - -type AddInpaintMaskFromImageDropData = BaseDropData & { - actionType: 'ADD_INPAINT_MASK_FROM_IMAGE'; -}; - -type AddRegionalGuidanceFromImageDropData = BaseDropData & { - actionType: 'ADD_REGIONAL_GUIDANCE_FROM_IMAGE'; -}; - -export type AddRegionalReferenceImageFromImageDropData = BaseDropData & { - actionType: 'ADD_REGIONAL_REFERENCE_IMAGE_FROM_IMAGE'; -}; - -export type AddGlobalReferenceImageFromImageDropData = BaseDropData & { - actionType: 'ADD_GLOBAL_REFERENCE_IMAGE_FROM_IMAGE'; -}; - -export type ReplaceLayerImageDropData = BaseDropData & { - actionType: 'REPLACE_LAYER_WITH_IMAGE'; - context: { - entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>; - }; -}; - -type UpscaleInitialImageDropData = BaseDropData & { - actionType: 'SET_UPSCALE_INITIAL_IMAGE'; -}; - -type NodesImageDropData = BaseDropData & { - actionType: 'SET_NODES_IMAGE'; - context: { - nodeId: string; - fieldName: string; - }; -}; - -export type AddToBoardDropData = BaseDropData & { - actionType: 'ADD_TO_BOARD'; - context: { boardId: string }; -}; - -export type RemoveFromBoardDropData = BaseDropData & { - actionType: 'REMOVE_FROM_BOARD'; -}; - -export type SelectForCompareDropData = BaseDropData & { - actionType: 'SELECT_FOR_COMPARE'; - context: { - firstImageName?: string | null; - secondImageName?: string | null; - }; -}; - -export type TypesafeDroppableData = - | NodesImageDropData - | AddToBoardDropData - | RemoveFromBoardDropData - | IPAImageDropData - | RGIPAdapterImageDropData - | SelectForCompareDropData - | UpscaleInitialImageDropData - | AddRasterLayerFromImageDropData - | AddControlLayerFromImageDropData - | ReplaceLayerImageDropData - | AddRegionalReferenceImageFromImageDropData - | AddGlobalReferenceImageFromImageDropData - | AddInpaintMaskFromImageDropData - | AddRegionalGuidanceFromImageDropData; - -type BaseDragData = { - id: string; -}; - -type NodeFieldDraggableData = BaseDragData & { - payloadType: 'NODE_FIELD'; - payload: { - nodeId: string; - field: FieldInputInstance; - fieldTemplate: FieldInputTemplate; - }; -}; - -export type ImageDraggableData = BaseDragData & { - payloadType: 'IMAGE_DTO'; - payload: { imageDTO: ImageDTO }; -}; - -export type GallerySelectionDraggableData = BaseDragData & { - payloadType: 'GALLERY_SELECTION'; - payload: { boardId: BoardId }; -}; - -export type TypesafeDraggableData = NodeFieldDraggableData | ImageDraggableData | GallerySelectionDraggableData; - -export interface UseDroppableTypesafeArguments extends Omit { - data?: TypesafeDroppableData; -} - -export type UseDroppableTypesafeReturnValue = Omit, 'active' | 'over'> & { - active: TypesafeActive | null; - over: TypesafeOver | null; -}; - -export interface UseDraggableTypesafeArguments extends Omit { - data?: TypesafeDraggableData; -} - -export type UseDraggableTypesafeReturnValue = Omit, 'active' | 'over'> & { - active: TypesafeActive | null; - over: TypesafeOver | null; -}; - -interface TypesafeActive extends Omit { - data: React.MutableRefObject; -} - -interface TypesafeOver extends Omit { - data: React.MutableRefObject; -} - -interface DragEvent { - activatorEvent: Event; - active: TypesafeActive; - collisions: Collision[] | null; - delta: Translate; - over: TypesafeOver | null; -} - -export interface DragStartEvent extends Pick {} -interface DragMoveEvent extends DragEvent {} -interface DragOverEvent extends DragMoveEvent {} -export interface DragEndEvent extends DragEvent {} -interface DragCancelEvent extends DragEndEvent {} - -export interface DndContextTypesafeProps - extends Omit { - onDragStart?(event: DragStartEvent): void; - onDragMove?(event: DragMoveEvent): void; - onDragOver?(event: DragOverEvent): void; - onDragEnd?(event: DragEndEvent): void; - onDragCancel?(event: DragCancelEvent): void; -} diff --git a/invokeai/frontend/web/src/features/dnd/util/customPointerWithin.ts b/invokeai/frontend/web/src/features/dnd/util/customPointerWithin.ts deleted file mode 100644 index 5de12483249..00000000000 --- a/invokeai/frontend/web/src/features/dnd/util/customPointerWithin.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { CollisionDetection } from '@dnd-kit/core'; -import { pointerWithin } from '@dnd-kit/core'; - -/** - * Filters out droppable elements that are overflowed, then applies the pointerWithin collision detection. - * - * Fixes collision detection firing on droppables that are not visible, having been scrolled out of view. - * - * See https://github.com/clauderic/dnd-kit/issues/1198 - */ -export const customPointerWithin: CollisionDetection = (arg) => { - if (!arg.pointerCoordinates) { - // sanity check - return []; - } - - // Get all elements at the pointer coordinates. This excludes elements which are overflowed, - // so it won't include the droppable elements that are scrolled out of view. - const targetElements = document.elementsFromPoint(arg.pointerCoordinates.x, arg.pointerCoordinates.y); - - const filteredDroppableContainers = arg.droppableContainers.filter((container) => { - if (!container.node.current) { - return false; - } - // Only include droppable elements that are in the list of elements at the pointer coordinates. - return targetElements.includes(container.node.current); - }); - - // Run the provided collision detection with the filtered droppable elements. - return pointerWithin({ - ...arg, - droppableContainers: filteredDroppableContainers, - }); -}; diff --git a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts b/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts deleted file mode 100644 index 8072d62c9df..00000000000 --- a/invokeai/frontend/web/src/features/dnd/util/isValidDrop.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types'; - -export const isValidDrop = (overData?: TypesafeDroppableData | null, activeData?: TypesafeDraggableData | null) => { - if (!overData || !activeData) { - return false; - } - - const { actionType } = overData; - const { payloadType } = activeData; - - if (overData.id === activeData.id) { - return false; - } - - switch (actionType) { - case 'SET_IPA_IMAGE': - case 'SET_RG_IP_ADAPTER_IMAGE': - case 'ADD_RASTER_LAYER_FROM_IMAGE': - case 'ADD_CONTROL_LAYER_FROM_IMAGE': - case 'ADD_INPAINT_MASK_FROM_IMAGE': - case 'ADD_REGIONAL_GUIDANCE_FROM_IMAGE': - case 'SET_UPSCALE_INITIAL_IMAGE': - case 'SET_NODES_IMAGE': - case 'SELECT_FOR_COMPARE': - case 'REPLACE_LAYER_WITH_IMAGE': - case 'ADD_GLOBAL_REFERENCE_IMAGE_FROM_IMAGE': - case 'ADD_REGIONAL_REFERENCE_IMAGE_FROM_IMAGE': - return payloadType === 'IMAGE_DTO'; - case 'ADD_TO_BOARD': { - // If the board is the same, don't allow the drop - - // Check the payload types - const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(payloadType); - if (!isPayloadValid) { - return false; - } - - // Check if the image's board is the board we are dragging onto - if (payloadType === 'IMAGE_DTO') { - const { imageDTO } = activeData.payload; - const currentBoard = imageDTO.board_id ?? 'none'; - const destinationBoard = overData.context.boardId; - - return currentBoard !== destinationBoard; - } - - if (payloadType === 'GALLERY_SELECTION') { - // Assume all images are on the same board - this is true for the moment - const currentBoard = activeData.payload.boardId; - const destinationBoard = overData.context.boardId; - return currentBoard !== destinationBoard; - } - - return false; - } - case 'REMOVE_FROM_BOARD': { - // If the board is the same, don't allow the drop - - // Check the payload types - const isPayloadValid = ['IMAGE_DTO', 'GALLERY_SELECTION'].includes(payloadType); - if (!isPayloadValid) { - return false; - } - - // Check if the image's board is the board we are dragging onto - if (payloadType === 'IMAGE_DTO') { - const { imageDTO } = activeData.payload; - const currentBoard = imageDTO.board_id ?? 'none'; - - return currentBoard !== 'none'; - } - - if (payloadType === 'GALLERY_SELECTION') { - const currentBoard = activeData.payload.boardId; - return currentBoard !== 'none'; - } - - return false; - } - default: - return false; - } -}; diff --git a/invokeai/frontend/web/src/common/components/IAIDndImageIcon.tsx b/invokeai/frontend/web/src/features/dnd2/DndImageIcon.tsx similarity index 90% rename from invokeai/frontend/web/src/common/components/IAIDndImageIcon.tsx rename to invokeai/frontend/web/src/features/dnd2/DndImageIcon.tsx index 8c24c2be554..d9b63f1ec87 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImageIcon.tsx +++ b/invokeai/frontend/web/src/features/dnd2/DndImageIcon.tsx @@ -21,7 +21,7 @@ type Props = Omit & { tooltip: string; }; -const IAIDndImageIcon = (props: Props) => { +export const DndImageIcon = memo((props: Props) => { const { onClick, tooltip, icon, ...rest } = props; return ( @@ -35,6 +35,6 @@ const IAIDndImageIcon = (props: Props) => { {...rest} /> ); -}; +}); -export default memo(IAIDndImageIcon); +DndImageIcon.displayName = 'DndImageIcon'; diff --git a/invokeai/frontend/web/src/features/dnd2/test.tsx b/invokeai/frontend/web/src/features/dnd2/test.tsx deleted file mode 100644 index 365be27e8f1..00000000000 --- a/invokeai/frontend/web/src/features/dnd2/test.tsx +++ /dev/null @@ -1,335 +0,0 @@ -/** - * @jsxRuntime classic - * @jsx jsx - */ -import Button from '@atlaskit/button/new'; -import ImageIcon from '@atlaskit/icon/core/migration/image'; -import { easeInOut } from '@atlaskit/motion/curves'; -import { largeDurationMs, mediumDurationMs } from '@atlaskit/motion/durations'; -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter'; -import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/external/file'; -import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; -import { token } from '@atlaskit/tokens'; -// eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766 -import { css } from '@emotion/react'; -import { bind } from 'bind-event-listener'; -import { Fragment, memo, useCallback, useEffect, useRef, useState } from 'react'; -import invariant from 'tiny-invariant'; - -import { GlobalStyles } from './util/global-styles'; - -const galleryStyles = css({ - display: 'flex', - width: '70vw', - alignItems: 'center', - justifyContent: 'center', - gap: 'var(--grid)', - flexWrap: 'wrap', -}); -const imageStyles = css({ - display: 'block', - // borrowing values from pinterest - // ratio: 0.6378378378 - width: '216px', - height: '340px', - objectFit: 'cover', -}); -const uploadStyles = css({ - // overflow: 'hidden', - position: 'relative', - // using these to hide the details - borderRadius: 'calc(var(--grid) * 2)', - overflow: 'hidden', - transition: `opacity ${largeDurationMs}ms ${easeInOut}, filter ${largeDurationMs}ms ${easeInOut}`, -}); -const loadingStyles = css({ - opacity: '0', - filter: 'blur(1.5rem)', -}); -const readyStyles = css({ - opacity: '1', - filter: 'blur(0)', -}); - -const uploadDetailStyles = css({ - display: 'flex', - boxSizing: 'border-box', - width: '100%', - padding: 'var(--grid)', - position: 'absolute', - bottom: 0, - gap: 'var(--grid)', - flexDirection: 'row', - // background: token('color.background.sunken', fallbackColor), - backgroundColor: 'rgba(255,255,255,0.5)', -}); - -const uploadFilenameStyles = css({ - flexGrow: '1', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}); - -type UserUpload = { - type: 'image'; - dataUrl: string; - name: string; - size: number; -}; - -const Upload = memo(function Upload({ upload }: { upload: UserUpload }) { - const [state, setState] = useState<'loading' | 'ready'>('loading'); - const clearTimeout = useRef<() => void>(() => {}); - - useEffect(function mount() { - return function unmount() { - clearTimeout.current(); - }; - }, []); - - return ( -

- { - // this is the _only_ way I could find to get the animation to run - // correctly every time in all browsers - // setTimeout(fn, 0) -> sometimes wouldn't work in chrome (event nesting two) - // requestAnimationFrame -> nope (event nesting two) - // requestIdleCallback -> nope (doesn't work in safari) - // I can find no reliable hook for applying the `ready` state, - // this is the best I could manage 😩 - const timerId = setTimeout(() => setState('ready'), 100); - clearTimeout.current = () => window.clearTimeout(timerId); - }} - /> -
- {upload.name} - {Math.round(upload.size / 1000)}kB -
-
- ); -}); - -const Gallery = memo(function Gallery({ uploads: uploads }: { uploads: UserUpload[] }) { - if (!uploads.length) { - return null; - } - - return ( -
- {uploads.map((upload, index) => ( - - ))} -
- ); -}); - -const fileStyles = css({ - display: 'flex', - flexDirection: 'column', - padding: 'calc(var(--grid) * 6) calc(var(--grid) * 4)', - boxSizing: 'border-box', - alignItems: 'center', - justifyContent: 'center', - background: token('elevation.surface.sunken', '#091E4208'), - borderRadius: 'var(--border-radius)', - transition: `all ${mediumDurationMs}ms ${easeInOut}`, - border: '2px dashed transparent', - width: '100%', - gap: token('space.300', '24px'), -}); - -const textStyles = css({ - color: token('color.text.disabled', '#091E424F'), - fontSize: '1.4rem', - display: 'flex', - alignItems: 'center', - gap: token('space.075'), -}); - -const overStyles = css({ - background: token('color.background.selected.hovered', '#CCE0FF'), - color: token('color.text.selected', '#0C66E4'), - borderColor: token('color.border.brand', '#0C66E4'), -}); - -const potentialStyles = css({ - borderColor: token('color.border.brand', '#0C66E4'), -}); - -const appStyles = css({ - display: 'flex', - alignItems: 'center', - gap: 'calc(var(--grid) * 2)', - flexDirection: 'column', -}); - -const displayNoneStyles = css({ display: 'none' }); - -function Uploader() { - const ref = useRef(null); - const [state, setState] = useState<'idle' | 'potential' | 'over'>('idle'); - const [uploads, setUploads] = useState([]); - - /** - * Creating a stable reference so that we can use it in our unmount effect. - * - * If we used uploads as a dependency in the second `useEffect` it would run - * every time the uploads changed, which is not desirable. - */ - const stableUploadsRef = useRef(uploads); - useEffect(() => { - stableUploadsRef.current = uploads; - }, [uploads]); - - useEffect(() => { - return () => { - /** - * MDN recommends explicitly releasing the object URLs when possible, - * instead of relying just on the browser's garbage collection. - */ - stableUploadsRef.current.forEach((upload) => { - URL.revokeObjectURL(upload.dataUrl); - }); - }; - }, []); - - const addUpload = useCallback((file: File | null) => { - if (!file) { - return; - } - - if (!file.type.startsWith('image/')) { - return; - } - - const upload: UserUpload = { - type: 'image', - dataUrl: URL.createObjectURL(file), - name: file.name, - size: file.size, - }; - setUploads((current) => [...current, upload]); - }, []); - - const onFileInputChange = useCallback( - (event: React.ChangeEvent) => { - const files = Array.from(event.currentTarget.files ?? []); - files.forEach(addUpload); - }, - [addUpload] - ); - - useEffect(() => { - const el = ref.current; - invariant(el); - return combine( - dropTargetForExternal({ - element: el, - canDrop: containsFiles, - onDragEnter: () => setState('over'), - onDragLeave: () => setState('potential'), - onDrop: async ({ source }) => { - const files = await getFiles({ source }); - - files.forEach((file) => { - if (file == null) { - return; - } - if (!file.type.startsWith('image/')) { - return; - } - const reader = new FileReader(); - reader.readAsDataURL(file); - - // for simplicity: - // - not handling errors - // - not aborting the - // - not unbinding the event listener when the effect is removed - bind(reader, { - type: 'load', - listener(event) { - const result = reader.result; - if (typeof result === 'string') { - const upload: UserUpload = { - type: 'image', - dataUrl: result, - name: file.name, - size: file.size, - }; - setUploads((current) => [...current, upload]); - } - }, - }); - }); - }, - }), - monitorForExternal({ - canMonitor: containsFiles, - onDragStart: () => { - setState('potential'); - preventUnhandled.start(); - }, - onDrop: () => { - setState('idle'); - preventUnhandled.stop(); - }, - }) - ); - }); - - /** - * We trigger the file input manually when clicking the button. This also - * works when selecting the button using a keyboard. - * - * We do this for two reasons: - * - * 1. Styling file inputs is very limited. - * 2. Associating the button as a label for the input only gives us pointer - * support, but does not work for keyboard. - */ - const inputRef = useRef(null); - const onInputTriggerClick = useCallback(() => { - inputRef.current?.click(); - }, []); - - return ( -
-
- - Drop some images on me! - - - - - -
- -
- ); -} - -export default function Example() { - return ( - - - - - ); -} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx index 600afbb1cf7..2ad6ed160d8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx @@ -1,7 +1,7 @@ import { useShiftModifier } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; +import { DndImageIcon } from 'features/dnd2/DndImageIcon'; import type { MouseEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -32,7 +32,7 @@ export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => { } return ( - } tooltip={t('gallery.deleteImage_one')} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx index 792248bce18..b996058dc6a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx @@ -1,4 +1,4 @@ -import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { DndImageIcon } from 'features/dnd2/DndImageIcon'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -18,7 +18,7 @@ export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => }, [imageDTO, imageViewer]); return ( - } tooltip={t('gallery.openInViewer')} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx index 7ba787afab1..30d6b39790e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react'; import { $customStarUI } from 'app/store/nanostores/customStarUI'; -import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; +import { DndImageIcon } from 'features/dnd2/DndImageIcon'; import { memo, useCallback } from 'react'; import { PiStarBold, PiStarFill } from 'react-icons/pi'; import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; @@ -25,7 +25,7 @@ export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => { if (customStarUi) { return ( - { } return ( - : } tooltip={imageDTO.starred ? 'Unstar' : 'Star'} 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..b1e4be3377b 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,5 +1,3 @@ -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; import { Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay'; @@ -31,13 +29,6 @@ const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => { dispatch(workflowExposedFieldRemoved({ nodeId, fieldName })); }, [dispatch, fieldName, nodeId]); - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: `${nodeId}.${fieldName}` }); - - const style = { - transform: CSS.Translate.toString(transform), - transition, - }; - return ( { w="full" p={4} paddingLeft={0} - ref={setNodeRef} - style={style} > } - {...listeners} - {...attributes} mx={2} height="full" /> 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 1718cbc05a4..4ff8b3ec149 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,10 +2,10 @@ 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 IAIDndImageIcon from 'common/components/IAIDndImageIcon'; import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import { DndImage } from 'features/dnd2/DndImage'; +import { DndImageIcon } from 'features/dnd2/DndImageIcon'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field'; import { memo, useCallback, useEffect, useMemo } from 'react'; @@ -70,7 +70,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps - : undefined} tooltip="Reset Image" 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..3b9da6115ec 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,15 +1,11 @@ -import { arrayMove } from '@dnd-kit/sortable'; import { Box, Flex } from '@invoke-ai/ui-library'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { 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 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 { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo'; @@ -19,45 +15,21 @@ const WorkflowLinearTab = () => { const fields = useAppSelector(selector); const { isLoading } = useGetOpenAPISchemaQuery(); const { t } = useTranslation(); - const dispatch = useAppDispatch(); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - const fieldsStrings = fields.map((field) => `${field.nodeId}.${field.fieldName}`); - - if (over && active.id !== over.id) { - const oldIndex = fieldsStrings.indexOf(active.id as string); - const newIndex = fieldsStrings.indexOf(over.id as string); - - const newFields = arrayMove(fieldsStrings, oldIndex, newIndex) - .map((field) => fields.find((obj) => `${obj.nodeId}.${obj.fieldName}` === field)) - .filter((field) => field) as FieldIdentifier[]; - - dispatch(workflowExposedFieldsReordered(newFields)); - } - }, - [dispatch, fields] - ); - - const items = useMemo(() => fields.map((field) => `${field.nodeId}.${field.fieldName}`), [fields]); return ( - - - {isLoading ? ( - - ) : fields.length ? ( - fields.map(({ nodeId, fieldName }) => ( - - )) - ) : ( - - )} - - + + {isLoading ? ( + + ) : fields.length ? ( + fields.map(({ nodeId, fieldName }) => ( + + )) + ) : ( + + )} + ); 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 9edcc6756dd..d3c76126d98 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx @@ -1,9 +1,9 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIDndImageIcon from 'common/components/IAIDndImageIcon'; import { Dnd } from 'features/dnd2/dnd'; import { DndDropTarget } from 'features/dnd2/DndDropTarget'; import { DndImage } from 'features/dnd2/DndImage'; +import { DndImageIcon } from 'features/dnd2/DndImageIcon'; import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { t } from 'i18next'; import { useCallback, useMemo } from 'react'; @@ -35,7 +35,7 @@ export const UpscaleInitialImage = () => { <> - } tooltip={t('common.reset')} From a664c5fbcd959256046561760569562a2f30ae3b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:55:52 +1000 Subject: [PATCH 12/39] tidy(ui): move new dnd impl into features/dnd --- .../store/middleware/listenerMiddleware/listeners/dnd.ts | 2 +- .../features/controlLayers/components/CanvasDropArea.tsx | 4 ++-- .../controlLayers/components/CanvasRightPanel.tsx | 4 ++-- .../components/ControlLayer/ControlLayer.tsx | 4 ++-- .../components/IPAdapter/IPAdapterImagePreview.tsx | 8 ++++---- .../components/IPAdapter/IPAdapterSettings.tsx | 2 +- .../controlLayers/components/RasterLayer/RasterLayer.tsx | 4 ++-- .../RegionalGuidanceIPAdapterSettings.tsx | 2 +- .../web/src/features/{dnd2 => dnd}/DndDropOverlay.tsx | 2 +- .../web/src/features/{dnd2 => dnd}/DndDropTarget.tsx | 4 ++-- .../frontend/web/src/features/{dnd2 => dnd}/DndImage.tsx | 2 +- .../web/src/features/{dnd2 => dnd}/DndImageIcon.tsx | 0 invokeai/frontend/web/src/features/{dnd2 => dnd}/dnd.ts | 0 .../gallery/components/Boards/BoardsList/GalleryBoard.tsx | 4 ++-- .../gallery/components/Boards/BoardsList/NoBoardBoard.tsx | 4 ++-- .../gallery/components/ImageGrid/GalleryImage.tsx | 2 +- .../components/ImageGrid/GalleryImageDeleteIconButton.tsx | 2 +- .../ImageGrid/GalleryImageOpenInViewerIconButton.tsx | 2 +- .../components/ImageGrid/GalleryImageStarIconButton.tsx | 2 +- .../components/ImageViewer/CurrentImagePreview.tsx | 2 +- .../components/ImageViewer/ImageComparisonDroppable.tsx | 4 ++-- .../flow/nodes/CurrentImage/CurrentImageNode.tsx | 2 +- .../Invocation/fields/inputs/ImageFieldInputComponent.tsx | 8 ++++---- .../sidePanel/inspector/outputs/ImageOutputPreview.tsx | 2 +- .../UpscaleSettingsAccordion/UpscaleInitialImage.tsx | 8 ++++---- 25 files changed, 40 insertions(+), 40 deletions(-) rename invokeai/frontend/web/src/features/{dnd2 => dnd}/DndDropOverlay.tsx (97%) rename invokeai/frontend/web/src/features/{dnd2 => dnd}/DndDropTarget.tsx (98%) rename invokeai/frontend/web/src/features/{dnd2 => dnd}/DndImage.tsx (97%) rename invokeai/frontend/web/src/features/{dnd2 => dnd}/DndImageIcon.tsx (100%) rename invokeai/frontend/web/src/features/{dnd2 => dnd}/dnd.ts (100%) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts index 2bc76b1d1b9..7438cca229d 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts @@ -24,7 +24,7 @@ import type { CanvasRegionalGuidanceState, } from 'features/controlLayers/store/types'; import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import { Dnd } from 'features/dnd2/dnd'; +import { Dnd } from 'features/dnd/dnd'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index 69a0847ec93..ff5f7a00cfe 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -1,6 +1,6 @@ import { Grid, GridItem } from '@invoke-ai/ui-library'; -import { Dnd } from 'features/dnd2/dnd'; -import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { Dnd } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index 78a30b38c1d..a8f0861f0e8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -6,8 +6,8 @@ import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHook import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; -import type { Dnd } from 'features/dnd2/dnd'; -import { DndDropOverlay } from 'features/dnd2/DndDropOverlay'; +import type { Dnd } from 'features/dnd/dnd'; +import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index 8df32b0b6bf..0d08296b1ab 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -10,8 +10,8 @@ import { ControlLayerSettings } from 'features/controlLayers/components/ControlL import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import { Dnd } from 'features/dnd2/dnd'; -import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { Dnd } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx index b19696bbd73..2d8546e57fe 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx @@ -3,10 +3,10 @@ import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import type { ImageWithDims } from 'features/controlLayers/store/types'; -import type { Dnd } from 'features/dnd2/dnd'; -import { DndDropTarget } from 'features/dnd2/DndDropTarget'; -import { DndImage } from 'features/dnd2/DndImage'; -import { DndImageIcon } from 'features/dnd2/DndImageIcon'; +import type { Dnd } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { DndImage } from 'features/dnd/DndImage'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx index 88f11f1feec..0d3133ba9ba 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -19,7 +19,7 @@ import { import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import { Dnd } from 'features/dnd2/dnd'; +import { Dnd } from 'features/dnd/dnd'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold } from 'react-icons/pi'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index f70aca7cf13..68dbb96e900 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -7,8 +7,8 @@ import { CanvasEntityEditableTitle } from 'features/controlLayers/components/com import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import { Dnd } from 'features/dnd2/dnd'; -import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { Dnd } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index 1cd73cb4bdb..b0feed574e2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -20,7 +20,7 @@ import { } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import { Dnd } from 'features/dnd2/dnd'; +import { Dnd } from 'features/dnd/dnd'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi'; diff --git a/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx b/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx similarity index 97% rename from invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx rename to invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx index 511f7a09809..36cc11f1609 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndDropOverlay.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx @@ -1,5 +1,5 @@ import { Flex, Text } from '@invoke-ai/ui-library'; -import type { Dnd } from 'features/dnd2/dnd'; +import type { Dnd } from 'features/dnd/dnd'; import { memo } from 'react'; type Props = { diff --git a/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx similarity index 98% rename from invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx rename to invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx index 0f4cb77291f..618de47ac31 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndDropTarget.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx @@ -7,8 +7,8 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box } from '@invoke-ai/ui-library'; import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/dnd'; import { useAppDispatch } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd2/dnd'; -import { DndDropOverlay } from 'features/dnd2/DndDropOverlay'; +import { Dnd } from 'features/dnd/dnd'; +import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; import { memo, useEffect, useRef, useState } from 'react'; import { uploadImage } from 'services/api/endpoints/images'; import { z } from 'zod'; diff --git a/invokeai/frontend/web/src/features/dnd2/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx similarity index 97% rename from invokeai/frontend/web/src/features/dnd2/DndImage.tsx rename to invokeai/frontend/web/src/features/dnd/DndImage.tsx index 378a9a236d0..d3d0704ad84 100644 --- a/invokeai/frontend/web/src/features/dnd2/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -2,7 +2,7 @@ import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Image } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; -import { Dnd } from 'features/dnd2/dnd'; +import { Dnd } from 'features/dnd/dnd'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { memo, useEffect, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; diff --git a/invokeai/frontend/web/src/features/dnd2/DndImageIcon.tsx b/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx similarity index 100% rename from invokeai/frontend/web/src/features/dnd2/DndImageIcon.tsx rename to invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx diff --git a/invokeai/frontend/web/src/features/dnd2/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts similarity index 100% rename from invokeai/frontend/web/src/features/dnd2/dnd.ts rename to invokeai/frontend/web/src/features/dnd/dnd.ts diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index b686cecd5e8..099d3bf49f8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -2,8 +2,8 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd2/dnd'; -import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { Dnd } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; import { BoardEditableTitle } from 'features/gallery/components/Boards/BoardsList/BoardEditableTitle'; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index b8515c40a18..7d372338a58 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -1,8 +1,8 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd2/dnd'; -import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { Dnd } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardBoardContextMenu'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index c9cd501edd2..4c21667f4d2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -7,7 +7,7 @@ import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/lis import { useAppStore } from 'app/store/nanostores/store'; import { useAppSelector } from 'app/store/storeHooks'; import { useBoolean } from 'common/hooks/useBoolean'; -import { Dnd } from 'features/dnd2/dnd'; +import { Dnd } from 'features/dnd/dnd'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx index 2ad6ed160d8..93953381a9f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx @@ -1,7 +1,7 @@ import { useShiftModifier } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; -import { DndImageIcon } from 'features/dnd2/DndImageIcon'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; import type { MouseEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx index b996058dc6a..55c4a68e823 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx @@ -1,4 +1,4 @@ -import { DndImageIcon } from 'features/dnd2/DndImageIcon'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx index 30d6b39790e..60eb4971068 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react'; import { $customStarUI } from 'app/store/nanostores/customStarUI'; -import { DndImageIcon } from 'features/dnd2/DndImageIcon'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; import { memo, useCallback } from 'react'; import { PiStarBold, PiStarFill } from 'react-icons/pi'; import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; 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 ed954cc7127..9d6ac1efdf2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsSendingToCanvas } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo'; -import { DndImage } from 'features/dnd2/DndImage'; +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'; 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 13bf6eddad8..8a3de6f0093 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx @@ -1,6 +1,6 @@ import { useAppSelector } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd2/dnd'; -import { DndDropTarget } from 'features/dnd2/DndDropTarget'; +import { Dnd } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; 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 e0c947d6940..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 @@ -2,7 +2,7 @@ import { Flex, Image, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; -import { DndImage } from 'features/dnd2/DndImage'; +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'; 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 4ff8b3ec149..314145147e5 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,10 +2,10 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd2/dnd'; -import { DndDropTarget } from 'features/dnd2/DndDropTarget'; -import { DndImage } from 'features/dnd2/DndImage'; -import { DndImageIcon } from 'features/dnd2/DndImageIcon'; +import { Dnd } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { DndImage } from 'features/dnd/DndImage'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field'; import { memo, useCallback, useEffect, useMemo } from 'react'; 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 7cb3f8b1a82..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 { DndImage } from 'features/dnd2/DndImage'; +import { DndImage } from 'features/dnd/DndImage'; import { memo } from 'react'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { ImageOutput } from 'services/api/types'; 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 d3c76126d98..7bf325d9f79 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx @@ -1,9 +1,9 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd2/dnd'; -import { DndDropTarget } from 'features/dnd2/DndDropTarget'; -import { DndImage } from 'features/dnd2/DndImage'; -import { DndImageIcon } from 'features/dnd2/DndImageIcon'; +import { Dnd } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { DndImage } from 'features/dnd/DndImage'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { t } from 'i18next'; import { useCallback, useMemo } from 'react'; From b8f3388229e2c9fc642adb108ceb66ae489adc4e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 28 Oct 2024 16:19:41 +1000 Subject: [PATCH 13/39] feat(ui): multi-image drag preview --- .../components/ImageGrid/GalleryImage.tsx | 129 +++++++++++++----- 1 file changed, 92 insertions(+), 37 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 4c21667f4d2..25e0f65332c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,7 +1,9 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Box, Flex, Image } from '@invoke-ai/ui-library'; +import { Box, Flex, Heading, Image } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; import { useAppStore } from 'app/store/nanostores/store'; @@ -16,6 +18,8 @@ import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageVi import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { useTranslation } from 'react-i18next'; import type { ImageDTO } from 'services/api/types'; // This class name is used to calculate the number of images that fit in the gallery @@ -79,9 +83,16 @@ interface Props { imageDTO: ImageDTO; } +type MultiImageDragPreviewState = { + container: HTMLElement; + imageDTOs: ImageDTO[]; + domRect: DOMRect; +}; + export const GalleryImage = memo(({ imageDTO }: Props) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); + const [dragPreviewState, setDragPreviewState] = useState(null); const [element, ref] = useState(null); const dndId = useId(); const selectIsSelectedForCompare = useMemo( @@ -115,13 +126,10 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { // When we have multiple images selected, and the dragged image is part of the selection, initiate a // multi-image drag. if (gallery.selection.length > 1 && gallery.selection.includes(imageDTO)) { - return Dnd.Source.multipleImage.getData( - { - imageDTOs: gallery.selection, - boardId: gallery.selectedBoardId, - }, - 'gallery-selection' - ); + return Dnd.Source.multipleImage.getData({ + imageDTOs: gallery.selection, + boardId: gallery.selectedBoardId, + }); } // Otherwise, initiate a single-image drag @@ -136,11 +144,32 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { return; } }, + // See: https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/drag-previews + onGenerateDragPreview: ({ nativeSetDragImage, source, location }) => { + if (Dnd.Source.multipleImage.typeGuard(source.data)) { + const { imageDTOs } = source.data.payload; + const domRect = source.element.getBoundingClientRect(); + setCustomNativeDragPreview({ + render({ container }) { + // Cause a `react` re-render to create your portal synchronously + setDragPreviewState({ container, imageDTOs, domRect }); + // In our cleanup function: cause a `react` re-render to create remove your portal + // Note: you can also remove the portal in `onDragStart`, + // which is when the cleanup function is called + return () => setDragPreviewState(null); + }, + nativeSetDragImage, + getOffset: preserveOffsetOnSource({ + element: source.element, + input: location.current.input, + }), + }); + } + }, }), monitorForElements({ // This is a "global" drag start event, meaning that it is called for all drag events. onDragStart: ({ source }) => { - console.log(source); // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the // selection. This is called for all drag events. if (Dnd.Source.multipleImage.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) { @@ -184,36 +213,62 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { useImageContextMenu(imageDTO, element); return ( - - + - } - w={imageDTO.width} - objectFit="contain" - maxW="full" - maxH="full" - borderRadius="base" - /> - - - + + } + w={imageDTO.width} + objectFit="contain" + maxW="full" + maxH="full" + borderRadius="base" + /> + + + + {dragPreviewState !== null && + ReactDOM.createPortal( + , + dragPreviewState.container + )} + ); }); GalleryImage.displayName = 'GalleryImage'; + +const MultiImagePreview = memo(({ imageDTOs, domRect }: { imageDTOs: ImageDTO[]; domRect: DOMRect }) => { + const { t } = useTranslation(); + return ( + + {imageDTOs.length} + {t('parameters.images')} + + ); +}); + +MultiImagePreview.displayName = 'MultiImagePreview'; From 8853c00265f9874aca55aadb84fdfeaff942509f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 28 Oct 2024 16:34:03 +1000 Subject: [PATCH 14/39] tidy(ui): more efficient dnd overlay styling --- .../web/src/features/dnd/DndDropOverlay.tsx | 103 ++++++++++-------- 1 file changed, 59 insertions(+), 44 deletions(-) diff --git a/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx b/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx index 36cc11f1609..1c87d7b15ef 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx @@ -1,3 +1,4 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex, Text } from '@invoke-ai/ui-library'; import type { Dnd } from 'features/dnd/dnd'; import { memo } from 'react'; @@ -8,6 +9,60 @@ type Props = { withBackdrop?: boolean; }; +const sx = { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + color: 'base.300', + borderColor: 'base.300', + transitionProperty: 'common', + transitionDuration: '0.1s', + '.dnd-drop-overlay-backdrop': { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + bg: 'base.900', + opacity: 0.7, + borderRadius: 'base', + alignItems: 'center', + justifyContent: 'center', + transitionProperty: 'inherit', + transitionDuration: 'inherit', + }, + '.dnd-drop-overlay-content': { + position: 'absolute', + top: 0.5, + right: 0.5, + bottom: 0.5, + left: 0.5, + opacity: 1, + borderWidth: 1.5, + borderRadius: 'base', + borderStyle: 'dashed', + alignItems: 'center', + justifyContent: 'center', + borderColor: 'inherit', + transitionProperty: 'inherit', + transitionDuration: 'inherit', + }, + '.dnd-drop-overlay-label': { + fontSize: 'lg', + fontWeight: 'semibold', + textAlign: 'center', + color: 'inherit', + transitionProperty: 'inherit', + transitionDuration: 'inherit', + }, + '&[data-dnd-state="over"]': { + color: 'invokeYellow.300', + borderColor: 'invokeYellow.300', + }, +} satisfies SystemStyleObject; + export const DndDropOverlay = memo((props: Props) => { const { dndState, label, withBackdrop = true } = props; @@ -16,50 +71,10 @@ export const DndDropOverlay = memo((props: Props) => { } return ( - - - - - {label && ( - - {label} - - )} + + {withBackdrop && } + + {label && {label}} ); From 294499e08e37dd3ca6380f59938864df49cf3d7b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 30 Oct 2024 16:56:14 +1000 Subject: [PATCH 15/39] feat(ui): use custom drag previews for images --- .../dnd/DndDragPreviewMultipleImage.tsx | 64 +++++++++++++++ .../dnd/DndDragPreviewSingleImage.tsx | 62 +++++++++++++++ .../web/src/features/dnd/DndImage.tsx | 35 ++++++--- invokeai/frontend/web/src/features/dnd/dnd.ts | 35 +++++++++ .../components/ImageGrid/GalleryImage.tsx | 77 ++++++------------- 5 files changed, 208 insertions(+), 65 deletions(-) create mode 100644 invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx create mode 100644 invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx new file mode 100644 index 00000000000..6c14ea4539e --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx @@ -0,0 +1,64 @@ +import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import { Flex, Heading } from '@invoke-ai/ui-library'; +import type { Dnd } from 'features/dnd/dnd'; +import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/dnd'; +import { memo } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import type { ImageDTO } from 'services/api/types'; +import type { Param0 } from 'tsafe'; + +const DndDragPreviewMultipleImage = memo(({ imageDTOs }: { imageDTOs: ImageDTO[] }) => { + const { t } = useTranslation(); + return ( + + {imageDTOs.length} + {t('parameters.images')} + + ); +}); + +DndDragPreviewMultipleImage.displayName = 'DndDragPreviewMultipleImage'; + +export type DndDragPreviewMultipleImageState = { + type: 'multiple-image'; + container: HTMLElement; + imageDTOs: ImageDTO[]; +}; + +export const createMultipleImageDragPreview = (arg: DndDragPreviewMultipleImageState) => + createPortal(, arg.container); + +type SetMultipleDragPreviewArg = { + multipleImageDndData: Dnd.types['SourceDataTypeMap']['multipleImage']; + setDragPreviewState: (dragPreviewState: DndDragPreviewMultipleImageState | null) => void; + onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>; +}; + +export const setMultipleImageDragPreview = ({ + multipleImageDndData, + onGenerateDragPreviewArgs, + setDragPreviewState, +}: SetMultipleDragPreviewArg) => { + const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs; + setCustomNativeDragPreview({ + render({ container }) { + setDragPreviewState({ type: 'multiple-image', container, imageDTOs: multipleImageDndData.payload.imageDTOs }); + return () => setDragPreviewState(null); + }, + nativeSetDragImage, + getOffset: preserveOffsetOnSourceFallbackCentered({ + element: source.element, + input: location.current.input, + }), + }); +}; diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx new file mode 100644 index 00000000000..9777c9d4de4 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx @@ -0,0 +1,62 @@ +import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import { chakra, Flex } from '@invoke-ai/ui-library'; +import type { Dnd } from 'features/dnd/dnd'; +import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/dnd'; +import { memo } from 'react'; +import { createPortal } from 'react-dom'; +import type { ImageDTO } from 'services/api/types'; +import type { Param0 } from 'tsafe'; + +const ChakraImg = chakra('img'); + +const DndDragPreviewSingleImage = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { + return ( + + + + ); +}); + +DndDragPreviewSingleImage.displayName = 'DndDragPreviewSingleImage'; + +export type DndDragPreviewSingleImageState = { + type: 'single-image'; + container: HTMLElement; + imageDTO: ImageDTO; +}; + +export const createSingleImageDragPreview = (arg: DndDragPreviewSingleImageState) => + createPortal(, arg.container); + +type SetSingleDragPreviewArg = { + singleImageDndData: Dnd.types['SourceDataTypeMap']['singleImage']; + setDragPreviewState: (dragPreviewState: DndDragPreviewSingleImageState | null) => void; + onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>; +}; + +export const setSingleImageDragPreview = ({ + singleImageDndData, + onGenerateDragPreviewArgs, + setDragPreviewState, +}: SetSingleDragPreviewArg) => { + const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs; + setCustomNativeDragPreview({ + render({ container }) { + setDragPreviewState({ type: 'single-image', container, imageDTO: singleImageDndData.payload.imageDTO }); + return () => setDragPreviewState(null); + }, + nativeSetDragImage, + getOffset: preserveOffsetOnSourceFallbackCentered({ + element: source.element, + input: location.current.input, + }), + }); +}; diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index d3d0704ad84..0438736f681 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -3,6 +3,8 @@ import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Image } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; import { Dnd } from 'features/dnd/dnd'; +import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; +import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { memo, useEffect, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -26,6 +28,7 @@ export const DndImage = memo(({ imageDTO, ...rest }: Props) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); const [element, ref] = useState(null); + const [dragPreviewState, setDragPreviewState] = useState(null); useEffect(() => { if (!element) { @@ -40,22 +43,34 @@ export const DndImage = memo(({ imageDTO, ...rest }: Props) => { onDrop: () => { setIsDragging(false); }, + onGenerateDragPreview: (args) => { + if (Dnd.Source.singleImage.typeGuard(args.source.data)) { + setSingleImageDragPreview({ + singleImageDndData: args.source.data, + onGenerateDragPreviewArgs: args, + setDragPreviewState, + }); + } + }, }); }, [imageDTO, element, store]); useImageContextMenu(imageDTO, element); return ( - + <> + + {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null} + ); }); diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index 234e6b5fb58..fbea84b9781 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/no-namespace */ // We will use namespaces to organize the Dnd types +import type { Input } from '@atlaskit/pragmatic-drag-and-drop/dist/types/entry-point/types'; +import type { GetOffsetFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/public-utils/element/custom-native-drag-preview/types'; +import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import type { BoardId } from 'features/gallery/store/types'; @@ -477,3 +480,35 @@ export const Dnd = { }, }, }; + +/** + * The size of the image drag preview in theme units. + */ +export const DND_IMAGE_DRAG_PREVIEW_SIZE = 32 satisfies SystemStyleObject['w']; + +/** + * A drag preview offset function that works like the provided `preserveOffsetOnSource`, except when either the X or Y + * offset is outside the container, in which case it centers the preview in the container. + */ +export function preserveOffsetOnSourceFallbackCentered({ + element, + input, +}: { + element: HTMLElement; + input: Input; +}): GetOffsetFn { + return ({ container }) => { + const sourceRect = element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + let offsetX = input.clientX - sourceRect.x; + let offsetY = input.clientY - sourceRect.y; + + if (offsetY > containerRect.height || offsetX > containerRect.width) { + offsetX = containerRect.width / 2; + offsetY = containerRect.height / 2; + } + + return { x: offsetX, y: offsetY }; + }; +} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 25e0f65332c..053c01b36bf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,15 +1,17 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source'; -import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Box, Flex, Heading, Image } from '@invoke-ai/ui-library'; +import { Box, Flex, Image } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; import { useAppStore } from 'app/store/nanostores/store'; import { useAppSelector } from 'app/store/storeHooks'; import { useBoolean } from 'common/hooks/useBoolean'; import { Dnd } from 'features/dnd/dnd'; +import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage'; +import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage'; +import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; +import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId'; @@ -18,8 +20,6 @@ import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageVi import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'; -import ReactDOM from 'react-dom'; -import { useTranslation } from 'react-i18next'; import type { ImageDTO } from 'services/api/types'; // This class name is used to calculate the number of images that fit in the gallery @@ -83,16 +83,12 @@ interface Props { imageDTO: ImageDTO; } -type MultiImageDragPreviewState = { - container: HTMLElement; - imageDTOs: ImageDTO[]; - domRect: DOMRect; -}; - export const GalleryImage = memo(({ imageDTO }: Props) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); - const [dragPreviewState, setDragPreviewState] = useState(null); + const [dragPreviewState, setDragPreviewState] = useState< + DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null + >(null); const [element, ref] = useState(null); const dndId = useId(); const selectIsSelectedForCompare = useMemo( @@ -144,25 +140,18 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { return; } }, - // See: https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/drag-previews - onGenerateDragPreview: ({ nativeSetDragImage, source, location }) => { - if (Dnd.Source.multipleImage.typeGuard(source.data)) { - const { imageDTOs } = source.data.payload; - const domRect = source.element.getBoundingClientRect(); - setCustomNativeDragPreview({ - render({ container }) { - // Cause a `react` re-render to create your portal synchronously - setDragPreviewState({ container, imageDTOs, domRect }); - // In our cleanup function: cause a `react` re-render to create remove your portal - // Note: you can also remove the portal in `onDragStart`, - // which is when the cleanup function is called - return () => setDragPreviewState(null); - }, - nativeSetDragImage, - getOffset: preserveOffsetOnSource({ - element: source.element, - input: location.current.input, - }), + onGenerateDragPreview: (args) => { + if (Dnd.Source.multipleImage.typeGuard(args.source.data)) { + setMultipleImageDragPreview({ + multipleImageDndData: args.source.data, + onGenerateDragPreviewArgs: args, + setDragPreviewState, + }); + } else if (Dnd.Source.singleImage.typeGuard(args.source.data)) { + setSingleImageDragPreview({ + singleImageDndData: args.source.data, + onGenerateDragPreviewArgs: args, + setDragPreviewState, }); } }, @@ -243,32 +232,10 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { - {dragPreviewState !== null && - ReactDOM.createPortal( - , - dragPreviewState.container - )} + {dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null} + {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null} ); }); GalleryImage.displayName = 'GalleryImage'; - -const MultiImagePreview = memo(({ imageDTOs, domRect }: { imageDTOs: ImageDTO[]; domRect: DOMRect }) => { - const { t } = useTranslation(); - return ( - - {imageDTOs.length} - {t('parameters.images')} - - ); -}); - -MultiImagePreview.displayName = 'MultiImagePreview'; From b9d202eba86dfb7cf42e39bba8347f04969f9812 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:14:09 +1000 Subject: [PATCH 16/39] feat(ui): dnd layer reordering (wip) --- invokeai/frontend/web/package.json | 1 + invokeai/frontend/web/pnpm-lock.yaml | 10 ++ .../components/CanvasRightPanel.tsx | 17 +- .../ControlLayer/ControlLayerEntityList.tsx | 18 +- .../InpaintMask/InpaintMaskList.tsx | 18 +- .../RasterLayer/RasterLayerEntityList.tsx | 18 +- .../RegionalGuidanceEntityList.tsx | 18 +- .../common/CanvasEntityContainer.tsx | 117 +++++++++++- .../common/CanvasEntityGroupList.tsx | 83 ++++++++- .../controlLayers/store/canvasSlice.ts | 57 ++++++ .../src/features/controlLayers/store/types.ts | 2 + .../web/src/features/dnd/DndDropIndicator.tsx | 166 ++++++++++++++++++ invokeai/frontend/web/src/features/dnd/dnd.ts | 5 + 13 files changed, 482 insertions(+), 48 deletions(-) create mode 100644 invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 98665a63411..b8cd3e02000 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -54,6 +54,7 @@ "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", "@fontsource-variable/inter": "^5.1.0", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 832a3e8ab44..17ead7a8e59 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@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 @@ -323,6 +326,13 @@ packages: '@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: diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index a8f0861f0e8..9ce939d0114 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -6,7 +6,7 @@ import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHook import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; -import type { Dnd } from 'features/dnd/dnd'; +import { Dnd } from 'features/dnd/dnd'; import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; @@ -195,11 +195,6 @@ const PanelTabs = memo(() => { } }; - const canMonitor = () => { - // Only monitor if we are not already on the gallery tab - return selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery'; - }; - const onDragStart = () => { // Set the state to pending when a drag starts setGalleryTabDndState('potential'); @@ -212,7 +207,13 @@ const PanelTabs = memo(() => { onDragLeave, }), monitorForElements({ - canMonitor, + canMonitor: ({ source }) => { + if (!Dnd.Source.singleImage.typeGuard(source.data) || !Dnd.Source.multipleImage.typeGuard(source.data)) { + return false; + } + // Only monitor if we are not already on the gallery tab + return selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery'; + }, onDragStart, }), dropTargetForExternal({ @@ -221,7 +222,7 @@ const PanelTabs = memo(() => { onDragLeave, }), monitorForExternal({ - canMonitor, + canMonitor: () => selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery', onDragStart, }) ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx index 9421804090e..86737367b00 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx @@ -3,12 +3,12 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; import { ControlLayer } from 'features/controlLayers/components/ControlLayer/ControlLayer'; -import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { memo } from 'react'; -const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => { - return canvas.controlLayers.entities.map(mapId).reverse(); +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.controlLayers.entities.map(getEntityIdentifier).toReversed(); }); const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { @@ -17,17 +17,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte export const ControlLayerEntityList = memo(() => { const isSelected = useAppSelector(selectIsSelected); - const layerIds = useAppSelector(selectEntityIds); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); - if (layerIds.length === 0) { + if (entityIdentifiers.length === 0) { return null; } - if (layerIds.length > 0) { + if (entityIdentifiers.length > 0) { return ( - - {layerIds.map((id) => ( - + + {entityIdentifiers.map((entityIdentifier) => ( + ))} ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx index 6b04ff511a9..f6da8aba41e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx @@ -3,12 +3,12 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask'; -import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { memo } from 'react'; -const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => { - return canvas.inpaintMasks.entities.map(mapId).reverse(); +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.inpaintMasks.entities.map(getEntityIdentifier).toReversed(); }); const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { @@ -17,17 +17,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte export const InpaintMaskList = memo(() => { const isSelected = useAppSelector(selectIsSelected); - const entityIds = useAppSelector(selectEntityIds); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); - if (entityIds.length === 0) { + if (entityIdentifiers.length === 0) { return null; } - if (entityIds.length > 0) { + if (entityIdentifiers.length > 0) { return ( - - {entityIds.map((id) => ( - + + {entityIdentifiers.map((entityIdentifier) => ( + ))} ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx index 4e2cbd581c7..86c35f7d7d4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx @@ -3,12 +3,12 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; import { RasterLayer } from 'features/controlLayers/components/RasterLayer/RasterLayer'; -import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { memo } from 'react'; -const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => { - return canvas.rasterLayers.entities.map(mapId).reverse(); +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.rasterLayers.entities.map(getEntityIdentifier).toReversed(); }); const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { return selectedEntityIdentifier?.type === 'raster_layer'; @@ -16,17 +16,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte export const RasterLayerEntityList = memo(() => { const isSelected = useAppSelector(selectIsSelected); - const layerIds = useAppSelector(selectEntityIds); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); - if (layerIds.length === 0) { + if (entityIdentifiers.length === 0) { return null; } - if (layerIds.length > 0) { + if (entityIdentifiers.length > 0) { return ( - - {layerIds.map((id) => ( - + + {entityIdentifiers.map((entityIdentifier) => ( + ))} ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx index 870fec1d69f..9a13315e0e0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx @@ -3,12 +3,12 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; import { RegionalGuidance } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidance'; -import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { memo } from 'react'; -const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => { - return canvas.regionalGuidance.entities.map(mapId).reverse(); +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.regionalGuidance.entities.map(getEntityIdentifier).toReversed(); }); const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { return selectedEntityIdentifier?.type === 'regional_guidance'; @@ -16,17 +16,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte export const RegionalGuidanceEntityList = memo(() => { const isSelected = useAppSelector(selectIsSelected); - const rgIds = useAppSelector(selectEntityIds); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); - if (rgIds.length === 0) { + if (entityIdentifiers.length === 0) { return null; } - if (rgIds.length > 0) { + if (entityIdentifiers.length > 0) { return ( - - {rgIds.map((id) => ( - + + {entityIdentifiers.map((entityIdentifier) => ( + ))} ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx index 4430fc83dc7..b4237301822 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx @@ -1,11 +1,37 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +// import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview'; +// import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { Flex } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected'; import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor'; import { entitySelected } from 'features/controlLayers/store/canvasSlice'; +import { Dnd } from 'features/dnd/dnd'; +import DropIndicator from 'features/dnd/DndDropIndicator'; import type { PropsWithChildren } from 'react'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; + +type DndState = + | { + type: 'idle'; + } + | { + type: 'preview'; + container: HTMLElement; + } + | { + type: 'is-dragging'; + } + | { + type: 'is-dragging-over'; + closestEdge: Edge | null; + }; + +const idle: DndState = { type: 'idle' }; export const CanvasEntityContainer = memo((props: PropsWithChildren) => { const dispatch = useAppDispatch(); @@ -18,9 +44,95 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => { } dispatch(entitySelected({ entityIdentifier })); }, [dispatch, entityIdentifier, isSelected]); + const ref = useRef(null); + const [dndState, setDndState] = useState(idle); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + return combine( + draggable({ + element, + getInitialData() { + return Dnd.Source.singleCanvasEntity.getData({ entityIdentifier }); + }, + // onGenerateDragPreview({ nativeSetDragImage }) { + // setCustomNativeDragPreview({ + // nativeSetDragImage, + // getOffset: pointerOutsideOfPreview({ + // x: '16px', + // y: '8px', + // }), + // render({ container }) { + // setState({ type: 'preview', container }); + // }, + // }); + // }, + onDragStart() { + setDndState({ type: 'is-dragging' }); + }, + onDrop() { + setDndState(idle); + }, + }), + dropTargetForElements({ + element, + canDrop({ source }) { + // not allowing dropping on yourself + if (source.element === element) { + return false; + } + // only allowing tasks to be dropped on me + if (!Dnd.Source.singleCanvasEntity.typeGuard(source.data)) { + return false; + } + if (source.data.payload.entityIdentifier.type !== entityIdentifier.type) { + return false; + } + return true; + }, + getData({ input }) { + const data = Dnd.Source.singleCanvasEntity.getData({ entityIdentifier }); + return attachClosestEdge(data, { + element, + input, + allowedEdges: ['top', 'bottom'], + }); + }, + getIsSticky() { + return true; + }, + onDragEnter({ self }) { + const closestEdge = extractClosestEdge(self.data); + setDndState({ type: 'is-dragging-over', closestEdge }); + }, + onDrag({ self }) { + const closestEdge = extractClosestEdge(self.data); + + // Only need to update react state if nothing has changed. + // Prevents re-rendering. + setDndState((current) => { + if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { + return current; + } + return { type: 'is-dragging-over', closestEdge }; + }); + }, + onDragLeave() { + setDndState(idle); + }, + onDrop() { + setDndState(idle); + }, + }) + ); + }, [entityIdentifier]); return ( { borderRadius="base" > {props.children} + {dndState.type === 'is-dragging-over' && dndState.closestEdge ? ( + + ) : null} ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx index b9d1999a8fc..609b6426c10 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx @@ -1,5 +1,9 @@ +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import { useBoolean } from 'common/hooks/useBoolean'; import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles'; @@ -8,24 +12,97 @@ import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/component import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle'; import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover'; import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle'; -import { type CanvasEntityIdentifier, isRenderableEntityType } from 'features/controlLayers/store/types'; +import { entitiesReordered } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { isRenderableEntityType } from 'features/controlLayers/store/types'; +import { Dnd } from 'features/dnd/dnd'; import type { PropsWithChildren } from 'react'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; +import { flushSync } from 'react-dom'; import { PiCaretDownBold } from 'react-icons/pi'; type Props = PropsWithChildren<{ isSelected: boolean; type: CanvasEntityIdentifier['type']; + entityIdentifiers: CanvasEntityIdentifier[]; }>; const _hover: SystemStyleObject = { opacity: 1, }; -export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props) => { +export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityIdentifiers }: Props) => { const title = useEntityTypeTitle(type); const informationalPopoverFeature = useEntityTypeInformationalPopover(type); const collapse = useBoolean(true); + const dispatch = useAppDispatch(); + + useEffect(() => { + return monitorForElements({ + canMonitor({ source }) { + if (!Dnd.Source.singleCanvasEntity.typeGuard(source.data)) { + return false; + } + if (source.data.payload.entityIdentifier.type !== type) { + return false; + } + return true; + }, + onDrop({ location, source }) { + const target = location.current.dropTargets[0]; + if (!target) { + return; + } + + const sourceData = source.data; + const targetData = target.data; + + if ( + !Dnd.Source.singleCanvasEntity.typeGuard(sourceData) || + !Dnd.Source.singleCanvasEntity.typeGuard(targetData) + ) { + return; + } + + const indexOfSource = entityIdentifiers.findIndex( + (entityIdentifier) => entityIdentifier.id === sourceData.payload.entityIdentifier.id + ); + const indexOfTarget = entityIdentifiers.findIndex( + (entityIdentifier) => entityIdentifier.id === targetData.payload.entityIdentifier.id + ); + + if (indexOfTarget < 0 || indexOfSource < 0) { + return; + } + + const closestEdgeOfTarget = extractClosestEdge(targetData); + + // Using `flushSync` so we can query the DOM straight after this line + flushSync(() => { + dispatch( + entitiesReordered({ + type, + entityIdentifiers: reorderWithEdge({ + list: entityIdentifiers, + startIndex: indexOfSource, + indexOfTarget, + closestEdgeOfTarget, + axis: 'vertical', + }), + }) + ); + }); + // // Being simple and just querying for the task after the drop. + // // We could use react context to register the element in a lookup, + // // and then we could retrieve that element after the drop and use + // // `triggerPostMoveFlash`. But this gets the job done. + // const element = document.querySelector(`[data-task-id="${sourceData.taskId}"]`); + // if (element instanceof HTMLElement) { + // triggerPostMoveFlash(element); + // } + }, + }); + }, [dispatch, entityIdentifiers, type]); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 07bb5635f47..9187785e7ae 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -14,6 +14,8 @@ import { selectRegionalGuidanceReferenceImage, } from 'features/controlLayers/store/selectors'; import type { + CanvasEntityStateFromType, + CanvasEntityType, CanvasInpaintMaskState, CanvasMetadata, FillStyle, @@ -1345,6 +1347,46 @@ export const canvasSlice = createSlice({ } moveToStart(selectAllEntitiesOfType(state, entity.type), entity); }, + entitiesReordered: ( + state: CanvasState, + action: PayloadAction<{ type: T; entityIdentifiers: CanvasEntityIdentifier[] }> + ) => { + const { type, entityIdentifiers } = action.payload; + + switch (type) { + case 'raster_layer': { + state.rasterLayers.entities = reorderEntities( + state.rasterLayers.entities, + entityIdentifiers as CanvasEntityIdentifier<'raster_layer'>[] + ); + break; + } + case 'control_layer': + state.controlLayers.entities = reorderEntities( + state.controlLayers.entities, + entityIdentifiers as CanvasEntityIdentifier<'control_layer'>[] + ); + break; + case 'inpaint_mask': + state.inpaintMasks.entities = reorderEntities( + state.inpaintMasks.entities, + entityIdentifiers as CanvasEntityIdentifier<'inpaint_mask'>[] + ); + break; + case 'regional_guidance': + state.regionalGuidance.entities = reorderEntities( + state.regionalGuidance.entities, + entityIdentifiers as CanvasEntityIdentifier<'regional_guidance'>[] + ); + break; + case 'reference_image': + state.referenceImages.entities = reorderEntities( + state.referenceImages.entities, + entityIdentifiers as CanvasEntityIdentifier<'reference_image'>[] + ); + break; + } + }, entityOpacityChanged: (state, action: PayloadAction>) => { const { entityIdentifier, opacity } = action.payload; const entity = selectEntity(state, entityIdentifier); @@ -1471,6 +1513,7 @@ export const { entityArrangedBackwardOne, entityArrangedToBack, entityOpacityChanged, + entitiesReordered, // allEntitiesDeleted, // currently unused allEntitiesOfTypeIsHiddenToggled, // bbox @@ -1604,3 +1647,17 @@ function actionsThrottlingFilter(action: UnknownAction) { }, THROTTLE_MS); return true; } + +const reorderEntities = ( + entities: CanvasEntityStateFromType[], + sortedEntityIdentifiers: CanvasEntityIdentifier[] +) => { + const sortedEntities: CanvasEntityStateFromType[] = []; + for (const { id } of sortedEntityIdentifiers.toReversed()) { + const entity = entities.find((entity) => entity.id === id); + if (entity) { + sortedEntities.push(entity); + } + } + return sortedEntities; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index efc05d62ae7..9bba7fc12f0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -466,6 +466,8 @@ export type EntityRasterizedPayload = EntityIdentifierPayload<{ export type GenerationMode = 'txt2img' | 'img2img' | 'inpaint' | 'outpaint'; +export type CanvasEntityStateFromType = Extract; + export function isRenderableEntityType( entityType: CanvasEntityState['type'] ): entityType is CanvasRenderableEntityState['type'] { diff --git a/invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx b/invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx new file mode 100644 index 00000000000..9b674bac119 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx @@ -0,0 +1,166 @@ +/** + * Spacing tokens don't make a lot of sense for this specific use case, + * so disabling the linting rule. + */ + +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types'; +import { Box, type SystemStyleObject } from '@invoke-ai/ui-library'; +import type { CSSProperties } from 'react'; + +/** + * Design decisions for the drop indicator's main line + */ +export const line = { + borderRadius: 0, + thickness: 2, + backgroundColor: 'base.300', +}; +export type DropIndicatorProps = { + /** + * The `edge` to draw a drop indicator on. + * + * `edge` is required as for the best possible performance + * outcome you should only render this component when it needs to do something + * + * @example {closestEdge && } + */ + edge: Edge; + /** + * `gap` allows you to position the drop indicator further away from the drop target. + * `gap` should be the distance between your drop targets + * a drop indicator will be rendered halfway between the drop targets + * (the drop indicator will be offset by half of the `gap`) + * + * `gap` should be a valid CSS length. + * @example "8px" + * @example "var(--gap)" + */ + gap?: string; +}; +const terminalSize = 8; + +const lineStyles: SystemStyleObject = { + display: 'block', + position: 'absolute', + zIndex: 1, + // Blocking pointer events to prevent the line from triggering drag events + // Dragging over the line should count as dragging over the element behind it + pointerEvents: 'none', + background: line.backgroundColor, + + // Terminal + '::before': { + content: '""', + width: terminalSize, + height: terminalSize, + boxSizing: 'border-box', + position: 'absolute', + border: `${line.thickness}px solid ${line.backgroundColor}`, + borderRadius: '50%', + }, +}; + +/** + * By default, the edge of the terminal will be aligned to the edge of the line. + * + * Offsetting the terminal by half its size aligns the middle of the terminal + * with the edge of the line. + * + * We must offset by half the line width in the opposite direction so that the + * middle of the terminal aligns with the middle of the line. + * + * That is, + * + * offset = - (terminalSize / 2) + (line.thickness / 2) + * + * which simplifies to the following value. + */ +const offsetToAlignTerminalWithLine = (line.thickness - terminalSize) / 2; + +/** + * We inset the line by half the terminal size, + * so that the terminal only half sticks out past the item. + */ +const lineOffset = terminalSize / 2; + +type Orientation = 'horizontal' | 'vertical'; + +const orientationStyles: Record = { + horizontal: { + height: line.thickness, + left: lineOffset, + right: 0, + '::before': { + // Horizontal indicators have the terminal on the left + left: -terminalSize, + }, + }, + vertical: { + width: line.thickness, + top: lineOffset, + bottom: 0, + '::before': { + // Vertical indicators have the terminal at the top + top: -terminalSize, + }, + }, +}; + +const edgeToOrientationMap: Record = { + top: 'horizontal', + bottom: 'horizontal', + left: 'vertical', + right: 'vertical', +}; + +const edgeStyles: Record = { + top: { + top: 'var(--local-line-offset)', + '::before': { + top: offsetToAlignTerminalWithLine, + }, + }, + right: { + right: 'var(--local-line-offset)', + '::before': { + right: offsetToAlignTerminalWithLine, + }, + }, + bottom: { + bottom: 'var(--local-line-offset)', + '::before': { + bottom: offsetToAlignTerminalWithLine, + }, + }, + left: { + left: 'var(--local-line-offset)', + '::before': { + left: offsetToAlignTerminalWithLine, + }, + }, +}; + +/** + * __Drop indicator__ + * + * A drop indicator is used to communicate the intended resting place of the draggable item. The orientation of the drop indicator should always match the direction of the content flow. + */ +export function DropIndicator({ edge, gap = '0px' }: DropIndicatorProps) { + /** + * To clearly communicate the resting place of a draggable item during a drag operation, + * the drop indicator should be positioned half way between draggable items. + */ + const lineOffset = `calc(-0.5 * (${gap} + ${line.thickness}px))`; + + const orientation = edgeToOrientationMap[edge]; + + return ( + + ); +} + +// This default export is intended for usage with React.lazy +export default DropIndicator; diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index fbea84b9781..e085f108d7a 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -166,10 +166,15 @@ const singleImage = buildDndSourceApi<{ imageDTO: ImageDTO }>('SingleImage'); * Dnd source API for multiple image source. */ const multipleImage = buildDndSourceApi<{ imageDTOs: ImageDTO[]; boardId: BoardId }>('MultipleImage'); +/** + * Dnd source API for a single canvas entity. + */ +const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity'); const DndSource = { singleImage, multipleImage, + singleCanvasEntity, } as const; type SourceDataTypeMap = { From 1f2063a918496542b49d0caf8c515c49a024f63f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 30 Oct 2024 21:39:08 +1000 Subject: [PATCH 17/39] feat(ui): layer reordering styling --- .../common/CanvasEntityContainer.tsx | 44 +++++----- .../common/CanvasEntityGroupList.tsx | 38 ++++++-- .../src/features/controlLayers/store/types.ts | 4 + .../web/src/features/dnd/DndDropIndicator.tsx | 86 +++---------------- invokeai/frontend/web/src/features/dnd/dnd.ts | 11 +++ 5 files changed, 80 insertions(+), 103 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx index b4237301822..c9781db8660 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx @@ -4,14 +4,14 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-d // import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; -import { Flex } from '@invoke-ai/ui-library'; +import { Box, Flex } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected'; import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor'; import { entitySelected } from 'features/controlLayers/store/canvasSlice'; import { Dnd } from 'features/dnd/dnd'; -import DropIndicator from 'features/dnd/DndDropIndicator'; +import { DndDropIndicator } from 'features/dnd/DndDropIndicator'; import type { PropsWithChildren } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; @@ -80,11 +80,6 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => { dropTargetForElements({ element, canDrop({ source }) { - // not allowing dropping on yourself - if (source.element === element) { - return false; - } - // only allowing tasks to be dropped on me if (!Dnd.Source.singleCanvasEntity.typeGuard(source.data)) { return false; } @@ -131,22 +126,29 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => { }, [entityIdentifier]); return ( - - {props.children} + + + {props.children} + {dndState.type === 'is-dragging-over' && dndState.closestEdge ? ( - + ) : null} - + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx index 609b6426c10..c9c0b01f281 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx @@ -6,6 +6,7 @@ import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-librar import { useAppDispatch } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import { useBoolean } from 'common/hooks/useBoolean'; +import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar'; import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles'; import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton'; import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton'; @@ -15,7 +16,7 @@ import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTi import { entitiesReordered } from 'features/controlLayers/store/canvasSlice'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { isRenderableEntityType } from 'features/controlLayers/store/types'; -import { Dnd } from 'features/dnd/dnd'; +import { Dnd, triggerPostMoveFlash } from 'features/dnd/dnd'; import type { PropsWithChildren } from 'react'; import { memo, useEffect } from 'react'; import { flushSync } from 'react-dom'; @@ -75,8 +76,29 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI return; } + // Don't move if the source and target are the same index, meaning same position in the list + if (indexOfSource === indexOfTarget) { + return; + } + const closestEdgeOfTarget = extractClosestEdge(targetData); + // It's possible that the indices are different, but refer to the same position. For example, if the source is + // at 2 and the target is at 3, but the target edge is 'top', then the entity is already in the correct position. + // We should bail if this is the case. + let edgeIndexDelta = 0; + + if (closestEdgeOfTarget === 'bottom') { + edgeIndexDelta = 1; + } else if (closestEdgeOfTarget === 'top') { + edgeIndexDelta = -1; + } + + // If the source is already in the correct position, we don't need to move it. + if (indexOfSource === indexOfTarget + edgeIndexDelta) { + return; + } + // Using `flushSync` so we can query the DOM straight after this line flushSync(() => { dispatch( @@ -92,14 +114,12 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI }) ); }); - // // Being simple and just querying for the task after the drop. - // // We could use react context to register the element in a lookup, - // // and then we could retrieve that element after the drop and use - // // `triggerPostMoveFlash`. But this gets the job done. - // const element = document.querySelector(`[data-task-id="${sourceData.taskId}"]`); - // if (element instanceof HTMLElement) { - // triggerPostMoveFlash(element); - // } + + // Flash the element that was moved + const element = document.querySelector(`[data-entity-id="${sourceData.payload.entityIdentifier.id}"]`); + if (element instanceof HTMLElement) { + triggerPostMoveFlash(element, colorTokenToCssVar('base.700')); + } }, }); }, [dispatch, entityIdentifiers, type]); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 9bba7fc12f0..7a21b918820 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -552,6 +552,10 @@ export const getEntityIdentifier = ( return { id: entity.id, type: entity.type }; }; +export const entityIdentifierToString = (entityIdentifer: CanvasEntityIdentifier): string => { + return `${entityIdentifer.type}-${entityIdentifer.id}`; +}; + export const isMaskEntityIdentifier = ( entityIdentifier: CanvasEntityIdentifier ): entityIdentifier is CanvasEntityIdentifier<'inpaint_mask' | 'regional_guidance'> => { diff --git a/invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx b/invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx index 9b674bac119..4a67ce2f5e2 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx @@ -1,20 +1,17 @@ -/** - * Spacing tokens don't make a lot of sense for this specific use case, - * so disabling the linting rule. - */ - +// Adapted from https://github.com/alexreardon/pdnd-react-tailwind/blob/main/src/drop-indicator.tsx import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types'; -import { Box, type SystemStyleObject } from '@invoke-ai/ui-library'; +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box } from '@invoke-ai/ui-library'; import type { CSSProperties } from 'react'; /** * Design decisions for the drop indicator's main line */ export const line = { - borderRadius: 0, thickness: 2, - backgroundColor: 'base.300', + backgroundColor: 'base.500', }; + export type DropIndicatorProps = { /** * The `edge` to draw a drop indicator on. @@ -37,72 +34,30 @@ export type DropIndicatorProps = { */ gap?: string; }; -const terminalSize = 8; const lineStyles: SystemStyleObject = { display: 'block', position: 'absolute', zIndex: 1, + borderRadius: 'full', // Blocking pointer events to prevent the line from triggering drag events // Dragging over the line should count as dragging over the element behind it pointerEvents: 'none', background: line.backgroundColor, - - // Terminal - '::before': { - content: '""', - width: terminalSize, - height: terminalSize, - boxSizing: 'border-box', - position: 'absolute', - border: `${line.thickness}px solid ${line.backgroundColor}`, - borderRadius: '50%', - }, }; -/** - * By default, the edge of the terminal will be aligned to the edge of the line. - * - * Offsetting the terminal by half its size aligns the middle of the terminal - * with the edge of the line. - * - * We must offset by half the line width in the opposite direction so that the - * middle of the terminal aligns with the middle of the line. - * - * That is, - * - * offset = - (terminalSize / 2) + (line.thickness / 2) - * - * which simplifies to the following value. - */ -const offsetToAlignTerminalWithLine = (line.thickness - terminalSize) / 2; - -/** - * We inset the line by half the terminal size, - * so that the terminal only half sticks out past the item. - */ -const lineOffset = terminalSize / 2; - type Orientation = 'horizontal' | 'vertical'; const orientationStyles: Record = { horizontal: { - height: line.thickness, - left: lineOffset, - right: 0, - '::before': { - // Horizontal indicators have the terminal on the left - left: -terminalSize, - }, + height: `${line.thickness}px`, + left: 2, + right: 2, }, vertical: { - width: line.thickness, - top: lineOffset, - bottom: 0, - '::before': { - // Vertical indicators have the terminal at the top - top: -terminalSize, - }, + width: `${line.thickness}px`, + top: 2, + bottom: 2, }, }; @@ -116,27 +71,15 @@ const edgeToOrientationMap: Record = { const edgeStyles: Record = { top: { top: 'var(--local-line-offset)', - '::before': { - top: offsetToAlignTerminalWithLine, - }, }, right: { right: 'var(--local-line-offset)', - '::before': { - right: offsetToAlignTerminalWithLine, - }, }, bottom: { bottom: 'var(--local-line-offset)', - '::before': { - bottom: offsetToAlignTerminalWithLine, - }, }, left: { left: 'var(--local-line-offset)', - '::before': { - left: offsetToAlignTerminalWithLine, - }, }, }; @@ -145,7 +88,7 @@ const edgeStyles: Record = { * * A drop indicator is used to communicate the intended resting place of the draggable item. The orientation of the drop indicator should always match the direction of the content flow. */ -export function DropIndicator({ edge, gap = '0px' }: DropIndicatorProps) { +export function DndDropIndicator({ edge, gap = '0px' }: DropIndicatorProps) { /** * To clearly communicate the resting place of a draggable item during a drag operation, * the drop indicator should be positioned half way between draggable items. @@ -161,6 +104,3 @@ export function DropIndicator({ edge, gap = '0px' }: DropIndicatorProps) { /> ); } - -// This default export is intended for usage with React.lazy -export default DropIndicator; diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index e085f108d7a..b142fd3bffa 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -6,6 +6,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import type { BoardId } from 'features/gallery/store/types'; +import type { CSSProperties } from 'react'; import type { ImageDTO } from 'services/api/types'; import type { ValueOf } from 'type-fest'; import type { Jsonifiable } from 'type-fest/source/jsonifiable'; @@ -517,3 +518,13 @@ export function preserveOffsetOnSourceFallbackCentered({ return { x: offsetX, y: offsetY }; }; } + +// Based on https://github.com/atlassian/pragmatic-drag-and-drop/blob/main/packages/flourish/src/trigger-post-move-flash.tsx +// That package has a lot of extra deps so we just copied the function here +export function triggerPostMoveFlash(element: HTMLElement, backgroundColor: CSSProperties['backgroundColor']) { + element.animate([{ backgroundColor }, {}], { + duration: 700, + easing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)', + iterations: 1, + }); +} From 5a34d4663fa285035129456c53cd6f05e3d08f62 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 30 Oct 2024 22:04:20 +1000 Subject: [PATCH 18/39] feat(ui): restore dnd to workflow fields --- .../common/CanvasEntityContainer.tsx | 37 +--- invokeai/frontend/web/src/features/dnd/dnd.ts | 24 +++ .../Invocation/fields/LinearViewField.tsx | 179 +++++++++++++----- .../sidePanel/workflow/WorkflowLinearTab.tsx | 139 ++++++++++++-- 4 files changed, 277 insertions(+), 102 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx index c9781db8660..6998c1d82e2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx @@ -1,8 +1,5 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -// import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview'; -// import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; -import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { Box, Flex } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; @@ -10,29 +7,12 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected'; import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor'; import { entitySelected } from 'features/controlLayers/store/canvasSlice'; -import { Dnd } from 'features/dnd/dnd'; +import type { DndState } from 'features/dnd/dnd'; +import { Dnd, idle } from 'features/dnd/dnd'; import { DndDropIndicator } from 'features/dnd/DndDropIndicator'; import type { PropsWithChildren } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; -type DndState = - | { - type: 'idle'; - } - | { - type: 'preview'; - container: HTMLElement; - } - | { - type: 'is-dragging'; - } - | { - type: 'is-dragging-over'; - closestEdge: Edge | null; - }; - -const idle: DndState = { type: 'idle' }; - export const CanvasEntityContainer = memo((props: PropsWithChildren) => { const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext(); @@ -58,18 +38,6 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => { getInitialData() { return Dnd.Source.singleCanvasEntity.getData({ entityIdentifier }); }, - // onGenerateDragPreview({ nativeSetDragImage }) { - // setCustomNativeDragPreview({ - // nativeSetDragImage, - // getOffset: pointerOutsideOfPreview({ - // x: '16px', - // y: '8px', - // }), - // render({ container }) { - // setState({ type: 'preview', container }); - // }, - // }); - // }, onDragStart() { setDndState({ type: 'is-dragging' }); }, @@ -128,6 +96,7 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => { return ( ('SingleCanvasEntity'); +/** + * Dnd source API for a single workflow field. + */ +const singleWorkflowField = buildDndSourceApi<{ fieldIdentifier: FieldIdentifier }>('SingleWorkflowField'); const DndSource = { singleImage, multipleImage, singleCanvasEntity, + singleWorkflowField, } as const; type SourceDataTypeMap = { @@ -528,3 +535,20 @@ export function triggerPostMoveFlash(element: HTMLElement, backgroundColor: CSSP iterations: 1, }); } + +export type DndState = + | { + type: 'idle'; + } + | { + type: 'preview'; + container: HTMLElement; + } + | { + type: 'is-dragging'; + } + | { + type: 'is-dragging-over'; + closestEdge: Edge | null; + }; +export const idle: DndState = { type: 'idle' }; 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 b1e4be3377b..26a12ed88d4 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,14 +1,19 @@ -import { Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { Box, Circle, Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay'; +import type { DndState } from 'features/dnd/dnd'; +import { Dnd, idle } from 'features/dnd/dnd'; +import { DndDropIndicator } from 'features/dnd/DndDropIndicator'; import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck'; 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 { memo, useCallback, useEffect, useRef, useState } 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'; @@ -29,61 +34,131 @@ const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => { dispatch(workflowExposedFieldRemoved({ nodeId, fieldName })); }, [dispatch, fieldName, nodeId]); + const ref = useRef(null); + const [dndState, setDndState] = useState(idle); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + return combine( + draggable({ + element, + getInitialData() { + return Dnd.Source.singleWorkflowField.getData({ fieldIdentifier: { nodeId, fieldName } }); + }, + onDragStart() { + setDndState({ type: 'is-dragging' }); + }, + onDrop() { + setDndState(idle); + }, + }), + dropTargetForElements({ + element, + canDrop({ source }) { + if (!Dnd.Source.singleWorkflowField.typeGuard(source.data)) { + return false; + } + return true; + }, + getData({ input }) { + const data = Dnd.Source.singleWorkflowField.getData({ fieldIdentifier: { nodeId, fieldName } }); + return attachClosestEdge(data, { + element, + input, + allowedEdges: ['top', 'bottom'], + }); + }, + getIsSticky() { + return true; + }, + onDragEnter({ self }) { + const closestEdge = extractClosestEdge(self.data); + setDndState({ type: 'is-dragging-over', closestEdge }); + }, + onDrag({ self }) { + const closestEdge = extractClosestEdge(self.data); + + // Only need to update react state if nothing has changed. + // Prevents re-rendering. + setDndState((current) => { + if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { + return current; + } + return { type: 'is-dragging-over', closestEdge }; + }); + }, + onDragLeave() { + setDndState(idle); + }, + onDrop() { + setDndState(idle); + }, + }) + ); + }, [fieldName, nodeId]); + return ( - - } - mx={2} - height="full" - /> - - - - - {isValueChanged && ( + + + + + + + {isMouseOverNode && } + {isValueChanged && ( + } + /> + )} + } + openDelay={HANDLE_TOOLTIP_OPEN_DELAY} + placement="top" + > + + + + } + onClick={handleRemoveField} + icon={} /> - )} - } - openDelay={HANDLE_TOOLTIP_OPEN_DELAY} - placement="top" - > - - - - - } - /> + + - - - + {dndState.type === 'is-dragging-over' && dndState.closestEdge ? ( + + ) : null} + ); }; 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 3b9da6115ec..654e4bfed48 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,34 +1,29 @@ +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 { useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar'; +import { Dnd, triggerPostMoveFlash } from 'features/dnd/dnd'; import LinearViewFieldInternal from 'features/nodes/components/flow/nodes/Invocation/fields/LinearViewField'; -import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; -import { memo } from 'react'; +import { selectWorkflowSlice, workflowExposedFieldsReordered } from 'features/nodes/store/workflowSlice'; +import type { FieldIdentifier } from 'features/nodes/types/field'; +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 = () => { - const fields = useAppSelector(selector); - const { isLoading } = useGetOpenAPISchemaQuery(); - const { t } = useTranslation(); - return ( - {isLoading ? ( - - ) : fields.length ? ( - fields.map(({ nodeId, fieldName }) => ( - - )) - ) : ( - - )} + @@ -36,3 +31,115 @@ const WorkflowLinearTab = () => { }; 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(); + + useEffect(() => { + return monitorForElements({ + canMonitor({ source }) { + if (!Dnd.Source.singleWorkflowField.typeGuard(source.data)) { + return false; + } + return true; + }, + onDrop({ location, source }) { + const target = location.current.dropTargets[0]; + if (!target) { + return; + } + + const sourceData = source.data; + const targetData = target.data; + + if ( + !Dnd.Source.singleWorkflowField.typeGuard(sourceData) || + !Dnd.Source.singleWorkflowField.typeGuard(targetData) + ) { + return; + } + + const indexOfSource = fields.findIndex( + (fieldIdentifier) => fieldIdentifier.fieldName === sourceData.payload.fieldIdentifier.fieldName + ); + const indexOfTarget = fields.findIndex( + (fieldIdentifier) => fieldIdentifier.fieldName === targetData.payload.fieldIdentifier.fieldName + ); + + 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: fields, + 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.fieldName}"]`); + if (element instanceof HTMLElement) { + triggerPostMoveFlash(element, colorTokenToCssVar('base.700')); + } + }, + }); + }, [dispatch, fields]); + + return ( + <> + {fields.map(({ nodeId, fieldName }) => ( + + ))} + + ); +}); + +FieldListInnerContent.displayName = 'FieldListInnerContent'; From ce30f3634ce7426f88b480f7d946b52cab23ba94 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 30 Oct 2024 22:13:59 +1000 Subject: [PATCH 19/39] fix(ui): min height for workflow image field drop target --- .../nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx | 1 + 1 file changed, 1 insertion(+) 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 314145147e5..6051c1a5030 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 @@ -59,6 +59,7 @@ const ImageFieldInputComponent = (props: FieldComponentProps Date: Thu, 31 Oct 2024 06:56:10 +1000 Subject: [PATCH 20/39] tidy(ui): dnd stuff --- .../frontend/web/src/app/logging/logger.ts | 1 + .../middleware/listenerMiddleware/index.ts | 4 - .../listenerMiddleware/listeners/dnd.ts | 284 -------------- .../CanvasEntityContainer.tsx | 49 +++ .../CanvasEntityGroupList.tsx | 15 +- .../useCanvasEntityListDnd.ts | 85 ++++ .../components/ControlLayer/ControlLayer.tsx | 2 +- .../ControlLayer/ControlLayerEntityList.tsx | 2 +- .../components/IPAdapter/IPAdapter.tsx | 2 +- .../components/IPAdapter/IPAdapterList.tsx | 2 +- .../components/InpaintMask/InpaintMask.tsx | 2 +- .../InpaintMask/InpaintMaskList.tsx | 2 +- .../components/RasterLayer/RasterLayer.tsx | 2 +- .../RasterLayer/RasterLayerEntityList.tsx | 2 +- .../RegionalGuidance/RegionalGuidance.tsx | 2 +- .../RegionalGuidanceEntityList.tsx | 2 +- .../common/CanvasEntityContainer.tsx | 124 ------ .../controlLayers/store/canvasSlice.ts | 6 +- .../src/features/controlLayers/store/types.ts | 1 + .../web/src/features/dnd/DndDropTarget.tsx | 22 +- ...Indicator.tsx => DndListDropIndicator.tsx} | 21 +- invokeai/frontend/web/src/features/dnd/dnd.ts | 371 +++++++++++++++--- .../web/src/features/dnd/useDndMonitor.ts | 69 ++++ .../Invocation/fields/LinearViewField.tsx | 117 ++---- .../sidePanel/workflow/WorkflowLinearTab.tsx | 17 +- .../workflow/useLinearViewFieldDnd.ts | 82 ++++ .../src/features/ui/components/AppContent.tsx | 2 + 27 files changed, 676 insertions(+), 614 deletions(-) delete mode 100644 invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx rename invokeai/frontend/web/src/features/controlLayers/components/{common => CanvasEntityList}/CanvasEntityGroupList.tsx (94%) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts delete mode 100644 invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx rename invokeai/frontend/web/src/features/dnd/{DndDropIndicator.tsx => DndListDropIndicator.tsx} (83%) create mode 100644 invokeai/frontend/web/src/features/dnd/useDndMonitor.ts create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts 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/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index d40818e09e9..554a274cf95 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -9,7 +9,6 @@ import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddlewar import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted'; import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected'; import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload'; -import { addDndDroppedListener } from 'app/store/middleware/listenerMiddleware/listeners/dnd'; import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear'; import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes'; import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; @@ -93,9 +92,6 @@ addGetOpenAPISchemaListener(startAppListening); addWorkflowLoadRequestedListener(startAppListening); addUpdateAllNodesRequestedListener(startAppListening); -// DND -addDndDroppedListener(startAppListening); - // Models addModelSelectedListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts deleted file mode 100644 index 7438cca229d..00000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/dnd.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { createAction } from '@reduxjs/toolkit'; -import { logger } from 'app/logging/logger'; -import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; -import { deepClone } from 'common/util/deepClone'; -import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks'; -import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { - controlLayerAdded, - entityRasterized, - entitySelected, - inpaintMaskAdded, - rasterLayerAdded, - referenceImageAdded, - referenceImageIPAdapterImageChanged, - rgAdded, - rgIPAdapterImageChanged, -} from 'features/controlLayers/store/canvasSlice'; -import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { - CanvasControlLayerState, - CanvasInpaintMaskState, - CanvasRasterLayerState, - CanvasReferenceImageState, - CanvasRegionalGuidanceState, -} from 'features/controlLayers/store/types'; -import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import { Dnd } from 'features/dnd/dnd'; -import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; -import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; -import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; -import { imagesApi } from 'services/api/endpoints/images'; - -const log = logger('system'); - -export const dndDropped = createAction<{ - sourceData: Dnd.types['SourceDataUnion']; - targetData: Dnd.types['TargetDataUnion']; -}>('dnd/dndDropped2'); - -export const addDndDroppedListener = (startAppListening: AppStartListening) => { - startAppListening({ - actionCreator: dndDropped, - effect: (action, { dispatch, getState }) => { - const { sourceData, targetData } = action.payload; - - // Single image dropped - if (Dnd.Source.singleImage.typeGuard(sourceData)) { - log.debug({ sourceData, targetData }, 'Image dropped'); - const { imageDTO } = sourceData.payload; - - // Image dropped on IP Adapter - if ( - Dnd.Target.setGlobalReferenceImage.typeGuard(targetData) && - Dnd.Target.setGlobalReferenceImage.validateDrop(sourceData, targetData) - ) { - const { globalReferenceImageId } = targetData.payload; - dispatch( - referenceImageIPAdapterImageChanged({ - entityIdentifier: { id: globalReferenceImageId, type: 'reference_image' }, - imageDTO, - }) - ); - return; - } - - //Image dropped on Regional Guidance IP Adapter - if ( - Dnd.Target.setRegionalGuidanceReferenceImage.typeGuard(targetData) && - Dnd.Target.setRegionalGuidanceReferenceImage.validateDrop(sourceData, targetData) - ) { - const { regionalGuidanceId, referenceImageId } = targetData.payload; - dispatch( - rgIPAdapterImageChanged({ - entityIdentifier: { id: regionalGuidanceId, type: 'regional_guidance' }, - referenceImageId, - imageDTO, - }) - ); - return; - } - - // Add raster layer from image - if ( - Dnd.Target.newRasterLayerFromImage.typeGuard(targetData) && - Dnd.Target.newRasterLayerFromImage.validateDrop(sourceData, targetData) - ) { - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectCanvasSlice(getState()).bbox.rect; - const overrides: Partial = { - objects: [imageObject], - position: { x, y }, - }; - dispatch(rasterLayerAdded({ overrides, isSelected: true })); - return; - } - - // Add inpaint mask from image - if ( - Dnd.Target.newInpaintMaskFromImage.typeGuard(targetData) && - Dnd.Target.newInpaintMaskFromImage.validateDrop(sourceData, targetData) - ) { - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectCanvasSlice(getState()).bbox.rect; - const overrides: Partial = { - objects: [imageObject], - position: { x, y }, - }; - dispatch(inpaintMaskAdded({ overrides, isSelected: true })); - return; - } - - // Add regional guidance from image - if ( - Dnd.Target.newRegionalGuidanceFromImage.typeGuard(targetData) && - Dnd.Target.newRegionalGuidanceFromImage.validateDrop(sourceData, targetData) - ) { - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectCanvasSlice(getState()).bbox.rect; - const overrides: Partial = { - objects: [imageObject], - position: { x, y }, - }; - dispatch(rgAdded({ overrides, isSelected: true })); - return; - } - - // Add control layer from image - if ( - Dnd.Target.newControlLayerFromImage.typeGuard(targetData) && - Dnd.Target.newControlLayerFromImage.validateDrop(sourceData, targetData) - ) { - const state = getState(); - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectCanvasSlice(state).bbox.rect; - const defaultControlAdapter = selectDefaultControlAdapter(state); - const overrides: Partial = { - objects: [imageObject], - position: { x, y }, - controlAdapter: defaultControlAdapter, - }; - dispatch(controlLayerAdded({ overrides, isSelected: true })); - return; - } - - // Add regional guidance layer w/ reference image from image - if ( - Dnd.Target.newRegionalGuidanceReferenceImageFromImage.typeGuard(targetData) && - Dnd.Target.newRegionalGuidanceReferenceImageFromImage.validateDrop(sourceData, targetData) - ) { - const state = getState(); - const ipAdapter = deepClone(selectDefaultIPAdapter(state)); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); - const overrides: Partial = { - referenceImages: [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }], - }; - dispatch(rgAdded({ overrides, isSelected: true })); - return; - } - - // Add global reference image from image - if ( - Dnd.Target.newGlobalReferenceImageFromImage.typeGuard(targetData) && - Dnd.Target.newGlobalReferenceImageFromImage.validateDrop(sourceData, targetData) - ) { - const state = getState(); - const ipAdapter = deepClone(selectDefaultIPAdapter(state)); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); - const overrides: Partial = { ipAdapter }; - dispatch(referenceImageAdded({ overrides, isSelected: true })); - return; - } - - // Replace layer with image - if ( - Dnd.Target.replaceLayerWithImage.typeGuard(targetData) && - Dnd.Target.replaceLayerWithImage.validateDrop(sourceData, targetData) - ) { - const state = getState(); - const { entityIdentifier } = targetData.payload; - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectCanvasSlice(state).bbox.rect; - dispatch(entityRasterized({ entityIdentifier, imageObject, position: { x, y }, replaceObjects: true })); - dispatch(entitySelected({ entityIdentifier })); - return; - } - - // Image dropped on node image field - if ( - Dnd.Target.setNodeImageField.typeGuard(targetData) && - Dnd.Target.setNodeImageField.validateDrop(sourceData, targetData) - ) { - const { fieldName, nodeId } = targetData.payload; - dispatch( - fieldImageValueChanged({ - nodeId, - fieldName, - value: imageDTO, - }) - ); - return; - } - - // Image selected for compare - if ( - Dnd.Target.selectForCompare.typeGuard(targetData) && - Dnd.Target.selectForCompare.validateDrop(sourceData, targetData) - ) { - dispatch(imageToCompareChanged(imageDTO)); - return; - } - - // Image added to board - if (Dnd.Target.addToBoard.typeGuard(targetData) && Dnd.Target.addToBoard.validateDrop(sourceData, targetData)) { - const { boardId } = targetData.payload; - dispatch( - imagesApi.endpoints.addImageToBoard.initiate({ - imageDTO, - board_id: boardId, - }) - ); - dispatch(selectionChanged([])); - return; - } - - // Image removed from board - if ( - Dnd.Target.removeFromBoard.typeGuard(targetData) && - Dnd.Target.removeFromBoard.validateDrop(sourceData, targetData) - ) { - dispatch( - imagesApi.endpoints.removeImageFromBoard.initiate({ - imageDTO, - }) - ); - dispatch(selectionChanged([])); - return; - } - - // Image dropped on upscale initial image - if ( - Dnd.Target.setUpscaleInitialImageFromImage.typeGuard(targetData) && - Dnd.Target.setUpscaleInitialImageFromImage.validateDrop(sourceData, targetData) - ) { - dispatch(upscaleInitialImageChanged(imageDTO)); - return; - } - } - - if (Dnd.Source.multipleImage.typeGuard(sourceData)) { - log.debug({ sourceData, targetData }, 'Multiple images dropped'); - const { imageDTOs } = sourceData.payload; - - // Multiple images dropped on user board - if (Dnd.Target.addToBoard.typeGuard(targetData) && Dnd.Target.addToBoard.validateDrop(sourceData, targetData)) { - const { boardId } = targetData.payload; - dispatch( - imagesApi.endpoints.addImagesToBoard.initiate({ - imageDTOs, - board_id: boardId, - }) - ); - dispatch(selectionChanged([])); - return; - } - - // Multiple images dropped on Uncategorized board (e.g. removed from board) - if ( - Dnd.Target.removeFromBoard.typeGuard(targetData) && - Dnd.Target.removeFromBoard.validateDrop(sourceData, targetData) - ) { - dispatch( - imagesApi.endpoints.removeImagesFromBoard.initiate({ - imageDTOs, - }) - ); - dispatch(selectionChanged([])); - return; - } - } - - log.error({ sourceData, targetData }, 'Invalid dnd drop'); - }, - }); -}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx new file mode 100644 index 00000000000..18429ad75cf --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx @@ -0,0 +1,49 @@ +import { Box, Flex } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useCanvasEntityListDnd } from 'features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected'; +import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor'; +import { entitySelected } from 'features/controlLayers/store/canvasSlice'; +import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator'; +import type { PropsWithChildren } from 'react'; +import { memo, useCallback, useRef } from 'react'; + +export const CanvasEntityContainer = memo((props: PropsWithChildren) => { + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext(); + const isSelected = useEntityIsSelected(entityIdentifier); + const selectionColor = useEntitySelectionColor(entityIdentifier); + const onClick = useCallback(() => { + if (isSelected) { + return; + } + dispatch(entitySelected({ entityIdentifier })); + }, [dispatch, entityIdentifier, isSelected]); + const ref = useRef(null); + + const dndState = useCanvasEntityListDnd(ref, entityIdentifier); + + return ( + + + {props.children} + + + + ); +}); + +CanvasEntityContainer.displayName = 'CanvasEntityContainer'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx similarity index 94% rename from invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx index c9c0b01f281..a94bc6ff3c1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityGroupList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx @@ -1,13 +1,13 @@ import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge'; -import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import { useBoolean } from 'common/hooks/useBoolean'; import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar'; import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles'; +import { singleCanvasEntity } from 'features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd'; import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton'; import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton'; import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle'; @@ -16,7 +16,7 @@ import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTi import { entitiesReordered } from 'features/controlLayers/store/canvasSlice'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { isRenderableEntityType } from 'features/controlLayers/store/types'; -import { Dnd, triggerPostMoveFlash } from 'features/dnd/dnd'; +import { triggerPostMoveFlash } from 'features/dnd/dnd'; import type { PropsWithChildren } from 'react'; import { memo, useEffect } from 'react'; import { flushSync } from 'react-dom'; @@ -28,10 +28,6 @@ type Props = PropsWithChildren<{ entityIdentifiers: CanvasEntityIdentifier[]; }>; -const _hover: SystemStyleObject = { - opacity: 1, -}; - export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityIdentifiers }: Props) => { const title = useEntityTypeTitle(type); const informationalPopoverFeature = useEntityTypeInformationalPopover(type); @@ -41,7 +37,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI useEffect(() => { return monitorForElements({ canMonitor({ source }) { - if (!Dnd.Source.singleCanvasEntity.typeGuard(source.data)) { + if (!singleCanvasEntity.typeGuard(source.data)) { return false; } if (source.data.payload.entityIdentifier.type !== type) { @@ -58,10 +54,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI const sourceData = source.data; const targetData = target.data; - if ( - !Dnd.Source.singleCanvasEntity.typeGuard(sourceData) || - !Dnd.Source.singleCanvasEntity.typeGuard(targetData) - ) { + if (!singleCanvasEntity.typeGuard(sourceData) || !singleCanvasEntity.typeGuard(targetData)) { return; } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts new file mode 100644 index 00000000000..d9d34d7066c --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts @@ -0,0 +1,85 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import type { DndListState } from 'features/dnd/dnd'; +import { buildDndSourceApi, idle } from 'features/dnd/dnd'; +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; + +/** + * Dnd source API for a single canvas entity. + */ +export const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity'); + +export const useCanvasEntityListDnd = (ref: RefObject, entityIdentifier: CanvasEntityIdentifier) => { + const [dndState, setDndState] = useState(idle); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + return combine( + draggable({ + element, + getInitialData() { + return singleCanvasEntity.getData({ entityIdentifier }); + }, + onDragStart() { + setDndState({ type: 'is-dragging' }); + }, + onDrop() { + setDndState(idle); + }, + }), + dropTargetForElements({ + element, + canDrop({ source }) { + if (!singleCanvasEntity.typeGuard(source.data)) { + return false; + } + if (source.data.payload.entityIdentifier.type !== entityIdentifier.type) { + return false; + } + return true; + }, + getData({ input }) { + const data = singleCanvasEntity.getData({ entityIdentifier }); + return attachClosestEdge(data, { + element, + input, + allowedEdges: ['top', 'bottom'], + }); + }, + getIsSticky() { + return true; + }, + onDragEnter({ self }) { + const closestEdge = extractClosestEdge(self.data); + setDndState({ type: 'is-dragging-over', closestEdge }); + }, + onDrag({ self }) { + const closestEdge = extractClosestEdge(self.data); + + // Only need to update react state if nothing has changed. + // Prevents re-rendering. + setDndState((current) => { + if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { + return current; + } + return { type: 'is-dragging-over', closestEdge }; + }); + }, + onDragLeave() { + setDndState(idle); + }, + onDrop() { + setDndState(idle); + }, + }) + ); + }, [entityIdentifier, ref]); + + return dndState; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index 0d08296b1ab..c2158a35780 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -1,5 +1,5 @@ import { Spacer } from '@invoke-ai/ui-library'; -import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx index 86737367b00..a353ee59f19 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx @@ -1,7 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; import { ControlLayer } from 'features/controlLayers/components/ControlLayer/ControlLayer'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx index 27c9db0ad10..d0fbc6a105d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx @@ -1,5 +1,5 @@ import { Spacer } from '@invoke-ai/ui-library'; -import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx index 486391fbb84..0246e5930e6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx @@ -2,7 +2,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; import { IPAdapter } from 'features/controlLayers/components/IPAdapter/IPAdapter'; import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx index 12ee4acbf80..cb5bcb8950f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx @@ -1,5 +1,5 @@ import { Spacer } from '@invoke-ai/ui-library'; -import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx index f6da8aba41e..8bbb49a9865 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx @@ -1,7 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 68dbb96e900..a12f99ab919 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -1,5 +1,5 @@ import { Spacer } from '@invoke-ai/ui-library'; -import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx index 86c35f7d7d4..c585a49cc3e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx @@ -1,7 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; import { RasterLayer } from 'features/controlLayers/components/RasterLayer/RasterLayer'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx index 9a6d75d4082..1d52e1b582b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx @@ -1,5 +1,5 @@ import { Spacer } from '@invoke-ai/ui-library'; -import { CanvasEntityContainer } from 'features/controlLayers/components/common/CanvasEntityContainer'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx index 9a13315e0e0..75224b7689a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx @@ -1,7 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; -import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; import { RegionalGuidance } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidance'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx deleted file mode 100644 index 6998c1d82e2..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/components/common/CanvasEntityContainer.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; -import { Box, Flex } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; -import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected'; -import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor'; -import { entitySelected } from 'features/controlLayers/store/canvasSlice'; -import type { DndState } from 'features/dnd/dnd'; -import { Dnd, idle } from 'features/dnd/dnd'; -import { DndDropIndicator } from 'features/dnd/DndDropIndicator'; -import type { PropsWithChildren } from 'react'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; - -export const CanvasEntityContainer = memo((props: PropsWithChildren) => { - const dispatch = useAppDispatch(); - const entityIdentifier = useEntityIdentifierContext(); - const isSelected = useEntityIsSelected(entityIdentifier); - const selectionColor = useEntitySelectionColor(entityIdentifier); - const onClick = useCallback(() => { - if (isSelected) { - return; - } - dispatch(entitySelected({ entityIdentifier })); - }, [dispatch, entityIdentifier, isSelected]); - const ref = useRef(null); - const [dndState, setDndState] = useState(idle); - - useEffect(() => { - const element = ref.current; - if (!element) { - return; - } - return combine( - draggable({ - element, - getInitialData() { - return Dnd.Source.singleCanvasEntity.getData({ entityIdentifier }); - }, - onDragStart() { - setDndState({ type: 'is-dragging' }); - }, - onDrop() { - setDndState(idle); - }, - }), - dropTargetForElements({ - element, - canDrop({ source }) { - if (!Dnd.Source.singleCanvasEntity.typeGuard(source.data)) { - return false; - } - if (source.data.payload.entityIdentifier.type !== entityIdentifier.type) { - return false; - } - return true; - }, - getData({ input }) { - const data = Dnd.Source.singleCanvasEntity.getData({ entityIdentifier }); - return attachClosestEdge(data, { - element, - input, - allowedEdges: ['top', 'bottom'], - }); - }, - getIsSticky() { - return true; - }, - onDragEnter({ self }) { - const closestEdge = extractClosestEdge(self.data); - setDndState({ type: 'is-dragging-over', closestEdge }); - }, - onDrag({ self }) { - const closestEdge = extractClosestEdge(self.data); - - // Only need to update react state if nothing has changed. - // Prevents re-rendering. - setDndState((current) => { - if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { - return current; - } - return { type: 'is-dragging-over', closestEdge }; - }); - }, - onDragLeave() { - setDndState(idle); - }, - onDrop() { - setDndState(idle); - }, - }) - ); - }, [entityIdentifier]); - - return ( - - - {props.children} - - {dndState.type === 'is-dragging-over' && dndState.closestEdge ? ( - - ) : null} - - ); -}); - -CanvasEntityContainer.displayName = 'CanvasEntityContainer'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 9187785e7ae..87feb3f2fd5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -1213,7 +1213,7 @@ export const canvasSlice = createSlice({ } }, entityRasterized: (state, action: PayloadAction) => { - const { entityIdentifier, imageObject, position, replaceObjects } = action.payload; + const { entityIdentifier, imageObject, position, replaceObjects, isSelected } = action.payload; const entity = selectEntity(state, entityIdentifier); if (!entity) { return; @@ -1225,6 +1225,10 @@ export const canvasSlice = createSlice({ entity.position = position; } } + + if (isSelected) { + state.selectedEntityIdentifier = entityIdentifier; + } }, entityBrushLineAdded: (state, action: PayloadAction) => { const { entityIdentifier, brushLine } = action.payload; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 7a21b918820..13e973db0cf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -451,6 +451,7 @@ export type EntityRasterizedPayload = EntityIdentifierPayload<{ imageObject: CanvasImageState; position: Coordinate; replaceObjects: boolean; + isSelected?: boolean; }>; /** diff --git a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx index 618de47ac31..c5ae02a0d20 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx @@ -5,7 +5,6 @@ import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/exter import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box } from '@invoke-ai/ui-library'; -import { dndDropped } from 'app/store/middleware/listenerMiddleware/listeners/dnd'; import { useAppDispatch } from 'app/store/storeHooks'; import { Dnd } from 'features/dnd/dnd'; import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; @@ -103,13 +102,13 @@ export const DndDropTarget = memo((props: Props) => { setDndState('potential'); }, getData: () => targetData, - onDrop: (args) => { - const sourceData = args.source.data; - if (!Dnd.Util.isDndSourceData(sourceData)) { - return; - } - dispatch(dndDropped({ sourceData, targetData })); - }, + // onDrop: (args) => { + // const sourceData = args.source.data; + // if (!Dnd.Util.isDndSourceData(sourceData)) { + // return; + // } + // dispatch(dndDropped({ sourceData, targetData })); + // }, }), monitorForElements({ canMonitor: (args) => { @@ -174,12 +173,7 @@ export const DndDropTarget = memo((props: Props) => { image_category: 'user', is_intermediate: false, }); - dispatch( - dndDropped({ - sourceData: Dnd.Source.singleImage.getData({ imageDTO }), - targetData, - }) - ); + Dnd.Util.handleDrop(Dnd.Source.singleImage.getData({ imageDTO }), targetData); } }, }), diff --git a/invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx similarity index 83% rename from invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx rename to invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx index 4a67ce2f5e2..4329be952b5 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx @@ -2,6 +2,7 @@ import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box } from '@invoke-ai/ui-library'; +import type { DndListState } from 'features/dnd/dnd'; import type { CSSProperties } from 'react'; /** @@ -88,7 +89,7 @@ const edgeStyles: Record = { * * A drop indicator is used to communicate the intended resting place of the draggable item. The orientation of the drop indicator should always match the direction of the content flow. */ -export function DndDropIndicator({ edge, gap = '0px' }: DropIndicatorProps) { +function DndDropIndicatorInternal({ edge, gap = '0px' }: DropIndicatorProps) { /** * To clearly communicate the resting place of a draggable item during a drag operation, * the drop indicator should be positioned half way between draggable items. @@ -104,3 +105,21 @@ export function DndDropIndicator({ edge, gap = '0px' }: DropIndicatorProps) { /> ); } + +export const DndListDropIndicator = ({ dndState }: { dndState: DndListState }) => { + if (dndState.type !== 'is-dragging-over') { + return null; + } + + if (!dndState.closestEdge) { + return null; + } + + return ( + + ); +}; diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index a30a60f7f51..bfcbe4efb70 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -4,11 +4,35 @@ import type { Input } from '@atlaskit/pragmatic-drag-and-drop/dist/types/entry-p import type { GetOffsetFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/public-utils/element/custom-native-drag-preview/types'; import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/closest-edge'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { getStore } from 'app/store/nanostores/store'; +import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { + controlLayerAdded, + entityRasterized, + inpaintMaskAdded, + rasterLayerAdded, + referenceImageAdded, + referenceImageIPAdapterImageChanged, + rgAdded, + rgIPAdapterImageChanged, +} from 'features/controlLayers/store/canvasSlice'; +import { selectBboxRect } from 'features/controlLayers/store/selectors'; +import type { + CanvasControlLayerState, + CanvasEntityIdentifier, + CanvasInpaintMaskState, + CanvasRasterLayerState, + CanvasReferenceImageState, + CanvasRegionalGuidanceState, +} from 'features/controlLayers/store/types'; +import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import type { BoardId } from 'features/gallery/store/types'; -import type { FieldIdentifier } from 'features/nodes/types/field'; +import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; +import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import type { CSSProperties } from 'react'; +import { imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; import type { ValueOf } from 'type-fest'; import type { Jsonifiable } from 'type-fest/source/jsonifiable'; @@ -151,7 +175,7 @@ type DndSourceAPI = { * @template P The optional payload of the Dnd source. * @param type The type of the Dnd source. */ -const buildDndSourceApi =

(type: string) => { +export const buildDndSourceApi =

(type: string) => { return { type, kind: 'source', @@ -169,20 +193,10 @@ const singleImage = buildDndSourceApi<{ imageDTO: ImageDTO }>('SingleImage'); * Dnd source API for multiple image source. */ const multipleImage = buildDndSourceApi<{ imageDTOs: ImageDTO[]; boardId: BoardId }>('MultipleImage'); -/** - * Dnd source API for a single canvas entity. - */ -const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity'); -/** - * Dnd source API for a single workflow field. - */ -const singleWorkflowField = buildDndSourceApi<{ fieldIdentifier: FieldIdentifier }>('SingleWorkflowField'); const DndSource = { singleImage, multipleImage, - singleCanvasEntity, - singleWorkflowField, } as const; type SourceDataTypeMap = { @@ -207,6 +221,7 @@ type DndTargetApi = DndSourceAPI & { * @returns Whether the drop is valid. */ validateDrop: (sourceData: DndData, targetData: T) => boolean; + handleDrop: (sourceData: DndData, targetData: T) => void; }; /** @@ -220,7 +235,11 @@ const buildDndTargetApi =

( validateDrop: ( sourceData: DndData, targetData: DndData - ) => boolean + ) => boolean, + handleDrop: ( + sourceData: DndData, + targetData: DndData + ) => void ) => { return { type, @@ -228,56 +247,172 @@ const buildDndTargetApi =

( typeGuard: _buildDataTypeGuard>(type, 'target'), getData: _buildDataGetter>(type, 'target'), validateDrop, + handleDrop, } satisfies DndTargetApi>; }; /** * Dnd target API for setting the image on an existing Global Reference Image layer. */ -const setGlobalReferenceImage = buildDndTargetApi<{ globalReferenceImageId: string }>( +const setGlobalReferenceImage = buildDndTargetApi<{ entityIdentifier: CanvasEntityIdentifier<'reference_image'> }>( 'SetGlobalReferenceImage', - singleImage.typeGuard + singleImage.typeGuard, + (sourceData, targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch } = getStore(); + const { imageDTO } = sourceData.payload; + const { entityIdentifier } = targetData.payload; + dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO })); + } ); /** * Dnd target API for setting the image on an existing Regional Guidance layer's Reference Image. */ const setRegionalGuidanceReferenceImage = buildDndTargetApi<{ - regionalGuidanceId: string; + entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>; referenceImageId: string; -}>('SetRegionalGuidanceReferenceImage', singleImage.typeGuard); +}>('SetRegionalGuidanceReferenceImage', singleImage.typeGuard, (sourceData, targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch } = getStore(); + const { imageDTO } = sourceData.payload; + const { entityIdentifier, referenceImageId } = targetData.payload; + dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO })); +}); /** * Dnd target API for creating a new a Raster Layer from an image. */ -const newRasterLayerFromImage = buildDndTargetApi('NewRasterLayerFromImage', singleImage.typeGuard); +const newRasterLayerFromImage = buildDndTargetApi( + 'NewRasterLayerFromImage', + singleImage.typeGuard, + (sourceData, _targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch, getState } = getStore(); + const { imageDTO } = sourceData.payload; + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectBboxRect(getState()); + const overrides: Partial = { + objects: [imageObject], + position: { x, y }, + }; + dispatch(rasterLayerAdded({ overrides, isSelected: true })); + } +); /** * Dnd target API for creating a new a Control Layer from an image. */ -const newControlLayerFromImage = buildDndTargetApi('NewControlLayerFromImage', singleImage.typeGuard); +const newControlLayerFromImage = buildDndTargetApi( + 'NewControlLayerFromImage', + singleImage.typeGuard, + (sourceData, _targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { imageDTO } = sourceData.payload; + const { dispatch, getState } = getStore(); + const state = getState(); + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectBboxRect(state); + const controlAdapter = selectDefaultControlAdapter(state); + const overrides: Partial = { + objects: [imageObject], + position: { x, y }, + controlAdapter, + }; + dispatch(controlLayerAdded({ overrides, isSelected: true })); + } +); /** * Dnd target API for adding an Inpaint Mask from an image. */ -const newInpaintMaskFromImage = buildDndTargetApi('NewInpaintMaskFromImage', singleImage.typeGuard); +const newInpaintMaskFromImage = buildDndTargetApi( + 'NewInpaintMaskFromImage', + singleImage.typeGuard, + (sourceData, _targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch, getState } = getStore(); + const { imageDTO } = sourceData.payload; + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectBboxRect(getState()); + const overrides: Partial = { + objects: [imageObject], + position: { x, y }, + }; + dispatch(inpaintMaskAdded({ overrides, isSelected: true })); + } +); /** * Dnd target API for adding a new Global Reference Image layer with a pre-set Reference Image from an image. */ -const newGlobalReferenceImageFromImage = buildDndTargetApi('NewGlobalReferenceImageFromImage', singleImage.typeGuard); +const newGlobalReferenceImageFromImage = buildDndTargetApi( + 'NewGlobalReferenceImageFromImage', + singleImage.typeGuard, + (sourceData, _targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch, getState } = getStore(); + const { imageDTO } = sourceData.payload; + const ipAdapter = selectDefaultIPAdapter(getState()); + ipAdapter.image = imageDTOToImageWithDims(imageDTO); + const overrides: Partial = { ipAdapter }; + dispatch(referenceImageAdded({ overrides, isSelected: true })); + } +); /** * Dnd target API for adding a new Regional Guidance layer from an image. */ -const newRegionalGuidanceFromImage = buildDndTargetApi('NewRegionalGuidanceFromImage', singleImage.typeGuard); +const newRegionalGuidanceFromImage = buildDndTargetApi( + 'NewRegionalGuidanceFromImage', + singleImage.typeGuard, + (sourceData, _targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch, getState } = getStore(); + const { imageDTO } = sourceData.payload; + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectBboxRect(getState()); + const overrides: Partial = { + objects: [imageObject], + position: { x, y }, + }; + dispatch(rgAdded({ overrides, isSelected: true })); + } +); /** * Dnd target API for adding a new Regional Guidance layer with a pre-set Reference Image from an image. */ const newRegionalGuidanceReferenceImageFromImage = buildDndTargetApi( 'NewRegionalGuidanceReferenceImageFromImage', - singleImage.typeGuard + singleImage.typeGuard, + (sourceData, _targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch, getState } = getStore(); + const { imageDTO } = sourceData.payload; + const ipAdapter = selectDefaultIPAdapter(getState()); + ipAdapter.image = imageDTOToImageWithDims(imageDTO); + const overrides: Partial = { + referenceImages: [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }], + }; + dispatch(rgAdded({ overrides, isSelected: true })); + } ); /** @@ -286,19 +421,57 @@ const newRegionalGuidanceReferenceImageFromImage = buildDndTargetApi( */ const replaceLayerWithImage = buildDndTargetApi<{ entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>; -}>('ReplaceLayerWithImage', singleImage.typeGuard); +}>('ReplaceLayerWithImage', singleImage.typeGuard, (sourceData, targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch, getState } = getStore(); + const { imageDTO } = sourceData.payload; + const { entityIdentifier } = targetData.payload; + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectBboxRect(getState()); + dispatch( + entityRasterized({ + entityIdentifier, + imageObject, + position: { x, y }, + replaceObjects: true, + isSelected: true, + }) + ); +}); /** * Dnd target API for setting the initial image on the upscaling tab. */ -const setUpscaleInitialImageFromImage = buildDndTargetApi('SetUpscaleInitialImageFromImage', singleImage.typeGuard); +const setUpscaleInitialImageFromImage = buildDndTargetApi( + 'SetUpscaleInitialImageFromImage', + singleImage.typeGuard, + (sourceData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch } = getStore(); + const { imageDTO } = sourceData.payload; + dispatch(upscaleInitialImageChanged(imageDTO)); + } +); /** * Dnd target API for setting an image field on a node. */ const setNodeImageField = buildDndTargetApi<{ nodeId: string; fieldName: string }>( 'SetNodeImageField', - singleImage.typeGuard + singleImage.typeGuard, + (sourceData, targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch } = getStore(); + const { imageDTO } = sourceData.payload; + const { fieldName, nodeId } = targetData.payload; + dispatch(fieldImageValueChanged({ nodeId, fieldName, value: imageDTO })); + } ); /** @@ -307,55 +480,104 @@ const setNodeImageField = buildDndTargetApi<{ nodeId: string; fieldName: string const selectForCompare = buildDndTargetApi<{ firstImageName?: string | null; secondImageName?: string | null; -}>('SelectForCompare', (sourceData, targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - // Do not allow the same images to be selected for comparison - if (sourceData.payload.imageDTO.image_name === targetData.payload.firstImageName) { - return false; - } - if (sourceData.payload.imageDTO.image_name === targetData.payload.secondImageName) { - return false; +}>( + 'SelectForCompare', + (sourceData, targetData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + // Do not allow the same images to be selected for comparison + if (sourceData.payload.imageDTO.image_name === targetData.payload.firstImageName) { + return false; + } + if (sourceData.payload.imageDTO.image_name === targetData.payload.secondImageName) { + return false; + } + return true; + }, + (sourceData) => { + if (!singleImage.typeGuard(sourceData)) { + return false; + } + const { dispatch } = getStore(); + const { imageDTO } = sourceData.payload; + dispatch(imageToCompareChanged(imageDTO)); } - return true; -}); +); /** * Dnd target API for adding an image to a board. */ -const addToBoard = buildDndTargetApi<{ boardId: string }>('AddToBoard', (sourceData, targetData) => { - if (singleImage.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; - const destinationBoard = targetData.payload.boardId; - return currentBoard !== destinationBoard; - } +const addToBoard = buildDndTargetApi<{ boardId: string }>( + 'AddToBoard', + (sourceData, targetData) => { + if (singleImage.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } - if (multipleImage.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.boardId; - const destinationBoard = targetData.payload.boardId; - return currentBoard !== destinationBoard; - } + if (multipleImage.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.boardId; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } - return false; -}); + return false; + }, + (sourceData, targetData) => { + if (singleImage.typeGuard(sourceData)) { + const { dispatch } = getStore(); + const { imageDTO } = sourceData.payload; + const { boardId } = targetData.payload; + dispatch(imagesApi.endpoints.addImageToBoard.initiate({ imageDTO, board_id: boardId }, { track: false })); + dispatch(selectionChanged([])); + } + + if (multipleImage.typeGuard(sourceData)) { + const { dispatch } = getStore(); + const { imageDTOs } = sourceData.payload; + const { boardId } = targetData.payload; + dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false })); + dispatch(selectionChanged([])); + } + } +); /** * Dnd target API for removing an image from a board. */ -const removeFromBoard = buildDndTargetApi('RemoveFromBoard', (sourceData) => { - if (singleImage.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; - return currentBoard !== 'none'; - } +const removeFromBoard = buildDndTargetApi( + 'RemoveFromBoard', + (sourceData) => { + if (singleImage.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; + return currentBoard !== 'none'; + } - if (multipleImage.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.boardId; - return currentBoard !== 'none'; - } + if (multipleImage.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.boardId; + return currentBoard !== 'none'; + } - return false; -}); + return false; + }, + (sourceData) => { + if (singleImage.typeGuard(sourceData)) { + const { dispatch } = getStore(); + const { imageDTO } = sourceData.payload; + dispatch(imagesApi.endpoints.removeImageFromBoard.initiate({ imageDTO }, { track: false })); + dispatch(selectionChanged([])); + } + + if (multipleImage.typeGuard(sourceData)) { + const { dispatch } = getStore(); + const { imageDTOs } = sourceData.payload; + dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false })); + dispatch(selectionChanged([])); + } + } +); const DndTarget = { /** @@ -491,6 +713,25 @@ export const Dnd = { } return false; }, + /** + * Validates whether a drop is valid. + * @param sourceData The data being dragged. + * @param targetData The data of the target being dragged onto. + * @returns Whether the drop is valid. + */ + handleDrop: (sourceData: SourceDataUnion, targetData: TargetDataUnion): void => { + for (const targetApi of targetApisArray) { + if (targetApi.typeGuard(targetData)) { + /** + * TS cannot narrow the type of the targetApi and will error in the handleDrop call. + * We've just checked that targetData is of the right type, though, so this cast to `any` is safe. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + targetApi.handleDrop(sourceData, targetData as any); + return; + } + } + }, }, }; @@ -536,7 +777,7 @@ export function triggerPostMoveFlash(element: HTMLElement, backgroundColor: CSSP }); } -export type DndState = +export type DndListState = | { type: 'idle'; } @@ -551,4 +792,4 @@ export type DndState = type: 'is-dragging-over'; closestEdge: Edge | null; }; -export const idle: DndState = { type: 'idle' }; +export const idle: DndListState = { type: 'idle' }; diff --git a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts new file mode 100644 index 00000000000..75156700127 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts @@ -0,0 +1,69 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter'; +import { containsFiles } from '@atlaskit/pragmatic-drag-and-drop/external/file'; +import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; +import { logger } from 'app/logging/logger'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { Dnd } from 'features/dnd/dnd'; +import { useEffect } from 'react'; + +const log = logger('dnd'); + +export const useDndMonitor = () => { + useAssertSingleton('useDropMonitor'); + + useEffect(() => { + return combine( + monitorForElements({ + canMonitor: ({ source }) => { + const sourceData = source.data; + + // Check for allowed sources + if (!Dnd.Source.singleImage.typeGuard(sourceData) && !Dnd.Source.multipleImage.typeGuard(sourceData)) { + return false; + } + + return true; + }, + onDrop: ({ source, location }) => { + const target = location.current.dropTargets[0]; + if (!target) { + return; + } + + const sourceData = source.data; + const targetData = target.data; + + // Check for allowed sources + if (!Dnd.Source.singleImage.typeGuard(sourceData) && !Dnd.Source.multipleImage.typeGuard(sourceData)) { + return; + } + + // Check for allowed targets + if (!Dnd.Util.isDndTargetData(targetData)) { + return; + } + + log.debug({ sourceData, targetData }, 'Dropped image'); + + Dnd.Util.handleDrop(sourceData, targetData); + }, + }), + monitorForExternal({ + canMonitor: (args) => { + if (!containsFiles(args)) { + return false; + } + return true; + }, + onDragStart: () => { + preventUnhandled.start(); + }, + onDrop: () => { + preventUnhandled.stop(); + }, + }) + ); + }, []); +}; 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 26a12ed88d4..d52490c70e5 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,17 +1,14 @@ -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { Box, Circle, Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import type { DndState } from 'features/dnd/dnd'; -import { Dnd, idle } from 'features/dnd/dnd'; -import { DndDropIndicator } from 'features/dnd/DndDropIndicator'; +import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator'; import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck'; +import { useLinearViewFieldDnd } from 'features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd'; import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue'; import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode'; import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice'; import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import type { FieldIdentifier } from 'features/nodes/types/field'; +import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi'; @@ -20,92 +17,28 @@ import FieldTooltipContent from './FieldTooltipContent'; import InputFieldRenderer from './InputFieldRenderer'; type Props = { - nodeId: string; - fieldName: string; + fieldIdentifier: FieldIdentifier; }; -const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => { +const LinearViewFieldInternal = ({ fieldIdentifier }: Props) => { const dispatch = useAppDispatch(); - const { isValueChanged, onReset } = useFieldOriginalValue(nodeId, fieldName); - const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(nodeId); + const { isValueChanged, onReset } = useFieldOriginalValue(fieldIdentifier.nodeId, fieldIdentifier.fieldName); + const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(fieldIdentifier.nodeId); const { t } = useTranslation(); const handleRemoveField = useCallback(() => { - dispatch(workflowExposedFieldRemoved({ nodeId, fieldName })); - }, [dispatch, fieldName, nodeId]); + dispatch(workflowExposedFieldRemoved(fieldIdentifier)); + }, [dispatch, fieldIdentifier]); const ref = useRef(null); - const [dndState, setDndState] = useState(idle); - - useEffect(() => { - const element = ref.current; - if (!element) { - return; - } - return combine( - draggable({ - element, - getInitialData() { - return Dnd.Source.singleWorkflowField.getData({ fieldIdentifier: { nodeId, fieldName } }); - }, - onDragStart() { - setDndState({ type: 'is-dragging' }); - }, - onDrop() { - setDndState(idle); - }, - }), - dropTargetForElements({ - element, - canDrop({ source }) { - if (!Dnd.Source.singleWorkflowField.typeGuard(source.data)) { - return false; - } - return true; - }, - getData({ input }) { - const data = Dnd.Source.singleWorkflowField.getData({ fieldIdentifier: { nodeId, fieldName } }); - return attachClosestEdge(data, { - element, - input, - allowedEdges: ['top', 'bottom'], - }); - }, - getIsSticky() { - return true; - }, - onDragEnter({ self }) { - const closestEdge = extractClosestEdge(self.data); - setDndState({ type: 'is-dragging-over', closestEdge }); - }, - onDrag({ self }) { - const closestEdge = extractClosestEdge(self.data); - - // Only need to update react state if nothing has changed. - // Prevents re-rendering. - setDndState((current) => { - if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { - return current; - } - return { type: 'is-dragging-over', closestEdge }; - }); - }, - onDragLeave() { - setDndState(idle); - }, - onDrop() { - setDndState(idle); - }, - }) - ); - }, [fieldName, nodeId]); + const dndState = useLinearViewFieldDnd(ref, fieldIdentifier); return ( { > - + {isMouseOverNode && } {isValueChanged && ( @@ -131,7 +64,13 @@ const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => { /> )} } + label={ + + } openDelay={HANDLE_TOOLTIP_OPEN_DELAY} placement="top" > @@ -148,24 +87,18 @@ const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => { icon={} /> - + - {dndState.type === 'is-dragging-over' && dndState.closestEdge ? ( - - ) : null} + ); }; -const LinearViewField = ({ nodeId, fieldName }: Props) => { +const LinearViewField = ({ fieldIdentifier }: Props) => { return ( - - + + ); }; 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 654e4bfed48..2fd14cd6362 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 @@ -7,8 +7,9 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar'; -import { Dnd, triggerPostMoveFlash } from 'features/dnd/dnd'; +import { triggerPostMoveFlash } from 'features/dnd/dnd'; import LinearViewFieldInternal from 'features/nodes/components/flow/nodes/Invocation/fields/LinearViewField'; +import { singleWorkflowField } from 'features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd'; import { selectWorkflowSlice, workflowExposedFieldsReordered } from 'features/nodes/store/workflowSlice'; import type { FieldIdentifier } from 'features/nodes/types/field'; import { memo, useEffect } from 'react'; @@ -56,7 +57,7 @@ const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) = useEffect(() => { return monitorForElements({ canMonitor({ source }) { - if (!Dnd.Source.singleWorkflowField.typeGuard(source.data)) { + if (!singleWorkflowField.typeGuard(source.data)) { return false; } return true; @@ -70,10 +71,7 @@ const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) = const sourceData = source.data; const targetData = target.data; - if ( - !Dnd.Source.singleWorkflowField.typeGuard(sourceData) || - !Dnd.Source.singleWorkflowField.typeGuard(targetData) - ) { + if (!singleWorkflowField.typeGuard(sourceData) || !singleWorkflowField.typeGuard(targetData)) { return; } @@ -135,8 +133,11 @@ const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) = return ( <> - {fields.map(({ nodeId, fieldName }) => ( - + {fields.map((fieldIdentifier) => ( + ))} ); 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..6c5159b644c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts @@ -0,0 +1,82 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import type { DndListState } from 'features/dnd/dnd'; +import { buildDndSourceApi, idle } from 'features/dnd/dnd'; +import type { FieldIdentifier } from 'features/nodes/types/field'; +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; + +/** + * Dnd source API for a single workflow field. + */ +export const singleWorkflowField = buildDndSourceApi<{ fieldIdentifier: FieldIdentifier }>('SingleWorkflowField'); + +export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifier: FieldIdentifier) => { + const [dndState, setDndState] = useState(idle); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + return combine( + draggable({ + element, + getInitialData() { + return singleWorkflowField.getData({ fieldIdentifier }); + }, + onDragStart() { + setDndState({ type: 'is-dragging' }); + }, + onDrop() { + setDndState(idle); + }, + }), + dropTargetForElements({ + element, + canDrop({ source }) { + if (!singleWorkflowField.typeGuard(source.data)) { + return false; + } + return true; + }, + getData({ input }) { + const data = singleWorkflowField.getData({ fieldIdentifier }); + return attachClosestEdge(data, { + element, + input, + allowedEdges: ['top', 'bottom'], + }); + }, + getIsSticky() { + return true; + }, + onDragEnter({ self }) { + const closestEdge = extractClosestEdge(self.data); + setDndState({ type: 'is-dragging-over', closestEdge }); + }, + onDrag({ self }) { + const closestEdge = extractClosestEdge(self.data); + + // Only need to update react state if nothing has changed. + // Prevents re-rendering. + setDndState((current) => { + if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { + return current; + } + return { type: 'is-dragging-over', closestEdge }; + }); + }, + onDragLeave() { + setDndState(idle); + }, + onDrop() { + setDndState(idle); + }, + }) + ); + }, [fieldIdentifier, ref]); + + return dndState; +}; 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( From 279406c4737608ef036d02973e71f958126343e0 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 31 Oct 2024 07:26:22 +1000 Subject: [PATCH 21/39] feat(ui): more dnd cleanup and tidy --- .../CanvasEntityContainer.tsx | 32 ++++++++++++------- .../useCanvasEntityListDnd.ts | 19 ++++++----- .../components/IPAdapter/IPAdapterList.tsx | 18 +++++------ .../IPAdapter/IPAdapterSettings.tsx | 8 ++--- .../RegionalGuidanceIPAdapterSettings.tsx | 4 +-- .../src/features/dnd/DndListDropIndicator.tsx | 4 +-- .../Invocation/fields/LinearViewField.tsx | 26 ++++++++++----- .../workflow/useLinearViewFieldDnd.ts | 19 ++++++----- 8 files changed, 75 insertions(+), 55 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx index 18429ad75cf..f38e78b4448 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx @@ -1,19 +1,33 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useCanvasEntityListDnd } from 'features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected'; -import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor'; import { entitySelected } from 'features/controlLayers/store/canvasSlice'; import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator'; import type { PropsWithChildren } from 'react'; import { memo, useCallback, useRef } from 'react'; +const sx = { + position: 'relative', + flexDir: 'column', + w: 'full', + bg: 'base.850', + borderRadius: 'base', + '&[data-selected=true]': { + bg: 'base.800', + }, + '&[data-is-dragging=true]': { + opacity: 0.3, + }, + transitionProperty: 'common', +} satisfies SystemStyleObject; + export const CanvasEntityContainer = memo((props: PropsWithChildren) => { const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext(); const isSelected = useEntityIsSelected(entityIdentifier); - const selectionColor = useEntitySelectionColor(entityIdentifier); const onClick = useCallback(() => { if (isSelected) { return; @@ -22,26 +36,22 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => { }, [dispatch, entityIdentifier, isSelected]); const ref = useRef(null); - const dndState = useCanvasEntityListDnd(ref, entityIdentifier); + const [dndListState, isDragging] = useCanvasEntityListDnd(ref, entityIdentifier); return ( {props.children} - + ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts index d9d34d7066c..d486e53e55f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts @@ -13,7 +13,8 @@ import { useEffect, useState } from 'react'; export const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity'); export const useCanvasEntityListDnd = (ref: RefObject, entityIdentifier: CanvasEntityIdentifier) => { - const [dndState, setDndState] = useState(idle); + const [dndListState, setDndListState] = useState(idle); + const [isDragging, setIsDragging] = useState(false); useEffect(() => { const element = ref.current; @@ -27,10 +28,12 @@ export const useCanvasEntityListDnd = (ref: RefObject, entityIdenti return singleCanvasEntity.getData({ entityIdentifier }); }, onDragStart() { - setDndState({ type: 'is-dragging' }); + setDndListState({ type: 'is-dragging' }); + setIsDragging(true); }, onDrop() { - setDndState(idle); + setDndListState(idle); + setIsDragging(false); }, }), dropTargetForElements({ @@ -57,14 +60,14 @@ export const useCanvasEntityListDnd = (ref: RefObject, entityIdenti }, onDragEnter({ self }) { const closestEdge = extractClosestEdge(self.data); - setDndState({ type: 'is-dragging-over', closestEdge }); + setDndListState({ type: 'is-dragging-over', closestEdge }); }, onDrag({ self }) { const closestEdge = extractClosestEdge(self.data); // Only need to update react state if nothing has changed. // Prevents re-rendering. - setDndState((current) => { + setDndListState((current) => { if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { return current; } @@ -72,14 +75,14 @@ export const useCanvasEntityListDnd = (ref: RefObject, entityIdenti }); }, onDragLeave() { - setDndState(idle); + setDndListState(idle); }, onDrop() { - setDndState(idle); + setDndListState(idle); }, }) ); }, [entityIdentifier, ref]); - return dndState; + return [dndListState, isDragging] as const; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx index 0246e5930e6..38cdbde8c7c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx @@ -4,12 +4,12 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; import { IPAdapter } from 'features/controlLayers/components/IPAdapter/IPAdapter'; -import { mapId } from 'features/controlLayers/konva/util'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { memo } from 'react'; -const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => { - return canvas.referenceImages.entities.map(mapId).reverse(); +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.referenceImages.entities.map(getEntityIdentifier).toReversed(); }); const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { return selectedEntityIdentifier?.type === 'reference_image'; @@ -17,17 +17,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte export const IPAdapterList = memo(() => { const isSelected = useAppSelector(selectIsSelected); - const ipaIds = useAppSelector(selectEntityIds); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); - if (ipaIds.length === 0) { + if (entityIdentifiers.length === 0) { return null; } - if (ipaIds.length > 0) { + if (entityIdentifiers.length > 0) { return ( - - {ipaIds.map((id) => ( - + + {entityIdentifiers.map((entityIdentifiers) => ( + ))} ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx index 0d3133ba9ba..7cd149bac20 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -85,12 +85,8 @@ export const IPAdapterSettings = memo(() => { [entityIdentifier.id] ); const targetData = useMemo( - () => - Dnd.Target.setGlobalReferenceImage.getData( - { globalReferenceImageId: entityIdentifier.id }, - ipAdapter.image?.image_name - ), - [entityIdentifier.id, ipAdapter.image?.image_name] + () => Dnd.Target.setGlobalReferenceImage.getData({ entityIdentifier }, ipAdapter.image?.image_name), + [entityIdentifier, ipAdapter.image?.image_name] ); const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); const isBusy = useCanvasIsBusy(); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index b0feed574e2..de8c6c56e70 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -94,10 +94,10 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro const targetData = useMemo( () => Dnd.Target.setRegionalGuidanceReferenceImage.getData( - { regionalGuidanceId: entityIdentifier.id, referenceImageId }, + { entityIdentifier, referenceImageId }, ipAdapter.image?.image_name ), - [entityIdentifier.id, ipAdapter.image?.image_name, referenceImageId] + [entityIdentifier, ipAdapter.image?.image_name, referenceImageId] ); const postUploadAction = useMemo( diff --git a/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx index 4329be952b5..96590b4f86c 100644 --- a/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx @@ -3,7 +3,6 @@ import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box } from '@invoke-ai/ui-library'; import type { DndListState } from 'features/dnd/dnd'; -import type { CSSProperties } from 'react'; /** * Design decisions for the drop indicator's main line @@ -100,8 +99,7 @@ function DndDropIndicatorInternal({ edge, gap = '0px' }: DropIndicatorProps) { 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 d52490c70e5..ea9a01323ce 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,3 +1,4 @@ +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 { DndListDropIndicator } from 'features/dnd/DndListDropIndicator'; @@ -20,6 +21,19 @@ type Props = { fieldIdentifier: FieldIdentifier; }; +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(fieldIdentifier.nodeId, fieldIdentifier.fieldName); @@ -31,7 +45,7 @@ const LinearViewFieldInternal = ({ fieldIdentifier }: Props) => { }, [dispatch, fieldIdentifier]); const ref = useRef(null); - const dndState = useLinearViewFieldDnd(ref, fieldIdentifier); + const [dndListState, isDragging] = useLinearViewFieldDnd(ref, fieldIdentifier); return ( @@ -39,14 +53,10 @@ const LinearViewFieldInternal = ({ fieldIdentifier }: Props) => { ref={ref} // This is used to trigger the post-move flash animation data-field-name={fieldIdentifier.fieldName} + data-is-dragging={isDragging} onMouseEnter={handleMouseOver} onMouseLeave={handleMouseOut} - layerStyle="second" - alignItems="center" - position="relative" - borderRadius="base" - w="full" - p={2} + sx={sx} > @@ -90,7 +100,7 @@ const LinearViewFieldInternal = ({ fieldIdentifier }: Props) => { - + ); }; 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 index 6c5159b644c..8dea9126387 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts @@ -13,7 +13,8 @@ import { useEffect, useState } from 'react'; export const singleWorkflowField = buildDndSourceApi<{ fieldIdentifier: FieldIdentifier }>('SingleWorkflowField'); export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifier: FieldIdentifier) => { - const [dndState, setDndState] = useState(idle); + const [dndListState, setListDndState] = useState(idle); + const [isDragging, setIsDragging] = useState(false); useEffect(() => { const element = ref.current; @@ -27,10 +28,12 @@ export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifi return singleWorkflowField.getData({ fieldIdentifier }); }, onDragStart() { - setDndState({ type: 'is-dragging' }); + setListDndState({ type: 'is-dragging' }); + setIsDragging(true); }, onDrop() { - setDndState(idle); + setListDndState(idle); + setIsDragging(false); }, }), dropTargetForElements({ @@ -54,14 +57,14 @@ export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifi }, onDragEnter({ self }) { const closestEdge = extractClosestEdge(self.data); - setDndState({ type: 'is-dragging-over', closestEdge }); + 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. - setDndState((current) => { + setListDndState((current) => { if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { return current; } @@ -69,14 +72,14 @@ export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifi }); }, onDragLeave() { - setDndState(idle); + setListDndState(idle); }, onDrop() { - setDndState(idle); + setListDndState(idle); }, }) ); }, [fieldIdentifier, ref]); - return dndState; + return [dndListState, isDragging] as const; }; From c1562a87935afb27b120134f1a1e1576d3201342 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 31 Oct 2024 17:38:18 +1000 Subject: [PATCH 22/39] feat(ui): support different labels for external drop targets (e.g. uploads) --- .../listeners/imageUploaded.ts | 116 ++++-------------- .../components/CanvasDropArea.tsx | 14 ++- .../components/ControlLayer/ControlLayer.tsx | 4 +- .../IPAdapter/IPAdapterImagePreview.tsx | 5 +- .../IPAdapter/IPAdapterSettings.tsx | 13 +- .../components/RasterLayer/RasterLayer.tsx | 4 +- .../RegionalGuidanceIPAdapterSettings.tsx | 13 +- .../web/src/features/dnd/DndDropTarget.tsx | 40 +++--- .../Boards/BoardsList/GalleryBoard.tsx | 2 +- .../Boards/BoardsList/NoBoardBoard.tsx | 2 +- .../inputs/ImageFieldInputComponent.tsx | 10 -- .../UpscaleInitialImage.tsx | 7 -- 12 files changed, 68 insertions(+), 162 deletions(-) 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/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index ff5f7a00cfe..38b32243015 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -1,4 +1,5 @@ import { Grid, GridItem } from '@invoke-ai/ui-library'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; @@ -14,6 +15,7 @@ const addGlobalReferenceImageFromImageDndTargetData = Dnd.Target.newGlobalRefere export const CanvasDropArea = memo(() => { const { t } = useTranslation(); const imageViewer = useImageViewer(); + const isBusy = useCanvasIsBusy(); if (imageViewer.isOpen) { return null; @@ -33,27 +35,31 @@ export const CanvasDropArea = memo(() => { > diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index c2158a35780..51be4c55083 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -9,6 +9,7 @@ import { ControlLayerBadges } from 'features/controlLayers/components/ControlLay import { ControlLayerSettings } from 'features/controlLayers/components/ControlLayer/ControlLayerSettings'; import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; @@ -21,6 +22,7 @@ type Props = { export const ControlLayer = memo(({ id }: Props) => { const { t } = useTranslation(); + const isBusy = useCanvasIsBusy(); const entityIdentifier = useMemo>( () => ({ id, type: 'control_layer' }), [id] @@ -44,7 +46,7 @@ export const ControlLayer = memo(({ id }: Props) => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx index 2d8546e57fe..4105d876507 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx @@ -11,7 +11,7 @@ import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import type { ImageDTO, PostUploadAction } from 'services/api/types'; +import type { ImageDTO } from 'services/api/types'; import { $isConnected } from 'services/events/stores'; const sx = { @@ -32,10 +32,9 @@ type Props = { image: ImageWithDims | null; onChangeImage: (imageDTO: ImageDTO | null) => void; targetData: Dnd.types['TargetDataUnion']; - postUploadAction: PostUploadAction; }; -export const IPAdapterImagePreview = memo(({ image, onChangeImage, targetData, postUploadAction }: Props) => { +export const IPAdapterImagePreview = memo(({ image, onChangeImage, targetData }: Props) => { const { t } = useTranslation(); const isConnected = useStore($isConnected); const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx index 7cd149bac20..b8c434edf28 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -23,7 +23,7 @@ import { Dnd } from 'features/dnd/dnd'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold } from 'react-icons/pi'; -import type { ImageDTO, IPAdapterModelConfig, IPALayerImagePostUploadAction } from 'services/api/types'; +import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types'; import { IPAdapterImagePreview } from './IPAdapterImagePreview'; import { IPAdapterModel } from './IPAdapterModel'; @@ -80,10 +80,6 @@ export const IPAdapterSettings = memo(() => { [dispatch, entityIdentifier] ); - const postUploadAction = useMemo( - () => ({ type: 'SET_IPA_IMAGE', id: entityIdentifier.id }), - [entityIdentifier.id] - ); const targetData = useMemo( () => Dnd.Target.setGlobalReferenceImage.getData({ entityIdentifier }, ipAdapter.image?.image_name), [entityIdentifier, ipAdapter.image?.image_name] @@ -121,12 +117,7 @@ export const IPAdapterSettings = memo(() => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index a12f99ab919..01b7698bd8d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -6,6 +6,7 @@ import { CanvasEntityPreviewImage } from 'features/controlLayers/components/comm import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; @@ -18,6 +19,7 @@ type Props = { export const RasterLayer = memo(({ id }: Props) => { const { t } = useTranslation(); + const isBusy = useCanvasIsBusy(); const entityIdentifier = useMemo>(() => ({ id, type: 'raster_layer' }), [id]); const targetData = useMemo( () => Dnd.Target.replaceLayerWithImage.getData({ entityIdentifier }, entityIdentifier.id), @@ -34,7 +36,7 @@ export const RasterLayer = memo(({ id }: Props) => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index de8c6c56e70..4b630bc0e94 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -24,7 +24,7 @@ import { Dnd } from 'features/dnd/dnd'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi'; -import type { ImageDTO, IPAdapterModelConfig, RGIPAdapterImagePostUploadAction } from 'services/api/types'; +import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types'; import { assert } from 'tsafe'; type Props = { @@ -100,10 +100,6 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro [entityIdentifier, ipAdapter.image?.image_name, referenceImageId] ); - const postUploadAction = useMemo( - () => ({ type: 'SET_RG_IP_ADAPTER_IMAGE', id: entityIdentifier.id, referenceImageId: referenceImageId }), - [entityIdentifier.id, referenceImageId] - ); const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId); const isBusy = useCanvasIsBusy(); @@ -151,12 +147,7 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro - + diff --git a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx index c5ae02a0d20..0c02c5071ea 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx @@ -58,15 +58,16 @@ const zUploadFile = z ); type Props = { - label: string; targetData: Dnd.types['TargetDataUnion']; - elementDropEnabled?: boolean; - externalDropEnabled?: boolean; + label: string; + externalLabel?: string; + isDisabled?: boolean; }; export const DndDropTarget = memo((props: Props) => { - const { label, targetData, elementDropEnabled = true, externalDropEnabled = true } = props; + const { targetData, label, externalLabel = label, isDisabled } = props; const [dndState, setDndState] = useState('idle'); + const [dndOrigin, setDndOrigin] = useState<'element' | 'external' | null>(null); const ref = useRef(null); const dispatch = useAppDispatch(); @@ -74,8 +75,7 @@ export const DndDropTarget = memo((props: Props) => { if (!ref.current) { return; } - - if (!elementDropEnabled) { + if (isDisabled) { return; } @@ -83,9 +83,6 @@ export const DndDropTarget = memo((props: Props) => { dropTargetForElements({ element: ref.current, canDrop: (args) => { - if (!elementDropEnabled) { - return false; - } const sourceData = args.source.data; if (!Dnd.Util.isDndSourceData(sourceData)) { return false; @@ -102,13 +99,6 @@ export const DndDropTarget = memo((props: Props) => { setDndState('potential'); }, getData: () => targetData, - // onDrop: (args) => { - // const sourceData = args.source.data; - // if (!Dnd.Util.isDndSourceData(sourceData)) { - // return; - // } - // dispatch(dndDropped({ sourceData, targetData })); - // }, }), monitorForElements({ canMonitor: (args) => { @@ -122,21 +112,23 @@ export const DndDropTarget = memo((props: Props) => { return Dnd.Util.isValidDrop(sourceData, targetData); }, onDragStart: () => { + setDndOrigin('element'); setDndState('potential'); }, onDrop: () => { + setDndOrigin(null); setDndState('idle'); }, }) ); - }, [targetData, dispatch, elementDropEnabled]); + }, [targetData, dispatch, isDisabled]); useEffect(() => { if (!ref.current) { return; } - if (!externalDropEnabled) { + if (isDisabled) { return; } @@ -144,9 +136,6 @@ export const DndDropTarget = memo((props: Props) => { dropTargetForExternal({ element: ref.current, canDrop: (args) => { - if (!externalDropEnabled) { - return false; - } if (!containsFiles(args)) { return false; } @@ -185,20 +174,25 @@ export const DndDropTarget = memo((props: Props) => { return true; }, onDragStart: () => { + setDndOrigin('external'); setDndState('potential'); preventUnhandled.start(); }, onDrop: () => { + setDndOrigin(null); setDndState('idle'); preventUnhandled.stop(); }, }) ); - }, [targetData, dispatch, externalDropEnabled]); + }, [targetData, dispatch, isDisabled]); return ( - + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 099d3bf49f8..928d1fbe254 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -80,7 +80,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { )} - + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index 7d372338a58..b0b386c3aee 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -99,7 +99,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { )} - + ); }); 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 6051c1a5030..5733fa473f1 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 @@ -12,7 +12,6 @@ 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 { $isConnected } from 'services/events/stores'; import type { FieldComponentProps } from './types'; @@ -38,15 +37,6 @@ const ImageFieldInputComponent = (props: FieldComponentProps( - () => ({ - type: 'SET_NODES_IMAGE', - nodeId, - fieldName: field.name, - }), - [nodeId, field.name] - ); - useEffect(() => { if (isConnected && isError) { handleReset(); 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 7bf325d9f79..92ac12d1b8f 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx @@ -8,17 +8,10 @@ import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/ import { t } from 'i18next'; import { useCallback, useMemo } from 'react'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; -import type { PostUploadAction } from 'services/api/types'; export const UpscaleInitialImage = () => { const dispatch = useAppDispatch(); const imageDTO = useAppSelector(selectUpscaleInitialImage); - const postUploadAction = useMemo( - () => ({ - type: 'SET_UPSCALE_INITIAL_IMAGE', - }), - [] - ); const targetData = useMemo( () => Dnd.Target.setUpscaleInitialImageFromImage.getData(), [] From 99c36976ffad5cde185991c55e13e8b2ce7b25c0 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Nov 2024 08:59:35 +1000 Subject: [PATCH 23/39] refactor(ui): dnd actions to image actions We don't need a "dnd" image system. We need a "image action" system. We need to execute specific flows with images from various "origins": - internal dnd e.g. from gallery - external dnd e.g. user drags an image file into the browser - direct file upload e.g. user clicks an upload button - some other internal app button e.g. a context menu The actions are now generalized to better support these various use-cases. --- .../components/CanvasDropArea.tsx | 13 +- .../CanvasEntityGroupList.tsx | 2 +- .../useCanvasEntityListDnd.ts | 22 +- .../components/CanvasMainPanelContent.tsx | 4 +- .../components/CanvasRightPanel.tsx | 9 +- .../components/ControlLayer/ControlLayer.tsx | 7 +- .../IPAdapter/IPAdapterImagePreview.tsx | 4 +- .../IPAdapter/IPAdapterSettings.tsx | 7 +- .../components/RasterLayer/RasterLayer.tsx | 7 +- .../RegionalGuidanceIPAdapterSettings.tsx | 10 +- .../dnd/DndDragPreviewMultipleImage.tsx | 6 +- .../dnd/DndDragPreviewSingleImage.tsx | 6 +- .../web/src/features/dnd/DndDropOverlay.tsx | 4 +- .../web/src/features/dnd/DndDropTarget.tsx | 79 +- .../web/src/features/dnd/DndImage.tsx | 6 +- .../src/features/dnd/DndListDropIndicator.tsx | 4 +- invokeai/frontend/web/src/features/dnd/dnd.ts | 795 ------------------ .../frontend/web/src/features/dnd/types.ts | 30 + .../web/src/features/dnd/useDndMonitor.ts | 43 +- .../frontend/web/src/features/dnd/util.ts | 46 + .../Boards/BoardsList/GalleryBoard.tsx | 8 +- .../Boards/BoardsList/NoBoardBoard.tsx | 8 +- .../components/ImageGrid/GalleryImage.tsx | 14 +- .../ImageViewer/ImageComparisonDroppable.tsx | 14 +- .../web/src/features/imageActions/actions.ts | 458 ++++++++++ .../inputs/ImageFieldInputComponent.tsx | 10 +- .../sidePanel/workflow/WorkflowLinearTab.tsx | 2 +- .../workflow/useLinearViewFieldDnd.ts | 23 +- .../UpscaleInitialImage.tsx | 8 +- 29 files changed, 726 insertions(+), 923 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/dnd/dnd.ts create mode 100644 invokeai/frontend/web/src/features/dnd/types.ts create mode 100644 invokeai/frontend/web/src/features/dnd/util.ts create mode 100644 invokeai/frontend/web/src/features/imageActions/actions.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index 38b32243015..7ccf49d9abb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -1,16 +1,17 @@ import { Grid, GridItem } from '@invoke-ai/ui-library'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { newCanvasEntityFromImageActionApi } from 'features/imageActions/actions'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -const addRasterLayerFromImageDndTargetData = Dnd.Target.newRasterLayerFromImage.getData(); -const addControlLayerFromImageDndTargetData = Dnd.Target.newControlLayerFromImage.getData(); -const addRegionalGuidanceReferenceImageFromImageDndTargetData = - Dnd.Target.newRegionalGuidanceReferenceImageFromImage.getData(); -const addGlobalReferenceImageFromImageDndTargetData = Dnd.Target.newGlobalReferenceImageFromImage.getData(); +const addRasterLayerFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ type: 'raster_layer' }); +const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ type: 'control_layer' }); +const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ + type: 'regional_guidance_with_reference_image', +}); +const addGlobalReferenceImageFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ type: 'reference_image' }); export const CanvasDropArea = memo(() => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx index a94bc6ff3c1..bffbfb4ba9f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx @@ -16,7 +16,7 @@ import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTi import { entitiesReordered } from 'features/controlLayers/store/canvasSlice'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { isRenderableEntityType } from 'features/controlLayers/store/types'; -import { triggerPostMoveFlash } from 'features/dnd/dnd'; +import { triggerPostMoveFlash } from 'features/dnd/util'; import type { PropsWithChildren } from 'react'; import { memo, useEffect } from 'react'; import { flushSync } from 'react-dom'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts index d486e53e55f..5b13be700a3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts @@ -2,18 +2,26 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import type { DndListState } from 'features/dnd/dnd'; -import { buildDndSourceApi, idle } from 'features/dnd/dnd'; +import { type DndListTargetState, idle } from 'features/dnd/types'; +import type { ActionData, ActionSourceApi } from 'features/imageActions/actions'; +import { buildGetData, buildTypeAndKey, buildTypeGuard } from 'features/imageActions/actions'; import type { RefObject } from 'react'; import { useEffect, useState } from 'react'; -/** - * Dnd source API for a single canvas entity. - */ -export const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity'); +const _singleCanvasEntity = buildTypeAndKey('single-canvas-entity'); +type SingleCanvasEntitySourceData = ActionData< + typeof _singleCanvasEntity.type, + typeof _singleCanvasEntity.key, + { entityIdentifier: CanvasEntityIdentifier } +>; +export const singleCanvasEntity: ActionSourceApi = { + ..._singleCanvasEntity, + typeGuard: buildTypeGuard(_singleCanvasEntity.key), + getData: buildGetData(_singleCanvasEntity.key, _singleCanvasEntity.type), +}; export const useCanvasEntityListDnd = (ref: RefObject, entityIdentifier: CanvasEntityIdentifier) => { - const [dndListState, setDndListState] = useState(idle); + const [dndListState, setDndListState] = useState(idle); const [isDragging, setIsDragging] = useState(false); useEffect(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx index b66c0d8367c..91931980659 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -109,7 +109,9 @@ export const CanvasMainPanelContent = memo(() => { - + + + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index 9ce939d0114..86220bd9831 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -6,10 +6,11 @@ import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHook import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; +import type { DndTargetState } from 'features/dnd/types'; import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { multipleImageSourceApi, singleImageSourceApi } from 'features/imageActions/actions'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; @@ -84,8 +85,8 @@ const PanelTabs = memo(() => { const { t } = useTranslation(); const store = useAppStore(); const activeEntityCount = useAppSelector(selectEntityCountActive); - const [layersTabDndState, setLayersTabDndState] = useState('idle'); - const [galleryTabDndState, setGalleryTabDndState] = useState('idle'); + const [layersTabDndState, setLayersTabDndState] = useState('idle'); + const [galleryTabDndState, setGalleryTabDndState] = useState('idle'); const layersTabRef = useRef(null); const galleryTabRef = useRef(null); const timeoutRef = useRef(null); @@ -208,7 +209,7 @@ const PanelTabs = memo(() => { }), monitorForElements({ canMonitor: ({ source }) => { - if (!Dnd.Source.singleImage.typeGuard(source.data) || !Dnd.Source.multipleImage.typeGuard(source.data)) { + if (!singleImageSourceApi.typeGuard(source.data) || !multipleImageSourceApi.typeGuard(source.data)) { return false; } // Only monitor if we are not already on the gallery tab diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index 51be4c55083..d45dc7402bf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -11,8 +11,9 @@ import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityA import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import type { ReplaceCanvasEntityObjectsWithImageActionData} from 'features/imageActions/actions'; +import {replaceCanvasEntityObjectsWithImageActionApi } from 'features/imageActions/actions'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,8 +28,8 @@ export const ControlLayer = memo(({ id }: Props) => { () => ({ id, type: 'control_layer' }), [id] ); - const targetData = useMemo( - () => Dnd.Target.replaceLayerWithImage.getData({ entityIdentifier }, entityIdentifier.id), + const targetData = useMemo( + () => replaceCanvasEntityObjectsWithImageActionApi.getData({ entityIdentifier }, entityIdentifier.id), [entityIdentifier] ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx index 4105d876507..3875e3ae58c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx @@ -3,10 +3,10 @@ import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import type { ImageWithDims } from 'features/controlLayers/store/types'; -import type { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; +import type { SetGlobalReferenceImageActionData, SetRegionalGuidanceReferenceImageActionData } from 'features/imageActions/actions'; import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; @@ -31,7 +31,7 @@ const sx = { type Props = { image: ImageWithDims | null; onChangeImage: (imageDTO: ImageDTO | null) => void; - targetData: Dnd.types['TargetDataUnion']; + targetData: SetGlobalReferenceImageActionData | SetRegionalGuidanceReferenceImageActionData; }; export const IPAdapterImagePreview = memo(({ image, onChangeImage, targetData }: Props) => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx index b8c434edf28..39aa6979604 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -19,7 +19,8 @@ import { import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import { Dnd } from 'features/dnd/dnd'; +import type { SetGlobalReferenceImageActionData} from 'features/imageActions/actions'; +import {setGlobalReferenceImageActionApi } from 'features/imageActions/actions'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold } from 'react-icons/pi'; @@ -80,8 +81,8 @@ export const IPAdapterSettings = memo(() => { [dispatch, entityIdentifier] ); - const targetData = useMemo( - () => Dnd.Target.setGlobalReferenceImage.getData({ entityIdentifier }, ipAdapter.image?.image_name), + const targetData = useMemo( + () => setGlobalReferenceImageActionApi.getData({ entityIdentifier }, ipAdapter.image?.image_name), [entityIdentifier, ipAdapter.image?.image_name] ); const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 01b7698bd8d..1b6a17328f2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -8,8 +8,9 @@ import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAd import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import type { ReplaceCanvasEntityObjectsWithImageActionData} from 'features/imageActions/actions'; +import {replaceCanvasEntityObjectsWithImageActionApi } from 'features/imageActions/actions'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -21,8 +22,8 @@ export const RasterLayer = memo(({ id }: Props) => { const { t } = useTranslation(); const isBusy = useCanvasIsBusy(); const entityIdentifier = useMemo>(() => ({ id, type: 'raster_layer' }), [id]); - const targetData = useMemo( - () => Dnd.Target.replaceLayerWithImage.getData({ entityIdentifier }, entityIdentifier.id), + const targetData = useMemo( + () => replaceCanvasEntityObjectsWithImageActionApi.getData({ entityIdentifier }, entityIdentifier.id), [entityIdentifier] ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index 4b630bc0e94..366215ca1ac 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -20,7 +20,8 @@ import { } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import { Dnd } from 'features/dnd/dnd'; +import type { SetRegionalGuidanceReferenceImageActionData} from 'features/imageActions/actions'; +import {setRegionalGuidanceReferenceImageActionApi } from 'features/imageActions/actions'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi'; @@ -91,12 +92,9 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro [dispatch, entityIdentifier, referenceImageId] ); - const targetData = useMemo( + const targetData = useMemo( () => - Dnd.Target.setRegionalGuidanceReferenceImage.getData( - { entityIdentifier, referenceImageId }, - ipAdapter.image?.image_name - ), + setRegionalGuidanceReferenceImageActionApi.getData({ entityIdentifier, referenceImageId }, ipAdapter.image?.image_name), [entityIdentifier, ipAdapter.image?.image_name, referenceImageId] ); diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx index 6c14ea4539e..f2ed0cc6379 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx @@ -1,8 +1,8 @@ import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import { Flex, Heading } from '@invoke-ai/ui-library'; -import type { Dnd } from 'features/dnd/dnd'; -import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/dnd'; +import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util'; +import type { MultipleImageSourceData } from 'features/imageActions/actions'; import { memo } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; @@ -39,7 +39,7 @@ export const createMultipleImageDragPreview = (arg: DndDragPreviewMultipleImageS createPortal(, arg.container); type SetMultipleDragPreviewArg = { - multipleImageDndData: Dnd.types['SourceDataTypeMap']['multipleImage']; + multipleImageDndData: MultipleImageSourceData; setDragPreviewState: (dragPreviewState: DndDragPreviewMultipleImageState | null) => void; onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>; }; diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx index 9777c9d4de4..b00c601bd9a 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx @@ -1,8 +1,8 @@ import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import { chakra, Flex } from '@invoke-ai/ui-library'; -import type { Dnd } from 'features/dnd/dnd'; -import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/dnd'; +import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util'; +import type { SingleImageSourceData } from 'features/imageActions/actions'; import { memo } from 'react'; import { createPortal } from 'react-dom'; import type { ImageDTO } from 'services/api/types'; @@ -37,7 +37,7 @@ export const createSingleImageDragPreview = (arg: DndDragPreviewSingleImageState createPortal(, arg.container); type SetSingleDragPreviewArg = { - singleImageDndData: Dnd.types['SourceDataTypeMap']['singleImage']; + singleImageDndData: SingleImageSourceData; setDragPreviewState: (dragPreviewState: DndDragPreviewSingleImageState | null) => void; onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>; }; diff --git a/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx b/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx index 1c87d7b15ef..025f2b5703d 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropOverlay.tsx @@ -1,10 +1,10 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex, Text } from '@invoke-ai/ui-library'; -import type { Dnd } from 'features/dnd/dnd'; +import type { DndTargetState } from 'features/dnd/types'; import { memo } from 'react'; type Props = { - dndState: Dnd.types['DndState']; + dndState: DndTargetState; label?: string; withBackdrop?: boolean; }; diff --git a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx index 0c02c5071ea..4c7c643a501 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx @@ -5,9 +5,17 @@ import { containsFiles, getFiles } from '@atlaskit/pragmatic-drag-and-drop/exter import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box } from '@invoke-ai/ui-library'; +import { getStore } from 'app/store/nanostores/store'; import { useAppDispatch } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; +import type { DndTargetState } from 'features/dnd/types'; +import type { MultipleImageAction, RecordUnknown, SingleImageAction } from 'features/imageActions/actions'; +import { + multipleImageActions, + multipleImageSourceApi, + singleImageActions, + singleImageSourceApi, +} from 'features/imageActions/actions'; import { memo, useEffect, useRef, useState } from 'react'; import { uploadImage } from 'services/api/endpoints/images'; import { z } from 'zod'; @@ -58,7 +66,7 @@ const zUploadFile = z ); type Props = { - targetData: Dnd.types['TargetDataUnion']; + targetData: SingleImageAction | MultipleImageAction; label: string; externalLabel?: string; isDisabled?: boolean; @@ -66,31 +74,59 @@ type Props = { export const DndDropTarget = memo((props: Props) => { const { targetData, label, externalLabel = label, isDisabled } = props; - const [dndState, setDndState] = useState('idle'); + const [dndState, setDndState] = useState('idle'); const [dndOrigin, setDndOrigin] = useState<'element' | 'external' | null>(null); const ref = useRef(null); const dispatch = useAppDispatch(); useEffect(() => { - if (!ref.current) { + const element = ref.current; + if (!element) { return; } if (isDisabled) { return; } + const { dispatch, getState } = getStore(); + + const isValidDrop = (sourceData: RecordUnknown, targetData: RecordUnknown) => { + if (singleImageSourceApi.typeGuard(sourceData)) { + for (const target of singleImageActions) { + if (target.typeGuard(targetData)) { + // TS cannot infer `targetData` but we've just checked it. This is safe. + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + if (target.isValid(sourceData, targetData as any, dispatch, getState)) { + return true; + } + } + } + } + + if (multipleImageSourceApi.typeGuard(sourceData)) { + for (const target of multipleImageActions) { + if (target.typeGuard(targetData)) { + // TS cannot infer `targetData` but we've just checked it. This is safe. + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + if (target.isValid(sourceData, targetData as any, dispatch, getState)) { + return true; + } + } + } + } + + return false; + }; + return combine( dropTargetForElements({ - element: ref.current, - canDrop: (args) => { - const sourceData = args.source.data; - if (!Dnd.Util.isDndSourceData(sourceData)) { - return false; - } - if (Dnd.Util.getDndId(targetData) === Dnd.Util.getDndId(sourceData)) { + element, + canDrop: ({ source }) => { + const sourceData = source.data; + if (sourceData.id === targetData.id) { return false; } - return Dnd.Util.isValidDrop(sourceData, targetData); + return isValidDrop(sourceData, targetData); }, onDragEnter: () => { setDndState('over'); @@ -101,15 +137,12 @@ export const DndDropTarget = memo((props: Props) => { getData: () => targetData, }), monitorForElements({ - canMonitor: (args) => { - const sourceData = args.source.data; - if (!Dnd.Util.isDndSourceData(sourceData)) { + canMonitor: ({ source }) => { + const sourceData = source.data; + if (sourceData.id === targetData.id) { return false; } - if (Dnd.Util.getDndId(targetData) === Dnd.Util.getDndId(sourceData)) { - return false; - } - return Dnd.Util.isValidDrop(sourceData, targetData); + return isValidDrop(sourceData, targetData); }, onDragStart: () => { setDndOrigin('element'); @@ -124,17 +157,17 @@ export const DndDropTarget = memo((props: Props) => { }, [targetData, dispatch, isDisabled]); useEffect(() => { - if (!ref.current) { + const element = ref.current; + if (!element) { return; } - if (isDisabled) { return; } return combine( dropTargetForExternal({ - element: ref.current, + element, canDrop: (args) => { if (!containsFiles(args)) { return false; @@ -162,7 +195,7 @@ export const DndDropTarget = memo((props: Props) => { image_category: 'user', is_intermediate: false, }); - Dnd.Util.handleDrop(Dnd.Source.singleImage.getData({ imageDTO }), targetData); + // Dnd.Util.handleDrop(Dnd.Source.singleImage.getData({ imageDTO }), targetData); } }, }), diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index 0438736f681..07eab6897a5 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -2,10 +2,10 @@ import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Image } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; -import { Dnd } from 'features/dnd/dnd'; import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { singleImageSourceApi } from 'features/imageActions/actions'; import { memo, useEffect, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -36,7 +36,7 @@ export const DndImage = memo(({ imageDTO, ...rest }: Props) => { } return draggable({ element, - getInitialData: () => Dnd.Source.singleImage.getData({ imageDTO }, imageDTO.image_name), + getInitialData: () => singleImageSourceApi.getData({ imageDTO }, imageDTO.image_name), onDragStart: () => { setIsDragging(true); }, @@ -44,7 +44,7 @@ export const DndImage = memo(({ imageDTO, ...rest }: Props) => { setIsDragging(false); }, onGenerateDragPreview: (args) => { - if (Dnd.Source.singleImage.typeGuard(args.source.data)) { + if (singleImageSourceApi.typeGuard(args.source.data)) { setSingleImageDragPreview({ singleImageDndData: args.source.data, onGenerateDragPreviewArgs: args, diff --git a/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx index 96590b4f86c..7c13d1d2bc5 100644 --- a/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx @@ -2,7 +2,7 @@ import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types'; import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box } from '@invoke-ai/ui-library'; -import type { DndListState } from 'features/dnd/dnd'; +import type { DndListTargetState } from 'features/dnd/types'; /** * Design decisions for the drop indicator's main line @@ -104,7 +104,7 @@ function DndDropIndicatorInternal({ edge, gap = '0px' }: DropIndicatorProps) { ); } -export const DndListDropIndicator = ({ dndState }: { dndState: DndListState }) => { +export const DndListDropIndicator = ({ dndState }: { dndState: DndListTargetState }) => { if (dndState.type !== 'is-dragging-over') { return null; } diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts deleted file mode 100644 index bfcbe4efb70..00000000000 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ /dev/null @@ -1,795 +0,0 @@ -/* eslint-disable @typescript-eslint/no-namespace */ // We will use namespaces to organize the Dnd types - -import type { Input } from '@atlaskit/pragmatic-drag-and-drop/dist/types/entry-point/types'; -import type { GetOffsetFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/public-utils/element/custom-native-drag-preview/types'; -import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/closest-edge'; -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { getStore } from 'app/store/nanostores/store'; -import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks'; -import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { - controlLayerAdded, - entityRasterized, - inpaintMaskAdded, - rasterLayerAdded, - referenceImageAdded, - referenceImageIPAdapterImageChanged, - rgAdded, - rgIPAdapterImageChanged, -} from 'features/controlLayers/store/canvasSlice'; -import { selectBboxRect } from 'features/controlLayers/store/selectors'; -import type { - CanvasControlLayerState, - CanvasEntityIdentifier, - CanvasInpaintMaskState, - CanvasRasterLayerState, - CanvasReferenceImageState, - CanvasRegionalGuidanceState, -} from 'features/controlLayers/store/types'; -import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; -import type { BoardId } from 'features/gallery/store/types'; -import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; -import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; -import type { CSSProperties } from 'react'; -import { imagesApi } from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; -import type { ValueOf } from 'type-fest'; -import type { Jsonifiable } from 'type-fest/source/jsonifiable'; - -/** - * This file contains types, APIs, and utilities for Dnd functionality, as provided by pragmatic-drag-and-drop: - * - Source and target data types - * - Builders for source and target data types, which create type guards, data-getters and validation functions - * - Other utilities for working with Dnd data - * - A function to validate whether a drop is valid, given the source and target data - * - * See: - * - https://github.com/atlassian/pragmatic-drag-and-drop - * - https://atlassian.design/components/pragmatic-drag-and-drop/about - */ - -/** - * A type for unknown Dnd data. `pragmatic-drag-and-drop` types all data as this type. - */ -type UnknownDndData = Record; - -/** - * A Dnd kind, which can be either a source or a target. - */ -type DndKind = 'source' | 'target'; - -/** - * Data for a given Dnd source or target, which contains metadata and payload. - * @template T The type string of the Dnd data. This should be unique for each type of Dnd data. - * @template K The kind of the Dnd data ('source' or 'target'). - * @template P The optional payload of the Dnd data. This can be any "Jsonifiable" data - that is, data that can be - * serialized to JSON. This ensures the data can be safely stored in Redux, logged, etc. - */ -type DndData< - T extends string = string, - K extends DndKind = DndKind, - P extends Jsonifiable | undefined = Jsonifiable | undefined, -> = { - /** - * Metadata about the DndData. - */ - meta: { - /** - * An identifier for this data. This may or may not be unique. This is primarily used to prevent a source from - * dropping on itself. - * - * A consumer may be both a Dnd source and target of the same type. For example, the upscaling initial image is - * a Dnd target and may contain an image, which is itself a Dnd source. In this case, the Dnd ID is used to prevent - * the upscaling initial image (and other instances of that same image) from being dropped onto itself. - * - * This is accomplished by checking the Dnd ID of the source against the Dnd ID of the target. If they match, the - * drop is rejected. - */ - id: string; - /** - * The type of the DndData. - */ - type: T; - /** - * The kind of the DndData (source or target). - */ - kind: K; - }; - /** - * The arbitrarily-shaped payload of the DndData. - */ - payload: P; -}; - -/** - * Builds a type guard for a specific DndData type. - * @template T The Dnd data type. - * @param type The type of the Dnd source or target data. - * @param kind The kind of the Dnd source or target data. - * @returns A type guard for the Dnd data. - */ -const _buildDataTypeGuard = (type: T['meta']['type'], kind: T['meta']['kind']) => { - // pragmatic-drag-and-drop types all data as unknown, so we need to cast it to the expected type - return (data: UnknownDndData): data is T => { - try { - return (data as DndData).meta.type === type && (data as DndData).meta.kind === kind; - } catch { - return false; - } - }; -}; - -/** - * Builds a getter for a specific DndData type. - * - * The getter accepts arbitrary data and an optional Dnd ID. If no Dnd ID is provided, a unique one is generated. - * - * @template T The Dnd data type. - * @param type The type of the Dnd source or target data. - * @param kind The kind of the Dnd source or target data. - * @returns A getter for the DndData type. - */ -const _buildDataGetter = - (type: T['meta']['type'], kind: T['meta']['kind']) => - (payload: T['payload'] extends undefined ? void : T['payload'], dndId?: string | null): T => { - return { - meta: { - id: dndId ?? getPrefixedId(`dnd-${kind}-${type}`), - type, - kind, - }, - payload, - } as T; - }; - -/** - * The API for a Dnd source. - */ -type DndSourceAPI = { - /** - * The type of the Dnd source. - */ - type: string; - /** - * The kind of the Dnd source. It is always 'source'. - */ - kind: 'source'; - /** - * A type guard for the DndData type. - * @param data The data to check. - * @returns Whether the data is of the DndData type. - */ - typeGuard: ReturnType>; - /** - * Gets a typed DndData object for the parent type. - * @param payload The payload for this DndData. - * @param dndId The Dnd ID to use. If not provided, a unique one is generated. - * @returns The DndData. - */ - getData: ReturnType>; -}; - -/** - * Builds a Dnd source API. - * @template P The optional payload of the Dnd source. - * @param type The type of the Dnd source. - */ -export const buildDndSourceApi =

(type: string) => { - return { - type, - kind: 'source', - typeGuard: _buildDataTypeGuard>(type, 'source'), - getData: _buildDataGetter>(type, 'source'), - } satisfies DndSourceAPI>; -}; - -//#region DndSourceData -/** - * Dnd source API for single image source. - */ -const singleImage = buildDndSourceApi<{ imageDTO: ImageDTO }>('SingleImage'); -/** - * Dnd source API for multiple image source. - */ -const multipleImage = buildDndSourceApi<{ imageDTOs: ImageDTO[]; boardId: BoardId }>('MultipleImage'); - -const DndSource = { - singleImage, - multipleImage, -} as const; - -type SourceDataTypeMap = { - [K in keyof typeof DndSource]: ReturnType<(typeof DndSource)[K]['getData']>; -}; - -/** - * A union of all possible DndSourceData types. - */ -type SourceDataUnion = ValueOf; -//#endregion - -//#region DndTargetData -/** - * The API for a Dnd target. - */ -type DndTargetApi = DndSourceAPI & { - /** - * Validates whether a drop is valid, give the source and target data. - * @param sourceData The source data (i.e. the data being dragged) - * @param targetData The target data (i.e. the data being dragged onto) - * @returns Whether the drop is valid. - */ - validateDrop: (sourceData: DndData, targetData: T) => boolean; - handleDrop: (sourceData: DndData, targetData: T) => void; -}; - -/** - * Builds a Dnd target API. - * @template P The optional payload of the Dnd target. - * @param type The type of the Dnd target. - * @param validateDrop A function that validates whether a drop is valid. - */ -const buildDndTargetApi =

( - type: string, - validateDrop: ( - sourceData: DndData, - targetData: DndData - ) => boolean, - handleDrop: ( - sourceData: DndData, - targetData: DndData - ) => void -) => { - return { - type, - kind: 'source', - typeGuard: _buildDataTypeGuard>(type, 'target'), - getData: _buildDataGetter>(type, 'target'), - validateDrop, - handleDrop, - } satisfies DndTargetApi>; -}; - -/** - * Dnd target API for setting the image on an existing Global Reference Image layer. - */ -const setGlobalReferenceImage = buildDndTargetApi<{ entityIdentifier: CanvasEntityIdentifier<'reference_image'> }>( - 'SetGlobalReferenceImage', - singleImage.typeGuard, - (sourceData, targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch } = getStore(); - const { imageDTO } = sourceData.payload; - const { entityIdentifier } = targetData.payload; - dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO })); - } -); - -/** - * Dnd target API for setting the image on an existing Regional Guidance layer's Reference Image. - */ -const setRegionalGuidanceReferenceImage = buildDndTargetApi<{ - entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>; - referenceImageId: string; -}>('SetRegionalGuidanceReferenceImage', singleImage.typeGuard, (sourceData, targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch } = getStore(); - const { imageDTO } = sourceData.payload; - const { entityIdentifier, referenceImageId } = targetData.payload; - dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO })); -}); - -/** - * Dnd target API for creating a new a Raster Layer from an image. - */ -const newRasterLayerFromImage = buildDndTargetApi( - 'NewRasterLayerFromImage', - singleImage.typeGuard, - (sourceData, _targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch, getState } = getStore(); - const { imageDTO } = sourceData.payload; - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectBboxRect(getState()); - const overrides: Partial = { - objects: [imageObject], - position: { x, y }, - }; - dispatch(rasterLayerAdded({ overrides, isSelected: true })); - } -); - -/** - * Dnd target API for creating a new a Control Layer from an image. - */ -const newControlLayerFromImage = buildDndTargetApi( - 'NewControlLayerFromImage', - singleImage.typeGuard, - (sourceData, _targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { imageDTO } = sourceData.payload; - const { dispatch, getState } = getStore(); - const state = getState(); - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectBboxRect(state); - const controlAdapter = selectDefaultControlAdapter(state); - const overrides: Partial = { - objects: [imageObject], - position: { x, y }, - controlAdapter, - }; - dispatch(controlLayerAdded({ overrides, isSelected: true })); - } -); - -/** - * Dnd target API for adding an Inpaint Mask from an image. - */ -const newInpaintMaskFromImage = buildDndTargetApi( - 'NewInpaintMaskFromImage', - singleImage.typeGuard, - (sourceData, _targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch, getState } = getStore(); - const { imageDTO } = sourceData.payload; - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectBboxRect(getState()); - const overrides: Partial = { - objects: [imageObject], - position: { x, y }, - }; - dispatch(inpaintMaskAdded({ overrides, isSelected: true })); - } -); - -/** - * Dnd target API for adding a new Global Reference Image layer with a pre-set Reference Image from an image. - */ -const newGlobalReferenceImageFromImage = buildDndTargetApi( - 'NewGlobalReferenceImageFromImage', - singleImage.typeGuard, - (sourceData, _targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch, getState } = getStore(); - const { imageDTO } = sourceData.payload; - const ipAdapter = selectDefaultIPAdapter(getState()); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); - const overrides: Partial = { ipAdapter }; - dispatch(referenceImageAdded({ overrides, isSelected: true })); - } -); - -/** - * Dnd target API for adding a new Regional Guidance layer from an image. - */ -const newRegionalGuidanceFromImage = buildDndTargetApi( - 'NewRegionalGuidanceFromImage', - singleImage.typeGuard, - (sourceData, _targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch, getState } = getStore(); - const { imageDTO } = sourceData.payload; - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectBboxRect(getState()); - const overrides: Partial = { - objects: [imageObject], - position: { x, y }, - }; - dispatch(rgAdded({ overrides, isSelected: true })); - } -); - -/** - * Dnd target API for adding a new Regional Guidance layer with a pre-set Reference Image from an image. - */ -const newRegionalGuidanceReferenceImageFromImage = buildDndTargetApi( - 'NewRegionalGuidanceReferenceImageFromImage', - singleImage.typeGuard, - (sourceData, _targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch, getState } = getStore(); - const { imageDTO } = sourceData.payload; - const ipAdapter = selectDefaultIPAdapter(getState()); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); - const overrides: Partial = { - referenceImages: [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }], - }; - dispatch(rgAdded({ overrides, isSelected: true })); - } -); - -/** - * Dnd target API for replacing the content of a layer with an image. This works for Control Layers, Raster Layers, - * Inpaint Masks, and Regional Guidance layers. - */ -const replaceLayerWithImage = buildDndTargetApi<{ - entityIdentifier: CanvasEntityIdentifier<'control_layer' | 'raster_layer' | 'inpaint_mask' | 'regional_guidance'>; -}>('ReplaceLayerWithImage', singleImage.typeGuard, (sourceData, targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch, getState } = getStore(); - const { imageDTO } = sourceData.payload; - const { entityIdentifier } = targetData.payload; - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectBboxRect(getState()); - dispatch( - entityRasterized({ - entityIdentifier, - imageObject, - position: { x, y }, - replaceObjects: true, - isSelected: true, - }) - ); -}); - -/** - * Dnd target API for setting the initial image on the upscaling tab. - */ -const setUpscaleInitialImageFromImage = buildDndTargetApi( - 'SetUpscaleInitialImageFromImage', - singleImage.typeGuard, - (sourceData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch } = getStore(); - const { imageDTO } = sourceData.payload; - dispatch(upscaleInitialImageChanged(imageDTO)); - } -); - -/** - * Dnd target API for setting an image field on a node. - */ -const setNodeImageField = buildDndTargetApi<{ nodeId: string; fieldName: string }>( - 'SetNodeImageField', - singleImage.typeGuard, - (sourceData, targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch } = getStore(); - const { imageDTO } = sourceData.payload; - const { fieldName, nodeId } = targetData.payload; - dispatch(fieldImageValueChanged({ nodeId, fieldName, value: imageDTO })); - } -); - -/** - * Dnd target API for selecting images for comparison. - */ -const selectForCompare = buildDndTargetApi<{ - firstImageName?: string | null; - secondImageName?: string | null; -}>( - 'SelectForCompare', - (sourceData, targetData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - // Do not allow the same images to be selected for comparison - if (sourceData.payload.imageDTO.image_name === targetData.payload.firstImageName) { - return false; - } - if (sourceData.payload.imageDTO.image_name === targetData.payload.secondImageName) { - return false; - } - return true; - }, - (sourceData) => { - if (!singleImage.typeGuard(sourceData)) { - return false; - } - const { dispatch } = getStore(); - const { imageDTO } = sourceData.payload; - dispatch(imageToCompareChanged(imageDTO)); - } -); - -/** - * Dnd target API for adding an image to a board. - */ -const addToBoard = buildDndTargetApi<{ boardId: string }>( - 'AddToBoard', - (sourceData, targetData) => { - if (singleImage.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; - const destinationBoard = targetData.payload.boardId; - return currentBoard !== destinationBoard; - } - - if (multipleImage.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.boardId; - const destinationBoard = targetData.payload.boardId; - return currentBoard !== destinationBoard; - } - - return false; - }, - (sourceData, targetData) => { - if (singleImage.typeGuard(sourceData)) { - const { dispatch } = getStore(); - const { imageDTO } = sourceData.payload; - const { boardId } = targetData.payload; - dispatch(imagesApi.endpoints.addImageToBoard.initiate({ imageDTO, board_id: boardId }, { track: false })); - dispatch(selectionChanged([])); - } - - if (multipleImage.typeGuard(sourceData)) { - const { dispatch } = getStore(); - const { imageDTOs } = sourceData.payload; - const { boardId } = targetData.payload; - dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false })); - dispatch(selectionChanged([])); - } - } -); - -/** - * Dnd target API for removing an image from a board. - */ -const removeFromBoard = buildDndTargetApi( - 'RemoveFromBoard', - (sourceData) => { - if (singleImage.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; - return currentBoard !== 'none'; - } - - if (multipleImage.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.boardId; - return currentBoard !== 'none'; - } - - return false; - }, - (sourceData) => { - if (singleImage.typeGuard(sourceData)) { - const { dispatch } = getStore(); - const { imageDTO } = sourceData.payload; - dispatch(imagesApi.endpoints.removeImageFromBoard.initiate({ imageDTO }, { track: false })); - dispatch(selectionChanged([])); - } - - if (multipleImage.typeGuard(sourceData)) { - const { dispatch } = getStore(); - const { imageDTOs } = sourceData.payload; - dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false })); - dispatch(selectionChanged([])); - } - } -); - -const DndTarget = { - /** - * Set the image on an existing Global Reference Image layer. - */ - setGlobalReferenceImage, - setRegionalGuidanceReferenceImage, - // Add layer from image - newRasterLayerFromImage, - newControlLayerFromImage, - // Add a layer w/ ref image preset - newGlobalReferenceImageFromImage, - newRegionalGuidanceReferenceImageFromImage, - // Replace layer content w/ image - replaceLayerWithImage, - // Set the upscale image - setUpscaleInitialImageFromImage, - // Set a field on a node - setNodeImageField, - // Select images for comparison - selectForCompare, - // Add an image to a board - addToBoard, - // Remove an image from a board - essentially add to Uncategorized - removeFromBoard, - // These are currently unused - newRegionalGuidanceFromImage, - newInpaintMaskFromImage, -} as const; - -type TargetDataTypeMap = { - [K in keyof typeof DndTarget]: ReturnType<(typeof DndTarget)[K]['getData']>; -}; - -type TargetDataUnion = ValueOf; - -const targetApisArray = Object.values(DndTarget); - -//#endregion - -/** - * The Dnd namespace, providing types and APIs for Dnd functionality. - */ -export declare namespace Dnd { - export type types = { - /** - * A union of all Dnd states. - * - `idle`: No drag is occurring, or the drag is not valid for the current drop target. - * - `potential`: A drag is occurring, and the drag is valid for the current drop target, but the drag is not over the - * drop target. - * - `over`: A drag is occurring, and the drag is valid for the current drop target, and the drag is over the drop target. - */ - DndState: 'idle' | 'potential' | 'over'; - /** - * A Dnd kind, which can be either a source or a target. - */ - DndKind: DndKind; - /** - * A type for unknown Dnd data. `pragmatic-drag-and-drop` types all data as this type. - */ - UnknownDndData: UnknownDndData; - /** - * A map of target APIs to their data types. - */ - SourceDataTypeMap: SourceDataTypeMap; - /** - * A union of all possible source data types. - */ - SourceDataUnion: SourceDataUnion; - /** - * A map of target APIs to their data types. - */ - TargetDataTypeMap: TargetDataTypeMap; - /** - * A union of all possible target data types. - */ - TargetDataUnion: TargetDataUnion; - }; -} - -export const Dnd = { - Source: DndSource, - Target: DndTarget, - Util: { - /** - * Gets the Dnd ID from a DndData object. - * @param data The DndData object. - * @returns The Dnd ID. - */ - getDndId: (data: DndData): string => { - return data.meta.id; - }, - /** - * Checks if the data is a Dnd source data object. - * @param data The data to check. - */ - isDndSourceData: (data: UnknownDndData): data is SourceDataUnion => { - try { - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - return (data as DndData).meta.kind === 'source'; - } catch { - return false; - } - }, - /** - * Checks if the data is a Dnd target data object. - * @param data The data to check. - */ - isDndTargetData: (data: UnknownDndData): data is TargetDataUnion => { - try { - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - return (data as DndData).meta.kind === 'target'; - } catch { - return false; - } - }, - /** - * Validates whether a drop is valid. - * @param sourceData The data being dragged. - * @param targetData The data of the target being dragged onto. - * @returns Whether the drop is valid. - */ - isValidDrop: (sourceData: SourceDataUnion, targetData: TargetDataUnion): boolean => { - for (const targetApi of targetApisArray) { - if (targetApi.typeGuard(targetData)) { - /** - * TS cannot narrow the type of the targetApi and will error in the validator call. - * We've just checked that targetData is of the right type, though, so this cast to `any` is safe. - */ - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - return targetApi.validateDrop(sourceData, targetData as any); - } - } - return false; - }, - /** - * Validates whether a drop is valid. - * @param sourceData The data being dragged. - * @param targetData The data of the target being dragged onto. - * @returns Whether the drop is valid. - */ - handleDrop: (sourceData: SourceDataUnion, targetData: TargetDataUnion): void => { - for (const targetApi of targetApisArray) { - if (targetApi.typeGuard(targetData)) { - /** - * TS cannot narrow the type of the targetApi and will error in the handleDrop call. - * We've just checked that targetData is of the right type, though, so this cast to `any` is safe. - */ - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - targetApi.handleDrop(sourceData, targetData as any); - return; - } - } - }, - }, -}; - -/** - * The size of the image drag preview in theme units. - */ -export const DND_IMAGE_DRAG_PREVIEW_SIZE = 32 satisfies SystemStyleObject['w']; - -/** - * A drag preview offset function that works like the provided `preserveOffsetOnSource`, except when either the X or Y - * offset is outside the container, in which case it centers the preview in the container. - */ -export function preserveOffsetOnSourceFallbackCentered({ - element, - input, -}: { - element: HTMLElement; - input: Input; -}): GetOffsetFn { - return ({ container }) => { - const sourceRect = element.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - - let offsetX = input.clientX - sourceRect.x; - let offsetY = input.clientY - sourceRect.y; - - if (offsetY > containerRect.height || offsetX > containerRect.width) { - offsetX = containerRect.width / 2; - offsetY = containerRect.height / 2; - } - - return { x: offsetX, y: offsetY }; - }; -} - -// Based on https://github.com/atlassian/pragmatic-drag-and-drop/blob/main/packages/flourish/src/trigger-post-move-flash.tsx -// That package has a lot of extra deps so we just copied the function here -export function triggerPostMoveFlash(element: HTMLElement, backgroundColor: CSSProperties['backgroundColor']) { - element.animate([{ backgroundColor }, {}], { - duration: 700, - easing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)', - iterations: 1, - }); -} - -export type DndListState = - | { - type: 'idle'; - } - | { - type: 'preview'; - container: HTMLElement; - } - | { - type: 'is-dragging'; - } - | { - type: 'is-dragging-over'; - closestEdge: Edge | null; - }; -export const idle: DndListState = { type: 'idle' }; diff --git a/invokeai/frontend/web/src/features/dnd/types.ts b/invokeai/frontend/web/src/features/dnd/types.ts new file mode 100644 index 00000000000..0ede75650ad --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd/types.ts @@ -0,0 +1,30 @@ +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; + +/** + * States for a dnd target. + * - `idle`: No drag is occurring, or the drag is not valid for the current drop target. + * - `potential`: A drag is occurring, and the drag is valid for the current drop target, but the drag is not over the + * drop target. + * - `over`: A drag is occurring, and the drag is valid for the current drop target, and the drag is over the drop target. + */ +export type DndTargetState = 'idle' | 'potential' | 'over'; + +/** + * States for a dnd list. + */ +export type DndListTargetState = + | { + type: 'idle'; + } + | { + type: 'preview'; + container: HTMLElement; + } + | { + type: 'is-dragging'; + } + | { + type: 'is-dragging-over'; + closestEdge: Edge | null; + }; +export const idle: DndListTargetState = { type: 'idle' }; diff --git a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts index 75156700127..1a2a58d81e7 100644 --- a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts +++ b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts @@ -4,8 +4,10 @@ import { monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/a import { containsFiles } from '@atlaskit/pragmatic-drag-and-drop/external/file'; import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; import { logger } from 'app/logging/logger'; +import { getStore } from 'app/store/nanostores/store'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; -import { Dnd } from 'features/dnd/dnd'; +import { parseify } from 'common/util/serialize'; +import { multipleImageSourceApi, multipleImageActions, singleImageSourceApi, singleImageActions } from 'features/imageActions/actions'; import { useEffect } from 'react'; const log = logger('dnd'); @@ -20,7 +22,7 @@ export const useDndMonitor = () => { const sourceData = source.data; // Check for allowed sources - if (!Dnd.Source.singleImage.typeGuard(sourceData) && !Dnd.Source.multipleImage.typeGuard(sourceData)) { + if (!singleImageSourceApi.typeGuard(sourceData) && !multipleImageSourceApi.typeGuard(sourceData)) { return false; } @@ -35,19 +37,40 @@ export const useDndMonitor = () => { const sourceData = source.data; const targetData = target.data; + const { dispatch, getState } = getStore(); + // Check for allowed sources - if (!Dnd.Source.singleImage.typeGuard(sourceData) && !Dnd.Source.multipleImage.typeGuard(sourceData)) { - return; + if (singleImageSourceApi.typeGuard(sourceData)) { + for (const target of singleImageActions) { + if (target.typeGuard(targetData)) { + // TS cannot infer `targetData` but we've just checked it. This is safe. + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + if (target.isValid(sourceData, targetData as any, dispatch, getState)) { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + target.handler(sourceData, targetData as any, dispatch, getState); + log.debug(parseify({ sourceData, targetData }), 'Dropped single image'); + return; + } + } + } } - // Check for allowed targets - if (!Dnd.Util.isDndTargetData(targetData)) { - return; + if (multipleImageSourceApi.typeGuard(sourceData)) { + for (const target of multipleImageActions) { + if (target.typeGuard(targetData)) { + // TS cannot infer `targetData` but we've just checked it. This is safe. + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + if (target.isValid(sourceData, targetData as any, dispatch, getState)) { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + target.handler(sourceData, targetData as any, dispatch, getState); + log.debug(parseify({ sourceData, targetData }), 'Dropped multiple images'); + return; + } + } + } } - log.debug({ sourceData, targetData }, 'Dropped image'); - - Dnd.Util.handleDrop(sourceData, targetData); + log.warn(parseify({ sourceData, targetData }), 'Invalid image drop'); }, }), monitorForExternal({ diff --git a/invokeai/frontend/web/src/features/dnd/util.ts b/invokeai/frontend/web/src/features/dnd/util.ts new file mode 100644 index 00000000000..f20614ad773 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd/util.ts @@ -0,0 +1,46 @@ +import type { GetOffsetFn } from '@atlaskit/pragmatic-drag-and-drop/dist/types/public-utils/element/custom-native-drag-preview/types'; +import type { Input } from '@atlaskit/pragmatic-drag-and-drop/types'; +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import type { CSSProperties } from 'react'; + +/** + * The size of the image drag preview in theme units. + */ +export const DND_IMAGE_DRAG_PREVIEW_SIZE = 32 satisfies SystemStyleObject['w']; + +/** + * A drag preview offset function that works like the provided `preserveOffsetOnSource`, except when either the X or Y + * offset is outside the container, in which case it centers the preview in the container. + */ +export function preserveOffsetOnSourceFallbackCentered({ + element, + input, +}: { + element: HTMLElement; + input: Input; +}): GetOffsetFn { + return ({ container }) => { + const sourceRect = element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + let offsetX = input.clientX - sourceRect.x; + let offsetY = input.clientY - sourceRect.y; + + if (offsetY > containerRect.height || offsetX > containerRect.width) { + offsetX = containerRect.width / 2; + offsetY = containerRect.height / 2; + } + + return { x: offsetX, y: offsetY }; + }; +} + +// Based on https://github.com/atlassian/pragmatic-drag-and-drop/blob/main/packages/flourish/src/trigger-post-move-flash.tsx +// That package has a lot of extra deps so we just copied the function here +export function triggerPostMoveFlash(element: HTMLElement, backgroundColor: CSSProperties['backgroundColor']) { + element.animate([{ backgroundColor }, {}], { + duration: 700, + easing: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)', + iterations: 1, + }); +} diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 928d1fbe254..fcb834aa480 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -2,7 +2,6 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; @@ -14,6 +13,8 @@ import { selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; +import type { AddImageToBoardActionData} from 'features/imageActions/actions'; +import {addImageToBoardActionApi } from 'features/imageActions/actions'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArchiveBold, PiImageSquare } from 'react-icons/pi'; @@ -44,10 +45,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { } }, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]); - const targetData = useMemo( - () => Dnd.Target.addToBoard.getData({ boardId: board.board_id }), - [board.board_id] - ); + const targetData = useMemo(() => addImageToBoardActionApi.getData({ boardId: board.board_id }), [board.board_id]); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index b0b386c3aee..f434e90fb7a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -1,7 +1,6 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; @@ -12,6 +11,8 @@ import { selectBoardSearchText, } from 'features/gallery/store/gallerySelectors'; import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; +import type { RemoveImageFromBoardActionData} from 'features/imageActions/actions'; +import {removeImageFromBoardActionApi } from 'features/imageActions/actions'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; @@ -43,10 +44,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { } }, [dispatch, autoAssignBoardOnClick]); - const targetData = useMemo( - () => Dnd.Target.removeFromBoard.getData(), - [] - ); + const targetData = useMemo(() => removeImageFromBoardActionApi.getData(), []); const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 053c01b36bf..ba15a881b8b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -7,7 +7,6 @@ import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/lis import { useAppStore } from 'app/store/nanostores/store'; import { useAppSelector } from 'app/store/storeHooks'; import { useBoolean } from 'common/hooks/useBoolean'; -import { Dnd } from 'features/dnd/dnd'; import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage'; import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage'; import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; @@ -18,6 +17,7 @@ import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/SizedSkeletonLoader'; import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; +import { multipleImageSourceApi, singleImageSourceApi } from 'features/imageActions/actions'; import type { MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -122,32 +122,32 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { // When we have multiple images selected, and the dragged image is part of the selection, initiate a // multi-image drag. if (gallery.selection.length > 1 && gallery.selection.includes(imageDTO)) { - return Dnd.Source.multipleImage.getData({ + return multipleImageSourceApi.getData({ imageDTOs: gallery.selection, boardId: gallery.selectedBoardId, }); } // Otherwise, initiate a single-image drag - return Dnd.Source.singleImage.getData({ imageDTO }, imageDTO.image_name); + return singleImageSourceApi.getData({ imageDTO }, imageDTO.image_name); }, // This is a "local" drag start event, meaning that it is only called when this specific image is dragged. onDragStart: ({ source }) => { // When we start dragging a single image, set the dragging state to true. This is only called when this // specific image is dragged. - if (Dnd.Source.singleImage.typeGuard(source.data)) { + if (singleImageSourceApi.typeGuard(source.data)) { setIsDragging(true); return; } }, onGenerateDragPreview: (args) => { - if (Dnd.Source.multipleImage.typeGuard(args.source.data)) { + if (multipleImageSourceApi.typeGuard(args.source.data)) { setMultipleImageDragPreview({ multipleImageDndData: args.source.data, onGenerateDragPreviewArgs: args, setDragPreviewState, }); - } else if (Dnd.Source.singleImage.typeGuard(args.source.data)) { + } else if (singleImageSourceApi.typeGuard(args.source.data)) { setSingleImageDragPreview({ singleImageDndData: args.source.data, onGenerateDragPreviewArgs: args, @@ -161,7 +161,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { onDragStart: ({ source }) => { // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the // selection. This is called for all drag events. - if (Dnd.Source.multipleImage.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) { + if (multipleImageSourceApi.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) { setIsDragging(true); } }, 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 8a3de6f0093..741dfcd9e46 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparisonDroppable.tsx @@ -1,21 +1,11 @@ -import { useAppSelector } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { type SetComparisonImageActionData, setComparisonImageActionApi } from 'features/imageActions/actions'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { selectComparisonImages } from './common'; - export const ImageComparisonDroppable = memo(() => { const { t } = useTranslation(); - const comparisonImages = useAppSelector(selectComparisonImages); - const targetData = useMemo(() => { - const { firstImage, secondImage } = comparisonImages; - return Dnd.Target.selectForCompare.getData({ - firstImageName: firstImage?.image_name, - secondImageName: secondImage?.image_name, - }); - }, [comparisonImages]); + const targetData = useMemo(() => setComparisonImageActionApi.getData(), []); return ; }); 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..cc095688b27 --- /dev/null +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -0,0 +1,458 @@ +import type { AppDispatch, RootState } from 'app/store/store'; +import { selectDefaultControlAdapter, selectDefaultIPAdapter } from 'features/controlLayers/hooks/addLayerHooks'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { + controlLayerAdded, + entityRasterized, + inpaintMaskAdded, + rasterLayerAdded, + referenceImageAdded, + referenceImageIPAdapterImageChanged, + rgAdded, + rgIPAdapterImageChanged, +} from 'features/controlLayers/store/canvasSlice'; +import { selectBboxRect } from 'features/controlLayers/store/selectors'; +import type { + CanvasEntityIdentifier, + CanvasEntityType, + CanvasRenderableEntityIdentifier, +} from 'features/controlLayers/store/types'; +import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; +import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; +import type { BoardId } from 'features/gallery/store/types'; +import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; +import type { FieldIdentifier } from 'features/nodes/types/field'; +import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; +import { imagesApi } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; +import type { JsonObject } from 'type-fest'; + +export type RecordUnknown = Record; + +export type ActionData< + Type extends string = string, + PrivateKey extends symbol = symbol, + Payload extends JsonObject | void = JsonObject | void, +> = { + [key in PrivateKey]: true; +} & { + id: string; + type: Type; + payload: Payload; +}; + +export const buildTypeAndKey = (type: T) => { + const key = Symbol(type); + return { type, key } as const; +}; + +export const buildTypeGuard = (key: symbol) => { + const typeGuard = (val: RecordUnknown): val is T => Boolean(val[key]); + return typeGuard; +}; + +export const buildGetData = (key: symbol, type: T['type']) => { + const getData = (payload: T['payload'] extends undefined ? void : T['payload'], id?: string): T => + ({ + [key]: true, + id: id ?? getPrefixedId(type), + type, + payload, + }) as T; + return getData; +}; + +export type ActionSourceApi = { + key: symbol; + type: SourceData['type']; + typeGuard: ReturnType>; + getData: ReturnType>; +}; +//#region Single Image +const _singleImage = buildTypeAndKey('single-image'); +export type SingleImageSourceData = ActionData< + typeof _singleImage.type, + typeof _singleImage.key, + { imageDTO: ImageDTO } +>; +export const singleImageSourceApi: ActionSourceApi = { + ..._singleImage, + typeGuard: buildTypeGuard(_singleImage.key), + getData: buildGetData(_singleImage.key, _singleImage.type), +}; +//#endregion + +//#region Multiple Image +const _multipleImage = buildTypeAndKey('multiple-image'); +export type MultipleImageSourceData = ActionData< + typeof _multipleImage.type, + typeof _multipleImage.key, + { imageDTOs: ImageDTO[]; boardId: BoardId } +>; +export const multipleImageSourceApi: ActionSourceApi = { + ..._multipleImage, + typeGuard: buildTypeGuard(_multipleImage.key), + getData: buildGetData(_multipleImage.key, _multipleImage.type), +}; +//#endregion + +type ActionTargetApi = { + key: symbol; + type: TargetData['type']; + typeGuard: ReturnType>; + getData: ReturnType>; + isValid: ( + sourceData: RecordUnknown, + targetData: TargetData, + dispatch: AppDispatch, + getState: () => RootState + ) => boolean; + handler: (sourceData: SourceData, targetData: TargetData, dispatch: AppDispatch, getState: () => RootState) => void; +}; + +//#region Set Global Reference Image +const _setGlobalReferenceImage = buildTypeAndKey('set-global-reference-image'); +export type SetGlobalReferenceImageActionData = ActionData< + typeof _setGlobalReferenceImage.type, + typeof _setGlobalReferenceImage.key, + { entityIdentifier: CanvasEntityIdentifier<'reference_image'> } +>; +export const setGlobalReferenceImageActionApi: ActionTargetApi< + SetGlobalReferenceImageActionData, + SingleImageSourceData +> = { + ..._setGlobalReferenceImage, + typeGuard: buildTypeGuard(_setGlobalReferenceImage.key), + getData: buildGetData(_setGlobalReferenceImage.key, _setGlobalReferenceImage.type), + isValid: (sourceData, _targetData, _dispatch, _getState) => { + if (singleImageSourceApi.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: (sourceData, targetData, dispatch, _getState) => { + const { imageDTO } = sourceData.payload; + const { entityIdentifier } = targetData.payload; + dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO })); + }, +}; +//#endregion + +//#region Set Regional Guidance Reference Image +const _setRegionalGuidanceReferenceImage = buildTypeAndKey('set-regional-guidance-reference-image'); +export type SetRegionalGuidanceReferenceImageActionData = ActionData< + typeof _setRegionalGuidanceReferenceImage.type, + typeof _setRegionalGuidanceReferenceImage.key, + { entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>; referenceImageId: string } +>; +export const setRegionalGuidanceReferenceImageActionApi: ActionTargetApi< + SetRegionalGuidanceReferenceImageActionData, + SingleImageSourceData +> = { + ..._setRegionalGuidanceReferenceImage, + typeGuard: buildTypeGuard(_setRegionalGuidanceReferenceImage.key), + getData: buildGetData(_setRegionalGuidanceReferenceImage.key, _setRegionalGuidanceReferenceImage.type), + isValid: (sourceData, _targetData, _dispatch, _getState) => { + if (singleImageSourceApi.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: (sourceData, targetData, dispatch, _getState) => { + const { imageDTO } = sourceData.payload; + const { entityIdentifier, referenceImageId } = targetData.payload; + dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO })); + }, +}; +//#endregion + +//# Set Upscale Initial Image +const _setUpscaleInitialImage = buildTypeAndKey('set-upscale-initial-image'); +export type SetUpscaleInitialImageActionData = ActionData< + typeof _setUpscaleInitialImage.type, + typeof _setUpscaleInitialImage.key, + void +>; +export const setUpscaleInitialImageActionApi: ActionTargetApi = + { + ..._setUpscaleInitialImage, + typeGuard: buildTypeGuard(_setUpscaleInitialImage.key), + getData: buildGetData(_setUpscaleInitialImage.key, _setUpscaleInitialImage.type), + isValid: (sourceData, _targetData, _dispatch, _getState) => { + if (singleImageSourceApi.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: (sourceData, _targetData, dispatch, _getState) => { + const { imageDTO } = sourceData.payload; + dispatch(upscaleInitialImageChanged(imageDTO)); + }, + }; +//#endregion + +//#region Set Node Image Field Image +const _setNodeImageFieldImage = buildTypeAndKey('set-node-image-field-image'); +export type SetNodeImageFieldImageActionData = ActionData< + typeof _setNodeImageFieldImage.type, + typeof _setNodeImageFieldImage.key, + { fieldIdentifer: FieldIdentifier } +>; +export const setNodeImageFieldImageActionApi: ActionTargetApi = + { + ..._setNodeImageFieldImage, + typeGuard: buildTypeGuard(_setNodeImageFieldImage.key), + getData: buildGetData(_setNodeImageFieldImage.key, _setNodeImageFieldImage.type), + isValid: (sourceData, _targetData, _dispatch, _getState) => { + if (singleImageSourceApi.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: (sourceData, targetData, dispatch, _getState) => { + const { imageDTO } = sourceData.payload; + const { fieldIdentifer } = targetData.payload; + dispatch(fieldImageValueChanged({ ...fieldIdentifer, value: imageDTO })); + }, + }; +//#endregion + +//# Set Comparison Image +const _setComparisonImage = buildTypeAndKey('set-comparison-image'); +export type SetComparisonImageActionData = ActionData< + typeof _setComparisonImage.type, + typeof _setComparisonImage.key, + void +>; +export const setComparisonImageActionApi: ActionTargetApi = { + ..._setComparisonImage, + typeGuard: buildTypeGuard(_setComparisonImage.key), + getData: buildGetData(_setComparisonImage.key, _setComparisonImage.type), + isValid: (sourceData, _targetData, _dispatch, getState) => { + if (!singleImageSourceApi.typeGuard(sourceData)) { + return false; + } + const { firstImage, secondImage } = selectComparisonImages(getState()); + // Do not allow the same images to be selected for comparison + if (sourceData.payload.imageDTO.image_name === firstImage?.image_name) { + return false; + } + if (sourceData.payload.imageDTO.image_name === secondImage?.image_name) { + return false; + } + return true; + }, + handler: (sourceData, _targetData, dispatch, _getState) => { + const { imageDTO } = sourceData.payload; + dispatch(imageToCompareChanged(imageDTO)); + }, +}; +//#endregion + +//#region New Canvas Entity +const _newCanvasEntity = buildTypeAndKey('new-canvas-entity'); +export type NewCanvasEntityFromImageActionData = ActionData< + typeof _newCanvasEntity.type, + typeof _newCanvasEntity.key, + { type: CanvasEntityType | 'regional_guidance_with_reference_image' } +>; +export const newCanvasEntityFromImageActionApi: ActionTargetApi< + NewCanvasEntityFromImageActionData, + SingleImageSourceData +> = { + ..._newCanvasEntity, + typeGuard: buildTypeGuard(_newCanvasEntity.key), + getData: buildGetData(_newCanvasEntity.key, _newCanvasEntity.type), + isValid: (sourceData, _targetData, _dispatch, _getState) => { + if (!singleImageSourceApi.typeGuard(sourceData)) { + return false; + } + return true; + }, + handler: (sourceData, targetData, dispatch, getState) => { + const { type } = targetData.payload; + const { imageDTO } = sourceData.payload; + const state = getState(); + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectBboxRect(state); + const overrides = { + objects: [imageObject], + position: { x, y }, + }; + switch (type) { + case 'raster_layer': { + dispatch(rasterLayerAdded({ overrides, isSelected: true })); + break; + } + case 'control_layer': { + const controlAdapter = selectDefaultControlAdapter(state); + dispatch(controlLayerAdded({ overrides: { ...overrides, controlAdapter }, isSelected: true })); + break; + } + case 'inpaint_mask': { + dispatch(inpaintMaskAdded({ overrides, isSelected: true })); + break; + } + case 'regional_guidance': { + dispatch(rgAdded({ overrides, isSelected: true })); + break; + } + case 'reference_image': { + const ipAdapter = selectDefaultIPAdapter(getState()); + ipAdapter.image = imageDTOToImageWithDims(imageDTO); + dispatch(referenceImageAdded({ overrides: { ipAdapter }, isSelected: true })); + break; + } + case 'regional_guidance_with_reference_image': { + const ipAdapter = selectDefaultIPAdapter(getState()); + ipAdapter.image = imageDTOToImageWithDims(imageDTO); + const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }]; + dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true })); + } + } + }, +}; +//#endregion + +//#region Replace Canvas Entity Objects With Image +const _replaceCanvasEntityObjectsWithImage = buildTypeAndKey('replace-canvas-entity-objects-with-image'); +export type ReplaceCanvasEntityObjectsWithImageActionData = ActionData< + typeof _replaceCanvasEntityObjectsWithImage.type, + typeof _replaceCanvasEntityObjectsWithImage.key, + { entityIdentifier: CanvasRenderableEntityIdentifier } +>; +export const replaceCanvasEntityObjectsWithImageActionApi: ActionTargetApi< + ReplaceCanvasEntityObjectsWithImageActionData, + SingleImageSourceData +> = { + ..._replaceCanvasEntityObjectsWithImage, + typeGuard: buildTypeGuard(_replaceCanvasEntityObjectsWithImage.key), + getData: buildGetData(_replaceCanvasEntityObjectsWithImage.key, _replaceCanvasEntityObjectsWithImage.type), + isValid: (sourceData, _targetData, _dispatch, _getState) => { + if (!singleImageSourceApi.typeGuard(sourceData)) { + return false; + } + return true; + }, + handler: (sourceData, targetData, dispatch, getState) => { + const { imageDTO } = sourceData.payload; + const { entityIdentifier } = targetData.payload; + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectBboxRect(getState()); + dispatch( + entityRasterized({ + entityIdentifier, + imageObject, + position: { x, y }, + replaceObjects: true, + isSelected: true, + }) + ); + }, +}; +//#endregion + +//#region Add To Board +const _addToBoard = buildTypeAndKey('add-to-board'); +export type AddImageToBoardActionData = ActionData< + typeof _addToBoard.type, + typeof _addToBoard.key, + { boardId: BoardId } +>; +export const addImageToBoardActionApi: ActionTargetApi< + AddImageToBoardActionData, + SingleImageSourceData | MultipleImageSourceData +> = { + ..._addToBoard, + typeGuard: buildTypeGuard(_addToBoard.key), + getData: buildGetData(_addToBoard.key, _addToBoard.type), + isValid: (sourceData, targetData, _dispatch, _getState) => { + if (singleImageSourceApi.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } + if (multipleImageSourceApi.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.boardId; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } + return false; + }, + handler: (sourceData, targetData, dispatch, _getState) => { + if (singleImageSourceApi.typeGuard(sourceData)) { + const { imageDTO } = sourceData.payload; + const { boardId } = targetData.payload; + dispatch(imagesApi.endpoints.addImageToBoard.initiate({ imageDTO, board_id: boardId }, { track: false })); + dispatch(selectionChanged([])); + } + + if (multipleImageSourceApi.typeGuard(sourceData)) { + const { imageDTOs } = sourceData.payload; + const { boardId } = targetData.payload; + dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false })); + dispatch(selectionChanged([])); + } + }, +}; +//#endregion + +//#region Remove From Board +const _removeFromBoard = buildTypeAndKey('add-to-board'); +export type RemoveImageFromBoardActionData = ActionData< + typeof _removeFromBoard.type, + typeof _removeFromBoard.key, + void +>; +export const removeImageFromBoardActionApi: ActionTargetApi< + RemoveImageFromBoardActionData, + SingleImageSourceData | MultipleImageSourceData +> = { + ..._removeFromBoard, + typeGuard: buildTypeGuard(_removeFromBoard.key), + getData: buildGetData(_removeFromBoard.key, _removeFromBoard.type), + isValid: (sourceData, _targetData, _dispatch, _getState) => { + if (singleImageSourceApi.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; + return currentBoard !== 'none'; + } + + if (multipleImageSourceApi.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.boardId; + return currentBoard !== 'none'; + } + + return false; + }, + handler: (sourceData, _targetData, dispatch, _getState) => { + if (singleImageSourceApi.typeGuard(sourceData)) { + const { imageDTO } = sourceData.payload; + dispatch(imagesApi.endpoints.removeImageFromBoard.initiate({ imageDTO }, { track: false })); + dispatch(selectionChanged([])); + } + + if (multipleImageSourceApi.typeGuard(sourceData)) { + const { imageDTOs } = sourceData.payload; + dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false })); + dispatch(selectionChanged([])); + } + }, +}; +//#endregion + +export const singleImageActions = [ + setGlobalReferenceImageActionApi, + setRegionalGuidanceReferenceImageActionApi, + setUpscaleInitialImageActionApi, + setNodeImageFieldImageActionApi, + setComparisonImageActionApi, + newCanvasEntityFromImageActionApi, + replaceCanvasEntityObjectsWithImageActionApi, + addImageToBoardActionApi, + removeImageFromBoardActionApi, +] as const; +export type SingleImageAction = ReturnType<(typeof singleImageActions)[number]['getData']>; + +export const multipleImageActions = [addImageToBoardActionApi, removeImageFromBoardActionApi] as const; +export type MultipleImageAction = ReturnType<(typeof multipleImageActions)[number]['getData']>; 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 5733fa473f1..992161b9485 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,10 +2,11 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; +import type { SetNodeImageFieldImageActionData } from 'features/imageActions/actions'; +import { setNodeImageFieldImageActionApi } from 'features/imageActions/actions'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field'; import { memo, useCallback, useEffect, useMemo } from 'react'; @@ -32,9 +33,10 @@ const ImageFieldInputComponent = (props: FieldComponentProps( - () => Dnd.Target.setNodeImageField.getData({ nodeId, fieldName: field.name }, field.value?.image_name), - [field.name, field.value?.image_name, nodeId] + const targetData = useMemo( + () => + setNodeImageFieldImageActionApi.getData({ fieldIdentifer: { nodeId, fieldName: field.name } }, field.value?.image_name), + [field, nodeId] ); useEffect(() => { 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 2fd14cd6362..b10d0dfb45b 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 @@ -7,7 +7,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar'; -import { triggerPostMoveFlash } from 'features/dnd/dnd'; +import { triggerPostMoveFlash } from 'features/dnd/util'; import LinearViewFieldInternal from 'features/nodes/components/flow/nodes/Invocation/fields/LinearViewField'; import { singleWorkflowField } from 'features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd'; import { selectWorkflowSlice, workflowExposedFieldsReordered } from 'features/nodes/store/workflowSlice'; 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 index 8dea9126387..81771db7a5f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts @@ -1,19 +1,28 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; -import type { DndListState } from 'features/dnd/dnd'; -import { buildDndSourceApi, idle } from 'features/dnd/dnd'; +import type { DndListTargetState } from 'features/dnd/types'; +import { idle } from 'features/dnd/types'; +import type { ActionData, ActionSourceApi } from 'features/imageActions/actions'; +import { buildGetData, buildTypeAndKey, buildTypeGuard } from 'features/imageActions/actions'; import type { FieldIdentifier } from 'features/nodes/types/field'; import type { RefObject } from 'react'; import { useEffect, useState } from 'react'; -/** - * Dnd source API for a single workflow field. - */ -export const singleWorkflowField = buildDndSourceApi<{ fieldIdentifier: FieldIdentifier }>('SingleWorkflowField'); +const _singleWorkflowField = buildTypeAndKey('single-workflow-field'); +type SingleWorkflowFieldSourceData = ActionData< + typeof _singleWorkflowField.type, + typeof _singleWorkflowField.key, + { fieldIdentifier: FieldIdentifier } +>; +export const singleWorkflowField: ActionSourceApi = { + ..._singleWorkflowField, + typeGuard: buildTypeGuard(_singleWorkflowField.key), + getData: buildGetData(_singleWorkflowField.key, _singleWorkflowField.type), +}; export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifier: FieldIdentifier) => { - const [dndListState, setListDndState] = useState(idle); + const [dndListState, setListDndState] = useState(idle); const [isDragging, setIsDragging] = useState(false); useEffect(() => { 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 92ac12d1b8f..275a95cea76 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx @@ -1,9 +1,10 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { Dnd } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; +import type { SetUpscaleInitialImageActionData } from 'features/imageActions/actions'; +import { setUpscaleInitialImageActionApi } from 'features/imageActions/actions'; import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { t } from 'i18next'; import { useCallback, useMemo } from 'react'; @@ -12,10 +13,7 @@ import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; export const UpscaleInitialImage = () => { const dispatch = useAppDispatch(); const imageDTO = useAppSelector(selectUpscaleInitialImage); - const targetData = useMemo( - () => Dnd.Target.setUpscaleInitialImageFromImage.getData(), - [] - ); + const targetData = useMemo(() => setUpscaleInitialImageActionApi.getData(), []); const onReset = useCallback(() => { dispatch(upscaleInitialImageChanged(null)); From 03b50fa32f83787197dc5f812b0058676d6b9836 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Sat, 2 Nov 2024 11:25:13 +1000 Subject: [PATCH 24/39] feat(ui): use new image actions system for image menu --- .../controlLayers/hooks/addLayerHooks.ts | 170 +----------------- .../ImageMenuItemNewFromImageSubMenu.tsx | 55 +++--- .../web/src/features/imageActions/actions.ts | 149 ++++++++++++++- 3 files changed, 180 insertions(+), 194 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts index 1115c162b38..61f1c75f952 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts @@ -2,11 +2,8 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { deepClone } from 'common/util/deepClone'; -import { CanvasEntityAdapterBase } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { canvasReset } from 'features/controlLayers/store/actions'; import { - bboxChangedFromCanvas, controlLayerAdded, inpaintMaskAdded, rasterLayerAdded, @@ -17,37 +14,20 @@ import { rgPositivePromptChanged, } from 'features/controlLayers/store/canvasSlice'; import { selectBase } from 'features/controlLayers/store/paramsSlice'; -import { - selectBboxModelBase, - selectBboxRect, - selectCanvasSlice, - selectEntityOrThrow, -} from 'features/controlLayers/store/selectors'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; import type { - CanvasControlLayerState, CanvasEntityIdentifier, - CanvasInpaintMaskState, - CanvasRasterLayerState, CanvasRegionalGuidanceState, ControlNetConfig, IPAdapterConfig, T2IAdapterConfig, } from 'features/controlLayers/store/types'; -import { - imageDTOToImageObject, - initialControlNet, - initialIPAdapter, - initialT2IAdapter, -} from 'features/controlLayers/store/util'; -import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; +import { initialControlNet, initialIPAdapter, initialT2IAdapter } from 'features/controlLayers/store/util'; import { zModelIdentifierField } from 'features/nodes/types/common'; -import { getOptimalDimension } from 'features/parameters/util/optimalDimension'; import { useCallback } from 'react'; import { modelConfigsAdapterSelectors, selectModelConfigsQuery } from 'services/api/endpoints/models'; -import type { ControlNetModelConfig, ImageDTO, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types'; +import type { ControlNetModelConfig, IPAdapterModelConfig, T2IAdapterModelConfig } from 'services/api/types'; import { isControlNetOrT2IAdapterModelConfig, isIPAdapterModelConfig } from 'services/api/types'; -import type { Equals } from 'tsafe'; -import { assert } from 'tsafe'; /** @knipignore */ export const selectDefaultControlAdapter = createSelector( @@ -110,150 +90,6 @@ export const useAddRasterLayer = () => { return func; }; -export const useNewRasterLayerFromImage = () => { - const dispatch = useAppDispatch(); - const bboxRect = useAppSelector(selectBboxRect); - const func = useCallback( - (imageDTO: ImageDTO) => { - const imageObject = imageDTOToImageObject(imageDTO); - const overrides: Partial = { - position: { x: bboxRect.x, y: bboxRect.y }, - objects: [imageObject], - }; - dispatch(rasterLayerAdded({ overrides, isSelected: true })); - }, - [bboxRect.x, bboxRect.y, dispatch] - ); - - return func; -}; - -export const useNewControlLayerFromImage = () => { - const dispatch = useAppDispatch(); - const bboxRect = useAppSelector(selectBboxRect); - const func = useCallback( - (imageDTO: ImageDTO) => { - const imageObject = imageDTOToImageObject(imageDTO); - const overrides: Partial = { - position: { x: bboxRect.x, y: bboxRect.y }, - objects: [imageObject], - }; - dispatch(controlLayerAdded({ overrides, isSelected: true })); - }, - [bboxRect.x, bboxRect.y, dispatch] - ); - - return func; -}; - -export const useNewInpaintMaskFromImage = () => { - const dispatch = useAppDispatch(); - const bboxRect = useAppSelector(selectBboxRect); - const func = useCallback( - (imageDTO: ImageDTO) => { - const imageObject = imageDTOToImageObject(imageDTO); - const overrides: Partial = { - position: { x: bboxRect.x, y: bboxRect.y }, - objects: [imageObject], - }; - dispatch(inpaintMaskAdded({ overrides, isSelected: true })); - }, - [bboxRect.x, bboxRect.y, dispatch] - ); - - return func; -}; - -export const useNewRegionalGuidanceFromImage = () => { - const dispatch = useAppDispatch(); - const bboxRect = useAppSelector(selectBboxRect); - const func = useCallback( - (imageDTO: ImageDTO) => { - const imageObject = imageDTOToImageObject(imageDTO); - const overrides: Partial = { - position: { x: bboxRect.x, y: bboxRect.y }, - objects: [imageObject], - }; - dispatch(rgAdded({ overrides, isSelected: true })); - }, - [bboxRect.x, bboxRect.y, dispatch] - ); - - return func; -}; - -/** - * Returns a function that adds a new canvas with the given image as the initial image, replicating the img2img flow: - * - Reset the canvas - * - Resize the bbox to the image's aspect ratio at the optimal size for the selected model - * - Add the image as a raster layer - * - Resizes the layer to fit the bbox using the 'fill' strategy - * - * This allows the user to immediately generate a new image from the given image without any additional steps. - */ -export const useNewCanvasFromImage = () => { - const dispatch = useAppDispatch(); - const bboxRect = useAppSelector(selectBboxRect); - const base = useAppSelector(selectBboxModelBase); - const func = useCallback( - (imageDTO: ImageDTO, type: CanvasRasterLayerState['type'] | CanvasControlLayerState['type']) => { - // Calculate the new bbox dimensions to fit the image's aspect ratio at the optimal size - const ratio = imageDTO.width / imageDTO.height; - const optimalDimension = getOptimalDimension(base); - const { width, height } = calculateNewSize(ratio, optimalDimension ** 2, base); - - // The overrides need to include the layer's ID so we can transform the layer it is initialized - let overrides: Partial | Partial; - - if (type === 'raster_layer') { - overrides = { - id: getPrefixedId('raster_layer'), - position: { x: bboxRect.x, y: bboxRect.y }, - objects: [imageDTOToImageObject(imageDTO)], - } satisfies Partial; - } else if (type === 'control_layer') { - overrides = { - id: getPrefixedId('control_layer'), - position: { x: bboxRect.x, y: bboxRect.y }, - objects: [imageDTOToImageObject(imageDTO)], - } satisfies Partial; - } else { - // Catch unhandled types - assert>(false); - } - - CanvasEntityAdapterBase.registerInitCallback(async (adapter) => { - // Skip the callback if the adapter is not the one we are creating - if (adapter.id !== overrides.id) { - return false; - } - // Fit the layer to the bbox w/ fill strategy - await adapter.transformer.startTransform({ silent: true }); - adapter.transformer.fitToBboxFill(); - await adapter.transformer.applyTransform(); - return true; - }); - - dispatch(canvasReset()); - // The `bboxChangedFromCanvas` reducer does no validation! Careful! - dispatch(bboxChangedFromCanvas({ x: 0, y: 0, width, height })); - - // The type casts are safe because the type is checked above - if (type === 'raster_layer') { - dispatch(rasterLayerAdded({ overrides: overrides as Partial, isSelected: true })); - } else if (type === 'control_layer') { - dispatch(controlLayerAdded({ overrides: overrides as Partial, isSelected: true })); - } else { - // Catch unhandled types - assert>(false); - } - }, - [base, bboxRect.x, bboxRect.y, dispatch] - ); - - return func; -}; - export const useAddInpaintMask = () => { const dispatch = useAppDispatch(); const func = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx index cec5f1c6a44..f0abdba0bfd 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx @@ -1,18 +1,17 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppStore } from 'app/store/nanostores/store'; import { useAppDispatch } from 'app/store/storeHooks'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; -import { - useNewCanvasFromImage, - useNewControlLayerFromImage, - useNewInpaintMaskFromImage, - useNewRasterLayerFromImage, - useNewRegionalGuidanceFromImage, -} from 'features/controlLayers/hooks/addLayerHooks'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { sentImageToCanvas } from 'features/gallery/store/actions'; +import { + newCanvasEntityFromImageActionApi, + newCanvasFromImageActionApi, + singleImageSourceApi, +} from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; import { setActiveTab } from 'features/ui/store/uiSlice'; import { memo, useCallback } from 'react'; @@ -22,18 +21,16 @@ import { PiFileBold, PiPlusBold } from 'react-icons/pi'; export const ImageMenuItemNewFromImageSubMenu = memo(() => { const { t } = useTranslation(); const subMenu = useSubMenu(); + const store = useAppStore(); const dispatch = useAppDispatch(); const imageDTO = useImageDTOContext(); const imageViewer = useImageViewer(); const isBusy = useCanvasIsBusy(); - const newRasterLayerFromImage = useNewRasterLayerFromImage(); - const newControlLayerFromImage = useNewControlLayerFromImage(); - const newInpaintMaskFromImage = useNewInpaintMaskFromImage(); - const newRegionalGuidanceFromImage = useNewRegionalGuidanceFromImage(); - const newCanvasFromImage = useNewCanvasFromImage(); const onClickNewCanvasWithRasterLayerFromImage = useCallback(() => { - newCanvasFromImage(imageDTO, 'raster_layer'); + const targetData = newCanvasFromImageActionApi.getData({ type: 'raster_layer' }); + const sourceData = singleImageSourceApi.getData({ imageDTO }); + newCanvasFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); dispatch(setActiveTab('canvas')); imageViewer.close(); toast({ @@ -41,10 +38,12 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, newCanvasFromImage, t]); + }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); const onClickNewCanvasWithControlLayerFromImage = useCallback(() => { - newCanvasFromImage(imageDTO, 'control_layer'); + const targetData = newCanvasFromImageActionApi.getData({ type: 'control_layer' }); + const sourceData = singleImageSourceApi.getData({ imageDTO }); + newCanvasFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); dispatch(setActiveTab('canvas')); imageViewer.close(); toast({ @@ -52,11 +51,13 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, newCanvasFromImage, t]); + }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); const onClickNewRasterLayerFromImage = useCallback(() => { + const targetData = newCanvasEntityFromImageActionApi.getData({ type: 'raster_layer' }); + const sourceData = singleImageSourceApi.getData({ imageDTO }); + newCanvasEntityFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); dispatch(sentImageToCanvas()); - newRasterLayerFromImage(imageDTO); dispatch(setActiveTab('canvas')); imageViewer.close(); toast({ @@ -64,11 +65,13 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, newRasterLayerFromImage, t]); + }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); const onClickNewControlLayerFromImage = useCallback(() => { + const targetData = newCanvasEntityFromImageActionApi.getData({ type: 'control_layer' }); + const sourceData = singleImageSourceApi.getData({ imageDTO }); + newCanvasEntityFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); dispatch(sentImageToCanvas()); - newControlLayerFromImage(imageDTO); dispatch(setActiveTab('canvas')); imageViewer.close(); toast({ @@ -76,11 +79,13 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, newControlLayerFromImage, t]); + }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); const onClickNewInpaintMaskFromImage = useCallback(() => { + const targetData = newCanvasEntityFromImageActionApi.getData({ type: 'inpaint_mask' }); + const sourceData = singleImageSourceApi.getData({ imageDTO }); + newCanvasEntityFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); dispatch(sentImageToCanvas()); - newInpaintMaskFromImage(imageDTO); dispatch(setActiveTab('canvas')); imageViewer.close(); toast({ @@ -88,11 +93,13 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, newInpaintMaskFromImage, t]); + }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); const onClickNewRegionalGuidanceFromImage = useCallback(() => { + const targetData = newCanvasEntityFromImageActionApi.getData({ type: 'regional_guidance' }); + const sourceData = singleImageSourceApi.getData({ imageDTO }); + newCanvasEntityFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); dispatch(sentImageToCanvas()); - newRegionalGuidanceFromImage(imageDTO); dispatch(setActiveTab('canvas')); imageViewer.close(); toast({ @@ -100,7 +107,7 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, newRegionalGuidanceFromImage, t]); + }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); return ( }> diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index cc095688b27..562831e981f 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -1,7 +1,10 @@ import type { AppDispatch, RootState } from 'app/store/store'; import { selectDefaultControlAdapter, 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, @@ -11,21 +14,29 @@ import { rgAdded, rgIPAdapterImageChanged, } from 'features/controlLayers/store/canvasSlice'; -import { selectBboxRect } from 'features/controlLayers/store/selectors'; +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 } from 'features/controlLayers/store/util'; +import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import type { BoardId } from 'features/gallery/store/types'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import type { FieldIdentifier } from 'features/nodes/types/field'; import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; +import { 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'; import type { JsonObject } from 'type-fest'; export type RecordUnknown = Record; @@ -250,8 +261,8 @@ export const setComparisonImageActionApi: ActionTargetApi; +/** + * Returns a function that adds a new canvas with the given image as the initial image, replicating the img2img flow: + * - Reset the canvas + * - Resize the bbox to the image's aspect ratio at the optimal size for the selected model + * - Add the image as a raster layer + * - Resizes the layer to fit the bbox using the 'fill' strategy + * + * This allows the user to immediately generate a new image from the given image without any additional steps. + */ +export const newCanvasFromImageActionApi: ActionTargetApi = { + ..._newCanvasFromImage, + typeGuard: buildTypeGuard(_newCanvasFromImage.key), + getData: buildGetData(_newCanvasFromImage.key, _newCanvasFromImage.type), + isValid: (sourceData, _targetData, _dispatch, _getState) => { + if (!singleImageSourceApi.typeGuard(sourceData)) { + return false; + } + return true; + }, + handler: (sourceData, targetData, dispatch, getState) => { + const { type } = targetData.payload; + const { imageDTO } = sourceData.payload; + const state = getState(); + + const 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 controlAdapter = selectDefaultControlAdapter(state); + const overrides = { + id: getPrefixedId('control_layer'), + objects: [imageObject], + position: { x, y }, + controlAdapter, + } 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); } }, }; From 3ddd94201936bee22db95ab5085927f0b28ff284 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 5 Nov 2024 20:29:32 +1000 Subject: [PATCH 25/39] refactor(ui): image actions sep of concerns --- .../components/CanvasDropArea.tsx | 26 +- .../CanvasEntityGroupList.tsx | 6 +- .../useCanvasEntityListDnd.ts | 21 +- .../components/CanvasRightPanel.tsx | 34 +- .../components/ControlLayer/ControlLayer.tsx | 15 +- .../IPAdapter/IPAdapterImagePreview.tsx | 77 +- .../IPAdapter/IPAdapterSettings.tsx | 15 +- .../components/RasterLayer/RasterLayer.tsx | 15 +- .../RegionalGuidanceIPAdapterSettings.tsx | 18 +- .../dnd/DndDragPreviewMultipleImage.tsx | 4 +- .../dnd/DndDragPreviewSingleImage.tsx | 4 +- .../web/src/features/dnd/DndDropTarget.tsx | 63 +- .../web/src/features/dnd/DndImage.tsx | 6 +- invokeai/frontend/web/src/features/dnd/dnd.ts | 485 ++++++++++++ .../web/src/features/dnd/useDndMonitor.ts | 46 +- .../Boards/BoardsList/GalleryBoard.tsx | 16 +- .../Boards/BoardsList/NoBoardBoard.tsx | 13 +- .../ImageMenuItemNewFromImageSubMenu.tsx | 50 +- .../components/ImageGrid/GalleryImage.tsx | 14 +- .../ImageViewer/ImageComparisonDroppable.tsx | 13 +- .../web/src/features/imageActions/actions.ts | 711 +++++------------- .../inputs/ImageFieldInputComponent.tsx | 17 +- .../sidePanel/workflow/WorkflowLinearTab.tsx | 9 +- .../workflow/useLinearViewFieldDnd.ts | 21 +- .../UpscaleInitialImage.tsx | 15 +- 25 files changed, 931 insertions(+), 783 deletions(-) create mode 100644 invokeai/frontend/web/src/features/dnd/dnd.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx index 7ccf49d9abb..e988ecce683 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -1,17 +1,21 @@ import { Grid, GridItem } from '@invoke-ai/ui-library'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { newCanvasEntityFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; -import { newCanvasEntityFromImageActionApi } from 'features/imageActions/actions'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -const addRasterLayerFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ type: 'raster_layer' }); -const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ type: 'control_layer' }); -const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ +const addRasterLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ type: 'raster_layer' }); +const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ + type: 'control_layer', +}); +const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ type: 'regional_guidance_with_reference_image', }); -const addGlobalReferenceImageFromImageDndTargetData = newCanvasEntityFromImageActionApi.getData({ type: 'reference_image' }); +const addGlobalReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ + type: 'reference_image', +}); export const CanvasDropArea = memo(() => { const { t } = useTranslation(); @@ -36,14 +40,16 @@ export const CanvasDropArea = memo(() => { > @@ -51,14 +57,16 @@ export const CanvasDropArea = memo(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx index bffbfb4ba9f..07ca2093a52 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx @@ -7,7 +7,6 @@ import { InformationalPopover } from 'common/components/InformationalPopover/Inf import { useBoolean } from 'common/hooks/useBoolean'; import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar'; import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles'; -import { singleCanvasEntity } from 'features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd'; import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton'; import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton'; import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle'; @@ -16,6 +15,7 @@ import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTi import { entitiesReordered } from 'features/controlLayers/store/canvasSlice'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { isRenderableEntityType } from 'features/controlLayers/store/types'; +import { singleCanvasEntityDndSource } from 'features/dnd/dnd'; import { triggerPostMoveFlash } from 'features/dnd/util'; import type { PropsWithChildren } from 'react'; import { memo, useEffect } from 'react'; @@ -37,7 +37,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI useEffect(() => { return monitorForElements({ canMonitor({ source }) { - if (!singleCanvasEntity.typeGuard(source.data)) { + if (!singleCanvasEntityDndSource.typeGuard(source.data)) { return false; } if (source.data.payload.entityIdentifier.type !== type) { @@ -54,7 +54,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI const sourceData = source.data; const targetData = target.data; - if (!singleCanvasEntity.typeGuard(sourceData) || !singleCanvasEntity.typeGuard(targetData)) { + if (!singleCanvasEntityDndSource.typeGuard(sourceData) || !singleCanvasEntityDndSource.typeGuard(targetData)) { return; } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts index 5b13be700a3..d47e3c0c781 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts @@ -2,24 +2,11 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { singleCanvasEntityDndSource } from 'features/dnd/dnd'; import { type DndListTargetState, idle } from 'features/dnd/types'; -import type { ActionData, ActionSourceApi } from 'features/imageActions/actions'; -import { buildGetData, buildTypeAndKey, buildTypeGuard } from 'features/imageActions/actions'; import type { RefObject } from 'react'; import { useEffect, useState } from 'react'; -const _singleCanvasEntity = buildTypeAndKey('single-canvas-entity'); -type SingleCanvasEntitySourceData = ActionData< - typeof _singleCanvasEntity.type, - typeof _singleCanvasEntity.key, - { entityIdentifier: CanvasEntityIdentifier } ->; -export const singleCanvasEntity: ActionSourceApi = { - ..._singleCanvasEntity, - typeGuard: buildTypeGuard(_singleCanvasEntity.key), - getData: buildGetData(_singleCanvasEntity.key, _singleCanvasEntity.type), -}; - export const useCanvasEntityListDnd = (ref: RefObject, entityIdentifier: CanvasEntityIdentifier) => { const [dndListState, setDndListState] = useState(idle); const [isDragging, setIsDragging] = useState(false); @@ -33,7 +20,7 @@ export const useCanvasEntityListDnd = (ref: RefObject, entityIdenti draggable({ element, getInitialData() { - return singleCanvasEntity.getData({ entityIdentifier }); + return singleCanvasEntityDndSource.getData({ entityIdentifier }); }, onDragStart() { setDndListState({ type: 'is-dragging' }); @@ -47,7 +34,7 @@ export const useCanvasEntityListDnd = (ref: RefObject, entityIdenti dropTargetForElements({ element, canDrop({ source }) { - if (!singleCanvasEntity.typeGuard(source.data)) { + if (!singleCanvasEntityDndSource.typeGuard(source.data)) { return false; } if (source.data.payload.entityIdentifier.type !== entityIdentifier.type) { @@ -56,7 +43,7 @@ export const useCanvasEntityListDnd = (ref: RefObject, entityIdenti return true; }, getData({ input }) { - const data = singleCanvasEntity.getData({ entityIdentifier }); + const data = singleCanvasEntityDndSource.getData({ entityIdentifier }); return attachClosestEdge(data, { element, input, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx index 86220bd9831..ac0c6690057 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -6,11 +6,11 @@ import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHook import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; +import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; import type { DndTargetState } from 'features/dnd/types'; import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; -import { multipleImageSourceApi, singleImageSourceApi } from 'features/imageActions/actions'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; @@ -103,9 +103,11 @@ const PanelTabs = memo(() => { return; } + const getIsOnLayersTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'layers'; + const onDragEnter = () => { // If we are already on the layers tab, do nothing - if (selectActiveTabCanvasRightPanel(store.getState()) === 'layers') { + if (getIsOnLayersTab()) { return; } @@ -121,7 +123,7 @@ const PanelTabs = memo(() => { }; const onDragLeave = () => { // Set the state to idle or pending depending on the current tab - if (selectActiveTabCanvasRightPanel(store.getState()) === 'layers') { + if (getIsOnLayersTab()) { setLayersTabDndState('idle'); } else { setLayersTabDndState('potential'); @@ -131,10 +133,6 @@ const PanelTabs = memo(() => { clearTimeout(timeoutRef.current); } }; - const canMonitor = () => { - // Only monitor if we are not already on the layers tab - return selectActiveTabCanvasRightPanel(store.getState()) !== 'layers'; - }; const onDragStart = () => { // Set the state to pending when a drag starts setLayersTabDndState('potential'); @@ -146,7 +144,13 @@ const PanelTabs = memo(() => { onDragLeave, }), monitorForElements({ - canMonitor, + canMonitor: ({ source }) => { + if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) { + return false; + } + // Only monitor if we are not already on the gallery tab + return !getIsOnLayersTab(); + }, onDragStart, }), dropTargetForExternal({ @@ -155,7 +159,7 @@ const PanelTabs = memo(() => { onDragLeave, }), monitorForExternal({ - canMonitor, + canMonitor: () => !getIsOnLayersTab(), onDragStart, }) ); @@ -166,9 +170,11 @@ const PanelTabs = memo(() => { return; } + const getIsOnGalleryTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'gallery'; + const onDragEnter = () => { // If we are already on the gallery tab, do nothing - if (selectActiveTabCanvasRightPanel(store.getState()) === 'gallery') { + if (getIsOnGalleryTab()) { return; } @@ -185,7 +191,7 @@ const PanelTabs = memo(() => { const onDragLeave = () => { // Set the state to idle or pending depending on the current tab - if (selectActiveTabCanvasRightPanel(store.getState()) === 'gallery') { + if (getIsOnGalleryTab()) { setGalleryTabDndState('idle'); } else { setGalleryTabDndState('potential'); @@ -209,11 +215,11 @@ const PanelTabs = memo(() => { }), monitorForElements({ canMonitor: ({ source }) => { - if (!singleImageSourceApi.typeGuard(source.data) || !multipleImageSourceApi.typeGuard(source.data)) { + if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) { return false; } // Only monitor if we are not already on the gallery tab - return selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery'; + return !getIsOnGalleryTab(); }, onDragStart, }), @@ -223,7 +229,7 @@ const PanelTabs = memo(() => { onDragLeave, }), monitorForExternal({ - canMonitor: () => selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery', + canMonitor: () => !getIsOnGalleryTab(), onDragStart, }) ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx index d45dc7402bf..9c40863166d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -11,9 +11,9 @@ import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityA import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import type { ReplaceCanvasEntityObjectsWithImageDndTargetData } from 'features/dnd/dnd'; +import { replaceCanvasEntityObjectsWithImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; -import type { ReplaceCanvasEntityObjectsWithImageActionData} from 'features/imageActions/actions'; -import {replaceCanvasEntityObjectsWithImageActionApi } from 'features/imageActions/actions'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,8 +28,8 @@ export const ControlLayer = memo(({ id }: Props) => { () => ({ id, type: 'control_layer' }), [id] ); - const targetData = useMemo( - () => replaceCanvasEntityObjectsWithImageActionApi.getData({ entityIdentifier }, entityIdentifier.id), + const dndTargetData = useMemo( + () => replaceCanvasEntityObjectsWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id), [entityIdentifier] ); @@ -47,7 +47,12 @@ export const ControlLayer = memo(({ id }: Props) => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx index 3875e3ae58c..8f4d7dfd5d3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx @@ -3,10 +3,13 @@ import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import type { ImageWithDims } from 'features/controlLayers/store/types'; +import type { + setGlobalReferenceImageDndTarget, + setRegionalGuidanceReferenceImageDndTarget, +} from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; -import type { SetGlobalReferenceImageActionData, SetRegionalGuidanceReferenceImageActionData } from 'features/imageActions/actions'; import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; @@ -28,43 +31,51 @@ const sx = { }, } satisfies SystemStyleObject; -type Props = { +type Props = { image: ImageWithDims | null; onChangeImage: (imageDTO: ImageDTO | null) => void; - targetData: SetGlobalReferenceImageActionData | SetRegionalGuidanceReferenceImageActionData; + dndTarget: T; + dndTargetData: ReturnType; }; -export const IPAdapterImagePreview = memo(({ image, onChangeImage, targetData }: Props) => { - const { t } = useTranslation(); - const isConnected = useStore($isConnected); - const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken); - const handleResetControlImage = useCallback(() => { - onChangeImage(null); - }, [onChangeImage]); +export const IPAdapterImagePreview = memo( + ({ + image, + onChangeImage, + dndTarget, + dndTargetData, + }: Props) => { + const { t } = useTranslation(); + const isConnected = useStore($isConnected); + const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken); + const handleResetControlImage = useCallback(() => { + onChangeImage(null); + }, [onChangeImage]); - useEffect(() => { - if (isConnected && isError) { - handleResetControlImage(); - } - }, [handleResetControlImage, isError, isConnected]); + useEffect(() => { + if (isConnected && isError) { + handleResetControlImage(); + } + }, [handleResetControlImage, isError, isConnected]); - return ( - - {imageDTO && ( - <> - - - } - tooltip={t('common.reset')} - /> - - - )} - - - ); -}); + return ( + + {imageDTO && ( + <> + + + } + tooltip={t('common.reset')} + /> + + + )} + + + ); + } +); IPAdapterImagePreview.displayName = 'IPAdapterImagePreview'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx index 39aa6979604..218e7571d25 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -19,8 +19,8 @@ import { import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import type { SetGlobalReferenceImageActionData} from 'features/imageActions/actions'; -import {setGlobalReferenceImageActionApi } from 'features/imageActions/actions'; +import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd'; +import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold } from 'react-icons/pi'; @@ -81,8 +81,8 @@ export const IPAdapterSettings = memo(() => { [dispatch, entityIdentifier] ); - const targetData = useMemo( - () => setGlobalReferenceImageActionApi.getData({ entityIdentifier }, ipAdapter.image?.image_name), + const dndTargetData = useMemo( + () => setGlobalReferenceImageDndTarget.getData({ entityIdentifier }, ipAdapter.image?.image_name), [entityIdentifier, ipAdapter.image?.image_name] ); const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); @@ -118,7 +118,12 @@ export const IPAdapterSettings = memo(() => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 1b6a17328f2..bad65ebd92e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -8,9 +8,9 @@ import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAd import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import type { ReplaceCanvasEntityObjectsWithImageDndTargetData } from 'features/dnd/dnd'; +import { replaceCanvasEntityObjectsWithImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; -import type { ReplaceCanvasEntityObjectsWithImageActionData} from 'features/imageActions/actions'; -import {replaceCanvasEntityObjectsWithImageActionApi } from 'features/imageActions/actions'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -22,8 +22,8 @@ export const RasterLayer = memo(({ id }: Props) => { const { t } = useTranslation(); const isBusy = useCanvasIsBusy(); const entityIdentifier = useMemo>(() => ({ id, type: 'raster_layer' }), [id]); - const targetData = useMemo( - () => replaceCanvasEntityObjectsWithImageActionApi.getData({ entityIdentifier }, entityIdentifier.id), + const dndTargetData = useMemo( + () => replaceCanvasEntityObjectsWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id), [entityIdentifier] ); @@ -37,7 +37,12 @@ export const RasterLayer = memo(({ id }: Props) => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index 366215ca1ac..8ee97d351d2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -20,8 +20,8 @@ import { } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors'; import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; -import type { SetRegionalGuidanceReferenceImageActionData} from 'features/imageActions/actions'; -import {setRegionalGuidanceReferenceImageActionApi } from 'features/imageActions/actions'; +import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd/dnd'; +import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi'; @@ -92,9 +92,12 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro [dispatch, entityIdentifier, referenceImageId] ); - const targetData = useMemo( + const dndTargetData = useMemo( () => - setRegionalGuidanceReferenceImageActionApi.getData({ entityIdentifier, referenceImageId }, ipAdapter.image?.image_name), + setRegionalGuidanceReferenceImageDndTarget.getData( + { entityIdentifier, referenceImageId }, + ipAdapter.image?.image_name + ), [entityIdentifier, ipAdapter.image?.image_name, referenceImageId] ); @@ -145,7 +148,12 @@ export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Pro - + diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx index f2ed0cc6379..7232d0fc2af 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx @@ -1,8 +1,8 @@ import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import { Flex, Heading } from '@invoke-ai/ui-library'; +import type { MultipleImageDndSourceData } from 'features/dnd/dnd'; import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util'; -import type { MultipleImageSourceData } from 'features/imageActions/actions'; import { memo } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; @@ -39,7 +39,7 @@ export const createMultipleImageDragPreview = (arg: DndDragPreviewMultipleImageS createPortal(, arg.container); type SetMultipleDragPreviewArg = { - multipleImageDndData: MultipleImageSourceData; + multipleImageDndData: MultipleImageDndSourceData; setDragPreviewState: (dragPreviewState: DndDragPreviewMultipleImageState | null) => void; onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>; }; diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx index b00c601bd9a..d25241a1d91 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleImage.tsx @@ -1,8 +1,8 @@ import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import { chakra, Flex } from '@invoke-ai/ui-library'; +import type { SingleImageDndSourceData } from 'features/dnd/dnd'; import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util'; -import type { SingleImageSourceData } from 'features/imageActions/actions'; import { memo } from 'react'; import { createPortal } from 'react-dom'; import type { ImageDTO } from 'services/api/types'; @@ -37,7 +37,7 @@ export const createSingleImageDragPreview = (arg: DndDragPreviewSingleImageState createPortal(, arg.container); type SetSingleDragPreviewArg = { - singleImageDndData: SingleImageSourceData; + singleImageDndData: SingleImageDndSourceData; setDragPreviewState: (dragPreviewState: DndDragPreviewSingleImageState | null) => void; onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>; }; diff --git a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx index 4c7c643a501..c19ca5ecad8 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx @@ -7,15 +7,9 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box } from '@invoke-ai/ui-library'; import { getStore } from 'app/store/nanostores/store'; import { useAppDispatch } from 'app/store/storeHooks'; +import type { AnyDndTarget } from 'features/dnd/dnd'; import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; import type { DndTargetState } from 'features/dnd/types'; -import type { MultipleImageAction, RecordUnknown, SingleImageAction } from 'features/imageActions/actions'; -import { - multipleImageActions, - multipleImageSourceApi, - singleImageActions, - singleImageSourceApi, -} from 'features/imageActions/actions'; import { memo, useEffect, useRef, useState } from 'react'; import { uploadImage } from 'services/api/endpoints/images'; import { z } from 'zod'; @@ -65,15 +59,16 @@ const zUploadFile = z (file) => ({ message: `File extension .${file.name.split('.').at(-1)} is not supported` }) ); -type Props = { - targetData: SingleImageAction | MultipleImageAction; +type Props = { + dndTarget: T; + dndTargetData: ReturnType; label: string; externalLabel?: string; isDisabled?: boolean; }; -export const DndDropTarget = memo((props: Props) => { - const { targetData, label, externalLabel = label, isDisabled } = props; +export const DndDropTarget = memo((props: Props) => { + const { dndTarget, dndTargetData, label, externalLabel = label, isDisabled } = props; const [dndState, setDndState] = useState('idle'); const [dndOrigin, setDndOrigin] = useState<'element' | 'external' | null>(null); const ref = useRef(null); @@ -90,43 +85,11 @@ export const DndDropTarget = memo((props: Props) => { const { dispatch, getState } = getStore(); - const isValidDrop = (sourceData: RecordUnknown, targetData: RecordUnknown) => { - if (singleImageSourceApi.typeGuard(sourceData)) { - for (const target of singleImageActions) { - if (target.typeGuard(targetData)) { - // TS cannot infer `targetData` but we've just checked it. This is safe. - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - if (target.isValid(sourceData, targetData as any, dispatch, getState)) { - return true; - } - } - } - } - - if (multipleImageSourceApi.typeGuard(sourceData)) { - for (const target of multipleImageActions) { - if (target.typeGuard(targetData)) { - // TS cannot infer `targetData` but we've just checked it. This is safe. - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - if (target.isValid(sourceData, targetData as any, dispatch, getState)) { - return true; - } - } - } - } - - return false; - }; - return combine( dropTargetForElements({ element, canDrop: ({ source }) => { - const sourceData = source.data; - if (sourceData.id === targetData.id) { - return false; - } - return isValidDrop(sourceData, targetData); + return dndTarget.isValid({ sourceData: source.data, targetData: dndTargetData, dispatch, getState }); }, onDragEnter: () => { setDndState('over'); @@ -134,15 +97,11 @@ export const DndDropTarget = memo((props: Props) => { onDragLeave: () => { setDndState('potential'); }, - getData: () => targetData, + getData: () => dndTargetData, }), monitorForElements({ canMonitor: ({ source }) => { - const sourceData = source.data; - if (sourceData.id === targetData.id) { - return false; - } - return isValidDrop(sourceData, targetData); + return dndTarget.isValid({ sourceData: source.data, targetData: dndTargetData, dispatch, getState }); }, onDragStart: () => { setDndOrigin('element'); @@ -154,7 +113,7 @@ export const DndDropTarget = memo((props: Props) => { }, }) ); - }, [targetData, dispatch, isDisabled]); + }, [dispatch, isDisabled, dndTarget, dndTargetData]); useEffect(() => { const element = ref.current; @@ -218,7 +177,7 @@ export const DndDropTarget = memo((props: Props) => { }, }) ); - }, [targetData, dispatch, isDisabled]); + }, [dispatch, isDisabled]); return ( diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index 07eab6897a5..807e7df9da3 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -2,10 +2,10 @@ import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { ImageProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Image } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; +import { singleImageDndSource } from 'features/dnd/dnd'; import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; -import { singleImageSourceApi } from 'features/imageActions/actions'; import { memo, useEffect, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -36,7 +36,7 @@ export const DndImage = memo(({ imageDTO, ...rest }: Props) => { } return draggable({ element, - getInitialData: () => singleImageSourceApi.getData({ imageDTO }, imageDTO.image_name), + getInitialData: () => singleImageDndSource.getData({ imageDTO }, imageDTO.image_name), onDragStart: () => { setIsDragging(true); }, @@ -44,7 +44,7 @@ export const DndImage = memo(({ imageDTO, ...rest }: Props) => { setIsDragging(false); }, onGenerateDragPreview: (args) => { - if (singleImageSourceApi.typeGuard(args.source.data)) { + if (singleImageDndSource.typeGuard(args.source.data)) { setSingleImageDragPreview({ singleImageDndData: args.source.data, onGenerateDragPreviewArgs: args, diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts new file mode 100644 index 00000000000..4fabfafcc0d --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -0,0 +1,485 @@ +import type { AppDispatch, RootState } from 'app/store/store'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import type { + CanvasEntityIdentifier, + CanvasEntityType, + CanvasRenderableEntityIdentifier, +} from 'features/controlLayers/store/types'; +import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; +import type { BoardId } from 'features/gallery/store/types'; +import { + addImagesToBoard, + createNewCanvasEntityFromImage, + newCanvasFromImage, + removeImagesFromBoard, + replaceCanvasEntityObjectsWithImage, + setComparisonImage, + setGlobalReferenceImage, + setNodeImageFieldImage, + setRegionalGuidanceReferenceImage, + setUpscaleInitialImage, +} from 'features/imageActions/actions'; +import type { FieldIdentifier } from 'features/nodes/types/field'; +import type { ImageDTO } from 'services/api/types'; +import type { JsonObject } from 'type-fest'; + +export type RecordUnknown = Record; + +export type DndData< + Type extends string = string, + PrivateKey extends symbol = symbol, + Payload extends JsonObject | void = JsonObject | void, +> = { + [key in PrivateKey]: true; +} & { + id: string; + type: Type; + payload: Payload; +}; + +export const buildTypeAndKey = (type: T) => { + const key = Symbol(type); + return { type, key } as const; +}; + +export const buildTypeGuard = (key: symbol) => { + const typeGuard = (val: RecordUnknown): val is T => Boolean(val[key]); + return typeGuard; +}; + +export const buildGetData = (key: symbol, type: T['type']) => { + const getData = (payload: T['payload'] extends undefined ? void : T['payload'], id?: string): T => + ({ + [key]: true, + id: id ?? getPrefixedId(type), + type, + payload, + }) as T; + return getData; +}; + +export type DndSource = { + key: symbol; + type: SourceData['type']; + typeGuard: ReturnType>; + getData: ReturnType>; +}; +//#region Single Image +const _singleImage = buildTypeAndKey('single-image'); +export type SingleImageDndSourceData = DndData< + typeof _singleImage.type, + typeof _singleImage.key, + { imageDTO: ImageDTO } +>; +export const singleImageDndSource: DndSource = { + ..._singleImage, + typeGuard: buildTypeGuard(_singleImage.key), + getData: buildGetData(_singleImage.key, _singleImage.type), +}; +//#endregion + +//#region Multiple Image +const _multipleImage = buildTypeAndKey('multiple-image'); +export type MultipleImageDndSourceData = DndData< + typeof _multipleImage.type, + typeof _multipleImage.key, + { imageDTOs: ImageDTO[]; boardId: BoardId } +>; +export const multipleImageDndSource: DndSource = { + ..._multipleImage, + typeGuard: buildTypeGuard(_multipleImage.key), + getData: buildGetData(_multipleImage.key, _multipleImage.type), +}; +//#endregion + +const _singleCanvasEntity = buildTypeAndKey('single-canvas-entity'); +type SingleCanvasEntityDndSourceData = DndData< + typeof _singleCanvasEntity.type, + typeof _singleCanvasEntity.key, + { entityIdentifier: CanvasEntityIdentifier } +>; +export const singleCanvasEntityDndSource: DndSource = { + ..._singleCanvasEntity, + typeGuard: buildTypeGuard(_singleCanvasEntity.key), + getData: buildGetData(_singleCanvasEntity.key, _singleCanvasEntity.type), +}; + +const _singleWorkflowField = buildTypeAndKey('single-workflow-field'); +type SingleWorkflowFieldDndSourceData = DndData< + typeof _singleWorkflowField.type, + typeof _singleWorkflowField.key, + { fieldIdentifier: FieldIdentifier } +>; +export const singleWorkflowFieldDndSource: DndSource = { + ..._singleWorkflowField, + typeGuard: buildTypeGuard(_singleWorkflowField.key), + getData: buildGetData(_singleWorkflowField.key, _singleWorkflowField.type), +}; + +type DndTarget = { + key: symbol; + type: TargetData['type']; + typeGuard: ReturnType>; + getData: ReturnType>; + isValid: (arg: { + sourceData: RecordUnknown; + targetData: TargetData; + dispatch: AppDispatch; + getState: () => RootState; + }) => boolean; + handler: (arg: { + sourceData: SourceData; + targetData: TargetData; + dispatch: AppDispatch; + getState: () => RootState; + }) => void; +}; + +//#region Set Global Reference Image +const _setGlobalReferenceImage = buildTypeAndKey('set-global-reference-image'); +export type SetGlobalReferenceImageDndTargetData = DndData< + typeof _setGlobalReferenceImage.type, + typeof _setGlobalReferenceImage.key, + { entityIdentifier: CanvasEntityIdentifier<'reference_image'> } +>; +export const setGlobalReferenceImageDndTarget: DndTarget< + SetGlobalReferenceImageDndTargetData, + SingleImageDndSourceData +> = { + ..._setGlobalReferenceImage, + typeGuard: buildTypeGuard(_setGlobalReferenceImage.key), + getData: buildGetData(_setGlobalReferenceImage.key, _setGlobalReferenceImage.type), + isValid: ({ sourceData }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: ({ sourceData, targetData, dispatch }) => { + const { imageDTO } = sourceData.payload; + const { entityIdentifier } = targetData.payload; + setGlobalReferenceImage({ entityIdentifier, imageDTO, dispatch }); + }, +}; +//#endregion + +//#region Set Regional Guidance Reference Image +const _setRegionalGuidanceReferenceImage = buildTypeAndKey('set-regional-guidance-reference-image'); +export type SetRegionalGuidanceReferenceImageDndTargetData = DndData< + typeof _setRegionalGuidanceReferenceImage.type, + typeof _setRegionalGuidanceReferenceImage.key, + { entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>; referenceImageId: string } +>; +export const setRegionalGuidanceReferenceImageDndTarget: DndTarget< + SetRegionalGuidanceReferenceImageDndTargetData, + SingleImageDndSourceData +> = { + ..._setRegionalGuidanceReferenceImage, + typeGuard: buildTypeGuard(_setRegionalGuidanceReferenceImage.key), + getData: buildGetData(_setRegionalGuidanceReferenceImage.key, _setRegionalGuidanceReferenceImage.type), + isValid: ({ sourceData }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: ({ sourceData, targetData, dispatch }) => { + const { imageDTO } = sourceData.payload; + const { entityIdentifier, referenceImageId } = targetData.payload; + setRegionalGuidanceReferenceImage({ imageDTO, entityIdentifier, referenceImageId, dispatch }); + }, +}; +//#endregion + +//# Set Upscale Initial Image +const _setUpscaleInitialImage = buildTypeAndKey('set-upscale-initial-image'); +export type SetUpscaleInitialImageDndTargetData = DndData< + typeof _setUpscaleInitialImage.type, + typeof _setUpscaleInitialImage.key, + void +>; +export const setUpscaleInitialImageDndTarget: DndTarget = + { + ..._setUpscaleInitialImage, + typeGuard: buildTypeGuard(_setUpscaleInitialImage.key), + getData: buildGetData(_setUpscaleInitialImage.key, _setUpscaleInitialImage.type), + isValid: ({ sourceData }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: ({ sourceData, dispatch }) => { + const { imageDTO } = sourceData.payload; + setUpscaleInitialImage({ imageDTO, dispatch }); + }, + }; +//#endregion + +//#region Set Node Image Field Image +const _setNodeImageFieldImage = buildTypeAndKey('set-node-image-field-image'); +export type SetNodeImageFieldImageDndTargetData = DndData< + typeof _setNodeImageFieldImage.type, + typeof _setNodeImageFieldImage.key, + { fieldIdentifer: FieldIdentifier } +>; +export const setNodeImageFieldImageDndTarget: DndTarget = + { + ..._setNodeImageFieldImage, + typeGuard: buildTypeGuard(_setNodeImageFieldImage.key), + getData: buildGetData(_setNodeImageFieldImage.key, _setNodeImageFieldImage.type), + isValid: ({ sourceData }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: ({ sourceData, targetData, dispatch }) => { + const { imageDTO } = sourceData.payload; + const { fieldIdentifer } = targetData.payload; + setNodeImageFieldImage({ fieldIdentifer, imageDTO, dispatch }); + }, + }; +//#endregion + +//# Set Comparison Image +const _setComparisonImage = buildTypeAndKey('set-comparison-image'); +export type SetComparisonImageDndTargetData = DndData< + typeof _setComparisonImage.type, + typeof _setComparisonImage.key, + void +>; +export const setComparisonImageDndTarget: DndTarget = { + ..._setComparisonImage, + typeGuard: buildTypeGuard(_setComparisonImage.key), + getData: buildGetData(_setComparisonImage.key, _setComparisonImage.type), + isValid: ({ sourceData, getState }) => { + if (!singleImageDndSource.typeGuard(sourceData)) { + return false; + } + const { firstImage, secondImage } = selectComparisonImages(getState()); + // Do not allow the same images to be selected for comparison + if (sourceData.payload.imageDTO.image_name === firstImage?.image_name) { + return false; + } + if (sourceData.payload.imageDTO.image_name === secondImage?.image_name) { + return false; + } + return true; + }, + handler: ({ sourceData, dispatch }) => { + const { imageDTO } = sourceData.payload; + setComparisonImage({ imageDTO, dispatch }); + }, +}; +//#endregion + +//#region New Canvas Entity from Image +const _newCanvasEntity = buildTypeAndKey('new-canvas-entity-from-image'); +export type NewCanvasEntityFromImageDndTargetData = DndData< + typeof _newCanvasEntity.type, + typeof _newCanvasEntity.key, + { type: CanvasEntityType | 'regional_guidance_with_reference_image' } +>; +export const newCanvasEntityFromImageDndTarget: DndTarget< + NewCanvasEntityFromImageDndTargetData, + SingleImageDndSourceData +> = { + ..._newCanvasEntity, + typeGuard: buildTypeGuard(_newCanvasEntity.key), + getData: buildGetData(_newCanvasEntity.key, _newCanvasEntity.type), + isValid: ({ sourceData }) => { + if (!singleImageDndSource.typeGuard(sourceData)) { + return false; + } + return true; + }, + handler: ({ sourceData, targetData, dispatch, getState }) => { + const { type } = targetData.payload; + const { imageDTO } = sourceData.payload; + createNewCanvasEntityFromImage({ type, imageDTO, dispatch, getState }); + }, +}; + +//#endregion + +//#region New Canvas from Image +const _newCanvasFromImage = buildTypeAndKey('new-canvas-from-image'); +export type NewCanvasFromImageDndTargetData = DndData< + typeof _newCanvasFromImage.type, + typeof _newCanvasFromImage.key, + { type: CanvasEntityType | 'regional_guidance_with_reference_image' } +>; + +export const newCanvasFromImageDndTarget: DndTarget = { + ..._newCanvasFromImage, + typeGuard: buildTypeGuard(_newCanvasFromImage.key), + getData: buildGetData(_newCanvasFromImage.key, _newCanvasFromImage.type), + isValid: ({ sourceData }) => { + if (!singleImageDndSource.typeGuard(sourceData)) { + return false; + } + return true; + }, + handler: ({ sourceData, targetData, dispatch, getState }) => { + const { type } = targetData.payload; + const { imageDTO } = sourceData.payload; + newCanvasFromImage({ type, imageDTO, dispatch, getState }); + }, +}; + +//#endregion + +//#region Replace Canvas Entity Objects With Image +const _replaceCanvasEntityObjectsWithImage = buildTypeAndKey('replace-canvas-entity-objects-with-image'); +export type ReplaceCanvasEntityObjectsWithImageDndTargetData = DndData< + typeof _replaceCanvasEntityObjectsWithImage.type, + typeof _replaceCanvasEntityObjectsWithImage.key, + { entityIdentifier: CanvasRenderableEntityIdentifier } +>; +export const replaceCanvasEntityObjectsWithImageDndTarget: DndTarget< + ReplaceCanvasEntityObjectsWithImageDndTargetData, + SingleImageDndSourceData +> = { + ..._replaceCanvasEntityObjectsWithImage, + typeGuard: buildTypeGuard(_replaceCanvasEntityObjectsWithImage.key), + getData: buildGetData(_replaceCanvasEntityObjectsWithImage.key, _replaceCanvasEntityObjectsWithImage.type), + isValid: ({ sourceData }) => { + if (!singleImageDndSource.typeGuard(sourceData)) { + return false; + } + return true; + }, + handler: ({ sourceData, targetData, dispatch, getState }) => { + const { imageDTO } = sourceData.payload; + const { entityIdentifier } = targetData.payload; + replaceCanvasEntityObjectsWithImage({ imageDTO, entityIdentifier, dispatch, getState }); + }, +}; +//#endregion + +//#region Add To Board +const _addToBoard = buildTypeAndKey('add-to-board'); +export type AddImageToBoardDndTargetData = DndData< + typeof _addToBoard.type, + typeof _addToBoard.key, + { boardId: BoardId } +>; +export const addImageToBoardDndTarget: DndTarget< + AddImageToBoardDndTargetData, + SingleImageDndSourceData | MultipleImageDndSourceData +> = { + ..._addToBoard, + typeGuard: buildTypeGuard(_addToBoard.key), + getData: buildGetData(_addToBoard.key, _addToBoard.type), + isValid: ({ sourceData, targetData }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } + if (multipleImageDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.boardId; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } + return false; + }, + handler: ({ sourceData, targetData, dispatch }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + const { imageDTO } = sourceData.payload; + const { boardId } = targetData.payload; + addImagesToBoard({ imageDTOs: [imageDTO], boardId, dispatch }); + } + + if (multipleImageDndSource.typeGuard(sourceData)) { + const { imageDTOs } = sourceData.payload; + const { boardId } = targetData.payload; + addImagesToBoard({ imageDTOs, boardId, dispatch }); + } + }, +}; + +//#endregion + +//#region Remove From Board +const _removeFromBoard = buildTypeAndKey('remove-from-board'); +export type RemoveImageFromBoardDndTargetData = DndData< + typeof _removeFromBoard.type, + typeof _removeFromBoard.key, + void +>; +export const removeImageFromBoardDndTarget: DndTarget< + RemoveImageFromBoardDndTargetData, + SingleImageDndSourceData | MultipleImageDndSourceData +> = { + ..._removeFromBoard, + typeGuard: buildTypeGuard(_removeFromBoard.key), + getData: buildGetData(_removeFromBoard.key, _removeFromBoard.type), + isValid: ({ sourceData }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; + return currentBoard !== 'none'; + } + + if (multipleImageDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.boardId; + return currentBoard !== 'none'; + } + + return false; + }, + handler: ({ sourceData, dispatch }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + const { imageDTO } = sourceData.payload; + removeImagesFromBoard({ imageDTOs: [imageDTO], dispatch }); + } + + if (multipleImageDndSource.typeGuard(sourceData)) { + const { imageDTOs } = sourceData.payload; + removeImagesFromBoard({ imageDTOs, dispatch }); + } + }, +}; + +//#endregion + +export const dndTargets = [ + // Single Image + setGlobalReferenceImageDndTarget, + setRegionalGuidanceReferenceImageDndTarget, + setUpscaleInitialImageDndTarget, + setNodeImageFieldImageDndTarget, + setComparisonImageDndTarget, + newCanvasEntityFromImageDndTarget, + replaceCanvasEntityObjectsWithImageDndTarget, + addImageToBoardDndTarget, + removeImageFromBoardDndTarget, + // Single or Multiple Image + addImageToBoardDndTarget, + removeImageFromBoardDndTarget, +] as const; + +export type AnyDndTarget = (typeof dndTargets)[number]; + +export const isValidDrop = (arg: { + sourceData: RecordUnknown; + targetData: RecordUnknown; + dispatch: AppDispatch; + getState: () => RootState; +}) => { + if (arg.sourceData.id === arg.targetData.id) { + return false; + } + for (const dndTarget of dndTargets) { + if (!dndTarget.typeGuard(arg.targetData)) { + continue; + } + // TS cannot infer `targetData` but we've just checked it. This is safe. + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + if (!dndTarget.isValid(arg)) { + return true; + } + } + return false; +}; diff --git a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts index 1a2a58d81e7..5c36e5b71b1 100644 --- a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts +++ b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts @@ -7,7 +7,7 @@ import { logger } from 'app/logging/logger'; import { getStore } from 'app/store/nanostores/store'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { parseify } from 'common/util/serialize'; -import { multipleImageSourceApi, multipleImageActions, singleImageSourceApi, singleImageActions } from 'features/imageActions/actions'; +import { dndTargets, multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; import { useEffect } from 'react'; const log = logger('dnd'); @@ -22,7 +22,7 @@ export const useDndMonitor = () => { const sourceData = source.data; // Check for allowed sources - if (!singleImageSourceApi.typeGuard(sourceData) && !multipleImageSourceApi.typeGuard(sourceData)) { + if (!singleImageDndSource.typeGuard(sourceData) && !multipleImageDndSource.typeGuard(sourceData)) { return false; } @@ -31,6 +31,7 @@ export const useDndMonitor = () => { onDrop: ({ source, location }) => { const target = location.current.dropTargets[0]; if (!target) { + log.warn('No dnd target'); return; } @@ -39,38 +40,23 @@ export const useDndMonitor = () => { const { dispatch, getState } = getStore(); - // Check for allowed sources - if (singleImageSourceApi.typeGuard(sourceData)) { - for (const target of singleImageActions) { - if (target.typeGuard(targetData)) { - // TS cannot infer `targetData` but we've just checked it. This is safe. - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - if (target.isValid(sourceData, targetData as any, dispatch, getState)) { - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - target.handler(sourceData, targetData as any, dispatch, getState); - log.debug(parseify({ sourceData, targetData }), 'Dropped single image'); - return; - } - } + for (const dndTarget of dndTargets) { + if (!dndTarget.typeGuard(targetData)) { + continue; } - } - - if (multipleImageSourceApi.typeGuard(sourceData)) { - for (const target of multipleImageActions) { - if (target.typeGuard(targetData)) { - // TS cannot infer `targetData` but we've just checked it. This is safe. - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - if (target.isValid(sourceData, targetData as any, dispatch, getState)) { - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - target.handler(sourceData, targetData as any, dispatch, getState); - log.debug(parseify({ sourceData, targetData }), 'Dropped multiple images'); - return; - } - } + // TS cannot infer `targetData` but we've just checked it. This is safe. + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const arg = { sourceData, targetData: targetData as any, dispatch, getState }; + if (!dndTarget.isValid(arg)) { + continue; } + + log.debug(parseify({ sourceData, targetData }), 'Handling dnd drop'); + dndTarget.handler(arg); + return; } - log.warn(parseify({ sourceData, targetData }), 'Invalid image drop'); + log.warn(parseify({ sourceData, targetData }), 'Invalid drop'); }, }), monitorForExternal({ diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index fcb834aa480..2a4c97029a7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -2,6 +2,8 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import type { AddImageToBoardDndTargetData } from 'features/dnd/dnd'; +import { addImageToBoardDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu'; @@ -13,8 +15,6 @@ import { selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; -import type { AddImageToBoardActionData} from 'features/imageActions/actions'; -import {addImageToBoardActionApi } from 'features/imageActions/actions'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArchiveBold, PiImageSquare } from 'react-icons/pi'; @@ -45,7 +45,10 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { } }, [selectedBoardId, board.board_id, autoAssignBoardOnClick, autoAddBoardId, dispatch]); - const targetData = useMemo(() => addImageToBoardActionApi.getData({ boardId: board.board_id }), [board.board_id]); + const dndTargetData = useMemo( + () => addImageToBoardDndTarget.getData({ boardId: board.board_id }), + [board.board_id] + ); return ( @@ -78,7 +81,12 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { )} - + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index f434e90fb7a..84a57a44314 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -1,6 +1,8 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import type { RemoveImageFromBoardDndTargetData } from 'features/dnd/dnd'; +import { removeImageFromBoardDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { AutoAddBadge } from 'features/gallery/components/Boards/AutoAddBadge'; import { BoardTooltip } from 'features/gallery/components/Boards/BoardsList/BoardTooltip'; @@ -11,8 +13,6 @@ import { selectBoardSearchText, } from 'features/gallery/store/gallerySelectors'; import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; -import type { RemoveImageFromBoardActionData} from 'features/imageActions/actions'; -import {removeImageFromBoardActionApi } from 'features/imageActions/actions'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; @@ -44,7 +44,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { } }, [dispatch, autoAssignBoardOnClick]); - const targetData = useMemo(() => removeImageFromBoardActionApi.getData(), []); + const dndTargetData = useMemo(() => removeImageFromBoardDndTarget.getData(), []); const { t } = useTranslation(); @@ -97,7 +97,12 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { )} - + ); }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx index f0abdba0bfd..86cdcbc5382 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewFromImageSubMenu.tsx @@ -1,17 +1,12 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/nanostores/store'; -import { useAppDispatch } from 'app/store/storeHooks'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { sentImageToCanvas } from 'features/gallery/store/actions'; -import { - newCanvasEntityFromImageActionApi, - newCanvasFromImageActionApi, - singleImageSourceApi, -} from 'features/imageActions/actions'; +import { createNewCanvasEntityFromImage, newCanvasFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; import { setActiveTab } from 'features/ui/store/uiSlice'; import { memo, useCallback } from 'react'; @@ -22,15 +17,13 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { const { t } = useTranslation(); const subMenu = useSubMenu(); const store = useAppStore(); - const dispatch = useAppDispatch(); const imageDTO = useImageDTOContext(); const imageViewer = useImageViewer(); const isBusy = useCanvasIsBusy(); const onClickNewCanvasWithRasterLayerFromImage = useCallback(() => { - const targetData = newCanvasFromImageActionApi.getData({ type: 'raster_layer' }); - const sourceData = singleImageSourceApi.getData({ imageDTO }); - newCanvasFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); + const { dispatch, getState } = store; + newCanvasFromImage({ imageDTO, type: 'raster_layer', dispatch, getState }); dispatch(setActiveTab('canvas')); imageViewer.close(); toast({ @@ -38,12 +31,11 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); + }, [imageDTO, imageViewer, store, t]); const onClickNewCanvasWithControlLayerFromImage = useCallback(() => { - const targetData = newCanvasFromImageActionApi.getData({ type: 'control_layer' }); - const sourceData = singleImageSourceApi.getData({ imageDTO }); - newCanvasFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); + const { dispatch, getState } = store; + newCanvasFromImage({ imageDTO, type: 'control_layer', dispatch, getState }); dispatch(setActiveTab('canvas')); imageViewer.close(); toast({ @@ -51,12 +43,11 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); + }, [imageDTO, imageViewer, store, t]); const onClickNewRasterLayerFromImage = useCallback(() => { - const targetData = newCanvasEntityFromImageActionApi.getData({ type: 'raster_layer' }); - const sourceData = singleImageSourceApi.getData({ imageDTO }); - newCanvasEntityFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); + const { dispatch, getState } = store; + createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState }); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); imageViewer.close(); @@ -65,12 +56,11 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); + }, [imageDTO, imageViewer, store, t]); const onClickNewControlLayerFromImage = useCallback(() => { - const targetData = newCanvasEntityFromImageActionApi.getData({ type: 'control_layer' }); - const sourceData = singleImageSourceApi.getData({ imageDTO }); - newCanvasEntityFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); + const { dispatch, getState } = store; + createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState }); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); imageViewer.close(); @@ -79,12 +69,11 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); + }, [imageDTO, imageViewer, store, t]); const onClickNewInpaintMaskFromImage = useCallback(() => { - const targetData = newCanvasEntityFromImageActionApi.getData({ type: 'inpaint_mask' }); - const sourceData = singleImageSourceApi.getData({ imageDTO }); - newCanvasEntityFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); + const { dispatch, getState } = store; + createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState }); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); imageViewer.close(); @@ -93,12 +82,11 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); + }, [imageDTO, imageViewer, store, t]); const onClickNewRegionalGuidanceFromImage = useCallback(() => { - const targetData = newCanvasEntityFromImageActionApi.getData({ type: 'regional_guidance' }); - const sourceData = singleImageSourceApi.getData({ imageDTO }); - newCanvasEntityFromImageActionApi.handler(sourceData, targetData, store.dispatch, store.getState); + const { dispatch, getState } = store; + createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState }); dispatch(sentImageToCanvas()); dispatch(setActiveTab('canvas')); imageViewer.close(); @@ -107,7 +95,7 @@ export const ImageMenuItemNewFromImageSubMenu = memo(() => { title: t('toast.sentToCanvas'), status: 'success', }); - }, [dispatch, imageDTO, imageViewer, store.dispatch, store.getState, t]); + }, [imageDTO, imageViewer, store, t]); return ( }> diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index ba15a881b8b..b27025de898 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -7,6 +7,7 @@ import { galleryImageClicked } from 'app/store/middleware/listenerMiddleware/lis import { useAppStore } from 'app/store/nanostores/store'; import { useAppSelector } from 'app/store/storeHooks'; import { useBoolean } from 'common/hooks/useBoolean'; +import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage'; import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage'; import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; @@ -17,7 +18,6 @@ import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid import { SizedSkeletonLoader } from 'features/gallery/components/ImageGrid/SizedSkeletonLoader'; import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; -import { multipleImageSourceApi, singleImageSourceApi } from 'features/imageActions/actions'; import type { MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; @@ -122,32 +122,32 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { // When we have multiple images selected, and the dragged image is part of the selection, initiate a // multi-image drag. if (gallery.selection.length > 1 && gallery.selection.includes(imageDTO)) { - return multipleImageSourceApi.getData({ + return multipleImageDndSource.getData({ imageDTOs: gallery.selection, boardId: gallery.selectedBoardId, }); } // Otherwise, initiate a single-image drag - return singleImageSourceApi.getData({ imageDTO }, imageDTO.image_name); + return singleImageDndSource.getData({ imageDTO }, imageDTO.image_name); }, // This is a "local" drag start event, meaning that it is only called when this specific image is dragged. onDragStart: ({ source }) => { // When we start dragging a single image, set the dragging state to true. This is only called when this // specific image is dragged. - if (singleImageSourceApi.typeGuard(source.data)) { + if (singleImageDndSource.typeGuard(source.data)) { setIsDragging(true); return; } }, onGenerateDragPreview: (args) => { - if (multipleImageSourceApi.typeGuard(args.source.data)) { + if (multipleImageDndSource.typeGuard(args.source.data)) { setMultipleImageDragPreview({ multipleImageDndData: args.source.data, onGenerateDragPreviewArgs: args, setDragPreviewState, }); - } else if (singleImageSourceApi.typeGuard(args.source.data)) { + } else if (singleImageDndSource.typeGuard(args.source.data)) { setSingleImageDragPreview({ singleImageDndData: args.source.data, onGenerateDragPreviewArgs: args, @@ -161,7 +161,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { onDragStart: ({ source }) => { // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the // selection. This is called for all drag events. - if (multipleImageSourceApi.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) { + if (multipleImageDndSource.typeGuard(source.data) && source.data.payload.imageDTOs.includes(imageDTO)) { setIsDragging(true); } }, 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 741dfcd9e46..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,13 +1,20 @@ +import type { SetComparisonImageDndTargetData } from 'features/dnd/dnd'; +import { setComparisonImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; -import { type SetComparisonImageActionData, setComparisonImageActionApi } from 'features/imageActions/actions'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; export const ImageComparisonDroppable = memo(() => { const { t } = useTranslation(); - const targetData = useMemo(() => setComparisonImageActionApi.getData(), []); + const dndTargetData = useMemo(() => setComparisonImageDndTarget.getData(), []); - return ; + return ( + + ); }); ImageComparisonDroppable.displayName = 'ImageComparisonDroppable'; diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index 562831e981f..1de69bdc8dc 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -26,7 +26,6 @@ import type { } from 'features/controlLayers/store/types'; import { imageDTOToImageObject, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; -import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import type { BoardId } from 'features/gallery/store/types'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; @@ -37,305 +36,95 @@ import { imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; -import type { JsonObject } from 'type-fest'; -export type RecordUnknown = Record; - -export type ActionData< - Type extends string = string, - PrivateKey extends symbol = symbol, - Payload extends JsonObject | void = JsonObject | void, -> = { - [key in PrivateKey]: true; -} & { - id: string; - type: Type; - payload: Payload; -}; - -export const buildTypeAndKey = (type: T) => { - const key = Symbol(type); - return { type, key } as const; -}; - -export const buildTypeGuard = (key: symbol) => { - const typeGuard = (val: RecordUnknown): val is T => Boolean(val[key]); - return typeGuard; +export const setGlobalReferenceImage = (arg: { + imageDTO: ImageDTO; + entityIdentifier: CanvasEntityIdentifier<'reference_image'>; + dispatch: AppDispatch; +}) => { + const { imageDTO, entityIdentifier, dispatch } = arg; + dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO })); }; -export const buildGetData = (key: symbol, type: T['type']) => { - const getData = (payload: T['payload'] extends undefined ? void : T['payload'], id?: string): T => - ({ - [key]: true, - id: id ?? getPrefixedId(type), - type, - payload, - }) as T; - return getData; +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 type ActionSourceApi = { - key: symbol; - type: SourceData['type']; - typeGuard: ReturnType>; - getData: ReturnType>; -}; -//#region Single Image -const _singleImage = buildTypeAndKey('single-image'); -export type SingleImageSourceData = ActionData< - typeof _singleImage.type, - typeof _singleImage.key, - { imageDTO: ImageDTO } ->; -export const singleImageSourceApi: ActionSourceApi = { - ..._singleImage, - typeGuard: buildTypeGuard(_singleImage.key), - getData: buildGetData(_singleImage.key, _singleImage.type), +export const setUpscaleInitialImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => { + const { imageDTO, dispatch } = arg; + dispatch(upscaleInitialImageChanged(imageDTO)); }; -//#endregion -//#region Multiple Image -const _multipleImage = buildTypeAndKey('multiple-image'); -export type MultipleImageSourceData = ActionData< - typeof _multipleImage.type, - typeof _multipleImage.key, - { imageDTOs: ImageDTO[]; boardId: BoardId } ->; -export const multipleImageSourceApi: ActionSourceApi = { - ..._multipleImage, - typeGuard: buildTypeGuard(_multipleImage.key), - getData: buildGetData(_multipleImage.key, _multipleImage.type), +export const setNodeImageFieldImage = (arg: { + imageDTO: ImageDTO; + fieldIdentifer: FieldIdentifier; + dispatch: AppDispatch; +}) => { + const { imageDTO, fieldIdentifer, dispatch } = arg; + dispatch(fieldImageValueChanged({ ...fieldIdentifer, value: imageDTO })); }; -//#endregion -type ActionTargetApi = { - key: symbol; - type: TargetData['type']; - typeGuard: ReturnType>; - getData: ReturnType>; - isValid: ( - sourceData: RecordUnknown, - targetData: TargetData, - dispatch: AppDispatch, - getState: () => RootState - ) => boolean; - handler: (sourceData: SourceData, targetData: TargetData, dispatch: AppDispatch, getState: () => RootState) => void; +export const setComparisonImage = (arg: { imageDTO: ImageDTO; dispatch: AppDispatch }) => { + const { imageDTO, dispatch } = arg; + dispatch(imageToCompareChanged(imageDTO)); }; -//#region Set Global Reference Image -const _setGlobalReferenceImage = buildTypeAndKey('set-global-reference-image'); -export type SetGlobalReferenceImageActionData = ActionData< - typeof _setGlobalReferenceImage.type, - typeof _setGlobalReferenceImage.key, - { entityIdentifier: CanvasEntityIdentifier<'reference_image'> } ->; -export const setGlobalReferenceImageActionApi: ActionTargetApi< - SetGlobalReferenceImageActionData, - SingleImageSourceData -> = { - ..._setGlobalReferenceImage, - typeGuard: buildTypeGuard(_setGlobalReferenceImage.key), - getData: buildGetData(_setGlobalReferenceImage.key, _setGlobalReferenceImage.type), - isValid: (sourceData, _targetData, _dispatch, _getState) => { - if (singleImageSourceApi.typeGuard(sourceData)) { - return true; - } - return false; - }, - handler: (sourceData, targetData, dispatch, _getState) => { - const { imageDTO } = sourceData.payload; - const { entityIdentifier } = targetData.payload; - dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO })); - }, -}; -//#endregion - -//#region Set Regional Guidance Reference Image -const _setRegionalGuidanceReferenceImage = buildTypeAndKey('set-regional-guidance-reference-image'); -export type SetRegionalGuidanceReferenceImageActionData = ActionData< - typeof _setRegionalGuidanceReferenceImage.type, - typeof _setRegionalGuidanceReferenceImage.key, - { entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>; referenceImageId: string } ->; -export const setRegionalGuidanceReferenceImageActionApi: ActionTargetApi< - SetRegionalGuidanceReferenceImageActionData, - SingleImageSourceData -> = { - ..._setRegionalGuidanceReferenceImage, - typeGuard: buildTypeGuard(_setRegionalGuidanceReferenceImage.key), - getData: buildGetData(_setRegionalGuidanceReferenceImage.key, _setRegionalGuidanceReferenceImage.type), - isValid: (sourceData, _targetData, _dispatch, _getState) => { - if (singleImageSourceApi.typeGuard(sourceData)) { - return true; - } - return false; - }, - handler: (sourceData, targetData, dispatch, _getState) => { - const { imageDTO } = sourceData.payload; - const { entityIdentifier, referenceImageId } = targetData.payload; - dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO })); - }, -}; -//#endregion - -//# Set Upscale Initial Image -const _setUpscaleInitialImage = buildTypeAndKey('set-upscale-initial-image'); -export type SetUpscaleInitialImageActionData = ActionData< - typeof _setUpscaleInitialImage.type, - typeof _setUpscaleInitialImage.key, - void ->; -export const setUpscaleInitialImageActionApi: ActionTargetApi = - { - ..._setUpscaleInitialImage, - typeGuard: buildTypeGuard(_setUpscaleInitialImage.key), - getData: buildGetData(_setUpscaleInitialImage.key, _setUpscaleInitialImage.type), - isValid: (sourceData, _targetData, _dispatch, _getState) => { - if (singleImageSourceApi.typeGuard(sourceData)) { - return true; - } - return false; - }, - handler: (sourceData, _targetData, dispatch, _getState) => { - const { imageDTO } = sourceData.payload; - dispatch(upscaleInitialImageChanged(imageDTO)); - }, +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 }, }; -//#endregion - -//#region Set Node Image Field Image -const _setNodeImageFieldImage = buildTypeAndKey('set-node-image-field-image'); -export type SetNodeImageFieldImageActionData = ActionData< - typeof _setNodeImageFieldImage.type, - typeof _setNodeImageFieldImage.key, - { fieldIdentifer: FieldIdentifier } ->; -export const setNodeImageFieldImageActionApi: ActionTargetApi = - { - ..._setNodeImageFieldImage, - typeGuard: buildTypeGuard(_setNodeImageFieldImage.key), - getData: buildGetData(_setNodeImageFieldImage.key, _setNodeImageFieldImage.type), - isValid: (sourceData, _targetData, _dispatch, _getState) => { - if (singleImageSourceApi.typeGuard(sourceData)) { - return true; - } - return false; - }, - handler: (sourceData, targetData, dispatch, _getState) => { - const { imageDTO } = sourceData.payload; - const { fieldIdentifer } = targetData.payload; - dispatch(fieldImageValueChanged({ ...fieldIdentifer, value: imageDTO })); - }, - }; -//#endregion - -//# Set Comparison Image -const _setComparisonImage = buildTypeAndKey('set-comparison-image'); -export type SetComparisonImageActionData = ActionData< - typeof _setComparisonImage.type, - typeof _setComparisonImage.key, - void ->; -export const setComparisonImageActionApi: ActionTargetApi = { - ..._setComparisonImage, - typeGuard: buildTypeGuard(_setComparisonImage.key), - getData: buildGetData(_setComparisonImage.key, _setComparisonImage.type), - isValid: (sourceData, _targetData, _dispatch, getState) => { - if (!singleImageSourceApi.typeGuard(sourceData)) { - return false; + switch (type) { + case 'raster_layer': { + dispatch(rasterLayerAdded({ overrides, isSelected: true })); + break; } - const { firstImage, secondImage } = selectComparisonImages(getState()); - // Do not allow the same images to be selected for comparison - if (sourceData.payload.imageDTO.image_name === firstImage?.image_name) { - return false; + case 'control_layer': { + const controlAdapter = selectDefaultControlAdapter(state); + dispatch(controlLayerAdded({ overrides: { ...overrides, controlAdapter }, isSelected: true })); + break; } - if (sourceData.payload.imageDTO.image_name === secondImage?.image_name) { - return false; + case 'inpaint_mask': { + dispatch(inpaintMaskAdded({ overrides, isSelected: true })); + break; } - return true; - }, - handler: (sourceData, _targetData, dispatch, _getState) => { - const { imageDTO } = sourceData.payload; - dispatch(imageToCompareChanged(imageDTO)); - }, -}; -//#endregion - -//#region New Canvas Entity from Image -const _newCanvasEntity = buildTypeAndKey('new-canvas-entity-from-image'); -export type NewCanvasEntityFromImageActionData = ActionData< - typeof _newCanvasEntity.type, - typeof _newCanvasEntity.key, - { type: CanvasEntityType | 'regional_guidance_with_reference_image' } ->; -export const newCanvasEntityFromImageActionApi: ActionTargetApi< - NewCanvasEntityFromImageActionData, - SingleImageSourceData -> = { - ..._newCanvasEntity, - typeGuard: buildTypeGuard(_newCanvasEntity.key), - getData: buildGetData(_newCanvasEntity.key, _newCanvasEntity.type), - isValid: (sourceData, _targetData, _dispatch, _getState) => { - if (!singleImageSourceApi.typeGuard(sourceData)) { - return false; + case 'regional_guidance': { + dispatch(rgAdded({ overrides, isSelected: true })); + break; } - return true; - }, - handler: (sourceData, targetData, dispatch, getState) => { - const { type } = targetData.payload; - const { imageDTO } = sourceData.payload; - const state = getState(); - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectBboxRect(state); - const overrides = { - objects: [imageObject], - position: { x, y }, - }; - switch (type) { - case 'raster_layer': { - dispatch(rasterLayerAdded({ overrides, isSelected: true })); - break; - } - case 'control_layer': { - const controlAdapter = selectDefaultControlAdapter(state); - dispatch(controlLayerAdded({ overrides: { ...overrides, controlAdapter }, isSelected: true })); - break; - } - case 'inpaint_mask': { - dispatch(inpaintMaskAdded({ overrides, isSelected: true })); - break; - } - case 'regional_guidance': { - dispatch(rgAdded({ overrides, isSelected: true })); - break; - } - case 'reference_image': { - const ipAdapter = selectDefaultIPAdapter(getState()); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); - dispatch(referenceImageAdded({ overrides: { ipAdapter }, isSelected: true })); - break; - } - case 'regional_guidance_with_reference_image': { - const ipAdapter = selectDefaultIPAdapter(getState()); - ipAdapter.image = imageDTOToImageWithDims(imageDTO); - const referenceImages = [{ id: getPrefixedId('regional_guidance_reference_image'), ipAdapter }]; - dispatch(rgAdded({ overrides: { referenceImages }, isSelected: true })); - 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; } - }, + } }; -//#endregion -//#region New Canvas from Image -const _newCanvasFromImage = buildTypeAndKey('new-canvas-from-image'); -export type NewCanvasFromImageActionData = ActionData< - typeof _newCanvasFromImage.type, - typeof _newCanvasFromImage.key, - { type: CanvasEntityType | 'regional_guidance_with_reference_image' } ->; /** - * Returns a function that adds a new canvas with the given image as the initial image, replicating the img2img flow: + * 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 @@ -343,259 +132,141 @@ export type NewCanvasFromImageActionData = ActionData< * * This allows the user to immediately generate a new image from the given image without any additional steps. */ -export const newCanvasFromImageActionApi: ActionTargetApi = { - ..._newCanvasFromImage, - typeGuard: buildTypeGuard(_newCanvasFromImage.key), - getData: buildGetData(_newCanvasFromImage.key, _newCanvasFromImage.type), - isValid: (sourceData, _targetData, _dispatch, _getState) => { - if (!singleImageSourceApi.typeGuard(sourceData)) { - return false; - } - return true; - }, - handler: (sourceData, targetData, dispatch, getState) => { - const { type } = targetData.payload; - const { imageDTO } = sourceData.payload; - const state = getState(); - - const 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); +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 imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectBboxRect(state); + 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 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; - }); - }; + const imageObject = imageDTOToImageObject(imageDTO); + const { x, y } = selectBboxRect(state); - 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 controlAdapter = selectDefaultControlAdapter(state); - const overrides = { - id: getPrefixedId('control_layer'), - objects: [imageObject], - position: { x, y }, - controlAdapter, - } 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; + 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; } - default: - assert>(false); - } - }, -}; -//#endregion + // Fit the layer to the bbox w/ fill strategy + await adapter.transformer.startTransform({ silent: true }); + adapter.transformer.fitToBboxFill(); + await adapter.transformer.applyTransform(); + return true; + }); + }; -//#region Replace Canvas Entity Objects With Image -const _replaceCanvasEntityObjectsWithImage = buildTypeAndKey('replace-canvas-entity-objects-with-image'); -export type ReplaceCanvasEntityObjectsWithImageActionData = ActionData< - typeof _replaceCanvasEntityObjectsWithImage.type, - typeof _replaceCanvasEntityObjectsWithImage.key, - { entityIdentifier: CanvasRenderableEntityIdentifier } ->; -export const replaceCanvasEntityObjectsWithImageActionApi: ActionTargetApi< - ReplaceCanvasEntityObjectsWithImageActionData, - SingleImageSourceData -> = { - ..._replaceCanvasEntityObjectsWithImage, - typeGuard: buildTypeGuard(_replaceCanvasEntityObjectsWithImage.key), - getData: buildGetData(_replaceCanvasEntityObjectsWithImage.key, _replaceCanvasEntityObjectsWithImage.type), - isValid: (sourceData, _targetData, _dispatch, _getState) => { - if (!singleImageSourceApi.typeGuard(sourceData)) { - return false; - } - return true; - }, - handler: (sourceData, targetData, dispatch, getState) => { - const { imageDTO } = sourceData.payload; - const { entityIdentifier } = targetData.payload; - const imageObject = imageDTOToImageObject(imageDTO); - const { x, y } = selectBboxRect(getState()); - dispatch( - entityRasterized({ - entityIdentifier, - imageObject, + switch (type) { + case 'raster_layer': { + const overrides = { + id: getPrefixedId('raster_layer'), + objects: [imageObject], position: { x, y }, - replaceObjects: true, - isSelected: true, - }) - ); - }, -}; -//#endregion - -//#region Add To Board -const _addToBoard = buildTypeAndKey('add-to-board'); -export type AddImageToBoardActionData = ActionData< - typeof _addToBoard.type, - typeof _addToBoard.key, - { boardId: BoardId } ->; -export const addImageToBoardActionApi: ActionTargetApi< - AddImageToBoardActionData, - SingleImageSourceData | MultipleImageSourceData -> = { - ..._addToBoard, - typeGuard: buildTypeGuard(_addToBoard.key), - getData: buildGetData(_addToBoard.key, _addToBoard.type), - isValid: (sourceData, targetData, _dispatch, _getState) => { - if (singleImageSourceApi.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; - const destinationBoard = targetData.payload.boardId; - return currentBoard !== destinationBoard; + } 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; } - if (multipleImageSourceApi.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.boardId; - const destinationBoard = targetData.payload.boardId; - return currentBoard !== destinationBoard; - } - return false; - }, - handler: (sourceData, targetData, dispatch, _getState) => { - if (singleImageSourceApi.typeGuard(sourceData)) { - const { imageDTO } = sourceData.payload; - const { boardId } = targetData.payload; - dispatch(imagesApi.endpoints.addImageToBoard.initiate({ imageDTO, board_id: boardId }, { track: false })); - dispatch(selectionChanged([])); + case 'control_layer': { + const controlAdapter = selectDefaultControlAdapter(state); + const overrides = { + id: getPrefixedId('control_layer'), + objects: [imageObject], + position: { x, y }, + controlAdapter, + } 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; } - - if (multipleImageSourceApi.typeGuard(sourceData)) { - const { imageDTOs } = sourceData.payload; - const { boardId } = targetData.payload; - dispatch(imagesApi.endpoints.addImagesToBoard.initiate({ imageDTOs, board_id: boardId }, { track: false })); - dispatch(selectionChanged([])); + 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; } - }, -}; -//#endregion - -//#region Remove From Board -const _removeFromBoard = buildTypeAndKey('add-to-board'); -export type RemoveImageFromBoardActionData = ActionData< - typeof _removeFromBoard.type, - typeof _removeFromBoard.key, - void ->; -export const removeImageFromBoardActionApi: ActionTargetApi< - RemoveImageFromBoardActionData, - SingleImageSourceData | MultipleImageSourceData -> = { - ..._removeFromBoard, - typeGuard: buildTypeGuard(_removeFromBoard.key), - getData: buildGetData(_removeFromBoard.key, _removeFromBoard.type), - isValid: (sourceData, _targetData, _dispatch, _getState) => { - if (singleImageSourceApi.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; - return currentBoard !== 'none'; + 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; } - - if (multipleImageSourceApi.typeGuard(sourceData)) { - const currentBoard = sourceData.payload.boardId; - return currentBoard !== 'none'; + case 'reference_image': { + const ipAdapter = selectDefaultIPAdapter(getState()); + ipAdapter.image = imageDTOToImageWithDims(imageDTO); + dispatch(canvasReset()); + dispatch(referenceImageAdded({ overrides: { ipAdapter }, isSelected: true })); + break; } - - return false; - }, - handler: (sourceData, _targetData, dispatch, _getState) => { - if (singleImageSourceApi.typeGuard(sourceData)) { - const { imageDTO } = sourceData.payload; - dispatch(imagesApi.endpoints.removeImageFromBoard.initiate({ imageDTO }, { track: false })); - dispatch(selectionChanged([])); + 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); + } +}; - if (multipleImageSourceApi.typeGuard(sourceData)) { - const { imageDTOs } = sourceData.payload; - dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ imageDTOs }, { track: false })); - dispatch(selectionChanged([])); - } - }, +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, + }) + ); }; -//#endregion -export const singleImageActions = [ - setGlobalReferenceImageActionApi, - setRegionalGuidanceReferenceImageActionApi, - setUpscaleInitialImageActionApi, - setNodeImageFieldImageActionApi, - setComparisonImageActionApi, - newCanvasEntityFromImageActionApi, - replaceCanvasEntityObjectsWithImageActionApi, - addImageToBoardActionApi, - removeImageFromBoardActionApi, -] as const; -export type SingleImageAction = ReturnType<(typeof singleImageActions)[number]['getData']>; +export const 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 multipleImageActions = [addImageToBoardActionApi, removeImageFromBoardActionApi] as const; -export type MultipleImageAction = ReturnType<(typeof multipleImageActions)[number]['getData']>; +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/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 992161b9485..32f3b333df5 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,11 +2,11 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch } from 'app/store/storeHooks'; +import 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 type { SetNodeImageFieldImageActionData } from 'features/imageActions/actions'; -import { setNodeImageFieldImageActionApi } from 'features/imageActions/actions'; import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import type { ImageFieldInputInstance, ImageFieldInputTemplate } from 'features/nodes/types/field'; import { memo, useCallback, useEffect, useMemo } from 'react'; @@ -33,9 +33,12 @@ const ImageFieldInputComponent = (props: FieldComponentProps( + const dndTargetData = useMemo( () => - setNodeImageFieldImageActionApi.getData({ fieldIdentifer: { nodeId, fieldName: field.name } }, field.value?.image_name), + setNodeImageFieldImageDndTarget.getData( + { fieldIdentifer: { nodeId, fieldName: field.name } }, + field.value?.image_name + ), [field, nodeId] ); @@ -71,7 +74,11 @@ const ImageFieldInputComponent = (props: FieldComponentProps )} - + ); }; 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 b10d0dfb45b..9f992378e52 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 @@ -7,9 +7,9 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar'; +import { singleWorkflowFieldDndSource } from 'features/dnd/dnd'; import { triggerPostMoveFlash } from 'features/dnd/util'; import LinearViewFieldInternal from 'features/nodes/components/flow/nodes/Invocation/fields/LinearViewField'; -import { singleWorkflowField } from 'features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd'; import { selectWorkflowSlice, workflowExposedFieldsReordered } from 'features/nodes/store/workflowSlice'; import type { FieldIdentifier } from 'features/nodes/types/field'; import { memo, useEffect } from 'react'; @@ -57,7 +57,7 @@ const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) = useEffect(() => { return monitorForElements({ canMonitor({ source }) { - if (!singleWorkflowField.typeGuard(source.data)) { + if (!singleWorkflowFieldDndSource.typeGuard(source.data)) { return false; } return true; @@ -71,7 +71,10 @@ const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) = const sourceData = source.data; const targetData = target.data; - if (!singleWorkflowField.typeGuard(sourceData) || !singleWorkflowField.typeGuard(targetData)) { + if ( + !singleWorkflowFieldDndSource.typeGuard(sourceData) || + !singleWorkflowFieldDndSource.typeGuard(targetData) + ) { return; } 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 index 81771db7a5f..745c8ecdd33 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd.ts @@ -1,26 +1,13 @@ 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 { ActionData, ActionSourceApi } from 'features/imageActions/actions'; -import { buildGetData, buildTypeAndKey, buildTypeGuard } from 'features/imageActions/actions'; import type { FieldIdentifier } from 'features/nodes/types/field'; import type { RefObject } from 'react'; import { useEffect, useState } from 'react'; -const _singleWorkflowField = buildTypeAndKey('single-workflow-field'); -type SingleWorkflowFieldSourceData = ActionData< - typeof _singleWorkflowField.type, - typeof _singleWorkflowField.key, - { fieldIdentifier: FieldIdentifier } ->; -export const singleWorkflowField: ActionSourceApi = { - ..._singleWorkflowField, - typeGuard: buildTypeGuard(_singleWorkflowField.key), - getData: buildGetData(_singleWorkflowField.key, _singleWorkflowField.type), -}; - export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifier: FieldIdentifier) => { const [dndListState, setListDndState] = useState(idle); const [isDragging, setIsDragging] = useState(false); @@ -34,7 +21,7 @@ export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifi draggable({ element, getInitialData() { - return singleWorkflowField.getData({ fieldIdentifier }); + return singleWorkflowFieldDndSource.getData({ fieldIdentifier }); }, onDragStart() { setListDndState({ type: 'is-dragging' }); @@ -48,13 +35,13 @@ export const useLinearViewFieldDnd = (ref: RefObject, fieldIdentifi dropTargetForElements({ element, canDrop({ source }) { - if (!singleWorkflowField.typeGuard(source.data)) { + if (!singleWorkflowFieldDndSource.typeGuard(source.data)) { return false; } return true; }, getData({ input }) { - const data = singleWorkflowField.getData({ fieldIdentifier }); + const data = singleWorkflowFieldDndSource.getData({ fieldIdentifier }); return attachClosestEdge(data, { element, input, 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 275a95cea76..c90746f696d 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleInitialImage.tsx @@ -1,10 +1,10 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +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 type { SetUpscaleInitialImageActionData } from 'features/imageActions/actions'; -import { setUpscaleInitialImageActionApi } from 'features/imageActions/actions'; import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { t } from 'i18next'; import { useCallback, useMemo } from 'react'; @@ -13,7 +13,10 @@ import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; export const UpscaleInitialImage = () => { const dispatch = useAppDispatch(); const imageDTO = useAppSelector(selectUpscaleInitialImage); - const targetData = useMemo(() => setUpscaleInitialImageActionApi.getData(), []); + const dndTargetData = useMemo( + () => setUpscaleInitialImageDndTarget.getData(), + [] + ); const onReset = useCallback(() => { dispatch(upscaleInitialImageChanged(null)); @@ -49,7 +52,11 @@ export const UpscaleInitialImage = () => { >{`${imageDTO.width}x${imageDTO.height}`} )} - + ); From dc5a1f1dad3e0e542fffceeaa479af7620cd9c4f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 5 Nov 2024 20:32:33 +1000 Subject: [PATCH 26/39] fix(ui): scroll issue w/ boards list --- .../gallery/components/Boards/BoardsList/BoardsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx index f24b09b0e4b..fbe21e22185 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx @@ -106,7 +106,7 @@ export const BoardsList = ({ isPrivate }: Props) => { - + {boardElements.length ? ( boardElements ) : ( From c9861905107fc328eec0956139b0e2531eaa2a6a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 5 Nov 2024 20:36:34 +1000 Subject: [PATCH 27/39] fix(ui): ts issues --- .../frontend/web/src/features/dnd/DndDropTarget.tsx | 12 ++++++++++-- invokeai/frontend/web/src/features/dnd/dnd.ts | 5 +++-- .../frontend/web/src/features/dnd/useDndMonitor.ts | 13 +++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx index c19ca5ecad8..ec9733e5c68 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDropTarget.tsx @@ -89,7 +89,11 @@ export const DndDropTarget = memo((props: Props) => { dropTargetForElements({ element, canDrop: ({ source }) => { - return dndTarget.isValid({ sourceData: source.data, targetData: dndTargetData, dispatch, getState }); + // TS cannot infer `dndTargetData` but we've just checked it. + // TODO(psyche): Figure out how to satisfy TS. + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const arg = { sourceData: source.data, targetData: dndTargetData, dispatch, getState } as any; + return dndTarget.isValid(arg); }, onDragEnter: () => { setDndState('over'); @@ -101,7 +105,11 @@ export const DndDropTarget = memo((props: Props) => { }), monitorForElements({ canMonitor: ({ source }) => { - return dndTarget.isValid({ sourceData: source.data, targetData: dndTargetData, dispatch, getState }); + // TS cannot infer `dndTargetData` but we've just checked it. + // TODO(psyche): Figure out how to satisfy TS. + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const arg = { sourceData: source.data, targetData: dndTargetData, dispatch, getState } as any; + return dndTarget.isValid(arg); }, onDragStart: () => { setDndOrigin('element'); diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index 4fabfafcc0d..3756850d11b 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -475,9 +475,10 @@ export const isValidDrop = (arg: { if (!dndTarget.typeGuard(arg.targetData)) { continue; } - // TS cannot infer `targetData` but we've just checked it. This is safe. + // TS cannot infer `arg.targetData` but we've just checked it. + // TODO(psyche): Figure out how to satisfy TS. /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - if (!dndTarget.isValid(arg)) { + if (!dndTarget.isValid(arg as any)) { return true; } } diff --git a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts index 5c36e5b71b1..cfe84d31e24 100644 --- a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts +++ b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts @@ -44,15 +44,20 @@ export const useDndMonitor = () => { if (!dndTarget.typeGuard(targetData)) { continue; } - // TS cannot infer `targetData` but we've just checked it. This is safe. + const arg = { sourceData, targetData, dispatch, getState }; + // TS cannot infer `arg.targetData` but we've just checked it. + // TODO(psyche): Figure out how to satisfy TS. /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const arg = { sourceData, targetData: targetData as any, dispatch, getState }; - if (!dndTarget.isValid(arg)) { + if (!dndTarget.isValid(arg as any)) { continue; } log.debug(parseify({ sourceData, targetData }), 'Handling dnd drop'); - dndTarget.handler(arg); + + // TS cannot infer `arg.targetData` but we've just checked it. + // TODO(psyche): Figure out how to satisfy TS. + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + dndTarget.handler(arg as any); return; } From 3147b834559fc7c1dc876fcb8dced03d1751e588 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:47:42 +1000 Subject: [PATCH 28/39] feat(ui): rework image uploads (wip) --- .../src/common/components/UploadButton.tsx | 0 .../src/common/hooks/useImageUploadButton.tsx | 94 +++++++++++++++---- .../ControlLayerControlAdapter.tsx | 22 +++-- .../IPAdapter/IPAdapterImagePreview.tsx | 14 ++- .../web/src/features/dnd/DndDropTarget.tsx | 5 +- .../components/GalleryUploadButton.tsx | 6 +- .../web/src/services/api/endpoints/images.ts | 67 +++++++++---- 7 files changed, 153 insertions(+), 55 deletions(-) create mode 100644 invokeai/frontend/web/src/common/components/UploadButton.tsx diff --git a/invokeai/frontend/web/src/common/components/UploadButton.tsx b/invokeai/frontend/web/src/common/components/UploadButton.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx index 64d1861f9d3..1c8540e359f 100644 --- a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx +++ b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx @@ -1,3 +1,4 @@ +import { Flex, Icon, type SystemStyleObject } 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 +8,22 @@ 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 { PiUploadSimpleBold } from 'react-icons/pi'; +import { uploadImages, useUploadImageMutation } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; +import { assert } from 'tsafe'; -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 +46,46 @@ const log = logger('gallery'); *