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