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 (
- {type}
+ {startCase(type)}
);
};
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,
},
};