mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-13 03:45:33 -05:00
feat(ui): dnd layer reordering (wip)
This commit is contained in:
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<CanvasEntityGroupList type="control_layer" isSelected={isSelected}>
|
||||
{layerIds.map((id) => (
|
||||
<ControlLayer key={id} id={id} />
|
||||
<CanvasEntityGroupList type="control_layer" isSelected={isSelected} entityIdentifiers={entityIdentifiers}>
|
||||
{entityIdentifiers.map((entityIdentifier) => (
|
||||
<ControlLayer key={entityIdentifier.id} id={entityIdentifier.id} />
|
||||
))}
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<CanvasEntityGroupList type="inpaint_mask" isSelected={isSelected}>
|
||||
{entityIds.map((id) => (
|
||||
<InpaintMask key={id} id={id} />
|
||||
<CanvasEntityGroupList type="inpaint_mask" isSelected={isSelected} entityIdentifiers={entityIdentifiers}>
|
||||
{entityIdentifiers.map((entityIdentifier) => (
|
||||
<InpaintMask key={entityIdentifier.id} id={entityIdentifier.id} />
|
||||
))}
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<CanvasEntityGroupList type="raster_layer" isSelected={isSelected}>
|
||||
{layerIds.map((id) => (
|
||||
<RasterLayer key={id} id={id} />
|
||||
<CanvasEntityGroupList type="raster_layer" isSelected={isSelected} entityIdentifiers={entityIdentifiers}>
|
||||
{entityIdentifiers.map((entityIdentifier) => (
|
||||
<RasterLayer key={entityIdentifier.id} id={entityIdentifier.id} />
|
||||
))}
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<CanvasEntityGroupList type="regional_guidance" isSelected={isSelected}>
|
||||
{rgIds.map((id) => (
|
||||
<RegionalGuidance key={id} id={id} />
|
||||
<CanvasEntityGroupList type="regional_guidance" isSelected={isSelected} entityIdentifiers={entityIdentifiers}>
|
||||
{entityIdentifiers.map((entityIdentifier) => (
|
||||
<RegionalGuidance key={entityIdentifier.id} id={entityIdentifier.id} />
|
||||
))}
|
||||
</CanvasEntityGroupList>
|
||||
);
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const [dndState, setDndState] = useState<DndState>(idle);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
getInitialData() {
|
||||
return Dnd.Source.singleCanvasEntity.getData({ entityIdentifier });
|
||||
},
|
||||
// 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 (
|
||||
<Flex
|
||||
ref={ref}
|
||||
position="relative"
|
||||
flexDir="column"
|
||||
w="full"
|
||||
@@ -31,6 +143,9 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
|
||||
borderRadius="base"
|
||||
>
|
||||
{props.children}
|
||||
{dndState.type === 'is-dragging-over' && dndState.closestEdge ? (
|
||||
<DropIndicator edge={dndState.closestEdge} gap="8px" />
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Flex flexDir="column" w="full">
|
||||
|
||||
@@ -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: <T extends CanvasEntityType>(
|
||||
state: CanvasState,
|
||||
action: PayloadAction<{ type: T; entityIdentifiers: CanvasEntityIdentifier<T>[] }>
|
||||
) => {
|
||||
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<EntityIdentifierPayload<{ opacity: number }>>) => {
|
||||
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 = <T extends CanvasEntityType>(
|
||||
entities: CanvasEntityStateFromType<T>[],
|
||||
sortedEntityIdentifiers: CanvasEntityIdentifier<T>[]
|
||||
) => {
|
||||
const sortedEntities: CanvasEntityStateFromType<T>[] = [];
|
||||
for (const { id } of sortedEntityIdentifiers.toReversed()) {
|
||||
const entity = entities.find((entity) => entity.id === id);
|
||||
if (entity) {
|
||||
sortedEntities.push(entity);
|
||||
}
|
||||
}
|
||||
return sortedEntities;
|
||||
};
|
||||
|
||||
@@ -466,6 +466,8 @@ export type EntityRasterizedPayload = EntityIdentifierPayload<{
|
||||
|
||||
export type GenerationMode = 'txt2img' | 'img2img' | 'inpaint' | 'outpaint';
|
||||
|
||||
export type CanvasEntityStateFromType<T extends CanvasEntityType> = Extract<CanvasEntityState, { type: T }>;
|
||||
|
||||
export function isRenderableEntityType(
|
||||
entityType: CanvasEntityState['type']
|
||||
): entityType is CanvasRenderableEntityState['type'] {
|
||||
|
||||
166
invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx
Normal file
166
invokeai/frontend/web/src/features/dnd/DndDropIndicator.tsx
Normal file
@@ -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 && <DropIndicator edge={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<Orientation, SystemStyleObject> = {
|
||||
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<Edge, Orientation> = {
|
||||
top: 'horizontal',
|
||||
bottom: 'horizontal',
|
||||
left: 'vertical',
|
||||
right: 'vertical',
|
||||
};
|
||||
|
||||
const edgeStyles: Record<Edge, SystemStyleObject> = {
|
||||
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 (
|
||||
<Box
|
||||
sx={{ ...lineStyles, ...orientationStyles[orientation], ...edgeStyles[edge] }}
|
||||
style={{ '--local-line-offset': lineOffset } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// This default export is intended for usage with React.lazy
|
||||
export default DropIndicator;
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user