diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 98665a6341..b8cd3e0200 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 832a3e8ab4..17ead7a8e5 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 a8f0861f0e..9ce939d011 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 9421804090..86737367b0 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 6b04ff511a..f6da8aba41 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 4e2cbd581c..86c35f7d7d 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 870fec1d69..9a13315e0e 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 4430fc83dc..b423730182 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 b9d1999a8f..609b6426c1 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 07bb5635f4..9187785e7a 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 efc05d62ae..9bba7fc12f 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 0000000000..9b674bac11 --- /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 fbea84b978..e085f108d7 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 = {