diff --git a/GDJS/Runtime/InGameEditor/InGameEditor.tsx b/GDJS/Runtime/InGameEditor/InGameEditor.tsx index 0b35c92130..c2649b987d 100644 --- a/GDJS/Runtime/InGameEditor/InGameEditor.tsx +++ b/GDJS/Runtime/InGameEditor/InGameEditor.tsx @@ -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 && diff --git a/GDJS/Runtime/debugger-client/hot-reloader.ts b/GDJS/Runtime/debugger-client/hot-reloader.ts index 34efd3280b..1ae12706dd 100644 --- a/GDJS/Runtime/debugger-client/hot-reloader.ts +++ b/GDJS/Runtime/debugger-client/hot-reloader.ts @@ -1932,6 +1932,28 @@ namespace gdjs { sizeChanged = true; } } + + // Check if flips changed (objects with the flippable capability). + const flippableObject = + runtimeObject as unknown as Partial; + 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 & diff --git a/newIDE/app/scripts/undo-redo-e2e.js b/newIDE/app/scripts/undo-redo-e2e.js index ef058330f6..0323668ef7 100644 --- a/newIDE/app/scripts/undo-redo-e2e.js +++ b/newIDE/app/scripts/undo-redo-e2e.js @@ -222,7 +222,11 @@ class Editor { const { page } = this; // Native confirmation dialogs (like when removing a behavior) block the // page until answered. - page.on('dialog', dialog => dialog.accept()); + this.nativeDialogMessages = []; + page.on('dialog', dialog => { + this.nativeDialogMessages.push(dialog.message()); + dialog.accept(); + }); // Crashes of the engine (like an access to a deleted object) must fail // any scenario. this.fatalErrors = []; @@ -751,6 +755,52 @@ class Editor { }, objectName); } + /** Run a function in the 3D editor's frame, with the in-game editor. */ + evaluateIn3DEditor(fn, ...args) { + const frame = this.page + .frames() + .find(frame => /in-game-editor-preview/.test(frame.url())); + return frame.evaluate( + (fnSource, ...args) => { + const game = window.__game; + if (!game || !game._inGameEditor) return 'no game'; + // eslint-disable-next-line no-new-func + return new Function('return ' + fnSource)()( + game._inGameEditor, + ...args + ); + }, + fn.toString(), + ...args + ); + } + + /** + * Whether the gizmo of the selection (move/rotate/scale tool) is shown + * and usable: visible, with finite, non-zero transforms. + */ + getGizmoStateIn3DEditor() { + return this.evaluateIn3DEditor(editor => { + const controls = editor._selectionControls; + if (!controls) return 'no selection controls'; + const helper = controls.threeTransformControls.getHelper(); + const { dummyThreeObject } = controls; + const scale = dummyThreeObject.scale.toArray(); + const problems = []; + if (!helper.visible) problems.push('helper hidden'); + if (!scale.every(value => Number.isFinite(value) && value !== 0)) + problems.push(`dummy scale ${scale.join()}`); + helper.updateMatrixWorld(true); + let hasNonFiniteMatrix = false; + helper.traverse(child => { + if (child.matrixWorld.elements.some(value => !Number.isFinite(value))) + hasNonFiniteMatrix = true; + }); + if (hasNonFiniteMatrix) problems.push('non finite gizmo matrix'); + return problems.length ? problems.join(', ') : 'ok'; + }); + } + /** Start recording the elements getting the undo/redo highlight. */ recordHighlights() { return this.page.evaluate(() => { @@ -1407,6 +1457,121 @@ const makeDimensionsScenario = is3D => async editor => { expectEqual(await getDimensions(), ['150', '100', '100'], 'after redo'); await expectWidthIn3DEditor(150, 'after redo'); }; +const makeZeroSizeScenario = keepRatio => async editor => { + await editor.switchTo3D(); + await editor.selectInstancesOf('MyCube'); + const field = name => `#instance-properties-editor [id="${name}"]`; + const getDimensions = async () => [ + await editor.getFieldValue(field('Width')), + await editor.getFieldValue(field('Height')), + await editor.getFieldValue(field('Depth')), + ]; + await editor.waitUntil( + 'default dimensions to be known', + async () => (await getDimensions()).join() === '100,100,100', + 20000 + ); + if (keepRatio) { + await editor.page.click(field('Keep ratio')); + await sleep(HISTORY_SAVE_DELAY); + } + await editor.evaluateIn3DEditor(editor => + editor._setTransformControlsMode('scale') + ); + await editor.waitUntil( + 'scale gizmo shown', + async () => (await editor.getGizmoStateIn3DEditor()) === 'ok', + 15000 + ); + + await editor.setFieldValue(field('Height'), '0'); + expectEqual((await getDimensions())[1], '0', 'height set to 0'); + await editor.waitUntil( + 'size applied in the 3D editor', + async () => + (await editor.getWidthIn3DEditor('MyCube')) === (keepRatio ? 0 : 100), + 15000 + ); + + await editor.recordHighlights(); + await editor.undo(); + expectEqual(await getDimensions(), ['100', '100', '100'], 'after undo'); + const highlights = await editor.getHighlights(); + expectEqual( + highlights.includes('Height'), + true, + `height highlighted (${highlights})` + ); + await editor.waitUntil( + 'size restored in the 3D editor', + async () => (await editor.getWidthIn3DEditor('MyCube')) === 100, + 15000 + ); + let gizmoState = null; + try { + await editor.waitUntil( + 'scale gizmo shown again', + async () => + (gizmoState = await editor.getGizmoStateIn3DEditor()) === 'ok', + 5000 + ); + } catch (error) { + expectEqual(gizmoState, 'ok', 'scale gizmo after undo'); + } +}; +if (with3D) { + scenarios[ + 'undo of a size set to 0 keeps the scale gizmo and highlights the height (3D editor)' + ] = makeZeroSizeScenario(false); + scenarios[ + 'undo of a size set to 0 with the ratio kept keeps the scale gizmo and highlights the height (3D editor)' + ] = makeZeroSizeScenario(true); +} + +if (with3D) { + scenarios['undo/redo of a flip is shown in the 3D editor'] = async editor => { + await editor.switchTo3D(); + await editor.selectInstancesOf('MyCube'); + const isFlippedXIn3DEditor = () => + editor.evaluateIn3DEditor(editor => { + const objects = editor._currentScene.getObjects('MyCube'); + return objects && objects.length + ? objects[0].isFlippedX() + : 'no object'; + }); + await editor.waitUntil( + 'object in the 3D editor', + async () => (await isFlippedXIn3DEditor()) === false, + 20000 + ); + // The first flip button: X. + await editor.page.evaluate(() => { + document + .querySelector('#instance-properties-editor [id="Flip"]') + .querySelector('button') + .click(); + }); + await sleep(HISTORY_SAVE_DELAY); + await editor.waitUntil( + 'flipped in the 3D editor', + async () => (await isFlippedXIn3DEditor()) === true, + 15000 + ); + await editor.undo(); + await editor.waitUntil( + 'flip undone in the 3D editor', + async () => (await isFlippedXIn3DEditor()) === false, + 15000 + ); + await editor.redo(); + await editor.waitUntil( + 'flip redone in the 3D editor', + async () => (await isFlippedXIn3DEditor()) === true, + 15000 + ); + }; +} + scenarios[ 'instance dimensions survive undo/redo (2D editor)' ] = makeDimensionsScenario(false); @@ -2422,11 +2587,13 @@ scenarios['deleting and renaming a group can be undone'] = async editor => { await (await groupRow('MyGroup')).asElement().click({ button: 'right' }); await editor.clickMenuItem(/^delete/i); await sleep(500); - if (await editor.page.$('[role="dialog"]')) + if (await editor.page.$('[role="dialog"]')) { + await expectNoUndoneClaimInDialog(editor, 'group deletion confirmation'); await editor.clickButtonWithText( '[role="dialog"]', /^(confirm|delete|yes|remove)/ ); + } await editor.waitUntil( 'group deleted', async () => !(await hasGroup('MyGroup')) @@ -2518,6 +2685,241 @@ scenarios['renaming a layer can be undone'] = async editor => { expectEqual(await hasLayer('Background'), false, 'old name after redo'); }; +const clickEditorTab = async (editor, label) => { + const handle = await editor.page.evaluateHandle(label => { + const elements = Array.from(document.querySelectorAll('span, p, div')); + return elements.find( + element => element.children.length === 0 && element.textContent === label + ); + }, label); + await handle.asElement().click(); + await sleep(1500); +}; + +const getRunningFlashAnimationsCount = editor => + editor.page.evaluate( + () => + document + .getAnimations() + .filter( + animation => + animation.animationName === 'undo-redo-property-flash' && + animation.playState === 'running' + ).length + ); + +scenarios[ + 'coming back to the scene tab does not flash the last undo again' +] = async editor => { + const x = '#instance-properties-editor [id="X"]'; + await editor.selectInstancesOf('MyObject'); + await editor.setFieldValue(x, '450'); + await editor.recordHighlights(); + await editor.undo(); + expectEqual(await editor.getHighlights(), ['X'], 'flashed by the undo'); + // Let the flash end. + await editor.waitUntil( + 'flash ended', + async () => (await getRunningFlashAnimationsCount(editor)) === 0 + ); + + await clickEditorTab(editor, 'Scene (Events)'); + await clickEditorTab(editor, 'Scene'); + await editor.waitUntil('scene editor active again', () => + editor.page.evaluate( + () => !!document.querySelector('#scene-editor[data-active]') + ) + ); + expectEqual( + await getRunningFlashAnimationsCount(editor), + 0, + 'flashes running after coming back' + ); +}; + +/** + * The whole project serialized, plus every object serialized as the game + * gets it (with its default "capability" behaviors, which the project + * serialization leaves out). Keys are sorted so that two equal values + * give the same string. + */ +const getSerializedProject = editor => + editor.page.evaluate(() => { + const element = document.getElementById('scene-editor'); + const fiberKey = Object.keys(element).find(key => + key.startsWith('__reactFiber$') + ); + let fiber = fiberKey ? element[fiberKey] : null; + while (fiber && !(fiber.stateNode && fiber.stateNode.instancesSelection)) + fiber = fiber.return; + const { project, objectsContainer } = fiber.stateNode.props; + const toJSObject = serialize => { + // eslint-disable-next-line no-undef + const serializedElement = new gd.SerializerElement(); + serialize(serializedElement); + // eslint-disable-next-line no-undef + const json = gd.Serializer.toJSON(serializedElement); + serializedElement.delete(); + return JSON.parse(json); + }; + const objectsAsInGame = {}; + for (let i = 0; i < objectsContainer.getObjectsCount(); i++) { + const object = objectsContainer.getObjectAt(i); + objectsAsInGame[object.getName()] = toJSObject(serializedElement => + // eslint-disable-next-line no-undef + gd.BehaviorDefaultFlagClearer.serializeObjectWithCleanDefaultBehaviorFlags( + object, + serializedElement + ) + ); + } + const sortKeys = value => + Array.isArray(value) + ? value.map(sortKeys) + : value && typeof value === 'object' + ? Object.fromEntries( + Object.keys(value) + .sort() + .map(key => [key, sortKeys(value[key])]) + ) + : value; + return JSON.stringify( + sortKeys({ + project: toJSObject(serializedElement => + project.serializeTo(serializedElement) + ), + objectsAsInGame, + }) + ); + }); + +/** + * What the editor does after many edits (adding a behavior, undoing...): + * done once before comparing, so that it's not seen as a difference. + */ +const normalizeBehaviorsSharedData = editor => + editor.page.evaluate(() => { + const element = document.getElementById('scene-editor'); + const fiberKey = Object.keys(element).find(key => + key.startsWith('__reactFiber$') + ); + let fiber = fiberKey ? element[fiberKey] : null; + while (fiber && !(fiber.stateNode && fiber.stateNode.instancesSelection)) + fiber = fiber.return; + // eslint-disable-next-line no-undef + gd.WholeProjectRefactorer.updateBehaviorsSharedData( + fiber.stateNode.props.project + ); + }); + +const clickIfPresent = (editor, selector) => + editor.page.evaluate(selector => { + const element = document.querySelector(selector); + if (element) element.click(); + }, selector); + +const exactUndoEdits = { + 'object property': async editor => { + await editor.selectObjectInList('MyCube'); + await clickIfPresent(editor, '#object-properties-section-unfold-button'); + await editor.setFieldValue('#object-properties-editor [id="width"]', '200'); + }, + 'object variable': async editor => { + await editor.selectObjectInList('VariablesObject'); + await editor.unfoldVariablesSection( + '#object-properties-editor', + 'object-variables-section' + ); + await editor.setFieldValue( + '#object-properties-editor #variable-0-text-value', + '100' + ); + }, + 'object behavior property': async editor => { + await editor.selectObjectInList('MyObject'); + const panel = '#object-properties-editor'; + const behaviorPanel = `${panel} [id="behavior-panel-DestroyOutside"]`; + const field = `${behaviorPanel} [id="extraBorder"]`; + await clickIfPresent(editor, `${panel} #behaviors-section-unfold-button`); + await editor.waitUntil('behavior listed', () => + isSelectorVisible(editor, behaviorPanel) + ); + if (!(await isSelectorVisible(editor, field))) { + await clickIfPresent(editor, `${behaviorPanel} button`); + await editor.waitUntil('behavior unfolded', () => + isSelectorVisible(editor, field) + ); + } + await editor.setFieldValue(field, '50'); + }, + 'instance property': async editor => { + await editor.selectInstancesOf('MyObject'); + await editor.setFieldValue('#instance-properties-editor [id="X"]', '450'); + }, + 'instance variable': async editor => { + await editor.selectInstancesOf('MyObject'); + await editor.unfoldVariablesSection( + '#instance-properties-editor', + 'instance-variables-section' + ); + await editor.setFieldValue( + '#instance-properties-editor #variable-0-text-value', + '100' + ); + }, +}; +Object.keys(exactUndoEdits).forEach(kind => { + const name = `undo of an ${kind} change restores the project exactly`; + fixtureSetups[name] = behaviorFixtureSetup; + scenarios[name] = async editor => { + await normalizeBehaviorsSharedData(editor); + const before = await getSerializedProject(editor); + await exactUndoEdits[kind](editor); + await sleep(HISTORY_SAVE_DELAY); + const changed = await getSerializedProject(editor); + expectEqual(changed !== before, true, 'project changed by the edit'); + + await editor.undo(); + const afterUndo = await getSerializedProject(editor); + if (afterUndo !== before) + throw new Error( + `Project differs after undo: ${describeJsonDifference( + before, + afterUndo + )}` + ); + await editor.redo(); + expectEqual( + await getSerializedProject(editor), + changed, + 'project after redo' + ); + }; +}); + +/** The first path at which two serialized JSON values differ. */ +const describeJsonDifference = (expectedJson, actualJson) => { + const walk = (expected, actual, path) => { + if (JSON.stringify(expected) === JSON.stringify(actual)) return null; + if ( + typeof expected !== 'object' || + typeof actual !== 'object' || + !expected || + !actual + ) + return `${path}: expected ${JSON.stringify( + expected + )}, got ${JSON.stringify(actual)}`; + const keys = new Set([...Object.keys(expected), ...Object.keys(actual)]); + for (const key of keys) { + const difference = walk(expected[key], actual[key], `${path}.${key}`); + if (difference) return difference; + } + return `${path}: differs`; + }; + return walk(JSON.parse(expectedJson), JSON.parse(actualJson), 'project'); +}; + scenarios[ 'locking an instance from the instances list can be undone' ] = async editor => { @@ -2657,10 +3059,20 @@ scenarios['undo highlights a select field of an instance'] = async editor => { await editor.undo(); expectEqual(await editor.getPanel(), 'group', 'panel after undo'); expectEqual(await getMembersCount(), 2, 'members after undo'); + // The object put back in the group is highlighted, not the section. + expectEqual( + (await editor.getHighlights()).sort(), + ['group-object-OtherObject', 'group-row:MyGroup'], + 'highlighted elements after undo' + ); + await editor.recordHighlights(); + await editor.redo(); + expectEqual(await getMembersCount(), 1, 'members after redo'); + // Its row is gone: the section is highlighted. expectEqual( (await editor.getHighlights()).sort(), ['group-objects-section', 'group-row:MyGroup'], - 'highlighted elements' + 'highlighted elements after redo' ); }; } @@ -2963,6 +3375,1369 @@ const clickSelectorIfPresent = (editor, selector) => }; } +const getUndoStepsCount = editor => + editor.page.evaluate(() => { + const element = document.getElementById('scene-editor'); + const fiberKey = Object.keys(element).find(key => + key.startsWith('__reactFiber$') + ); + let fiber = fiberKey ? element[fiberKey] : null; + while (fiber && !(fiber.stateNode && fiber.stateNode.instancesSelection)) + fiber = fiber.return; + return fiber.stateNode.state.history.previousActions.length; + }); + +const clickDialogTab = async (editor, label) => { + await editor.page.evaluate(label => { + Array.from( + document.querySelectorAll( + '[role="dialog"] [role="tab"], [role="dialog"] button' + ) + ) + .find(element => element.textContent === label) + .click(); + }, label); + await sleep(500); +}; + +const openObjectEditorDialogFromBehaviors = async editor => { + await editor.selectObjectInList('MyObject'); + await clickSelectorIfPresent(editor, '#behaviors-section-unfold-button'); + await editor.clickSectionHeaderButton('behaviors-section', 'open-editor'); + await editor.page.waitForSelector('#object-editor-dialog'); + await clickDialogTab(editor, 'Behaviors'); +}; + +{ + const name = 'object editor dialog records the changes in the order made'; + fixtureSetups[name] = project => { + behaviorFixtureSetup(project); + project.layouts[0].objects[0].effects = [ + { + name: 'MyEffect', + effectType: 'Sepia', + doubleParameters: { opacity: 1 }, + stringParameters: {}, + booleanParameters: {}, + }, + ]; + }; + scenarios[name] = async editor => { + const stepsBefore = await getUndoStepsCount(editor); + await openObjectEditorDialogFromBehaviors(editor); + await editor.setFieldValue('[role="dialog"] [id="extraBorder"]', '50'); + await clickDialogTab(editor, 'Effects'); + await editor.setFieldValue('[role="dialog"] [id="opacity"]', '0.5'); + await clickDialogTab(editor, 'Behaviors'); + await editor.setFieldValue('[role="dialog"] [id="extraBorder"]', '70'); + await applyDialog(editor); + expectEqual( + (await getUndoStepsCount(editor)) - stepsBefore, + 3, + 'steps recorded by the dialog' + ); + + const getValues = async () => { + const { objectsAsInGame } = JSON.parse( + await getSerializedProject(editor) + ); + const object = objectsAsInGame.MyObject; + return [ + object.behaviors.find(({ name }) => name === 'DestroyOutside') + .extraBorder, + object.effects[0].doubleParameters.opacity, + ]; + }; + expectEqual(await getValues(), [70, 0.5], 'applied'); + await editor.undo(); + expectEqual(await getValues(), [50, 0.5], 'after the first undo'); + await editor.undo(); + expectEqual(await getValues(), [50, 1], 'after the second undo'); + await editor.undo(); + expectEqual(await getValues(), [0, 1], 'after the third undo'); + await editor.redo(); + expectEqual(await getValues(), [50, 1], 'after a redo'); + }; +} + +scenarios[ + 'adding a behavior then editing it in the object editor dialog are two steps' +] = async editor => { + const stepsBefore = await getUndoStepsCount(editor); + await openObjectEditorDialogFromBehaviors(editor); + await editor.page.click('[role="dialog"] #add-behavior-button'); + await editor.page.waitForSelector( + '#behavior-item-DestroyOutsideBehavior--DestroyOutside' + ); + await editor.page.click( + '#behavior-item-DestroyOutsideBehavior--DestroyOutside' + ); + await editor.page.waitForSelector('[role="dialog"] [id="extraBorder"]'); + await sleep(HISTORY_SAVE_DELAY); + const getBehavior = async () => { + const { objectsAsInGame } = JSON.parse(await getSerializedProject(editor)); + return objectsAsInGame.MyObject.behaviors.find( + ({ name }) => name === 'DestroyOutside' + ); + }; + const defaultBorder = (await getBehavior()).extraBorder; + await editor.setFieldValue('[role="dialog"] [id="extraBorder"]', '50'); + await applyDialog(editor); + expectEqual( + (await getUndoStepsCount(editor)) - stepsBefore, + 2, + 'steps recorded by the dialog' + ); + + expectEqual((await getBehavior()).extraBorder, 50, 'applied'); + await editor.undo(); + expectEqual( + (await getBehavior()).extraBorder, + defaultBorder, + 'after the first undo' + ); + await editor.undo(); + expectEqual(await getBehavior(), undefined, 'after the second undo'); +}; + +{ + const name = 'cancelling the object editor dialog records no step'; + fixtureSetups[name] = behaviorFixtureSetup; + scenarios[name] = async editor => { + const stepsBefore = await getUndoStepsCount(editor); + await openObjectEditorDialogFromBehaviors(editor); + await editor.setFieldValue('[role="dialog"] [id="extraBorder"]', '50'); + // Long enough for the change to be recorded. + await sleep(HISTORY_SAVE_DELAY); + await editor.clickButtonWithText('#object-editor-dialog', /^cancel$/); + await sleep(500); + // A confirmation asks to confirm ("Cancel") or to continue editing. + if (await editor.page.$('#object-editor-dialog')) + await editor.clickButtonWithText( + '[role="dialog"]:not(#object-editor-dialog)', + /^cancel$/ + ); + await editor.waitUntil( + 'dialog closed', + async () => !(await editor.page.$('#object-editor-dialog')) + ); + expectEqual( + await getUndoStepsCount(editor), + stepsBefore, + 'steps after cancelling' + ); + expectEqual( + await editor.getFieldValue( + '#object-properties-editor [id="behavior-panel-DestroyOutside"] [id="extraBorder"]' + ), + '0', + 'value after cancelling' + ); + }; +} + +const getLayerAsInProject = async (editor, layerName) => + JSON.parse(await getSerializedProject(editor)).project.layouts[0].layers.find( + ({ name }) => name === layerName + ); + +const openLayerEditorDialogFromEffects = async editor => { + await editor.selectLayer('Background'); + await clickSelectorIfPresent( + editor, + '#layer-2d-effects-section-unfold-button' + ); + await editor.clickSectionHeaderButton( + 'layer-2d-effects-section', + 'open-editor' + ); + await editor.page.waitForSelector('#layer-editor-dialog'); +}; + +{ + const name = 'layer editor dialog records the changes in the order made'; + fixtureSetups[name] = effectsFixtureSetup; + scenarios[name] = async editor => { + const stepsBefore = await getUndoStepsCount(editor); + await openLayerEditorDialogFromEffects(editor); + await editor.setFieldValue('[role="dialog"] [id="opacity"]', '0.5'); + await clickDialogTab(editor, 'Properties'); + await editor.setFieldValue( + '[role="dialog"] [id="Far plane distance"]', + '5000' + ); + await clickDialogTab(editor, 'Effects'); + await editor.setFieldValue('[role="dialog"] [id="opacity"]', '0.7'); + await applyDialog(editor); + expectEqual( + (await getUndoStepsCount(editor)) - stepsBefore, + 3, + 'steps recorded by the dialog' + ); + + const getValues = async () => { + const layer = await getLayerAsInProject(editor, 'Background'); + return [ + layer.effects.find(({ name }) => name === 'My2DEffect').doubleParameters + .opacity, + layer.camera3DFarPlaneDistance, + ]; + }; + expectEqual(await getValues(), [0.7, 5000], 'applied'); + await editor.undo(); + expectEqual(await getValues(), [0.5, 5000], 'after the first undo'); + await editor.undo(); + expectEqual(await getValues(), [0.5, 10000], 'after the second undo'); + await editor.undo(); + expectEqual(await getValues(), [1, 10000], 'after the third undo'); + await editor.redo(); + expectEqual(await getValues(), [0.5, 10000], 'after a redo'); + }; +} + +{ + const name = 'cancelling the layer editor dialog records no step'; + fixtureSetups[name] = effectsFixtureSetup; + scenarios[name] = async editor => { + const stepsBefore = await getUndoStepsCount(editor); + await openLayerEditorDialogFromEffects(editor); + await editor.setFieldValue('[role="dialog"] [id="opacity"]', '0.5'); + // Long enough for the change to be recorded. + await sleep(HISTORY_SAVE_DELAY); + await editor.clickButtonWithText('#layer-editor-dialog', /^cancel$/); + await sleep(500); + // A confirmation asks to confirm ("Cancel") or to continue editing. + if (await editor.page.$('#layer-editor-dialog')) + await editor.clickButtonWithText( + '[role="dialog"]:not(#layer-editor-dialog)', + /^cancel$/ + ); + await editor.waitUntil( + 'dialog closed', + async () => !(await editor.page.$('#layer-editor-dialog')) + ); + expectEqual( + await getUndoStepsCount(editor), + stepsBefore, + 'steps after cancelling' + ); + expectEqual( + (await getLayerAsInProject(editor, 'Background')).effects[0] + .doubleParameters.opacity, + 1, + 'value after cancelling' + ); + }; +} + +{ + const name = + 'undo of a group variable change highlights the group, not its objects'; + fixtureSetups[name] = project => { + project.layouts[0].objectsGroups.push({ + name: 'VariablesGroup', + objects: [{ name: 'VariablesObject' }], + }); + }; + scenarios[name] = async editor => { + await editor.selectGroup('VariablesGroup'); + const panel = '#object-group-properties-editor'; + await editor.unfoldVariablesSection(panel, 'group-variables-section'); + await editor.setFieldValue(`${panel} #variable-0-text-value`, '134'); + // The groups panel, closed, is opened again to show the group. + await (await editor.page.$( + '#toolbar-open-object-groups-panel-button' + )).click(); + await editor.waitUntil( + 'groups panel closed', + async () => !(await editor.page.$('#objects-groups-list')) + ); + await editor.recordHighlights(); + await editor.undo(); + await editor.waitUntil('groups panel opened', () => + exists(editor, '#objects-groups-list') + ); + const highlights = await editor.getHighlights(); + expectEqual(await editor.getPanel(), 'group', 'panel after undo'); + expectEqual( + highlights.includes('group-row:VariablesGroup'), + true, + `group row highlighted (${highlights})` + ); + expectEqual( + highlights.includes('VarA'), + true, + `variable highlighted in the group panel (${highlights})` + ); + expectEqual( + highlights.some(highlight => highlight.startsWith('object-row:')), + false, + `no object row highlighted (${highlights})` + ); + }; +} + +{ + const name = + 'undo of a group behavior change highlights the group, not its objects'; + fixtureSetups[name] = behaviorFixtureSetup; + scenarios[name] = async editor => { + await editor.selectGroup('MyGroup'); + const panel = '#object-group-properties-editor'; + const behaviorPanel = `${panel} [id="behavior-panel-DestroyOutside"]`; + const field = `${behaviorPanel} [id="extraBorder"]`; + await clickSelectorIfPresent( + editor, + `${panel} #behaviors-section-unfold-button` + ); + await editor.waitUntil('behavior listed', () => + isSelectorVisible(editor, behaviorPanel) + ); + if (!(await isSelectorVisible(editor, field))) { + await clickSelectorIfPresent(editor, `${behaviorPanel} button`); + await editor.waitUntil('behavior unfolded', () => + isSelectorVisible(editor, field) + ); + } + await editor.setFieldValue(field, '50'); + await editor.recordHighlights(); + await editor.undo(); + const highlights = await editor.getHighlights(); + expectEqual(await editor.getPanel(), 'group', 'panel after undo'); + expectEqual( + highlights.includes('group-row:MyGroup'), + true, + `group row highlighted (${highlights})` + ); + expectEqual( + highlights.includes('extraBorder'), + true, + `behavior field highlighted in the group panel (${highlights})` + ); + expectEqual( + highlights.some(highlight => highlight.startsWith('object-row:')), + false, + `no object row highlighted (${highlights})` + ); + }; +} + +const makeBehaviorAdditionRemovalScenario = ({ + select, + panel, +}) => async editor => { + await select(editor); + const section = 'behaviors-section'; + const behaviorPanel = `${panel} [id="behavior-panel-DestroyOutside"]`; + await clickSelectorIfPresent(editor, `#${section}-unfold-button`); + + // Addition. + await editor.clickSectionHeaderButton(section, 'add'); + await editor.page.waitForSelector( + '#behavior-item-DestroyOutsideBehavior--DestroyOutside' + ); + await editor.page.click( + '#behavior-item-DestroyOutsideBehavior--DestroyOutside' + ); + await editor.waitUntil('behavior added', () => + isSelectorVisible(editor, behaviorPanel) + ); + await sleep(HISTORY_SAVE_DELAY); + await editor.recordHighlights(); + await editor.undo(); + await editor.waitUntil( + 'behavior removed by the undo', + async () => !(await editor.page.$(behaviorPanel)) + ); + // The object's row in the list flashes too, like for any object change. + const expectHighlight = async (highlight, description) => { + const highlights = await editor.getHighlights(); + expectEqual( + highlights.includes(highlight), + true, + `${description} (${highlights})` + ); + }; + await expectHighlight(section, 'undo of an addition'); + await editor.recordHighlights(); + await editor.redo(); + await editor.waitUntil('behavior back after the redo', () => + isSelectorVisible(editor, behaviorPanel) + ); + await expectHighlight('behavior-panel-DestroyOutside', 'redo of an addition'); + + // Removal. + editor.nativeDialogMessages = []; + await clickSelectorIfPresent(editor, `${behaviorPanel} #remove-behavior`); + await sleep(500); + if (await editor.page.$('[role="dialog"]')) + await editor.clickButtonWithText( + '[role="dialog"]', + /^(confirm|delete|yes|remove)/ + ); + await editor.waitUntil( + 'behavior removed', + async () => !(await editor.page.$(behaviorPanel)) + ); + // The removal can be undone: the confirmation must not say otherwise. + expectEqual( + editor.nativeDialogMessages.some(message => /undone/i.test(message)), + false, + `confirmation message (${editor.nativeDialogMessages.join(' | ')})` + ); + await sleep(HISTORY_SAVE_DELAY); + await editor.recordHighlights(); + await editor.undo(); + await editor.waitUntil('behavior back after the undo', () => + isSelectorVisible(editor, behaviorPanel) + ); + await expectHighlight('behavior-panel-DestroyOutside', 'undo of a removal'); + await editor.recordHighlights(); + await editor.redo(); + await editor.waitUntil( + 'behavior removed by the redo', + async () => !(await editor.page.$(behaviorPanel)) + ); + await expectHighlight(section, 'redo of a removal'); +}; +scenarios[ + 'undo/redo of a behavior addition and removal are highlighted' +] = makeBehaviorAdditionRemovalScenario({ + select: editor => editor.selectObjectInList('MyObject'), + panel: '#object-properties-editor', +}); +scenarios[ + 'undo/redo of a group behavior addition and removal are highlighted' +] = makeBehaviorAdditionRemovalScenario({ + select: editor => editor.selectGroup('MyGroup'), + panel: '#object-group-properties-editor', +}); + +scenarios[ + 'undo/redo of a layer effect addition and removal are highlighted' +] = async editor => { + await editor.selectLayer('Background'); + const panel = '#layer-properties-editor'; + const section = 'layer-2d-effects-section'; + const effectPanel = `${panel} [id="effect-panel-Sepia"]`; + await clickSelectorIfPresent(editor, `#${section}-unfold-button`); + const expectHighlight = async (highlight, description) => { + const highlights = await editor.getHighlights(); + expectEqual( + highlights.includes(highlight), + true, + `${description} (${highlights})` + ); + }; + + // Addition. + await editor.clickSectionHeaderButton(section, 'add'); + await editor.page.waitForSelector('#new-effect-dialog'); + await editor.page.click('#new-effect-from-scratch-tab'); + await editor.page.waitForSelector('#effect-item-Sepia'); + await editor.page.click('#effect-item-Sepia'); + await editor.waitUntil('effect added', () => + isSelectorVisible(editor, effectPanel) + ); + await sleep(HISTORY_SAVE_DELAY); + await editor.recordHighlights(); + await editor.undo(); + await editor.waitUntil( + 'effect removed by the undo', + async () => !(await editor.page.$(effectPanel)) + ); + await expectHighlight(section, 'undo of an addition'); + await editor.recordHighlights(); + await editor.redo(); + await editor.waitUntil('effect back after the redo', () => + isSelectorVisible(editor, effectPanel) + ); + await expectHighlight('effect-panel-Sepia', 'redo of an addition'); + + // Removal. + await clickSelectorIfPresent(editor, `${effectPanel} #remove-effect`); + await sleep(500); + if (await editor.page.$('[role="dialog"]')) + await editor.clickButtonWithText( + '[role="dialog"]', + /^(confirm|delete|yes|remove)/ + ); + await editor.waitUntil( + 'effect removed', + async () => !(await editor.page.$(effectPanel)) + ); + await sleep(HISTORY_SAVE_DELAY); + await editor.recordHighlights(); + await editor.undo(); + await editor.waitUntil('effect back after the undo', () => + isSelectorVisible(editor, effectPanel) + ); + await expectHighlight('effect-panel-Sepia', 'undo of a removal'); + await editor.recordHighlights(); + await editor.redo(); + await editor.waitUntil( + 'effect removed by the redo', + async () => !(await editor.page.$(effectPanel)) + ); + await expectHighlight(section, 'redo of a removal'); +}; + +/** The change confirmed by the open dialog can be undone: it must not say otherwise. */ +const expectNoUndoneClaimInDialog = async (editor, description) => { + const text = await editor.page.evaluate( + () => document.querySelector('[role="dialog"]').textContent + ); + expectEqual(/undone/i.test(text), false, `${description} (${text})`); +}; + +scenarios['setting a group as global can be undone'] = async editor => { + await editor.openPanel('groups'); + const row = '#objects-groups-list [data-group-name="MyGroup"]'; + await editor.page.waitForSelector(row); + const getGroupScopes = async () => { + const { project } = JSON.parse(await getSerializedProject(editor)); + const hasGroup = groups => groups.some(({ name }) => name === 'MyGroup'); + return { + global: hasGroup(project.objectsGroups), + scene: hasGroup(project.layouts[0].objectsGroups), + }; + }; + expectEqual( + await getGroupScopes(), + { global: false, scene: true }, + 'initially' + ); + + await (await editor.page.$(row)).click({ button: 'right' }); + await editor.clickMenuItem(/set as global group/i); + await editor.page.waitForSelector('[role="dialog"]'); + await expectNoUndoneClaimInDialog(editor, 'set as global confirmation'); + await editor.clickButtonWithText('[role="dialog"]', /^set as global$/i); + await editor.waitUntil( + 'group set as global', + async () => (await getGroupScopes()).global + ); + await sleep(HISTORY_SAVE_DELAY); + expectEqual(await getGroupScopes(), { global: true, scene: false }, 'global'); + + await editor.undo(); + expectEqual( + await getGroupScopes(), + { global: false, scene: true }, + 'after undo' + ); + await editor.redo(); + expectEqual( + await getGroupScopes(), + { global: true, scene: false }, + 'after redo' + ); +}; + +scenarios[ + 'undo of a background color change made in the layers panel highlights it there' +] = async editor => { + await editor.openPanel('layers'); + const row = '#layers-list [data-background-color]'; + await editor.page.waitForSelector(row); + // The color swatch of the row opens a picker with a "hex" input. + await editor.page.click(`${row} div[style*="background"]`); + const hexInput = await editor.page.waitForSelector('.sketch-picker input'); + await hexInput.click({ clickCount: 3 }); + await editor.page.keyboard.type('102030'); + await editor.page.keyboard.press('Enter'); + await editor.page.keyboard.press('Escape'); + await editor.waitUntil( + 'picker closed', + async () => !(await editor.page.$('.sketch-picker')) + ); + await sleep(HISTORY_SAVE_DELAY); + const getColor = async () => { + const { project } = JSON.parse(await getSerializedProject(editor)); + const { r, v, b } = project.layouts[0]; + return [r, v, b]; + }; + expectEqual(await getColor(), [16, 32, 48], 'color changed'); + + await editor.recordHighlights(); + await editor.undo(); + expectEqual(await getColor(), [209, 209, 209], 'color after undo'); + const highlights = await editor.getHighlights(); + expectEqual( + highlights.includes('background-color'), + true, + `layers panel row highlighted (${highlights})` + ); + expectEqual( + highlights.includes('BackgroundColor'), + true, + `properties panel field highlighted (${highlights})` + ); + await editor.recordHighlights(); + await editor.redo(); + expectEqual(await getColor(), [16, 32, 48], 'color after redo'); + expectEqual( + (await editor.getHighlights()).includes('background-color'), + true, + 'layers panel row highlighted after redo' + ); +}; + +{ + const name = + 'undo/redo of a group variable addition and deletion are highlighted and shown'; + fixtureSetups[name] = project => { + project.layouts[0].objectsGroups.push({ + name: 'VariablesGroup', + objects: [{ name: 'VariablesObject' }], + }); + }; + scenarios[name] = async editor => { + const { page } = editor; + await editor.selectGroup('VariablesGroup'); + const panel = '#object-group-properties-editor'; + const section = 'group-variables-section'; + await editor.unfoldVariablesSection(panel, section); + const getNames = () => editor.getVariableNames(panel); + expectEqual(await getNames(), ['VarA', 'VarB'], 'initial variables'); + + // Addition. + await editor.clickSectionHeaderButton(section, 'add'); + await sleep(HISTORY_SAVE_DELAY); + expectEqual((await getNames()).length, 3, 'variable added'); + await editor.recordHighlights(); + await editor.undo(); + expectEqual((await getNames()).length, 2, 'variables after undo'); + expectEqual( + (await editor.getHighlights()).includes(section), + true, + 'undo of an addition' + ); + await editor.recordHighlights(); + await editor.redo(); + expectEqual( + (await editor.getHighlights()).includes('Variable'), + true, + 'redo of an addition' + ); + + // Deletion: select the row of VarA, delete it from the toolbar. + const row = await page.$(`${panel} [data-variable-node-id="VarA"]`); + const box = await row.boundingBox(); + await page.mouse.click(box.x + box.width - 10, box.y + box.height / 2); + await sleep(200); + await page.evaluate(section => { + document + .getElementById(`${section}-content`) + .querySelector('#variables-list-delete-button') + .click(); + }, section); + await editor.waitUntil( + 'variable deleted', + async () => !(await getNames()).includes('VarA') + ); + await sleep(HISTORY_SAVE_DELAY); + await editor.recordHighlights(); + await editor.undo(); + expectEqual((await getNames()).includes('VarA'), true, 'variable back'); + expectEqual( + (await editor.getHighlights()).includes('VarA'), + true, + 'undo of a deletion' + ); + await editor.recordHighlights(); + await editor.redo(); + expectEqual( + (await editor.getHighlights()).includes(section), + true, + 'redo of a deletion' + ); + }; +} + +{ + const name = 'group editor dialog makes one step per variable'; + fixtureSetups[name] = project => { + project.layouts[0].objectsGroups.push({ + name: 'VariablesGroup', + objects: [{ name: 'VariablesObject' }], + }); + }; + scenarios[name] = async editor => { + await editor.selectGroup('VariablesGroup'); + const panel = '#object-group-properties-editor'; + const section = 'group-variables-section'; + await editor.unfoldVariablesSection(panel, section); + await editor.clickSectionHeaderButton(section, 'open-editor'); + await editor.page.waitForSelector('[role="dialog"] #variable-0-text-value'); + await editor.setFieldValue('[role="dialog"] #variable-0-text-value', '100'); + await editor.setFieldValue('[role="dialog"] #variable-1-text-value', '200'); + await applyDialog(editor); + + const getValues = async () => [ + await editor.getFieldValue(`${panel} #variable-0-text-value`), + await editor.getFieldValue(`${panel} #variable-1-text-value`), + ]; + expectEqual(await getValues(), ['100', '200'], 'applied'); + await editor.undo(); + const afterFirstUndo = await getValues(); + expectEqual( + [afterFirstUndo[0] === '1', afterFirstUndo[1] === '2'].filter(Boolean) + .length, + 1, + `one variable reverted by the first undo (${afterFirstUndo.join()})` + ); + await editor.undo(); + expectEqual(await getValues(), ['1', '2'], 'after the second undo'); + await editor.redo(); + await editor.redo(); + expectEqual(await getValues(), ['100', '200'], 'after two redos'); + }; +} + +/** Change the project as another editor would, while on the events tab. */ +const changeProjectFromTheEventsSheet = async (editor, change) => { + await clickEditorTab(editor, 'Scene (Events)'); + await editor.page.evaluate(changeSource => { + const element = document.getElementById('scene-editor'); + const fiberKey = Object.keys(element).find(key => + key.startsWith('__reactFiber$') + ); + let fiber = fiberKey ? element[fiberKey] : null; + while (fiber && !(fiber.stateNode && fiber.stateNode.instancesSelection)) + fiber = fiber.return; + // eslint-disable-next-line no-new-func + new Function('props', 'gd', 'return ' + changeSource)( + fiber.stateNode.props, + // eslint-disable-next-line no-undef + gd + )(fiber.stateNode.props); + }, change.toString()); + await clickEditorTab(editor, 'Scene'); + await editor.waitUntil('scene editor active again', () => + editor.page.evaluate( + () => !!document.querySelector('#scene-editor[data-active]') + ) + ); +}; + +scenarios[ + 'a variable created from the events sheet survives the undo of its edit' +] = async editor => { + await changeProjectFromTheEventsSheet(editor, props => + props.layout + .getVariables() + .insertNew('FromEvents', 0) + .setValue(5) + ); + const panel = '#scene-properties-editor'; + await editor.unfoldVariablesSection(panel, 'scene-variables-section'); + await editor.waitUntil( + 'variable shown', + async () => (await editor.getVariableNames(panel))[0] === 'FromEvents' + ); + const field = `${panel} #variable-0-text-value`; + expectEqual(await editor.getFieldValue(field), '5', 'created value'); + await editor.setFieldValue(field, '50'); + + await editor.undo(); + expectEqual( + await editor.getVariableNames(panel), + ['FromEvents', 'VarA', 'VarB'], + 'variables after undo' + ); + expectEqual(await editor.getFieldValue(field), '5', 'value after undo'); + await editor.redo(); + expectEqual(await editor.getFieldValue(field), '50', 'value after redo'); +}; + +scenarios[ + 'an object created from the events sheet survives the undo of another change' +] = async editor => { + await changeProjectFromTheEventsSheet(editor, (props, gd) => + props.objectsContainer.insertNewObject( + props.project, + 'Sprite', + 'FromEvents', + 0 + ) + ); + const row = '#objects-list [data-object-name="FromEvents"]'; + await editor.page.waitForSelector(row); + await editor.selectObjectInList('MyCube'); + await clickSelectorIfPresent( + editor, + '#object-properties-section-unfold-button' + ); + const field = '#object-properties-editor [id="width"]'; + await editor.setFieldValue(field, '200'); + + await editor.undo(); + expectEqual(await editor.getFieldValue(field), '100', 'width after undo'); + expectEqual(await exists(editor, row), true, 'object still there after undo'); + await editor.redo(); + expectEqual(await exists(editor, row), true, 'object still there after redo'); +}; + +{ + const name = 'scene properties dialog makes one step per scene behavior'; + fixtureSetups[name] = project => { + project.layouts[0].objects[0].behaviors = [ + { name: 'Physics2', type: 'Physics2::Physics2Behavior' }, + ]; + project.layouts[0].behaviorsSharedData = [ + { + name: 'Physics2', + type: 'Physics2::Physics2Behavior', + gravityX: 0, + gravityY: 9.8, + scaleX: 100, + scaleY: 100, + worldScale: 100, + }, + ]; + }; + scenarios[name] = async editor => { + const getSharedData = async () => { + const { project } = JSON.parse(await getSerializedProject(editor)); + return project.layouts[0].behaviorsSharedData.find( + ({ name }) => name === 'Physics2' + ); + }; + expectEqual((await getSharedData()).gravityY, 9.8, 'initial gravity'); + const stepsBefore = await getUndoStepsCount(editor); + + const canvas = await editor.page.$('#scene-editor canvas'); + const box = await canvas.boundingBox(); + await editor.page.mouse.click( + box.x + box.width / 2, + box.y + box.height / 2, + { + button: 'right', + } + ); + await editor.clickMenuItem(/open scene properties/i); + await editor.page.waitForSelector('[role="dialog"] [id="gravityY"]'); + await editor.setFieldValue('[role="dialog"] [id="gravityY"]', '5'); + await editor.setFieldValue('[role="dialog"] [id="gravityX"]', '1'); + await editor.clickButtonWithText('[role="dialog"]', /^ok$/i); + await editor.waitUntil( + 'dialog closed', + async () => !(await editor.page.$('[role="dialog"]')) + ); + await sleep(500); + expectEqual( + (await getUndoStepsCount(editor)) - stepsBefore, + 1, + 'steps recorded for the behavior' + ); + const applied = await getSharedData(); + expectEqual([applied.gravityX, applied.gravityY], [1, 5], 'applied'); + + await editor.undo(); + const afterUndo = await getSharedData(); + expectEqual( + [afterUndo.gravityX, afterUndo.gravityY], + [0, 9.8], + 'after undo' + ); + await editor.redo(); + const afterRedo = await getSharedData(); + expectEqual([afterRedo.gravityX, afterRedo.gravityY], [1, 5], 'after redo'); + }; +} + +/** A glTF binary file of a 1x1x1 box mesh (the smallest model to load). */ +const makeBoxGlb = () => { + const positions = new Float32Array( + [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + [0, 0, 1], + [1, 0, 1], + [1, 1, 1], + [0, 1, 1], + ].flat() + ); + const indices = new Uint16Array( + [ + [0, 2, 1], + [0, 3, 2], + [4, 5, 6], + [4, 6, 7], + [0, 1, 5], + [0, 5, 4], + [2, 3, 7], + [2, 7, 6], + [1, 2, 6], + [1, 6, 5], + [0, 4, 7], + [0, 7, 3], + ].flat() + ); + const pad = buffer => + Buffer.concat([buffer, Buffer.alloc((4 - (buffer.length % 4)) % 4)]); + const bin = pad( + Buffer.concat([Buffer.from(positions.buffer), Buffer.from(indices.buffer)]) + ); + const json = pad( + Buffer.from( + JSON.stringify({ + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 8, + type: 'VEC3', + min: [0, 0, 0], + max: [1, 1, 1], + }, + { bufferView: 1, componentType: 5123, count: 36, type: 'SCALAR' }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: positions.byteLength }, + { + buffer: 0, + byteOffset: positions.byteLength, + byteLength: indices.byteLength, + }, + ], + buffers: [{ byteLength: bin.length }], + }).padEnd(0, ' ') + ) + ); + // The JSON chunk is padded with spaces, the binary one with zeros. + for (let i = json.length - 1; i >= 0 && json[i] === 0; i--) json[i] = 0x20; + const chunk = (type, data) => { + const header = Buffer.alloc(8); + header.writeUInt32LE(data.length, 0); + header.writeUInt32LE(type, 4); + return Buffer.concat([header, data]); + }; + const jsonChunk = chunk(0x4e4f534a, json); + const binChunk = chunk(0x004e4942, bin); + const header = Buffer.alloc(12); + header.write('glTF', 0); + header.writeUInt32LE(2, 4); + header.writeUInt32LE(12 + jsonChunk.length + binChunk.length, 8); + return Buffer.concat([header, jsonChunk, binChunk]); +}; + +{ + // A 3D model: its size is only known once the model is loaded. + const name = + 'instance dimensions known late are shown after an undo (2D editor)'; + fixtureSetups[name] = (project, { modelUrl }) => { + project.resources.resources.push({ + kind: 'model3D', + name: 'model.glb', + file: modelUrl, + metadata: '', + userAdded: true, + }); + project.layouts[0].objects.push({ + name: 'ModelObject', + type: 'Scene3D::Model3DObject', + variables: [], + behaviors: [], + effects: [], + content: { + width: 100, + height: 100, + depth: 100, + keepAspectRatio: true, + modelResourceName: 'model.glb', + rotationX: 0, + rotationY: 0, + rotationZ: 0, + materialType: 'StandardWithoutMetalness', + originLocation: 'ModelOrigin', + centerLocation: 'ModelOrigin', + crossfadeDuration: 0.5, + animations: [], + }, + }); + project.layouts[0].instances.push({ + name: 'ModelObject', + persistentUuid: '44444444-4444-4444-4444-444444444444', + x: 600, + y: 400, + layer: '', + zOrder: 3, + angle: 0, + customSize: false, + width: 0, + height: 0, + numberProperties: [], + stringProperties: [], + initialVariables: [], + }); + }; + scenarios[name] = async editor => { + // Selected while the model is still loading: the panel shows the + // placeholder size (1), and must show the real one once known, without + // any other action. + await editor.selectInstancesOf('ModelObject'); + const field = name => `#instance-properties-editor [id="${name}"]`; + const getDimensions = async () => + [ + await editor.getFieldValue(field('Width')), + await editor.getFieldValue(field('Height')), + await editor.getFieldValue(field('Depth')), + ].join(); + const expectSizeShown = async description => { + let dimensions = null; + try { + await editor.waitUntil( + description, + async () => (dimensions = await getDimensions()) === '100,100,100', + 8000 + ); + } catch (error) { + expectEqual(dimensions, '100,100,100', description); + } + }; + await expectSizeShown('model size shown once loaded'); + + // Same after an undo, which creates the renderers again. + await editor.setFieldValue(field('X'), '650'); + await editor.undo(); + expectEqual(await editor.getFieldValue(field('X')), '600', 'X after undo'); + await expectSizeShown('model size shown again after the undo'); + }; +} + +{ + const name = 'moving a group in the groups list can be undone'; + fixtureSetups[name] = project => { + project.layouts[0].objectsGroups.push( + { name: 'GroupB', objects: [] }, + { name: 'GroupC', objects: [] } + ); + }; + scenarios[name] = async editor => { + await editor.openPanel('groups'); + const getOrder = () => + editor.page.evaluate(() => + Array.from( + document.querySelectorAll('#objects-groups-list [data-group-name]') + ) + .map(element => element.getAttribute('data-group-name')) + .join() + ); + await editor.waitUntil( + 'groups listed', + async () => (await getOrder()) === 'MyGroup,GroupB,GroupC' + ); + // What a drag and drop of the last group to the top does. + await editor.page.evaluate(() => { + const element = document.getElementById('scene-editor'); + const fiberKey = Object.keys(element).find(key => + key.startsWith('__reactFiber$') + ); + let fiber = fiberKey ? element[fiberKey] : null; + while (fiber && !(fiber.stateNode && fiber.stateNode.instancesSelection)) + fiber = fiber.return; + const sceneEditor = fiber.stateNode; + sceneEditor.props.objectsContainer.getObjectGroups().move(2, 0); + sceneEditor._onObjectGroupsModified(); + sceneEditor.forceUpdateObjectGroupsList(); + }); + await editor.waitUntil( + 'group moved', + async () => (await getOrder()) === 'GroupC,MyGroup,GroupB' + ); + + await editor.undo(); + await editor.waitUntil( + 'order restored by the undo', + async () => (await getOrder()) === 'MyGroup,GroupB,GroupC' + ); + await editor.redo(); + await editor.waitUntil( + 'moved again by the redo', + async () => (await getOrder()) === 'GroupC,MyGroup,GroupB' + ); + }; +} + +const scrollKeepingPanels = { + object: { + setup: project => { + project.layouts[0].objects[2].variables = makeManyVariables(); + }, + select: editor => editor.selectObjectInList('VariablesObject'), + panel: '#object-properties-editor', + section: 'object-variables-section', + }, + instance: { + setup: project => { + project.layouts[0].instances[0].initialVariables = makeManyVariables(); + }, + select: editor => editor.selectInstancesOf('MyObject'), + panel: '#instance-properties-editor', + section: 'instance-variables-section', + }, +}; +Object.keys(scrollKeepingPanels).forEach(kind => { + const { setup, select, panel, section } = scrollKeepingPanels[kind]; + const name = `undo of a ${kind} variable already on screen keeps the panel scroll position`; + fixtureSetups[name] = setup; + scenarios[name] = async editor => { + const { page } = editor; + await select(editor); + await editor.unfoldVariablesSection(panel, section); + const field = `${panel} #variable-10-text-value`; + // The panel's scrolling element (marked, to tell if it's mounted again). + const markScroller = () => + page.evaluate(panel => { + let element = document.querySelector(panel); + while ( + element && + !( + element.scrollHeight > element.clientHeight && + /auto|scroll/.test(getComputedStyle(element).overflowY) + ) + ) + element = element.parentElement; + if (element) element.setAttribute('data-e2e-scrolling-element', '1'); + return !!element; + }, panel); + const scroller = '[data-e2e-scrolling-element]'; + const getScrollTop = () => + page.evaluate( + scroller => Math.round(document.querySelector(scroller).scrollTop), + scroller + ); + // Scroll so that the row is at the given place of the visible area. + const placeRow = place => + page.evaluate( + (scroller, field, place) => { + const scrolling = document.querySelector(scroller); + const row = document.querySelector(field); + const rowTop = + row.getBoundingClientRect().top - + scrolling.getBoundingClientRect().top + + scrolling.scrollTop; + const rowHeight = row.getBoundingClientRect().height; + scrolling.scrollTop = + place === 'top' + ? rowTop + : place === 'bottom' + ? rowTop + rowHeight - scrolling.clientHeight + : rowTop - scrolling.clientHeight / 2; + }, + scroller, + field, + place + ); + expectEqual(await markScroller(), true, 'scrolling element found'); + + // Wherever the row is on screen, the undo must not scroll the panel + // (nor mount it again). + for (const place of ['top', 'center', 'bottom']) { + await placeRow(place); + await sleep(300); + await editor.setFieldValue(field, place === 'top' ? '77' : '78'); + const before = await getScrollTop(); + await editor.undo(); + await sleep(1500); + const isSame = await page.evaluate( + scroller => !!document.querySelector(scroller), + scroller + ); + expectEqual(isSame, true, `panel kept mounted (row at the ${place})`); + // A row right at the top edge is partly under the sticky header of + // its section: the list legitimately scrolls it fully into view then + // (a few pixels). Anywhere else, no scroll at all. + const tolerance = place === 'top' ? 10 : 0; + const after = await getScrollTop(); + expectEqual( + Math.abs(after - before) <= tolerance, + true, + `scroll position after undo (row at the ${place}): ${before} -> ${after}` + ); + } + }; +}); + +{ + const name = + 'the properties panel reopened by an undo is at the position it was closed'; + fixtureSetups[name] = project => { + project.layouts[0].objects[2].variables = makeManyVariables(); + }; + scenarios[name] = async editor => { + const { page } = editor; + await editor.selectObjectInList('VariablesObject'); + const panel = '#object-properties-editor'; + await editor.unfoldVariablesSection(panel, 'object-variables-section'); + // The panel's scrolling element: the outermost scrolling ancestor. + const withOutermostScroller = (fn, ...args) => + page.evaluate( + (panel, fnSource, ...args) => { + let outermost = null; + for ( + let element = document.querySelector(panel); + element; + element = element.parentElement + ) { + if ( + element.scrollHeight > element.clientHeight && + /auto|scroll/.test(getComputedStyle(element).overflowY) + ) + outermost = element; + } + return outermost + ? // eslint-disable-next-line no-new-func + new Function('return ' + fnSource)()(outermost, ...args) + : null; + }, + panel, + fn.toString(), + ...args + ); + const getOutermostScrollTop = () => + withOutermostScroller(element => Math.round(element.scrollTop)); + const scrollOutermostBy = delta => + withOutermostScroller((element, delta) => { + element.scrollTop += delta; + }, delta); + const field = `${panel} #variable-60-text-value`; + // Scroll the outermost scrolling ancestor far down: the row of the + // variable, rendered once visible, is then on screen. + await withOutermostScroller(element => { + element.scrollTop = 1300; + }); + await page.waitForSelector(field); + await sleep(400); + await editor.setFieldValue(field, '77'); + // A last scroll right before closing: it must be remembered too. + await scrollOutermostBy(40); + await sleep(50); + const closedAt = await getOutermostScrollTop(); + await (await page.$('#toolbar-open-properties-panel-button')).click(); + await editor.waitUntil('panel closed', async () => !(await page.$(panel))); + + // The undo reopens the panel where it was closed: the changed variable + // is on screen there, so nothing scrolls. + await editor.undo(); + await page.waitForSelector(panel); + const samples = []; + for (let i = 0; i < 12; i++) { + samples.push(await getOutermostScrollTop()); + await sleep(100); + } + expectEqual( + samples.every( + sample => sample !== null && Math.abs(sample - closedAt) <= 10 + ), + true, + `panel position after reopening (closed at ${closedAt}, seen: ${samples.join()})` + ); + }; +} + +{ + const name = + 'the properties panel reopened by an undo scrolls to the field, then not on redo'; + fixtureSetups[name] = project => { + project.layouts[0].objects[2].variables = makeManyVariables(); + }; + scenarios[name] = async editor => { + const { page } = editor; + await editor.selectObjectInList('VariablesObject'); + const panel = '#object-properties-editor'; + await editor.unfoldVariablesSection(panel, 'object-variables-section'); + const withOutermostScroller = (fn, ...args) => + page.evaluate( + (panel, fnSource, ...args) => { + let outermost = null; + for ( + let element = document.querySelector(panel); + element; + element = element.parentElement + ) { + if ( + element.scrollHeight > element.clientHeight && + /auto|scroll/.test(getComputedStyle(element).overflowY) + ) + outermost = element; + } + return outermost + ? // eslint-disable-next-line no-new-func + new Function('return ' + fnSource)()(outermost, ...args) + : null; + }, + panel, + fn.toString(), + ...args + ); + const getScrollTop = () => + withOutermostScroller(element => Math.round(element.scrollTop)); + const field = `${panel} #variable-10-text-value`; + const isFieldOnScreen = () => + page.evaluate(field => { + const element = document.querySelector(field); + if (!element) return false; + const rect = element.getBoundingClientRect(); + return rect.top >= 0 && rect.bottom <= window.innerHeight; + }, field); + const closePanel = async () => { + await (await page.$('#toolbar-open-properties-panel-button')).click(); + await editor.waitUntil( + 'panel closed', + async () => !(await page.$(panel)) + ); + }; + const samplePositions = async () => { + const samples = []; + for (let i = 0; i < 10; i++) { + samples.push(await getScrollTop()); + await sleep(100); + } + return samples; + }; + + // Change the variable, then scroll so that it's hidden, and close. + await editor.setFieldValue(field, '77'); + await withOutermostScroller(element => { + element.scrollTop = 1300; + }); + await sleep(400); + expectEqual(await isFieldOnScreen(), false, 'field hidden before closing'); + await closePanel(); + + // Undo: the panel reopens and scrolls to show the field. + await editor.undo(); + await page.waitForSelector(panel); + await editor.waitUntil('field shown by the undo', isFieldOnScreen); + await sleep(1000); + const positionAfterUndo = await getScrollTop(); + await closePanel(); + + // Redo: the panel reopens where it was, the field is on screen: no scroll. + await editor.redo(); + await page.waitForSelector(panel); + const samples = await samplePositions(); + expectEqual(await isFieldOnScreen(), true, 'field on screen after redo'); + expectEqual( + samples.every( + sample => sample !== null && Math.abs(sample - positionAfterUndo) <= 10 + ), + true, + `no scroll after redo (after undo: ${positionAfterUndo}, seen: ${samples.join()})` + ); + }; +} + scenarios[ 'project manager: scenes and external layouts can be undone, without crash' ] = async editor => { @@ -3774,6 +5549,18 @@ const scrollToVariablePanels = { panel: '#object-properties-editor', section: 'object-variables-section', }, + group: { + setup: project => { + project.layouts[0].objects[2].variables = makeManyVariables(); + project.layouts[0].objectsGroups.push({ + name: 'VariablesGroup', + objects: [{ name: 'VariablesObject' }], + }); + }, + select: editor => editor.selectGroup('VariablesGroup'), + panel: '#object-group-properties-editor', + section: 'group-variables-section', + }, }; Object.keys(scrollToVariablePanels).forEach(kind => { const { setup, select, panel, section } = scrollToVariablePanels[kind]; @@ -4309,6 +6096,17 @@ Object.assign(scenarios, { const serveFixture = () => new Promise(resolve => { const server = http.createServer((request, response) => { + if (request.url.startsWith('/model.glb')) { + // Slow, like a real model: its size is known late. + setTimeout(() => { + response.writeHead(200, { + 'Content-Type': 'model/gltf-binary', + 'Access-Control-Allow-Origin': '*', + }); + response.end(makeBoxGlb()); + }, 2000); + return; + } response.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', @@ -4318,7 +6116,10 @@ const serveFixture = () => request.url.replace(/^\//, '').replace(/\.json$/, '') ); const project = JSON.parse(JSON.stringify(fixtureProject)); - if (fixtureSetups[scenarioName]) fixtureSetups[scenarioName](project); + if (fixtureSetups[scenarioName]) + fixtureSetups[scenarioName](project, { + modelUrl: `http://${request.headers.host}/model.glb`, + }); response.end(JSON.stringify(project)); }); server.listen(0, '127.0.0.1', () => resolve(server)); diff --git a/newIDE/app/src/BehaviorsEditor/index.js b/newIDE/app/src/BehaviorsEditor/index.js index c7e73ef6be..5142bb96f7 100644 --- a/newIDE/app/src/BehaviorsEditor/index.js +++ b/newIDE/app/src/BehaviorsEditor/index.js @@ -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(); for (const object of objects) { const dependentBehaviors = gd.WholeProjectRefactorer.findDependentBehaviorNames( diff --git a/newIDE/app/src/EffectsList/index.js b/newIDE/app/src/EffectsList/index.js index 2db3f64941..c0a3255b1f 100644 --- a/newIDE/app/src/EffectsList/index.js +++ b/newIDE/app/src/EffectsList/index.js @@ -302,6 +302,7 @@ const Effect = React.forwardRef(( project={project} schema={effectMetadata.parametersSchema} instances={[effect]} + onInstancesModified={onEffectsUpdated} resourceManagementProps={resourceManagementProps} projectScopedContainersAccessor={ projectScopedContainersAccessor diff --git a/newIDE/app/src/InstancesEditor/InstancesRenderer/LayerRenderer.js b/newIDE/app/src/InstancesEditor/InstancesRenderer/LayerRenderer.js index cd335bd8f6..d7749102dc 100644 --- a/newIDE/app/src/InstancesEditor/InstancesRenderer/LayerRenderer.js +++ b/newIDE/app/src/InstancesEditor/InstancesRenderer/LayerRenderer.js @@ -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; 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(); diff --git a/newIDE/app/src/InstancesEditor/InstancesRenderer/index.js b/newIDE/app/src/InstancesEditor/InstancesRenderer/index.js index 6b1a0cc097..02636d94fe 100644 --- a/newIDE/app/src/InstancesEditor/InstancesRenderer/index.js +++ b/newIDE/app/src/InstancesEditor/InstancesRenderer/index.js @@ -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, diff --git a/newIDE/app/src/InstancesEditor/index.js b/newIDE/app/src/InstancesEditor/index.js index cd467f37e2..947ae1207f 100644 --- a/newIDE/app/src/InstancesEditor/index.js +++ b/newIDE/app/src/InstancesEditor/index.js @@ -121,6 +121,9 @@ export type InstancesEditorPropsWithoutSizeAndScroll = {| onInstancesMoved: (instances: Array) => void, onInstancesResized: (instances: Array) => void, onInstancesRotated: (instances: Array) => 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) => void, selectedObjectNames: Array, onContextMenu: ( x: number, @@ -562,6 +565,7 @@ export default class InstancesEditor extends Component { 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 { } }; + _instancesWithChangedDefaultSize: Array = []; + _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); diff --git a/newIDE/app/src/LayersList/BackgroundColorTreeViewItemContent.js b/newIDE/app/src/LayersList/BackgroundColorTreeViewItemContent.js index 52988fd2c0..010fc15ab4 100644 --- a/newIDE/app/src/LayersList/BackgroundColorTreeViewItemContent.js +++ b/newIDE/app/src/LayersList/BackgroundColorTreeViewItemContent.js @@ -35,7 +35,7 @@ export class BackgroundColorTreeViewItemContent implements TreeViewItemContent { } getDataSet(): ?HTMLDataset { - return null; + return { backgroundColor: 'true' }; } getThumbnail(): ?string { diff --git a/newIDE/app/src/LayersList/LayerEditorDialog.js b/newIDE/app/src/LayersList/LayerEditorDialog.js index 10e20a1b22..3b01d17149 100644 --- a/newIDE/app/src/LayersList/LayerEditorDialog.js +++ b/newIDE/app/src/LayersList/LayerEditorDialog.js @@ -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 ( {layer.getName()} properties @@ -279,6 +290,7 @@ const LayerEditorDialog = ({ onChange={(e, i, value: string) => { layer.setDefaultCameraBehavior(value); forceUpdate(); + notifyOfChange(); }} > { layer.setRenderingType(newValue); forceUpdate(); + notifyOfChange(); }} > { 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; diff --git a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/PointsEditor/index.js b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/PointsEditor/index.js index 7896691b37..035d1388b4 100644 --- a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/PointsEditor/index.js +++ b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/PointsEditor/index.js @@ -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`, }); diff --git a/newIDE/app/src/ObjectEditor/ObjectEditorDialog.js b/newIDE/app/src/ObjectEditor/ObjectEditorDialog.js index ab93a3dbdc..7ee192a7e4 100644 --- a/newIDE/app/src/ObjectEditor/ObjectEditorDialog.js +++ b/newIDE/app/src/ObjectEditor/ObjectEditorDialog.js @@ -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( diff --git a/newIDE/app/src/ObjectGroupEditor/CompactObjectGroupEditor.js b/newIDE/app/src/ObjectGroupEditor/CompactObjectGroupEditor.js index 60a765dcbc..ffab8bea1f 100644 --- a/newIDE/app/src/ObjectGroupEditor/CompactObjectGroupEditor.js +++ b/newIDE/app/src/ObjectGroupEditor/CompactObjectGroupEditor.js @@ -129,12 +129,14 @@ const CompactObjectGroupEditor = ({ return isObjectListLocked ? ( ) : ( onObjectRemoved(objectName)} diff --git a/newIDE/app/src/ObjectGroupEditor/CompactObjectGroupPropertiesEditor.js b/newIDE/app/src/ObjectGroupEditor/CompactObjectGroupPropertiesEditor.js index d775ac1c0b..20bce2dec8 100644 --- a/newIDE/app/src/ObjectGroupEditor/CompactObjectGroupPropertiesEditor.js +++ b/newIDE/app/src/ObjectGroupEditor/CompactObjectGroupPropertiesEditor.js @@ -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] ); diff --git a/newIDE/app/src/ObjectGroupsList/index.js b/newIDE/app/src/ObjectGroupsList/index.js index 8a806e1961..8ee8cd9bbf 100644 --- a/newIDE/app/src/ObjectGroupsList/index.js +++ b/newIDE/app/src/ObjectGroupsList/index.js @@ -245,7 +245,7 @@ const ObjectGroupsList = React.forwardRef( 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( 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`, diff --git a/newIDE/app/src/ProjectManager/index.js b/newIDE/app/src/ProjectManager/index.js index 13efa881c3..55e68d848d 100644 --- a/newIDE/app/src/ProjectManager/index.js +++ b/newIDE/app/src/ProjectManager/index.js @@ -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( 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. }, })); diff --git a/newIDE/app/src/SceneEditor/EditorsDisplay.flow.js b/newIDE/app/src/SceneEditor/EditorsDisplay.flow.js index 6e229f0e85..9669e7c736 100644 --- a/newIDE/app/src/SceneEditor/EditorsDisplay.flow.js +++ b/newIDE/app/src/SceneEditor/EditorsDisplay.flow.js @@ -168,6 +168,7 @@ export type SceneEditorsDisplayProps = {| onInstancesMoved: (Array) => void, onInstancesResized: (Array) => void, onInstancesRotated: (Array) => void, + onInstancesDefaultSizeChanged: (Array) => void, isInstanceOf3DObject: gdInitialInstance => boolean, onSelectAllInstancesOfObjectInLayout: string => void, diff --git a/newIDE/app/src/SceneEditor/GranularNamedItemsHistorySteps.js b/newIDE/app/src/SceneEditor/GranularNamedItemsHistorySteps.js index b6db6fd3c3..5b343ee080 100644 --- a/newIDE/app/src/SceneEditor/GranularNamedItemsHistorySteps.js +++ b/newIDE/app/src/SceneEditor/GranularNamedItemsHistorySteps.js @@ -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, after: Array @@ -24,7 +42,7 @@ export const getIntermediateNamedItemsStates = ( const changedNames: Array = []; 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 => { + const keys: Array = []; + 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, + after: Array, + appliedNames: Set +): Array => { + 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; +}; diff --git a/newIDE/app/src/SceneEditor/GranularNamedItemsHistorySteps.spec.js b/newIDE/app/src/SceneEditor/GranularNamedItemsHistorySteps.spec.js index 2b0b9bd7d9..4b4359b0a4 100644 --- a/newIDE/app/src/SceneEditor/GranularNamedItemsHistorySteps.spec.js +++ b/newIDE/app/src/SceneEditor/GranularNamedItemsHistorySteps.spec.js @@ -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); + }); +}); diff --git a/newIDE/app/src/SceneEditor/InstanceOrObjectPropertiesEditorContainer.js b/newIDE/app/src/SceneEditor/InstanceOrObjectPropertiesEditorContainer.js index d471104ded..4e57c85662 100644 --- a/newIDE/app/src/SceneEditor/InstanceOrObjectPropertiesEditorContainer.js +++ b/newIDE/app/src/SceneEditor/InstanceOrObjectPropertiesEditorContainer.js @@ -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); }, diff --git a/newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js b/newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js index 13cf2b2342..3344bff820 100644 --- a/newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js +++ b/newIDE/app/src/SceneEditor/MosaicEditorsDisplay/index.js @@ -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} diff --git a/newIDE/app/src/SceneEditor/ObjectsAndVariablesHistoryTargets.js b/newIDE/app/src/SceneEditor/ObjectsAndVariablesHistoryTargets.js index f2ac9e3f67..5486cf99d5 100644 --- a/newIDE/app/src/SceneEditor/ObjectsAndVariablesHistoryTargets.js +++ b/newIDE/app/src/SceneEditor/ObjectsAndVariablesHistoryTargets.js @@ -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); diff --git a/newIDE/app/src/SceneEditor/ObjectsAndVariablesHistoryTargets.spec.js b/newIDE/app/src/SceneEditor/ObjectsAndVariablesHistoryTargets.spec.js index 51113d12a3..4759dd3855 100644 --- a/newIDE/app/src/SceneEditor/ObjectsAndVariablesHistoryTargets.spec.js +++ b/newIDE/app/src/SceneEditor/ObjectsAndVariablesHistoryTargets.spec.js @@ -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] diff --git a/newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js b/newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js index 1be06ff706..43e6b380b5 100644 --- a/newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js +++ b/newIDE/app/src/SceneEditor/SwipeableDrawerEditorsDisplay/index.js @@ -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} diff --git a/newIDE/app/src/SceneEditor/index.js b/newIDE/app/src/SceneEditor/index.js index 198c6750eb..3a87032320 100644 --- a/newIDE/app/src/SceneEditor/index.js +++ b/newIDE/app/src/SceneEditor/index.js @@ -55,7 +55,7 @@ import { redoComposite, } from '../Utils/History'; import { diffInstancesSnapshots } from '../Utils/InstancesSnapshotDiff'; -import '../UI/UndoRedoFlash.css'; +import { flashElement } from '../UI/UndoRedoFlash'; import { getVariablesContainerHistoryTarget, getObjectsContainerHistoryTarget, @@ -67,7 +67,12 @@ import { getChangedVariableNodeIds, hasRemovedVariables, } from './PropertyRowsFlashDiff'; -import { getIntermediateNamedItemsStates } from './GranularNamedItemsHistorySteps'; +import { + getIntermediateNamedItemsStates, + getNamedItemsStateWithChangesApplied, + getSerializedObjectChangeKeys, + stableStringify, +} from './GranularNamedItemsHistorySteps'; import PixiResourcesLoader from '../ObjectsRendering/PixiResourcesLoader'; import { @@ -173,7 +178,9 @@ const scenePropertyKeyToPropertyFieldId: { [string]: string } = { // How the attributes of a serialized instance (as found in the history // snapshots) map to the field ids of the compact instance properties editor // (see `CompactInstancePropertiesSchema.js`). -const serializedInstanceKeyToPropertyFieldId: { [string]: string } = { +const serializedInstanceKeyToPropertyFieldId: { + [string]: string | Array, +} = { x: 'X', y: 'Y', z: 'Z', @@ -186,7 +193,7 @@ const serializedInstanceKeyToPropertyFieldId: { [string]: string } = { height: 'Height', depth: 'Depth', // Toggling the custom size is seen in the size fields. - customSize: 'Width', + customSize: ['Width', 'Height'], customDepth: 'Depth', hidden: 'Hide instance', locked: 'Lock instance', @@ -321,11 +328,6 @@ const REFACTORED_HISTORY_KEYS = [ 'instances', ]; // Targets that other editors (other scenes) can change too. -const SHARED_HISTORY_KEYS = [ - 'globalObjects', - 'globalObjectGroups', - 'globalVariables', -]; // In development, check after each step that no target changed without // being declared (such a change would be silently reverted by an undo). const CHECK_UNDECLARED_HISTORY_CHANGES = @@ -466,6 +468,9 @@ type CopyCutPasteOptions = {| const editSceneIconReactNode = ; +// Edits of the same thing made within this delay are one undoable step. +const PANEL_HISTORY_SAVE_DELAY = 500; + export default class SceneEditor extends React.Component { instancesSelection: InstancesSelection; contextMenu: ?ContextMenuInterface; @@ -540,10 +545,11 @@ export default class SceneEditor extends React.Component { // when switching between the 2D and 3D editors, which don't listen to // the keyboard on the same element. if (!prevProps.isActive && this.props.isActive) { - // Another editor may have changed the shared targets while this one - // was inactive: refresh them so the next step is based on their + // Another editor (the events sheet, the project manager, another + // scene tab...) may have changed the project while this one was + // inactive: refresh the targets so the next step is based on their // actual value (and an undo doesn't revert the other editor's change). - this._refreshHistoryValue(SHARED_HISTORY_KEYS); + this._refreshHistoryValuesFromProject(); } if ( (!prevProps.isActive && this.props.isActive) || @@ -1207,10 +1213,12 @@ export default class SceneEditor extends React.Component { }; editLayerEffects = (layer: ?gdLayer) => { + if (layer) this._startLayerEditorDialogSession(layer); this.setState({ editedLayer: layer, editedLayerInitialTab: 'effects' }); }; editLayer = (layer: ?gdLayer) => { + if (layer) this._startLayerEditorDialogSession(layer); this.setState({ editedLayer: layer, editedLayerInitialTab: 'properties' }); }; @@ -1229,17 +1237,20 @@ export default class SceneEditor extends React.Component { ) => { const { project } = this.props; if (editedObject) { + const editedObjectWithContext = { + object: editedObject, + global: project.getObjects().hasObjectNamed(editedObject.getName()), + }; + this._startObjectEditorDialogSession(editedObjectWithContext); this.setState( { - editedObjectWithContext: { - object: editedObject, - global: project.getObjects().hasObjectNamed(editedObject.getName()), - }, + editedObjectWithContext, editedObjectInitialTab: initialTab || 'properties', }, callback ); } else { + this._endDialogEditSession(); this.setState( { editedObjectWithContext: null, @@ -1374,10 +1385,16 @@ export default class SceneEditor extends React.Component { ); } // The variables tab, if used, refactors the variables of every object of - // the group at once - recorded as a single step, separate from the - // membership changes above (splitting it further would mean grouping by - // variable name across every affected object). - this._recordHistoryStep(undefined, changeContext, OBJECTS_HISTORY_KEYS); + // the group at once: one step per variable, separate from the + // membership changes above. + if (editedGroup) { + this._recordGranularGroupVariablesHistorySteps( + editedGroup, + changeContext + ); + } else { + this._recordHistoryStep(undefined, changeContext, OBJECTS_HISTORY_KEYS); + } if (editedGroup) { // TODO Set the `global` attribute correctly. this.props.onObjectGroupEdited({ @@ -1397,6 +1414,148 @@ export default class SceneEditor extends React.Component { * per object added or removed - instead of a single step covering the * whole group. */ + /** + * Split a change made to the variables of the objects of a group (all at + * once, by an "Apply" of the group editor dialog) into one undoable step + * per variable name, applied to every object of the group. + */ + _recordGranularGroupVariablesHistorySteps = ( + group: gdObjectGroup, + changeContext: HistoryChangeContext + ) => { + this._flushPendingPanelHistorySave(); + const groupObjectNames = new Set(group.getAllObjectsNames().toJSArray()); + const beforeValue = this._getLatestHistory().currentValue; + const afterValue = this._serializeHistoryTargets(OBJECTS_HISTORY_KEYS); + + // The variable names that changed on any object of the group, in the + // order of the variables of the objects. + const changedNames: Array = []; + const addChangedNames = ( + beforeVariables: Array, + afterVariables: Array + ) => { + const beforeByName = new Map( + beforeVariables.map(variable => [variable.name, variable]) + ); + const afterNames = new Set(afterVariables.map(({ name }) => name)); + [...afterVariables, ...beforeVariables].forEach(variable => { + const { name } = variable; + if (changedNames.includes(name)) return; + const isChanged = afterNames.has(name) + ? stableStringify(beforeByName.get(name)) !== + stableStringify(variable) + : true; + if (isChanged) changedNames.push(name); + }); + }; + OBJECTS_HISTORY_KEYS.forEach(key => { + const beforeObjects = (beforeValue[key] || {}).objects || []; + ((afterValue[key] || {}).objects || []).forEach(afterObject => { + if (!groupObjectNames.has(afterObject.name)) return; + const beforeObject = findSerializedItemByName( + beforeObjects, + afterObject.name + ); + if (!beforeObject) return; + addChangedNames( + (beforeObject.serialized || {}).variables || [], + (afterObject.serialized || {}).variables || [] + ); + }); + }); + + if (changedNames.length === 0) { + // Nothing about the variables changed - but the objects could have + // been changed in another way: don't lose it. + this._recordHistoryStep(undefined, changeContext, OBJECTS_HISTORY_KEYS); + return; + } + + const revealedChangeContext = this._withRevealSelection(changeContext); + const appliedNames: Set = new Set(); + changedNames.forEach(name => { + appliedNames.add(name); + const value: { [string]: Object } = {}; + OBJECTS_HISTORY_KEYS.forEach(key => { + const beforeObjects = (beforeValue[key] || {}).objects || []; + const afterKeyValue = afterValue[key] || {}; + value[key] = { + ...afterKeyValue, + objects: (afterKeyValue.objects || []).map(afterObject => { + const beforeObject = findSerializedItemByName( + beforeObjects, + afterObject.name + ); + if (!groupObjectNames.has(afterObject.name) || !beforeObject) + return afterObject; + return { + ...afterObject, + serialized: { + ...afterObject.serialized, + variables: getNamedItemsStateWithChangesApplied( + (beforeObject.serialized || {}).variables || [], + (afterObject.serialized || {}).variables || [], + appliedNames + ), + }, + }; + }), + }; + }); + this._setHistory( + savePartialValueToHistory( + this._getLatestHistory(), + value, + undefined, + revealedChangeContext + ) + ); + }); + this.updateToolbar(); + this._checkNoUndeclaredHistoryChange(OBJECTS_HISTORY_KEYS); + }; + + /** + * Split a change made to the behaviors shared data (all at once, by an + * "Apply" of the scene properties dialog) into one undoable step per + * behavior that changed. + */ + _recordGranularBehaviorsSharedDataHistorySteps = ( + changeContext: HistoryChangeContext + ) => { + this._flushPendingPanelHistorySave(); + const before = + this._getLatestHistory().currentValue.behaviorsSharedData || {}; + const after = + this._serializeHistoryTargets(['behaviorsSharedData']) + .behaviorsSharedData || {}; + const changedNames = [ + ...new Set([...Object.keys(before), ...Object.keys(after)]), + ].filter( + name => stableStringify(before[name]) !== stableStringify(after[name]) + ); + if (changedNames.length === 0) return; + + const revealedChangeContext = this._withRevealSelection(changeContext); + let current = { ...before }; + changedNames.forEach(name => { + current = { ...current }; + if (name in after) current[name] = after[name]; + else delete current[name]; + this._setHistory( + savePartialValueToHistory( + this._getLatestHistory(), + { behaviorsSharedData: current }, + undefined, + revealedChangeContext + ) + ); + }); + this.updateToolbar(); + this._checkNoUndeclaredHistoryChange(['behaviorsSharedData']); + }; + _recordGranularObjectGroupMembershipHistorySteps = ( groupsKey: string, groupName: string, @@ -2382,6 +2541,18 @@ export default class SceneEditor extends React.Component { this._sendUpdatedInstances(instances); }; + _onInstancesDefaultSizeChanged = (instances: Array) => { + // The properties panel shows the size of the selected instances. + const selectedInstances = this.instancesSelection.getSelectedInstances(); + if ( + selectedInstances.some(selectedInstance => + instances.some(instance => instance.ptr === selectedInstance.ptr) + ) + ) { + this.forceUpdatePropertiesEditor(); + } + }; + _onInstancesRotated = (instances: Array) => { this._recordHistoryStep('EDIT', { source: 'canvas' }, ['instances'], () => this.forceUpdatePropertiesEditor() @@ -2519,13 +2690,34 @@ export default class SceneEditor extends React.Component { * (for targets changed outside of the history). */ _refreshHistoryValue = (keys: Array) => { - this._setHistory( - refreshCompositeHistoryValue( - this._getLatestHistory(), - this._getHistoryTargets(), - keys - ) + const history = this._getLatestHistory(); + const refreshedHistory = refreshCompositeHistoryValue( + history, + this._getHistoryTargets(), + keys ); + // Nothing to do (and no "unsaved changes") when nothing changed. + if ( + keys.every( + key => + JSON.stringify(history.currentValue[key]) === + JSON.stringify(refreshedHistory.currentValue[key]) + ) + ) + return; + this._setHistory(refreshedHistory); + }; + + /** + * Called when this editor becomes active again: the project may have been + * changed by another editor meanwhile. What it changed is not undoable + * from here, but must never be reverted by the undo of a later step. + * (Not to be called while this editor is used: it would absorb a change + * of this editor made before its step is recorded.) + */ + _refreshHistoryValuesFromProject = () => { + this._flushPendingPanelHistorySave(); + this._refreshHistoryValue(Object.keys(this._getHistoryTargets())); }; /** @@ -2733,7 +2925,7 @@ export default class SceneEditor extends React.Component { }); // Everything else that changed (the object's own configuration, which // has no equivalent way to be split further) as one final step. - if (JSON.stringify(current) !== JSON.stringify(afterObject.serialized)) { + if (stableStringify(current) !== stableStringify(afterObject.serialized)) { objectStates.push(afterObject.serialized); } @@ -2745,31 +2937,13 @@ export default class SceneEditor extends React.Component { } const revealedChangeContext = this._withRevealSelection(changeContext); - const extraKeysValue = - extraKeys.length > 0 ? this._serializeHistoryTargets(extraKeys) : {}; objectStates.forEach((serializedObject, index) => { const isLastState = index === objectStates.length - 1; - this._setHistory( - savePartialValueToHistory( - this._getLatestHistory(), - { - [key]: { - objects: afterObjects.map(item => - item.name === objectName - ? { - name: objectName, - type: item.type, - serialized: serializedObject, - } - : item - ), - folders: afterValue.folders, - }, - ...(isLastState ? extraKeysValue : {}), - }, - undefined, - revealedChangeContext - ) + this._recordObjectStateHistoryStep( + objectWithContext, + serializedObject, + revealedChangeContext, + isLastState ? extraKeys : [] ); }); @@ -2777,6 +2951,185 @@ export default class SceneEditor extends React.Component { this._checkNoUndeclaredHistoryChange([key, ...extraKeys]); }; + /** + * Record one step where the object is in the given serialized state + * (the other objects being as they are now). + */ + _recordObjectStateHistoryStep = ( + objectWithContext: ObjectWithContext, + serializedObject: Object, + changeContext: HistoryChangeContext, + extraKeys: Array + ) => { + const key = objectWithContext.global ? 'globalObjects' : 'objects'; + const objectName = objectWithContext.object.getName(); + const currentValue = this._serializeHistoryTargets([key])[key] || {}; + const extraKeysValue = + extraKeys.length > 0 ? this._serializeHistoryTargets(extraKeys) : {}; + this._setHistory( + savePartialValueToHistory( + this._getLatestHistory(), + { + [key]: { + objects: (currentValue.objects || []).map(item => + item.name === objectName + ? { + name: objectName, + type: item.type, + serialized: serializedObject, + } + : item + ), + folders: currentValue.folders, + }, + ...extraKeysValue, + }, + undefined, + changeContext + ) + ); + }; + + /** + * While an editor dialog (object, layer) is open, its changes are + * recorded as they are made: one step per edited thing (a behavior, a + * variable, an effect, the animations...), in order. Edits of the same + * thing made within a short time are one step (like typing in a field). + * Cancelling the dialog drops these steps. + */ + _dialogEditSession: ?{| + historyAtOpen: HistoryState, + serialize: () => Object, + recordStep: (serialized: Object) => void, + lastSerialized: Object, + pendingChangeKeys: ?Array, + timeoutId: ?TimeoutID, + |} = null; + + _startDialogEditSession = ({ + serialize, + recordStep, + }: {| + serialize: () => Object, + recordStep: (serialized: Object) => void, + |}) => { + this._endDialogEditSession(); + this._flushPendingPanelHistorySave(); + this._dialogEditSession = { + historyAtOpen: this._getLatestHistory(), + serialize, + recordStep, + lastSerialized: serialize(), + pendingChangeKeys: null, + timeoutId: null, + }; + }; + + _endDialogEditSession = () => { + const session = this._dialogEditSession; + if (!session) return; + if (session.timeoutId) clearTimeout(session.timeoutId); + this._dialogEditSession = null; + }; + + _onDialogEditChange = () => { + const session = this._dialogEditSession; + if (!session) return; + + const serialized = session.serialize(); + const changeKeys = getSerializedObjectChangeKeys( + session.lastSerialized, + serialized + ); + if (changeKeys.length === 0) return; + + const { pendingChangeKeys } = session; + if ( + pendingChangeKeys && + JSON.stringify(pendingChangeKeys) !== JSON.stringify(changeKeys) + ) { + // Something else is edited: close the pending step at the state + // just before this change. + session.recordStep(session.lastSerialized); + } + session.lastSerialized = serialized; + session.pendingChangeKeys = changeKeys; + if (session.timeoutId) clearTimeout(session.timeoutId); + session.timeoutId = setTimeout( + this._flushDialogEditStep, + PANEL_HISTORY_SAVE_DELAY + ); + }; + + _flushDialogEditStep = () => { + const session = this._dialogEditSession; + if (!session) return; + if (session.timeoutId) clearTimeout(session.timeoutId); + session.timeoutId = null; + if (!session.pendingChangeKeys) return; + session.pendingChangeKeys = null; + session.recordStep(session.lastSerialized); + }; + + _cancelDialogEditSession = () => { + const session = this._dialogEditSession; + if (!session) return; + this._endDialogEditSession(); + if (this._getLatestHistory() !== session.historyAtOpen) { + this._setHistory(session.historyAtOpen); + this.updateToolbar(); + } + }; + + _startObjectEditorDialogSession = (objectWithContext: ObjectWithContext) => { + const { object } = objectWithContext; + this._startDialogEditSession({ + serialize: () => { + // Same as the history snapshot of an object. + this._ensurePersistentUuidsOfObject(object); + return serializeToJSObject(object); + }, + recordStep: serializedObject => { + this.updateBehaviorsSharedData(); + this._recordObjectStateHistoryStep( + objectWithContext, + serializedObject, + this._getObjectEditorDialogChangeContext(objectWithContext), + ['behaviorsSharedData'] + ); + this.updateToolbar(); + }, + }); + }; + + _startLayerEditorDialogSession = (layer: gdLayer) => { + this._startDialogEditSession({ + serialize: () => serializeToJSObject(layer), + recordStep: serializedLayer => { + this._recordLayerStateHistoryStep( + layer, + serializedLayer, + this._getLayerEditorDialogChangeContext(layer) + ); + this.updateToolbar(); + }, + }); + }; + + _getObjectEditorDialogChangeContext = ( + objectWithContext: ObjectWithContext + ): HistoryChangeContext => ({ + source: 'panel', + editorId: 'properties', + // The dialog can be opened without selecting the object first. + revealSelection: { + lastSelectionType: 'object', + selectedObjectNames: [objectWithContext.object.getName()], + selectedObjectGroupName: null, + selectedLayerName: null, + }, + }); + /** * Split a change to a layer, made all at once by an "Apply" of the full * layer editor dialog, into one undoable step per effect that changed, @@ -2815,7 +3168,7 @@ export default class SceneEditor extends React.Component { }); // Everything else that changed (the layer's own properties) as one // final step. - if (JSON.stringify(current) !== JSON.stringify(afterLayer)) { + if (stableStringify(current) !== stableStringify(afterLayer)) { layerStates.push(afterLayer); } @@ -2823,17 +3176,10 @@ export default class SceneEditor extends React.Component { const revealedChangeContext = this._withRevealSelection(changeContext); layerStates.forEach(serializedLayer => { - this._setHistory( - savePartialValueToHistory( - this._getLatestHistory(), - { - layers: afterLayers.map(item => - item.name === layerName ? serializedLayer : item - ), - }, - undefined, - revealedChangeContext - ) + this._recordLayerStateHistoryStep( + layer, + serializedLayer, + revealedChangeContext ); }); @@ -2841,6 +3187,47 @@ export default class SceneEditor extends React.Component { this._checkNoUndeclaredHistoryChange(['layers']); }; + /** + * Record one step where the layer is in the given serialized state (the + * other layers being as they are now). + */ + _recordLayerStateHistoryStep = ( + layer: gdLayer, + serializedLayer: Object, + changeContext: HistoryChangeContext + ) => { + const layerName = layer.getName(); + const currentLayers = + this._serializeHistoryTargets(['layers']).layers || []; + this._setHistory( + savePartialValueToHistory( + this._getLatestHistory(), + { + layers: currentLayers.map(item => + item.name === layerName ? serializedLayer : item + ), + }, + undefined, + changeContext + ) + ); + }; + + // The change is shown in the properties panel, with the edited layer + // selected (the dialog can be opened without selecting the layer first). + _getLayerEditorDialogChangeContext = ( + layer: gdLayer + ): HistoryChangeContext => ({ + source: 'panel', + editorId: 'properties', + revealSelection: { + lastSelectionType: 'layer', + selectedObjectNames: [], + selectedObjectGroupName: null, + selectedLayerName: layer.getName(), + }, + }); + _recordHistoryCommand = ( command: HistoryCommand, changeContext: HistoryChangeContext @@ -2950,7 +3337,7 @@ export default class SceneEditor extends React.Component { // $FlowFixMe[missing-local-annot] _flushPendingPanelHistorySaveDebounced = (debounce(() => { this._flushPendingPanelHistorySave(); - }, 500): any); + }, PANEL_HISTORY_SAVE_DELAY): any); /** * Return the value of the instances target, with the given instances @@ -3136,19 +3523,11 @@ export default class SceneEditor extends React.Component { } } this.updateBehaviorsSharedData(); + // Whatever was not recorded while the dialog was open (an edit made + // without notifying the dialog): nothing, usually. this._recordGranularObjectHistorySteps( objectWithContext, - { - source: 'panel', - editorId: 'properties', - // The dialog can be opened without selecting the object first. - revealSelection: { - lastSelectionType: 'object', - selectedObjectNames: [objectWithContext.object.getName()], - selectedObjectGroupName: null, - selectedLayerName: null, - }, - }, + this._getObjectEditorDialogChangeContext(objectWithContext), ['behaviorsSharedData'] ); if (this.props.unsavedChanges) @@ -4730,15 +5109,35 @@ export default class SceneEditor extends React.Component { containerElement.focus(); }; - /** - * Restart the flash animation of an element (remove then re-add the - * class, with a reflow in between so the animation is seen again). - */ _flashElement = (element: HTMLElement) => { - element.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - element.classList.remove('undo-redo-property-flash'); - void element.offsetWidth; - element.classList.add('undo-redo-property-flash'); + if (!this._isElementInView(element)) + element.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + flashElement(element); + }; + + /** + * Whether the element is (nearly) entirely visible in its scrolling + * ancestors - scrolling to it would then only shift the panel by a few + * pixels, which looks like a glitch. + */ + _isElementInView = (element: HTMLElement): boolean => { + const tolerance = 8; + const rect = element.getBoundingClientRect(); + for ( + let ancestor = element.parentElement; + ancestor; + ancestor = ancestor.parentElement + ) { + const { overflowY } = getComputedStyle(ancestor); + if (overflowY !== 'auto' && overflowY !== 'scroll') continue; + const ancestorRect = ancestor.getBoundingClientRect(); + if ( + rect.top < ancestorRect.top - tolerance || + rect.bottom > ancestorRect.bottom + tolerance + ) + return false; + } + return true; }; _isElementVisible = (element: HTMLElement): boolean => @@ -4802,11 +5201,32 @@ export default class SceneEditor extends React.Component { setTimeout(tryFlash, 150); }; + _flashLayersListBackgroundColorRow = () => { + const containerElement = this._containerElement; + const { editorDisplay } = this; + if (!containerElement || !editorDisplay) return; + if (!editorDisplay.isEditorVisible('layers-list')) return; + + this._flashOrRetryThenFallback(() => { + const element = this._findElementByAttribute( + containerElement, + 'data-background-color', + 'true' + ); + if (element && this._isElementVisible(element)) { + this._flashElement(element); + return true; + } + return false; + }, containerElement); + }; + _flashObjectGroupsListRowByName = (groupName: string) => { const containerElement = this._containerElement; const { editorDisplay } = this; if (!containerElement || !editorDisplay) return; - if (!editorDisplay.isEditorVisible('object-groups-list')) return; + // The groups panel is usually closed: open it to show the group. + editorDisplay.ensureEditorVisible('object-groups-list'); const { objectsContainer, globalObjectsContainer } = this.props; const groupsContainer = [ @@ -5102,10 +5522,34 @@ export default class SceneEditor extends React.Component { if (!containerElement || !editorDisplay) return; if (!editorDisplay.isEditorVisible(editorId)) return; + // Removed effects have no panel anymore: flash their section instead. + const afterNames = new Set((after || []).map(effect => effect.name)); + new Set( + (before || []) + .filter(effect => !afterNames.has(effect.name)) + .map(getSectionId) + ).forEach(sectionId => { + this._unfoldSection(sectionId); + const sectionElement = this._findElementByAttribute( + containerElement, + 'id', + sectionId + ); + if (sectionElement && this._isElementVisible(sectionElement)) + this._flashElement(sectionElement); + }); + (after || []).forEach(afterEffect => { const beforeEffect = findSerializedItemByName(before, afterEffect.name); - // An added effect is already visible by appearing in the list. - if (!beforeEffect) return; + // An added effect: its whole panel. + if (!beforeEffect) { + this._revealChangedFieldsOfSubPanel( + getSectionId(afterEffect), + `effect-panel-${afterEffect.name}`, + [] + ); + return; + } const changedKeys = getChangedTopLevelKeys(beforeEffect, afterEffect, [ 'name', ]); @@ -5155,19 +5599,26 @@ export default class SceneEditor extends React.Component { ); if (!sectionElement || !this._isElementVisible(sectionElement)) return; + // Removed behaviors have no panel anymore: flash the section instead. + const afterNames = new Set((after || []).map(behavior => behavior.name)); + if ((before || []).some(behavior => !afterNames.has(behavior.name))) { + this._unfoldSection('behaviors-section'); + this._flashElement(sectionElement); + } + (after || []).forEach(afterBehavior => { const beforeBehavior = findSerializedItemByName( before, afterBehavior.name ); - // An added behavior is already visible by appearing in the list. - if (!beforeBehavior) return; - const changedKeys = getChangedTopLevelKeys( - beforeBehavior, - afterBehavior, - ['name', 'type'] - ); - if (changedKeys.length === 0) return; + // An added behavior: its whole panel. + const changedKeys = beforeBehavior + ? getChangedTopLevelKeys(beforeBehavior, afterBehavior, [ + 'name', + 'type', + ]) + : []; + if (beforeBehavior && changedKeys.length === 0) return; this._revealChangedFieldsOfSubPanel( 'behaviors-section', @@ -5221,8 +5672,11 @@ export default class SceneEditor extends React.Component { JSON.stringify(beforeInstance[key]) !== JSON.stringify(afterInstance[key]) ) { - const fieldId = serializedInstanceKeyToPropertyFieldId[key]; - if (fieldId) changedFieldIds.add(fieldId); + const fieldIds = serializedInstanceKeyToPropertyFieldId[key]; + if (fieldIds) + [] + .concat(fieldIds) + .forEach(fieldId => changedFieldIds.add(fieldId)); } }); if ( @@ -5396,7 +5850,51 @@ export default class SceneEditor extends React.Component { }; /** - * Flash the membership section of every group whose objects changed. + * Flash the group in the list and, in the group panel, the rows of what + * changed on its objects (the same for every object of the group: the + * first changed one is enough). + */ + _flashChangedGroupObjectsPropertyRows = ( + groupName: string, + objectsBefore: Array, + objectsAfter: Array + ) => { + const { editorDisplay } = this; + if (!editorDisplay) return; + this._flashObjectGroupsListRowByName(groupName); + if (!editorDisplay.isEditorVisible('properties')) return; + + const changedObject = objectsAfter.find(afterObject => { + const beforeObject = findSerializedItemByName( + objectsBefore, + afterObject.name + ); + return ( + beforeObject && + stableStringify(beforeObject.serialized) !== + stableStringify(afterObject.serialized) + ); + }); + if (!changedObject) return; + const beforeSerialized = + (findSerializedItemByName(objectsBefore, changedObject.name) || {}) + .serialized || {}; + const afterSerialized = changedObject.serialized || {}; + this._flashChangedBehaviorRows( + beforeSerialized.behaviors, + afterSerialized.behaviors + ); + this._flashChangedVariableRows( + 'group-variables-section', + beforeSerialized.variables, + afterSerialized.variables + ); + }; + + /** + * Flash, in the membership section of every group whose objects changed, + * the rows of the objects added to it - or the section itself when + * objects were removed from it (their rows are gone). */ _flashChangedObjectGroupPropertyRows = ( groupsBefore: Array, @@ -5407,18 +5905,21 @@ export default class SceneEditor extends React.Component { if (!containerElement || !editorDisplay) return; if (!editorDisplay.isEditorVisible('properties')) return; + const getObjectNames = (group: Object): Array => + ((group.serialized || {}).objects || []).map(({ name }) => name); groupsAfter.forEach(afterGroup => { const beforeGroup = findSerializedItemByName( groupsBefore, afterGroup.name ); if (!beforeGroup) return; - const changedKeys = getChangedTopLevelKeys( - beforeGroup.serialized, - afterGroup.serialized, - ['name'] + const beforeNames = getObjectNames(beforeGroup); + const afterNames = getObjectNames(afterGroup); + const addedNames = afterNames.filter(name => !beforeNames.includes(name)); + const hasRemovedObjects = beforeNames.some( + name => !afterNames.includes(name) ); - if (changedKeys.length === 0) return; + if (addedNames.length === 0 && !hasRemovedObjects) return; this._unfoldSection('group-objects-section'); const sectionElement = this._findElementByAttribute( @@ -5426,9 +5927,24 @@ export default class SceneEditor extends React.Component { 'id', 'group-objects-section' ); - if (sectionElement && this._isElementVisible(sectionElement)) { - this._flashElement(sectionElement); - } + if (!sectionElement || !this._isElementVisible(sectionElement)) return; + if (hasRemovedObjects) this._flashElement(sectionElement); + if (addedNames.length === 0) return; + this._flashOrRetryThenFallback(() => { + let flashedAnyRow = false; + addedNames.forEach(name => { + const element = this._findElementByAttribute( + containerElement, + 'id', + `group-object-${name}` + ); + if (element && this._isElementVisible(element)) { + this._flashElement(element); + flashedAnyRow = true; + } + }); + return flashedAnyRow; + }, sectionElement); }); }; @@ -5491,21 +6007,35 @@ export default class SceneEditor extends React.Component { _flashChangedPropertyRows = ( beforeChange: Object, afterChange: Object, - changedKeys: Array + changedKeys: Array, + revealSelection: ?SelectionByName ) => { const getObjects = (value: ?Object): Array => (value && value.objects) || []; if (changedKeys.some(key => OBJECTS_HISTORY_KEYS.includes(key))) { - this._flashChangedObjectPropertyRows( - [ - ...getObjects(beforeChange.objects), - ...getObjects(beforeChange.globalObjects), - ], - [ - ...getObjects(afterChange.objects), - ...getObjects(afterChange.globalObjects), - ] - ); + const objectsBefore = [ + ...getObjects(beforeChange.objects), + ...getObjects(beforeChange.globalObjects), + ]; + const objectsAfter = [ + ...getObjects(afterChange.objects), + ...getObjects(afterChange.globalObjects), + ]; + // A change made to a group (its variables, its behaviors) is applied + // to its objects: show it on the group, not on each object. + const revealedGroupName = + revealSelection && revealSelection.lastSelectionType === 'objectGroup' + ? revealSelection.selectedObjectGroupName + : null; + if (revealedGroupName) { + this._flashChangedGroupObjectsPropertyRows( + revealedGroupName, + objectsBefore, + objectsAfter + ); + } else { + this._flashChangedObjectPropertyRows(objectsBefore, objectsAfter); + } } // An object moved to another folder (or a folder deleted, moving its @@ -5589,6 +6119,9 @@ export default class SceneEditor extends React.Component { this._flashFieldsOfSection('scene-properties-section', [ ...changedFieldIds, ]); + // The background color can also be changed from the layers list. + if (changedFieldIds.has('BackgroundColor')) + this._flashLayersListBackgroundColorRow(); } if (changedKeys.includes('behaviorsSharedData')) { const before = beforeChange.behaviorsSharedData || {}; @@ -5709,7 +6242,14 @@ export default class SceneEditor extends React.Component { // `_flashChangedInstancePropertyRows` above already does). requestAnimationFrame(() => { requestAnimationFrame(() => { - this._flashChangedPropertyRows(beforeChange, afterChange, changedKeys); + this._flashChangedPropertyRows( + beforeChange, + afterChange, + changedKeys, + changeContext && changeContext.source === 'panel' + ? changeContext.revealSelection + : null + ); if (renamedObjectName) { this._flashObjectListRowByName(renamedObjectName); } @@ -6047,6 +6587,9 @@ export default class SceneEditor extends React.Component { onInstancesMoved={this._onInstancesMovedAndSendToEditor3D} onInstancesResized={this._onInstancesResized} onInstancesRotated={this._onInstancesRotated} + onInstancesDefaultSizeChanged={ + this._onInstancesDefaultSizeChanged + } isInstanceOf3DObject={this.isInstanceOf3DObject} onSelectAllInstancesOfObjectInLayout={ this.onSelectAllInstancesOfObjectInLayout @@ -6101,7 +6644,9 @@ export default class SceneEditor extends React.Component { editedObjectWithContext.object.getName() ); }} + onChange={this._onDialogEditChange} onCancel={() => { + this._cancelDialogEditSession(); if (editedObjectWithContext) { this.props.onObjectEdited( editedObjectWithContext, @@ -6129,6 +6674,7 @@ export default class SceneEditor extends React.Component { // The editedObjectWithContext state must be reset // because no hot-reload can happen while an object is edited. const appliedObjectWithContext = editedObjectWithContext; + this._flushDialogEditStep(); this.editObject(null, undefined, () => { // When resource parameters changed an hot-reload is // already triggered by _onObjectEdited. @@ -6345,22 +6891,18 @@ export default class SceneEditor extends React.Component { layer={this.state.editedLayer} initialInstances={initialInstances} initialTab={this.state.editedLayerInitialTab} + onChange={this._onDialogEditChange} onApply={(hasAnyEffectBeenAdded: boolean) => { const { editedLayer } = this.state; + this._flushDialogEditStep(); + this._endDialogEditSession(); if (editedLayer) { - // The change is shown in the properties panel, - // with the edited layer selected (the dialog can - // be opened without selecting the layer first). - this._recordGranularLayerHistorySteps(editedLayer, { - source: 'panel', - editorId: 'properties', - revealSelection: { - lastSelectionType: 'layer', - selectedObjectNames: [], - selectedObjectGroupName: null, - selectedLayerName: editedLayer.getName(), - }, - }); + // Whatever was not recorded while the dialog was + // open: nothing, usually. + this._recordGranularLayerHistorySteps( + editedLayer, + this._getLayerEditorDialogChangeContext(editedLayer) + ); } if (hasAnyEffectBeenAdded) { // This triggers a full hot-reload. We don't need @@ -6373,11 +6915,12 @@ export default class SceneEditor extends React.Component { editedLayer: null, }); }} - onCancel={() => + onCancel={() => { + this._cancelDialogEditSession(); this.setState({ editedLayer: null, - }) - } + }); + }} hotReloadPreviewButtonProps={ this.props.hotReloadPreviewButtonProps } @@ -6390,11 +6933,18 @@ export default class SceneEditor extends React.Component { layout={layout} onClose={() => this.openSceneProperties(false)} onApply={() => { - this._recordHistoryStep( - undefined, - { source: 'panel', editorId: 'properties' }, - ['sceneProperties'] + const changeContext: HistoryChangeContext = { + source: 'panel', + editorId: 'properties', + }; + // The behaviors shared data edited in the dialog: + // one step per behavior, then the properties. + this._recordGranularBehaviorsSharedDataHistorySteps( + changeContext ); + this._recordHistoryStep(undefined, changeContext, [ + 'sceneProperties', + ]); this.openSceneProperties(false); }} onEditVariables={() => this.openSceneVariables(true)} diff --git a/newIDE/app/src/UI/UndoRedoFlash.js b/newIDE/app/src/UI/UndoRedoFlash.js new file mode 100644 index 0000000000..c44119c7a1 --- /dev/null +++ b/newIDE/app/src/UI/UndoRedoFlash.js @@ -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); +}; diff --git a/newIDE/app/src/Utils/UsePersistedScrollPosition.js b/newIDE/app/src/Utils/UsePersistedScrollPosition.js index 4b4e4a7be9..49842f841b 100644 --- a/newIDE/app/src/Utils/UsePersistedScrollPosition.js +++ b/newIDE/app/src/Utils/UsePersistedScrollPosition.js @@ -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 = new Map(); + export const usePersistedScrollPosition = ({ project, scrollViewRef, @@ -34,11 +39,16 @@ export const usePersistedScrollPosition = ({ const projectId = project.getProjectUuid(); const saveScrollTimeoutId = React.useRef(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, diff --git a/newIDE/app/src/stories/componentStories/FullSizeInstancesEditorWithScrollbars.stories.js b/newIDE/app/src/stories/componentStories/FullSizeInstancesEditorWithScrollbars.stories.js index 4478e1a250..9c977a2501 100644 --- a/newIDE/app/src/stories/componentStories/FullSizeInstancesEditorWithScrollbars.stories.js +++ b/newIDE/app/src/stories/componentStories/FullSizeInstancesEditorWithScrollbars.stories.js @@ -58,6 +58,7 @@ export const Default = (): React.Node => ( onInstanceDoubleClicked={() => {}} onInstancesMoved={() => {}} onInstancesResized={() => {}} + onInstancesDefaultSizeChanged={() => {}} onInstancesRotated={() => {}} selectedObjectNames={[]} onContextMenu={() => {}}