diff --git a/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx
index 5abb58e916..600f0d5174 100644
--- a/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx
+++ b/invokeai/frontend/web/src/features/dnd/DndListDropIndicator.tsx
@@ -9,7 +9,8 @@ import type { DndListTargetState } from 'features/dnd/types';
*/
const line = {
thickness: 2,
- backgroundColor: 'base.500',
+ backgroundColor: 'red',
+ // backgroundColor: 'base.500',
};
type DropIndicatorProps = {
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 3790d14a3d..1f339344e6 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
@@ -1,12 +1,15 @@
import { Flex, IconButton, type SystemStyleObject } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { ContainerContext, DepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
+import {
+ ContainerContextProvider,
+ DepthContextProvider,
+ useDepthContext,
+} from 'features/nodes/components/sidePanel/builder/contexts';
import { DividerElementComponent } from 'features/nodes/components/sidePanel/builder/DividerElementComponent';
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
import { HeadingElementComponent } from 'features/nodes/components/sidePanel/builder/HeadingElementComponent';
import { NodeFieldElementComponent } from 'features/nodes/components/sidePanel/builder/NodeFieldElementComponent';
import { TextElementComponent } from 'features/nodes/components/sidePanel/builder/TextElementComponent';
-import { useMonitorForFormElementDnd } from 'features/nodes/components/sidePanel/builder/use-builder-dnd';
import { formElementAdded, selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
import type { ContainerElement } from 'features/nodes/types/workflow';
import {
@@ -18,7 +21,7 @@ import {
isNodeFieldElement,
isTextElement,
} from 'features/nodes/types/workflow';
-import { memo, useCallback, useContext } from 'react';
+import { memo, useCallback } from 'react';
import { PiPlusBold } from 'react-icons/pi';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
@@ -52,34 +55,33 @@ export const ContainerElementComponent = memo(({ id }: { id: string }) => {
ContainerElementComponent.displayName = 'ContainerElementComponent';
export const ContainerElementComponentViewMode = memo(({ el }: { el: ContainerElement }) => {
- const depth = useContext(DepthContext);
+ const depth = useDepthContext();
const { id, data } = el;
const { children, direction } = data;
return (
-
-
+
+
{children.map((childId) => (
))}
- {' '}
-
+
+
);
});
ContainerElementComponentViewMode.displayName = 'ContainerElementComponentViewMode';
export const ContainerElementComponentEditMode = memo(({ el }: { el: ContainerElement }) => {
- const depth = useContext(DepthContext);
+ const depth = useDepthContext();
const { id, data } = el;
const { children, direction } = data;
- useMonitorForFormElementDnd(id, children);
return (
-
-
+
+
{children.map((childId) => (
@@ -87,8 +89,8 @@ export const ContainerElementComponentEditMode = memo(({ el }: { el: ContainerEl
{direction === 'row' && children.length < 3 && depth < 2 && }
{direction === 'column' && depth < 1 && }
-
-
+
+
);
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/DividerElementComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/DividerElementComponent.tsx
index 29b9e8be3c..45276c8065 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/DividerElementComponent.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/DividerElementComponent.tsx
@@ -1,12 +1,12 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
-import { ContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
+import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
import type { DividerElement } from 'features/nodes/types/workflow';
import { DIVIDER_CLASS_NAME, isDividerElement } from 'features/nodes/types/workflow';
-import { memo, useContext } from 'react';
+import { memo } from 'react';
const sx: SystemStyleObject = {
bg: 'base.700',
@@ -40,7 +40,7 @@ export const DividerElementComponent = memo(({ id }: { id: string }) => {
DividerElementComponent.displayName = 'DividerElementComponent';
export const DividerElementComponentViewMode = memo(({ el }: { el: DividerElement }) => {
- const container = useContext(ContainerContext);
+ const container = useContainerContext();
const { id } = el;
return (
@@ -56,7 +56,7 @@ export const DividerElementComponentViewMode = memo(({ el }: { el: DividerElemen
DividerElementComponentViewMode.displayName = 'DividerElementComponentViewMode';
export const DividerElementComponentEditMode = memo(({ el }: { el: DividerElement }) => {
- const container = useContext(ContainerContext);
+ const container = useContainerContext();
const { id } = el;
return (
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/DndListDropIndicator.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/DndListDropIndicator.tsx
new file mode 100644
index 0000000000..e1a89d5dc9
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/DndListDropIndicator.tsx
@@ -0,0 +1,122 @@
+// Adapted from https://github.com/alexreardon/pdnd-react-tailwind/blob/main/src/drop-indicator.tsx
+import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types';
+import type { SystemStyleObject } from '@invoke-ai/ui-library';
+import { Box } from '@invoke-ai/ui-library';
+import type { DndListTargetState } from 'features/nodes/components/sidePanel/builder/use-builder-dnd';
+
+/**
+ * Design decisions for the drop indicator's main line
+ */
+const line = {
+ thickness: 2,
+ backgroundColor: 'base.500',
+};
+
+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 lineStyles: SystemStyleObject = {
+ display: 'block',
+ position: 'absolute',
+ zIndex: 1,
+ borderRadius: 'full',
+ // 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,
+};
+
+type Orientation = 'horizontal' | 'vertical';
+
+const orientationStyles: Record = {
+ horizontal: {
+ height: `${line.thickness}px`,
+ left: 2,
+ right: 2,
+ },
+ vertical: {
+ width: `${line.thickness}px`,
+ top: 2,
+ bottom: 2,
+ },
+};
+
+const edgeToOrientationMap: Record = {
+ top: 'horizontal',
+ bottom: 'horizontal',
+ left: 'vertical',
+ right: 'vertical',
+};
+
+const edgeStyles: Record = {
+ top: {
+ top: 'var(--local-line-offset)',
+ },
+ right: {
+ right: 'var(--local-line-offset)',
+ },
+ bottom: {
+ bottom: 'var(--local-line-offset)',
+ },
+ left: {
+ left: 'var(--local-line-offset)',
+ },
+};
+
+/**
+ * __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.
+ */
+function DndDropIndicatorInternal({ 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 (
+
+ );
+}
+
+export const DndListDropIndicator = ({ dndState, gap }: { dndState: DndListTargetState; gap?: string }) => {
+ if (dndState.type !== 'is-dragging-over') {
+ return null;
+ }
+
+ if (!dndState.closestCenterOrEdge || dndState.closestCenterOrEdge === 'center') {
+ return null;
+ }
+
+ return (
+
+ );
+};
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 7f1b243612..c7ae517d60 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,18 +1,20 @@
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 { DndListDropIndicator } from 'features/dnd/DndListDropIndicator';
-import type { DndListTargetState } from 'features/dnd/types';
-import { DepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
+import { useContainerContext, 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';
import { formElementRemoved } from 'features/nodes/store/workflowSlice';
import { type FormElement, isContainerElement } from 'features/nodes/types/workflow';
import { startCase } from 'lodash-es';
-import { memo, useCallback, useContext, useRef } from 'react';
+import { memo, useCallback, useRef } from 'react';
import { PiXBold } from 'react-icons/pi';
export const EDIT_MODE_WRAPPER_CLASS_NAME = getPrefixedId('edit-mode-wrapper', '-');
+export const getEditModeWrapperId = (id: string) => `${id}-edit-mode-wrapper`;
+
const getHeaderBgColor = (depth: number) => {
if (depth <= 1) {
return 'base.800';
@@ -40,6 +42,9 @@ const getBgColor = (dndListState: DndListTargetState) => {
case 'is-dragging':
return 'red';
case 'is-dragging-over':
+ if (dndListState.closestCenterOrEdge === 'center') {
+ return 'magenta';
+ }
return 'blue';
case 'preview':
return 'green';
@@ -50,22 +55,27 @@ export const FormElementEditModeWrapper = memo(
({ element, children, ...rest }: { element: FormElement } & FlexProps) => {
const draggableRef = useRef(null);
const dragHandleRef = useRef(null);
- const [dndListState] = useDraggableFormElement(element.id, draggableRef, dragHandleRef);
- const depth = useContext(DepthContext);
+ const container = useContainerContext();
+ const [dndListState] = useDraggableFormElement(element.id, container?.id ?? null, draggableRef, dragHandleRef);
+ const depth = useDepthContext();
const dispatch = useAppDispatch();
const removeElement = useCallback(() => {
dispatch(formElementRemoved({ id: element.id }));
}, [dispatch, element.id]);
+ if (dndListState.type !== 'idle') {
+ // console.log(element.id, 'dndListState', dndListState);
+ }
+
return (
- {getHeaderLabel(element)}
+ {element.id}
+ {/* {getHeaderLabel(element)} */}
-
+
+
{content}
-
-
+
+
);
});
TextElementComponentEditMode.displayName = 'TextElementComponentEditMode';
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/WorkflowBuilder.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/WorkflowBuilder.tsx
index 76c5986382..d379af9ff9 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/WorkflowBuilder.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/WorkflowBuilder.tsx
@@ -2,6 +2,7 @@ import { Button, Flex } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { FormElementComponent } from 'features/nodes/components/sidePanel/builder/ContainerElementComponent';
+import { useMonitorForFormElementDnd } from 'features/nodes/components/sidePanel/builder/use-builder-dnd';
import { formLoaded, formModeToggled, selectWorkflowFormMode } from 'features/nodes/store/workflowSlice';
import { elements, rootElementId } from 'features/nodes/types/workflow';
import { memo, useCallback, useEffect } from 'react';
@@ -9,14 +10,16 @@ import { memo, useCallback, useEffect } from 'react';
export const WorkflowBuilder = memo(() => {
const dispatch = useAppDispatch();
const mode = useAppSelector(selectWorkflowFormMode);
+ useMonitorForFormElementDnd();
useEffect(() => {
+ // dispatch(formReset());
dispatch(formLoaded({ elements, rootElementId }));
}, [dispatch]);
return (
-
+
{rootElementId && }
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
new file mode 100644
index 0000000000..cfb719a9d5
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/center-or-closest-edge.ts
@@ -0,0 +1,71 @@
+// Adapted from https://github.com/atlassian/pragmatic-drag-and-drop/blob/main/packages/hitbox/src/closest-edge.ts
+// This adaptation adds 'center' as a possible target
+import type { Input, Position } from '@atlaskit/pragmatic-drag-and-drop/types';
+import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/dist/types/types';
+
+export type CenterOrEdge = 'center' | Edge;
+
+// re-exporting type to make it easy to use
+
+const getDistanceToCenterOrEdge: {
+ [TKey in CenterOrEdge]: (rect: DOMRect, client: Position) => number;
+} = {
+ top: (rect, client) => Math.abs(client.y - rect.top),
+ right: (rect, client) => Math.abs(rect.right - client.x),
+ bottom: (rect, client) => Math.abs(rect.bottom - client.y),
+ left: (rect, client) => Math.abs(client.x - rect.left),
+ 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);
+ },
+};
+
+// using a symbol so we can guarantee a key with a unique value
+const uniqueKey = Symbol('centerWithClosestEdge');
+
+/**
+ * Adds a unique `Symbol` to the `userData` object. Use with `extractClosestEdge()` for type safe lookups.
+ */
+export function attachClosestCenterOrEdge(
+ userData: Record,
+ {
+ element,
+ input,
+ allowedCenterOrEdge,
+ }: {
+ element: Element;
+ input: Input;
+ allowedCenterOrEdge: CenterOrEdge[];
+ }
+): Record {
+ const client: Position = {
+ x: input.clientX,
+ y: input.clientY,
+ };
+ // I tried caching the result of `getBoundingClientRect()` for a single
+ // frame in order to improve performance.
+ // However, on measurement I saw no improvement. So no longer caching
+ const rect: DOMRect = element.getBoundingClientRect();
+ const entries = allowedCenterOrEdge.map((edge) => {
+ return {
+ edge,
+ value: getDistanceToCenterOrEdge[edge](rect, client),
+ };
+ });
+
+ // edge can be `null` when `allowedCenterOrEdge` is []
+ const addClosestCenterOrEdge: CenterOrEdge | null = entries.sort((a, b) => a.value - b.value)[0]?.edge ?? null;
+
+ return {
+ ...userData,
+ [uniqueKey]: addClosestCenterOrEdge,
+ };
+}
+
+/**
+ * Returns the value added by `attachClosestEdge()` to the `userData` object. It will return `null` if there is no value.
+ */
+export function extractClosestCenterOrEdge(userData: Record): CenterOrEdge | null {
+ return (userData[uniqueKey] as CenterOrEdge) ?? null;
+}
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/contexts.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/contexts.ts
deleted file mode 100644
index 795bb0db95..0000000000
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/contexts.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type { ContainerElement } from 'features/nodes/types/workflow';
-import { createContext } from 'react';
-
-export const ContainerContext = createContext(null);
-export const DepthContext = createContext(0);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/contexts.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/contexts.tsx
new file mode 100644
index 0000000000..2e888dc7cd
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/contexts.tsx
@@ -0,0 +1,35 @@
+import type { ContainerElement, ElementId } from 'features/nodes/types/workflow';
+import type { PropsWithChildren } from 'react';
+import { createContext, memo, useContext, useMemo } from 'react';
+
+type ContainerContextValue = {
+ id: ElementId;
+ direction: ContainerElement['data']['direction'];
+};
+
+const ContainerContext = createContext(null);
+
+export const ContainerContextProvider = memo(
+ ({ id, direction, children }: PropsWithChildren) => {
+ const ctxValue = useMemo(() => ({ id, direction }), [id, direction]);
+ return {children};
+ }
+);
+ContainerContextProvider.displayName = 'ContainerContextProvider';
+
+export const useContainerContext = () => {
+ const container = useContext(ContainerContext);
+ return container;
+};
+
+const DepthContext = createContext(0);
+
+export const DepthContextProvider = memo(({ depth, children }: PropsWithChildren<{ depth: number }>) => {
+ return {children};
+});
+DepthContextProvider.displayName = 'DepthContextProvider';
+
+export const useDepthContext = () => {
+ const depth = useContext(DepthContext);
+ return depth;
+};
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 2582170eb6..54909ea7fc 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
@@ -4,105 +4,173 @@ import {
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
-import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
-import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge';
import { getStore } from 'app/store/nanostores/store';
import { useAppDispatch } from 'app/store/storeHooks';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
-import type { DndListTargetState } from 'features/dnd/types';
-import { idle } from 'features/dnd/types';
import { firefoxDndFix, triggerPostMoveFlash } from 'features/dnd/util';
-import { formElementContainerDataChanged } from 'features/nodes/store/workflowSlice';
-import type { ElementId, FormElement } from 'features/nodes/types/workflow';
+import type { CenterOrEdge } from 'features/nodes/components/sidePanel/builder/center-or-closest-edge';
+import {
+ attachClosestCenterOrEdge,
+ extractClosestCenterOrEdge,
+} 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 { isContainerElement } from 'features/nodes/types/workflow';
import type { RefObject } from 'react';
import { useEffect, useState } from 'react';
import { flushSync } from 'react-dom';
import { assert } from 'tsafe';
-export const useMonitorForFormElementDnd = (containerId: string, children: ElementId[]) => {
+/**
+ * States for a dnd list with containers.
+ */
+export type DndListTargetState =
+ | {
+ type: 'idle';
+ }
+ | {
+ type: 'preview';
+ container: HTMLElement;
+ }
+ | {
+ type: 'is-dragging';
+ }
+ | {
+ type: 'is-dragging-over';
+ closestCenterOrEdge: CenterOrEdge | null;
+ };
+export const idle: DndListTargetState = { type: 'idle' };
+
+type DndData = {
+ element: FormElement;
+ container: ContainerElement | null;
+};
+
+const getElement = (id: ElementId, guard?: (el: FormElement) => el is T): T => {
+ const el = getStore().getState().workflow.form?.elements[id];
+ assert(el);
+ if (guard) {
+ assert(guard(el));
+ return el;
+ } else {
+ return el as T;
+ }
+};
+
+const adjustIndexForDrop = (index: number, edge: Exclude) => {
+ if (edge === 'left' || edge === 'top') {
+ return index - 1;
+ }
+ return index + 1;
+};
+
+export const useMonitorForFormElementDnd = () => {
const dispatch = useAppDispatch();
useEffect(() => {
return monitorForElements({
- canMonitor({ source }) {
- return (source.data as FormElement).id === containerId;
- },
+ // canMonitor({ source }) {
+ // return (source.data as FormElement).id === containerId;
+ // },
+ canMonitor: () => true,
onDrop({ location, source }) {
const target = location.current.dropTargets[0];
if (!target) {
return;
}
- const sourceData = source.data as FormElement;
- const targetData = target.data as FormElement;
+ const sourceData = source.data as DndData;
+ const targetData = target.data as DndData;
- const sourceElementId = sourceData.id;
- const targetElementId = targetData.id;
+ const sourceElementId = sourceData.element.id;
+ const targetElementId = targetData.element.id;
- const childrenClone = [...children];
+ const closestCenterOrEdge = extractClosestCenterOrEdge(targetData);
- const indexOfSource = childrenClone.findIndex((elementId) => elementId === sourceElementId);
- const indexOfTarget = childrenClone.findIndex((elementId) => elementId === targetElementId);
+ 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;
+ }
+ flushSync(() => {
+ dispatch(formElementMoved({ id: sourceElementId, containerId: targetContainer.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.
+ return;
+ }
+ const indexOfSource = targetContainer.data.children.findIndex((elementId) => elementId === sourceElementId);
+ const indexOfTarget = targetContainer.data.children.findIndex((elementId) => elementId === targetElementId);
- if (indexOfTarget < 0 || indexOfSource < 0) {
+ 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,
+ })
+ );
+ });
+ }
+ } else {
+ // No container, cannot do anything
return;
}
+ // const childrenClone = [...targetData.container.data.children];
- // Don't move if the source and target are the same index, meaning same position in the list
- if (indexOfSource === indexOfTarget) {
- return;
- }
+ // const indexOfSource = childrenClone.findIndex((elementId) => elementId === sourceElementId);
+ // const indexOfTarget = childrenClone.findIndex((elementId) => elementId === targetElementId);
- const closestEdgeOfTarget = extractClosestEdge(targetData);
-
- // It's possible that the indices are different, but refer to the same position. For example, if the source is
- // at 2 and the target is at 3, but the target edge is 'top', then the entity is already in the correct position.
- // We should bail if this is the case.
- // let edgeIndexDelta = 0;
-
- // if (closestEdgeOfTarget === 'bottom') {
- // edgeIndexDelta = 1;
- // } else if (closestEdgeOfTarget === 'top') {
- // edgeIndexDelta = -1;
- // }
-
- // If the source is already in the correct position, we don't need to move it.
- // if (indexOfSource === indexOfTarget + edgeIndexDelta) {
+ // if (indexOfTarget < 0 || indexOfSource < 0) {
// return;
// }
- const reorderedChildren = reorderWithEdge({
- list: childrenClone,
- startIndex: indexOfSource,
- indexOfTarget,
- closestEdgeOfTarget,
- axis: 'vertical',
- });
+ // // 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(formElementContainerDataChanged({ id: containerId, changes: { children: reorderedChildren } }));
- });
+ // flushSync(() => {
+ // dispatch(
+ // formElementMoved({
+ // id: sourceElementId,
+ // containerId: targetData.container.id,
+ // index: indexOfTarget,
+ // })
+ // );
+ // });
// Flash the element that was moved
- const element = document.querySelector(`#${sourceElementId}`);
+ const element = document.querySelector(`#${getEditModeWrapperId(sourceElementId)}`);
if (element instanceof HTMLElement) {
triggerPostMoveFlash(element, colorTokenToCssVar('base.700'));
}
},
});
- }, [children, containerId, dispatch]);
-};
-
-const getElement = (id: ElementId) => {
- const el = getStore().getState().workflow.form?.elements[id];
- assert(el !== undefined);
- return el;
+ }, [dispatch]);
};
export const useDraggableFormElement = (
elementId: ElementId,
+ containerId: ElementId | null,
draggableRef: RefObject,
dragHandleRef: RefObject
) => {
@@ -118,14 +186,16 @@ export const useDraggableFormElement = (
return combine(
firefoxDndFix(draggableElement),
draggable({
+ canDrag: () => Boolean(containerId),
element: draggableElement,
dragHandle: dragHandleElement,
getInitialData() {
- return getElement(elementId);
+ const data: DndData = {
+ element: getElement(elementId),
+ container: containerId ? getElement(containerId, isContainerElement) : null,
+ };
+ return data;
},
- // getInitialData() {
- // return singleWorkflowFieldDndSource.getData({ fieldIdentifier });
- // },
onDragStart() {
setListDndState({ type: 'is-dragging' });
setIsDragging(true);
@@ -137,35 +207,57 @@ export const useDraggableFormElement = (
}),
dropTargetForElements({
element: draggableElement,
- canDrop() {
- return isContainerElement(getElement(elementId));
- },
+ // canDrop() {},
getData({ input }) {
- const data = { elementId };
- return attachClosestEdge(data, {
+ const element = getElement(elementId);
+ const container = containerId ? getElement(containerId, isContainerElement) : null;
+
+ const data: DndData = {
+ element,
+ container,
+ };
+
+ const allowedCenterOrEdge: CenterOrEdge[] = [];
+
+ if (isContainerElement(element)) {
+ allowedCenterOrEdge.push('center');
+ }
+
+ if (container?.data.direction === 'row') {
+ allowedCenterOrEdge.push('left', 'right');
+ }
+
+ if (container?.data.direction === 'column') {
+ allowedCenterOrEdge.push('top', 'bottom');
+ }
+
+ return attachClosestCenterOrEdge(data, {
element: draggableElement,
input,
- allowedEdges: ['top', 'bottom', 'left', 'right'],
+ allowedCenterOrEdge,
});
},
getIsSticky() {
return true;
},
- onDragEnter({ self }) {
- const closestEdge = extractClosestEdge(self.data);
- setListDndState({ type: 'is-dragging-over', closestEdge });
- console.log('onDragEnter', self.data);
- },
- onDrag({ self }) {
- const closestEdge = extractClosestEdge(self.data);
+ 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.
+ if (!innermostDropTargetElement || innermostDropTargetElement !== draggableElement) {
+ setListDndState(idle);
+ return;
+ }
+
+ const closestCenterOrEdge = extractClosestCenterOrEdge(self.data);
// Only need to update react state if nothing has changed.
// Prevents re-rendering.
setListDndState((current) => {
- if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
+ if (current.type === 'is-dragging-over' && current.closestCenterOrEdge === closestCenterOrEdge) {
return current;
}
- return { type: 'is-dragging-over', closestEdge };
+ return { type: 'is-dragging-over', closestCenterOrEdge };
});
},
onDragLeave() {
@@ -176,7 +268,7 @@ export const useDraggableFormElement = (
},
})
);
- }, [dragHandleRef, draggableRef, elementId]);
+ }, [containerId, 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 367d35c2a5..aa596c6db9 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
@@ -150,30 +150,24 @@ export const workflowSlice = createSlice({
// Cannot add an element if the form has not been created
return;
}
- const { elements } = state.form;
const { element, containerId, index } = action.payload;
-
- const container = elements[containerId];
- if (!container || !isContainerElement(container)) {
- return;
- }
-
- elements[element.id] = element;
-
- if (index === undefined) {
- container.data.children.push(element.id);
- } else {
- container.data.children.splice(index, 0, element.id);
- }
+ addElement({ formState: state.form, element, containerId, index });
},
formElementRemoved: (state, action: PayloadAction<{ id: string }>) => {
if (!state.form) {
// Cannot remove an element if the form has not been created
return;
}
- const { elements, rootElementId } = state.form;
const { id } = action.payload;
- recursivelyRemoveElement(elements, id, rootElementId);
+ recursivelyRemoveElement({ id, formState: state.form });
+ },
+ formElementMoved: (state, action: PayloadAction<{ id: string; containerId: string; index?: number }>) => {
+ if (!state.form) {
+ // Cannot remove an element if the form has not been created
+ return;
+ }
+ const { id, containerId, index } = action.payload;
+ moveElement({ formState: state.form, id, containerId, index });
},
formElementContainerDataChanged: (
state,
@@ -314,6 +308,7 @@ export const {
formCreated,
formElementAdded,
formElementRemoved,
+ formElementMoved,
formElementContainerDataChanged,
formReset,
formModeToggled,
@@ -363,12 +358,14 @@ export const useElement = (id: string): FormElement | undefined => {
return element;
};
-const recursivelyRemoveElement = (
- elements: NonNullable['elements'],
- id: string,
- containerId: string
-): boolean => {
- const container = elements[containerId];
+const recursivelyRemoveElement = (args: {
+ id: string;
+ containerId?: string;
+ formState: NonNullable;
+}): boolean => {
+ const { id, containerId, formState } = args;
+ const { elements, rootElementId } = formState;
+ const container = elements[containerId || rootElementId];
if (!container || !isContainerElement(container)) {
return false;
@@ -382,10 +379,54 @@ const recursivelyRemoveElement = (
}
for (const childId of container.data.children) {
- if (recursivelyRemoveElement(elements, id, childId)) {
+ if (recursivelyRemoveElement({ id, containerId: childId, formState })) {
return true;
}
}
return false;
};
+
+const addElement = (args: {
+ formState: NonNullable;
+ element: FormElement;
+ containerId: string;
+ index?: number;
+}) => {
+ const { formState, element, containerId, index } = args;
+ const { elements } = formState;
+ const container = elements[containerId];
+ if (!container || !isContainerElement(container)) {
+ return;
+ }
+
+ elements[element.id] = element;
+
+ if (index === undefined) {
+ container.data.children.push(element.id);
+ } else {
+ container.data.children.splice(index, 0, element.id);
+ }
+};
+
+const moveElement = (args: {
+ formState: NonNullable;
+ id: string;
+ containerId: string;
+ index?: number;
+}) => {
+ const { formState, id, containerId, index } = args;
+ const { elements } = formState;
+
+ const element = elements[id];
+ if (!element) {
+ return;
+ }
+ const container = elements[containerId];
+ if (!container || !isContainerElement(container)) {
+ return;
+ }
+
+ recursivelyRemoveElement({ formState, id });
+ addElement({ formState, element, containerId, index });
+};
diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.ts
index 5edabaf2dd..2ee91f3a03 100644
--- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts
@@ -95,6 +95,7 @@ export type ElementId = z.infer;
const zElementBase = z.object({
id: zElementId,
+ parentId: zElementId.optional(),
});
const NODE_FIELD_TYPE = 'node-field';
@@ -109,11 +110,13 @@ export type NodeFieldElement = z.infer;
export const isNodeFieldElement = (el: FormElement): el is NodeFieldElement => el.type === NODE_FIELD_TYPE;
const nodeField = (
nodeId: NodeFieldElement['data']['fieldIdentifier']['nodeId'],
- fieldName: NodeFieldElement['data']['fieldIdentifier']['fieldName']
+ fieldName: NodeFieldElement['data']['fieldIdentifier']['fieldName'],
+ parentId?: NodeFieldElement['parentId']
): NodeFieldElement => {
const element: NodeFieldElement = {
id: getPrefixedId(NODE_FIELD_TYPE, '-'),
type: NODE_FIELD_TYPE,
+ parentId,
data: {
fieldIdentifier: { nodeId, fieldName },
},
@@ -139,10 +142,12 @@ export type HeadingElement = z.infer;
export const isHeadingElement = (el: FormElement): el is HeadingElement => el.type === HEADING_TYPE;
const heading = (
content: HeadingElement['data']['content'],
- level: HeadingElement['data']['level']
+ level: HeadingElement['data']['level'],
+ parentId?: NodeFieldElement['parentId']
): HeadingElement => {
const element: HeadingElement = {
id: getPrefixedId(HEADING_TYPE, '-'),
+ parentId,
type: HEADING_TYPE,
data: {
content,
@@ -168,9 +173,14 @@ const zTextElement = zElementBase.extend({
});
export type TextElement = z.infer;
export const isTextElement = (el: FormElement): el is TextElement => el.type === TEXT_TYPE;
-const text = (content: TextElement['data']['content'], fontSize: TextElement['data']['fontSize']): TextElement => {
+const text = (
+ content: TextElement['data']['content'],
+ fontSize: TextElement['data']['fontSize'],
+ parentId?: NodeFieldElement['parentId']
+): TextElement => {
const element: TextElement = {
id: getPrefixedId(TEXT_TYPE, '-'),
+ parentId,
type: TEXT_TYPE,
data: {
content,
@@ -193,9 +203,10 @@ const zDividerElement = zElementBase.extend({
});
export type DividerElement = z.infer;
export const isDividerElement = (el: FormElement): el is DividerElement => el.type === DIVIDER_TYPE;
-const divider = (): DividerElement => {
+const divider = (parentId?: NodeFieldElement['parentId']): DividerElement => {
const element: DividerElement = {
id: getPrefixedId(DIVIDER_TYPE, '-'),
+ parentId,
type: DIVIDER_TYPE,
};
addElement(element);
@@ -207,31 +218,25 @@ const _divider = (...args: Parameters): DividerElement => {
return element;
};
-export type ContainerElement = {
- id: string;
- type: typeof CONTAINER_TYPE;
- data: {
- direction: 'row' | 'column';
- children: ElementId[];
- };
-};
-
const CONTAINER_TYPE = 'container';
export const CONTAINER_CLASS_NAME = getPrefixedId(CONTAINER_TYPE, '-');
-const zContainerElement: z.ZodType = zElementBase.extend({
+const zContainerElement = zElementBase.extend({
type: z.literal(CONTAINER_TYPE),
data: z.object({
direction: z.enum(['row', 'column']),
children: z.array(zElementId),
}),
});
+export type ContainerElement = z.infer;
export const isContainerElement = (el: FormElement): el is ContainerElement => el.type === CONTAINER_TYPE;
export const container = (
direction: ContainerElement['data']['direction'],
- children: ContainerElement['data']['children']
+ children: ContainerElement['data']['children'],
+ parentId?: NodeFieldElement['parentId']
): ContainerElement => {
const element: ContainerElement = {
id: getPrefixedId(CONTAINER_TYPE, '-'),
+ parentId,
type: CONTAINER_TYPE,
data: {
direction,
@@ -250,18 +255,35 @@ const zFormElement = z.union([zContainerElement, zNodeFieldElement, zHeadingElem
export type FormElement = z.infer;
-export const rootElementId: string = _container('column', [
- _heading('My Cool Workflow', 1).id,
- _text('This is a description of what my workflow does. It does things.', 'md').id,
- _divider().id,
- _heading('First Section', 2).id,
- _text('The first section includes fields relevant to the first section. This note describes that fact.', 'sm').id,
- _divider().id,
- _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
- _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
- _nodeField('14744f68-9000-4694-b4d6-cbe83ee231ee', 'model').id,
-]).id;
+// export const rootElementId: string = _container('column', [
+// _heading('My Cool Workflow', 1).id,
+// _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
+// _nodeField('14744f68-9000-4694-b4d6-cbe83ee231ee', 'model').id,
+// _container('row', [_container('column', []).id, _container('column', []).id, _container('column', []).id]).id,
+// ]).id;
+const rootContainer = container('column', []);
+addElement(rootContainer);
+const children = [
+ heading('My Cool Workflow', 1, rootContainer.id),
+ text('This is a description of what my workflow does. It does things.', 'md', rootContainer.id),
+ divider(rootContainer.id),
+ heading('First Section', 2, rootContainer.id),
+ text(
+ 'The first section includes fields relevant to the first section. This note describes that fact.',
+ 'sm',
+ rootContainer.id
+ ),
+ divider(rootContainer.id),
+ text('These are some text that are definitely super helpful.', 'sm', rootContainer.id),
+ divider(rootContainer.id),
+];
+children.forEach((child) => {
+ addElement(child);
+ rootContainer.data.children.push(child.id);
+});
+
+export const rootElementId = rootContainer.id;
// export const rootElementId: string = _container('column', [
// _heading('My Cool Workflow', 1).id,
// _text('This is a description of what my workflow does. It does things.', 'md').id,
@@ -269,36 +291,36 @@ export const rootElementId: string = _container('column', [
// _heading('First Section', 2).id,
// _text('The first section includes fields relevant to the first section. This note describes that fact.', 'sm').id,
// _divider().id,
-// _container('row', [
-// _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
-// _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
-// _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
-// ]).id,
-// _nodeField('9c058600-8d73-4702-912b-0ccf37403bfd', 'value').id,
-// _nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value').id,
-// _nodeField('4e16cbf6-457c-46fb-9ab7-9cb262fa1e03', 'value').id,
-// _nodeField('39cb5272-a9d7-4da9-9c35-32e02b46bb34', 'color').id,
-// _container('row', [
-// _container('column', [
-// _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
-// _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
-// ]).id,
-// _container('column', [
-// _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
-// _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
-// _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
-// _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
-// ]).id,
-// ]).id,
-// _nodeField('14744f68-9000-4694-b4d6-cbe83ee231ee', 'model').id,
+// // _container('row', [
+// // _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
+// // _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
+// // _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
+// // ]).id,
+// // _nodeField('9c058600-8d73-4702-912b-0ccf37403bfd', 'value').id,
+// // _nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value').id,
+// // _nodeField('4e16cbf6-457c-46fb-9ab7-9cb262fa1e03', 'value').id,
+// // _nodeField('39cb5272-a9d7-4da9-9c35-32e02b46bb34', 'color').id,
+// // _container('row', [
+// // _container('column', [
+// // _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
+// // _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
+// // ]).id,
+// // _container('column', [
+// // _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
+// // _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
+// // _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
+// // _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
+// // ]).id,
+// // ]).id,
+// // _nodeField('14744f68-9000-4694-b4d6-cbe83ee231ee', 'model').id,
// _divider().id,
// _text('These are some text that are definitely super helpful.', 'sm').id,
// _divider().id,
-// _container('row', [
-// _container('column', [
-// _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
-// _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
-// ]).id,
-// _container('column', [_nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value').id]).id,
-// ]).id,
+// // _container('row', [
+// // _container('column', [
+// // _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
+// // _nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
+// // ]).id,
+// // _container('column', [_nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value').id]).id,
+// // ]).id,
// ]).id;