Round of fixes

This commit is contained in:
Clem
2026-10-05 18:14:06 +02:00
committed by Clément Pasteau
parent a92d493641
commit f4d4c8f15b
30 changed files with 2953 additions and 188 deletions
+37 -22
View File
@@ -1087,6 +1087,8 @@ namespace gdjs {
object: gdjs.RuntimeObject;
dummyThreeObject: THREE.Object3D;
threeTransformControls: THREE_ADDONS.TransformControls;
/** Put the controls back on the object, after it was changed. */
syncWithObject: () => void;
} | null = null;
private _transformControlsMode: 'translate' | 'rotate' | 'scale' =
'translate';
@@ -2813,30 +2815,33 @@ namespace gdjs {
const scaleDragWorldScale = new THREE.Vector3();
const scaleDragLocalStart = new THREE.Vector3();
const scaleDragLocalEnd = new THREE.Vector3();
const syncDummyWithObject = () => {
this._selectionControlsMovementTotalDelta = null;
this._updateDummyLocation(
dummyThreeObject,
lastEditableSelectedObject,
threeTransformControls
);
// Reset the initial position to the current position, so that
// it's ready to be dragged again.
initialObjectX = lastEditableSelectedObject.getX();
initialObjectY = lastEditableSelectedObject.getY();
initialObjectZ = is3D(lastEditableSelectedObject)
? lastEditableSelectedObject.getZ()
: 0;
initialObjectWidth = lastEditableSelectedObject.getWidth();
initialObjectHeight = lastEditableSelectedObject.getHeight();
initialObjectDepth = is3D(lastEditableSelectedObject)
? lastEditableSelectedObject.getDepth()
: 0;
initialDummyPosition.copy(dummyThreeObject.position);
initialDummyRotation.copy(dummyThreeObject.rotation);
initialDummyScale.copy(dummyThreeObject.scale);
};
threeTransformControls.addEventListener('change', (e) => {
if (!threeTransformControls.dragging) {
this._selectionControlsMovementTotalDelta = null;
this._updateDummyLocation(
dummyThreeObject,
lastEditableSelectedObject,
threeTransformControls
);
// Reset the initial position to the current position, so that
// it's ready to be dragged again.
initialObjectX = lastEditableSelectedObject.getX();
initialObjectY = lastEditableSelectedObject.getY();
initialObjectZ = is3D(lastEditableSelectedObject)
? lastEditableSelectedObject.getZ()
: 0;
initialObjectWidth = lastEditableSelectedObject.getWidth();
initialObjectHeight = lastEditableSelectedObject.getHeight();
initialObjectDepth = is3D(lastEditableSelectedObject)
? lastEditableSelectedObject.getDepth()
: 0;
initialDummyPosition.copy(dummyThreeObject.position);
initialDummyRotation.copy(dummyThreeObject.rotation);
initialDummyScale.copy(dummyThreeObject.scale);
syncDummyWithObject();
return;
}
@@ -3056,11 +3061,21 @@ namespace gdjs {
object: lastEditableSelectedObject,
dummyThreeObject,
threeTransformControls,
syncWithObject: syncDummyWithObject,
};
}
}
}
// The object can have been changed by something else than the controls
// (a hot-reload after an undo/redo or an edit in the properties panel...).
if (
this._selectionControls &&
!this._selectionControls.threeTransformControls.dragging
) {
this._selectionControls.syncWithObject();
}
if (
lastEditableSelectedObject &&
this._selectionControls &&
@@ -1932,6 +1932,28 @@ namespace gdjs {
sizeChanged = true;
}
}
// Check if flips changed (objects with the flippable capability).
const flippableObject =
runtimeObject as unknown as Partial<gdjs.Flippable>;
if (flippableObject.flipX && flippableObject.flipY) {
if (!!oldInstance.flippedX !== !!newInstance.flippedX) {
flippableObject.flipX(!!newInstance.flippedX);
somethingChanged = true;
}
if (!!oldInstance.flippedY !== !!newInstance.flippedY) {
flippableObject.flipY(!!newInstance.flippedY);
somethingChanged = true;
}
}
if (
gdjs.Base3DHandler &&
gdjs.Base3DHandler.is3D(runtimeObject) &&
!!oldInstance.flippedZ !== !!newInstance.flippedZ
) {
runtimeObject.flipZ(!!newInstance.flippedZ);
somethingChanged = true;
}
if (runtimeObject instanceof gdjs.CustomRuntimeObject) {
// The `objects` attribute is already resolved by `resolveCustomObjectConfigurations()`.
const oldCustomObjectData = oldObjectData as ObjectData &
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -433,8 +433,7 @@ export const useManageObjectBehaviors = ({
const removeBehavior = React.useCallback(
(behaviorName: string) => {
let message =
"Are you sure you want to remove this behavior? This can't be undone.";
let message = 'Are you sure you want to remove this behavior?';
const allDependentBehaviorsSet = new Set<string>();
for (const object of objects) {
const dependentBehaviors = gd.WholeProjectRefactorer.findDependentBehaviorNames(
+1
View File
@@ -302,6 +302,7 @@ const Effect = React.forwardRef((
project={project}
schema={effectMetadata.parametersSchema}
instances={[effect]}
onInstancesModified={onEffectsUpdated}
resourceManagementProps={resourceManagementProps}
projectScopedContainersAccessor={
projectScopedContainersAccessor
@@ -42,6 +42,10 @@ export default class LayerRenderer {
y: number,
|}) => void;
onInstanceDoubleClicked: gdInitialInstance => void;
/** The size of an instance without custom size can be known late (a
* texture or a model loaded after a while). */
onInstanceDefaultSizeChanged: gdInitialInstance => void;
_lastDefaultSizes: Map<number, string>;
onOverInstance: gdInitialInstance => void;
onOutInstance: gdInitialInstance => void;
onMoveInstance: (gdInitialInstance, number, number) => void;
@@ -110,6 +114,7 @@ export default class LayerRenderer {
onInstanceClicked,
onInstanceRightClicked,
onInstanceDoubleClicked,
onInstanceDefaultSizeChanged,
onOverInstance,
onOutInstance,
onMoveInstance,
@@ -133,6 +138,7 @@ export default class LayerRenderer {
y: number,
|}) => void,
onInstanceDoubleClicked: gdInitialInstance => void,
onInstanceDefaultSizeChanged: gdInitialInstance => void,
onOverInstance: gdInitialInstance => void,
onOutInstance: gdInitialInstance => void,
onMoveInstance: (gdInitialInstance, number, number) => void,
@@ -153,6 +159,8 @@ export default class LayerRenderer {
this.onInstanceClicked = onInstanceClicked;
this.onInstanceRightClicked = onInstanceRightClicked;
this.onInstanceDoubleClicked = onInstanceDoubleClicked;
this.onInstanceDefaultSizeChanged = onInstanceDefaultSizeChanged;
this._lastDefaultSizes = new Map();
this.onOverInstance = onOverInstance;
this.onOutInstance = onOutInstance;
this.onMoveInstance = onMoveInstance;
@@ -212,6 +220,7 @@ export default class LayerRenderer {
const time = performance.now();
renderedInstance.update();
const duration = performance.now() - time;
this._notifyIfDefaultSizeChanged(instance, renderedInstance);
increaseInstanceUpdate(
this._basicProfilingCounters,
@@ -308,6 +317,18 @@ export default class LayerRenderer {
);
};
_notifyIfDefaultSizeChanged(
instance: gdInitialInstance,
renderedInstance: RenderedInstance | Rendered3DInstance
) {
const defaultSize = `${renderedInstance.getDefaultWidth()},${renderedInstance.getDefaultHeight()},${renderedInstance.getDefaultDepth()}`;
const lastDefaultSize = this._lastDefaultSizes.get(instance.ptr);
if (lastDefaultSize === defaultSize) return;
this._lastDefaultSizes.set(instance.ptr, defaultSize);
if (lastDefaultSize !== undefined)
this.onInstanceDefaultSizeChanged(instance);
}
getUnrotatedInstanceSize = (instance: gdInitialInstance): any => {
const renderedInstance = this.getOrCreateRendererOfInstance(instance);
const hasCustomSize = instance.hasCustomSize();
@@ -38,6 +38,7 @@ export default class InstancesRenderer {
|}) => void;
_showObjectInstancesIn3D: boolean;
onInstanceDoubleClicked: gdInitialInstance => void;
onInstanceDefaultSizeChanged: gdInitialInstance => void;
onOverInstance: gdInitialInstance => void;
onOutInstance: gdInitialInstance => void;
onMoveInstance: (gdInitialInstance, number, number) => void;
@@ -72,6 +73,7 @@ export default class InstancesRenderer {
onInstanceClicked,
onInstanceRightClicked,
onInstanceDoubleClicked,
onInstanceDefaultSizeChanged,
onOverInstance,
onOutInstance,
onMoveInstance,
@@ -95,6 +97,7 @@ export default class InstancesRenderer {
y: number,
|}) => void,
onInstanceDoubleClicked: gdInitialInstance => void,
onInstanceDefaultSizeChanged: gdInitialInstance => void,
onOverInstance: gdInitialInstance => void,
onOutInstance: gdInitialInstance => void,
onMoveInstance: (gdInitialInstance, number, number) => void,
@@ -113,6 +116,7 @@ export default class InstancesRenderer {
this.onInstanceClicked = onInstanceClicked;
this.onInstanceRightClicked = onInstanceRightClicked;
this.onInstanceDoubleClicked = onInstanceDoubleClicked;
this.onInstanceDefaultSizeChanged = onInstanceDefaultSizeChanged;
this.onOverInstance = onOverInstance;
this.onOutInstance = onOutInstance;
this.onMoveInstance = onMoveInstance;
@@ -210,6 +214,7 @@ export default class InstancesRenderer {
onInstanceClicked: this.onInstanceClicked,
onInstanceRightClicked: this.onInstanceRightClicked,
onInstanceDoubleClicked: this.onInstanceDoubleClicked,
onInstanceDefaultSizeChanged: this.onInstanceDefaultSizeChanged,
onOverInstance: this.onOverInstance,
onOutInstance: this.onOutInstance,
onMoveInstance: this.onMoveInstance,
+17
View File
@@ -121,6 +121,9 @@ export type InstancesEditorPropsWithoutSizeAndScroll = {|
onInstancesMoved: (instances: Array<gdInitialInstance>) => void,
onInstancesResized: (instances: Array<gdInitialInstance>) => void,
onInstancesRotated: (instances: Array<gdInitialInstance>) => void,
// The size of instances without custom size can be known late (a
// texture or a model loaded after a while): what displays it must update.
onInstancesDefaultSizeChanged: (instances: Array<gdInitialInstance>) => void,
selectedObjectNames: Array<string>,
onContextMenu: (
x: number,
@@ -562,6 +565,7 @@ export default class InstancesEditor extends Component<Props, State> {
onInstanceClicked: this._onInstanceClicked,
onInstanceRightClicked: this._onInstanceRightClicked,
onInstanceDoubleClicked: this._onInstanceDoubleClicked,
onInstanceDefaultSizeChanged: this._onInstanceDefaultSizeChanged,
showObjectInstancesIn3D: this._showObjectInstancesIn3D,
});
this.selectionRectangle = new SelectionRectangle({
@@ -1391,6 +1395,19 @@ export default class InstancesEditor extends Component<Props, State> {
}
};
_instancesWithChangedDefaultSize: Array<gdInitialInstance> = [];
_onInstanceDefaultSizeChanged = (instance: gdInitialInstance) => {
// Reported once per frame, for all the instances concerned.
if (this._instancesWithChangedDefaultSize.length === 0) {
requestAnimationFrame(() => {
const instances = this._instancesWithChangedDefaultSize;
this._instancesWithChangedDefaultSize = [];
this.props.onInstancesDefaultSizeChanged(instances);
});
}
this._instancesWithChangedDefaultSize.push(instance);
};
_onInstanceDoubleClicked = (instance: gdInitialInstance) => {
if (!this.keyboardShortcuts.shouldIgnoreDoubleClick()) {
this.props.onInstanceDoubleClicked(instance);
@@ -35,7 +35,7 @@ export class BackgroundColorTreeViewItemContent implements TreeViewItemContent {
}
getDataSet(): ?HTMLDataset {
return null;
return { backgroundColor: 'true' };
}
getThumbnail(): ?string {
+15 -1
View File
@@ -44,6 +44,8 @@ type Props = {|
onCancel: () => void,
onApply: (hasAnyEffectBeenAdded: boolean) => void,
// Called after each change made to the layer while the dialog is open.
onChange?: () => void,
// Preview:
hotReloadPreviewButtonProps: HotReloadPreviewButtonProps,
@@ -59,6 +61,7 @@ const LayerEditorDialog = ({
initialInstances,
onCancel,
onApply,
onChange,
hotReloadPreviewButtonProps,
resourceManagementProps,
projectScopedContainersAccessor,
@@ -66,11 +69,18 @@ const LayerEditorDialog = ({
const forceUpdate = useForceUpdate();
const {
onCancelChanges,
notifyOfChange,
notifyOfChange: notifyEditorOfChange,
} = useSerializableObjectCancelableEditor({
serializableObject: layer,
onCancel,
});
const notifyOfChange = React.useCallback(
() => {
notifyEditorOfChange();
if (onChange) onChange();
},
[notifyEditorOfChange, onChange]
);
const [
camera3DFieldOfViewError,
setCamera3DFieldOfViewError,
@@ -200,6 +210,7 @@ const LayerEditorDialog = ({
return (
<Dialog
id="layer-editor-dialog"
title={
layer.getName() ? (
<Trans>{layer.getName()} properties</Trans>
@@ -279,6 +290,7 @@ const LayerEditorDialog = ({
onChange={(e, i, value: string) => {
layer.setDefaultCameraBehavior(value);
forceUpdate();
notifyOfChange();
}}
>
<SelectOption
@@ -343,6 +355,7 @@ const LayerEditorDialog = ({
onChange={(e, i, newValue: string) => {
layer.setRenderingType(newValue);
forceUpdate();
notifyOfChange();
}}
>
<SelectOption
@@ -377,6 +390,7 @@ const LayerEditorDialog = ({
layer.getCamera3DNearPlaneDistance()
);
forceUpdate();
notifyOfChange();
}}
>
<SelectOption
@@ -194,7 +194,7 @@ const CollisionMasksEditor = ({
if (enable) {
const answer = await showConfirmation({
title: t`Use same collision mask for all animations?`,
message: t`Having the same collision masks for all animations will erase and reset all the other animations collision masks. This can't be undone. Are you sure you want to share these collision masks amongst all the animations of the object?`,
message: t`Having the same collision masks for all animations will erase and reset all the other animations collision masks. Are you sure you want to share these collision masks amongst all the animations of the object?`,
confirmButtonLabel: t`Use same collision mask`,
dismissButtonLabel: t`Cancel`,
});
@@ -223,7 +223,7 @@ const CollisionMasksEditor = ({
const answer = await showConfirmation({
title: t`Use same collision mask for all frames?`,
message: t`
Having the same collision masks for all frames will erase and reset all the other frames collision masks. This can't be undone. Are you sure you want to share these collision masks amongst all the frames of the animation?`,
Having the same collision masks for all frames will erase and reset all the other frames collision masks. Are you sure you want to share these collision masks amongst all the frames of the animation?`,
confirmButtonLabel: t`Use same collision mask`,
dismissButtonLabel: t`Cancel`,
});
@@ -119,7 +119,7 @@ const PointsListBody = (props: PointsListBodyProps) => {
onClick={props.onSelectPoint}
onRemove={() => {
const answer = Window.showConfirmDialog(
"Are you sure you want to remove this point? This can't be undone."
'Are you sure you want to remove this point?'
);
if (!answer) return;
@@ -162,7 +162,7 @@ const PointsEditor = ({
const answer = await showConfirmation({
title: t`Use same points for all animations?`,
message: t`
Having the same points for all animations will erase and reset all the other animations points. This can't be undone. Are you sure you want to share these points amongst all the animations of the object?`,
Having the same points for all animations will erase and reset all the other animations points. Are you sure you want to share these points amongst all the animations of the object?`,
confirmButtonLabel: t`Use same points`,
dismissButtonLabel: t`Cancel`,
});
@@ -184,7 +184,7 @@ const PointsEditor = ({
if (enable) {
const answer = await showConfirmation({
title: t`Use same points for all frames?`,
message: t`Having the same points for all frames will erase and reset all the other frames points. This can't be undone. Are you sure you want to share these points amongst all the frames of the animation?`,
message: t`Having the same points for all frames will erase and reset all the other frames points. Are you sure you want to share these points amongst all the frames of the animation?`,
confirmButtonLabel: t`Use same points`,
dismissButtonLabel: t`Cancel`,
});
@@ -44,6 +44,8 @@ type Props = {|
hasAnyEffectBeenAdded: boolean
) => void,
onCancel: () => void,
// Called after each change made to the object while the dialog is open.
onChange?: () => void,
// Object renaming:
onRename: string => void,
@@ -145,7 +147,7 @@ const InnerDialog = (props: InnerDialogProps) => {
const {
onCancelChanges,
notifyOfChange,
notifyOfChange: notifyEditorOfChange,
hasUnsavedChanges,
getOriginalContentSerializedElement,
} = useSerializableObjectCancelableEditor({
@@ -153,6 +155,14 @@ const InnerDialog = (props: InnerDialogProps) => {
useProjectToUnserialize: project,
onCancel,
});
const { onChange } = props;
const notifyOfChange = React.useCallback(
() => {
notifyEditorOfChange();
if (onChange) onChange();
},
[notifyEditorOfChange, onChange]
);
useMarkObjectAsOpenedInEditor(object);
const [hasResourceChanged, setResourceChanged] = React.useState<boolean>(
@@ -129,12 +129,14 @@ const CompactObjectGroupEditor = ({
return isObjectListLocked ? (
<ListItem
key={objectName}
id={`group-object-${objectName}`}
primaryText={objectName}
leftIcon={icon}
/>
) : (
<ListItem
key={objectName}
id={`group-object-${objectName}`}
primaryText={objectName}
displayRemoveButton
onRemove={() => onObjectRemoved(objectName)}
@@ -116,6 +116,7 @@ export type CompactObjectGroupPropertiesEditorInterface = {|
* they must be recomputed to display changes made outside this editor.
*/
refreshVariables: () => void,
revealVariable: (nodeId: string) => void,
|};
export const CompactObjectGroupPropertiesEditor: React.ComponentType<{
@@ -184,6 +185,10 @@ export const CompactObjectGroupPropertiesEditor: React.ComponentType<{
ref,
() => ({
refreshVariables: forceRecomputeGroupVariablesContainer,
revealVariable: (nodeId: string) => {
if (variablesListRef.current)
variablesListRef.current.revealVariable(nodeId);
},
}),
[forceRecomputeGroupVariablesContainer]
);
+2 -2
View File
@@ -245,7 +245,7 @@ const ObjectGroupsList = React.forwardRef<Props, ObjectGroupsListInterface>(
const answer = await showDeleteConfirmation({
title: t`Remove group`,
message: t`Are you sure you want to remove this group? This can't be undone.`,
message: t`Are you sure you want to remove this group?`,
});
if (!answer) return;
@@ -370,7 +370,7 @@ const ObjectGroupsList = React.forwardRef<Props, ObjectGroupsListInterface>(
const answer = await showConfirmation({
title: t`Set as global group`,
message: t`Global elements help manage objects across multiple scenes and are recommended for frequently used objects. This action cannot be undone.
message: t`Global elements help manage objects across multiple scenes and are recommended for frequently used objects.
Do you want to set this as global group?`,
confirmButtonLabel: t`Set as global`,
+2 -5
View File
@@ -93,7 +93,7 @@ import { isMacLike } from '../Utils/Platform';
import optionalRequire from '../Utils/OptionalRequire';
import { useShouldAutofocusInput } from '../UI/Responsive/ScreenTypeMeasurer';
import { ProjectScopedContainersAccessor } from '../InstructionOrExpression/EventsScope';
import '../UI/UndoRedoFlash.css';
import { flashElement } from '../UI/UndoRedoFlash';
const electron = optionalRequire('electron');
@@ -720,10 +720,7 @@ const ProjectManager = React.forwardRef<Props, ProjectManagerInterface>(
candidate => candidate.getAttribute(datasetAttribute) === name
);
if (!element) return;
element.classList.remove('undo-redo-property-flash');
// Force a reflow so re-adding the class restarts the animation.
void element.offsetWidth;
element.classList.add('undo-redo-property-flash');
flashElement(element);
}, 100); // A few ms is enough for a new render to be done.
},
}));
@@ -168,6 +168,7 @@ export type SceneEditorsDisplayProps = {|
onInstancesMoved: (Array<gdInitialInstance>) => void,
onInstancesResized: (Array<gdInitialInstance>) => void,
onInstancesRotated: (Array<gdInitialInstance>) => void,
onInstancesDefaultSizeChanged: (Array<gdInitialInstance>) => void,
isInstanceOf3DObject: gdInitialInstance => boolean,
onSelectAllInstancesOfObjectInLayout: string => void,
@@ -14,6 +14,24 @@
* changed) can have a slightly different order for newly added items, which
* doesn't matter since they're never shown, only diffed.
*/
/**
* JSON of a value with its keys sorted: libGD can reorder the keys of a
* serialized item (like a behavior) after a change, which is not a change.
*/
export const stableStringify = (value: any): string =>
JSON.stringify(value, (key, nestedValue) =>
nestedValue &&
typeof nestedValue === 'object' &&
!Array.isArray(nestedValue)
? Object.keys(nestedValue)
.sort()
.reduce((sorted, key) => {
sorted[key] = nestedValue[key];
return sorted;
}, {})
: nestedValue
);
export const getIntermediateNamedItemsStates = (
before: Array<Object>,
after: Array<Object>
@@ -24,7 +42,7 @@ export const getIntermediateNamedItemsStates = (
const changedNames: Array<string> = [];
after.forEach(item => {
const beforeItem = beforeByName.get(item.name);
if (!beforeItem || JSON.stringify(beforeItem) !== JSON.stringify(item)) {
if (!beforeItem || stableStringify(beforeItem) !== stableStringify(item)) {
changedNames.push(item.name);
}
});
@@ -56,3 +74,67 @@ export const getIntermediateNamedItemsStates = (
});
return states;
};
const namedItemsKeys = ['behaviors', 'effects', 'variables'];
/**
* What differs between two serialized states of an object: one key per
* named item (`behaviors:Physics`, `variables:Life`...) that was added,
* removed or changed, and the name of every other top-level key that
* changed (`animations`, `width`...). Two edits with different keys are
* different undoable steps.
*/
export const getSerializedObjectChangeKeys = (
before: Object,
after: Object
): Array<string> => {
const keys: Array<string> = [];
new Set([...Object.keys(before), ...Object.keys(after)]).forEach(key => {
if (stableStringify(before[key]) === stableStringify(after[key])) return;
if (!namedItemsKeys.includes(key)) {
keys.push(key);
return;
}
const beforeByName = new Map(
(before[key] || []).map(item => [item.name, item])
);
const afterByName = new Map(
(after[key] || []).map(item => [item.name, item])
);
new Set([...beforeByName.keys(), ...afterByName.keys()]).forEach(name => {
if (
stableStringify(beforeByName.get(name)) !==
stableStringify(afterByName.get(name))
)
keys.push(`${key}:${name}`);
});
});
return keys;
};
/**
* The state of an array of named items where only the items whose name is
* in `appliedNames` are as in `after` (added, changed or removed), the
* others being as in `before` - so a change made to several arrays at once
* (the variables of every object of a group) can be split into one step
* per item name. With every name applied, the result is exactly `after`.
*/
export const getNamedItemsStateWithChangesApplied = (
before: Array<Object>,
after: Array<Object>,
appliedNames: Set<string>
): Array<Object> => {
const beforeByName = new Map(before.map(item => [item.name, item]));
const afterNames = new Set(after.map(item => item.name));
const state = after
.map(item =>
appliedNames.has(item.name) ? item : beforeByName.get(item.name) || null
)
.filter(Boolean);
// Removed items whose removal is not applied yet.
before.forEach(item => {
if (!afterNames.has(item.name) && !appliedNames.has(item.name))
state.push(item);
});
return state;
};
@@ -1,5 +1,9 @@
// @flow
import { getIntermediateNamedItemsStates } from './GranularNamedItemsHistorySteps';
import {
getIntermediateNamedItemsStates,
getNamedItemsStateWithChangesApplied,
getSerializedObjectChangeKeys,
} from './GranularNamedItemsHistorySteps';
describe('getIntermediateNamedItemsStates', () => {
it('returns nothing when nothing changed', () => {
@@ -109,3 +113,84 @@ describe('getIntermediateNamedItemsStates', () => {
expect(states).toEqual([after]);
});
});
describe('getSerializedObjectChangeKeys', () => {
const object = {
name: 'Player',
width: 10,
animations: [{ name: 'Idle' }],
behaviors: [{ name: 'Physics', gravity: 9 }],
variables: [{ name: 'Life', value: 3 }],
effects: [],
};
it('returns nothing when nothing changed', () => {
expect(getSerializedObjectChangeKeys(object, { ...object })).toEqual([]);
});
it('names the changed, added and removed named items', () => {
expect(
getSerializedObjectChangeKeys(object, {
...object,
behaviors: [{ name: 'Physics', gravity: 10 }, { name: 'Tween' }],
variables: [],
})
).toEqual(['behaviors:Physics', 'behaviors:Tween', 'variables:Life']);
});
it('ignores a different order of the keys', () => {
expect(
getSerializedObjectChangeKeys(object, {
...object,
behaviors: [{ gravity: 9, name: 'Physics' }],
})
).toEqual([]);
});
it('names the other changed top-level keys', () => {
expect(
getSerializedObjectChangeKeys(object, {
...object,
width: 20,
animations: [{ name: 'Idle' }, { name: 'Run' }],
})
).toEqual(['width', 'animations']);
});
});
describe('getNamedItemsStateWithChangesApplied', () => {
const before = [{ name: 'A', value: 1 }, { name: 'B', value: 2 }];
const after = [{ name: 'A', value: 10 }, { name: 'C', value: 3 }];
it('applies nothing with no name', () => {
expect(
getNamedItemsStateWithChangesApplied(before, after, new Set())
).toEqual(before);
});
it('applies the change, the addition or the removal of the given names only', () => {
expect(
getNamedItemsStateWithChangesApplied(before, after, new Set(['A']))
).toEqual([{ name: 'A', value: 10 }, { name: 'B', value: 2 }]);
expect(
getNamedItemsStateWithChangesApplied(before, after, new Set(['C']))
).toEqual([
{ name: 'A', value: 1 },
{ name: 'C', value: 3 },
{ name: 'B', value: 2 },
]);
expect(
getNamedItemsStateWithChangesApplied(before, after, new Set(['B']))
).toEqual([{ name: 'A', value: 1 }]);
});
it('is exactly the final state with every name applied', () => {
expect(
getNamedItemsStateWithChangesApplied(
before,
after,
new Set(['A', 'B', 'C'])
)
).toEqual(after);
});
});
@@ -191,6 +191,10 @@ export const InstanceOrObjectPropertiesEditorContainer: React.ComponentType<{
compactObjectPropertiesEditorRef.current.revealVariable(nodeId);
else if (compactInstancePropertiesEditorRef.current)
compactInstancePropertiesEditorRef.current.revealVariable(nodeId);
else if (compactObjectGroupPropertiesEditorRef.current)
compactObjectGroupPropertiesEditorRef.current.revealVariable(
nodeId
);
else if (compactScenePropertiesEditorRef.current)
compactScenePropertiesEditorRef.current.revealVariable(nodeId);
},
@@ -491,6 +491,9 @@ const MosaicEditorsDisplay: React.ComponentType<{
onInstancesMoved={props.onInstancesMoved}
onInstancesResized={props.onInstancesResized}
onInstancesRotated={props.onInstancesRotated}
onInstancesDefaultSizeChanged={
props.onInstancesDefaultSizeChanged
}
selectedObjectNames={selectedObjectNames}
onContextMenu={props.onContextMenu}
isInstanceOf3DObject={props.isInstanceOf3DObject}
@@ -105,6 +105,7 @@ export const getObjectsContainerHistoryTarget = (
const object = objectsContainer.getObject(name);
// Unserializing adds (or updates) the behaviors, but never removes
// the ones that are not part of the restored object anymore.
// Default (capability) behaviors are never serialized: keep them.
const targetBehaviorNames = new Set(
((serialized && serialized.behaviors) || []).map(
behavior => behavior.name
@@ -113,7 +114,11 @@ export const getObjectsContainerHistoryTarget = (
object
.getAllBehaviorNames()
.toJSArray()
.filter(behaviorName => !targetBehaviorNames.has(behaviorName))
.filter(
behaviorName =>
!targetBehaviorNames.has(behaviorName) &&
!object.getBehavior(behaviorName).isDefaultBehavior()
)
.forEach(behaviorName => object.removeBehavior(behaviorName));
unserializeFromJSObject(object, serialized, 'unserializeFrom', project);
} else {
@@ -173,6 +178,9 @@ export const getObjectGroupsContainerHistoryTarget = (
targetGroups.forEach(({ name, serialized }, index) => {
if (objectGroupsContainer.has(name)) {
unserializeFromJSObject(objectGroupsContainer.get(name), serialized);
const currentIndex = objectGroupsContainer.getPosition(name);
if (currentIndex !== index)
objectGroupsContainer.move(currentIndex, index);
} else {
const newGroup = objectGroupsContainer.insertNew(name, index);
unserializeFromJSObject(newGroup, serialized);
@@ -217,6 +217,46 @@ describe('getObjectsContainerHistoryTarget', () => {
project.delete();
});
it('keeps the default (capability) behaviors of an object across undo/redo', () => {
const { project, objectsContainer } = makeProject();
const player = objectsContainer.insertNewObject(
project,
'Sprite',
'Player',
0
);
const defaultBehaviorNames = player
.getAllBehaviorNames()
.toJSArray()
.filter(name => player.getBehavior(name).isDefaultBehavior());
// A sprite has capability behaviors like "Scale", "Opacity", "Effect".
expect(defaultBehaviorNames).toContain('Scale');
const target = getObjectsContainerHistoryTarget(
objectsContainer,
project,
noopEnsurePersistentUuids
);
const before = target.getValue();
player
.getVariables()
.insertNew('Life', 0)
.setValue(10);
const after = target.getValue();
for (let i = 0; i < 2; i++) {
target.setValue(before);
target.setValue(after);
}
const object = objectsContainer.getObject('Player');
defaultBehaviorNames.forEach(name => {
expect(object.hasBehaviorNamed(name)).toBe(true);
expect(object.getBehavior(name).isDefaultBehavior()).toBe(true);
});
project.delete();
});
it('restores the folder structure', () => {
const { project, objectsContainer } = makeProject();
const target = getObjectsContainerHistoryTarget(
@@ -328,6 +368,33 @@ describe('getObjectGroupsContainerHistoryTarget', () => {
});
});
describe('getObjectGroupsContainerHistoryTarget - order', () => {
it('restores the order of the groups', () => {
// $FlowFixMe[invalid-constructor]
const project = new gd.ProjectHelper.createNewGDJSProject();
const layout = project.insertNewLayout('Scene', 0);
const groups = layout.getObjects().getObjectGroups();
groups.insertNew('A', 0);
groups.insertNew('B', 1);
groups.insertNew('C', 2);
const target = getObjectGroupsContainerHistoryTarget(groups);
const getNames = () =>
[0, 1, 2].map(index => groups.getAt(index).getName());
const before = target.getValue();
groups.move(2, 0);
expect(getNames()).toEqual(['C', 'A', 'B']);
const after = target.getValue();
target.setValue(before);
expect(getNames()).toEqual(['A', 'B', 'C']);
target.setValue(after);
expect(getNames()).toEqual(['C', 'A', 'B']);
project.delete();
});
});
describe('getVariablesContainerHistoryTarget', () => {
const makeContainer = () => {
// $FlowFixMe[invalid-constructor]
@@ -395,6 +395,9 @@ const SwipeableDrawerEditorsDisplay: React.ComponentType<{
onInstancesMoved={props.onInstancesMoved}
onInstancesResized={props.onInstancesResized}
onInstancesRotated={props.onInstancesRotated}
onInstancesDefaultSizeChanged={
props.onInstancesDefaultSizeChanged
}
selectedObjectNames={selectedObjectNames}
onContextMenu={props.onContextMenu}
isInstanceOf3DObject={props.isInstanceOf3DObject}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
// @flow
import './UndoRedoFlash.css';
const flashClassName = 'undo-redo-property-flash';
const flashDurationInMs = 1600; // Same as in `UndoRedoFlash.css`.
/**
* Play the flash animation of an element, restarting it if needed.
* The class is removed once played: a CSS animation starts again every
* time its element is displayed again (like when coming back to a tab).
*/
export const flashElement = (element: HTMLElement) => {
element.classList.remove(flashClassName);
void element.offsetWidth; // Reflow, so that re-adding the class restarts the animation.
element.classList.add(flashClassName);
const removeFlash = () => {
element.classList.remove(flashClassName);
element.removeEventListener('animationend', removeFlash);
};
element.addEventListener('animationend', removeFlash);
// `animationend` is not fired for an element hidden in the meantime.
setTimeout(removeFlash, flashDurationInMs + 100);
};
@@ -19,6 +19,11 @@ type Props = {|
saveDebounceTimeInMs?: number,
|};
// The positions saved in the preferences, also kept here: a save is a React
// state update, which a panel mounted right after (reopened by an undo) can
// still read as not applied.
const latestSavedScrollPositions: Map<string, number> = new Map();
export const usePersistedScrollPosition = ({
project,
scrollViewRef,
@@ -34,11 +39,16 @@ export const usePersistedScrollPosition = ({
const projectId = project.getProjectUuid();
const saveScrollTimeoutId = React.useRef<?TimeoutID>(null);
// The save of the latest scroll position is debounced: when the panel is
// closed before it happened, do it right away (or the panel would open
// again where it was a moment before being closed).
const savePendingScrollPositionRef = React.useRef<() => void>(() => {});
React.useEffect(
() => () => {
if (saveScrollTimeoutId.current) {
clearTimeout(saveScrollTimeoutId.current);
saveScrollTimeoutId.current = null;
savePendingScrollPositionRef.current();
}
},
[]
@@ -53,10 +63,15 @@ export const usePersistedScrollPosition = ({
const editorStateForProject = getEditorStateForProject(projectId);
if (!editorStateForProject) return;
const latestSavedScrollPosition = latestSavedScrollPositions.get(
`${projectId}/${persistedPanelStateType}/${persistedPanelStateId}`
);
const scrollPosition =
editorStateForProject.propertiesPanel[persistedPanelStateType]?.[
persistedPanelStateId
]?.scrollPosition;
latestSavedScrollPosition !== undefined
? latestSavedScrollPosition
: editorStateForProject.propertiesPanel[persistedPanelStateType]?.[
persistedPanelStateId
]?.scrollPosition;
if (!Number.isFinite(scrollPosition)) {
return;
}
@@ -81,7 +96,15 @@ export const usePersistedScrollPosition = ({
clearTimeout(saveScrollTimeoutId.current);
}
saveScrollTimeoutId.current = setTimeout(() => {
// Read now: the scroll view can be gone when saved after the panel
// is closed.
const scrollPosition = scrollView.getScrollPosition();
const saveScrollPosition = () => {
saveScrollTimeoutId.current = null;
latestSavedScrollPositions.set(
`${projectId}/${persistedPanelStateType}/${persistedPanelStateId}`,
scrollPosition
);
const currentEditorState = getEditorStateForProject(projectId);
const panelState: EditorStateForPropertyPanel = {
@@ -89,7 +112,7 @@ export const usePersistedScrollPosition = ({
...currentEditorState?.propertiesPanel[persistedPanelStateType]?.[
persistedPanelStateId
],
scrollPosition: scrollView.getScrollPosition(),
scrollPosition,
};
setEditorStateForProject(projectId, {
@@ -101,7 +124,12 @@ export const usePersistedScrollPosition = ({
},
},
});
}, saveDebounceTimeInMs);
};
savePendingScrollPositionRef.current = saveScrollPosition;
saveScrollTimeoutId.current = setTimeout(
saveScrollPosition,
saveDebounceTimeInMs
);
},
[
getEditorStateForProject,
@@ -58,6 +58,7 @@ export const Default = (): React.Node => (
onInstanceDoubleClicked={() => {}}
onInstancesMoved={() => {}}
onInstancesResized={() => {}}
onInstancesDefaultSizeChanged={() => {}}
onInstancesRotated={() => {}}
selectedObjectNames={[]}
onContextMenu={() => {}}