mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-01 17:14:58 -05:00
feat(ui): builder field settings (WIP)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -17,6 +17,7 @@ export const FloatFieldInput = memo((props: FieldComponentProps<FloatFieldInputI
|
||||
step={step}
|
||||
fineStep={fineStep}
|
||||
className="nodrag"
|
||||
flex="1 1 0"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<HTMLElement>,
|
||||
dragHandleRef: RefObject<HTMLElement>
|
||||
) => {
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 <IntegerFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
if (config?.configType !== 'integer-field-config') {
|
||||
return <IntegerFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
if (config.component === 'input') {
|
||||
return <IntegerFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else if (config.component === 'slider') {
|
||||
return <IntegerFieldSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else if (config.component === 'input-and-slider') {
|
||||
return <IntegerFieldInputAndSlider nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFloatFieldInputTemplate(template)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<IntegerFieldInputInstance, IntegerFieldInputTemplate>) => {
|
||||
const { defaultValue, onChange, value, min, max, step, fineStep } = useIntegerField(props);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompositeNumberInput
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
fineStep={fineStep}
|
||||
className="nodrag"
|
||||
flex="1 1 0"
|
||||
/>
|
||||
<CompositeSlider
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
fineStep={fineStep}
|
||||
className="nodrag"
|
||||
marks
|
||||
withThumbTooltip
|
||||
flex="1 1 0"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
IntegerFieldInputAndSlider.displayName = 'IntegerFieldInputAndSlider';
|
||||
@@ -18,9 +18,9 @@ export const IntegerFieldSlider = memo(
|
||||
step={step}
|
||||
fineStep={fineStep}
|
||||
className="nodrag"
|
||||
w="full"
|
||||
marks
|
||||
withThumbTooltip
|
||||
flex="1 1 0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<DepthContextProvider depth={depth + 1}>
|
||||
<ContainerContextProvider id={id} direction={direction}>
|
||||
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-direction={direction}>
|
||||
<ContainerContextProvider id={id} layout={layout}>
|
||||
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-layout={layout}>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={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 (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<DepthContextProvider depth={depth + 1}>
|
||||
<ContainerContextProvider id={id} direction={direction}>
|
||||
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-direction={direction}>
|
||||
<ContainerContextProvider id={id} layout={layout}>
|
||||
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-layout={layout}>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<IconButton aria-label="settings" icon={<PiWrenchFill />} variant="link" size="sm" alignSelf="stretch" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<FormControl>
|
||||
<FormLabel m={0}>Direction</FormLabel>
|
||||
<FormLabel m={0}>{t('workflows.builder.layout')}</FormLabel>
|
||||
<ButtonGroup variant="outline" size="sm">
|
||||
<Button onClick={toggleDirection} colorScheme={element.data.direction === 'row' ? 'invokeBlue' : 'base'}>
|
||||
Row
|
||||
<Button onClick={setLayoutToRow} colorScheme={layout === 'row' ? 'invokeBlue' : 'base'}>
|
||||
{t('workflows.builder.row')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={toggleDirection}
|
||||
colorScheme={element.data.direction === 'column' ? 'invokeBlue' : 'base'}
|
||||
>
|
||||
Column
|
||||
<Button onClick={setLayoutToColumn} colorScheme={layout === 'column' ? 'invokeBlue' : 'base'}>
|
||||
{t('workflows.builder.column')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</FormControl>
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
</Text>
|
||||
<Spacer />
|
||||
{isContainerElement(element) && <ContainerElementSettings element={element} />}
|
||||
{isNodeFieldElement(element) && <NodeFieldElementSettings element={element} />}
|
||||
{element.parentId && (
|
||||
<IconButton
|
||||
aria-label="delete"
|
||||
|
||||
@@ -39,7 +39,7 @@ NodeFieldElementComponent.displayName = 'NodeFieldElementComponent';
|
||||
|
||||
export const NodeFieldElementInputComponent = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
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 (
|
||||
<FormControl flexGrow={1} orientation="vertical">
|
||||
{withLabel && _label && <FormLabel>{_label}</FormLabel>}
|
||||
<Flex w="full">
|
||||
<InputFieldRenderer nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} />
|
||||
<FormControl flex="1 1 0" orientation="vertical">
|
||||
{showLabel && _label && <FormLabel>{_label}</FormLabel>}
|
||||
<Flex w="full" gap={4}>
|
||||
<InputFieldRenderer
|
||||
nodeId={fieldIdentifier.nodeId}
|
||||
fieldName={fieldIdentifier.fieldName}
|
||||
config={data.config}
|
||||
/>
|
||||
</Flex>
|
||||
{withDescription && _description && <FormHelperText>{_description}</FormHelperText>}
|
||||
{showDescription && _description && <FormHelperText>{_description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
@@ -66,7 +70,7 @@ export const NodeFieldElementComponentViewMode = memo(({ el }: { el: NodeFieldEl
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<Flex id={id} className={NODE_FIELD_CLASS_NAME} flexGrow={1}>
|
||||
<Flex id={id} className={NODE_FIELD_CLASS_NAME} flex="1 1 0">
|
||||
<NodeFieldElementInputComponent el={el} />
|
||||
</Flex>
|
||||
);
|
||||
@@ -79,7 +83,7 @@ export const NodeFieldElementComponentEditMode = memo(({ el }: { el: NodeFieldEl
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<Flex id={id} className={NODE_FIELD_CLASS_NAME} flexGrow={1}>
|
||||
<Flex id={id} className={NODE_FIELD_CLASS_NAME} flex="1 1 0">
|
||||
<NodeFieldElementInputComponent el={el} />
|
||||
</Flex>
|
||||
</FormElementEditModeWrapper>
|
||||
|
||||
@@ -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<HTMLSelectElement>) => {
|
||||
const newConfig: NodeFieldIntegerConfig = {
|
||||
...config,
|
||||
component: e.target.value as NodeFieldIntegerConfig['component'],
|
||||
};
|
||||
dispatch(formElementNodeFieldDataChanged({ id, changes: { config: newConfig } }));
|
||||
},
|
||||
[config, dispatch, id]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<FormLabel flex={1}>{t('workflows.builder.component')}</FormLabel>
|
||||
<Select value={config.component} onChange={onChangeComponent} size='sm'>
|
||||
<option value="input">{t('workflows.builder.input')}</option>
|
||||
<option value="slider">{t('workflows.builder.slider')}</option>
|
||||
<option value="input-and-slider">{t('workflows.builder.both')}</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
NodeFieldElementIntegerConfig.displayName = 'NodeFieldElementIntegerConfig';
|
||||
@@ -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 (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<IconButton aria-label="settings" icon={<PiWrenchFill />} variant="link" size="sm" alignSelf="stretch" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverBody>settings</PopoverBody>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
<FormControl>
|
||||
<FormLabel flex={1}>{t('workflows.builder.label')}</FormLabel>
|
||||
<Switch size="sm" isChecked={showLabel} onChange={toggleShowLabel} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel flex={1}>{t('workflows.builder.description')}</FormLabel>
|
||||
<Switch size="sm" isChecked={showDescription} onChange={toggleShowDescription} />
|
||||
</FormControl>
|
||||
{data.config?.configType === 'integer-field-config' && (
|
||||
<NodeFieldElementIntegerConfig id={id} config={data.config} />
|
||||
)}
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
<Flex flexDir="column" w={mode === 'view' ? '512px' : 'min-content'} minW="512px" gap={2}>
|
||||
<ButtonGroup isAttached={false} justifyContent="center">
|
||||
<ToggleModeButton />
|
||||
<AddFormElementDndButton type="container" />
|
||||
<AddFormElementDndButton type="row" />
|
||||
<AddFormElementDndButton type="column" />
|
||||
<AddFormElementDndButton type="divider" />
|
||||
<AddFormElementDndButton type="heading" />
|
||||
<AddFormElementDndButton type="text" />
|
||||
@@ -53,7 +55,10 @@ const ToggleModeButton = memo(() => {
|
||||
});
|
||||
ToggleModeButton.displayName = 'ToggleModeButton';
|
||||
|
||||
const useAddFormElementDnd = (type: Omit<FormElement['type'], 'node-field'>, draggableRef: RefObject<HTMLElement>) => {
|
||||
const useAddFormElementDnd = (
|
||||
type: Exclude<FormElement['type'], 'node-field' | 'container'> | 'row' | 'column',
|
||||
draggableRef: RefObject<HTMLElement>
|
||||
) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,10 +71,14 @@ const useAddFormElementDnd = (type: Omit<FormElement['type'], 'node-field'>, 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<FormElement['type'], 'node-field'>, dra
|
||||
return isDragging;
|
||||
};
|
||||
|
||||
const AddFormElementDndButton = ({ type }: { type: Omit<FormElement['type'], 'node-field'> }) => {
|
||||
const AddFormElementDndButton = ({ type }: { type: Parameters<typeof useAddFormElementDnd>[0] }) => {
|
||||
const draggableRef = useRef<HTMLButtonElement>(null);
|
||||
const isDragging = useAddFormElementDnd(type, draggableRef);
|
||||
|
||||
return (
|
||||
<Button ref={draggableRef} variant="ghost" pointerEvents="auto" opacity={isDragging ? 0.3 : 1}>
|
||||
{type}
|
||||
{startCase(type)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<ContainerContextValue | null>(null);
|
||||
|
||||
export const ContainerContextProvider = memo(
|
||||
({ id, direction, children }: PropsWithChildren<ContainerContextValue>) => {
|
||||
const ctxValue = useMemo(() => ({ id, direction }), [id, direction]);
|
||||
return <ContainerContext.Provider value={ctxValue}>{children}</ContainerContext.Provider>;
|
||||
}
|
||||
);
|
||||
export const ContainerContextProvider = memo(({ id, layout, children }: PropsWithChildren<ContainerContextValue>) => {
|
||||
const ctxValue = useMemo(() => ({ id, layout }), [id, layout]);
|
||||
return <ContainerContext.Provider value={ctxValue}>{children}</ContainerContext.Provider>;
|
||||
});
|
||||
ContainerContextProvider.displayName = 'ContainerContextProvider';
|
||||
|
||||
export const useContainerContext = () => {
|
||||
|
||||
@@ -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<string | symbol, unknown>): 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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof zFloatFieldConfig>;
|
||||
|
||||
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<typeof zIntegerFieldConfig>;
|
||||
|
||||
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<typeof zContainerElement>;
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user