diff --git a/Core/GDCore/IDE/WholeProjectRefactorer.cpp b/Core/GDCore/IDE/WholeProjectRefactorer.cpp index fcccd74727..8ba7bb1af0 100644 --- a/Core/GDCore/IDE/WholeProjectRefactorer.cpp +++ b/Core/GDCore/IDE/WholeProjectRefactorer.cpp @@ -845,9 +845,21 @@ void WholeProjectRefactorer::AddBehaviorAndRequiredBehaviors( return; }; + AddRequiredBehaviorsFor(project, object, behaviorName); +} + +void WholeProjectRefactorer::AddRequiredBehaviorsFor( + gd::Project& project, + gd::Object& object, + const gd::String& behaviorName) { + if (!object.HasBehaviorNamed(behaviorName)) { + return; + }; + gd::Behavior& behavior = object.GetBehavior(behaviorName); + const gd::Platform& platform = project.GetCurrentPlatform(); const gd::BehaviorMetadata& behaviorMetadata = - MetadataProvider::GetBehaviorMetadata(platform, behaviorType); + MetadataProvider::GetBehaviorMetadata(platform, behavior.GetTypeName()); if (MetadataProvider::IsBadBehaviorMetadata(behaviorMetadata)) { // Should not happen because the behavior was added successfully (so its // metadata are valid) - but double check anyway and bail out if the @@ -855,7 +867,6 @@ void WholeProjectRefactorer::AddBehaviorAndRequiredBehaviors( return; } - gd::Behavior& behavior = object.GetBehavior(behaviorName); for (auto const& keyValue : behavior.GetProperties()) { const gd::String& propertyName = keyValue.first; const gd::PropertyDescriptor& property = keyValue.second; diff --git a/Core/GDCore/IDE/WholeProjectRefactorer.h b/Core/GDCore/IDE/WholeProjectRefactorer.h index 10ba56bd06..fa520a8715 100644 --- a/Core/GDCore/IDE/WholeProjectRefactorer.h +++ b/Core/GDCore/IDE/WholeProjectRefactorer.h @@ -212,6 +212,13 @@ class GD_CORE_API WholeProjectRefactorer { gd::Object& object, const gd::String& behaviorType, const gd::String& behaviorName); + /** + * \brief Add required behaviors if necessary to fill every behavior + * properties of the given behaviors. + */ + static void AddRequiredBehaviorsFor(gd::Project& project, + gd::Object& object, + const gd::String& behaviorName); /** * \brief Find every behavior of the object that needs the given behaviors diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 14030caf70..c8884e1e8e 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -539,6 +539,9 @@ interface Behavior { [Value] MapStringPropertyDescriptor GetProperties(); boolean UpdateProperty([Const] DOMString name, [Const] DOMString value); void InitializeContent(); + + void SerializeTo([Ref] SerializerElement element); + void UnserializeFrom([Const, Ref] SerializerElement element); }; [JSImplementation=Behavior] @@ -2300,6 +2303,7 @@ interface WholeProjectRefactorer { void STATIC_EnsureBehaviorEventsFunctionsProperParameters([Const, Ref] EventsFunctionsExtension eventsFunctionsExtension, [Const, Ref] EventsBasedBehavior eventsBasedBehavior); void STATIC_EnsureObjectEventsFunctionsProperParameters([Const, Ref] EventsFunctionsExtension eventsFunctionsExtension, [Const, Ref] EventsBasedObject eventsBasedObject); void STATIC_AddBehaviorAndRequiredBehaviors([Ref] Project project, [Ref] gdObject obj, [Const] DOMString behaviorType, [Const] DOMString behaviorName); + void STATIC_AddRequiredBehaviorsFor([Ref] Project project, [Ref] gdObject obj, [Const] DOMString behaviorName); [Value] VectorString STATIC_FindDependentBehaviorNames([Const, Ref] Project project, [Const, Ref] gdObject obj, [Const] DOMString behaviorName); [Value] VectorUnfilledRequiredBehaviorPropertyProblem STATIC_FindInvalidRequiredBehaviorProperties([Const, Ref] Project project); [Value] VectorString STATIC_GetBehaviorsWithType([Const, Ref] gdObject obj, [Const] DOMString type); diff --git a/GDevelop.js/Bindings/Wrapper.cpp b/GDevelop.js/Bindings/Wrapper.cpp index cbfccfd904..a50867d94c 100644 --- a/GDevelop.js/Bindings/Wrapper.cpp +++ b/GDevelop.js/Bindings/Wrapper.cpp @@ -596,6 +596,7 @@ typedef ExtensionAndMetadata ExtensionAndExpressionMetadata; EnsureObjectEventsFunctionsProperParameters #define STATIC_AddBehaviorAndRequiredBehaviors \ AddBehaviorAndRequiredBehaviors +#define STATIC_AddRequiredBehaviorsFor AddRequiredBehaviorsFor #define STATIC_FindDependentBehaviorNames \ FindDependentBehaviorNames #define STATIC_FindInvalidRequiredBehaviorProperties \ diff --git a/GDevelop.js/types/gdbehavior.js b/GDevelop.js/types/gdbehavior.js index d2f6700271..847c9d51eb 100644 --- a/GDevelop.js/types/gdbehavior.js +++ b/GDevelop.js/types/gdbehavior.js @@ -8,6 +8,8 @@ declare class gdBehavior { getProperties(): gdMapStringPropertyDescriptor; updateProperty(name: string, value: string): boolean; initializeContent(): void; + serializeTo(element: gdSerializerElement): void; + unserializeFrom(element: gdSerializerElement): void; delete(): void; ptr: number; }; \ No newline at end of file diff --git a/GDevelop.js/types/gdwholeprojectrefactorer.js b/GDevelop.js/types/gdwholeprojectrefactorer.js index 845c62fa84..f5c0a1c21d 100644 --- a/GDevelop.js/types/gdwholeprojectrefactorer.js +++ b/GDevelop.js/types/gdwholeprojectrefactorer.js @@ -33,6 +33,7 @@ declare class gdWholeProjectRefactorer { static ensureBehaviorEventsFunctionsProperParameters(eventsFunctionsExtension: gdEventsFunctionsExtension, eventsBasedBehavior: gdEventsBasedBehavior): void; static ensureObjectEventsFunctionsProperParameters(eventsFunctionsExtension: gdEventsFunctionsExtension, eventsBasedObject: gdEventsBasedObject): void; static addBehaviorAndRequiredBehaviors(project: gdProject, obj: gdObject, behaviorType: string, behaviorName: string): void; + static addRequiredBehaviorsFor(project: gdProject, obj: gdObject, behaviorName: string): void; static findDependentBehaviorNames(project: gdProject, obj: gdObject, behaviorName: string): gdVectorString; static findInvalidRequiredBehaviorProperties(project: gdProject): gdVectorUnfilledRequiredBehaviorPropertyProblem; static getBehaviorsWithType(obj: gdObject, type: string): gdVectorString; diff --git a/newIDE/app/src/BehaviorsEditor/index.js b/newIDE/app/src/BehaviorsEditor/index.js index 01063cfd0b..d038e5191b 100644 --- a/newIDE/app/src/BehaviorsEditor/index.js +++ b/newIDE/app/src/BehaviorsEditor/index.js @@ -12,10 +12,12 @@ import NewBehaviorDialog from './NewBehaviorDialog'; import BehaviorsEditorService from './BehaviorsEditorService'; import Window from '../Utils/Window'; import { Column, Line } from '../UI/Grid'; +import { ResponsiveLineStackLayout } from '../UI/Layout'; import RaisedButton from '../UI/RaisedButton'; import { type ResourceManagementProps } from '../ResourcesList/ResourceSource'; import DismissableTutorialMessage from '../Hints/DismissableTutorialMessage'; import { ColumnStackLayout } from '../UI/Layout'; +import { LineStackLayout } from '../UI/Layout'; import useForceUpdate from '../Utils/UseForceUpdate'; import { Accordion, AccordionHeader, AccordionBody } from '../UI/Accordion'; import { EmptyPlaceholder } from '../UI/EmptyPlaceholder'; @@ -32,9 +34,36 @@ import ElementWithMenu from '../UI/Menu/ElementWithMenu'; import ThreeDotsMenu from '../UI/CustomSvgIcons/ThreeDotsMenu'; import Trash from '../UI/CustomSvgIcons/Trash'; import Add from '../UI/CustomSvgIcons/Add'; +import { mapVector } from '../Utils/MapFor'; +import Clipboard, { SafeExtractor } from '../Utils/Clipboard'; +import { + serializeToJSObject, + unserializeFromJSObject, +} from '../Utils/Serializer'; +import useAlertDialog from '../UI/Alert/useAlertDialog'; +import PasteIcon from '../UI/CustomSvgIcons/Clipboard'; +import CopyIcon from '../UI/CustomSvgIcons/Copy'; +import FlatButton from '../UI/FlatButton'; +import { useResponsiveWindowWidth } from '../UI/Reponsive/ResponsiveWindowMeasurer'; const gd: libGDevelop = global.gd; +const BEHAVIORS_CLIPBOARD_KIND = 'Behaviors'; + +export const useBehaviorOverridingAlertDialog = () => { + const { showConfirmation } = useAlertDialog(); + return async (existingBehaviorNames: Array): Promise => { + return await showConfirmation({ + title: t`Existing behaviors`, + message: t`These behaviors are already attached to the object:${'\n\n - ' + + existingBehaviorNames.join('\n\n - ') + + '\n\n'}Do you want to replace their property values?`, + confirmButtonLabel: t`Replace`, + dismissButtonLabel: t`Omit`, + }); + }; +}; + type Props = {| project: gdProject, eventsFunctionsExtension?: gdEventsFunctionsExtension, @@ -74,91 +103,280 @@ const BehaviorsEditor = (props: Props) => { [justAddedBehaviorName] ); + const windowWidth = useResponsiveWindowWidth(); + const isSmall = windowWidth === 'small'; + const [newBehaviorDialogOpen, setNewBehaviorDialogOpen] = React.useState( false ); - const { object, project, eventsFunctionsExtension } = props; + const showBehaviorOverridingConfirmation = useBehaviorOverridingAlertDialog(); + + const { + object, + project, + eventsFunctionsExtension, + onSizeUpdated, + onBehaviorsUpdated, + onUpdateBehaviorsSharedData, + openBehaviorEvents, + } = props; const allBehaviorNames = object.getAllBehaviorNames().toJSArray(); const forceUpdate = useForceUpdate(); const { values } = React.useContext(PreferencesContext); - const addBehavior = (type: string, defaultName: string) => { - const wasBehaviorAdded = addBehaviorToObject( - project, - object, - type, - defaultName - ); + const addBehavior = React.useCallback( + (type: string, defaultName: string) => { + const wasBehaviorAdded = addBehaviorToObject( + project, + object, + type, + defaultName + ); - if (wasBehaviorAdded) { - setNewBehaviorDialogOpen(false); - sendBehaviorAdded({ - behaviorType: type, - parentEditor: 'behaviors-editor', + if (wasBehaviorAdded) { + setNewBehaviorDialogOpen(false); + sendBehaviorAdded({ + behaviorType: type, + parentEditor: 'behaviors-editor', + }); + setJustAddedBehaviorName(defaultName); + } + + forceUpdate(); + if (onSizeUpdated) onSizeUpdated(); + onUpdateBehaviorsSharedData(); + if (onBehaviorsUpdated) onBehaviorsUpdated(); + }, + [ + forceUpdate, + object, + onBehaviorsUpdated, + onSizeUpdated, + onUpdateBehaviorsSharedData, + project, + ] + ); + + const onChangeBehaviorName = React.useCallback( + (behavior: gdBehavior, newName: string) => { + // TODO: This is disabled for now as there is no proper refactoring + // of events after a behavior renaming. Once refactoring is available, + // the text field can be enabled again and refactoring calls added here + // (or in a parent). + // Renaming a behavior is something that is really rare anyway! :) + + if (object.hasBehaviorNamed(newName)) return; + object.renameBehavior(behavior.getName(), newName); + forceUpdate(); + if (onBehaviorsUpdated) onBehaviorsUpdated(); + }, + [forceUpdate, object, onBehaviorsUpdated] + ); + + const onRemoveBehavior = React.useCallback( + (behaviorName: string) => { + let message = + "Are you sure you want to remove this behavior? This can't be undone."; + const dependentBehaviors = gd.WholeProjectRefactorer.findDependentBehaviorNames( + project, + object, + behaviorName + ).toJSArray(); + if (dependentBehaviors.length > 0) { + message += + '\nDependent behaviors will be removed too: ' + + dependentBehaviors.join(', '); + } + const answer = Window.showConfirmDialog(message); + + if (answer) { + object.removeBehavior(behaviorName); + dependentBehaviors.forEach(name => object.removeBehavior(name)); + if (onSizeUpdated) onSizeUpdated(); + } + if (onBehaviorsUpdated) onBehaviorsUpdated(); + }, + [object, onBehaviorsUpdated, onSizeUpdated, project] + ); + + const copyBehavior = React.useCallback( + (behaviorName: string) => { + const behavior = object.getBehavior(behaviorName); + Clipboard.set(BEHAVIORS_CLIPBOARD_KIND, [ + { + name: behaviorName, + type: behavior.getTypeName(), + serializedBehavior: serializeToJSObject(behavior), + }, + ]); + forceUpdate(); + }, + [forceUpdate, object] + ); + + const copyAllBehaviors = React.useCallback( + () => { + Clipboard.set( + BEHAVIORS_CLIPBOARD_KIND, + mapVector(object.getAllBehaviorNames(), behaviorName => { + const behavior = object.getBehavior(behaviorName); + return { + name: behaviorName, + type: behavior.getTypeName(), + serializedBehavior: serializeToJSObject(behavior), + }; + }) + ); + forceUpdate(); + }, + [forceUpdate, object] + ); + + const pasteBehaviors = React.useCallback( + async () => { + const clipboardContent = Clipboard.get(BEHAVIORS_CLIPBOARD_KIND); + const behaviorContents = SafeExtractor.extractArray(clipboardContent); + if (!behaviorContents) return; + + const newNamedBehaviors: Array<{ + name: string, + type: string, + serializedBehavior: string, + }> = []; + const existingNamedBehaviors: Array<{ + name: string, + type: string, + serializedBehavior: string, + }> = []; + const existingBehaviorFullNames: Array = []; + behaviorContents.forEach(behaviorContent => { + const name = SafeExtractor.extractStringProperty( + behaviorContent, + 'name' + ); + const type = SafeExtractor.extractStringProperty( + behaviorContent, + 'type' + ); + const serializedBehavior = SafeExtractor.extractObjectProperty( + behaviorContent, + 'serializedBehavior' + ); + if (!name || !type || !serializedBehavior) { + return; + } + + const behaviorMetadata = gd.MetadataProvider.getBehaviorMetadata( + project.getCurrentPlatform(), + type + ); + if ( + behaviorMetadata.getObjectType() !== '' && + behaviorMetadata.getObjectType() !== object.getType() + ) { + return; + } + + if (object.hasBehaviorNamed(name)) { + const existingBehavior = object.getBehavior(name); + if (existingBehavior.getTypeName() !== type) { + return; + } + existingNamedBehaviors.push({ name, type, serializedBehavior }); + existingBehaviorFullNames.push(behaviorMetadata.getFullName()); + } else { + newNamedBehaviors.push({ name, type, serializedBehavior }); + } }); - setJustAddedBehaviorName(defaultName); - } - forceUpdate(); - if (props.onSizeUpdated) props.onSizeUpdated(); - props.onUpdateBehaviorsSharedData(); - if (props.onBehaviorsUpdated) props.onBehaviorsUpdated(); - }; + let firstAddedBehaviorName: string | null = null; + newNamedBehaviors.forEach(({ name, type, serializedBehavior }) => { + object.addNewBehavior(project, type, name); + if (object.hasBehaviorNamed(name)) { + if (!firstAddedBehaviorName) { + firstAddedBehaviorName = name; + } + const behavior = object.getBehavior(name); + unserializeFromJSObject(behavior, serializedBehavior); + } + }); + // Add missing required behaviors as a 2nd step because these behaviors + // could have been in the array. + newNamedBehaviors.forEach(({ name }) => { + gd.WholeProjectRefactorer.addRequiredBehaviorsFor( + project, + object, + name + ); + }); - const onChangeBehaviorName = (behavior: gdBehavior, newName: string) => { - // TODO: This is disabled for now as there is no proper refactoring - // of events after a behavior renaming. Once refactoring is available, - // the text field can be enabled again and refactoring calls added here - // (or in a parent). - // Renaming a behavior is something that is really rare anyway! :) + let shouldOverrideBehaviors = false; + if (existingNamedBehaviors.length > 0) { + shouldOverrideBehaviors = await showBehaviorOverridingConfirmation( + existingBehaviorFullNames + ); - if (object.hasBehaviorNamed(newName)) return; - object.renameBehavior(behavior.getName(), newName); - forceUpdate(); - if (props.onBehaviorsUpdated) props.onBehaviorsUpdated(); - }; + if (shouldOverrideBehaviors) { + existingNamedBehaviors.forEach( + ({ name, type, serializedBehavior }) => { + if (object.hasBehaviorNamed(name)) { + const behavior = object.getBehavior(name); + // Property values can be replaced directly because the type has been check earlier. + unserializeFromJSObject(behavior, serializedBehavior); + } + } + ); + } + } - const onRemoveBehavior = (behaviorName: string) => { - let message = - "Are you sure you want to remove this behavior? This can't be undone."; - const dependentBehaviors = gd.WholeProjectRefactorer.findDependentBehaviorNames( - project, + forceUpdate(); + if (firstAddedBehaviorName) { + setJustAddedBehaviorName(firstAddedBehaviorName); + if (onSizeUpdated) onSizeUpdated(); + onUpdateBehaviorsSharedData(); + } else if (existingNamedBehaviors.length === 1) { + setJustAddedBehaviorName(existingNamedBehaviors[0].name); + } + if (firstAddedBehaviorName || shouldOverrideBehaviors) { + if (onBehaviorsUpdated) onBehaviorsUpdated(); + } + }, + [ + forceUpdate, object, - behaviorName - ).toJSArray(); - if (dependentBehaviors.length > 0) { - message += - '\nDependent behaviors will be removed too: ' + - dependentBehaviors.join(', '); - } - const answer = Window.showConfirmDialog(message); + onBehaviorsUpdated, + onSizeUpdated, + onUpdateBehaviorsSharedData, + project, + showBehaviorOverridingConfirmation, + ] + ); - if (answer) { - object.removeBehavior(behaviorName); - dependentBehaviors.forEach(name => object.removeBehavior(name)); - if (props.onSizeUpdated) props.onSizeUpdated(); - } - if (props.onBehaviorsUpdated) props.onBehaviorsUpdated(); - }; + const openExtension = React.useCallback( + (behaviorType: string) => { + const elements = behaviorType.split('::'); + if (elements.length !== 2) { + return; + } + const extensionName = elements[0]; + const behaviorName = elements[1]; - const openExtension = (behaviorType: string) => { - const elements = behaviorType.split('::'); - if (elements.length !== 2) { - return; - } - const extensionName = elements[0]; - const behaviorName = elements[1]; + if ( + !extensionName || + !project.hasEventsFunctionsExtensionNamed(extensionName) + ) { + return; + } + openBehaviorEvents(extensionName, behaviorName); + }, + [openBehaviorEvents, project] + ); - if ( - !extensionName || - !props.project.hasEventsFunctionsExtensionNamed(extensionName) - ) { - return; - } - props.openBehaviorEvents(extensionName, behaviorName); - }; + const isClipboardContainingBehaviors = Clipboard.has( + BEHAVIORS_CLIPBOARD_KIND + ); return ( @@ -171,11 +389,18 @@ const BehaviorsEditor = (props: Props) => { Behaviors add features to objects in a matter of clicks. } - actionLabel={Add a behavior} helpPagePath="/behaviors" tutorialId="intro-behaviors-and-functions" actionButtonId="add-behavior-button" + actionLabel={Add a behavior} onAction={() => setNewBehaviorDialogOpen(true)} + secondaryActionIcon={} + secondaryActionLabel={ + isClipboardContainingBehaviors ? Paste : null + } + onSecondaryAction={() => { + pasteBehaviors(); + }} /> ) : ( @@ -272,6 +497,15 @@ const BehaviorsEditor = (props: Props) => { label: i18n._(t`Delete`), click: () => onRemoveBehavior(behaviorName), }, + { + label: i18n._(t`Copy`), + click: () => copyBehavior(behaviorName), + }, + { + label: i18n._(t`Paste`), + click: pasteBehaviors, + enabled: isClipboardContainingBehaviors, + }, ...(project.hasEventsBasedBehavior(behaviorTypeName) ? [ { type: 'separator' }, @@ -333,7 +567,7 @@ const BehaviorsEditor = (props: Props) => { resourceManagementProps={ props.resourceManagementProps } - onBehaviorUpdated={props.onBehaviorsUpdated} + onBehaviorUpdated={onBehaviorsUpdated} /> @@ -343,16 +577,37 @@ const BehaviorsEditor = (props: Props) => { })} - - Add a behavior} - primary - onClick={() => setNewBehaviorDialogOpen(true)} - icon={} - id="add-behavior-button" - /> - + + + } + label={isSmall ? '' : Copy all behaviors} + onClick={() => { + copyAllBehaviors(); + }} + /> + } + label={isSmall ? '' : Paste} + onClick={() => { + pasteBehaviors(); + }} + disabled={!isClipboardContainingBehaviors} + /> + + + Add a behavior} + primary + onClick={() => setNewBehaviorDialogOpen(true)} + icon={} + id="add-behavior-button" + /> + + )} diff --git a/newIDE/app/src/UI/EmptyPlaceholder.js b/newIDE/app/src/UI/EmptyPlaceholder.js index 84271896a3..58453f135c 100644 --- a/newIDE/app/src/UI/EmptyPlaceholder.js +++ b/newIDE/app/src/UI/EmptyPlaceholder.js @@ -3,10 +3,10 @@ import * as React from 'react'; import { Trans } from '@lingui/macro'; import Container from '@material-ui/core/Container'; import { ColumnStackLayout } from './Layout'; -import { LineStackLayout } from '../UI/Layout'; +import { ResponsiveLineStackLayout } from './Layout'; import RaisedButton from '../UI/RaisedButton'; import FlatButton from '../UI/FlatButton'; -import { Column, Line, LargeSpacer } from './Grid'; +import { Column, LargeSpacer } from './Grid'; import HelpButton from '../UI/HelpButton'; import Text from '../UI/Text'; import TutorialButton from './TutorialButton'; @@ -64,7 +64,7 @@ export const EmptyPlaceholder = (props: Props) => ( - + {props.secondaryActionLabel && props.onSecondaryAction && ( ( } id={props.actionButtonId} /> - + {props.tutorialId ? ( ( ref={ref} > {leftIcon} - {leftIcon && } + {leftIcon && label && } {/* span element is required to prevent browser auto translators to crash the app - See https://github.com/4ian/GDevelop/issues/3453 */} {label ? {label} : null} - {rightIcon && } + {rightIcon && label && } {rightIcon} ); diff --git a/newIDE/app/src/stories/componentStories/EmptyPlaceholder.stories.js b/newIDE/app/src/stories/componentStories/EmptyPlaceholder.stories.js index 746372573d..1b96c35ea1 100644 --- a/newIDE/app/src/stories/componentStories/EmptyPlaceholder.stories.js +++ b/newIDE/app/src/stories/componentStories/EmptyPlaceholder.stories.js @@ -7,6 +7,7 @@ import paperDecorator from '../PaperDecorator'; import { EmptyPlaceholder } from '../../UI/EmptyPlaceholder'; import FixedHeightFlexContainer from '../FixedHeightFlexContainer'; +import PasteIcon from '../../UI/CustomSvgIcons/Clipboard'; export default { title: 'UI Building Blocks/EmptyPlaceholder', @@ -28,3 +29,22 @@ export const Default = () => ( /> ); + +export const WithSecondaryAction = () => ( + + } + secondaryActionLabel="Paste" + onSecondaryAction={action('onAdd')} + /> + +);