mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
feat(ui): configurable form field constraints (WIP3)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user