diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerContext.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerContext.ts deleted file mode 100644 index 167df8506b..0000000000 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerContext.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ContainerElement } from 'features/nodes/types/workflow'; -import { createContext, useContext } from 'react'; - -export const ContainerContext = createContext(null); - -export const useContainerContext = () => { - const containerDirection = useContext(ContainerContext); - return containerDirection; -}; 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 99d6d704ca..be5d766ac6 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,63 +1,120 @@ -import { Flex, type SystemStyleObject } from '@invoke-ai/ui-library'; -import { ContainerContext } from 'features/nodes/components/sidePanel/builder/ContainerContext'; +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 { 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 { useElement } from 'features/nodes/store/workflowSlice'; +import { formElementAdded, selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice'; +import type { ContainerElement } from 'features/nodes/types/workflow'; import { + container, CONTAINER_CLASS_NAME, - DIVIDER_CLASS_NAME, isContainerElement, isDividerElement, isHeadingElement, isNodeFieldElement, isTextElement, } from 'features/nodes/types/workflow'; -import { memo } from 'react'; +import { memo, useCallback, useContext } from 'react'; +import { PiPlusBold } from 'react-icons/pi'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; const sx: SystemStyleObject = { gap: 4, + flex: '1 1 0', '&[data-container-direction="column"]': { flexDir: 'column', - '> :last-child': { - flex: '1 0 0', - alignItems: 'flex-start', - }, }, '&[data-container-direction="row"]': { - '> *': { - flex: '1 1 0', - }, - }, - [`& > .${DIVIDER_CLASS_NAME}`]: { - flex: '0 0 1px', + flexDir: 'row', }, }; export const ContainerElementComponent = memo(({ id }: { id: string }) => { const el = useElement(id); + const mode = useAppSelector(selectWorkflowFormMode); if (!el || !isContainerElement(el)) { return null; } - const { children, direction } = el.data; + if (mode === 'view') { + return ; + } - return ( - - - {children.map((childId) => ( - - ))} - - - ); + // mode === 'edit' + return ; }); ContainerElementComponent.displayName = 'ContainerElementComponent'; +export const ContainerElementComponentViewMode = memo(({ el }: { el: ContainerElement }) => { + const depth = useContext(DepthContext); + 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 { id, data } = el; + const { children, direction } = data; + + return ( + + + + + {children.map((childId) => ( + + ))} + {direction === 'row' && children.length < 3 && depth < 2 && } + {direction === 'column' && depth < 1 && } + + + + + ); +}); +ContainerElementComponentEditMode.displayName = 'ContainerElementComponentEditMode'; + +const AddColumnButton = ({ containerId }: { containerId: string }) => { + const dispatch = useAppDispatch(); + const onClick = useCallback(() => { + const element = container('column', []); + dispatch(formElementAdded({ element, containerId })); + }, [containerId, dispatch]); + return ( + } h="unset" variant="ghost" size="sm" /> + ); +}; + +const AddRowButton = ({ containerId }: { containerId: string }) => { + const dispatch = useAppDispatch(); + const onClick = useCallback(() => { + const element = container('row', []); + dispatch(formElementAdded({ element, containerId })); + }, [containerId, dispatch]); + return ( + } w="unset" variant="ghost" size="sm" /> + ); +}; + // TODO(psyche): Can we move this into a separate file and avoid circular dependencies between it and ContainerElementComponent? export const FormElementComponent = memo(({ id }: { id: string }) => { const el = useElement(id); 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 d34952e91d..29b9e8be3c 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,22 +1,74 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex } from '@invoke-ai/ui-library'; -import { useElement } from 'features/nodes/store/workflowSlice'; +import { useAppSelector } from 'app/store/storeHooks'; +import { ContainerContext } 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 } from 'react'; +import { memo, useContext } from 'react'; const sx: SystemStyleObject = { bg: 'base.700', flexShrink: 0, + '&[data-orientation="horizontal"]': { + width: '100%', + height: '1px', + }, + '&[data-orientation="vertical"]': { + height: '100%', + width: '1px', + }, }; export const DividerElementComponent = memo(({ id }: { id: string }) => { const el = useElement(id); + const mode = useAppSelector(selectWorkflowFormMode); if (!el || !isDividerElement(el)) { return; } - return ; + if (mode === 'view') { + return ; + } + + // mode === 'edit' + return ; }); DividerElementComponent.displayName = 'DividerElementComponent'; + +export const DividerElementComponentViewMode = memo(({ el }: { el: DividerElement }) => { + const container = useContext(ContainerContext); + const { id } = el; + + return ( + + ); +}); + +DividerElementComponentViewMode.displayName = 'DividerElementComponentViewMode'; + +export const DividerElementComponentEditMode = memo(({ el }: { el: DividerElement }) => { + const container = useContext(ContainerContext); + const { id } = el; + + return ( + + + + ); +}); + +DividerElementComponentEditMode.displayName = 'DividerElementComponentEditMode'; 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 new file mode 100644 index 0000000000..dee267ad46 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeWrapper.tsx @@ -0,0 +1,87 @@ +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 { DepthContext } from 'features/nodes/components/sidePanel/builder/contexts'; +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 } from 'react'; +import { PiXBold } from 'react-icons/pi'; + +export const EDIT_MODE_WRAPPER_CLASS_NAME = getPrefixedId('edit-mode-wrapper', '-'); + +const getHeaderBgColor = (depth: number) => { + if (depth <= 1) { + return 'base.800'; + } + if (depth === 2) { + return 'base.750'; + } + return 'base.700'; +}; + +const getHeaderLabel = (el: FormElement) => { + if (isContainerElement(el)) { + if (el.data.direction === 'column') { + return 'Column'; + } + return 'Row'; + } + return startCase(el.type); +}; + +export const FormElementEditModeWrapper = memo( + ({ element, children, ...rest }: { element: FormElement } & FlexProps) => { + const depth = useContext(DepthContext); + const dispatch = useAppDispatch(); + const removeElement = useCallback(() => { + dispatch(formElementRemoved({ id: element.id })); + }, [dispatch, element.id]); + + return ( + + + + {getHeaderLabel(element)} + + + } + variant="link" + size="sm" + alignSelf="stretch" + colorScheme="error" + /> + + + {children} + + + ); + } +); + +FormElementEditModeWrapper.displayName = 'FormElementEditModeWrapper'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/HeadingElementComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/HeadingElementComponent.tsx index b8ea2c2c96..6608811bac 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/HeadingElementComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/HeadingElementComponent.tsx @@ -1,5 +1,8 @@ import { Flex, Heading } from '@invoke-ai/ui-library'; -import { useElement } from 'features/nodes/store/workflowSlice'; +import { useAppSelector } from 'app/store/storeHooks'; +import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper'; +import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice'; +import type { HeadingElement } from 'features/nodes/types/workflow'; import { HEADING_CLASS_NAME, isHeadingElement } from 'features/nodes/types/workflow'; import { memo } from 'react'; @@ -13,12 +16,25 @@ const LEVEL_TO_SIZE = { export const HeadingElementComponent = memo(({ id }: { id: string }) => { const el = useElement(id); + const mode = useAppSelector(selectWorkflowFormMode); if (!el || !isHeadingElement(el)) { return null; } - const { content, level } = el.data; + if (mode === 'view') { + return ; + } + + // mode === 'edit' + return ; +}); + +HeadingElementComponent.displayName = 'HeadingElementComponent'; + +export const HeadingElementComponentViewMode = memo(({ el }: { el: HeadingElement }) => { + const { id, data } = el; + const { content, level } = data; return ( @@ -27,4 +43,19 @@ export const HeadingElementComponent = memo(({ id }: { id: string }) => { ); }); -HeadingElementComponent.displayName = 'HeadingElementComponent'; +HeadingElementComponentViewMode.displayName = 'HeadingElementComponentViewMode'; + +export const HeadingElementComponentEditMode = memo(({ el }: { el: HeadingElement }) => { + const { id, data } = el; + const { content, level } = data; + + return ( + + + {content} + + + ); +}); + +HeadingElementComponentEditMode.displayName = 'HeadingElementComponentEditMode'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementComponent.tsx index 425dcc5698..60ced59ba1 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementComponent.tsx @@ -1,18 +1,34 @@ import { Flex } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate'; import { InputFieldViewMode } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldViewMode'; -import { useElement } from 'features/nodes/store/workflowSlice'; +import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper'; +import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice'; +import type { NodeFieldElement } from 'features/nodes/types/workflow'; import { isNodeFieldElement, NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow'; import { memo } from 'react'; export const NodeFieldElementComponent = memo(({ id }: { id: string }) => { const el = useElement(id); + const mode = useAppSelector(selectWorkflowFormMode); if (!el || !isNodeFieldElement(el)) { return null; } - const { fieldIdentifier } = el.data; + if (mode === 'view') { + return ; + } + + // mode === 'edit' + return ; +}); + +NodeFieldElementComponent.displayName = 'NodeFieldElementComponent'; + +export const NodeFieldElementComponentViewMode = memo(({ el }: { el: NodeFieldElement }) => { + const { id, data } = el; + const { fieldIdentifier } = data; return ( @@ -23,4 +39,21 @@ export const NodeFieldElementComponent = memo(({ id }: { id: string }) => { ); }); -NodeFieldElementComponent.displayName = 'NodeFieldElementComponent'; +NodeFieldElementComponentViewMode.displayName = 'NodeFieldElementComponentViewMode'; + +export const NodeFieldElementComponentEditMode = memo(({ el }: { el: NodeFieldElement }) => { + const { id, data } = el; + const { fieldIdentifier } = data; + + return ( + + + + + + + + ); +}); + +NodeFieldElementComponentEditMode.displayName = 'NodeFieldElementComponentEditMode'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/TextElementComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/TextElementComponent.tsx index 398db52157..90c4bdf612 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/TextElementComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/TextElementComponent.tsx @@ -1,16 +1,31 @@ import { Flex, Text } from '@invoke-ai/ui-library'; -import { useElement } from 'features/nodes/store/workflowSlice'; +import { useAppSelector } from 'app/store/storeHooks'; +import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper'; +import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice'; +import type { TextElement } from 'features/nodes/types/workflow'; import { isTextElement, TEXT_CLASS_NAME } from 'features/nodes/types/workflow'; import { memo } from 'react'; export const TextElementComponent = memo(({ id }: { id: string }) => { const el = useElement(id); + const mode = useAppSelector(selectWorkflowFormMode); if (!el || !isTextElement(el)) { return null; } - const { content, fontSize } = el.data; + if (mode === 'view') { + return ; + } + + // mode === 'edit' + return ; +}); +TextElementComponent.displayName = 'TextElementComponent'; + +export const TextElementComponentViewMode = memo(({ el }: { el: TextElement }) => { + const { id, data } = el; + const { content, fontSize } = data; return ( @@ -18,5 +33,18 @@ export const TextElementComponent = memo(({ id }: { id: string }) => { ); }); +TextElementComponentViewMode.displayName = 'TextElementComponentViewMode'; -TextElementComponent.displayName = 'TextElementComponent'; +export const TextElementComponentEditMode = memo(({ el }: { el: TextElement }) => { + const { id, data } = el; + const { content, fontSize } = data; + + return ( + + + {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 9d45d2321c..76c5986382 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 @@ -1,20 +1,23 @@ -import { Flex } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; +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 { formLoaded } from 'features/nodes/store/workflowSlice'; +import { formLoaded, formModeToggled, selectWorkflowFormMode } from 'features/nodes/store/workflowSlice'; import { elements, rootElementId } from 'features/nodes/types/workflow'; -import { memo, useEffect } from 'react'; +import { memo, useCallback, useEffect } from 'react'; export const WorkflowBuilder = memo(() => { const dispatch = useAppDispatch(); + const mode = useAppSelector(selectWorkflowFormMode); + useEffect(() => { dispatch(formLoaded({ elements, rootElementId })); }, [dispatch]); return ( - + + {rootElementId && } @@ -23,3 +26,15 @@ export const WorkflowBuilder = memo(() => { }); WorkflowBuilder.displayName = 'WorkflowBuilder'; + +const ToggleModeButton = memo(() => { + const dispatch = useAppDispatch(); + const mode = useAppSelector(selectWorkflowFormMode); + + const onClick = useCallback(() => { + dispatch(formModeToggled()); + }, [dispatch]); + + return ; +}); +ToggleModeButton.displayName = 'ToggleModeButton'; 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 new file mode 100644 index 0000000000..795bb0db95 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/contexts.ts @@ -0,0 +1,5 @@ +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/workflow/WorkflowPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx index e21606101b..1bfe0f7eac 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx @@ -13,7 +13,7 @@ const WorkflowFieldsLinearViewPanel = () => { - Builder + {t('common.builder')} {t('common.linear')} {t('common.details')} JSON diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 5684d0f2b3..06279aa61d 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -39,6 +39,7 @@ export type WorkflowsState = Omit & { _version: 1; isTouched: boolean; mode: WorkflowMode; + formMode: WorkflowMode; originalExposedFieldValues: FieldIdentifierWithValue[]; searchTerm: string; orderBy?: WorkflowRecordOrderBy; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts index ee923453f5..c9f5db2572 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts @@ -12,7 +12,13 @@ import type { } from 'features/nodes/store/types'; import type { FieldIdentifier } from 'features/nodes/types/field'; import { isInvocationNode } from 'features/nodes/types/invocation'; -import type { ContainerElement, FormElement, WorkflowCategory, WorkflowV3 } from 'features/nodes/types/workflow'; +import { + type ContainerElement, + type FormElement, + isContainerElement, + type WorkflowCategory, + type WorkflowV3, +} from 'features/nodes/types/workflow'; import { isEqual, omit, uniqBy } from 'lodash-es'; import { useMemo } from 'react'; import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types'; @@ -42,6 +48,7 @@ const initialWorkflowState: WorkflowState = { orderBy: undefined, // initial value is decided in component orderDirection: 'DESC', categorySections: {}, + formMode: 'view', ...blankWorkflow, }; @@ -138,31 +145,39 @@ export const workflowSlice = createSlice({ rootElementId: container.id, }; }, - formElementAdded: (state, action: PayloadAction<{ element: FormElement; containerId: string; index: number }>) => { + formElementAdded: (state, action: PayloadAction<{ element: FormElement; containerId: string; index?: number }>) => { if (!state.form) { // Cannot add an element if the form has not been created return; } + const { elements } = state.form; const { element, containerId, index } = action.payload; - const containerElement = state.form.elements[containerId]; - if (containerElement?.type !== 'container') { - return; - } - state.form.elements[element.id] = element; - containerElement.data.children.splice(index, 0, element.id); + addElement(elements, 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 form = state.form; + const { elements, rootElementId } = state.form; const { id } = action.payload; - buildRemoveElementById(form.elements)(id, form.rootElementId); + removeElement(elements, id, rootElementId); + }, + formElementMoved: (state, action: PayloadAction<{ id: string; containerId: string; index: number }>) => { + if (!state.form) { + // Cannot move an element if the form has not been created + return; + } + const { elements } = state.form; + const { id, containerId, index } = action.payload; + moveElement(elements, id, containerId, index); }, formReset: (state) => { state.form = undefined; }, + formModeToggled: (state) => { + state.formMode = state.formMode === 'edit' ? 'view' : 'edit'; + }, }, extraReducers: (builder) => { builder.addCase(workflowLoaded, (state, action) => { @@ -281,6 +296,7 @@ export const { formElementAdded, formElementRemoved, formReset, + formModeToggled, } = workflowSlice.actions; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -305,6 +321,7 @@ const createWorkflowSelector = (selector: Selector) => export const selectWorkflowName = createWorkflowSelector((workflow) => workflow.name); export const selectWorkflowId = createWorkflowSelector((workflow) => workflow.id); export const selectWorkflowMode = createWorkflowSelector((workflow) => workflow.mode); +export const selectWorkflowFormMode = createWorkflowSelector((workflow) => workflow.formMode); export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => workflow.isTouched); export const selectWorkflowSearchTerm = createWorkflowSelector((workflow) => workflow.searchTerm); export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workflow.orderBy); @@ -326,28 +343,66 @@ export const useElement = (id: string): FormElement | undefined => { return element; }; -const buildRemoveElementById = (elements: NonNullable['elements']) => { - const removeElementById = (id: string, containerId: string): boolean => { - const container = elements[containerId] as ContainerElement; +const addElement = ( + elements: NonNullable['elements'], + element: FormElement, + containerId: string, + index?: number +) => { + 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); + } +}; - if (container.type !== 'container') { - return false; - } +const removeElement = ( + elements: NonNullable['elements'], + id: string, + containerId: string +): boolean => { + const container = elements[containerId]; - const index = container.data.children.indexOf(id); - if (index !== -1) { - container.data.children.splice(index, 1); - delete elements[id]; + if (!container || !isContainerElement(container)) { + return false; + } + + const index = container.data.children.indexOf(id); + if (index !== -1) { + container.data.children.splice(index, 1); + delete elements[id]; + return true; + } + + for (const childId of container.data.children) { + if (removeElement(elements, id, childId)) { return true; } + } - for (const childId of container.data.children) { - if (removeElementById(id, childId)) { - return true; - } - } - - return false; - }; - return removeElementById; + return false; +}; + +const moveElement = ( + elements: NonNullable['elements'], + id: string, + containerId: string, + index: number +) => { + const element = elements[id]; + if (!element) { + return; + } + const container = elements[containerId]; + if (!container || !isContainerElement(container)) { + return; + } + + removeElement(elements, id, containerId); + addElement(elements, 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 fa9ca7e7f5..a5aec990ca 100644 --- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts +++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts @@ -118,6 +118,10 @@ const nodeField = ( fieldIdentifier: { nodeId, fieldName }, }, }; + return element; +}; +const _nodeField = (...args: Parameters): NodeFieldElement => { + const element = nodeField(...args); addElement(element); return element; }; @@ -145,6 +149,10 @@ const heading = ( level, }, }; + return element; +}; +const _heading = (...args: Parameters): HeadingElement => { + const element = heading(...args); addElement(element); return element; }; @@ -172,6 +180,11 @@ const text = (content: TextElement['data']['content'], fontSize: TextElement['da addElement(element); return element; }; +const _text = (...args: Parameters): TextElement => { + const element = text(...args); + addElement(element); + return element; +}; const DIVIDER_TYPE = 'divider'; export const DIVIDER_CLASS_NAME = getPrefixedId(DIVIDER_TYPE, '-'); @@ -188,6 +201,11 @@ const divider = (): DividerElement => { addElement(element); return element; }; +const _divider = (...args: Parameters): DividerElement => { + const element = divider(...args); + addElement(element); + return element; +}; export type ContainerElement = { id: string; @@ -208,7 +226,7 @@ const zContainerElement: z.ZodType = zElementBase.extend({ }), }); export const isContainerElement = (el: FormElement): el is ContainerElement => el.type === CONTAINER_TYPE; -const container = ( +export const container = ( direction: ContainerElement['data']['direction'], children: ContainerElement['data']['children'] ): ContainerElement => { @@ -220,6 +238,10 @@ const container = ( children, }, }; + return element; +}; +export const _container = (...args: Parameters): ContainerElement => { + const element = container(...args); addElement(element); return element; }; @@ -228,55 +250,53 @@ 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, - container('row', [ - nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id, - divider().id, - nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id, - divider().id, - nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').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, + _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, + _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, + _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('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, - nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, + _container('column', [ + _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, + _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, + _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, + _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, ]).id, - container('column', [ - container('row', [ - nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, - nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, + _container('column', [ + _container('row', [ + _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, + _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, ]).id, - container('row', [ - nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, - nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, + _container('row', [ + _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, + _nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id, ]).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, + _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, + _container('column', [_nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value').id]).id, ]).id, ]).id;