diff --git a/newIDE/app/public/res/functions/boolean_black.svg b/newIDE/app/public/res/functions/boolean_black.svg new file mode 100644 index 000000000000..49644782bce9 --- /dev/null +++ b/newIDE/app/public/res/functions/boolean_black.svg @@ -0,0 +1,19 @@ + + + + + diff --git a/newIDE/app/public/res/functions/number_black.svg b/newIDE/app/public/res/functions/number_black.svg new file mode 100644 index 000000000000..106a24fb4f7a --- /dev/null +++ b/newIDE/app/public/res/functions/number_black.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/newIDE/app/public/res/functions/string_black.svg b/newIDE/app/public/res/functions/string_black.svg new file mode 100644 index 000000000000..e4999d431f0c --- /dev/null +++ b/newIDE/app/public/res/functions/string_black.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/newIDE/app/src/BehaviorTypeSelector/index.js b/newIDE/app/src/BehaviorTypeSelector/index.js index 6750fd6b3687..e32f69d62b8e 100644 --- a/newIDE/app/src/BehaviorTypeSelector/index.js +++ b/newIDE/app/src/BehaviorTypeSelector/index.js @@ -13,6 +13,7 @@ type Props = {| objectType: string, value: string, onChange: string => void, + onFocus?: (event: SyntheticFocusEvent) => void, disabled?: boolean, eventsFunctionsExtension: gdEventsFunctionsExtension | null, |}; @@ -33,7 +34,7 @@ export default class BehaviorTypeSelector extends React.Component< }; render() { - const { disabled, objectType, value, onChange } = this.props; + const { disabled, objectType, value, onChange, onFocus } = this.props; const { behaviorMetadata } = this.state; // If the behavior type is not in the list, we'll still @@ -48,6 +49,7 @@ export default class BehaviorTypeSelector extends React.Component< onChange={(e, i, value: string) => { onChange(value); }} + onFocus={onFocus} disabled={disabled} fullWidth > diff --git a/newIDE/app/src/EventsBasedBehaviorEditor/EventsBasedBehaviorEditorPanel.js b/newIDE/app/src/EventsBasedBehaviorEditor/EventsBasedBehaviorEditorPanel.js deleted file mode 100644 index 2f9d50f0b201..000000000000 --- a/newIDE/app/src/EventsBasedBehaviorEditor/EventsBasedBehaviorEditorPanel.js +++ /dev/null @@ -1,116 +0,0 @@ -// @flow -import { Trans } from '@lingui/macro'; -import * as React from 'react'; -import EventsBasedBehaviorEditor from './index'; -import { Tabs } from '../UI/Tabs'; -import EventsBasedBehaviorPropertiesEditor from './EventsBasedBehaviorPropertiesEditor'; -import Background from '../UI/Background'; -import { Column, Line } from '../UI/Grid'; -import { type UnsavedChanges } from '../MainFrame/UnsavedChangesContext'; -import { type ExtensionItemConfigurationAttribute } from '../EventsFunctionsExtensionEditor'; -import { ProjectScopedContainersAccessor } from '../InstructionOrExpression/EventsScope'; - -type TabName = 'configuration' | 'behavior-properties' | 'scene-properties'; - -type Props = {| - project: gdProject, - projectScopedContainersAccessor: ProjectScopedContainersAccessor, - eventsFunctionsExtension: gdEventsFunctionsExtension, - eventsBasedBehavior: gdEventsBasedBehavior, - onRenameProperty: (oldName: string, newName: string) => void, - onRenameSharedProperty: (oldName: string, newName: string) => void, - onPropertyTypeChanged: (propertyName: string) => void, - onEventsFunctionsAdded: () => void, - unsavedChanges?: ?UnsavedChanges, - onConfigurationUpdated?: (?ExtensionItemConfigurationAttribute) => void, -|}; - -export default function EventsBasedBehaviorEditorPanel({ - eventsBasedBehavior, - eventsFunctionsExtension, - project, - projectScopedContainersAccessor, - onRenameProperty, - onRenameSharedProperty, - onPropertyTypeChanged, - unsavedChanges, - onEventsFunctionsAdded, - onConfigurationUpdated, -}: Props) { - const [currentTab, setCurrentTab] = React.useState('configuration'); - - const onPropertiesUpdated = React.useCallback( - () => { - if (unsavedChanges) { - unsavedChanges.triggerUnsavedChanges(); - } - }, - [unsavedChanges] - ); - - return ( - - - - - Configuration, - }, - { - value: 'behavior-properties', - label: Behavior properties, - }, - { - value: 'scene-properties', - label: Scene properties, - }, - ]} - /> - - - {currentTab === 'configuration' && ( - - )} - {currentTab === 'behavior-properties' && ( - - )} - {currentTab === 'scene-properties' && ( - - )} - - - ); -} diff --git a/newIDE/app/src/EventsBasedBehaviorEditor/EventsBasedBehaviorPropertiesEditor.js b/newIDE/app/src/EventsBasedBehaviorEditor/EventsBasedBehaviorPropertiesEditor.js deleted file mode 100644 index e93323d3e9b5..000000000000 --- a/newIDE/app/src/EventsBasedBehaviorEditor/EventsBasedBehaviorPropertiesEditor.js +++ /dev/null @@ -1,1119 +0,0 @@ -// @flow -import { Trans } from '@lingui/macro'; -import { t } from '@lingui/macro'; -import { I18n } from '@lingui/react'; -import { type I18n as I18nType } from '@lingui/core'; -import * as React from 'react'; -import { Column, Line, Spacer } from '../UI/Grid'; -import { LineStackLayout } from '../UI/Layout'; -import SelectField from '../UI/SelectField'; -import SelectOption from '../UI/SelectOption'; -import { mapFor, mapVector } from '../Utils/MapFor'; -import RaisedButton from '../UI/RaisedButton'; -import IconButton from '../UI/IconButton'; -import ElementWithMenu from '../UI/Menu/ElementWithMenu'; -import SemiControlledTextField from '../UI/SemiControlledTextField'; -import newNameGenerator from '../Utils/NewNameGenerator'; -import { ResponsiveLineStackLayout, ColumnStackLayout } from '../UI/Layout'; -import ChoicesEditor, { type Choice } from '../ChoicesEditor'; -import ColorField from '../UI/ColorField'; -import BehaviorTypeSelector from '../BehaviorTypeSelector'; -import SemiControlledAutoComplete from '../UI/SemiControlledAutoComplete'; -import ScrollView, { type ScrollViewInterface } from '../UI/ScrollView'; -import ThreeDotsMenu from '../UI/CustomSvgIcons/ThreeDotsMenu'; -import { getMeasurementUnitShortLabel } from '../PropertiesEditor/PropertiesMapToSchema'; -import Add from '../UI/CustomSvgIcons/Add'; -import { DragHandleIcon } from '../UI/DragHandle'; -import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext'; -import DropIndicator from '../UI/SortableVirtualizedItemList/DropIndicator'; -import { makeDragSourceAndDropTarget } from '../UI/DragAndDrop/DragSourceAndDropTarget'; -import useForceUpdate from '../Utils/UseForceUpdate'; -import Clipboard from '../Utils/Clipboard'; -import { SafeExtractor } from '../Utils/SafeExtractor'; -import { - serializeToJSObject, - unserializeFromJSObject, -} from '../Utils/Serializer'; -import PasteIcon from '../UI/CustomSvgIcons/Clipboard'; -import ResponsiveFlatButton from '../UI/ResponsiveFlatButton'; -import { EmptyPlaceholder } from '../UI/EmptyPlaceholder'; -import useAlertDialog from '../UI/Alert/useAlertDialog'; -import SearchBar from '../UI/SearchBar'; -import { renderQuickCustomizationMenuItems } from '../QuickCustomization/QuickCustomizationMenuItems'; -import ResourceTypeSelectField from '../EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/ResourceTypeSelectField'; -import { ProjectScopedContainersAccessor } from '../InstructionOrExpression/EventsScope'; - -const gd: libGDevelop = global.gd; - -const PROPERTIES_CLIPBOARD_KIND = 'Properties'; - -const DragSourceAndDropTarget = makeDragSourceAndDropTarget( - 'behavior-properties-list' -); - -const styles = { - rowContainer: { - display: 'flex', - flexDirection: 'column', - marginTop: 5, - }, - rowContent: { - display: 'flex', - flex: 1, - alignItems: 'center', - padding: '8px 0px', - }, -}; - -export const usePropertyOverridingAlertDialog = () => { - const { showConfirmation } = useAlertDialog(); - return async (existingPropertyNames: Array): Promise => { - return await showConfirmation({ - title: t`Existing properties`, - message: t`These properties already exist:${'\n\n - ' + - existingPropertyNames.join('\n\n - ') + - '\n\n'}Do you want to replace them?`, - confirmButtonLabel: t`Replace`, - dismissButtonLabel: t`Omit`, - }); - }; -}; - -const setExtraInfoString = ( - property: gdNamedPropertyDescriptor, - value: string -) => { - const vectorString = new gd.VectorString(); - vectorString.push_back(value); - property.setExtraInfo(vectorString); - vectorString.delete(); -}; - -type Props = {| - project: gdProject, - projectScopedContainersAccessor: ProjectScopedContainersAccessor, - extension: gdEventsFunctionsExtension, - eventsBasedBehavior: gdEventsBasedBehavior, - properties: gdPropertiesContainer, - isSceneProperties?: boolean, - onPropertiesUpdated?: () => void, - onRenameProperty: (oldName: string, newName: string) => void, - onPropertyTypeChanged: (propertyName: string) => void, - onEventsFunctionsAdded: () => void, - behaviorObjectType?: string, -|}; - -// Those names are used internally by GDevelop. -const PROTECTED_PROPERTY_NAMES = ['name', 'type']; - -const getValidatedPropertyName = ( - properties: gdPropertiesContainer, - projectScopedContainers: gdProjectScopedContainers, - newName: string -): string => { - const variablesContainersList = projectScopedContainers.getVariablesContainersList(); - const objectsContainersList = projectScopedContainers.getObjectsContainersList(); - const safeAndUniqueNewName = newNameGenerator( - gd.Project.getSafeName(newName), - tentativeNewName => - properties.has(tentativeNewName) || - variablesContainersList.has(tentativeNewName) || - objectsContainersList.hasObjectNamed(tentativeNewName) || - PROTECTED_PROPERTY_NAMES.includes(tentativeNewName) - ); - return safeAndUniqueNewName; -}; - -const getChoicesArray = ( - property: gdNamedPropertyDescriptor -): Array => { - return mapVector(property.getChoices(), choice => ({ - value: choice.getValue(), - label: choice.getLabel(), - })); -}; - -export default function EventsBasedBehaviorPropertiesEditor({ - project, - projectScopedContainersAccessor, - extension, - eventsBasedBehavior, - properties, - isSceneProperties, - onPropertiesUpdated, - onRenameProperty, - onPropertyTypeChanged, - onEventsFunctionsAdded, - behaviorObjectType, -}: Props) { - const scrollView = React.useRef(null); - const [ - justAddedPropertyName, - setJustAddedPropertyName, - ] = React.useState(null); - const justAddedPropertyElement = React.useRef(null); - - React.useEffect( - () => { - if ( - scrollView.current && - justAddedPropertyElement.current && - justAddedPropertyName - ) { - scrollView.current.scrollTo(justAddedPropertyElement.current); - setJustAddedPropertyName(null); - justAddedPropertyElement.current = null; - } - }, - [justAddedPropertyName] - ); - - const draggedProperty = React.useRef(null); - - const gdevelopTheme = React.useContext(GDevelopThemeContext); - - const showPropertyOverridingConfirmation = usePropertyOverridingAlertDialog(); - - const forceUpdate = useForceUpdate(); - - const [searchText, setSearchText] = React.useState(''); - const [ - searchMatchingPropertyNames, - setSearchMatchingPropertyNames, - ] = React.useState>([]); - - const triggerSearch = React.useCallback( - () => { - const matchingPropertyNames = mapVector( - properties, - (property: gdNamedPropertyDescriptor, i: number) => { - const lowerCaseSearchText = searchText.toLowerCase(); - return property - .getName() - .toLowerCase() - .includes(lowerCaseSearchText) || - property - .getLabel() - .toLowerCase() - .includes(lowerCaseSearchText) || - property - .getGroup() - .toLowerCase() - .includes(lowerCaseSearchText) - ? property.getName() - : null; - } - ).filter(Boolean); - setSearchMatchingPropertyNames(matchingPropertyNames); - }, - [properties, searchText] - ); - - React.useEffect( - () => { - if (searchText) { - triggerSearch(); - } else { - setSearchMatchingPropertyNames([]); - } - }, - [searchText, triggerSearch] - ); - - const addPropertyAt = React.useCallback( - (index: number) => { - const newName = newNameGenerator('Property', name => - properties.has(name) - ); - const property = properties.insertNew(newName, index); - property.setType('Number'); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - setJustAddedPropertyName(newName); - setSearchText(''); - }, - [forceUpdate, onPropertiesUpdated, properties] - ); - - const addProperty = React.useCallback( - () => { - addPropertyAt(properties.getCount()); - }, - [addPropertyAt, properties] - ); - - const removeProperty = React.useCallback( - (name: string) => { - properties.remove(name); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [forceUpdate, onPropertiesUpdated, properties] - ); - - const copyProperty = React.useCallback( - (property: gdNamedPropertyDescriptor) => { - Clipboard.set(PROPERTIES_CLIPBOARD_KIND, [ - { - name: property.getName(), - serializedProperty: serializeToJSObject(property), - }, - ]); - forceUpdate(); - }, - [forceUpdate] - ); - - const duplicateProperty = React.useCallback( - (property: gdNamedPropertyDescriptor, index: number) => { - const newName = newNameGenerator(property.getName(), name => - properties.has(name) - ); - - const newProperty = properties.insertNew(newName, index); - - unserializeFromJSObject(newProperty, serializeToJSObject(property)); - newProperty.setName(newName); - - forceUpdate(); - }, - [forceUpdate, properties] - ); - - const pasteProperties = React.useCallback( - async propertyInsertionIndex => { - const clipboardContent = Clipboard.get(PROPERTIES_CLIPBOARD_KIND); - const propertyContents = SafeExtractor.extractArray(clipboardContent); - if (!propertyContents) return; - - const newNamedProperties: Array<{ - name: string, - serializedProperty: string, - }> = []; - const existingNamedProperties: Array<{ - name: string, - serializedProperty: string, - }> = []; - propertyContents.forEach(propertyContent => { - const name = SafeExtractor.extractStringProperty( - propertyContent, - 'name' - ); - const serializedProperty = SafeExtractor.extractObjectProperty( - propertyContent, - 'serializedProperty' - ); - if (!name || !serializedProperty) { - return; - } - - if (properties.has(name)) { - existingNamedProperties.push({ name, serializedProperty }); - } else { - newNamedProperties.push({ name, serializedProperty }); - } - }); - - let firstAddedPropertyName: string | null = null; - let index = propertyInsertionIndex; - newNamedProperties.forEach(({ name, serializedProperty }) => { - const property = properties.insertNew(name, index); - index++; - unserializeFromJSObject(property, serializedProperty); - if (!firstAddedPropertyName) { - firstAddedPropertyName = name; - } - }); - - let shouldOverrideProperties = false; - if (existingNamedProperties.length > 0) { - shouldOverrideProperties = await showPropertyOverridingConfirmation( - existingNamedProperties.map(namedProperty => namedProperty.name) - ); - - if (shouldOverrideProperties) { - existingNamedProperties.forEach(({ name, serializedProperty }) => { - if (properties.has(name)) { - const property = properties.get(name); - unserializeFromJSObject(property, serializedProperty); - } - }); - } - } - - setSearchText(''); - forceUpdate(); - if (firstAddedPropertyName) { - setJustAddedPropertyName(firstAddedPropertyName); - } else if (existingNamedProperties.length === 1) { - setJustAddedPropertyName(existingNamedProperties[0].name); - } - if (firstAddedPropertyName || shouldOverrideProperties) { - if (onPropertiesUpdated) onPropertiesUpdated(); - } - }, - [ - forceUpdate, - properties, - showPropertyOverridingConfirmation, - onPropertiesUpdated, - ] - ); - - const pastePropertiesAtTheEnd = React.useCallback( - async () => { - await pasteProperties(properties.getCount()); - }, - [properties, pasteProperties] - ); - - const pastePropertiesBefore = React.useCallback( - async (property: gdNamedPropertyDescriptor) => { - await pasteProperties(properties.getPosition(property)); - }, - [properties, pasteProperties] - ); - - const moveProperty = React.useCallback( - (oldIndex: number, newIndex: number) => { - properties.move(oldIndex, newIndex); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [forceUpdate, onPropertiesUpdated, properties] - ); - - const movePropertyBefore = React.useCallback( - (targetProperty: gdNamedPropertyDescriptor) => { - const { current } = draggedProperty; - if (!current) return; - - const draggedIndex = properties.getPosition(current); - const targetIndex = properties.getPosition(targetProperty); - - properties.move( - draggedIndex, - targetIndex > draggedIndex ? targetIndex - 1 : targetIndex - ); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [properties, forceUpdate, onPropertiesUpdated] - ); - - const setChoices = React.useCallback( - (property: gdNamedPropertyDescriptor) => { - return (choices: Array) => { - property.clearChoices(); - for (const choice of choices) { - property.addChoice(choice.value, choice.label); - } - if ( - !getChoicesArray(property).some( - choice => choice.value === property.getValue() - ) - ) { - property.setValue(''); - } - forceUpdate(); - }; - }, - [forceUpdate] - ); - - const getPropertyGroupNames = React.useCallback( - (): Array => { - const groupNames = new Set(); - for (let i = 0; i < properties.size(); i++) { - const property = properties.at(i); - const group = property.getGroup() || ''; - groupNames.add(group); - } - return [...groupNames].sort((a, b) => a.localeCompare(b)); - }, - [properties] - ); - - const setHidden = React.useCallback( - (property: gdNamedPropertyDescriptor, enable: boolean) => { - property.setHidden(enable); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [forceUpdate, onPropertiesUpdated] - ); - - const setAdvanced = React.useCallback( - (property: gdNamedPropertyDescriptor, enable: boolean) => { - property.setAdvanced(enable); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [forceUpdate, onPropertiesUpdated] - ); - - const setDeprecated = React.useCallback( - (property: gdNamedPropertyDescriptor, enable: boolean) => { - property.setDeprecated(enable); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [forceUpdate, onPropertiesUpdated] - ); - - const isClipboardContainingProperties = Clipboard.has( - PROPERTIES_CLIPBOARD_KIND - ); - - return ( - - {({ i18n }) => ( - - {properties.getCount() > 0 ? ( - - - - {mapVector( - properties, - (property: gdNamedPropertyDescriptor, i: number) => { - const propertyRef = - justAddedPropertyName === property.getName() - ? justAddedPropertyElement - : null; - - if ( - searchText && - !searchMatchingPropertyNames.includes( - property.getName() - ) - ) { - return null; - } - - return ( - { - draggedProperty.current = property; - return {}; - }} - canDrag={() => true} - canDrop={() => true} - drop={() => { - movePropertyBefore(property); - }} - > - {({ - connectDragSource, - connectDropTarget, - isOver, - canDrop, - }) => - connectDropTarget( -
- {isOver && } -
- {connectDragSource( - - - - - - )} - - - { - if (newName === property.getName()) - return; - - const projectScopedContainers = projectScopedContainersAccessor.get(); - const validatedNewName = getValidatedPropertyName( - properties, - projectScopedContainers, - newName - ); - onRenameProperty( - property.getName(), - validatedNewName - ); - property.setName(validatedNewName); - - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - /> - - - { - if (value === 'Hidden') { - setHidden(property, true); - setDeprecated(property, false); - setAdvanced(property, false); - } else if (value === 'Deprecated') { - setHidden(property, false); - setDeprecated(property, true); - setAdvanced(property, false); - } else if (value === 'Advanced') { - setHidden(property, false); - setDeprecated(property, false); - setAdvanced(property, true); - } else if (value === 'Visible') { - setHidden(property, false); - setDeprecated(property, false); - setAdvanced(property, false); - } - }} - fullWidth - > - - - - - - - - - - - } - buildMenuTemplate={(i18n: I18nType) => [ - { - label: i18n._(t`Add a property below`), - click: () => addPropertyAt(i + 1), - }, - { - label: i18n._(t`Delete`), - click: () => - removeProperty(property.getName()), - }, - { - label: i18n._(t`Copy`), - click: () => copyProperty(property), - }, - { - label: i18n._(t`Paste`), - click: () => - pastePropertiesBefore(property), - enabled: isClipboardContainingProperties, - }, - { - label: i18n._(t`Duplicate`), - click: () => - duplicateProperty(property, i + 1), - }, - { type: 'separator' }, - { - label: i18n._(t`Move up`), - click: () => moveProperty(i, i - 1), - enabled: i - 1 >= 0, - }, - { - label: i18n._(t`Move down`), - click: () => moveProperty(i, i + 1), - enabled: i + 1 < properties.getCount(), - }, - { - label: i18n._( - t`Generate expression and action` - ), - click: () => { - gd.PropertyFunctionGenerator.generateBehaviorGetterAndSetter( - project, - extension, - eventsBasedBehavior, - property, - !!isSceneProperties - ); - onEventsFunctionsAdded(); - }, - enabled: gd.PropertyFunctionGenerator.canGenerateGetterAndSetter( - eventsBasedBehavior, - property - ), - }, - ...renderQuickCustomizationMenuItems({ - i18n, - visibility: property.getQuickCustomizationVisibility(), - onChangeVisibility: visibility => { - property.setQuickCustomizationVisibility( - visibility - ); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }, - }), - ]} - /> - -
- - - - Type} - value={property.getType()} - onChange={(e, i, value: string) => { - property.setType(value); - if (value === 'Behavior') { - property.setHidden(false); - } - if (value === 'Resource') { - setExtraInfoString( - property, - 'json' - ); - } - forceUpdate(); - onPropertyTypeChanged( - property.getName() - ); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - > - - - - - - - - - - {!isSceneProperties && ( - - )} - - {property.getType() === 'Number' && ( - Measurement unit - } - value={property - .getMeasurementUnit() - .getName()} - onChange={(e, i, value: string) => { - property.setMeasurementUnit( - gd.MeasurementUnit.getDefaultMeasurementUnitByName( - value - ) - ); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - > - {mapFor( - 0, - gd.MeasurementUnit.getDefaultMeasurementUnitsCount(), - i => { - const measurementUnit = gd.MeasurementUnit.getDefaultMeasurementUnitAtIndex( - i - ); - const unitShortLabel = getMeasurementUnitShortLabel( - measurementUnit - ); - const label = - measurementUnit.getLabel() + - (unitShortLabel.length > 0 - ? ' — ' + unitShortLabel - : ''); - return ( - - ); - } - )} - - )} - {(property.getType() === 'String' || - property.getType() === 'Number' || - property.getType() === - 'ObjectAnimationName' || - property.getType() === 'KeyboardKey' || - property.getType() === - 'MultilineString') && ( - Default value - } - hintText={ - property.getType() === 'Number' - ? '123' - : 'ABC' - } - value={property.getValue()} - onChange={newValue => { - property.setValue(newValue); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - multiline={ - property.getType() === - 'MultilineString' - } - fullWidth - /> - )} - {property.getType() === 'Boolean' && ( - Default value - } - value={ - property.getValue() === 'true' - ? 'true' - : 'false' - } - onChange={(e, i, value) => { - property.setValue(value); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - > - - - - )} - {property.getType() === 'Behavior' && ( - { - // Change the type of the required behavior. - const extraInfo = property.getExtraInfo(); - if (extraInfo.size() === 0) { - extraInfo.push_back(newValue); - } else { - extraInfo.set(0, newValue); - } - const behaviorMetadata = gd.MetadataProvider.getBehaviorMetadata( - project.getCurrentPlatform(), - newValue - ); - const projectScopedContainers = projectScopedContainersAccessor.get(); - const validatedNewName = getValidatedPropertyName( - properties, - projectScopedContainers, - behaviorMetadata.getDefaultName() - ); - property.setName(validatedNewName); - property.setLabel( - behaviorMetadata.getFullName() - ); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - disabled={false} - /> - )} - {property.getType() === 'Color' && ( - Default value - } - disableAlpha - fullWidth - color={property.getValue()} - onChange={color => { - property.setValue(color); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - /> - )} - {property.getType() === 'Resource' && ( - 0 - ? property.getExtraInfo().at(0) - : '' - } - onChange={(e, i, value) => { - setExtraInfoString(property, value); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - /> - )} - {property.getType() === 'Choice' && ( - Default value - } - value={property.getValue()} - onChange={(e, i, value) => { - property.setValue(value); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - > - {getChoicesArray(property).map( - (choice, index) => ( - - ) - )} - - )} - - {property.getType() === 'Choice' && ( - - )} - - Short label - } - translatableHintText={t`Make the purpose of the property easy to understand`} - floatingLabelFixed - value={property.getLabel()} - onChange={text => { - property.setLabel(text); - forceUpdate(); - }} - fullWidth - /> - Group name - } - hintText={t`Leave it empty to use the default group`} - fullWidth - value={property.getGroup()} - onChange={text => { - property.setGroup(text); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - dataSource={getPropertyGroupNames().map( - name => ({ - text: name, - value: name, - }) - )} - openOnFocus={true} - /> - - Description - } - translatableHintText={t`Optionally, explain the purpose of the property in more details`} - floatingLabelFixed - value={property.getDescription()} - onChange={text => { - property.setDescription(text); - forceUpdate(); - }} - fullWidth - /> - - -
- ) - } -
- ); - } - )} -
-
- - - - } - label={Paste} - onClick={() => { - pastePropertiesAtTheEnd(); - }} - disabled={!isClipboardContainingProperties} - /> - {}} - onChange={text => setSearchText(text)} - placeholder={t`Search properties`} - /> - - - Add a property} - onClick={addProperty} - icon={} - /> - - - -
- ) : ( - - Add your first property} - description={ - Properties store data inside behaviors. - } - actionLabel={Add a property} - helpPagePath={'/behaviors/events-based-behaviors'} - helpPageAnchor={'add-and-use-properties-in-a-behavior'} - onAction={addProperty} - secondaryActionIcon={} - secondaryActionLabel={ - isClipboardContainingProperties ? Paste : null - } - onSecondaryAction={() => { - pastePropertiesAtTheEnd(); - }} - /> - - )} -
- )} -
- ); -} diff --git a/newIDE/app/src/EventsBasedBehaviorEditor/index.js b/newIDE/app/src/EventsBasedBehaviorEditor/index.js deleted file mode 100644 index b0afe49c5e4d..000000000000 --- a/newIDE/app/src/EventsBasedBehaviorEditor/index.js +++ /dev/null @@ -1,222 +0,0 @@ -// @flow -import { Trans } from '@lingui/macro'; -import { t } from '@lingui/macro'; -import { I18n } from '@lingui/react'; - -import * as React from 'react'; -import TextField from '../UI/TextField'; -import SemiControlledTextField from '../UI/SemiControlledTextField'; -import ObjectTypeSelector from '../ObjectTypeSelector'; -import DismissableAlertMessage from '../UI/DismissableAlertMessage'; -import AlertMessage from '../UI/AlertMessage'; -import { ColumnStackLayout } from '../UI/Layout'; -import useForceUpdate from '../Utils/UseForceUpdate'; -import HelpButton from '../UI/HelpButton'; -import { Line } from '../UI/Grid'; -import { type UnsavedChanges } from '../MainFrame/UnsavedChangesContext'; -import Checkbox from '../UI/Checkbox'; -import { type ExtensionItemConfigurationAttribute } from '../EventsFunctionsExtensionEditor'; -import SelectField from '../UI/SelectField'; -import SelectOption from '../UI/SelectOption'; -import Window from '../Utils/Window'; -import ScrollView from '../UI/ScrollView'; - -const gd: libGDevelop = global.gd; - -const isDev = Window.isDev(); - -type Props = {| - project: gdProject, - eventsFunctionsExtension: gdEventsFunctionsExtension, - eventsBasedBehavior: gdEventsBasedBehavior, - unsavedChanges?: ?UnsavedChanges, - onConfigurationUpdated?: (?ExtensionItemConfigurationAttribute) => void, -|}; - -export default function EventsBasedBehaviorEditor({ - project, - eventsFunctionsExtension, - eventsBasedBehavior, - unsavedChanges, - onConfigurationUpdated, -}: Props) { - const forceUpdate = useForceUpdate(); - - const onChange = React.useCallback( - () => { - if (unsavedChanges) { - unsavedChanges.triggerUnsavedChanges(); - } - forceUpdate(); - }, - [forceUpdate, unsavedChanges] - ); - - // An array containing all the object types that are using the behavior - const allObjectTypes: Array = React.useMemo( - () => - gd.WholeProjectRefactorer.getAllObjectTypesUsingEventsBasedBehavior( - project, - eventsFunctionsExtension, - eventsBasedBehavior - ) - .toNewVectorString() - .toJSArray(), - [project, eventsFunctionsExtension, eventsBasedBehavior] - ); - - return ( - - {({ i18n }) => ( - - - - - This is the configuration of your behavior. Make sure to choose - a proper internal name as it's hard to change it later. Enter a - description explaining what the behavior is doing to the object. - - - Internal Name} - value={eventsBasedBehavior.getName()} - disabled - fullWidth - /> - Name displayed in editor} - value={eventsBasedBehavior.getFullName()} - onChange={text => { - eventsBasedBehavior.setFullName(text); - onChange(); - }} - fullWidth - /> - Description} - helperMarkdownText={i18n._( - t`Explain what the behavior is doing to the object. Start with a verb when possible.` - )} - value={eventsBasedBehavior.getDescription()} - onChange={text => { - eventsBasedBehavior.setDescription(text); - onChange(); - }} - multiline - fullWidth - rows={3} - /> - Object on which this behavior can be used - } - project={project} - value={eventsBasedBehavior.getObjectType()} - onChange={(objectType: string) => { - eventsBasedBehavior.setObjectType(objectType); - onChange(); - }} - allowedObjectTypes={ - allObjectTypes.length === 0 - ? undefined /* Allow anything as the behavior is not used */ - : allObjectTypes.length === 1 - ? [ - '', - allObjectTypes[0], - ] /* Allow only the type of the objects using the behavior */ - : [ - '', - ] /* More than one type of object are using the behavior. Only "any object" can be used on this behavior */ - } - /> - {allObjectTypes.length > 1 && ( - - - This behavior is being used by multiple types of objects. - Thus, you can't restrict its usage to any particular object - type. All the object types using this behavior are listed - here: - {allObjectTypes.join(', ')} - - - )} - {isDev && ( - Visibility in quick customization dialog - } - value={eventsBasedBehavior.getQuickCustomizationVisibility()} - onChange={(e, i, valueString: string) => { - // $FlowFixMe - const value: QuickCustomization_Visibility = valueString; - eventsBasedBehavior.setQuickCustomizationVisibility(value); - onChange(); - }} - fullWidth - > - - - - - )} - Private} - checked={eventsBasedBehavior.isPrivate()} - onCheck={(e, checked) => { - eventsBasedBehavior.setPrivate(checked); - if (onConfigurationUpdated) onConfigurationUpdated('isPrivate'); - onChange(); - }} - tooltipOrHelperText={ - eventsBasedBehavior.isPrivate() ? ( - - This behavior won't be visible in the scene and events - editors. - - ) : ( - - This behavior will be visible in the scene and events - editors. - - ) - } - /> - {eventsBasedBehavior - .getEventsFunctions() - .getEventsFunctionsCount() === 0 && ( - - - Once you're done, start adding some functions to the behavior. - Then, test the behavior by adding it to an object in a scene. - - - )} - - - - - - )} - - ); -} diff --git a/newIDE/app/src/EventsBasedObjectEditor/EventsBasedObjectEditorPanel.js b/newIDE/app/src/EventsBasedObjectEditor/EventsBasedObjectEditorPanel.js deleted file mode 100644 index ff5a032a0ee3..000000000000 --- a/newIDE/app/src/EventsBasedObjectEditor/EventsBasedObjectEditorPanel.js +++ /dev/null @@ -1,99 +0,0 @@ -// @flow -import { Trans } from '@lingui/macro'; -import * as React from 'react'; -import EventsBasedObjectEditor from './index'; -import { Tabs } from '../UI/Tabs'; -import EventsBasedObjectPropertiesEditor from './EventsBasedObjectPropertiesEditor'; -import Background from '../UI/Background'; -import { Column, Line } from '../UI/Grid'; -import { type UnsavedChanges } from '../MainFrame/UnsavedChangesContext'; -import { ProjectScopedContainersAccessor } from '../InstructionOrExpression/EventsScope'; - -type TabName = 'configuration' | 'properties' | 'children'; - -type Props = {| - project: gdProject, - projectScopedContainersAccessor: ProjectScopedContainersAccessor, - eventsFunctionsExtension: gdEventsFunctionsExtension, - eventsBasedObject: gdEventsBasedObject, - onRenameProperty: (oldName: string, newName: string) => void, - onPropertyTypeChanged: (propertyName: string) => void, - onEventsFunctionsAdded: () => void, - onOpenCustomObjectEditor: () => void, - unsavedChanges?: ?UnsavedChanges, - onEventsBasedObjectChildrenEdited: ( - eventsBasedObject: gdEventsBasedObject - ) => void, -|}; - -export default function EventsBasedObjectEditorPanel({ - project, - projectScopedContainersAccessor, - eventsFunctionsExtension, - eventsBasedObject, - onRenameProperty, - onPropertyTypeChanged, - onEventsFunctionsAdded, - onOpenCustomObjectEditor, - unsavedChanges, - onEventsBasedObjectChildrenEdited, -}: Props) { - const [currentTab, setCurrentTab] = React.useState('configuration'); - - const onPropertiesUpdated = React.useCallback( - () => { - if (unsavedChanges) { - unsavedChanges.triggerUnsavedChanges(); - } - }, - [unsavedChanges] - ); - - return ( - - - - - Configuration, - }, - { - value: 'properties', - label: Properties, - }, - ]} - /> - - - {currentTab === 'configuration' && ( - - )} - {currentTab === 'properties' && ( - - )} - - - ); -} diff --git a/newIDE/app/src/EventsBasedObjectEditor/EventsBasedObjectPropertiesEditor.js b/newIDE/app/src/EventsBasedObjectEditor/EventsBasedObjectPropertiesEditor.js deleted file mode 100644 index 425cea97ca9d..000000000000 --- a/newIDE/app/src/EventsBasedObjectEditor/EventsBasedObjectPropertiesEditor.js +++ /dev/null @@ -1,1043 +0,0 @@ -// @flow -import { Trans } from '@lingui/macro'; -import { t } from '@lingui/macro'; -import { I18n } from '@lingui/react'; -import { type I18n as I18nType } from '@lingui/core'; -import * as React from 'react'; -import { Column, Line, Spacer } from '../UI/Grid'; -import { LineStackLayout } from '../UI/Layout'; -import SelectField from '../UI/SelectField'; -import SelectOption from '../UI/SelectOption'; -import { mapFor, mapVector } from '../Utils/MapFor'; -import RaisedButton from '../UI/RaisedButton'; -import IconButton from '../UI/IconButton'; -import ElementWithMenu from '../UI/Menu/ElementWithMenu'; -import SemiControlledTextField from '../UI/SemiControlledTextField'; -import newNameGenerator from '../Utils/NewNameGenerator'; -import { ResponsiveLineStackLayout, ColumnStackLayout } from '../UI/Layout'; -import ChoicesEditor, { type Choice } from '../ChoicesEditor'; -import ColorField from '../UI/ColorField'; -import SemiControlledAutoComplete from '../UI/SemiControlledAutoComplete'; -import ScrollView, { type ScrollViewInterface } from '../UI/ScrollView'; -import ThreeDotsMenu from '../UI/CustomSvgIcons/ThreeDotsMenu'; -import { getMeasurementUnitShortLabel } from '../PropertiesEditor/PropertiesMapToSchema'; -import Add from '../UI/CustomSvgIcons/Add'; -import { DragHandleIcon } from '../UI/DragHandle'; -import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext'; -import DropIndicator from '../UI/SortableVirtualizedItemList/DropIndicator'; -import { makeDragSourceAndDropTarget } from '../UI/DragAndDrop/DragSourceAndDropTarget'; -import useForceUpdate from '../Utils/UseForceUpdate'; -import Clipboard from '../Utils/Clipboard'; -import { SafeExtractor } from '../Utils/SafeExtractor'; -import { - serializeToJSObject, - unserializeFromJSObject, -} from '../Utils/Serializer'; -import PasteIcon from '../UI/CustomSvgIcons/Clipboard'; -import ResponsiveFlatButton from '../UI/ResponsiveFlatButton'; -import { EmptyPlaceholder } from '../UI/EmptyPlaceholder'; -import useAlertDialog from '../UI/Alert/useAlertDialog'; -import SearchBar from '../UI/SearchBar'; -import ResourceTypeSelectField from '../EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/ResourceTypeSelectField'; -import { ProjectScopedContainersAccessor } from '../InstructionOrExpression/EventsScope'; - -const gd: libGDevelop = global.gd; - -const PROPERTIES_CLIPBOARD_KIND = 'Properties'; - -const DragSourceAndDropTarget = makeDragSourceAndDropTarget( - 'behavior-properties-list' -); - -const styles = { - rowContainer: { - display: 'flex', - flexDirection: 'column', - marginTop: 5, - }, - rowContent: { - display: 'flex', - flex: 1, - alignItems: 'center', - padding: '8px 0px', - }, -}; - -export const usePropertyOverridingAlertDialog = () => { - const { showConfirmation } = useAlertDialog(); - return async (existingPropertyNames: Array): Promise => { - return await showConfirmation({ - title: t`Existing properties`, - message: t`These properties already exist:${'\n\n - ' + - existingPropertyNames.join('\n\n - ') + - '\n\n'}Do you want to replace them?`, - confirmButtonLabel: t`Replace`, - dismissButtonLabel: t`Omit`, - }); - }; -}; - -const setExtraInfoString = ( - property: gdNamedPropertyDescriptor, - value: string -) => { - const vectorString = new gd.VectorString(); - vectorString.push_back(value); - property.setExtraInfo(vectorString); - vectorString.delete(); -}; - -type Props = {| - project: gdProject, - projectScopedContainersAccessor: ProjectScopedContainersAccessor, - extension: gdEventsFunctionsExtension, - eventsBasedObject: gdEventsBasedObject, - onPropertiesUpdated?: () => void, - onRenameProperty: (oldName: string, newName: string) => void, - onPropertyTypeChanged: (propertyName: string) => void, - onEventsFunctionsAdded: () => void, -|}; - -// Those names are used internally by GDevelop. -const PROTECTED_PROPERTY_NAMES = ['name', 'type']; - -const getValidatedPropertyName = ( - properties: gdPropertiesContainer, - projectScopedContainers: gdProjectScopedContainers, - newName: string -): string => { - const variablesContainersList = projectScopedContainers.getVariablesContainersList(); - const objectsContainersList = projectScopedContainers.getObjectsContainersList(); - const safeAndUniqueNewName = newNameGenerator( - gd.Project.getSafeName(newName), - tentativeNewName => - properties.has(tentativeNewName) || - variablesContainersList.has(tentativeNewName) || - objectsContainersList.hasObjectNamed(tentativeNewName) || - PROTECTED_PROPERTY_NAMES.includes(tentativeNewName) - ); - return safeAndUniqueNewName; -}; - -const getChoicesArray = ( - property: gdNamedPropertyDescriptor -): Array => { - return mapVector(property.getChoices(), choice => ({ - value: choice.getValue(), - label: choice.getLabel(), - })); -}; - -export default function EventsBasedObjectPropertiesEditor({ - project, - projectScopedContainersAccessor, - extension, - eventsBasedObject, - onPropertiesUpdated, - onRenameProperty, - onPropertyTypeChanged, - onEventsFunctionsAdded, -}: Props) { - const scrollView = React.useRef(null); - const [ - justAddedPropertyName, - setJustAddedPropertyName, - ] = React.useState(null); - const justAddedPropertyElement = React.useRef(null); - - React.useEffect( - () => { - if ( - scrollView.current && - justAddedPropertyElement.current && - justAddedPropertyName - ) { - scrollView.current.scrollTo(justAddedPropertyElement.current); - setJustAddedPropertyName(null); - justAddedPropertyElement.current = null; - } - }, - [justAddedPropertyName] - ); - - const draggedProperty = React.useRef(null); - - const gdevelopTheme = React.useContext(GDevelopThemeContext); - - const showPropertyOverridingConfirmation = usePropertyOverridingAlertDialog(); - - const forceUpdate = useForceUpdate(); - - const [searchText, setSearchText] = React.useState(''); - const [ - searchMatchingPropertyNames, - setSearchMatchingPropertyNames, - ] = React.useState>([]); - - const triggerSearch = React.useCallback( - () => { - const properties = eventsBasedObject.getPropertyDescriptors(); - const matchingPropertyNames = mapVector( - properties, - (property: gdNamedPropertyDescriptor, i: number) => { - const lowerCaseSearchText = searchText.toLowerCase(); - return property - .getName() - .toLowerCase() - .includes(lowerCaseSearchText) || - property - .getLabel() - .toLowerCase() - .includes(lowerCaseSearchText) || - property - .getGroup() - .toLowerCase() - .includes(lowerCaseSearchText) - ? property.getName() - : null; - } - ).filter(Boolean); - setSearchMatchingPropertyNames(matchingPropertyNames); - }, - [eventsBasedObject, searchText] - ); - - React.useEffect( - () => { - if (searchText) { - triggerSearch(); - } else { - setSearchMatchingPropertyNames([]); - } - }, - [searchText, triggerSearch] - ); - - const addPropertyAt = React.useCallback( - (index: number) => { - const properties = eventsBasedObject.getPropertyDescriptors(); - - const newName = newNameGenerator('Property', name => - properties.has(name) - ); - const property = properties.insertNew(newName, index); - property.setType('Number'); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - setJustAddedPropertyName(newName); - setSearchText(''); - }, - [eventsBasedObject, forceUpdate, onPropertiesUpdated] - ); - - const addProperty = React.useCallback( - () => { - const properties = eventsBasedObject.getPropertyDescriptors(); - addPropertyAt(properties.getCount()); - }, - [addPropertyAt, eventsBasedObject] - ); - - const removeProperty = React.useCallback( - (name: string) => { - const properties = eventsBasedObject.getPropertyDescriptors(); - - properties.remove(name); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [eventsBasedObject, forceUpdate, onPropertiesUpdated] - ); - - const copyProperty = React.useCallback( - (property: gdNamedPropertyDescriptor) => { - Clipboard.set(PROPERTIES_CLIPBOARD_KIND, [ - { - name: property.getName(), - serializedProperty: serializeToJSObject(property), - }, - ]); - forceUpdate(); - }, - [forceUpdate] - ); - - const duplicateProperty = React.useCallback( - (property: gdNamedPropertyDescriptor, index: number) => { - const properties = eventsBasedObject.getPropertyDescriptors(); - const newName = newNameGenerator(property.getName(), name => - properties.has(name) - ); - - const newProperty = properties.insertNew(newName, index); - - unserializeFromJSObject(newProperty, serializeToJSObject(property)); - newProperty.setName(newName); - - forceUpdate(); - }, - [forceUpdate, eventsBasedObject] - ); - - const pasteProperties = React.useCallback( - async propertyInsertionIndex => { - const properties = eventsBasedObject.getPropertyDescriptors(); - - const clipboardContent = Clipboard.get(PROPERTIES_CLIPBOARD_KIND); - const propertyContents = SafeExtractor.extractArray(clipboardContent); - if (!propertyContents) return; - - const newNamedProperties: Array<{ - name: string, - serializedProperty: string, - }> = []; - const existingNamedProperties: Array<{ - name: string, - serializedProperty: string, - }> = []; - propertyContents.forEach(propertyContent => { - const name = SafeExtractor.extractStringProperty( - propertyContent, - 'name' - ); - const serializedProperty = SafeExtractor.extractObjectProperty( - propertyContent, - 'serializedProperty' - ); - if (!name || !serializedProperty) { - return; - } - - if (properties.has(name)) { - existingNamedProperties.push({ name, serializedProperty }); - } else { - newNamedProperties.push({ name, serializedProperty }); - } - }); - - let firstAddedPropertyName: string | null = null; - let index = propertyInsertionIndex; - newNamedProperties.forEach(({ name, serializedProperty }) => { - const property = properties.insertNew(name, index); - index++; - unserializeFromJSObject(property, serializedProperty); - if (!firstAddedPropertyName) { - firstAddedPropertyName = name; - } - }); - - let shouldOverrideProperties = false; - if (existingNamedProperties.length > 0) { - shouldOverrideProperties = await showPropertyOverridingConfirmation( - existingNamedProperties.map(namedProperty => namedProperty.name) - ); - - if (shouldOverrideProperties) { - existingNamedProperties.forEach(({ name, serializedProperty }) => { - if (properties.has(name)) { - const property = properties.get(name); - unserializeFromJSObject(property, serializedProperty); - } - }); - } - } - - setSearchText(''); - forceUpdate(); - if (firstAddedPropertyName) { - setJustAddedPropertyName(firstAddedPropertyName); - } else if (existingNamedProperties.length === 1) { - setJustAddedPropertyName(existingNamedProperties[0].name); - } - if (firstAddedPropertyName || shouldOverrideProperties) { - if (onPropertiesUpdated) onPropertiesUpdated(); - } - }, - [ - eventsBasedObject, - forceUpdate, - showPropertyOverridingConfirmation, - onPropertiesUpdated, - ] - ); - - const pastePropertiesAtTheEnd = React.useCallback( - async () => { - const properties = eventsBasedObject.getPropertyDescriptors(); - await pasteProperties(properties.getCount()); - }, - [eventsBasedObject, pasteProperties] - ); - - const pastePropertiesBefore = React.useCallback( - async (property: gdNamedPropertyDescriptor) => { - const properties = eventsBasedObject.getPropertyDescriptors(); - await pasteProperties(properties.getPosition(property)); - }, - [eventsBasedObject, pasteProperties] - ); - - const moveProperty = React.useCallback( - (oldIndex: number, newIndex: number) => { - const properties = eventsBasedObject.getPropertyDescriptors(); - - properties.move(oldIndex, newIndex); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [eventsBasedObject, forceUpdate, onPropertiesUpdated] - ); - - const movePropertyBefore = React.useCallback( - (targetProperty: gdNamedPropertyDescriptor) => { - const { current } = draggedProperty; - if (!current) return; - - const properties = eventsBasedObject.getPropertyDescriptors(); - - const draggedIndex = properties.getPosition(current); - const targetIndex = properties.getPosition(targetProperty); - - properties.move( - draggedIndex, - targetIndex > draggedIndex ? targetIndex - 1 : targetIndex - ); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [eventsBasedObject, forceUpdate, onPropertiesUpdated] - ); - - const setChoices = React.useCallback( - (property: gdNamedPropertyDescriptor) => { - return (choices: Array) => { - property.clearChoices(); - for (const choice of choices) { - property.addChoice(choice.value, choice.label); - } - if ( - !getChoicesArray(property).some( - choice => choice.value === property.getValue() - ) - ) { - property.setValue(''); - } - forceUpdate(); - }; - }, - [forceUpdate] - ); - - const getPropertyGroupNames = React.useCallback( - (): Array => { - const properties = eventsBasedObject.getPropertyDescriptors(); - - const groupNames = new Set(); - for (let i = 0; i < properties.size(); i++) { - const property = properties.at(i); - const group = property.getGroup() || ''; - groupNames.add(group); - } - return [...groupNames].sort((a, b) => a.localeCompare(b)); - }, - [eventsBasedObject] - ); - - const setHidden = React.useCallback( - (property: gdNamedPropertyDescriptor, enable: boolean) => { - property.setHidden(enable); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [forceUpdate, onPropertiesUpdated] - ); - - const setAdvanced = React.useCallback( - (property: gdNamedPropertyDescriptor, enable: boolean) => { - property.setAdvanced(enable); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [forceUpdate, onPropertiesUpdated] - ); - - const setDeprecated = React.useCallback( - (property: gdNamedPropertyDescriptor, enable: boolean) => { - property.setDeprecated(enable); - forceUpdate(); - onPropertiesUpdated && onPropertiesUpdated(); - }, - [forceUpdate, onPropertiesUpdated] - ); - - const isClipboardContainingProperties = Clipboard.has( - PROPERTIES_CLIPBOARD_KIND - ); - - const properties = eventsBasedObject.getPropertyDescriptors(); - - return ( - - {({ i18n }) => ( - - {properties.getCount() > 0 ? ( - - - - {mapVector( - properties, - (property: gdNamedPropertyDescriptor, i: number) => { - const propertyRef = - justAddedPropertyName === property.getName() - ? justAddedPropertyElement - : null; - - if ( - searchText && - !searchMatchingPropertyNames.includes( - property.getName() - ) - ) { - return null; - } - - return ( - { - draggedProperty.current = property; - return {}; - }} - canDrag={() => true} - canDrop={() => true} - drop={() => { - movePropertyBefore(property); - }} - > - {({ - connectDragSource, - connectDropTarget, - isOver, - canDrop, - }) => - connectDropTarget( -
- {isOver && } -
- {connectDragSource( - - - - - - )} - - - { - if (newName === property.getName()) - return; - - const projectScopedContainers = projectScopedContainersAccessor.get(); - const validatedNewName = getValidatedPropertyName( - properties, - projectScopedContainers, - newName - ); - onRenameProperty( - property.getName(), - validatedNewName - ); - property.setName(validatedNewName); - - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - /> - - - { - if (value === 'Hidden') { - setHidden(property, true); - setDeprecated(property, false); - setAdvanced(property, false); - } else if (value === 'Deprecated') { - setHidden(property, false); - setDeprecated(property, true); - setAdvanced(property, false); - } else if (value === 'Advanced') { - setHidden(property, false); - setDeprecated(property, false); - setAdvanced(property, true); - } else if (value === 'Visible') { - setHidden(property, false); - setDeprecated(property, false); - setAdvanced(property, false); - } - }} - fullWidth - > - - - - - - - - - - - } - buildMenuTemplate={(i18n: I18nType) => [ - { - label: i18n._(t`Add a property below`), - click: () => addPropertyAt(i + 1), - }, - { - label: i18n._(t`Delete`), - click: () => - removeProperty(property.getName()), - }, - { - label: i18n._(t`Copy`), - click: () => copyProperty(property), - }, - { - label: i18n._(t`Paste`), - click: () => - pastePropertiesBefore(property), - enabled: isClipboardContainingProperties, - }, - { - label: i18n._(t`Duplicate`), - click: () => - duplicateProperty(property, i + 1), - }, - { type: 'separator' }, - { - label: i18n._(t`Move up`), - click: () => moveProperty(i, i - 1), - enabled: i - 1 >= 0, - }, - { - label: i18n._(t`Move down`), - click: () => moveProperty(i, i + 1), - enabled: i + 1 < properties.getCount(), - }, - { - label: i18n._( - t`Generate expression and action` - ), - click: () => { - gd.PropertyFunctionGenerator.generateObjectGetterAndSetter( - project, - extension, - eventsBasedObject, - property - ); - onEventsFunctionsAdded(); - }, - enabled: gd.PropertyFunctionGenerator.canGenerateGetterAndSetter( - eventsBasedObject, - property - ), - }, - ]} - /> - -
- - - - Type} - value={property.getType()} - onChange={(e, i, value: string) => { - property.setType(value); - if (value === 'Resource') { - setExtraInfoString( - property, - 'json' - ); - } - forceUpdate(); - onPropertyTypeChanged( - property.getName() - ); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - > - - - - - - - - - - {property.getType() === 'Number' && ( - Measurement unit - } - value={property - .getMeasurementUnit() - .getName()} - onChange={(e, i, value: string) => { - property.setMeasurementUnit( - gd.MeasurementUnit.getDefaultMeasurementUnitByName( - value - ) - ); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - > - {mapFor( - 0, - gd.MeasurementUnit.getDefaultMeasurementUnitsCount(), - i => { - const measurementUnit = gd.MeasurementUnit.getDefaultMeasurementUnitAtIndex( - i - ); - const unitShortLabel = getMeasurementUnitShortLabel( - measurementUnit - ); - const label = - measurementUnit.getLabel() + - (unitShortLabel.length > 0 - ? ' — ' + unitShortLabel - : ''); - return ( - - ); - } - )} - - )} - {(property.getType() === 'String' || - property.getType() === 'Number' || - property.getType() === - 'MultilineString') && ( - Default value - } - hintText={ - property.getType() === 'Number' - ? '123' - : 'ABC' - } - value={property.getValue()} - onChange={newValue => { - property.setValue(newValue); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - multiline={ - property.getType() === - 'MultilineString' - } - fullWidth - /> - )} - {property.getType() === 'Boolean' && ( - Default value - } - value={ - property.getValue() === 'true' - ? 'true' - : 'false' - } - onChange={(e, i, value) => { - property.setValue(value); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - > - - - - )} - {property.getType() === 'Color' && ( - Color - } - disableAlpha - fullWidth - color={property.getValue()} - onChange={color => { - property.setValue(color); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - /> - )} - {property.getType() === 'Choice' && ( - Default value - } - value={property.getValue()} - onChange={(e, i, value) => { - property.setValue(value); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - > - {getChoicesArray(property).map( - (choice, index) => ( - - ) - )} - - )} - {property.getType() === 'Resource' && ( - 0 - ? property.getExtraInfo().at(0) - : '' - } - onChange={(e, i, value) => { - setExtraInfoString(property, value); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - fullWidth - /> - )} - - {property.getType() === 'Choice' && ( - - )} - - Short label - } - translatableHintText={t`Make the purpose of the property easy to understand`} - floatingLabelFixed - value={property.getLabel()} - onChange={text => { - property.setLabel(text); - forceUpdate(); - }} - fullWidth - /> - Group name - } - hintText={t`Leave it empty to use the default group`} - fullWidth - value={property.getGroup()} - onChange={text => { - property.setGroup(text); - forceUpdate(); - onPropertiesUpdated && - onPropertiesUpdated(); - }} - dataSource={getPropertyGroupNames().map( - name => ({ - text: name, - value: name, - }) - )} - openOnFocus={true} - /> - - Description - } - translatableHintText={t`Optionally, explain the purpose of the property in more details`} - floatingLabelFixed - value={property.getDescription()} - onChange={text => { - property.setDescription(text); - forceUpdate(); - }} - fullWidth - /> - - -
- ) - } -
- ); - } - )} -
-
- - - - } - label={Paste} - onClick={() => { - pastePropertiesAtTheEnd(); - }} - disabled={!isClipboardContainingProperties} - /> - {}} - onChange={text => setSearchText(text)} - placeholder={t`Search properties`} - /> - - - Add a property} - onClick={addProperty} - icon={} - /> - - - -
- ) : ( - - Add your first property} - description={ - Properties store data inside objects. - } - actionLabel={Add a property} - helpPagePath={'/behaviors/events-based-behaviors'} - helpPageAnchor={'add-and-use-properties-in-a-behavior'} - onAction={addProperty} - secondaryActionIcon={} - secondaryActionLabel={ - isClipboardContainingProperties ? Paste : null - } - onSecondaryAction={() => { - pastePropertiesAtTheEnd(); - }} - /> - - )} -
- )} -
- ); -} diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedBehaviorEditor.js b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedBehaviorEditor.js new file mode 100644 index 000000000000..1ba3fde3ebd0 --- /dev/null +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedBehaviorEditor.js @@ -0,0 +1,217 @@ +// @flow +import { Trans } from '@lingui/macro'; +import { t } from '@lingui/macro'; +import { I18n } from '@lingui/react'; + +import * as React from 'react'; +import TextField from '../../UI/TextField'; +import SemiControlledTextField from '../../UI/SemiControlledTextField'; +import ObjectTypeSelector from '../../ObjectTypeSelector'; +import DismissableAlertMessage from '../../UI/DismissableAlertMessage'; +import AlertMessage from '../../UI/AlertMessage'; +import { ColumnStackLayout } from '../../UI/Layout'; +import useForceUpdate from '../../Utils/UseForceUpdate'; +import HelpButton from '../../UI/HelpButton'; +import { Line } from '../../UI/Grid'; +import { type UnsavedChanges } from '../../MainFrame/UnsavedChangesContext'; +import Checkbox from '../../UI/Checkbox'; +import { type ExtensionItemConfigurationAttribute } from '../../EventsFunctionsExtensionEditor'; +import SelectField from '../../UI/SelectField'; +import SelectOption from '../../UI/SelectOption'; +import Window from '../../Utils/Window'; + +const gd: libGDevelop = global.gd; + +const isDev = Window.isDev(); + +type Props = {| + project: gdProject, + eventsFunctionsExtension: gdEventsFunctionsExtension, + eventsBasedBehavior: gdEventsBasedBehavior, + unsavedChanges?: ?UnsavedChanges, + onConfigurationUpdated?: (?ExtensionItemConfigurationAttribute) => void, +|}; + +export default function EventsBasedBehaviorEditor({ + project, + eventsFunctionsExtension, + eventsBasedBehavior, + unsavedChanges, + onConfigurationUpdated, +}: Props) { + const forceUpdate = useForceUpdate(); + + const onChange = React.useCallback( + () => { + if (unsavedChanges) { + unsavedChanges.triggerUnsavedChanges(); + } + forceUpdate(); + }, + [forceUpdate, unsavedChanges] + ); + + // An array containing all the object types that are using the behavior + const allObjectTypes: Array = React.useMemo( + () => + gd.WholeProjectRefactorer.getAllObjectTypesUsingEventsBasedBehavior( + project, + eventsFunctionsExtension, + eventsBasedBehavior + ) + .toNewVectorString() + .toJSArray(), + [project, eventsFunctionsExtension, eventsBasedBehavior] + ); + + return ( + + {({ i18n }) => ( + + + + This is the configuration of your behavior. Make sure to choose a + proper internal name as it's hard to change it later. Enter a + description explaining what the behavior is doing to the object. + + + Internal Name} + value={eventsBasedBehavior.getName()} + disabled + fullWidth + /> + Name displayed in editor} + value={eventsBasedBehavior.getFullName()} + onChange={text => { + eventsBasedBehavior.setFullName(text); + onChange(); + }} + fullWidth + /> + Description} + helperMarkdownText={i18n._( + t`Explain what the behavior is doing to the object. Start with a verb when possible.` + )} + value={eventsBasedBehavior.getDescription()} + onChange={text => { + eventsBasedBehavior.setDescription(text); + onChange(); + }} + multiline + fullWidth + rows={3} + /> + Object on which this behavior can be used + } + project={project} + value={eventsBasedBehavior.getObjectType()} + onChange={(objectType: string) => { + eventsBasedBehavior.setObjectType(objectType); + onChange(); + }} + allowedObjectTypes={ + allObjectTypes.length === 0 + ? undefined /* Allow anything as the behavior is not used */ + : allObjectTypes.length === 1 + ? [ + '', + allObjectTypes[0], + ] /* Allow only the type of the objects using the behavior */ + : [ + '', + ] /* More than one type of object are using the behavior. Only "any object" can be used on this behavior */ + } + /> + {allObjectTypes.length > 1 && ( + + + This behavior is being used by multiple types of objects. Thus, + you can't restrict its usage to any particular object type. All + the object types using this behavior are listed here: + {allObjectTypes.join(', ')} + + + )} + {isDev && ( + Visibility in quick customization dialog + } + value={eventsBasedBehavior.getQuickCustomizationVisibility()} + onChange={(e, i, valueString: string) => { + // $FlowFixMe + const value: QuickCustomization_Visibility = valueString; + eventsBasedBehavior.setQuickCustomizationVisibility(value); + onChange(); + }} + fullWidth + > + + + + + )} + Private} + checked={eventsBasedBehavior.isPrivate()} + onCheck={(e, checked) => { + eventsBasedBehavior.setPrivate(checked); + if (onConfigurationUpdated) onConfigurationUpdated('isPrivate'); + onChange(); + }} + tooltipOrHelperText={ + eventsBasedBehavior.isPrivate() ? ( + + This behavior won't be visible in the scene and events + editors. + + ) : ( + + This behavior will be visible in the scene and events editors. + + ) + } + /> + {eventsBasedBehavior + .getEventsFunctions() + .getEventsFunctionsCount() === 0 && ( + + + Once you're done, start adding some functions to the behavior. + Then, test the behavior by adding it to an object in a scene. + + + )} + + + + + )} + + ); +} diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedBehaviorOrObjectPropertiesEditor.js b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedBehaviorOrObjectPropertiesEditor.js new file mode 100644 index 000000000000..78a7c8f0229d --- /dev/null +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedBehaviorOrObjectPropertiesEditor.js @@ -0,0 +1,775 @@ +// @flow +import { Trans } from '@lingui/macro'; +import { t } from '@lingui/macro'; +import { I18n } from '@lingui/react'; +import * as React from 'react'; +import { Column, Line } from '../../UI/Grid'; +import SelectField from '../../UI/SelectField'; +import SelectOption from '../../UI/SelectOption'; +import { mapFor, mapVector } from '../../Utils/MapFor'; +import SemiControlledTextField from '../../UI/SemiControlledTextField'; +import newNameGenerator from '../../Utils/NewNameGenerator'; +import { ResponsiveLineStackLayout, ColumnStackLayout } from '../../UI/Layout'; +import ChoicesEditor, { type Choice } from '../../ChoicesEditor'; +import ColorField from '../../UI/ColorField'; +import BehaviorTypeSelector from '../../BehaviorTypeSelector'; +import SemiControlledAutoComplete from '../../UI/SemiControlledAutoComplete'; +import { getMeasurementUnitShortLabel } from '../../PropertiesEditor/PropertiesMapToSchema'; +import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; +import useForceUpdate from '../../Utils/UseForceUpdate'; +import Clipboard from '../../Utils/Clipboard'; +import PasteIcon from '../../UI/CustomSvgIcons/Clipboard'; +import { EmptyPlaceholder } from '../../UI/EmptyPlaceholder'; +import ResourceTypeSelectField from '../../EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/ResourceTypeSelectField'; +import { ProjectScopedContainersAccessor } from '../../InstructionOrExpression/EventsScope'; +import { + pasteProperties, + PROPERTIES_CLIPBOARD_KIND, +} from '../PropertyListEditor/EventsBasedEntityPropertyTreeViewItemContent'; +import { usePropertyOverridingAlertDialog } from '../PropertyListEditor'; + +const gd: libGDevelop = global.gd; + +const styles = { + rowContainer: { + display: 'flex', + flexDirection: 'column', + marginTop: 5, + }, + rowContent: { + display: 'flex', + flex: 1, + alignItems: 'center', + padding: '8px 0px', + }, +}; + +const setExtraInfoString = ( + property: gdNamedPropertyDescriptor, + value: string +) => { + const vectorString = new gd.VectorString(); + vectorString.push_back(value); + property.setExtraInfo(vectorString); + vectorString.delete(); +}; + +type Props = {| + project: gdProject, + projectScopedContainersAccessor: ProjectScopedContainersAccessor, + extension: gdEventsFunctionsExtension, + eventsBasedBehavior?: ?gdEventsBasedBehavior, + eventsBasedObject?: ?gdEventsBasedObject, + properties: gdPropertiesContainer, + isSharedProperties?: boolean, + onPropertiesUpdated: () => void, + onFocusProperty: (propertyName: string) => void, + onRenameProperty: (oldName: string, newName: string) => void, + onPropertyTypeChanged: (propertyName: string) => void, + onEventsFunctionsAdded: () => void, + behaviorObjectType: string, +|}; + +// Those names are used internally by GDevelop. +const PROTECTED_PROPERTY_NAMES = ['name', 'type']; + +const getValidatedPropertyName = ( + properties: gdPropertiesContainer, + projectScopedContainers: gdProjectScopedContainers, + newName: string +): string => { + const variablesContainersList = projectScopedContainers.getVariablesContainersList(); + const objectsContainersList = projectScopedContainers.getObjectsContainersList(); + const safeAndUniqueNewName = newNameGenerator( + gd.Project.getSafeName(newName), + tentativeNewName => + properties.has(tentativeNewName) || + variablesContainersList.has(tentativeNewName) || + objectsContainersList.hasObjectNamed(tentativeNewName) || + PROTECTED_PROPERTY_NAMES.includes(tentativeNewName) + ); + return safeAndUniqueNewName; +}; + +const getChoicesArray = ( + property: gdNamedPropertyDescriptor +): Array => { + return mapVector(property.getChoices(), choice => ({ + value: choice.getValue(), + label: choice.getLabel(), + })); +}; + +export type EventsBasedBehaviorPropertiesEditorInterface = {| + forceUpdate: () => void, + getPropertyEditorRef: (propertyName: string) => React.ElementRef, +|}; + +export const EventsBasedBehaviorPropertiesEditor = React.forwardRef< + Props, + EventsBasedBehaviorPropertiesEditorInterface +>( + ( + { + project, + projectScopedContainersAccessor, + extension, + eventsBasedBehavior, + eventsBasedObject, + properties, + isSharedProperties, + onPropertiesUpdated, + onFocusProperty, + onRenameProperty, + onPropertyTypeChanged, + onEventsFunctionsAdded, + behaviorObjectType, + }: Props, + ref + ) => { + const forceUpdate = useForceUpdate(); + const propertyRefs = React.useRef(new Map>()); + React.useImperativeHandle(ref, () => ({ + forceUpdate, + getPropertyEditorRef: (propertyName: string) => + propertyRefs ? propertyRefs.current.get(propertyName) : null, + })); + + const gdevelopTheme = React.useContext(GDevelopThemeContext); + + const showPropertyOverridingConfirmation = usePropertyOverridingAlertDialog(); + + const addPropertyAt = React.useCallback( + (index: number) => { + const newName = newNameGenerator('Property', name => + properties.has(name) + ); + const property = properties.insertNew(newName, index); + property.setType('Number'); + forceUpdate(); + onPropertiesUpdated && onPropertiesUpdated(); + //setJustAddedPropertyName(newName); + }, + [forceUpdate, onPropertiesUpdated, properties] + ); + + const addProperty = React.useCallback( + () => { + addPropertyAt(properties.getCount()); + }, + [addPropertyAt, properties] + ); + + const pastePropertiesAtTheEnd = React.useCallback( + async () => { + await pasteProperties( + properties, + properties.getCount(), + showPropertyOverridingConfirmation + ); + }, + [properties, showPropertyOverridingConfirmation] + ); + + const setChoices = React.useCallback( + (property: gdNamedPropertyDescriptor) => { + return (choices: Array) => { + property.clearChoices(); + for (const choice of choices) { + property.addChoice(choice.value, choice.label); + } + if ( + !getChoicesArray(property).some( + choice => choice.value === property.getValue() + ) + ) { + property.setValue(''); + } + forceUpdate(); + }; + }, + [forceUpdate] + ); + + const getPropertyGroupNames = React.useCallback( + (): Array => { + const groupNames = new Set(); + for (let i = 0; i < properties.size(); i++) { + const property = properties.at(i); + const group = property.getGroup() || ''; + groupNames.add(group); + } + return [...groupNames].sort((a, b) => a.localeCompare(b)); + }, + [properties] + ); + + const setHidden = React.useCallback( + (property: gdNamedPropertyDescriptor, enable: boolean) => { + property.setHidden(enable); + forceUpdate(); + onPropertiesUpdated && onPropertiesUpdated(); + }, + [forceUpdate, onPropertiesUpdated] + ); + + const setAdvanced = React.useCallback( + (property: gdNamedPropertyDescriptor, enable: boolean) => { + property.setAdvanced(enable); + forceUpdate(); + onPropertiesUpdated && onPropertiesUpdated(); + }, + [forceUpdate, onPropertiesUpdated] + ); + + const setDeprecated = React.useCallback( + (property: gdNamedPropertyDescriptor, enable: boolean) => { + property.setDeprecated(enable); + forceUpdate(); + onPropertiesUpdated && onPropertiesUpdated(); + }, + [forceUpdate, onPropertiesUpdated] + ); + + const isClipboardContainingProperties = Clipboard.has( + PROPERTIES_CLIPBOARD_KIND + ); + + propertyRefs.current.clear(); + + return ( + + {({ i18n }) => ( + + {properties.getCount() > 0 ? ( + + {mapVector( + properties, + (property: gdNamedPropertyDescriptor, i: number) => { + return ( + +
{ + propertyRefs.current.set(property.getName(), ref); + }} + style={{ + ...styles.rowContent, + backgroundColor: + gdevelopTheme.list.itemsBackgroundColor, + }} + > + + + + { + if (newName === property.getName()) return; + + const projectScopedContainers = projectScopedContainersAccessor.get(); + const validatedNewName = getValidatedPropertyName( + properties, + projectScopedContainers, + newName + ); + onRenameProperty( + property.getName(), + validatedNewName + ); + property.setName(validatedNewName); + + forceUpdate(); + onPropertiesUpdated && + onPropertiesUpdated(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + fullWidth + /> + + + { + if (value === 'Hidden') { + setHidden(property, true); + setDeprecated(property, false); + setAdvanced(property, false); + } else if (value === 'Deprecated') { + setHidden(property, false); + setDeprecated(property, true); + setAdvanced(property, false); + } else if (value === 'Advanced') { + setHidden(property, false); + setDeprecated(property, false); + setAdvanced(property, true); + } else if (value === 'Visible') { + setHidden(property, false); + setDeprecated(property, false); + setAdvanced(property, false); + } + }} + onFocus={() => + onFocusProperty(property.getName()) + } + fullWidth + > + + + + + + + + +
+ + + + Type} + value={property.getType()} + onChange={(e, i, value: string) => { + property.setType(value); + if (value === 'Behavior') { + property.setHidden(false); + } + if (value === 'Resource') { + setExtraInfoString(property, 'json'); + } + forceUpdate(); + onPropertyTypeChanged(property.getName()); + onPropertiesUpdated && onPropertiesUpdated(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + fullWidth + > + + + + + + {eventsBasedObject && ( + + )} + {eventsBasedBehavior && !isSharedProperties && ( + + )} + {eventsBasedBehavior && !isSharedProperties && ( + + )} + + + {eventsBasedBehavior && !isSharedProperties && ( + + )} + + {property.getType() === 'Number' && ( + Measurement unit + } + value={property + .getMeasurementUnit() + .getName()} + onChange={(e, i, value: string) => { + property.setMeasurementUnit( + gd.MeasurementUnit.getDefaultMeasurementUnitByName( + value + ) + ); + forceUpdate(); + onPropertiesUpdated && + onPropertiesUpdated(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + fullWidth + > + {mapFor( + 0, + gd.MeasurementUnit.getDefaultMeasurementUnitsCount(), + i => { + const measurementUnit = gd.MeasurementUnit.getDefaultMeasurementUnitAtIndex( + i + ); + const unitShortLabel = getMeasurementUnitShortLabel( + measurementUnit + ); + const label = + measurementUnit.getLabel() + + (unitShortLabel.length > 0 + ? ' — ' + unitShortLabel + : ''); + return ( + + ); + } + )} + + )} + {(property.getType() === 'String' || + property.getType() === 'Number' || + property.getType() === 'ObjectAnimationName' || + property.getType() === 'KeyboardKey' || + property.getType() === 'MultilineString') && ( + Default value + } + hintText={ + property.getType() === 'Number' + ? '123' + : 'ABC' + } + value={property.getValue()} + onChange={newValue => { + property.setValue(newValue); + forceUpdate(); + onPropertiesUpdated && + onPropertiesUpdated(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + multiline={ + property.getType() === 'MultilineString' + } + fullWidth + /> + )} + {property.getType() === 'Boolean' && ( + Default value + } + value={ + property.getValue() === 'true' + ? 'true' + : 'false' + } + onChange={(e, i, value) => { + property.setValue(value); + forceUpdate(); + onPropertiesUpdated && + onPropertiesUpdated(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + fullWidth + > + + + + )} + {property.getType() === 'Behavior' && ( + { + // Change the type of the required behavior. + const extraInfo = property.getExtraInfo(); + if (extraInfo.size() === 0) { + extraInfo.push_back(newValue); + } else { + extraInfo.set(0, newValue); + } + const behaviorMetadata = gd.MetadataProvider.getBehaviorMetadata( + project.getCurrentPlatform(), + newValue + ); + const projectScopedContainers = projectScopedContainersAccessor.get(); + const validatedNewName = getValidatedPropertyName( + properties, + projectScopedContainers, + behaviorMetadata.getDefaultName() + ); + property.setName(validatedNewName); + property.setLabel( + behaviorMetadata.getFullName() + ); + forceUpdate(); + onPropertiesUpdated && + onPropertiesUpdated(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + disabled={false} + /> + )} + {property.getType() === 'Color' && ( + Default value + } + disableAlpha + fullWidth + color={property.getValue()} + onChange={color => { + property.setValue(color); + forceUpdate(); + onPropertiesUpdated && + onPropertiesUpdated(); + }} + /> + )} + {property.getType() === 'Resource' && ( + 0 + ? property.getExtraInfo().at(0) + : '' + } + onChange={(e, i, value) => { + setExtraInfoString(property, value); + forceUpdate(); + onPropertiesUpdated && + onPropertiesUpdated(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + fullWidth + /> + )} + {property.getType() === 'Choice' && ( + Default value + } + value={property.getValue()} + onChange={(e, i, value) => { + property.setValue(value); + forceUpdate(); + onPropertiesUpdated && + onPropertiesUpdated(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + fullWidth + > + {getChoicesArray(property).map( + (choice, index) => ( + + ) + )} + + )} + + {property.getType() === 'Choice' && ( + + )} + + Short label} + translatableHintText={t`Make the purpose of the property easy to understand`} + floatingLabelFixed + value={property.getLabel()} + onChange={text => { + property.setLabel(text); + forceUpdate(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + fullWidth + /> + Group name} + hintText={t`Leave it empty to use the default group`} + fullWidth + value={property.getGroup()} + onChange={text => { + property.setGroup(text); + forceUpdate(); + onPropertiesUpdated && onPropertiesUpdated(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + dataSource={getPropertyGroupNames().map( + name => ({ + text: name, + value: name, + }) + )} + openOnFocus={true} + /> + + Description} + translatableHintText={t`Optionally, explain the purpose of the property in more details`} + floatingLabelFixed + value={property.getDescription()} + onChange={text => { + property.setDescription(text); + forceUpdate(); + }} + onFocus={() => + onFocusProperty(property.getName()) + } + fullWidth + /> + + +
+ ); + } + )} +
+ ) : ( + + Add your first property} + description={ + eventsBasedObject ? ( + Properties store data inside objects. + ) : ( + Properties store data inside behaviors. + ) + } + actionLabel={Add a property} + helpPagePath={'/behaviors/events-based-behaviors'} + helpPageAnchor={'add-and-use-properties-in-a-behavior'} + onAction={addProperty} + secondaryActionIcon={} + secondaryActionLabel={ + isClipboardContainingProperties ? ( + Paste + ) : null + } + onSecondaryAction={() => { + pastePropertiesAtTheEnd(); + }} + /> + + )} +
+ )} +
+ ); + } +); diff --git a/newIDE/app/src/EventsBasedObjectEditor/index.js b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedObjectEditor.js similarity index 89% rename from newIDE/app/src/EventsBasedObjectEditor/index.js rename to newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedObjectEditor.js index a52bd1f217cf..bc886c365ebc 100644 --- a/newIDE/app/src/EventsBasedObjectEditor/index.js +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedObjectEditor.js @@ -3,19 +3,19 @@ import { Trans } from '@lingui/macro'; import { t } from '@lingui/macro'; import * as React from 'react'; -import TextField from '../UI/TextField'; -import SemiControlledTextField from '../UI/SemiControlledTextField'; -import DismissableAlertMessage from '../UI/DismissableAlertMessage'; -import AlertMessage from '../UI/AlertMessage'; -import { ColumnStackLayout } from '../UI/Layout'; -import useForceUpdate from '../Utils/UseForceUpdate'; -import Checkbox from '../UI/Checkbox'; -import HelpButton from '../UI/HelpButton'; -import { Line } from '../UI/Grid'; -import { type UnsavedChanges } from '../MainFrame/UnsavedChangesContext'; -import RaisedButton from '../UI/RaisedButton'; -import Window from '../Utils/Window'; -import ScrollView from '../UI/ScrollView'; +import TextField from '../../UI/TextField'; +import SemiControlledTextField from '../../UI/SemiControlledTextField'; +import DismissableAlertMessage from '../../UI/DismissableAlertMessage'; +import AlertMessage from '../../UI/AlertMessage'; +import { ColumnStackLayout } from '../../UI/Layout'; +import useForceUpdate from '../../Utils/UseForceUpdate'; +import Checkbox from '../../UI/Checkbox'; +import HelpButton from '../../UI/HelpButton'; +import { Line } from '../../UI/Grid'; +import { type UnsavedChanges } from '../../MainFrame/UnsavedChangesContext'; +import RaisedButton from '../../UI/RaisedButton'; +import Window from '../../Utils/Window'; +import ScrollView from '../../UI/ScrollView'; const gd: libGDevelop = global.gd; diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/index.js b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/index.js new file mode 100644 index 000000000000..b4cd4ab4ca85 --- /dev/null +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/index.js @@ -0,0 +1,259 @@ +// @flow +import { Trans } from '@lingui/macro'; +import * as React from 'react'; +import EventsBasedBehaviorEditor from './EventsBasedBehaviorEditor'; +import { + EventsBasedBehaviorPropertiesEditor, + type EventsBasedBehaviorPropertiesEditorInterface, +} from './EventsBasedBehaviorOrObjectPropertiesEditor'; +import Background from '../../UI/Background'; +import { type UnsavedChanges } from '../../MainFrame/UnsavedChangesContext'; +import { type ExtensionItemConfigurationAttribute } from '../../EventsFunctionsExtensionEditor'; +import { ProjectScopedContainersAccessor } from '../../InstructionOrExpression/EventsScope'; +import Text from '../../UI/Text'; +import { ColumnStackLayout } from '../../UI/Layout'; +import { Column, Line } from '../../UI/Grid'; +import ScrollView, { type ScrollViewInterface } from '../../UI/ScrollView'; +import EventsBasedObjectEditor from './EventsBasedObjectEditor'; +import RaisedButton from '../../UI/RaisedButton'; +import AddIcon from '../../UI/CustomSvgIcons/Add'; +import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; +import useForceUpdate from '../../Utils/UseForceUpdate'; +import newNameGenerator from '../../Utils/NewNameGenerator'; + +type Props = {| + project: gdProject, + projectScopedContainersAccessor: ProjectScopedContainersAccessor, + eventsFunctionsExtension: gdEventsFunctionsExtension, + eventsBasedBehavior?: ?gdEventsBasedBehavior, + eventsBasedObject?: ?gdEventsBasedObject, + onRenameProperty: (oldName: string, newName: string) => void, + onRenameSharedProperty: (oldName: string, newName: string) => void, + onPropertyTypeChanged: (propertyName: string) => void, + onFocusProperty: (propertyName: string, isSharedProperties: boolean) => void, + onPropertiesUpdated: () => void, + onEventsFunctionsAdded: () => void, + unsavedChanges?: ?UnsavedChanges, + onConfigurationUpdated?: (?ExtensionItemConfigurationAttribute) => void, + onOpenCustomObjectEditor: () => void, + onEventsBasedObjectChildrenEdited: ( + eventsBasedObject: gdEventsBasedObject + ) => void, +|}; + +export type EventsBasedBehaviorOrObjectEditorInterface = {| + forceUpdateProperties: () => void, + scrollToConfiguration: () => void, + scrollToProperty: (propertyName: string, isSharedProperties: boolean) => void, +|}; + +export const EventsBasedBehaviorOrObjectEditor = React.forwardRef< + Props, + EventsBasedBehaviorOrObjectEditorInterface +>( + ( + { + eventsBasedBehavior, + eventsBasedObject, + eventsFunctionsExtension, + project, + projectScopedContainersAccessor, + onRenameProperty, + onRenameSharedProperty, + onPropertyTypeChanged, + unsavedChanges, + onEventsFunctionsAdded, + onConfigurationUpdated, + onPropertiesUpdated, + onFocusProperty, + onOpenCustomObjectEditor, + onEventsBasedObjectChildrenEdited, + }: Props, + ref + ) => { + const forceUpdate = useForceUpdate(); + + const _onPropertiesUpdated = React.useCallback( + () => { + if (unsavedChanges) { + unsavedChanges.triggerUnsavedChanges(); + } + onPropertiesUpdated(); + }, + [onPropertiesUpdated, unsavedChanges] + ); + + const scrollView = React.useRef(null); + const propertiesEditor = React.useRef( + null + ); + const scenePropertiesEditor = React.useRef( + null + ); + + const scrollToProperty = React.useCallback( + (propertyName: string, isSharedProperties: boolean) => { + if (!scrollView.current) { + return; + } + if (isSharedProperties) { + if (scenePropertiesEditor.current) { + scrollView.current.scrollTo( + scenePropertiesEditor.current.getPropertyEditorRef(propertyName) + ); + } + } else { + if (propertiesEditor.current) { + scrollView.current.scrollTo( + propertiesEditor.current.getPropertyEditorRef(propertyName) + ); + } + } + }, + [] + ); + + React.useImperativeHandle(ref, () => ({ + forceUpdateProperties: () => { + if (propertiesEditor.current) { + propertiesEditor.current.forceUpdate(); + } + if (scenePropertiesEditor.current) { + scenePropertiesEditor.current.forceUpdate(); + } + }, + scrollToConfiguration: () => { + if (scrollView.current) { + scrollView.current.scrollToPosition(0); + } + }, + scrollToProperty, + })); + + const eventsBasedEntity = eventsBasedBehavior || eventsBasedObject; + + const addProperty = React.useCallback( + () => { + if (!eventsBasedEntity) { + return; + } + const properties = eventsBasedEntity.getPropertyDescriptors(); + const newName = newNameGenerator('Property', name => + properties.has(name) + ); + const property = properties.insertNew(newName, properties.getCount()); + property.setType('Number'); + forceUpdate(); + onPropertiesUpdated && onPropertiesUpdated(); + + // Scroll to the selected property. + // Ideally, we'd wait for the list to be updated to scroll, but + // to simplify the code, we just wait a few ms for a new render + // to be done. + setTimeout(() => { + scrollToProperty(newName, false); + }, 100); // A few ms is enough for a new render to be done. + }, + [eventsBasedEntity, forceUpdate, onPropertiesUpdated, scrollToProperty] + ); + + const { windowSize } = useResponsiveWindowSize(); + + return ( + + + + {eventsBasedBehavior ? ( + + ) : eventsBasedObject ? ( + + ) : null} + + {eventsBasedObject ? ( + Object properties + ) : ( + Behavior properties + )} + + {eventsBasedEntity && ( + + onFocusProperty(propertyName, false) + } + onPropertyTypeChanged={onPropertyTypeChanged} + onEventsFunctionsAdded={onEventsFunctionsAdded} + /> + )} + {eventsBasedBehavior && ( + + Scene properties + + )} + {eventsBasedBehavior && ( + + onFocusProperty(propertyName, true) + } + onPropertyTypeChanged={onPropertyTypeChanged} + onEventsFunctionsAdded={onEventsFunctionsAdded} + /> + )} + + + {windowSize === 'small' && ( + + + Add a property} + onClick={addProperty} + icon={} + /> + + + )} + + ); + } +); diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/ResourceTypeSelectField.js b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/ResourceTypeSelectField.js index 35a73ffec174..b6c4de72c4ce 100644 --- a/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/ResourceTypeSelectField.js +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/ResourceTypeSelectField.js @@ -14,12 +14,14 @@ type Props = {| index: number, text: string ) => void, + onFocus?: (event: SyntheticFocusEvent) => void, fullWidth?: boolean, |}; export default function ResourceTypeSelectField({ value, onChange, + onFocus, fullWidth, }: Props) { return ( @@ -29,6 +31,7 @@ export default function ResourceTypeSelectField({ floatingLabelText={Resource type} value={value} onChange={onChange} + onFocus={onFocus} fullWidth={fullWidth} > {allResourceKindsAndMetadata.map(({ kind, displayName }) => ( diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/PropertyListEditor/EventsBasedEntityPropertyTreeViewItemContent.js b/newIDE/app/src/EventsFunctionsExtensionEditor/PropertyListEditor/EventsBasedEntityPropertyTreeViewItemContent.js new file mode 100644 index 000000000000..2f51657b6058 --- /dev/null +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/PropertyListEditor/EventsBasedEntityPropertyTreeViewItemContent.js @@ -0,0 +1,428 @@ +// @flow +import { type I18n as I18nType } from '@lingui/core'; +import { t } from '@lingui/macro'; +import { Trans } from '@lingui/macro'; + +import * as React from 'react'; +import newNameGenerator from '../../Utils/NewNameGenerator'; +import Clipboard from '../../Utils/Clipboard'; +import { SafeExtractor } from '../../Utils/SafeExtractor'; +import { + serializeToJSObject, + unserializeFromJSObject, +} from '../../Utils/Serializer'; +import { + TreeViewItemContent, + type TreeItemProps, + propertiesRootFolderId, + sharedPropertiesRootFolderId, +} from '.'; +import Tooltip from '@material-ui/core/Tooltip'; +import { type HTMLDataset } from '../../Utils/HTMLDataset'; +import VisibilityOffIcon from '../../UI/CustomSvgIcons/VisibilityOff'; +import { renderQuickCustomizationMenuItems } from '../../QuickCustomization/QuickCustomizationMenuItems'; +import { ProjectScopedContainersAccessor } from '../../InstructionOrExpression/EventsScope'; + +const gd: libGDevelop = global.gd; + +export const PROPERTIES_CLIPBOARD_KIND = 'Properties'; + +const styles = { + tooltip: { marginRight: 5, verticalAlign: 'bottom' }, +}; + +// Those names are used internally by GDevelop. +const PROTECTED_PROPERTY_NAMES = ['name', 'type']; + +const getValidatedPropertyName = ( + properties: gdPropertiesContainer, + projectScopedContainers: gdProjectScopedContainers, + newName: string +): string => { + const variablesContainersList = projectScopedContainers.getVariablesContainersList(); + const objectsContainersList = projectScopedContainers.getObjectsContainersList(); + const safeAndUniqueNewName = newNameGenerator( + gd.Project.getSafeName(newName), + tentativeNewName => + properties.has(tentativeNewName) || + variablesContainersList.has(tentativeNewName) || + objectsContainersList.hasObjectNamed(tentativeNewName) || + PROTECTED_PROPERTY_NAMES.includes(tentativeNewName) + ); + return safeAndUniqueNewName; +}; + +export const pasteProperties = async ( + properties: gdPropertiesContainer, + insertionIndex: number, + showPropertyOverridingConfirmation: ( + existingPropertyNames: string[] + ) => Promise +): Promise => { + if (!Clipboard.has(PROPERTIES_CLIPBOARD_KIND)) return false; + + const clipboardContent = Clipboard.get(PROPERTIES_CLIPBOARD_KIND); + const propertyContents = SafeExtractor.extractArray(clipboardContent); + if (!propertyContents) return false; + + const newNamedProperties: Array<{ + name: string, + serializedProperty: string, + }> = []; + const existingNamedProperties: Array<{ + name: string, + serializedProperty: string, + }> = []; + propertyContents.forEach(propertyContent => { + const name = SafeExtractor.extractStringProperty(propertyContent, 'name'); + const serializedProperty = SafeExtractor.extractObjectProperty( + propertyContent, + 'serializedProperty' + ); + if (!name || !serializedProperty) { + return false; + } + + if (properties.has(name)) { + existingNamedProperties.push({ name, serializedProperty }); + } else { + newNamedProperties.push({ name, serializedProperty }); + } + }); + + let firstAddedPropertyName: string | null = null; + let index = insertionIndex; + newNamedProperties.forEach(({ name, serializedProperty }) => { + const property = properties.insertNew(name, index); + index++; + unserializeFromJSObject(property, serializedProperty); + if (!firstAddedPropertyName) { + firstAddedPropertyName = name; + } + }); + + let shouldOverrideProperties = false; + if (existingNamedProperties.length > 0) { + shouldOverrideProperties = await showPropertyOverridingConfirmation( + existingNamedProperties.map(namedProperty => namedProperty.name) + ); + + if (shouldOverrideProperties) { + existingNamedProperties.forEach(({ name, serializedProperty }) => { + if (properties.has(name)) { + const property = properties.get(name); + unserializeFromJSObject(property, serializedProperty); + } + }); + } + } + return true; +}; + +export type EventsBasedEntityPropertyTreeViewItemProps = {| + ...TreeItemProps, + project: gdProject, + projectScopedContainersAccessor: ProjectScopedContainersAccessor, + extension: gdEventsFunctionsExtension, + eventsBasedEntity: gdAbstractEventsBasedEntity, + eventsBasedBehavior: ?gdEventsBasedBehavior, + eventsBasedObject: ?gdEventsBasedObject, + properties: gdPropertiesContainer, + isSharedProperties: boolean, + onOpenProperty: (name: string, isSharedProperties: boolean) => void, + onRenameProperty: (newName: string, oldName: string) => void, + showPropertyOverridingConfirmation: ( + existingPropertyNames: string[] + ) => Promise, + onPropertiesUpdated: () => void, + onEventsFunctionsAdded: () => void, +|}; + +export const getEventsBasedEntityPropertyTreeViewItemId = ( + property: gdNamedPropertyDescriptor, + isSharedProperties: boolean +): string => { + // Pointers are used because they stay the same even when the names are + // changed. + return `${isSharedProperties ? 'shared-property' : 'property'}-${ + property.ptr + }`; +}; + +export class EventsBasedEntityPropertyTreeViewItemContent + implements TreeViewItemContent { + property: gdNamedPropertyDescriptor; + props: EventsBasedEntityPropertyTreeViewItemProps; + + constructor( + property: gdNamedPropertyDescriptor, + props: EventsBasedEntityPropertyTreeViewItemProps + ) { + this.property = property; + this.props = props; + } + + isDescendantOf(itemContent: TreeViewItemContent): boolean { + return itemContent.getId() === this.getRootId(); + } + + getRootId(): string { + return this.props.isSharedProperties + ? sharedPropertiesRootFolderId + : propertiesRootFolderId; + } + + getName(): string | React.Node { + return this.property.getName(); + } + + getId(): string { + return getEventsBasedEntityPropertyTreeViewItemId( + this.property, + this.props.isSharedProperties + ); + } + + getHtmlId(index: number): ?string { + return `${ + this.props.isSharedProperties ? 'shared-property' : 'property' + }-item-${index}`; + } + + getDataSet(): ?HTMLDataset { + return { + propertyName: this.property.getName(), + isSharedProperties: this.props.isSharedProperties ? 'true' : 'false', + }; + } + + getThumbnail(): ?string { + switch (this.property.getType()) { + case 'Number': + return 'res/functions/number_black.svg'; + case 'Boolean': + return 'res/functions/boolean_black.svg'; + case 'Behavior': + return 'res/functions/behavior_black.svg'; + default: + return 'res/functions/string_black.svg'; + } + } + + onClick(): void { + this.props.onOpenProperty( + this.property.getName(), + this.props.isSharedProperties + ); + } + + rename(newName: string): void { + const oldName = this.property.getName(); + if (oldName === newName) { + return; + } + this.props.onRenameProperty(oldName, newName); + + const projectScopedContainers = this.props.projectScopedContainersAccessor.get(); + const validatedNewName = getValidatedPropertyName( + this.props.properties, + projectScopedContainers, + newName + ); + this.props.onRenameProperty(oldName, validatedNewName); + this.property.setName(validatedNewName); + + this._onProjectItemModified(); + } + + edit(): void { + this.props.editName(this.getId()); + } + + buildMenuTemplate(i18n: I18nType, index: number) { + return [ + { + label: i18n._(t`Rename`), + click: () => this.edit(), + accelerator: 'F2', + }, + { + label: i18n._(t`Delete`), + click: () => this.delete(), + accelerator: 'Backspace', + }, + { + type: 'separator', + }, + { + label: i18n._(t`Copy`), + click: () => this.copy(), + accelerator: 'CmdOrCtrl+C', + }, + { + label: i18n._(t`Cut`), + click: () => this.cut(), + accelerator: 'CmdOrCtrl+X', + }, + { + label: i18n._(t`Paste`), + enabled: Clipboard.has(PROPERTIES_CLIPBOARD_KIND), + click: () => this.paste(), + accelerator: 'CmdOrCtrl+V', + }, + { + label: i18n._(t`Duplicate`), + click: () => this._duplicate(), + }, + { + type: 'separator', + }, + { + label: i18n._(t`Generate expression and action`), + click: () => { + const { + project, + extension, + eventsBasedBehavior, + eventsBasedObject, + isSharedProperties, + onEventsFunctionsAdded, + } = this.props; + if (eventsBasedBehavior) { + gd.PropertyFunctionGenerator.generateBehaviorGetterAndSetter( + project, + extension, + eventsBasedBehavior, + this.property, + isSharedProperties + ); + } else if (eventsBasedObject) { + gd.PropertyFunctionGenerator.generateObjectGetterAndSetter( + project, + extension, + eventsBasedObject, + this.property + ); + } + onEventsFunctionsAdded(); + }, + enabled: gd.PropertyFunctionGenerator.canGenerateGetterAndSetter( + this.props.eventsBasedEntity, + this.property + ), + }, + ...renderQuickCustomizationMenuItems({ + i18n, + visibility: this.property.getQuickCustomizationVisibility(), + onChangeVisibility: visibility => { + this.property.setQuickCustomizationVisibility(visibility); + this.props.forceUpdate(); + this.props.onPropertiesUpdated(); + }, + }), + ]; + } + + renderRightComponent(i18n: I18nType): ?React.Node { + const icons = []; + if (this.property.isHidden()) { + icons.push( + This property won't be visible in the editor.} + > + + + ); + } + return icons.length > 0 ? icons : null; + } + + delete(): void { + this.props.properties.remove(this.property.getName()); + this._onProjectItemModified(); + } + + getIndex(): number { + return this.props.properties.getPosition(this.property); + } + + moveAt(destinationIndex: number): void { + const originIndex = this.getIndex(); + if (destinationIndex !== originIndex) { + this.props.properties.move( + originIndex, + // When moving the item down, it must not be counted. + destinationIndex + (destinationIndex <= originIndex ? 0 : -1) + ); + this._onProjectItemModified(); + } + } + + copy(): void { + Clipboard.set(PROPERTIES_CLIPBOARD_KIND, [ + { + name: this.property.getName(), + serializedProperty: serializeToJSObject(this.property), + }, + ]); + } + + cut(): void { + this.copy(); + this.delete(); + } + + paste(): void { + this.pasteAsync(); + } + + async pasteAsync(): Promise { + const hasPasteAnyProperty = await pasteProperties( + this.props.properties, + this.getIndex() + 1, + this.props.showPropertyOverridingConfirmation + ); + if (hasPasteAnyProperty) { + this._onProjectItemModified(); + } + } + + _duplicate(): void { + const newName = newNameGenerator(this.property.getName(), name => + this.props.properties.has(name) + ); + const newProperty = this.props.properties.insertNew( + newName, + this.getIndex() + 1 + ); + + unserializeFromJSObject(newProperty, serializeToJSObject(this.property)); + newProperty.setName(newName); + + this._onProjectItemModified(); + this.props.editName( + getEventsBasedEntityPropertyTreeViewItemId( + newProperty, + this.props.isSharedProperties + ) + ); + } + + _onProjectItemModified() { + if (this.props.unsavedChanges) + this.props.unsavedChanges.triggerUnsavedChanges(); + this.props.forceUpdate(); + this.props.onPropertiesUpdated(); + } + + getRightButton(i18n: I18nType) { + return null; + } +} diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/PropertyListEditor/index.js b/newIDE/app/src/EventsFunctionsExtensionEditor/PropertyListEditor/index.js new file mode 100644 index 000000000000..3f01c6010759 --- /dev/null +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/PropertyListEditor/index.js @@ -0,0 +1,938 @@ +// @flow +import { Trans } from '@lingui/macro'; +import { I18n } from '@lingui/react'; +import { type I18n as I18nType } from '@lingui/core'; +import { t } from '@lingui/macro'; + +import * as React from 'react'; +import SearchBar, { type SearchBarInterface } from '../../UI/SearchBar'; +import newNameGenerator from '../../Utils/NewNameGenerator'; +import UnsavedChangesContext, { + type UnsavedChanges, +} from '../../MainFrame/UnsavedChangesContext'; +import ErrorBoundary from '../../UI/ErrorBoundary'; +import useForceUpdate from '../../Utils/UseForceUpdate'; + +import { AutoSizer } from 'react-virtualized'; +import Background from '../../UI/Background'; +import TreeView, { + type TreeViewInterface, + type MenuButton, +} from '../../UI/TreeView'; +import PreferencesContext, { + type Preferences, +} from '../../MainFrame/Preferences/PreferencesContext'; +import { Column, Line } from '../../UI/Grid'; +import Add from '../../UI/CustomSvgIcons/Add'; +import InAppTutorialContext from '../../InAppTutorial/InAppTutorialContext'; +import { mapFor } from '../../Utils/MapFor'; +import KeyboardShortcuts from '../../UI/KeyboardShortcuts'; +import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; +import { + EventsBasedEntityPropertyTreeViewItemContent, + getEventsBasedEntityPropertyTreeViewItemId, + type EventsBasedEntityPropertyTreeViewItemProps, +} from './EventsBasedEntityPropertyTreeViewItemContent'; +import { type MenuItemTemplate } from '../../UI/Menu/Menu.flow'; +import useAlertDialog from '../../UI/Alert/useAlertDialog'; +import { type ShowConfirmDeleteDialogOptions } from '../../UI/Alert/AlertContext'; +import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; +import { type GDevelopTheme } from '../../UI/Theme'; +import { type HTMLDataset } from '../../Utils/HTMLDataset'; +import { ColumnStackLayout } from '../../UI/Layout'; +import { ProjectScopedContainersAccessor } from '../../InstructionOrExpression/EventsScope'; + +const configurationItemId = 'events-based-entity-configuration'; +export const propertiesRootFolderId = 'properties'; +export const sharedPropertiesRootFolderId = 'shared-properties'; + +const propertiesEmptyPlaceholderId = 'properties-placeholder'; +const sharedPropertiesEmptyPlaceholderId = 'shared-properties-placeholder'; + +const styles = { + listContainer: { + flex: 1, + display: 'flex', + flexDirection: 'column', + padding: '0 8px 8px 8px', + }, + autoSizerContainer: { flex: 1 }, + autoSizer: { width: '100%' }, +}; + +const extensionItemReactDndType = 'GD_EXTENSION_ITEM'; + +export interface TreeViewItemContent { + getName(): string | React.Node; + getId(): string; + getHtmlId(index: number): ?string; + getDataSet(): ?HTMLDataset; + getThumbnail(): ?string; + onClick(): void; + buildMenuTemplate(i18n: I18nType, index: number): Array; + getRightButton(i18n: I18nType): ?MenuButton; + renderRightComponent(i18n: I18nType): ?React.Node; + rename(newName: string): void; + edit(): void; + delete(): void; + copy(): void; + paste(): void; + cut(): void; + getIndex(): number; + moveAt(destinationIndex: number): void; + isDescendantOf(itemContent: TreeViewItemContent): boolean; + getRootId(): string; +} + +interface TreeViewItem { + isRoot?: boolean; + isPlaceholder?: boolean; + +content: TreeViewItemContent; + getChildren(i18n: I18nType): ?Array; +} + +export type TreeItemProps = {| + forceUpdate: () => void, + forceUpdateList: () => void, + unsavedChanges?: ?UnsavedChanges, + preferences: Preferences, + gdevelopTheme: GDevelopTheme, + editName: (itemId: string) => void, + scrollToItem: (itemId: string) => void, + showDeleteConfirmation: ( + options: ShowConfirmDeleteDialogOptions + ) => Promise, +|}; + +class LeafTreeViewItem implements TreeViewItem { + content: TreeViewItemContent; + + constructor(content: TreeViewItemContent) { + this.content = content; + } + + getChildren(i18n: I18nType): ?Array { + return null; + } +} + +class PlaceHolderTreeViewItem implements TreeViewItem { + isPlaceholder = true; + content: TreeViewItemContent; + + constructor(id: string, label: string | React.Node) { + this.content = new LabelTreeViewItemContent(id, label); + } + + getChildren(i18n: I18nType): ?Array { + return null; + } +} + +class LabelTreeViewItemContent implements TreeViewItemContent { + id: string; + label: string | React.Node; + dataSet: { [string]: string }; + buildMenuTemplateFunction: ( + i18n: I18nType, + index: number + ) => Array; + rightButton: ?MenuButton; + + constructor( + id: string, + label: string | React.Node, + rightButton?: MenuButton + ) { + this.id = id; + this.label = label; + this.buildMenuTemplateFunction = (i18n: I18nType, index: number) => + rightButton + ? [ + { + id: rightButton.id, + label: rightButton.label, + click: rightButton.click, + }, + ] + : []; + this.rightButton = rightButton; + } + + getName(): string | React.Node { + return this.label; + } + + getId(): string { + return this.id; + } + + getRightButton(i18n: I18nType): ?MenuButton { + return this.rightButton; + } + + getHtmlId(index: number): ?string { + return this.id; + } + + getDataSet(): ?HTMLDataset { + return null; + } + + getThumbnail(): ?string { + return null; + } + + onClick(): void {} + + buildMenuTemplate(i18n: I18nType, index: number) { + return this.buildMenuTemplateFunction(i18n, index); + } + + renderRightComponent(i18n: I18nType): ?React.Node { + return null; + } + + rename(newName: string): void {} + + edit(): void {} + + delete(): void {} + + copy(): void {} + + paste(): void {} + + cut(): void {} + + getIndex(): number { + return 0; + } + + moveAt(destinationIndex: number): void {} + + isDescendantOf(itemContent: TreeViewItemContent): boolean { + return false; + } + + getRootId(): string { + return ''; + } +} + +class ActionTreeViewItemContent implements TreeViewItemContent { + id: string; + label: string | React.Node; + buildMenuTemplateFunction: ( + i18n: I18nType, + index: number + ) => Array; + thumbnail: ?string; + onClickCallback: () => void; + + constructor( + id: string, + label: string | React.Node, + onClickCallback: () => void, + thumbnail?: string + ) { + this.id = id; + this.label = label; + this.onClickCallback = onClickCallback; + this.thumbnail = thumbnail; + this.buildMenuTemplateFunction = (i18n: I18nType, index: number) => []; + } + + getName(): string | React.Node { + return this.label; + } + + getId(): string { + return this.id; + } + + getRightButton(i18n: I18nType): ?MenuButton { + return null; + } + + getEventsFunctionsContainer(): ?gdEventsFunctionsContainer { + return null; + } + + getHtmlId(index: number): ?string { + return this.id; + } + + getDataSet(): ?HTMLDataset { + return null; + } + + getThumbnail(): ?string { + return this.thumbnail; + } + + onClick(): void { + this.onClickCallback(); + } + + buildMenuTemplate(i18n: I18nType, index: number) { + return this.buildMenuTemplateFunction(i18n, index); + } + + renderRightComponent(i18n: I18nType): ?React.Node { + return null; + } + + rename(newName: string): void {} + + edit(): void {} + + delete(): void {} + + copy(): void {} + + paste(): void {} + + cut(): void {} + + getIndex(): number { + return 0; + } + + moveAt(destinationIndex: number): void {} + + isDescendantOf(itemContent: TreeViewItemContent): boolean { + return false; + } + + getRootId(): string { + return ''; + } +} + +const getTreeViewItemName = (item: TreeViewItem) => item.content.getName(); +const getTreeViewItemId = (item: TreeViewItem) => item.content.getId(); +const getTreeViewItemHtmlId = (item: TreeViewItem, index: number) => + item.content.getHtmlId(index); +const getTreeViewItemChildren = (i18n: I18nType) => (item: TreeViewItem) => + item.getChildren(i18n); +const getTreeViewItemThumbnail = (item: TreeViewItem) => + item.content.getThumbnail(); +const getTreeViewItemDataSet = (item: TreeViewItem) => + item.content.getDataSet(); +const buildMenuTemplate = (i18n: I18nType) => ( + item: TreeViewItem, + index: number +) => item.content.buildMenuTemplate(i18n, index); +const renderTreeViewItemRightComponent = (i18n: I18nType) => ( + item: TreeViewItem +) => item.content.renderRightComponent(i18n); +const renameItem = (item: TreeViewItem, newName: string) => { + item.content.rename(newName); +}; +const onClickItem = (item: TreeViewItem) => { + item.content.onClick(); +}; +const editItem = (item: TreeViewItem) => { + item.content.edit(); +}; +const deleteItem = (item: TreeViewItem) => { + item.content.delete(); +}; +const getTreeViewItemRightButton = (i18n: I18nType) => (item: TreeViewItem) => + item.content.getRightButton(i18n); + +export const usePropertyOverridingAlertDialog = () => { + const { showConfirmation } = useAlertDialog(); + return async (existingPropertyNames: Array): Promise => { + return await showConfirmation({ + title: t`Existing properties`, + message: t`These properties already exist:${'\n\n - ' + + existingPropertyNames.join('\n\n - ') + + '\n\n'}Do you want to replace them?`, + confirmButtonLabel: t`Replace`, + dismissButtonLabel: t`Omit`, + }); + }; +}; + +export type PropertyListEditorInterface = {| + forceUpdateList: () => void, + focusSearchBar: () => void, + setSelectedProperty: ( + propertyName: string, + isSharedProperties: boolean + ) => void, + getSelectedProperty: () => {| + propertyName: string, + isSharedProperties: boolean, + |} | null, +|}; + +type Props = {| + project: gdProject, + projectScopedContainersAccessor: ProjectScopedContainersAccessor, + extension: gdEventsFunctionsExtension, + eventsBasedBehavior: ?gdEventsBasedBehavior, + eventsBasedObject: ?gdEventsBasedObject, + onPropertiesUpdated: () => void, + onRenameProperty: (oldName: string, newName: string) => void, + onOpenConfiguration: () => void, + onOpenProperty: (name: string, isSharedProperties: boolean) => void, + onEventsFunctionsAdded: () => void, +|}; + +const PropertyListEditor = React.forwardRef( + ( + { + project, + projectScopedContainersAccessor, + extension, + eventsBasedBehavior, + eventsBasedObject, + onPropertiesUpdated, + onRenameProperty, + onOpenConfiguration, + onOpenProperty, + onEventsFunctionsAdded, + }, + ref + ) => { + const [selectedItems, setSelectedItems] = React.useState< + Array + >([]); + const unsavedChanges = React.useContext(UnsavedChangesContext); + const { triggerUnsavedChanges } = unsavedChanges; + const preferences = React.useContext(PreferencesContext); + const gdevelopTheme = React.useContext(GDevelopThemeContext); + const { currentlyRunningInAppTutorial } = React.useContext( + InAppTutorialContext + ); + const treeViewRef = React.useRef>(null); + const forceUpdate = useForceUpdate(); + const { isMobile } = useResponsiveWindowSize(); + const { showDeleteConfirmation } = useAlertDialog(); + const showPropertyOverridingConfirmation = usePropertyOverridingAlertDialog(); + + const forceUpdateList = React.useCallback( + () => { + forceUpdate(); + if (treeViewRef.current) treeViewRef.current.forceUpdateList(); + }, + [forceUpdate] + ); + + const [searchText, setSearchText] = React.useState(''); + + const scrollToItem = React.useCallback((itemId: string) => { + if (treeViewRef.current) { + treeViewRef.current.scrollToItemFromId(itemId); + } + }, []); + + const searchBarRef = React.useRef(null); + + const onProjectItemModified = React.useCallback( + () => { + forceUpdate(); + triggerUnsavedChanges(); + }, + [forceUpdate, triggerUnsavedChanges] + ); + + const eventsBasedEntity = eventsBasedBehavior || eventsBasedObject; + const properties = eventsBasedEntity + ? eventsBasedEntity.getPropertyDescriptors() + : null; + const sharedProperties = eventsBasedBehavior + ? eventsBasedBehavior.getSharedPropertyDescriptors() + : null; + + const editName = React.useCallback( + (itemId: string) => { + const treeView = treeViewRef.current; + if (treeView) { + if (isMobile) { + // Position item at top of the screen to make sure it will be visible + // once the keyboard is open. + treeView.scrollToItemFromId(itemId, 'start'); + } + treeView.renameItemFromId(itemId); + } + }, + [isMobile] + ); + + const addProperty = React.useCallback( + ( + properties: gdPropertiesContainer, + isSharedProperties: boolean, + index: number, + i18n: I18nType + ) => { + if (!properties) return; + + const newName = newNameGenerator(i18n._(t`Property`), name => + properties.has(name) + ); + const property = properties.insertNew(newName, index); + property.setType('Number'); + + onPropertiesUpdated(); + + onProjectItemModified(); + setSearchText(''); + + const propertyItemId = getEventsBasedEntityPropertyTreeViewItemId( + property, + isSharedProperties + ); + if (treeViewRef.current) { + treeViewRef.current.openItems([ + propertyItemId, + isSharedProperties + ? sharedPropertiesRootFolderId + : propertiesRootFolderId, + ]); + } + // Scroll to the new property. + // Ideally, we'd wait for the list to be updated to scroll, but + // to simplify the code, we just wait a few ms for a new render + // to be done. + setTimeout(() => { + scrollToItem(propertyItemId); + onOpenProperty(newName, isSharedProperties); + }, 100); // A few ms is enough for a new render to be done. + + // We focus it so the user can edit the name directly. + editName(propertyItemId); + }, + [ + onPropertiesUpdated, + onProjectItemModified, + editName, + scrollToItem, + onOpenProperty, + ] + ); + + const onTreeModified = React.useCallback( + (shouldForceUpdateList: boolean) => { + triggerUnsavedChanges(); + + if (shouldForceUpdateList) forceUpdateList(); + else forceUpdate(); + }, + [forceUpdate, forceUpdateList, triggerUnsavedChanges] + ); + + // Initialize keyboard shortcuts as empty. + // onDelete callback is set outside because it deletes the selected + // item (that is a props). As it is stored in a ref, the keyboard shortcut + // instance does not update with selectedItems changes. + const keyboardShortcutsRef = React.useRef( + new KeyboardShortcuts({ + shortcutCallbacks: {}, + }) + ); + React.useEffect( + () => { + if (keyboardShortcutsRef.current) { + keyboardShortcutsRef.current.setShortcutCallback('onDelete', () => { + if (selectedItems.length > 0) { + deleteItem(selectedItems[0]); + } + }); + keyboardShortcutsRef.current.setShortcutCallback('onRename', () => { + if (selectedItems.length > 0) { + editName(selectedItems[0].content.getId()); + } + }); + keyboardShortcutsRef.current.setShortcutCallback('onCopy', () => { + if (selectedItems.length > 0) { + selectedItems[0].content.copy(); + } + }); + keyboardShortcutsRef.current.setShortcutCallback('onPaste', () => { + if (selectedItems.length > 0) { + selectedItems[0].content.paste(); + } + }); + keyboardShortcutsRef.current.setShortcutCallback('onCut', () => { + if (selectedItems.length > 0) { + selectedItems[0].content.cut(); + } + }); + } + }, + [editName, selectedItems] + ); + + const propertiesTreeViewItemProps = React.useMemo( + () => + properties && eventsBasedEntity + ? { + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + showPropertyOverridingConfirmation, + editName, + scrollToItem, + project, + projectScopedContainersAccessor, + extension, + eventsBasedEntity, + eventsBasedBehavior, + eventsBasedObject, + properties, + isSharedProperties: false, + onOpenProperty, + onPropertiesUpdated, + onRenameProperty, + onEventsFunctionsAdded, + } + : null, + [ + properties, + eventsBasedEntity, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + showPropertyOverridingConfirmation, + editName, + scrollToItem, + project, + projectScopedContainersAccessor, + extension, + eventsBasedBehavior, + eventsBasedObject, + onOpenProperty, + onPropertiesUpdated, + onRenameProperty, + onEventsFunctionsAdded, + ] + ); + + const sharedPropertiesTreeViewItemProps = React.useMemo( + () => + sharedProperties && propertiesTreeViewItemProps + ? { + ...propertiesTreeViewItemProps, + properties: sharedProperties, + isSharedProperties: true, + } + : null, + [propertiesTreeViewItemProps, sharedProperties] + ); + + const createPropertyItem = React.useCallback( + (property: gdNamedPropertyDescriptor, isSharedProperties: boolean) => { + const treeViewItemProps = isSharedProperties + ? sharedPropertiesTreeViewItemProps + : propertiesTreeViewItemProps; + if (!treeViewItemProps) { + return null; + } + return new LeafTreeViewItem( + new EventsBasedEntityPropertyTreeViewItemContent( + property, + treeViewItemProps + ) + ); + }, + [propertiesTreeViewItemProps, sharedPropertiesTreeViewItemProps] + ); + + const getTreeViewData = React.useCallback( + (i18n: I18nType): Array => { + return !properties || !propertiesTreeViewItemProps + ? [] + : [ + new LeafTreeViewItem( + new ActionTreeViewItemContent( + configurationItemId, + i18n._(t`Configuration`), + onOpenConfiguration, + 'res/icons_default/properties_black.svg' + ) + ), + { + isRoot: true, + content: new LabelTreeViewItemContent( + propertiesRootFolderId, + eventsBasedObject + ? i18n._(t`Object properties`) + : i18n._(t`Behavior properties`), + { + icon: , + label: i18n._(t`Add a property`), + click: () => { + addProperty(properties, false, 0, i18n); + }, + id: 'add-property', + } + ), + getChildren(i18n: I18nType): ?Array { + if (properties.getCount() === 0) { + return [ + new PlaceHolderTreeViewItem( + propertiesEmptyPlaceholderId, + i18n._(t`Start by adding a new property.`) + ), + ]; + } + return mapFor(0, properties.getCount(), i => + createPropertyItem(properties.getAt(i), false) + ).filter(Boolean); + }, + }, + sharedProperties + ? { + isRoot: true, + content: new LabelTreeViewItemContent( + sharedPropertiesRootFolderId, + i18n._(t`Scene properties`), + { + icon: , + label: i18n._(t`Add a property`), + click: () => { + addProperty(sharedProperties, true, 0, i18n); + }, + id: 'add-shared-property', + } + ), + getChildren(i18n: I18nType): ?Array { + if (sharedProperties.getCount() === 0) { + return [ + new PlaceHolderTreeViewItem( + sharedPropertiesEmptyPlaceholderId, + i18n._(t`Start by adding a new property.`) + ), + ]; + } + return mapFor(0, sharedProperties.getCount(), i => + createPropertyItem(sharedProperties.getAt(i), true) + ).filter(Boolean); + }, + } + : null, + ].filter(Boolean); + }, + [ + addProperty, + createPropertyItem, + eventsBasedObject, + onOpenConfiguration, + properties, + propertiesTreeViewItemProps, + sharedProperties, + ] + ); + + React.useImperativeHandle(ref, () => ({ + forceUpdateList: () => { + forceUpdate(); + if (treeViewRef.current) treeViewRef.current.forceUpdateList(); + }, + focusSearchBar: () => { + if (searchBarRef.current) searchBarRef.current.focus(); + }, + setSelectedProperty: ( + propertyName: string, + isSharedProperties: boolean + ) => { + const propertiesContainer = isSharedProperties + ? sharedProperties + : properties; + if (!propertiesContainer || !propertiesContainer.has(propertyName)) { + return; + } + const property = propertiesContainer.get(propertyName); + const propertyItemId = getEventsBasedEntityPropertyTreeViewItemId( + property, + isSharedProperties + ); + setSelectedItems(selectedItems => { + if ( + selectedItems.length === 1 && + selectedItems[0].content.getId() === propertyItemId + ) { + return selectedItems; + } + return [createPropertyItem(property, isSharedProperties)].filter( + Boolean + ); + }); + scrollToItem(propertyItemId); + }, + getSelectedProperty: () => { + const selectedItem = selectedItems[0]; + if (!selectedItem) { + return null; + } + const dataset = selectedItem.content.getDataSet(); + if (!dataset || !dataset.propertyName || !dataset.isSharedProperties) { + return null; + } + return { + propertyName: dataset.propertyName, + isSharedProperties: dataset.isSharedProperties === 'true', + }; + }, + })); + + const canMoveSelectionTo = React.useCallback( + (destinationItem: TreeViewItem, where: 'before' | 'inside' | 'after') => + selectedItems.every(item => { + return ( + // Project and game settings children `getRootId` return an empty string. + item.content.getRootId().length > 0 && + item.content.getRootId() === destinationItem.content.getRootId() + ); + }), + [selectedItems] + ); + + const moveSelectionTo = React.useCallback( + ( + i18n: I18nType, + destinationItem: TreeViewItem, + where: 'before' | 'inside' | 'after' + ) => { + if (selectedItems.length === 0) { + return; + } + const selectedItem = selectedItems[0]; + selectedItem.content.moveAt( + destinationItem.content.getIndex() + (where === 'after' ? 1 : 0) + ); + onTreeModified(true); + }, + [onTreeModified, selectedItems] + ); + + /** + * Unselect item if one of the parent is collapsed (folded) so that the item + * does not stay selected and not visible to the user. + */ + const onCollapseItem = React.useCallback( + (item: TreeViewItem) => { + if (selectedItems.length !== 1 || item.isPlaceholder) { + return; + } + if (selectedItems[0].content.isDescendantOf(item.content)) { + setSelectedItems([]); + } + }, + [selectedItems] + ); + + // Force List component to be mounted again if project + // has been changed. Avoid accessing to invalid objects that could + // crash the app. + const listKey = eventsBasedEntity + ? eventsBasedEntity.ptr + : 'no-eventsBasedEntity'; + const initiallyOpenedNodeIds = [ + propertiesRootFolderId, + sharedPropertiesRootFolderId, + ]; + + return ( + + + + + + {}} + onChange={setSearchText} + placeholder={t`Search in properties`} + /> + + + + {({ i18n }) => ( +
+ + {({ height }) => ( + { + const itemToSelect = items[0]; + if (!itemToSelect) return; + if (itemToSelect.isRoot) return; + setSelectedItems(items); + }} + onClickItem={onClickItem} + onRenameItem={renameItem} + buildMenuTemplate={buildMenuTemplate(i18n)} + getItemRightButton={getTreeViewItemRightButton(i18n)} + renderRightComponent={renderTreeViewItemRightComponent( + i18n + )} + onMoveSelectionToItem={(destinationItem, where) => + moveSelectionTo(i18n, destinationItem, where) + } + canMoveSelectionToItem={canMoveSelectionTo} + reactDndType={extensionItemReactDndType} + initiallyOpenedNodeIds={initiallyOpenedNodeIds} + forceDefaultDraggingPreview + shouldHideMenuIcon={item => !item.content.getRootId()} + /> + )} + +
+ )} +
+
+
+
+ ); + } +); + +const PropertyListEditorWithErrorBoundary = React.forwardRef< + Props, + PropertyListEditorInterface +>((props, ref) => ( + Property list editor} + scope="property-list-editor" + > + + +)); + +export default PropertyListEditorWithErrorBoundary; diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/index.js b/newIDE/app/src/EventsFunctionsExtensionEditor/index.js index 94b7208ae50e..42030c6342cc 100644 --- a/newIDE/app/src/EventsFunctionsExtensionEditor/index.js +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/index.js @@ -19,8 +19,10 @@ import { type EventsFunctionCreationParameters } from '../EventsFunctionsList/Ev import { type EventsBasedObjectCreationParameters } from '../EventsFunctionsList/EventsBasedObjectTreeViewItemContent'; import Background from '../UI/Background'; import OptionsEditorDialog from './OptionsEditorDialog'; -import EventsBasedBehaviorEditorPanel from '../EventsBasedBehaviorEditor/EventsBasedBehaviorEditorPanel'; -import EventsBasedObjectEditorPanel from '../EventsBasedObjectEditor/EventsBasedObjectEditorPanel'; +import { + EventsBasedBehaviorOrObjectEditor, + type EventsBasedBehaviorOrObjectEditorInterface, +} from './EventsBasedBehaviorOrObjectEditor'; import { type ResourceManagementProps } from '../ResourcesList/ResourceSource'; import BehaviorMethodSelectorDialog from './BehaviorMethodSelectorDialog'; import ObjectMethodSelectorDialog from './ObjectMethodSelectorDialog'; @@ -43,6 +45,9 @@ import newNameGenerator from '../Utils/NewNameGenerator'; import { ProjectScopedContainersAccessor } from '../InstructionOrExpression/EventsScope'; import GlobalAndSceneVariablesDialog from '../VariablesList/GlobalAndSceneVariablesDialog'; import { type HotReloadPreviewButtonProps } from '../HotReload/HotReloadPreviewButton'; +import PropertyListEditor, { + type PropertyListEditorInterface, +} from './PropertyListEditor'; const gd: libGDevelop = global.gd; @@ -151,6 +156,9 @@ export default class EventsFunctionsExtensionEditor extends React.Component< }; editor: ?EventsSheetInterface; eventsFunctionList: ?EventsFunctionsListInterface; + eventsBasedBehaviorEditor: ?EventsBasedBehaviorOrObjectEditorInterface; + eventsBasedObjectEditor: ?EventsBasedBehaviorOrObjectEditorInterface; + propertyListEditor: ?PropertyListEditorInterface; _editorMosaic: ?EditorMosaicInterface; _editorNavigator: ?EditorNavigatorInterface; // Create an empty "context" of objects. @@ -359,7 +367,7 @@ export default class EventsFunctionsExtensionEditor extends React.Component< selectedEventsFunction && !selectedEventsFunction.getEvents().getEventsCount() ) { - this._editorNavigator.openEditor('parameters'); + //this._editorNavigator.openEditor('parameters'); } else { this._editorNavigator.openEditor('events-sheet'); } @@ -641,7 +649,7 @@ export default class EventsFunctionsExtensionEditor extends React.Component< this.updateToolbar(); if (selectedEventsBasedBehavior) { if (this._editorMosaic) { - this._editorMosaic.collapseEditor('parameters'); + //this._editorMosaic.collapseEditor('parameters'); } if (this._editorNavigator) { this._editorNavigator.openEditor('events-sheet'); @@ -668,7 +676,7 @@ export default class EventsFunctionsExtensionEditor extends React.Component< this.updateToolbar(); if (selectedEventsBasedObject) { if (this._editorMosaic) { - this._editorMosaic.collapseEditor('parameters'); + //this._editorMosaic.collapseEditor('parameters'); } if (this._editorNavigator) this._editorNavigator.openEditor('events-sheet'); @@ -1361,7 +1369,9 @@ export default class EventsFunctionsExtensionEditor extends React.Component< const editors = { parameters: { type: 'primary', - title: t`Function Configuration`, + title: selectedEventsFunction + ? t`Function Configuration` + : t`Properties`, toolbarControls: [], renderEditor: () => ( @@ -1420,6 +1430,66 @@ export default class EventsFunctionsExtensionEditor extends React.Component< unsavedChanges={this.props.unsavedChanges} getFunctionGroupNames={this._getFunctionGroupNames} /> + ) : (selectedEventsBasedObject || + selectedEventsBasedBehavior) && + this._projectScopedContainersAccessor ? ( + (this.propertyListEditor = ref)} + project={project} + projectScopedContainersAccessor={ + this._projectScopedContainersAccessor + } + extension={eventsFunctionsExtension} + eventsBasedBehavior={selectedEventsBasedBehavior} + eventsBasedObject={selectedEventsBasedObject} + onRenameProperty={(oldName, newName) => { + if (selectedEventsBasedBehavior) { + this._onBehaviorPropertyRenamed( + selectedEventsBasedBehavior, + oldName, + newName + ); + } else if (selectedEventsBasedObject) { + this._onObjectPropertyRenamed( + selectedEventsBasedObject, + oldName, + newName + ); + } + }} + onPropertiesUpdated={() => { + const eventsBasedEntityEditor = + this.eventsBasedBehaviorEditor || + this.eventsBasedObjectEditor; + if (eventsBasedEntityEditor) { + eventsBasedEntityEditor.forceUpdateProperties(); + } + }} + onOpenConfiguration={propertyName => { + const eventsBasedEntityEditor = + this.eventsBasedBehaviorEditor || + this.eventsBasedObjectEditor; + if (eventsBasedEntityEditor) { + eventsBasedEntityEditor.scrollToConfiguration(); + } + }} + onOpenProperty={(propertyName, isSharedProperties) => { + const eventsBasedEntityEditor = + this.eventsBasedBehaviorEditor || + this.eventsBasedObjectEditor; + if (eventsBasedEntityEditor) { + eventsBasedEntityEditor.scrollToProperty( + propertyName, + isSharedProperties + ); + } + }} + onEventsFunctionsAdded={() => { + if (this.eventsFunctionList) { + this.eventsFunctionList.forceUpdateList(); + } + }} + /> ) : ( @@ -1488,7 +1558,8 @@ export default class EventsFunctionsExtensionEditor extends React.Component< ) : selectedEventsBasedBehavior && this._projectScopedContainersAccessor ? ( - (this.eventsBasedBehaviorEditor = ref)} project={project} projectScopedContainersAccessor={ this._projectScopedContainersAccessor @@ -1518,16 +1589,32 @@ export default class EventsFunctionsExtensionEditor extends React.Component< propertyName ); }} + onPropertiesUpdated={() => { + if (this.propertyListEditor) { + this.propertyListEditor.forceUpdateList(); + } + }} + onFocusProperty={(propertyName, isSharedProperties) => { + if (this.propertyListEditor) { + this.propertyListEditor.setSelectedProperty( + propertyName, + isSharedProperties + ); + } + }} onEventsFunctionsAdded={() => { if (this.eventsFunctionList) { this.eventsFunctionList.forceUpdateList(); } }} onConfigurationUpdated={this._onConfigurationUpdated} + onOpenCustomObjectEditor={() => {}} + onEventsBasedObjectChildrenEdited={() => {}} /> ) : selectedEventsBasedObject && this._projectScopedContainersAccessor ? ( - (this.eventsBasedObjectEditor = ref)} project={project} projectScopedContainersAccessor={ this._projectScopedContainersAccessor @@ -1542,6 +1629,7 @@ export default class EventsFunctionsExtensionEditor extends React.Component< newName ) } + onRenameSharedProperty={() => {}} onPropertyTypeChanged={propertyName => { gd.WholeProjectRefactorer.changeEventsBasedObjectPropertyType( project, @@ -1550,6 +1638,19 @@ export default class EventsFunctionsExtensionEditor extends React.Component< propertyName ); }} + onPropertiesUpdated={() => { + if (this.propertyListEditor) { + this.propertyListEditor.forceUpdateList(); + } + }} + onFocusProperty={(propertyName, isSharedProperties) => { + if (this.propertyListEditor) { + this.propertyListEditor.setSelectedProperty( + propertyName, + isSharedProperties + ); + } + }} onEventsFunctionsAdded={() => { if (this.eventsFunctionList) { this.eventsFunctionList.forceUpdateList(); @@ -1646,7 +1747,11 @@ export default class EventsFunctionsExtensionEditor extends React.Component< transitions={{ 'events-sheet': { nextIcon: , - nextLabel: Parameters, + nextLabel: selectedEventsFunction ? ( + Parameters + ) : ( + Property list + ), nextEditor: 'parameters', previousEditor: () => { this._selectEventsFunction(null, null, null); @@ -1655,8 +1760,39 @@ export default class EventsFunctionsExtensionEditor extends React.Component< }, parameters: { nextIcon: , - nextLabel: Validate these parameters, - nextEditor: 'events-sheet', + nextLabel: selectedEventsFunction ? ( + Validate these parameters + ) : null, + nextEditor: selectedEventsFunction ? 'events-sheet' : null, + previousEditor: selectedEventsFunction + ? null + : () => { + if (this.propertyListEditor) { + const selection = this.propertyListEditor.getSelectedProperty(); + if (selection) { + const { + propertyName, + isSharedProperties, + } = selection; + // Scroll to the selected property. + // Ideally, we'd wait for the list to be updated to scroll, but + // to simplify the code, we just wait a few ms for a new render + // to be done. + setTimeout(() => { + const eventsBasedEntityEditor = + this.eventsBasedBehaviorEditor || + this.eventsBasedObjectEditor; + if (eventsBasedEntityEditor) { + eventsBasedEntityEditor.scrollToProperty( + propertyName, + isSharedProperties + ); + } + }, 100); // A few ms is enough for a new render to be done. + } + } + return 'events-sheet'; + }, }, }} onEditorChanged={ diff --git a/newIDE/app/src/UI/EditorMosaic/EditorNavigator.js b/newIDE/app/src/UI/EditorMosaic/EditorNavigator.js index 4177657e6493..166a68a4b4ed 100644 --- a/newIDE/app/src/UI/EditorMosaic/EditorNavigator.js +++ b/newIDE/app/src/UI/EditorMosaic/EditorNavigator.js @@ -27,10 +27,10 @@ type Props = {| }, transitions: { [string]: {| - nextEditor?: string | (() => string), - nextLabel?: React.Node, - nextIcon?: React.Node, - previousEditor?: string | (() => string), + nextEditor?: string | (() => string) | null, + nextLabel?: React.Node | null, + nextIcon?: React.Node | null, + previousEditor?: string | (() => string) | null, |}, }, onEditorChanged: (editorName: string) => void, diff --git a/newIDE/app/src/UI/ErrorBoundary.js b/newIDE/app/src/UI/ErrorBoundary.js index 81316f9383ff..b3d0c3bf1cf1 100644 --- a/newIDE/app/src/UI/ErrorBoundary.js +++ b/newIDE/app/src/UI/ErrorBoundary.js @@ -58,6 +58,7 @@ type ErrorBoundaryScope = | 'debugger' | 'resources' | 'extension-editor' + | 'property-list-editor' | 'extensions-search-dialog' | 'external-events-editor' | 'external-layout-editor' diff --git a/newIDE/app/src/UI/SelectField.js b/newIDE/app/src/UI/SelectField.js index 0959a5e93309..482035c5a4dc 100644 --- a/newIDE/app/src/UI/SelectField.js +++ b/newIDE/app/src/UI/SelectField.js @@ -43,6 +43,7 @@ type Props = {| children: React.Node, disabled?: boolean, stopPropagationOnClick?: boolean, + onFocus?: (event: SyntheticFocusEvent) => void, id?: ?string, style?: { @@ -128,6 +129,7 @@ const SelectField = React.forwardRef( } : undefined } + onFocus={props.onFocus} InputProps={{ style: props.inputStyle, disableUnderline: !!props.disableUnderline, diff --git a/newIDE/app/src/UI/SemiControlledAutoComplete.js b/newIDE/app/src/UI/SemiControlledAutoComplete.js index a2c3a12a0fc2..c7ee2625058d 100644 --- a/newIDE/app/src/UI/SemiControlledAutoComplete.js +++ b/newIDE/app/src/UI/SemiControlledAutoComplete.js @@ -55,6 +55,7 @@ type Props = {| id?: ?string, onBlur?: (event: SyntheticFocusEvent) => void, onClick?: (event: SyntheticPointerEvent) => void, + onFocus?: (event: SyntheticFocusEvent) => void, commitOnInputChange?: boolean, onRequestClose?: () => void, onApply?: () => void, @@ -314,6 +315,7 @@ export default React.forwardRef( setInputValue(null); setIsMenuOpen(false); }} + onFocus={props.onFocus} open={isMenuOpen} style={{ ...props.style, diff --git a/newIDE/app/src/stories/componentStories/EventsBasedBehaviorEditor/EventsBasedBehaviorEditor.stories.js b/newIDE/app/src/stories/componentStories/EventsBasedBehaviorEditor/EventsBasedBehaviorEditor.stories.js index 789672f86616..0a3ef06ac1f8 100644 --- a/newIDE/app/src/stories/componentStories/EventsBasedBehaviorEditor/EventsBasedBehaviorEditor.stories.js +++ b/newIDE/app/src/stories/componentStories/EventsBasedBehaviorEditor/EventsBasedBehaviorEditor.stories.js @@ -7,7 +7,7 @@ import { action } from '@storybook/addon-actions'; import { testProject } from '../../GDevelopJsInitializerDecorator'; import paperDecorator from '../../PaperDecorator'; -import EventsBasedBehaviorEditor from '../../../EventsBasedBehaviorEditor/'; +import EventsBasedBehaviorEditor from '../../../EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedBehaviorEditor'; export default { title: 'EventsBasedBehaviorEditor/index', diff --git a/newIDE/app/src/stories/componentStories/EventsBasedBehaviorEditor/EventsBasedBehaviorEditorPanel.stories.js b/newIDE/app/src/stories/componentStories/EventsBasedBehaviorEditor/EventsBasedBehaviorEditorPanel.stories.js index 414be7964d68..da25ac6a79b5 100644 --- a/newIDE/app/src/stories/componentStories/EventsBasedBehaviorEditor/EventsBasedBehaviorEditorPanel.stories.js +++ b/newIDE/app/src/stories/componentStories/EventsBasedBehaviorEditor/EventsBasedBehaviorEditorPanel.stories.js @@ -6,17 +6,17 @@ import { action } from '@storybook/addon-actions'; // Keep first as it creates the `global.gd` object: import { testProject } from '../../GDevelopJsInitializerDecorator'; -import EventsBasedBehaviorEditorPanel from '../../../EventsBasedBehaviorEditor/EventsBasedBehaviorEditorPanel'; +import { EventsBasedBehaviorOrObjectEditor } from '../../../EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor'; import DragAndDropContextProvider from '../../../UI/DragAndDrop/DragAndDropContextProvider'; export default { title: 'EventsBasedBehaviorEditor/EventsBasedBehaviorEditorDialog', - component: EventsBasedBehaviorEditorPanel, + component: EventsBasedBehaviorOrObjectEditor, }; export const Default = () => ( - ( onPropertyTypeChanged={action('onPropertyTypeChanged')} onRenameSharedProperty={action('shared property rename')} onEventsFunctionsAdded={action('functions added')} + onFocusProperty={action('onFocusProperty')} + onPropertiesUpdated={action('onPropertiesUpdated')} + onEventsBasedObjectChildrenEdited={action( + 'onEventsBasedObjectChildrenEdited' + )} + onOpenCustomObjectEditor={action('onOpenCustomObjectEditor')} /> ); export const WithoutFunction = () => ( - ( onPropertyTypeChanged={action('onPropertyTypeChanged')} onRenameSharedProperty={action('shared property rename')} onEventsFunctionsAdded={action('functions added')} + onFocusProperty={action('onFocusProperty')} + onPropertiesUpdated={action('onPropertiesUpdated')} + onEventsBasedObjectChildrenEdited={action( + 'onEventsBasedObjectChildrenEdited' + )} + onOpenCustomObjectEditor={action('onOpenCustomObjectEditor')} /> ); diff --git a/newIDE/app/src/stories/componentStories/EventsBasedObjectEditor/EventsBasedObjectEditor.stories.js b/newIDE/app/src/stories/componentStories/EventsBasedObjectEditor/EventsBasedObjectEditor.stories.js index 69bd53e6cb69..9b0ad1cc424d 100644 --- a/newIDE/app/src/stories/componentStories/EventsBasedObjectEditor/EventsBasedObjectEditor.stories.js +++ b/newIDE/app/src/stories/componentStories/EventsBasedObjectEditor/EventsBasedObjectEditor.stories.js @@ -7,7 +7,7 @@ import { action } from '@storybook/addon-actions'; import { testProject } from '../../GDevelopJsInitializerDecorator'; import paperDecorator from '../../PaperDecorator'; -import EventsBasedObjectEditor from '../../../EventsBasedObjectEditor'; +import EventsBasedObjectEditor from '../../../EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor/EventsBasedObjectEditor'; export default { title: 'EventsBasedObjectEditor/index', diff --git a/newIDE/app/src/stories/componentStories/EventsBasedObjectEditor/EventsBasedObjectEditorPanel.stories.js b/newIDE/app/src/stories/componentStories/EventsBasedObjectEditor/EventsBasedObjectEditorPanel.stories.js index 7963125b4d8d..507af69d776c 100644 --- a/newIDE/app/src/stories/componentStories/EventsBasedObjectEditor/EventsBasedObjectEditorPanel.stories.js +++ b/newIDE/app/src/stories/componentStories/EventsBasedObjectEditor/EventsBasedObjectEditorPanel.stories.js @@ -6,17 +6,17 @@ import { action } from '@storybook/addon-actions'; // Keep first as it creates the `global.gd` object: import { testProject } from '../../GDevelopJsInitializerDecorator'; -import EventsBasedObjectEditorPanel from '../../../EventsBasedObjectEditor/EventsBasedObjectEditorPanel'; +import { EventsBasedBehaviorOrObjectEditor } from '../../../EventsFunctionsExtensionEditor/EventsBasedBehaviorOrObjectEditor'; import DragAndDropContextProvider from '../../../UI/DragAndDrop/DragAndDropContextProvider'; export default { title: 'EventsBasedObjectEditor/EventsBasedObjectEditorDialog', - component: EventsBasedObjectEditorPanel, + component: EventsBasedBehaviorOrObjectEditor, }; export const Default = () => ( - ( onEventsBasedObjectChildrenEdited={action( 'onEventsBasedObjectChildrenEdited' )} + onFocusProperty={action('onFocusProperty')} + onPropertiesUpdated={action('onPropertiesUpdated')} + onRenameSharedProperty={action('onRenameSharedProperty')} /> );