From 14845932fb94cd6b684c2ffdf3a43f7bf9ebd7f1 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 24 Jan 2025 15:58:15 +1100
Subject: [PATCH] feat(ui): dnd almost fully working (WIP)
---
.../builder/ContainerElementComponent.tsx | 20 +--
.../builder/FormElementEditModeWrapper.tsx | 5 +-
.../builder/center-or-closest-edge.ts | 4 +-
.../sidePanel/builder/use-builder-dnd.ts | 152 +++++++-----------
.../src/features/nodes/store/workflowSlice.ts | 33 +++-
5 files changed, 105 insertions(+), 109 deletions(-)
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElementComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElementComponent.tsx
index 1f339344e6..0b2befc998 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElementComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElementComponent.tsx
@@ -86,8 +86,8 @@ export const ContainerElementComponentEditMode = memo(({ el }: { el: ContainerEl
{children.map((childId) => (
))}
- {direction === 'row' && children.length < 3 && depth < 2 && }
- {direction === 'column' && depth < 1 && }
+ {direction === 'row' && children.length < 3 && depth < 2 && }
+ {direction === 'column' && depth < 1 && }
@@ -96,23 +96,23 @@ export const ContainerElementComponentEditMode = memo(({ el }: { el: ContainerEl
});
ContainerElementComponentEditMode.displayName = 'ContainerElementComponentEditMode';
-const AddColumnButton = ({ containerId }: { containerId: string }) => {
+const AddColumnButton = ({ el }: { el: ContainerElement }) => {
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
- const element = container('column', []);
- dispatch(formElementAdded({ element, containerId }));
- }, [containerId, dispatch]);
+ const element = container('column', [], el.id);
+ dispatch(formElementAdded({ element, containerId: el.id }));
+ }, [dispatch, el.id]);
return (
} h="unset" variant="ghost" size="sm" />
);
};
-const AddRowButton = ({ containerId }: { containerId: string }) => {
+const AddRowButton = ({ el }: { el: ContainerElement }) => {
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
- const element = container('row', []);
- dispatch(formElementAdded({ element, containerId }));
- }, [containerId, dispatch]);
+ const element = container('row', [], el.id);
+ dispatch(formElementAdded({ element, containerId: el.id }));
+ }, [dispatch, el.id]);
return (
} w="unset" variant="ghost" size="sm" />
);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeWrapper.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeWrapper.tsx
index c7ae517d60..c1155f2bd5 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeWrapper.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeWrapper.tsx
@@ -1,7 +1,7 @@
import { Flex, type FlexProps, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { getPrefixedId } from 'features/controlLayers/konva/util';
-import { useContainerContext, useDepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
+import { useDepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator';
import type { DndListTargetState } from 'features/nodes/components/sidePanel/builder/use-builder-dnd';
import { useDraggableFormElement } from 'features/nodes/components/sidePanel/builder/use-builder-dnd';
@@ -55,8 +55,7 @@ export const FormElementEditModeWrapper = memo(
({ element, children, ...rest }: { element: FormElement } & FlexProps) => {
const draggableRef = useRef(null);
const dragHandleRef = useRef(null);
- const container = useContainerContext();
- const [dndListState] = useDraggableFormElement(element.id, container?.id ?? null, draggableRef, dragHandleRef);
+ const [dndListState] = useDraggableFormElement(element.id, draggableRef, dragHandleRef);
const depth = useDepthContext();
const dispatch = useAppDispatch();
const removeElement = useCallback(() => {
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/center-or-closest-edge.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/center-or-closest-edge.ts
index cfb719a9d5..aca12766fb 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/center-or-closest-edge.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/center-or-closest-edge.ts
@@ -5,6 +5,8 @@ import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/t
export type CenterOrEdge = 'center' | Edge;
+const CENTER_BIAS_FACTOR = 0.8;
+
// re-exporting type to make it easy to use
const getDistanceToCenterOrEdge: {
@@ -17,7 +19,7 @@ const getDistanceToCenterOrEdge: {
center: (rect, client) => {
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
- return Math.sqrt((client.x - centerX) ** 2 + (client.y - centerY) ** 2);
+ return Math.sqrt((client.x - centerX) ** 2 + (client.y - centerY) ** 2) * CENTER_BIAS_FACTOR;
},
};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-builder-dnd.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-builder-dnd.ts
index 54909ea7fc..f9bfee6595 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-builder-dnd.ts
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-builder-dnd.ts
@@ -15,7 +15,7 @@ import {
} from 'features/nodes/components/sidePanel/builder/center-or-closest-edge';
import { getEditModeWrapperId } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
import { formElementMoved } from 'features/nodes/store/workflowSlice';
-import type { ContainerElement, ElementId, FormElement } from 'features/nodes/types/workflow';
+import type { ElementId, FormElement } from 'features/nodes/types/workflow';
import { isContainerElement } from 'features/nodes/types/workflow';
import type { RefObject } from 'react';
import { useEffect, useState } from 'react';
@@ -42,9 +42,12 @@ export type DndListTargetState =
};
export const idle: DndListTargetState = { type: 'idle' };
+// using a symbol so we can guarantee a key with a unique value
+const uniqueBuilderDndKey = Symbol('builderDnd');
+
type DndData = {
+ [uniqueBuilderDndKey]: true;
element: FormElement;
- container: ContainerElement | null;
};
const getElement = (id: ElementId, guard?: (el: FormElement) => el is T): T => {
@@ -70,11 +73,8 @@ export const useMonitorForFormElementDnd = () => {
useEffect(() => {
return monitorForElements({
- // canMonitor({ source }) {
- // return (source.data as FormElement).id === containerId;
- // },
- canMonitor: () => true,
- onDrop({ location, source }) {
+ canMonitor: ({ source }) => uniqueBuilderDndKey in source.data,
+ onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) {
return;
@@ -83,83 +83,55 @@ export const useMonitorForFormElementDnd = () => {
const sourceData = source.data as DndData;
const targetData = target.data as DndData;
- const sourceElementId = sourceData.element.id;
- const targetElementId = targetData.element.id;
-
const closestCenterOrEdge = extractClosestCenterOrEdge(targetData);
if (closestCenterOrEdge === 'center') {
- const targetContainer = getElement(targetElementId);
- if (!isContainerElement(targetContainer)) {
- // Shouldn't happen - when dropped on the center of drop target, the target should always be a container type.
- return;
- }
+ // Move the element to the target container - should we double-check that the target is a container?
flushSync(() => {
- dispatch(formElementMoved({ id: sourceElementId, containerId: targetContainer.id }));
+ dispatch(formElementMoved({ id: sourceData.element.id, containerId: targetData.element.id }));
});
} else if (closestCenterOrEdge) {
- if (targetData.container) {
- const targetContainer = getElement(targetData.container.id);
- if (!isContainerElement(targetContainer)) {
- // Shouldn't happen - drop targets should always have a container.
+ // Move the element to the target's parent container at the correct index
+ const { parentId } = targetData.element;
+ assert(parentId !== undefined, 'Target element should have a parent');
+
+ const isReparenting = parentId !== sourceData.element.parentId;
+
+ const parentContainer = getElement(parentId, isContainerElement);
+ const targetIndex = parentContainer.data.children.findIndex(
+ (elementId) => elementId === targetData.element.id
+ );
+
+ let index: number | undefined = undefined;
+
+ if (!isReparenting) {
+ const sourceIndex = parentContainer.data.children.findIndex(
+ (elementId) => elementId === sourceData.element.id
+ );
+ if (sourceIndex === targetIndex || sourceIndex === adjustIndexForDrop(targetIndex, closestCenterOrEdge)) {
return;
}
- const indexOfSource = targetContainer.data.children.findIndex((elementId) => elementId === sourceElementId);
- const indexOfTarget = targetContainer.data.children.findIndex((elementId) => elementId === targetElementId);
-
- if (indexOfSource === indexOfTarget) {
- // Don't move if the source and target are the same index, meaning same position in the list.
- return;
- }
-
- const adjustedIndex = adjustIndexForDrop(indexOfTarget, closestCenterOrEdge);
-
- if (indexOfSource === adjustedIndex) {
- // Don't move if the source is already in the correct position.
- return;
- }
-
- flushSync(() => {
- dispatch(
- formElementMoved({
- id: sourceElementId,
- containerId: targetContainer.id,
- index: indexOfTarget,
- })
- );
- });
+ index = targetIndex;
+ } else {
+ index = adjustIndexForDrop(targetIndex, closestCenterOrEdge);
}
+
+ flushSync(() => {
+ dispatch(
+ formElementMoved({
+ id: sourceData.element.id,
+ containerId: parentId,
+ index,
+ })
+ );
+ });
} else {
// No container, cannot do anything
return;
}
- // const childrenClone = [...targetData.container.data.children];
-
- // const indexOfSource = childrenClone.findIndex((elementId) => elementId === sourceElementId);
- // const indexOfTarget = childrenClone.findIndex((elementId) => elementId === targetElementId);
-
- // 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;
- // }
-
- // Using `flushSync` so we can query the DOM straight after this line
- // flushSync(() => {
- // dispatch(
- // formElementMoved({
- // id: sourceElementId,
- // containerId: targetData.container.id,
- // index: indexOfTarget,
- // })
- // );
- // });
// Flash the element that was moved
- const element = document.querySelector(`#${getEditModeWrapperId(sourceElementId)}`);
+ const element = document.querySelector(`#${getEditModeWrapperId(sourceData.element.id)}`);
if (element instanceof HTMLElement) {
triggerPostMoveFlash(element, colorTokenToCssVar('base.700'));
}
@@ -170,7 +142,6 @@ export const useMonitorForFormElementDnd = () => {
export const useDraggableFormElement = (
elementId: ElementId,
- containerId: ElementId | null,
draggableRef: RefObject,
dragHandleRef: RefObject
) => {
@@ -183,38 +154,39 @@ export const useDraggableFormElement = (
if (!draggableElement || !dragHandleElement) {
return;
}
+ const _element = getElement(elementId);
+ if (!_element.parentId) {
+ // Root element, cannot drag
+ return;
+ }
return combine(
firefoxDndFix(draggableElement),
draggable({
- canDrag: () => Boolean(containerId),
element: draggableElement,
dragHandle: dragHandleElement,
- getInitialData() {
- const data: DndData = {
- element: getElement(elementId),
- container: containerId ? getElement(containerId, isContainerElement) : null,
- };
- return data;
- },
- onDragStart() {
+ getInitialData: () => ({
+ [uniqueBuilderDndKey]: true,
+ element: getElement(elementId),
+ }),
+ onDragStart: () => {
setListDndState({ type: 'is-dragging' });
setIsDragging(true);
},
- onDrop() {
+ onDrop: () => {
setListDndState(idle);
setIsDragging(false);
},
}),
dropTargetForElements({
element: draggableElement,
- // canDrop() {},
- getData({ input }) {
+ canDrop: ({ source }) => uniqueBuilderDndKey in source.data,
+ getData: ({ input }) => {
const element = getElement(elementId);
- const container = containerId ? getElement(containerId, isContainerElement) : null;
+ const container = element.parentId ? getElement(element.parentId, isContainerElement) : null;
const data: DndData = {
+ [uniqueBuilderDndKey]: true,
element,
- container,
};
const allowedCenterOrEdge: CenterOrEdge[] = [];
@@ -237,10 +209,8 @@ export const useDraggableFormElement = (
allowedCenterOrEdge,
});
},
- getIsSticky() {
- return true;
- },
- onDrag({ self, location }) {
+ getIsSticky: () => true,
+ onDrag: ({ self, location }) => {
const innermostDropTargetElement = location.current.dropTargets.at(0)?.element;
// If the innermost target is not this draggable element, bail. We only want to react when dragging over _this_ element.
@@ -260,15 +230,15 @@ export const useDraggableFormElement = (
return { type: 'is-dragging-over', closestCenterOrEdge };
});
},
- onDragLeave() {
+ onDragLeave: () => {
setListDndState(idle);
},
- onDrop() {
+ onDrop: () => {
setListDndState(idle);
},
})
);
- }, [containerId, dragHandleRef, draggableRef, elementId]);
+ }, [dragHandleRef, draggableRef, elementId]);
return [dndListState, isDragging] as const;
};
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
index aa596c6db9..47fdbc6563 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
@@ -422,11 +422,36 @@ const moveElement = (args: {
if (!element) {
return;
}
- const container = elements[containerId];
- if (!container || !isContainerElement(container)) {
+ const newContainer = elements[containerId];
+ if (!newContainer || !isContainerElement(newContainer)) {
return;
}
- recursivelyRemoveElement({ formState, id });
- addElement({ formState, element, containerId, index });
+ if (newContainer.data.children.includes(id)) {
+ // Moving within the same container - remove the element from its current position and insert it at the new position
+ const currentIndex = newContainer.data.children.indexOf(id);
+ if (currentIndex === -1) {
+ return;
+ }
+ newContainer.data.children.splice(currentIndex, 1);
+ if (index === undefined) {
+ newContainer.data.children.push(id);
+ } else {
+ newContainer.data.children.splice(index, 0, id);
+ }
+ } else if (element.parentId !== undefined) {
+ const oldContainer = elements[element.parentId];
+ if (!oldContainer || !isContainerElement(oldContainer)) {
+ return;
+ }
+ oldContainer.data.children = oldContainer.data.children.filter((childId) => childId !== id);
+ if (index === undefined) {
+ newContainer.data.children.push(id);
+ } else {
+ newContainer.data.children.splice(index, 0, id);
+ }
+ element.parentId = containerId;
+ } else {
+ // Should never happen
+ }
};