feat(ui): editable node form field labels & descriptions

This commit is contained in:
psychedelicious
2025-02-06 11:45:46 +11:00
parent 74c76611a9
commit 2c0b474f55
6 changed files with 111 additions and 36 deletions

View File

@@ -21,7 +21,8 @@ export const useEditable = ({ value, defaultValue, onChange: _onChange, onStartE
_onChange(newValue);
}
setIsEditing(false);
}, [localValue, defaultValue, value, _onChange]);
inputRef.current?.setSelectionRange(0, 0);
}, [localValue, defaultValue, value, inputRef, _onChange]);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setLocalValue(e.target.value);

View File

@@ -75,7 +75,7 @@ export const InputFieldTitle = memo((props: Props) => {
);
}
return <Input ref={inputRef} variant="unstyled" {...editable.inputProps} />;
return <Input ref={inputRef} variant="outline" {...editable.inputProps} />;
});
InputFieldTitle.displayName = 'InputFieldTitle';

View File

@@ -1,15 +1,17 @@
import { Flex, FormControl, FormHelperText, FormLabel } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { Flex, FormControl, FormHelperText, FormLabel, Input } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useEditable } from 'common/hooks/useEditable';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
import { useInputFieldDescription } from 'features/nodes/hooks/useInputFieldDescription';
import { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { fieldDescriptionChanged, fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { isNodeFieldElement, NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
export const NodeFieldElementComponent = memo(({ id }: { id: string }) => {
const el = useElement(id);
@@ -37,8 +39,8 @@ export const NodeFieldElementComponent = memo(({ id }: { id: string }) => {
NodeFieldElementComponent.displayName = 'NodeFieldElementComponent';
export const NodeFieldElementInputComponent = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
export const NodeFieldElementComponentViewMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id, data } = el;
const { fieldIdentifier, showLabel, showDescription } = data;
const label = useInputFieldLabel(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldDescription(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
@@ -50,28 +52,19 @@ export const NodeFieldElementInputComponent = memo(({ el }: { el: NodeFieldEleme
[description, fieldTemplate.description]
);
return (
<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>
{showDescription && _description && <FormHelperText>{_description}</FormHelperText>}
</FormControl>
);
});
NodeFieldElementInputComponent.displayName = 'NodeFieldElementInputComponent';
export const NodeFieldElementComponentViewMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id } = el;
return (
<Flex id={id} className={NODE_FIELD_CLASS_NAME} flex="1 1 0">
<NodeFieldElementInputComponent el={el} />
<FormControl flex="1 1 0" orientation="vertical">
{showLabel && <FormLabel>{_label}</FormLabel>}
<Flex w="full" gap={4}>
<InputFieldRenderer
nodeId={fieldIdentifier.nodeId}
fieldName={fieldIdentifier.fieldName}
config={data.config}
/>
</Flex>
{showDescription && _description && <FormHelperText>{_description}</FormHelperText>}
</FormControl>
</Flex>
);
});
@@ -79,15 +72,96 @@ export const NodeFieldElementComponentViewMode = memo(({ el }: { el: NodeFieldEl
NodeFieldElementComponentViewMode.displayName = 'NodeFieldElementComponentViewMode';
export const NodeFieldElementComponentEditMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id } = el;
const { id, data } = el;
const { fieldIdentifier, showLabel, showDescription } = data;
return (
<FormElementEditModeWrapper element={el}>
<Flex id={id} className={NODE_FIELD_CLASS_NAME} flex="1 1 0">
<NodeFieldElementInputComponent el={el} />
<FormControl flex="1 1 0" orientation="vertical">
{showLabel && <NodeFieldEditableLabel el={el} />}
<Flex w="full" gap={4}>
<InputFieldRenderer
nodeId={fieldIdentifier.nodeId}
fieldName={fieldIdentifier.fieldName}
config={data.config}
/>
</Flex>
{showDescription && <NodeFieldEditableDescription el={el} />}
</FormControl>
</Flex>
</FormElementEditModeWrapper>
);
});
NodeFieldElementComponentEditMode.displayName = 'NodeFieldElementComponentEditMode';
const NodeFieldEditableLabel = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
const label = useInputFieldLabel(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(
(label: string) => {
dispatch(fieldLabelChanged({ nodeId: fieldIdentifier.nodeId, fieldName: fieldIdentifier.fieldName, label }));
},
[dispatch, fieldIdentifier.fieldName, fieldIdentifier.nodeId]
);
const editable = useEditable({
value: label || fieldTemplate.title,
defaultValue: fieldTemplate.title,
inputRef,
onChange,
});
if (!editable.isEditing) {
return (
<FormLabel onDoubleClick={editable.startEditing} cursor="text">
{editable.value}
</FormLabel>
);
}
return <Input ref={inputRef} variant="outline" {...editable.inputProps} />;
});
NodeFieldEditableLabel.displayName = 'NodeFieldEditableLabel';
const NodeFieldEditableDescription = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
const description = useInputFieldDescription(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(
(description: string) => {
dispatch(
fieldDescriptionChanged({
nodeId: fieldIdentifier.nodeId,
fieldName: fieldIdentifier.fieldName,
val: description,
})
);
},
[dispatch, fieldIdentifier.fieldName, fieldIdentifier.nodeId]
);
const editable = useEditable({
value: description || fieldTemplate.description,
defaultValue: fieldTemplate.description,
inputRef,
onChange,
});
if (!editable.isEditing) {
return <FormHelperText onDoubleClick={editable.startEditing}>{editable.value}</FormHelperText>;
}
return <Input ref={inputRef} variant="outline" {...editable.inputProps} />;
});
NodeFieldEditableDescription.displayName = 'NodeFieldEditableDescription';

View File

@@ -10,9 +10,9 @@ export const useInputFieldDescription = (nodeId: string, fieldName: string) => {
createSelector(selectNodesSlice, (nodes) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return;
return '';
}
return node?.data.inputs[fieldName]?.description;
return node?.data.inputs[fieldName]?.description ?? '';
}),
[fieldName, nodeId]
);

View File

@@ -3,11 +3,11 @@ import { useAppSelector } from 'app/store/storeHooks';
import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useInputFieldLabel = (nodeId: string, fieldName: string): string | null => {
export const useInputFieldLabel = (nodeId: string, fieldName: string): string => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
return selectFieldInputInstance(nodes, nodeId, fieldName)?.label ?? null;
return selectFieldInputInstance(nodes, nodeId, fieldName)?.label ?? '';
}),
[fieldName, nodeId]
);

View File

@@ -36,13 +36,13 @@ const zFieldInput = z.enum(['connection', 'direct', 'any']);
const zFieldUIComponent = z.enum(['none', 'textarea', 'slider']);
const zFieldInputInstanceBase = z.object({
name: z.string().trim().min(1),
label: z.string().nullish(),
description: z.string().nullish(),
label: z.string().catch(''),
description: z.string().catch(''),
});
const zFieldTemplateBase = z.object({
name: z.string().min(1),
title: z.string().min(1),
description: z.string().nullish(),
description: z.string().catch(''),
ui_hidden: z.boolean(),
ui_type: z.string().nullish(),
ui_order: z.number().int().nullish(),