feat(ui): configurable form field constraints (WIP3)

This commit is contained in:
psychedelicious
2025-03-14 15:28:46 +10:00
parent 7fdde5e84a
commit d65ec0e250
5 changed files with 112 additions and 42 deletions

View File

@@ -1,5 +1,6 @@
import { CompositeNumberInput, Flex, FormControl, FormLabel, Select, Switch } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple';
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { fieldFloatValueChanged } from 'features/nodes/store/nodesSlice';
@@ -8,7 +9,7 @@ import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/
import { type NodeFieldFloatSettings, zNumberComponent } from 'features/nodes/types/workflow';
import { constrainNumber } from 'features/nodes/util/constrainNumber';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
@@ -71,25 +72,35 @@ const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
min: config.min !== undefined ? undefined : floatField.min,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
}, [config, dispatch, floatField.min, id]);
}, [config, dispatch, floatField, id]);
const onChange = useCallback(
(v: number) => {
(min: number) => {
const newConfig: NodeFieldFloatSettings = {
...config,
min: v,
min,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
// We may need to update the value if it is outside the new min/max range
const constrained = constrainNumber(field.value, floatField, newConfig);
if (field.value !== constrained) {
dispatch(fieldFloatValueChanged({ nodeId, fieldName, value: v }));
dispatch(fieldFloatValueChanged({ nodeId, fieldName, value: constrained }));
}
},
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
);
const constraintMin = useMemo(
() => roundUpToMultiple(floatField.min, floatField.step),
[floatField.min, floatField.step]
);
const constraintMax = useMemo(
() => (config.max ?? floatField.max) - floatField.step,
[config.max, floatField.max, floatField.step]
);
return (
<FormControl orientation="vertical">
<Flex justifyContent="space-between" w="full" alignItems="center">
@@ -101,8 +112,9 @@ const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
isDisabled={config.min === undefined}
value={config.min === undefined ? (`${floatField.min} (inherited)` as unknown as number) : config.min}
onChange={onChange}
min={floatField.min}
max={(config.max ?? floatField.max) - 0.1}
min={constraintMin}
max={constraintMax}
step={floatField.step}
/>
</FormControl>
);
@@ -122,13 +134,13 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
max: config.max !== undefined ? undefined : floatField.max,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
}, [config, dispatch, floatField.max, id]);
}, [config, dispatch, floatField, id]);
const onChange = useCallback(
(v: number) => {
(max: number) => {
const newConfig: NodeFieldFloatSettings = {
...config,
max: v,
max,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
@@ -141,6 +153,16 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
);
const constraintMin = useMemo(
() => (config.min ?? floatField.min) + floatField.step,
[config.min, floatField.min, floatField.step]
);
const constraintMax = useMemo(
() => roundDownToMultiple(floatField.max, floatField.step),
[floatField.max, floatField.step]
);
return (
<FormControl orientation="vertical">
<Flex justifyContent="space-between" w="full" alignItems="center">
@@ -152,8 +174,9 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
isDisabled={config.max === undefined}
value={config.max === undefined ? (`${floatField.max} (inherited)` as unknown as number) : config.max}
onChange={onChange}
min={(config.min ?? floatField.min) + 0.1}
max={floatField.max}
min={constraintMin}
max={constraintMax}
step={floatField.step}
/>
</FormControl>
);

View File

@@ -1,5 +1,6 @@
import { CompositeNumberInput, Flex, FormControl, FormLabel, Select, Switch } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple';
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { fieldIntegerValueChanged } from 'features/nodes/store/nodesSlice';
@@ -9,7 +10,7 @@ import type { NodeFieldIntegerSettings } from 'features/nodes/types/workflow';
import { zNumberComponent } from 'features/nodes/types/workflow';
import { constrainNumber } from 'features/nodes/util/constrainNumber';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
@@ -64,32 +65,42 @@ const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
const dispatch = useAppDispatch();
const field = useInputFieldInstance<IntegerFieldInputInstance>(nodeId, fieldName);
const floatField = useIntegerField(nodeId, fieldName, fieldTemplate);
const integerField = useIntegerField(nodeId, fieldName, fieldTemplate);
const onToggleSetting = useCallback(() => {
const newConfig: NodeFieldIntegerSettings = {
...config,
min: config.min !== undefined ? undefined : floatField.min,
min: config.min !== undefined ? undefined : integerField.min,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
}, [config, dispatch, floatField.min, id]);
}, [config, dispatch, integerField.min, id]);
const onChange = useCallback(
(v: number) => {
(min: number) => {
const newConfig: NodeFieldIntegerSettings = {
...config,
min: v,
min,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
// We may need to update the value if it is outside the new min/max range
const constrained = constrainNumber(field.value, { ...floatField }, newConfig);
const constrained = constrainNumber(field.value, integerField, newConfig);
if (field.value !== constrained) {
dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value: constrained }));
}
},
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
[config, dispatch, id, field, integerField, nodeId, fieldName]
);
const constraintMin = useMemo(
() => roundUpToMultiple(integerField.min, integerField.step),
[integerField.min, integerField.step]
);
const constraintMax = useMemo(
() => (config.max ?? integerField.max) - integerField.step,
[config.max, integerField.max, integerField.step]
);
return (
@@ -101,10 +112,11 @@ const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
<CompositeNumberInput
w="full"
isDisabled={config.min === undefined}
value={config.min === undefined ? (`${floatField.min} (inherited)` as unknown as number) : config.min}
value={config.min ?? (`${integerField.min} (inherited)` as unknown as number)}
onChange={onChange}
min={floatField.min}
max={(config.max ?? floatField.max) - 1}
min={constraintMin}
max={constraintMax}
step={integerField.step}
/>
</FormControl>
);
@@ -116,32 +128,42 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
const dispatch = useAppDispatch();
const field = useInputFieldInstance<IntegerFieldInputInstance>(nodeId, fieldName);
const floatField = useIntegerField(nodeId, fieldName, fieldTemplate);
const integerField = useIntegerField(nodeId, fieldName, fieldTemplate);
const onToggleSetting = useCallback(() => {
const newConfig: NodeFieldIntegerSettings = {
...config,
max: config.max !== undefined ? undefined : floatField.max,
max: config.max !== undefined ? undefined : integerField.max,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
}, [config, dispatch, floatField.max, id]);
}, [config, dispatch, integerField.max, id]);
const onChange = useCallback(
(v: number) => {
(max: number) => {
const newConfig: NodeFieldIntegerSettings = {
...config,
max: v,
max,
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
// We may need to update the value if it is outside the new min/max range
const constrained = constrainNumber(field.value, floatField, newConfig);
const constrained = constrainNumber(field.value, integerField, newConfig);
if (field.value !== constrained) {
dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value: constrained }));
}
},
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
[config, dispatch, field.value, fieldName, integerField, id, nodeId]
);
const constraintMin = useMemo(
() => (config.min ?? integerField.min) + integerField.step,
[config.min, integerField.min, integerField.step]
);
const constraintMax = useMemo(
() => roundDownToMultiple(integerField.max, integerField.step),
[integerField.max, integerField.step]
);
return (
@@ -153,10 +175,11 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
<CompositeNumberInput
w="full"
isDisabled={config.max === undefined}
value={config.max === undefined ? (`${floatField.max} (inherited)` as unknown as number) : config.max}
value={config.max ?? (`${integerField.max} (inherited)` as unknown as number)}
onChange={onChange}
min={(config.min ?? floatField.min) + 1}
max={floatField.max}
min={constraintMin}
max={constraintMax}
step={integerField.step}
/>
</FormControl>
);

View File

@@ -77,7 +77,6 @@ const zNodeFieldFloatSettings = z.object({
component: zNumberComponent.default('number-input'),
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional(),
});
export const getFloatFieldSettingsDefaults = (): NodeFieldFloatSettings => zNodeFieldFloatSettings.parse({});
export type NodeFieldFloatSettings = z.infer<typeof zNodeFieldFloatSettings>;
@@ -88,7 +87,6 @@ const zNodeFieldIntegerSettings = z.object({
component: zNumberComponent.default('number-input'),
min: z.number().optional(),
max: z.number().optional(),
step: z.number().optional(),
});
export type NodeFieldIntegerSettings = z.infer<typeof zNodeFieldIntegerSettings>;
export const getIntegerFieldSettingsDefaults = (): NodeFieldIntegerSettings => zNodeFieldIntegerSettings.parse({});

View File

@@ -29,6 +29,27 @@ describe('constrainNumber', () => {
expect(constrainNumber(11, constraints)).toEqual(10);
});
it('should always prefer to round to a multiple rather than the nearest value within the min and max', () => {
const constraints = { min: 0, max: 10, step: 3 };
expect(constrainNumber(1, constraints)).toEqual(0);
expect(constrainNumber(2, constraints)).toEqual(3);
expect(constrainNumber(3, constraints)).toEqual(3);
expect(constrainNumber(4, constraints)).toEqual(3);
expect(constrainNumber(7, constraints)).toEqual(6);
expect(constrainNumber(8, constraints)).toEqual(9);
expect(constrainNumber(9, constraints)).toEqual(9);
expect(constrainNumber(12, { min: 7, max: 12, step: 5 })).toEqual(10);
expect(constrainNumber(13, { min: 7, max: 12, step: 5 })).toEqual(10);
expect(constrainNumber(14, { min: 7, max: 12, step: 5 })).toEqual(10);
expect(constrainNumber(3, { min: 7, max: 12, step: 5 })).toEqual(10);
expect(constrainNumber(4, { min: 7, max: 12, step: 5 })).toEqual(10);
expect(constrainNumber(5, { min: 7, max: 12, step: 5 })).toEqual(10);
expect(constrainNumber(42, { min: 43, max: 81, step: 8 })).toEqual(48);
});
it('should handle negative multiples', () => {
const constraints = { min: -10, max: 10, step: 3 };
expect(constrainNumber(-9, constraints)).toEqual(-9);
@@ -52,7 +73,7 @@ describe('constrainNumber', () => {
// Value at 9 would normally round to 8
expect(constrainNumber(9, constraints)).toEqual(8);
// Value at 11 would normally round to 12, but max is 10
expect(constrainNumber(11, constraints)).toEqual(10);
expect(constrainNumber(11, constraints)).toEqual(8);
});
it('should handle decimal multiples', () => {

View File

@@ -1,6 +1,6 @@
import type { PartialDeep } from 'type-fest';
type NumberConstraints = { min: number; max: number; step?: number };
type NumberConstraints = { min: number; max: number; step: number };
/**
* Constrain a number to a range and round to the nearest multiple of a given value.
@@ -22,12 +22,17 @@ export const constrainNumber = (
return Math.min(Math.max(v, min), max);
}
// First clamp to range
v = Math.min(Math.max(v, min), max);
// Round to nearest multiple of multipleOf
const roundedValue = Math.round(v / multipleOf) * multipleOf;
let roundedValue = Math.round(v / multipleOf) * multipleOf;
// If the value is out of range, find the nearest valid multiple within range
if (roundedValue < min) {
roundedValue = Math.ceil(min / multipleOf) * multipleOf;
} else if (roundedValue > max) {
roundedValue = Math.floor(max / multipleOf) * multipleOf;
}
// Ensure the result is still within the range
// This handles cases where min or max aren't multiples of step
return Math.min(Math.max(roundedValue, min), max);
};