feat(ui): dnd layer reordering (wip)

This commit is contained in:
psychedelicious
2024-10-30 18:14:09 +10:00
parent 06283cffed
commit 54abd8d4d1
13 changed files with 482 additions and 48 deletions

View File

@@ -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,
})
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);
});

View File

@@ -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">

View File

@@ -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;
};

View File

@@ -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'] {