Files
GDevelop/newIDE/app/src/PropertiesEditor/index.js
2025-01-08 12:16:12 +01:00

616 lines
19 KiB
JavaScript

// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import SemiControlledTextField from '../UI/SemiControlledTextField';
import InlineCheckbox from '../UI/InlineCheckbox';
import ResourceSelectorWithThumbnail from '../ResourcesList/ResourceSelectorWithThumbnail';
import Subheader from '../UI/Subheader';
import SelectField from '../UI/SelectField';
import SelectOption from '../UI/SelectOption';
import ColorField from '../UI/ColorField';
import { MarkdownText } from '../UI/MarkdownText';
import { rgbOrHexToRGBString } from '../Utils/ColorTransformer';
import FormHelperText from '@material-ui/core/FormHelperText';
import InputAdornment from '@material-ui/core/InputAdornment';
import { type ResourceManagementProps } from '../ResourcesList/ResourceSource';
import {
TextFieldWithButtonLayout,
ResponsiveLineStackLayout,
ColumnStackLayout,
} from '../UI/Layout';
import RaisedButton from '../UI/RaisedButton';
import UnsavedChangesContext, {
type UnsavedChanges,
} from '../MainFrame/UnsavedChangesContext';
import { Column, Line } from '../UI/Grid';
import Text from '../UI/Text';
import useForceUpdate from '../Utils/UseForceUpdate';
import RaisedButtonWithSplitMenu from '../UI/RaisedButtonWithSplitMenu';
import Tooltip from '@material-ui/core/Tooltip';
import Edit from '../UI/CustomSvgIcons/Edit';
import {
type Schema,
type ValueField,
type ActionButton,
type SectionTitle,
type ResourceField,
type LeaderboardIdField,
} from '../CompactPropertiesEditor';
import LeaderboardIdPropertyField from './LeaderboardIdPropertyField';
// Re-export the types.
export type {
Schema,
ValueField,
ActionButton,
SectionTitle,
ResourceField,
Field,
} from '../CompactPropertiesEditor';
// An "instance" here is the objects for which properties are shown
export type Instance = Object; // This could be improved using generics.
export type Instances = Array<Instance>;
type Props = {|
onInstancesModified?: Instances => void,
instances: Instances,
schema: Schema,
mode?: 'column' | 'row',
// If set, render the "extra" description content from fields
// (see getExtraDescription).
renderExtraDescriptionText?: (extraDescription: string) => string,
unsavedChanges?: ?UnsavedChanges,
// Optional context:
project?: ?gdProject,
resourceManagementProps?: ?ResourceManagementProps,
|};
const styles = {
columnContainer: {
display: 'flex',
flexDirection: 'column',
},
fieldContainer: {
flex: 1,
display: 'flex',
flexDirection: 'row',
alignItems: 'baseline',
},
field: {
flex: 1,
width: 'auto',
},
subHeader: {
paddingLeft: 0,
},
};
const getDisabled = ({
instances,
field,
}: {|
instances: Instances,
field: ValueField,
|}): boolean => {
return typeof field.disabled === 'boolean'
? field.disabled
: typeof field.disabled === 'function'
? field.disabled(instances)
: false;
};
/**
* Get the value for the given field across all instances.
* If one of the instances doesn't share the same value, returns the default value.
* If there is no instances, returns the default value.
* If the field does not have a `getValue` method, returns `null`.
*/
const getFieldValue = ({
instances,
field,
defaultValue,
}: {|
instances: Instances,
field: ValueField | ActionButton | SectionTitle,
defaultValue?: any,
|}): any => {
if (!instances[0]) {
console.log(
'getFieldValue was called with an empty list of instances (or containing undefined). This is a bug that should be fixed'
);
return defaultValue;
}
const { getValue } = field;
if (!getValue) return null;
let value = getValue(instances[0]);
for (var i = 1; i < instances.length; ++i) {
if (value !== getValue(instances[i])) {
if (typeof defaultValue !== 'undefined') value = defaultValue;
break;
}
}
return value;
};
const getFieldLabel = ({
instances,
field,
}: {|
instances: Instances,
field: ValueField,
|}): any => {
if (!instances[0]) {
console.log(
'PropertiesEditor._getFieldLabel was called with an empty list of instances (or containing undefined). This is a bug that should be fixed'
);
return field.name;
}
if (field.getLabel) return field.getLabel(instances[0]);
return field.name;
};
const PropertiesEditor = ({
onInstancesModified,
instances,
schema,
mode,
renderExtraDescriptionText,
unsavedChanges,
project,
resourceManagementProps,
}: Props) => {
const forceUpdate = useForceUpdate();
const _onInstancesModified = React.useCallback(
(instances: Instances) => {
// This properties editor is dealing with fields that are
// responsible to update their state (see field.setValue).
if (unsavedChanges) unsavedChanges.triggerUnsavedChanges();
if (onInstancesModified) onInstancesModified(instances);
forceUpdate();
},
[unsavedChanges, onInstancesModified, forceUpdate]
);
const getFieldDescription = React.useCallback(
(field: ValueField): ?string => {
if (!instances[0]) {
console.log(
'PropertiesEditor._getFieldDescription was called with an empty list of instances (or containing undefined). This is a bug that should be fixed'
);
return undefined;
}
const descriptions: Array<string> = [];
if (field.getDescription)
descriptions.push(field.getDescription(instances[0]));
if (renderExtraDescriptionText && field.getExtraDescription)
descriptions.push(
renderExtraDescriptionText(field.getExtraDescription(instances[0]))
);
return descriptions.join('\n') || undefined;
},
[instances, renderExtraDescriptionText]
);
const renderInputField = React.useCallback(
(field: ValueField) => {
if (field.name === 'PLEASE_ALSO_SHOW_EDIT_BUTTON_THANKS') return null; // This special property was used in GDevelop 4 IDE to ask for a Edit button to be shown, ignore it.
if (field.valueType === 'boolean') {
const { setValue } = field;
const description = getFieldDescription(field);
return (
<InlineCheckbox
label={
!description ? (
getFieldLabel({ instances, field })
) : (
<React.Fragment>
<Line noMargin>{getFieldLabel({ instances, field })}</Line>
<FormHelperText style={{ display: 'inline' }}>
<MarkdownText source={description} />
</FormHelperText>
</React.Fragment>
)
}
key={field.name}
id={field.name}
checked={getFieldValue({ instances, field })}
onCheck={(event, newValue) => {
instances.forEach(i => setValue(i, !!newValue));
_onInstancesModified(instances);
}}
disabled={getDisabled({ instances, field })}
/>
);
} else if (field.valueType === 'number') {
const { setValue, getEndAdornment } = field;
const endAdornment = getEndAdornment && getEndAdornment(instances[0]);
return (
<SemiControlledTextField
value={getFieldValue({ instances, field })}
key={field.name}
id={field.name}
floatingLabelText={getFieldLabel({ instances, field })}
floatingLabelFixed
helperMarkdownText={getFieldDescription(field)}
onChange={newValue => {
const newNumberValue = parseFloat(newValue);
// If the value is not a number, the user is probably still typing, adding a dot or a comma.
// So don't update the value, it will be reverted if they leave the field.
if (isNaN(newNumberValue)) return;
instances.forEach(i => setValue(i, newNumberValue));
_onInstancesModified(instances);
}}
type="number"
style={styles.field}
disabled={getDisabled({ instances, field })}
endAdornment={
endAdornment && (
<Tooltip title={endAdornment.tooltipContent}>
<InputAdornment position="end">
{endAdornment.label}
</InputAdornment>
</Tooltip>
)
}
/>
);
} else if (field.valueType === 'color') {
const { setValue } = field;
return (
<Column key={field.name} expand noMargin>
<ColorField
id={field.name}
floatingLabelText={getFieldLabel({ instances, field })}
helperMarkdownText={getFieldDescription(field)}
disableAlpha
fullWidth
color={getFieldValue({ instances, field })}
onChange={color => {
const rgbString =
color.length === 0 ? '' : rgbOrHexToRGBString(color);
instances.forEach(i => setValue(i, rgbString));
_onInstancesModified(instances);
}}
/>
</Column>
);
} else if (field.valueType === 'textarea') {
const { setValue } = field;
return (
<SemiControlledTextField
key={field.name}
id={field.name}
onChange={text => {
instances.forEach(i => setValue(i, text || ''));
_onInstancesModified(instances);
}}
value={getFieldValue({ instances, field })}
floatingLabelText={getFieldLabel({ instances, field })}
floatingLabelFixed
helperMarkdownText={getFieldDescription(field)}
multiline
style={styles.field}
/>
);
}
else {
const {
onEditButtonBuildMenuTemplate,
onEditButtonClick,
setValue,
} = field;
return (
<TextFieldWithButtonLayout
key={field.name}
renderTextField={() => (
<SemiControlledTextField
value={getFieldValue({
instances,
field,
defaultValue: '(Multiple values)',
})}
id={field.name}
floatingLabelText={getFieldLabel({ instances, field })}
floatingLabelFixed
helperMarkdownText={getFieldDescription(field)}
onChange={newValue => {
instances.forEach(i => setValue(i, newValue || ''));
_onInstancesModified(instances);
}}
style={styles.field}
disabled={getDisabled({ instances, field })}
/>
)}
renderButton={style =>
onEditButtonClick && !onEditButtonBuildMenuTemplate ? (
<RaisedButton
style={style}
primary
disabled={instances.length !== 1}
icon={<Edit />}
label={<Trans>Edit</Trans>}
onClick={onEditButtonClick}
/>
) : onEditButtonBuildMenuTemplate ? (
<RaisedButtonWithSplitMenu
style={style}
primary
disabled={instances.length !== 1}
icon={<Edit />}
label={<Trans>Edit</Trans>}
onClick={onEditButtonClick}
buildMenuTemplate={onEditButtonBuildMenuTemplate}
/>
) : null
}
/>
);
}
},
[instances, getFieldDescription, _onInstancesModified]
);
const renderSelectField = React.useCallback(
(field: ValueField) => {
console.log(field);
if (!field.getChoices || !field.getValue) return;
const choices = field.getChoices();
if (choices.length < 2 && field.isHiddenWhenOnlyOneChoice) {
return;
}
const children = choices.map(({ value, label, labelIsUserDefined }) => (
<SelectOption
key={value}
value={value}
label={label}
shouldNotTranslate={labelIsUserDefined}
/>
));
if (field.valueType === 'number') {
const { setValue } = field;
return (
<SelectField
value={getFieldValue({ instances, field })}
key={field.name}
id={field.name}
floatingLabelText={getFieldLabel({ instances, field })}
helperMarkdownText={getFieldDescription(field)}
onChange={(event, index, newValue: string) => {
instances.forEach(i => setValue(i, parseFloat(newValue) || 0));
_onInstancesModified(instances);
}}
style={styles.field}
disabled={field.disabled}
>
{children}
</SelectField>
);
} else if (field.valueType === 'string') {
const { setValue } = field;
console.log("field value : ", getFieldValue({
instances,
field,
defaultValue: '(Multiple values)',
}));
return (
<SelectField
value={getFieldValue({
instances,
field,
defaultValue: '(Multiple values)',
})}
key={field.name}
id={field.name}
floatingLabelText={getFieldLabel({ instances, field })}
helperMarkdownText={getFieldDescription(field)}
onChange={(event, index, newValue: string) => {
instances.forEach(i => setValue(i, newValue || ''));
_onInstancesModified(instances);
}}
style={styles.field}
disabled={getDisabled({ instances, field })}
>
{children}
</SelectField>
);
}
},
[instances, _onInstancesModified, getFieldDescription]
);
const renderButton = React.useCallback(
(field: ActionButton) => {
let disabled = false;
if (field.disabled === 'onValuesDifferent') {
const DIFFERENT_VALUES = 'DIFFERENT_VALUES';
disabled =
getFieldValue({
instances,
field,
defaultValue: DIFFERENT_VALUES,
}) === DIFFERENT_VALUES;
}
return (
<RaisedButton
key={`button-${field.label}`}
fullWidth
primary
icon={<Edit />}
disabled={disabled}
label={field.label}
onClick={() => {
field.onClick(instances[0]);
}}
/>
);
},
[instances]
);
const renderResourceField = (field: ResourceField) => {
if (!project || !resourceManagementProps) {
console.error(
'You tried to display a resource field in a PropertiesEditor that does not support display resources. If you need to display resources, pass additional props (project, resourceManagementProps).'
);
return null;
}
const { setValue } = field;
return (
<ResourceSelectorWithThumbnail
key={field.name}
project={project}
resourceManagementProps={resourceManagementProps}
resourceKind={field.resourceKind}
resourceName={getFieldValue({
instances,
field,
defaultValue: '(Multiple values)', //TODO
})}
onChange={newValue => {
instances.forEach(i => setValue(i, newValue));
_onInstancesModified(instances);
}}
floatingLabelText={getFieldLabel({ instances, field })}
helperMarkdownText={getFieldDescription(field)}
/>
);
};
const renderLeaderboardField = (field: LeaderboardIdField) => {
if (!project) {
return null;
}
const { setValue } = field;
return (
<LeaderboardIdPropertyField
key={field.name}
project={project}
value={getFieldValue({
instances,
field,
defaultValue: '(Multiple values)', //TODO
})}
onChange={newValue => {
instances.forEach(i => setValue(i, newValue));
_onInstancesModified(instances);
}}
floatingLabelText={getFieldLabel({ instances, field })}
helperMarkdownText={getFieldDescription(field)}
fieldStyle={styles.field}
/>
);
};
const renderContainer =
mode === 'row'
? (fields: React.Node) => (
<ResponsiveLineStackLayout
noMargin
noResponsiveLandscape={Array.isArray(fields) && fields.length <= 3}
>
{fields}
</ResponsiveLineStackLayout>
)
: (fields: React.Node) => (
<ColumnStackLayout noMargin>{fields}</ColumnStackLayout>
);
const renderSectionTitle = React.useCallback((field: SectionTitle) => {
return (
<Line key={`section-title-${field.name}`}>
<Text displayInlineAsSpan>{field.name}</Text>
</Line>
);
}, []);
return renderContainer(
schema.map(field => {
if (!!field.nonFieldType) {
if (field.nonFieldType === 'sectionTitle') {
return renderSectionTitle(field);
} else if (field.nonFieldType === 'button') {
return renderButton(field);
}
return null;
} else if (field.children) {
if (field.type === 'row') {
const contentView = (
<UnsavedChangesContext.Consumer key={field.name}>
{unsavedChanges => (
<PropertiesEditor
project={project}
resourceManagementProps={resourceManagementProps}
schema={field.children}
instances={instances}
mode="row"
unsavedChanges={unsavedChanges}
onInstancesModified={onInstancesModified}
/>
)}
</UnsavedChangesContext.Consumer>
);
if (field.title) {
return [
<Text key={field.name + '-title'} size="block-title">
{field.title}
</Text>,
contentView,
];
}
return contentView;
}
return (
<div key={field.name}>
<Subheader>{field.name}</Subheader>
<UnsavedChangesContext.Consumer key={field.name}>
{unsavedChanges => (
<PropertiesEditor
project={project}
resourceManagementProps={resourceManagementProps}
schema={field.children}
instances={instances}
mode="column"
unsavedChanges={unsavedChanges}
onInstancesModified={onInstancesModified}
/>
)}
</UnsavedChangesContext.Consumer>
</div>
);
} else if (field.valueType === 'resource') {
return renderResourceField(field);
} else if (field.valueType === 'leaderboardId') {
return renderLeaderboardField(field);
} else {
console.log(field);
if (field.getChoices && field.getValue) return renderSelectField(field);
if (field.getValue) return renderInputField(field);
}
return null;
})
);
};
export default PropertiesEditor;