From 09879f4e1902a181aee85cde515bed28e66afbfc Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 5 Feb 2025 19:51:32 +1100 Subject: [PATCH] feat(ui): builder field settings (WIP) --- invokeai/frontend/web/public/locales/en.json | 18 ++++++- .../fields/FloatField/FloatFieldInput.tsx | 1 + .../fields/InputFieldEditModeNodes.tsx | 9 ++-- .../Invocation/fields/InputFieldRenderer.tsx | 20 ++++++-- .../Invocation/fields/InputFieldTitle.tsx | 23 +++++---- .../IntegerFieldInputAndSlider.tsx | 42 +++++++++++++++++ .../IntegerField/IntegerFieldSlider.tsx | 2 +- .../builder/ContainerElementComponent.tsx | 16 +++---- .../builder/ContainerElementSettings.tsx | 36 +++++++------- .../builder/DividerElementComponent.tsx | 4 +- .../builder/FormElementEditModeHeader.tsx | 6 ++- .../builder/NodeFieldElementComponent.tsx | 20 ++++---- .../NodeFieldElementIntegerSettings.tsx | 35 ++++++++++++++ .../builder/NodeFieldElementSettings.tsx | 47 +++++++++++++++++-- .../sidePanel/builder/WorkflowBuilder.tsx | 19 ++++++-- .../components/sidePanel/builder/contexts.tsx | 12 ++--- .../sidePanel/builder/use-builder-dnd.ts | 33 ++++++++----- .../web/src/features/nodes/types/workflow.ts | 39 +++++++++++---- 18 files changed, 290 insertions(+), 92 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index e1563f9896..ec9c412def 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -187,7 +187,10 @@ "values": "Values", "resetToDefaults": "Reset to Defaults", "seed": "Seed", - "combinatorial": "Combinatorial" + "combinatorial": "Combinatorial", + "layout": "Layout", + "row": "Row", + "column": "Column" }, "hrf": { "hrf": "High Resolution Fix", @@ -1694,7 +1697,18 @@ "download": "Download", "copyShareLink": "Copy Share Link", "copyShareLinkForWorkflow": "Copy Share Link for Workflow", - "delete": "Delete" + "delete": "Delete", + "builder": { + "layout": "Layout", + "row": "Row", + "column": "Column", + "label": "Label", + "description": "Description", + "component": "Component", + "input": "Input", + "slider": "Slider", + "both": "Both" + } }, "controlLayers": { "regional": "Regional", diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput.tsx index d97effb5f1..13b9d722d0 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/FloatField/FloatFieldInput.tsx @@ -17,6 +17,7 @@ export const FloatFieldInput = memo((props: FieldComponentProps ); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx index a83daf308f..2266460cd5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx @@ -10,7 +10,7 @@ import { useInputFieldConnectionState } from 'features/nodes/hooks/useInputField import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected'; import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid'; import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate'; -import type { FieldIdentifier } from 'features/nodes/types/field'; +import type { FieldIdentifier, FieldInputTemplate } from 'features/nodes/types/field'; import type { RefObject } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; @@ -44,7 +44,7 @@ export const InputFieldEditModeNodes = memo(({ nodeId, fieldName }: Props) => { setIsHovered(false); }, []); - const isDragging = useNodeFieldDnd({ nodeId, fieldName }, draggableRef, dragHandleRef); + const isDragging = useNodeFieldDnd({ nodeId, fieldName }, fieldTemplate, draggableRef, dragHandleRef); if (fieldTemplate.input === 'connection' || isConnected) { return ( @@ -113,6 +113,7 @@ InputFieldEditModeNodes.displayName = 'InputFieldEditModeNodes'; const useNodeFieldDnd = ( fieldIdentifier: FieldIdentifier, + fieldTemplate: FieldInputTemplate, draggableRef: RefObject, dragHandleRef: RefObject ) => { @@ -129,7 +130,7 @@ const useNodeFieldDnd = ( draggable({ element: draggableElement, dragHandle: dragHandleElement, - getInitialData: () => buildNodeFieldDndData(fieldIdentifier), + getInitialData: () => buildNodeFieldDndData(fieldIdentifier, fieldTemplate.type), onDragStart: () => { setIsDragging(true); }, @@ -138,7 +139,7 @@ const useNodeFieldDnd = ( }, }) ); - }, [dragHandleRef, draggableRef, fieldIdentifier]); + }, [dragHandleRef, draggableRef, fieldIdentifier, fieldTemplate.type]); return isDragging; }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx index c844853fbe..d30ba91088 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx @@ -8,6 +8,8 @@ import ModelIdentifierFieldInputComponent from 'features/nodes/components/flow/n import { StringFieldCollectionInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringFieldCollectionInputComponent'; import { StringGeneratorFieldInputComponent } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/StringGeneratorFieldComponent'; import { IntegerFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput'; +import { IntegerFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider'; +import { IntegerFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider'; import { StringFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldInput'; import { StringFieldTextarea } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldTextarea'; import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance'; @@ -82,6 +84,7 @@ import { isVAEModelFieldInputInstance, isVAEModelFieldInputTemplate, } from 'features/nodes/types/field'; +import type { NodeFieldElement } from 'features/nodes/types/workflow'; import { memo } from 'react'; import BoardFieldInputComponent from './inputs/BoardFieldInputComponent'; @@ -107,12 +110,14 @@ import SpandrelImageToImageModelFieldInputComponent from './inputs/SpandrelImage import T2IAdapterModelFieldInputComponent from './inputs/T2IAdapterModelFieldInputComponent'; import T5EncoderModelFieldInputComponent from './inputs/T5EncoderModelFieldInputComponent'; import VAEModelFieldInputComponent from './inputs/VAEModelFieldInputComponent'; -type InputFieldProps = { + +type Props = { nodeId: string; fieldName: string; + config?: NodeFieldElement['data']['config']; }; -export const InputFieldRenderer = memo(({ nodeId, fieldName }: InputFieldProps) => { +export const InputFieldRenderer = memo(({ nodeId, fieldName, config }: Props) => { const field = useInputFieldInstance(nodeId, fieldName); const template = useInputFieldTemplate(nodeId, fieldName); @@ -145,7 +150,16 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName }: InputFieldProps) if (!isIntegerFieldInputInstance(field)) { return null; } - return ; + if (config?.configType !== 'integer-field-config') { + return ; + } + if (config.component === 'input') { + return ; + } else if (config.component === 'slider') { + return ; + } else if (config.component === 'input-and-slider') { + return ; + } } if (isFloatFieldInputTemplate(template)) { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx index 1229b819ed..d4d2f93f08 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx @@ -7,7 +7,7 @@ import { useInputFieldTemplateTitle } from 'features/nodes/hooks/useInputFieldTe import { fieldLabelChanged } from 'features/nodes/store/nodesSlice'; import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants'; import type { ChangeEvent, KeyboardEvent } from 'react'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; const labelSx: SystemStyleObject = { @@ -42,15 +42,17 @@ export const InputFieldTitle = memo((props: Props) => { const dispatch = useAppDispatch(); const [isEditing, setIsEditing] = useState(false); - const [localTitle, setLocalTitle] = useState(label || fieldTemplateTitle || t('nodes.unknownField')); + const initialTitle = useMemo( + () => label || fieldTemplateTitle || t('nodes.unknownField'), + [fieldTemplateTitle, label, t] + ); + const [localTitle, setLocalTitle] = useState(() => initialTitle); const onBlur = useCallback(() => { const trimmedTitle = localTitle.trim(); const finalTitle = trimmedTitle || fieldTemplateTitle || t('nodes.unknownField'); - if (trimmedTitle !== localTitle) { - setLocalTitle(finalTitle); - dispatch(fieldLabelChanged({ nodeId, fieldName, label: finalTitle })); - } + setLocalTitle(finalTitle); + dispatch(fieldLabelChanged({ nodeId, fieldName, label: finalTitle })); setIsEditing(false); }, [localTitle, fieldTemplateTitle, t, dispatch, nodeId, fieldName]); @@ -63,11 +65,12 @@ export const InputFieldTitle = memo((props: Props) => { if (e.key === 'Enter') { onBlur(); } else if (e.key === 'Escape') { - setLocalTitle(label || fieldTemplateTitle || t('nodes.unknownField')); + setLocalTitle(initialTitle); + onBlur(); setIsEditing(false); } }, - [fieldTemplateTitle, label, onBlur, t] + [initialTitle, onBlur] ); const onEdit = useCallback(() => { @@ -76,8 +79,8 @@ export const InputFieldTitle = memo((props: Props) => { useEffect(() => { // Another component may change the title; sync local title with global state - setLocalTitle(label || fieldTemplateTitle || t('nodes.unknownField')); - }, [label, fieldTemplateTitle, t]); + setLocalTitle(initialTitle); + }, [label, fieldTemplateTitle, t, initialTitle]); useEffect(() => { if (isEditing) { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider.tsx new file mode 100644 index 0000000000..232f330af7 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider.tsx @@ -0,0 +1,42 @@ +import { CompositeNumberInput, CompositeSlider } from '@invoke-ai/ui-library'; +import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types'; +import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField'; +import type { IntegerFieldInputInstance, IntegerFieldInputTemplate } from 'features/nodes/types/field'; +import { memo } from 'react'; + +export const IntegerFieldInputAndSlider = memo( + (props: FieldComponentProps) => { + const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props); + + return ( + <> + + + + ); + } +); + +IntegerFieldInputAndSlider.displayName = 'IntegerFieldInputAndSlider'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider.tsx index cd8a0a50eb..b5a654eda1 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider.tsx @@ -18,9 +18,9 @@ export const IntegerFieldSlider = memo( step={step} fineStep={fineStep} className="nodrag" - w="full" marks withThumbTooltip + flex="1 1 0" /> ); } 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 8647194c03..e22cdd0828 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 @@ -27,10 +27,10 @@ import { assert } from 'tsafe'; const sx: SystemStyleObject = { gap: 4, flex: '1 1 0', - '&[data-container-direction="column"]': { + '&[data-container-layout="column"]': { flexDir: 'column', }, - '&[data-container-direction="row"]': { + '&[data-container-layout="row"]': { flexDir: 'row', }, }; @@ -55,12 +55,12 @@ ContainerElementComponent.displayName = 'ContainerElementComponent'; export const ContainerElementComponentViewMode = memo(({ el }: { el: ContainerElement }) => { const depth = useDepthContext(); const { id, data } = el; - const { children, direction } = data; + const { children, layout } = data; return ( - - + + {children.map((childId) => ( ))} @@ -74,13 +74,13 @@ ContainerElementComponentViewMode.displayName = 'ContainerElementComponentViewMo export const ContainerElementComponentEditMode = memo(({ el }: { el: ContainerElement }) => { const depth = useDepthContext(); const { id, data } = el; - const { children, direction } = data; + const { children, layout } = data; return ( - - + + {children.map((childId) => ( ))} diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElementSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElementSettings.tsx index 992637d2fa..07ccf57049 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElementSettings.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/ContainerElementSettings.tsx @@ -5,6 +5,7 @@ import { FormLabel, IconButton, Popover, + PopoverArrow, PopoverBody, PopoverContent, PopoverTrigger, @@ -13,36 +14,39 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { formElementContainerDataChanged } from 'features/nodes/store/workflowSlice'; import type { ContainerElement } from 'features/nodes/types/workflow'; import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { PiWrenchFill } from 'react-icons/pi'; export const ContainerElementSettings = memo(({ element }: { element: ContainerElement }) => { + const { id, data } = element; + const { layout } = data; + const { t } = useTranslation(); const dispatch = useAppDispatch(); - const toggleDirection = useCallback(() => { - dispatch( - formElementContainerDataChanged({ - id: element.id, - changes: { direction: element.data.direction === 'column' ? 'row' : 'column' }, - }) - ); - }, [dispatch, element.data.direction, element.id]); + + const setLayoutToRow = useCallback(() => { + dispatch(formElementContainerDataChanged({ id, changes: { layout: 'row' } })); + }, [dispatch, id]); + + const setLayoutToColumn = useCallback(() => { + dispatch(formElementContainerDataChanged({ id, changes: { layout: 'column' } })); + }, [dispatch, id]); + return ( } variant="link" size="sm" alignSelf="stretch" /> + - Direction + {t('workflows.builder.layout')} - - 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 45276c8065..b30eeab4ae 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 @@ -48,7 +48,7 @@ export const DividerElementComponentViewMode = memo(({ el }: { el: DividerElemen id={id} className={DIVIDER_CLASS_NAME} sx={sx} - data-orientation={container?.direction === 'column' ? 'horizontal' : 'vertical'} + data-orientation={container?.layout === 'column' ? 'horizontal' : 'vertical'} /> ); }); @@ -65,7 +65,7 @@ export const DividerElementComponentEditMode = memo(({ el }: { el: DividerElemen id={id} className={DIVIDER_CLASS_NAME} sx={sx} - data-orientation={container?.direction === 'column' ? 'horizontal' : 'vertical'} + data-orientation={container?.layout === 'column' ? 'horizontal' : 'vertical'} /> ); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx index f6ce3b28bf..c5afb58ea5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx @@ -3,15 +3,16 @@ import { Flex, forwardRef, IconButton, Spacer, Text } from '@invoke-ai/ui-librar import { useAppDispatch } from 'app/store/storeHooks'; import { ContainerElementSettings } from 'features/nodes/components/sidePanel/builder/ContainerElementSettings'; import { useDepthContext } from 'features/nodes/components/sidePanel/builder/contexts'; +import { NodeFieldElementSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementSettings'; import { formElementRemoved } from 'features/nodes/store/workflowSlice'; -import { type FormElement, isContainerElement } from 'features/nodes/types/workflow'; +import { type FormElement, isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow'; import { startCase } from 'lodash-es'; import { memo, useCallback } from 'react'; import { PiXBold } from 'react-icons/pi'; const getHeaderLabel = (el: FormElement) => { if (isContainerElement(el)) { - if (el.data.direction === 'column') { + if (el.data.layout === 'column') { return 'Column'; } return 'Row'; @@ -48,6 +49,7 @@ export const FormElementEditModeHeader = memo( {isContainerElement(element) && } + {isNodeFieldElement(element) && } {element.parentId && ( { const { data } = el; - const { fieldIdentifier, withLabel, withDescription } = data; + const { fieldIdentifier, showLabel, showDescription } = data; const label = useInputFieldLabel(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const description = useInputFieldDescription(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName); @@ -51,12 +51,16 @@ export const NodeFieldElementInputComponent = memo(({ el }: { el: NodeFieldEleme ); return ( - - {withLabel && _label && {_label}} - - + + {showLabel && _label && {_label}} + + - {withDescription && _description && {_description}} + {showDescription && _description && {_description}} ); }); @@ -66,7 +70,7 @@ export const NodeFieldElementComponentViewMode = memo(({ el }: { el: NodeFieldEl const { id } = el; return ( - + ); @@ -79,7 +83,7 @@ export const NodeFieldElementComponentEditMode = memo(({ el }: { el: NodeFieldEl return ( - + diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings.tsx new file mode 100644 index 0000000000..c697297b79 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings.tsx @@ -0,0 +1,35 @@ +import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice'; +import type { NodeFieldIntegerConfig } from 'features/nodes/types/workflow'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const NodeFieldElementIntegerConfig = memo(({ id, config }: { id: string; config: NodeFieldIntegerConfig }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const onChangeComponent = useCallback( + (e: ChangeEvent) => { + const newConfig: NodeFieldIntegerConfig = { + ...config, + component: e.target.value as NodeFieldIntegerConfig['component'], + }; + dispatch(formElementNodeFieldDataChanged({ id, changes: { config: newConfig } })); + }, + [config, dispatch, id] + ); + + return ( + + {t('workflows.builder.component')} + + + ); +}); +NodeFieldElementIntegerConfig.displayName = 'NodeFieldElementIntegerConfig'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx index 63fa217692..354e8af04f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx @@ -1,16 +1,57 @@ -import { IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger } from '@invoke-ai/ui-library'; +import { + FormControl, + FormLabel, + IconButton, + Popover, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, + Switch, +} from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { NodeFieldElementIntegerConfig } from 'features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings'; +import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice'; import type { NodeFieldElement } from 'features/nodes/types/workflow'; -import { memo } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { PiWrenchFill } from 'react-icons/pi'; export const NodeFieldElementSettings = memo(({ element }: { element: NodeFieldElement }) => { + const { id, data } = element; + const { showLabel, showDescription } = data; + + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const toggleShowLabel = useCallback(() => { + dispatch(formElementNodeFieldDataChanged({ id, changes: { showLabel: !showLabel } })); + }, [dispatch, id, showLabel]); + + const toggleShowDescription = useCallback(() => { + dispatch(formElementNodeFieldDataChanged({ id, changes: { showDescription: !showDescription } })); + }, [dispatch, id, showDescription]); + return ( } variant="link" size="sm" alignSelf="stretch" /> - settings + + + + {t('workflows.builder.label')} + + + + {t('workflows.builder.description')} + + + {data.config?.configType === 'integer-field-config' && ( + + )} + ); 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 e574f92a16..e07eb0fdfb 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 @@ -12,6 +12,7 @@ import { import { formModeToggled, selectRootElementId, selectWorkflowFormMode } from 'features/nodes/store/workflowSlice'; import type { FormElement } from 'features/nodes/types/workflow'; import { buildContainer, buildDivider, buildHeading, buildText } from 'features/nodes/types/workflow'; +import { startCase } from 'lodash-es'; import type { RefObject } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { assert } from 'tsafe'; @@ -27,7 +28,8 @@ export const WorkflowBuilder = memo(() => { - + + @@ -53,7 +55,10 @@ const ToggleModeButton = memo(() => { }); ToggleModeButton.displayName = 'ToggleModeButton'; -const useAddFormElementDnd = (type: Omit, draggableRef: RefObject) => { +const useAddFormElementDnd = ( + type: Exclude | 'row' | 'column', + draggableRef: RefObject +) => { const [isDragging, setIsDragging] = useState(false); useEffect(() => { @@ -66,10 +71,14 @@ const useAddFormElementDnd = (type: Omit, dra draggable({ element: draggableElement, getInitialData: () => { - if (type === 'container') { + if (type === 'row') { const element = buildContainer('row', []); return buildAddFormElementDndData(element); } + if (type === 'column') { + const element = buildContainer('column', []); + return buildAddFormElementDndData(element); + } if (type === 'divider') { const element = buildDivider(); return buildAddFormElementDndData(element); @@ -97,13 +106,13 @@ const useAddFormElementDnd = (type: Omit, dra return isDragging; }; -const AddFormElementDndButton = ({ type }: { type: Omit }) => { +const AddFormElementDndButton = ({ type }: { type: Parameters[0] }) => { const draggableRef = useRef(null); const isDragging = useAddFormElementDnd(type, draggableRef); return ( ); }; 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 index 2e888dc7cd..b93192b31b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/contexts.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/contexts.tsx @@ -4,17 +4,15 @@ import { createContext, memo, useContext, useMemo } from 'react'; type ContainerContextValue = { id: ElementId; - direction: ContainerElement['data']['direction']; + layout: ContainerElement['data']['layout']; }; const ContainerContext = createContext(null); -export const ContainerContextProvider = memo( - ({ id, direction, children }: PropsWithChildren) => { - const ctxValue = useMemo(() => ({ id, direction }), [id, direction]); - return {children}; - } -); +export const ContainerContextProvider = memo(({ id, layout, children }: PropsWithChildren) => { + const ctxValue = useMemo(() => ({ id, layout }), [id, layout]); + return {children}; +}); ContainerContextProvider.displayName = 'ContainerContextProvider'; export const useContainerContext = () => { 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 925761720e..eb63b349df 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-builder-dnd.ts +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-builder-dnd.ts @@ -15,7 +15,7 @@ import { } from 'features/nodes/components/sidePanel/builder/center-or-closest-edge'; import { getEditModeWrapperId } from 'features/nodes/components/sidePanel/builder/shared'; import { formElementAdded, formElementMoved } from 'features/nodes/store/workflowSlice'; -import type { FieldIdentifier } from 'features/nodes/types/field'; +import type { FieldIdentifier, FieldType } from 'features/nodes/types/field'; import type { ElementId, FormElement } from 'features/nodes/types/workflow'; import { buildNodeField, isContainerElement } from 'features/nodes/types/workflow'; import type { RefObject } from 'react'; @@ -53,10 +53,12 @@ const uniqueNodeFieldKey = Symbol('node-field'); type NodeFieldDndData = { [uniqueNodeFieldKey]: true; fieldIdentifier: FieldIdentifier; + fieldType: FieldType; }; -export const buildNodeFieldDndData = (fieldIdentifier: FieldIdentifier): NodeFieldDndData => ({ +export const buildNodeFieldDndData = (fieldIdentifier: FieldIdentifier, fieldType: FieldType): NodeFieldDndData => ({ [uniqueNodeFieldKey]: true, fieldIdentifier, + fieldType, }); const isNodeFieldDndData = (data: Record): data is NodeFieldDndData => { @@ -116,6 +118,10 @@ export const useMonitorForFormElementDnd = () => { } else if (closestCenterOrEdge) { // Move the element to the target's parent container at the correct index const { parentId } = targetData.element; + if (parentId === sourceData.element.id) { + // Cannot move an element into itself + return; + } assert(parentId !== undefined, 'Target element should have a parent'); const isReparenting = parentId !== sourceData.element.parentId; @@ -202,11 +208,12 @@ export const useMonitorForFormElementDnd = () => { const handleNodeFieldDrop = useCallback( (sourceData: NodeFieldDndData, targetData: MoveFormElementDndData) => { const closestCenterOrEdge = extractClosestCenterOrEdge(targetData); - const { nodeId, fieldName } = sourceData.fieldIdentifier; + const { fieldIdentifier, fieldType } = sourceData; + const { nodeId, fieldName } = fieldIdentifier; if (closestCenterOrEdge === 'center') { // Move the element to the target container - should we double-check that the target is a container? - const element = buildNodeField(nodeId, fieldName, targetData.element.id); + const element = buildNodeField(nodeId, fieldName, fieldType, targetData.element.id); flushSync(() => { dispatch(formElementAdded({ element, containerId: targetData.element.id })); }); @@ -215,7 +222,7 @@ export const useMonitorForFormElementDnd = () => { // Move the element to the target's parent container at the correct index const { parentId } = targetData.element; assert(parentId !== undefined, 'Target element should have a parent'); - const element = buildNodeField(nodeId, fieldName, parentId); + const element = buildNodeField(nodeId, fieldName, fieldType, parentId); const parentContainer = getElement(parentId, isContainerElement); const targetIndex = parentContainer.data.children.findIndex((elementId) => elementId === targetData.element.id); @@ -308,12 +315,16 @@ export const useDraggableFormElement = ( dropTargetForElements({ element: draggableElement, canDrop: ({ source }) => - isMoveFormElementDndData(source.data) || - isNodeFieldDndData(source.data) || - isAddFormElementDndData(source.data), + (isMoveFormElementDndData(source.data) && source.data.element.id !== getElement(elementId).parentId) || + isAddFormElementDndData(source.data) || + isNodeFieldDndData(source.data), getData: ({ input }) => { const element = getElement(elementId); - const container = element.parentId ? getElement(element.parentId, isContainerElement) : null; + const container = element.parentId + ? getElement(element.parentId, isContainerElement) + : isContainerElement(element) + ? element + : null; const data = buildMoveFormElementDndData(element); @@ -323,11 +334,11 @@ export const useDraggableFormElement = ( allowedCenterOrEdge.push('center'); } - if (container?.data.direction === 'row') { + if (element.parentId !== undefined && container?.data.layout === 'row') { allowedCenterOrEdge.push('left', 'right'); } - if (container?.data.direction === 'column') { + if (element.parentId !== undefined && container?.data.layout === 'column') { allowedCenterOrEdge.push('top', 'bottom'); } diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.ts index 9698b2c09b..23e71b7f9d 100644 --- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts +++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts @@ -1,6 +1,7 @@ import { getPrefixedId } from 'features/controlLayers/konva/util'; import { z } from 'zod'; +import type { FieldType } from './field'; import { zFieldIdentifier } from './field'; import { zInvocationNodeData, zNotesNodeData } from './invocation'; @@ -97,14 +98,18 @@ const NODE_FIELD_TYPE = 'node-field'; export const NODE_FIELD_CLASS_NAME = getPrefixedId(NODE_FIELD_TYPE, '-'); const FLOAT_FIELD_CONFIG_TYPE = 'float-field-config'; const zFloatFieldConfig = z.object({ - configType: z.literal(FLOAT_FIELD_CONFIG_TYPE), - component: z.enum(['input', 'slider', 'input-and-slider']), + configType: z.literal(FLOAT_FIELD_CONFIG_TYPE).default(FLOAT_FIELD_CONFIG_TYPE), + component: z.enum(['input', 'slider', 'input-and-slider']).default('input'), }); +export type NodeFieldFloatConfig = z.infer; + const INTEGER_FIELD_CONFIG_TYPE = 'integer-field-config'; const zIntegerFieldConfig = z.object({ - configType: z.literal(INTEGER_FIELD_CONFIG_TYPE), - component: z.enum(['input', 'slider', 'input-and-slider']), + configType: z.literal(INTEGER_FIELD_CONFIG_TYPE).default(INTEGER_FIELD_CONFIG_TYPE), + component: z.enum(['input', 'slider', 'input-and-slider']).default('input'), }); +export type NodeFieldIntegerConfig = z.infer; + const STRING_FIELD_CONFIG_TYPE = 'string-field-config'; const zStringFieldConfig = z.object({ configType: z.literal(STRING_FIELD_CONFIG_TYPE), @@ -112,8 +117,8 @@ const zStringFieldConfig = z.object({ }); const zNodeFieldData = z.object({ fieldIdentifier: zFieldIdentifier, - withLabel: z.boolean().default(true), - withDescription: z.boolean().default(true), + showLabel: z.boolean().default(true), + showDescription: z.boolean().default(true), config: z.union([zFloatFieldConfig, zIntegerFieldConfig, zStringFieldConfig]).optional(), }); const zNodeFieldElement = zElementBase.extend({ @@ -125,13 +130,27 @@ export const isNodeFieldElement = (el: FormElement): el is NodeFieldElement => e export const buildNodeField = ( nodeId: NodeFieldElement['data']['fieldIdentifier']['nodeId'], fieldName: NodeFieldElement['data']['fieldIdentifier']['fieldName'], + fieldType: FieldType, parentId?: NodeFieldElement['parentId'] ): NodeFieldElement => { + let config: NodeFieldElement['data']['config'] = undefined; + + if (fieldType.name === 'IntegerField') { + config = { + configType: 'integer-field-config', + component: 'input', + }; + } const element: NodeFieldElement = { id: getPrefixedId(NODE_FIELD_TYPE, '-'), type: NODE_FIELD_TYPE, parentId, - data: zNodeFieldData.parse({ fieldIdentifier: { nodeId, fieldName } }), + data: { + fieldIdentifier: { nodeId, fieldName }, + config, + showLabel: true, + showDescription: true, + }, }; return element; }; @@ -199,14 +218,14 @@ export const CONTAINER_CLASS_NAME = getPrefixedId(CONTAINER_TYPE, '-'); const zContainerElement = zElementBase.extend({ type: z.literal(CONTAINER_TYPE), data: z.object({ - direction: z.enum(['row', 'column']), + layout: 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 buildContainer = ( - direction: ContainerElement['data']['direction'], + layout: ContainerElement['data']['layout'], children: ContainerElement['data']['children'], parentId?: NodeFieldElement['parentId'] ): ContainerElement => { @@ -215,7 +234,7 @@ export const buildContainer = ( parentId, type: CONTAINER_TYPE, data: { - direction, + layout, children, }, };