Skip to content

Commit d65ec0e

Browse files
feat(ui): configurable form field constraints (WIP3)
1 parent 7fdde5e commit d65ec0e

File tree

5 files changed

+112
-42
lines changed

5 files changed

+112
-42
lines changed

invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings.tsx

+35-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CompositeNumberInput, Flex, FormControl, FormLabel, Select, Switch } from '@invoke-ai/ui-library';
22
import { useAppDispatch } from 'app/store/storeHooks';
3+
import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple';
34
import { useFloatField } from 'features/nodes/components/flow/nodes/Invocation/fields/FloatField/useFloatField';
45
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
56
import { fieldFloatValueChanged } from 'features/nodes/store/nodesSlice';
@@ -8,7 +9,7 @@ import type { FloatFieldInputInstance, FloatFieldInputTemplate } from 'features/
89
import { type NodeFieldFloatSettings, zNumberComponent } from 'features/nodes/types/workflow';
910
import { constrainNumber } from 'features/nodes/util/constrainNumber';
1011
import type { ChangeEvent } from 'react';
11-
import { memo, useCallback } from 'react';
12+
import { memo, useCallback, useMemo } from 'react';
1213
import { useTranslation } from 'react-i18next';
1314

1415
type Props = {
@@ -71,25 +72,35 @@ const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
7172
min: config.min !== undefined ? undefined : floatField.min,
7273
};
7374
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
74-
}, [config, dispatch, floatField.min, id]);
75+
}, [config, dispatch, floatField, id]);
7576

7677
const onChange = useCallback(
77-
(v: number) => {
78+
(min: number) => {
7879
const newConfig: NodeFieldFloatSettings = {
7980
...config,
80-
min: v,
81+
min,
8182
};
8283
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
8384

8485
// We may need to update the value if it is outside the new min/max range
8586
const constrained = constrainNumber(field.value, floatField, newConfig);
8687
if (field.value !== constrained) {
87-
dispatch(fieldFloatValueChanged({ nodeId, fieldName, value: v }));
88+
dispatch(fieldFloatValueChanged({ nodeId, fieldName, value: constrained }));
8889
}
8990
},
9091
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
9192
);
9293

94+
const constraintMin = useMemo(
95+
() => roundUpToMultiple(floatField.min, floatField.step),
96+
[floatField.min, floatField.step]
97+
);
98+
99+
const constraintMax = useMemo(
100+
() => (config.max ?? floatField.max) - floatField.step,
101+
[config.max, floatField.max, floatField.step]
102+
);
103+
93104
return (
94105
<FormControl orientation="vertical">
95106
<Flex justifyContent="space-between" w="full" alignItems="center">
@@ -101,8 +112,9 @@ const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
101112
isDisabled={config.min === undefined}
102113
value={config.min === undefined ? (`${floatField.min} (inherited)` as unknown as number) : config.min}
103114
onChange={onChange}
104-
min={floatField.min}
105-
max={(config.max ?? floatField.max) - 0.1}
115+
min={constraintMin}
116+
max={constraintMax}
117+
step={floatField.step}
106118
/>
107119
</FormControl>
108120
);
@@ -122,13 +134,13 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
122134
max: config.max !== undefined ? undefined : floatField.max,
123135
};
124136
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
125-
}, [config, dispatch, floatField.max, id]);
137+
}, [config, dispatch, floatField, id]);
126138

127139
const onChange = useCallback(
128-
(v: number) => {
140+
(max: number) => {
129141
const newConfig: NodeFieldFloatSettings = {
130142
...config,
131-
max: v,
143+
max,
132144
};
133145
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
134146

@@ -141,6 +153,16 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
141153
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
142154
);
143155

156+
const constraintMin = useMemo(
157+
() => (config.min ?? floatField.min) + floatField.step,
158+
[config.min, floatField.min, floatField.step]
159+
);
160+
161+
const constraintMax = useMemo(
162+
() => roundDownToMultiple(floatField.max, floatField.step),
163+
[floatField.max, floatField.step]
164+
);
165+
144166
return (
145167
<FormControl orientation="vertical">
146168
<Flex justifyContent="space-between" w="full" alignItems="center">
@@ -152,8 +174,9 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
152174
isDisabled={config.max === undefined}
153175
value={config.max === undefined ? (`${floatField.max} (inherited)` as unknown as number) : config.max}
154176
onChange={onChange}
155-
min={(config.min ?? floatField.min) + 0.1}
156-
max={floatField.max}
177+
min={constraintMin}
178+
max={constraintMax}
179+
step={floatField.step}
157180
/>
158181
</FormControl>
159182
);

invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings.tsx

+45-22
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CompositeNumberInput, Flex, FormControl, FormLabel, Select, Switch } from '@invoke-ai/ui-library';
22
import { useAppDispatch } from 'app/store/storeHooks';
3+
import { roundDownToMultiple, roundUpToMultiple } from 'common/util/roundDownToMultiple';
34
import { useIntegerField } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/useIntegerField';
45
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
56
import { fieldIntegerValueChanged } from 'features/nodes/store/nodesSlice';
@@ -9,7 +10,7 @@ import type { NodeFieldIntegerSettings } from 'features/nodes/types/workflow';
910
import { zNumberComponent } from 'features/nodes/types/workflow';
1011
import { constrainNumber } from 'features/nodes/util/constrainNumber';
1112
import type { ChangeEvent } from 'react';
12-
import { memo, useCallback } from 'react';
13+
import { memo, useCallback, useMemo } from 'react';
1314
import { useTranslation } from 'react-i18next';
1415

1516
type Props = {
@@ -64,32 +65,42 @@ const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
6465
const dispatch = useAppDispatch();
6566
const field = useInputFieldInstance<IntegerFieldInputInstance>(nodeId, fieldName);
6667

67-
const floatField = useIntegerField(nodeId, fieldName, fieldTemplate);
68+
const integerField = useIntegerField(nodeId, fieldName, fieldTemplate);
6869

6970
const onToggleSetting = useCallback(() => {
7071
const newConfig: NodeFieldIntegerSettings = {
7172
...config,
72-
min: config.min !== undefined ? undefined : floatField.min,
73+
min: config.min !== undefined ? undefined : integerField.min,
7374
};
75+
7476
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
75-
}, [config, dispatch, floatField.min, id]);
77+
}, [config, dispatch, integerField.min, id]);
7678

7779
const onChange = useCallback(
78-
(v: number) => {
80+
(min: number) => {
7981
const newConfig: NodeFieldIntegerSettings = {
8082
...config,
81-
min: v,
83+
min,
8284
};
83-
8485
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
8586

8687
// We may need to update the value if it is outside the new min/max range
87-
const constrained = constrainNumber(field.value, { ...floatField }, newConfig);
88+
const constrained = constrainNumber(field.value, integerField, newConfig);
8889
if (field.value !== constrained) {
8990
dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value: constrained }));
9091
}
9192
},
92-
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
93+
[config, dispatch, id, field, integerField, nodeId, fieldName]
94+
);
95+
96+
const constraintMin = useMemo(
97+
() => roundUpToMultiple(integerField.min, integerField.step),
98+
[integerField.min, integerField.step]
99+
);
100+
101+
const constraintMax = useMemo(
102+
() => (config.max ?? integerField.max) - integerField.step,
103+
[config.max, integerField.max, integerField.step]
93104
);
94105

95106
return (
@@ -101,10 +112,11 @@ const SettingMin = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
101112
<CompositeNumberInput
102113
w="full"
103114
isDisabled={config.min === undefined}
104-
value={config.min === undefined ? (`${floatField.min} (inherited)` as unknown as number) : config.min}
115+
value={config.min ?? (`${integerField.min} (inherited)` as unknown as number)}
105116
onChange={onChange}
106-
min={floatField.min}
107-
max={(config.max ?? floatField.max) - 1}
117+
min={constraintMin}
118+
max={constraintMax}
119+
step={integerField.step}
108120
/>
109121
</FormControl>
110122
);
@@ -116,32 +128,42 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
116128
const dispatch = useAppDispatch();
117129
const field = useInputFieldInstance<IntegerFieldInputInstance>(nodeId, fieldName);
118130

119-
const floatField = useIntegerField(nodeId, fieldName, fieldTemplate);
131+
const integerField = useIntegerField(nodeId, fieldName, fieldTemplate);
120132

121133
const onToggleSetting = useCallback(() => {
122134
const newConfig: NodeFieldIntegerSettings = {
123135
...config,
124-
max: config.max !== undefined ? undefined : floatField.max,
136+
max: config.max !== undefined ? undefined : integerField.max,
125137
};
126138
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
127-
}, [config, dispatch, floatField.max, id]);
139+
}, [config, dispatch, integerField.max, id]);
128140

129141
const onChange = useCallback(
130-
(v: number) => {
142+
(max: number) => {
131143
const newConfig: NodeFieldIntegerSettings = {
132144
...config,
133-
max: v,
145+
max,
134146
};
135147

136148
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
137149

138150
// We may need to update the value if it is outside the new min/max range
139-
const constrained = constrainNumber(field.value, floatField, newConfig);
151+
const constrained = constrainNumber(field.value, integerField, newConfig);
140152
if (field.value !== constrained) {
141153
dispatch(fieldIntegerValueChanged({ nodeId, fieldName, value: constrained }));
142154
}
143155
},
144-
[config, dispatch, field.value, fieldName, floatField, id, nodeId]
156+
[config, dispatch, field.value, fieldName, integerField, id, nodeId]
157+
);
158+
159+
const constraintMin = useMemo(
160+
() => (config.min ?? integerField.min) + integerField.step,
161+
[config.min, integerField.min, integerField.step]
162+
);
163+
164+
const constraintMax = useMemo(
165+
() => roundDownToMultiple(integerField.max, integerField.step),
166+
[integerField.max, integerField.step]
145167
);
146168

147169
return (
@@ -153,10 +175,11 @@ const SettingMax = memo(({ id, config, nodeId, fieldName, fieldTemplate }: Props
153175
<CompositeNumberInput
154176
w="full"
155177
isDisabled={config.max === undefined}
156-
value={config.max === undefined ? (`${floatField.max} (inherited)` as unknown as number) : config.max}
178+
value={config.max ?? (`${integerField.max} (inherited)` as unknown as number)}
157179
onChange={onChange}
158-
min={(config.min ?? floatField.min) + 1}
159-
max={floatField.max}
180+
min={constraintMin}
181+
max={constraintMax}
182+
step={integerField.step}
160183
/>
161184
</FormControl>
162185
);

invokeai/frontend/web/src/features/nodes/types/workflow.ts

-2
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ const zNodeFieldFloatSettings = z.object({
7777
component: zNumberComponent.default('number-input'),
7878
min: z.number().optional(),
7979
max: z.number().optional(),
80-
step: z.number().optional(),
8180
});
8281
export const getFloatFieldSettingsDefaults = (): NodeFieldFloatSettings => zNodeFieldFloatSettings.parse({});
8382
export type NodeFieldFloatSettings = z.infer<typeof zNodeFieldFloatSettings>;
@@ -88,7 +87,6 @@ const zNodeFieldIntegerSettings = z.object({
8887
component: zNumberComponent.default('number-input'),
8988
min: z.number().optional(),
9089
max: z.number().optional(),
91-
step: z.number().optional(),
9290
});
9391
export type NodeFieldIntegerSettings = z.infer<typeof zNodeFieldIntegerSettings>;
9492
export const getIntegerFieldSettingsDefaults = (): NodeFieldIntegerSettings => zNodeFieldIntegerSettings.parse({});

invokeai/frontend/web/src/features/nodes/util/constrainNumber.test.ts

+22-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@ describe('constrainNumber', () => {
2929
expect(constrainNumber(11, constraints)).toEqual(10);
3030
});
3131

32+
it('should always prefer to round to a multiple rather than the nearest value within the min and max', () => {
33+
const constraints = { min: 0, max: 10, step: 3 };
34+
expect(constrainNumber(1, constraints)).toEqual(0);
35+
expect(constrainNumber(2, constraints)).toEqual(3);
36+
expect(constrainNumber(3, constraints)).toEqual(3);
37+
expect(constrainNumber(4, constraints)).toEqual(3);
38+
expect(constrainNumber(7, constraints)).toEqual(6);
39+
expect(constrainNumber(8, constraints)).toEqual(9);
40+
expect(constrainNumber(9, constraints)).toEqual(9);
41+
42+
expect(constrainNumber(12, { min: 7, max: 12, step: 5 })).toEqual(10);
43+
expect(constrainNumber(13, { min: 7, max: 12, step: 5 })).toEqual(10);
44+
expect(constrainNumber(14, { min: 7, max: 12, step: 5 })).toEqual(10);
45+
46+
expect(constrainNumber(3, { min: 7, max: 12, step: 5 })).toEqual(10);
47+
expect(constrainNumber(4, { min: 7, max: 12, step: 5 })).toEqual(10);
48+
expect(constrainNumber(5, { min: 7, max: 12, step: 5 })).toEqual(10);
49+
50+
expect(constrainNumber(42, { min: 43, max: 81, step: 8 })).toEqual(48);
51+
});
52+
3253
it('should handle negative multiples', () => {
3354
const constraints = { min: -10, max: 10, step: 3 };
3455
expect(constrainNumber(-9, constraints)).toEqual(-9);
@@ -52,7 +73,7 @@ describe('constrainNumber', () => {
5273
// Value at 9 would normally round to 8
5374
expect(constrainNumber(9, constraints)).toEqual(8);
5475
// Value at 11 would normally round to 12, but max is 10
55-
expect(constrainNumber(11, constraints)).toEqual(10);
76+
expect(constrainNumber(11, constraints)).toEqual(8);
5677
});
5778

5879
it('should handle decimal multiples', () => {

invokeai/frontend/web/src/features/nodes/util/constrainNumber.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { PartialDeep } from 'type-fest';
22

3-
type NumberConstraints = { min: number; max: number; step?: number };
3+
type NumberConstraints = { min: number; max: number; step: number };
44

55
/**
66
* Constrain a number to a range and round to the nearest multiple of a given value.
@@ -22,12 +22,17 @@ export const constrainNumber = (
2222
return Math.min(Math.max(v, min), max);
2323
}
2424

25-
// First clamp to range
26-
v = Math.min(Math.max(v, min), max);
27-
2825
// Round to nearest multiple of multipleOf
29-
const roundedValue = Math.round(v / multipleOf) * multipleOf;
26+
let roundedValue = Math.round(v / multipleOf) * multipleOf;
27+
28+
// If the value is out of range, find the nearest valid multiple within range
29+
if (roundedValue < min) {
30+
roundedValue = Math.ceil(min / multipleOf) * multipleOf;
31+
} else if (roundedValue > max) {
32+
roundedValue = Math.floor(max / multipleOf) * multipleOf;
33+
}
3034

3135
// Ensure the result is still within the range
36+
// This handles cases where min or max aren't multiples of step
3237
return Math.min(Math.max(roundedValue, min), max);
3338
};

0 commit comments

Comments
 (0)