Allow to copy and paste behaviors from one object to another (#5205)

This commit is contained in:
D8H
2023-04-12 10:38:27 +02:00
committed by GitHub
parent 747cdf0243
commit bb0fe0ea37
10 changed files with 387 additions and 86 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -596,6 +596,7 @@ typedef ExtensionAndMetadata<ExpressionMetadata> ExtensionAndExpressionMetadata;
EnsureObjectEventsFunctionsProperParameters
#define STATIC_AddBehaviorAndRequiredBehaviors \
AddBehaviorAndRequiredBehaviors
#define STATIC_AddRequiredBehaviorsFor AddRequiredBehaviorsFor
#define STATIC_FindDependentBehaviorNames \
FindDependentBehaviorNames
#define STATIC_FindInvalidRequiredBehaviorProperties \

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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<string>): Promise<boolean> => {
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<string> = [];
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 (
<Column noMargin expand useFullHeight noOverflowParent>
@@ -171,11 +389,18 @@ const BehaviorsEditor = (props: Props) => {
Behaviors add features to objects in a matter of clicks.
</Trans>
}
actionLabel={<Trans>Add a behavior</Trans>}
helpPagePath="/behaviors"
tutorialId="intro-behaviors-and-functions"
actionButtonId="add-behavior-button"
actionLabel={<Trans>Add a behavior</Trans>}
onAction={() => setNewBehaviorDialogOpen(true)}
secondaryActionIcon={<PasteIcon />}
secondaryActionLabel={
isClipboardContainingBehaviors ? <Trans>Paste</Trans> : null
}
onSecondaryAction={() => {
pasteBehaviors();
}}
/>
</Column>
) : (
@@ -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}
/>
</Line>
</Column>
@@ -343,16 +577,37 @@ const BehaviorsEditor = (props: Props) => {
})}
</ScrollView>
<Column>
<Line justifyContent="flex-end" expand>
<RaisedButton
key="add-behavior-line"
label={<Trans>Add a behavior</Trans>}
primary
onClick={() => setNewBehaviorDialogOpen(true)}
icon={<Add />}
id="add-behavior-button"
/>
</Line>
<LineStackLayout noMargin>
<LineStackLayout expand>
<FlatButton
key={'copy-all-behaviors'}
leftIcon={<CopyIcon />}
label={isSmall ? '' : <Trans>Copy all behaviors</Trans>}
onClick={() => {
copyAllBehaviors();
}}
/>
<FlatButton
key={'paste-behaviors'}
leftIcon={<PasteIcon />}
label={isSmall ? '' : <Trans>Paste</Trans>}
onClick={() => {
pasteBehaviors();
}}
disabled={!isClipboardContainingBehaviors}
/>
</LineStackLayout>
<LineStackLayout justifyContent="flex-end" expand>
<RaisedButton
key="add-behavior-line"
label={<Trans>Add a behavior</Trans>}
primary
onClick={() => setNewBehaviorDialogOpen(true)}
icon={<Add />}
id="add-behavior-button"
/>
</LineStackLayout>
</LineStackLayout>
</Column>
</React.Fragment>
)}

View File

@@ -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) => (
</Text>
<LargeSpacer />
<ColumnStackLayout alignItems="center" noMargin>
<LineStackLayout noMargin>
<ResponsiveLineStackLayout noMargin>
{props.secondaryActionLabel && props.onSecondaryAction && (
<FlatButton
label={props.secondaryActionLabel}
@@ -90,7 +90,7 @@ export const EmptyPlaceholder = (props: Props) => (
}
id={props.actionButtonId}
/>
</LineStackLayout>
</ResponsiveLineStackLayout>
{props.tutorialId ? (
<TutorialButton
tutorialId={props.tutorialId}

View File

@@ -63,10 +63,10 @@ const FlatButton = React.forwardRef<Props, ButtonInterface>(
ref={ref}
>
{leftIcon}
{leftIcon && <Spacer />}
{leftIcon && label && <Spacer />}
{/* span element is required to prevent browser auto translators to crash the app - See https://github.com/4ian/GDevelop/issues/3453 */}
{label ? <span>{label}</span> : null}
{rightIcon && <Spacer />}
{rightIcon && label && <Spacer />}
{rightIcon}
</Button>
);

View File

@@ -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 = () => (
/>
</FixedHeightFlexContainer>
);
export const WithSecondaryAction = () => (
<FixedHeightFlexContainer
height={500}
justifyContent="center"
alignItems="center"
>
<EmptyPlaceholder
title="Add your first event"
description="You can use events to create cause and effect."
actionLabel="Add something"
helpPagePath="/objects/tiled_sprite"
onAction={action('onAdd')}
secondaryActionIcon={<PasteIcon />}
secondaryActionLabel="Paste"
onSecondaryAction={action('onAdd')}
/>
</FixedHeightFlexContainer>
);