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;