mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): support for custom string field dropdowns in builder
This commit is contained in:
@@ -13,6 +13,7 @@ import { StringGeneratorFieldInputComponent } from 'features/nodes/components/fl
|
||||
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 { StringFieldDropdown } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldDropdown';
|
||||
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';
|
||||
@@ -151,7 +152,7 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
|
||||
if (!isStringFieldInputInstance(field)) {
|
||||
return null;
|
||||
}
|
||||
if (settings?.type !== 'string-field-config') {
|
||||
if (!settings || settings.type !== 'string-field-config') {
|
||||
if (template.ui_component === 'textarea') {
|
||||
return <StringFieldTextarea nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else {
|
||||
@@ -162,8 +163,10 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
|
||||
return <StringFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else if (settings.component === 'textarea') {
|
||||
return <StringFieldTextarea nodeId={nodeId} field={field} fieldTemplate={template} />;
|
||||
} else if (settings.component === 'dropdown') {
|
||||
return <StringFieldDropdown nodeId={nodeId} field={field} fieldTemplate={template} settings={settings} />;
|
||||
} else {
|
||||
assert<Equals<never, typeof settings.component>>(false, 'Unexpected settings.component');
|
||||
assert<Equals<never, typeof settings>>(false, 'Unexpected settings');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Select } from '@invoke-ai/ui-library';
|
||||
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
|
||||
import { useStringField } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/useStringField';
|
||||
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
|
||||
import type { StringFieldInputInstance, StringFieldInputTemplate } from 'features/nodes/types/field';
|
||||
import type { NodeFieldStringSettings } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const StringFieldDropdown = memo(
|
||||
(
|
||||
props: FieldComponentProps<
|
||||
StringFieldInputInstance,
|
||||
StringFieldInputTemplate,
|
||||
{ settings: Extract<NodeFieldStringSettings, { component: 'dropdown' }> }
|
||||
>
|
||||
) => {
|
||||
const { value, onChange } = useStringField(props);
|
||||
|
||||
return (
|
||||
<Select
|
||||
onChange={onChange}
|
||||
className={`${NO_DRAG_CLASS} ${NO_PAN_CLASS} ${NO_WHEEL_CLASS}`}
|
||||
isDisabled={props.settings.options.length === 0}
|
||||
value={value}
|
||||
>
|
||||
{props.settings.options.map((choice, i) => (
|
||||
<option key={`${i}_${choice.value}`} value={choice.value}>
|
||||
{choice.label || choice.value || `Option ${i + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
StringFieldDropdown.displayName = 'StringFieldDropdown';
|
||||
@@ -10,7 +10,7 @@ export const useStringField = (props: FieldComponentProps<StringFieldInputInstan
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
dispatch(
|
||||
fieldStringValueChanged({
|
||||
nodeId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Box, Flex, FormControl } from '@invoke-ai/ui-library';
|
||||
import { Box, Divider, Flex, FormControl } from '@invoke-ai/ui-library';
|
||||
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
|
||||
import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
|
||||
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
@@ -9,6 +9,7 @@ import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/
|
||||
import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader';
|
||||
import { NodeFieldElementDescriptionEditable } from 'features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable';
|
||||
import { NodeFieldElementLabelEditable } from 'features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable';
|
||||
import { NodeFieldElementStringDropdownConfig } from 'features/nodes/components/sidePanel/builder/NodeFieldElementStringDropdownConfig';
|
||||
import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
|
||||
import type { NodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
@@ -55,7 +56,7 @@ const NodeFieldElementEditModeContent = memo(
|
||||
dragHandleRef: RefObject<HTMLDivElement>;
|
||||
isDragging: boolean;
|
||||
}) => {
|
||||
const { data } = el;
|
||||
const { id, data } = el;
|
||||
const { fieldIdentifier, showDescription } = data;
|
||||
return (
|
||||
<>
|
||||
@@ -72,6 +73,12 @@ const NodeFieldElementEditModeContent = memo(
|
||||
/>
|
||||
</Flex>
|
||||
{showDescription && <NodeFieldElementDescriptionEditable el={el} />}
|
||||
{data.settings?.type === 'string-field-config' && data.settings.component === 'dropdown' && (
|
||||
<>
|
||||
<Divider />
|
||||
<NodeFieldElementStringDropdownConfig id={id} settings={data.settings} />
|
||||
</>
|
||||
)}
|
||||
</FormControl>
|
||||
</InputFieldGate>
|
||||
</FormElementEditModeContent>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Button, Divider, Flex, Grid, GridItem, IconButton, Input, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { getOverlayScrollbarsParams, overlayScrollbarsStyles } from 'common/components/OverlayScrollbars/constants';
|
||||
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
|
||||
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
|
||||
import type { NodeFieldStringDropdownSettings } from 'features/nodes/types/workflow';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
|
||||
const overlayscrollbarsOptions = getOverlayScrollbarsParams({}).options;
|
||||
|
||||
export const NodeFieldElementStringDropdownConfig = memo(
|
||||
({ id, settings }: { id: string; settings: NodeFieldStringDropdownSettings }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onRemoveOption = useCallback(
|
||||
(index: number) => {
|
||||
const options = [...settings.options];
|
||||
options.splice(index, 1);
|
||||
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: { ...settings, options } } }));
|
||||
},
|
||||
[settings, dispatch, id]
|
||||
);
|
||||
|
||||
const onChangeOptionValue = useCallback(
|
||||
(index: number, value: string) => {
|
||||
if (!settings.options[index]) {
|
||||
return;
|
||||
}
|
||||
const option = { ...settings.options[index], value };
|
||||
const options = [...settings.options];
|
||||
options[index] = option;
|
||||
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: { ...settings, options } } }));
|
||||
},
|
||||
[dispatch, id, settings]
|
||||
);
|
||||
|
||||
const onChangeOptionLabel = useCallback(
|
||||
(index: number, label: string) => {
|
||||
if (!settings.options[index]) {
|
||||
return;
|
||||
}
|
||||
const option = { ...settings.options[index], label };
|
||||
const options = [...settings.options];
|
||||
options[index] = option;
|
||||
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: { ...settings, options } } }));
|
||||
},
|
||||
[dispatch, id, settings]
|
||||
);
|
||||
|
||||
const onAddOption = useCallback(() => {
|
||||
const options = [...settings.options, { label: '', value: '' }];
|
||||
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: { ...settings, options } } }));
|
||||
}, [dispatch, id, settings]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className={NO_DRAG_CLASS}
|
||||
position="relative"
|
||||
w="full"
|
||||
h="auto"
|
||||
maxH={64}
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
borderRadius="base"
|
||||
flexDir="column"
|
||||
gap={1}
|
||||
>
|
||||
<Button onClick={onAddOption} variant="ghost">
|
||||
{t('nodes.addItem')}
|
||||
</Button>
|
||||
{settings.options.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<OverlayScrollbarsComponent
|
||||
className={NO_WHEEL_CLASS}
|
||||
defer
|
||||
style={overlayScrollbarsStyles}
|
||||
options={overlayscrollbarsOptions}
|
||||
>
|
||||
<Grid gap={1} gridTemplateColumns="auto 1fr 1fr auto" gridTemplateRows="auto 1fr" alignItems="center">
|
||||
<GridItem minW={8}>
|
||||
<Text textAlign="center" variant="subtext">
|
||||
#
|
||||
</Text>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Text textAlign="center" variant="subtext">
|
||||
{t('common.label')}
|
||||
</Text>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Text textAlign="center" variant="subtext">
|
||||
{t('common.value')}
|
||||
</Text>
|
||||
</GridItem>
|
||||
<GridItem />
|
||||
{settings.options.map(({ value, label }, index) => (
|
||||
<ListItemContent
|
||||
key={index}
|
||||
value={value}
|
||||
label={label}
|
||||
index={index}
|
||||
onRemoveOption={onRemoveOption}
|
||||
onChangeOptionValue={onChangeOptionValue}
|
||||
onChangeOptionLabel={onChangeOptionLabel}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</OverlayScrollbarsComponent>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NodeFieldElementStringDropdownConfig.displayName = 'NodeFieldElementStringDropdownConfig';
|
||||
|
||||
type ListItemContentProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
index: number;
|
||||
onRemoveOption: (index: number) => void;
|
||||
onChangeOptionValue: (index: number, value: string) => void;
|
||||
onChangeOptionLabel: (index: number, label: string) => void;
|
||||
};
|
||||
|
||||
const ListItemContent = memo(
|
||||
({ value, label, index, onRemoveOption, onChangeOptionValue, onChangeOptionLabel }: ListItemContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onClickRemove = useCallback(() => {
|
||||
onRemoveOption(index);
|
||||
}, [index, onRemoveOption]);
|
||||
|
||||
const onChangeValue = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChangeOptionValue(index, e.target.value);
|
||||
},
|
||||
[index, onChangeOptionValue]
|
||||
);
|
||||
|
||||
const onChangeLabel = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChangeOptionLabel(index, e.target.value);
|
||||
},
|
||||
[index, onChangeOptionLabel]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridItem>
|
||||
<Text variant="subtext" textAlign="center">
|
||||
{index + 1}.
|
||||
</Text>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Input size="sm" resize="none" placeholder="label" value={label} onChange={onChangeLabel} />
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Input size="sm" resize="none" placeholder="value" value={value} onChange={onChangeValue} />
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
size="sm"
|
||||
variant="link"
|
||||
minW={8}
|
||||
minH={8}
|
||||
onClick={onClickRemove}
|
||||
icon={<PiXBold />}
|
||||
aria-label={t('common.delete')}
|
||||
/>
|
||||
</GridItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
ListItemContent.displayName = 'ListItemContent';
|
||||
@@ -2,6 +2,7 @@ import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
|
||||
import { type NodeFieldStringSettings, zStringComponent } from 'features/nodes/types/workflow';
|
||||
import { omit } from 'lodash-es';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -13,10 +14,23 @@ export const NodeFieldElementStringSettings = memo(
|
||||
|
||||
const onChangeComponent = useCallback(
|
||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const newConfig: NodeFieldStringSettings = {
|
||||
...config,
|
||||
component: zStringComponent.parse(e.target.value),
|
||||
};
|
||||
const component = zStringComponent.parse(e.target.value);
|
||||
if (component === config.component) {
|
||||
// no change
|
||||
return;
|
||||
}
|
||||
if (component === 'dropdown') {
|
||||
// if the component is changing to dropdown, we need to set the choices
|
||||
const newConfig: NodeFieldStringSettings = {
|
||||
...config,
|
||||
component,
|
||||
options: [{ value: 'my_value', label: 'My Label' }],
|
||||
};
|
||||
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
|
||||
return;
|
||||
}
|
||||
// if the component is changing from dropdown, we need to remove the choices
|
||||
const newConfig: NodeFieldStringSettings = omit({ ...config, component }, 'choices');
|
||||
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
|
||||
},
|
||||
[config, dispatch, id]
|
||||
@@ -28,6 +42,7 @@ export const NodeFieldElementStringSettings = memo(
|
||||
<Select value={config.component} onChange={onChangeComponent} size="sm">
|
||||
<option value="input">{t('workflows.builder.singleLine')}</option>
|
||||
<option value="textarea">{t('workflows.builder.multiLine')}</option>
|
||||
<option value="dropdown">{t('workflows.builder.dropdown')}</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
@@ -91,14 +91,36 @@ const zNodeFieldIntegerSettings = z.object({
|
||||
export type NodeFieldIntegerSettings = z.infer<typeof zNodeFieldIntegerSettings>;
|
||||
export const getIntegerFieldSettingsDefaults = (): NodeFieldIntegerSettings => zNodeFieldIntegerSettings.parse({});
|
||||
|
||||
export const zStringComponent = z.enum(['input', 'textarea']);
|
||||
export const zStringOption = z
|
||||
.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
.default({ label: '', value: '' });
|
||||
export type StringChoice = z.infer<typeof zStringOption>;
|
||||
export const zStringComponent = z.enum(['input', 'textarea', 'dropdown']);
|
||||
const STRING_FIELD_CONFIG_TYPE = 'string-field-config';
|
||||
const zNodeFieldStringSettings = z.object({
|
||||
const zNodeFieldStringInputSettings = z.object({
|
||||
type: z.literal(STRING_FIELD_CONFIG_TYPE).default(STRING_FIELD_CONFIG_TYPE),
|
||||
component: zStringComponent.default('input'),
|
||||
component: z.literal('input').default('input'),
|
||||
});
|
||||
const zNodeFieldStringTextareaSettings = z.object({
|
||||
type: z.literal(STRING_FIELD_CONFIG_TYPE).default(STRING_FIELD_CONFIG_TYPE),
|
||||
component: z.literal('textarea').default('textarea'),
|
||||
});
|
||||
const zNodeFieldStringDropdownSettings = z.object({
|
||||
type: z.literal(STRING_FIELD_CONFIG_TYPE).default(STRING_FIELD_CONFIG_TYPE),
|
||||
component: z.literal('dropdown').default('dropdown'),
|
||||
options: z.array(zStringOption),
|
||||
});
|
||||
export type NodeFieldStringDropdownSettings = z.infer<typeof zNodeFieldStringDropdownSettings>;
|
||||
const zNodeFieldStringSettings = z.union([
|
||||
zNodeFieldStringInputSettings,
|
||||
zNodeFieldStringTextareaSettings,
|
||||
zNodeFieldStringDropdownSettings,
|
||||
]);
|
||||
export type NodeFieldStringSettings = z.infer<typeof zNodeFieldStringSettings>;
|
||||
export const getStringFieldSettingsDefaults = (): NodeFieldStringSettings => zNodeFieldStringSettings.parse({});
|
||||
export const getStringFieldSettingsDefaults = (): NodeFieldStringSettings => zNodeFieldStringInputSettings.parse({});
|
||||
|
||||
const zNodeFieldData = z.object({
|
||||
fieldIdentifier: zFieldIdentifier,
|
||||
|
||||
Reference in New Issue
Block a user