feat(ui): builder field settings (WIP)

This commit is contained in:
psychedelicious
2025-02-05 19:51:32 +11:00
parent 4daa82c912
commit 09879f4e19
18 changed files with 290 additions and 92 deletions

View File

@@ -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",

View File

@@ -17,6 +17,7 @@ export const FloatFieldInput = memo((props: FieldComponentProps<FloatFieldInputI
step={step}
fineStep={fineStep}
className="nodrag"
flex="1 1 0"
/>
);
});

View File

@@ -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;
};

View File

@@ -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)) {

View File

@@ -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) {

View File

@@ -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';

View File

@@ -18,9 +18,9 @@ export const IntegerFieldSlider = memo(
step={step}
fineStep={fineStep}
className="nodrag"
w="full"
marks
withThumbTooltip
flex="1 1 0"
/>
);
}

View File

@@ -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} />
))}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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"

View File

@@ -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>

View File

@@ -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';

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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 = () => {

View File

@@ -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');
}

View File

@@ -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,
},
};