diff --git a/Core/GDCore/Project/CinematicSequence.cpp b/Core/GDCore/Project/CinematicSequence.cpp new file mode 100644 index 000000000000..7eef44515728 --- /dev/null +++ b/Core/GDCore/Project/CinematicSequence.cpp @@ -0,0 +1,25 @@ +/* + * GDevelop Core + * Copyright 2008-2026 Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ + +#include "CinematicSequence.h" +#include "GDCore/Serialization/SerializerElement.h" + +namespace gd { + +void CinematicSequence::SerializeTo(SerializerElement& element) const { + element.SetAttribute("name", name); + element.SetAttribute("sequenceData", sequenceData); + element.SetAttribute("associatedLayout", associatedLayout); +} + +void CinematicSequence::UnserializeFrom(gd::Project& project, + const SerializerElement& element) { + name = element.GetStringAttribute("name", "", "Name"); + sequenceData = element.GetStringAttribute("sequenceData", ""); + associatedLayout = element.GetStringAttribute("associatedLayout", "", "AssociatedLayout"); +} + +} // namespace gd diff --git a/Core/GDCore/Project/CinematicSequence.h b/Core/GDCore/Project/CinematicSequence.h new file mode 100644 index 000000000000..0d94204fc9e8 --- /dev/null +++ b/Core/GDCore/Project/CinematicSequence.h @@ -0,0 +1,83 @@ +/* + * GDevelop Core + * Copyright 2008-2026 Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ + +#pragma once + +#include +#include "GDCore/String.h" +namespace gd { +class SerializerElement; +class Project; +} + +namespace gd { + +/** + * \brief A cinematic sequence allows to store keyframes, tracks and orchestrate + * an animation or a cutscene that can be then read and played at runtime. + */ +class GD_CORE_API CinematicSequence { + public: + CinematicSequence(){}; + virtual ~CinematicSequence(){}; + + /** + * \brief Return a pointer to a new CinematicSequence constructed from this one. + */ + CinematicSequence* Clone() const { return new CinematicSequence(*this); }; + + /** + * \brief Return the name of the cinematic sequence. + */ + const gd::String& GetName() const { return name; } + + /** + * \brief Change the name of the cinematic sequence. + */ + void SetName(const gd::String& name_) { name = name_; } + + /** + * \brief Get the serialized content of the sequence (JSON format) + * managed by the IDE. + */ + const gd::String& GetSequenceData() const { return sequenceData; } + + /** + * \brief Change the serialized content of the sequence. + */ + void SetSequenceData(const gd::String& data) { sequenceData = data; } + + /** + * \brief Get the name of the layout last used to preview the cinematic sequence. + */ + const gd::String& GetAssociatedLayout() const { return associatedLayout; } + + /** + * \brief Set the name of the layout used to preview the cinematic sequence. + */ + void SetAssociatedLayout(const gd::String& name) { associatedLayout = name; } + + /** \name Serialization + */ + ///@{ + /** + * \brief Serialize cinematic sequence. + */ + void SerializeTo(SerializerElement& element) const; + + /** + * \brief Unserialize the cinematic sequence. + */ + void UnserializeFrom(gd::Project &project, const SerializerElement& element); + ///@} + + private: + gd::String name; + gd::String sequenceData; // JSON representation of Tracks/Keyframes + gd::String associatedLayout; // Used to know in which layout we preview +}; + +} // namespace gd diff --git a/Core/GDCore/Project/Project.cpp b/Core/GDCore/Project/Project.cpp index d96b48f74c63..d12e59278b9d 100644 --- a/Core/GDCore/Project/Project.cpp +++ b/Core/GDCore/Project/Project.cpp @@ -24,7 +24,8 @@ #include "GDCore/Project/CustomObjectConfiguration.h" #include "GDCore/Project/EventsFunctionsExtension.h" #include "GDCore/Project/ExternalEvents.h" -#include "GDCore/Project/ExternalLayout.h" +#include "GDCore/Project/ExternalLayout.h" +#include "GDCore/Project/CinematicSequence.h" #include "GDCore/Project/Layout.h" #include "GDCore/Project/Object.h" #include "GDCore/Project/ObjectConfiguration.h" @@ -568,6 +569,116 @@ void Project::RemoveExternalLayout(const gd::String& name) { if (externalLayout == externalLayouts.end()) return; externalLayouts.erase(externalLayout); +} + +void Project::MoveCinematicSequence(std::size_t oldIndex, std::size_t newIndex) { + if (oldIndex >= cinematicSequences.size() || newIndex >= cinematicSequences.size()) + return; + + std::unique_ptr cinematicSequence = + std::move(cinematicSequences[oldIndex]); + cinematicSequences.erase(cinematicSequences.begin() + oldIndex); + cinematicSequences.insert(cinematicSequences.begin() + newIndex, + std::move(cinematicSequence)); +}; + +void Project::MoveEventsFunctionsExtension(std::size_t oldIndex, + std::size_t newIndex) { + if (oldIndex >= eventsFunctionsExtensions.size() || + newIndex >= eventsFunctionsExtensions.size()) + return; + + std::unique_ptr eventsFunctionsExtension = + std::move(eventsFunctionsExtensions[oldIndex]); + eventsFunctionsExtensions.erase(eventsFunctionsExtensions.begin() + oldIndex); + eventsFunctionsExtensions.insert(eventsFunctionsExtensions.begin() + newIndex, + std::move(eventsFunctionsExtension)); +}; + +void Project::SwapExternalEvents(std::size_t first, std::size_t second) { + if (first >= externalEvents.size() || second >= externalEvents.size()) return; + + std::iter_swap(externalEvents.begin() + first, + externalEvents.begin() + second); +} + +void Project::SwapCinematicSequences(std::size_t first, std::size_t second) { + if (first >= cinematicSequences.size() || second >= cinematicSequences.size()) + return; + + std::iter_swap(cinematicSequences.begin() + first, + cinematicSequences.begin() + second); +} +bool Project::HasCinematicSequenceNamed(const gd::String& name) const { + return (find_if(cinematicSequences.begin(), + cinematicSequences.end(), + [&name](const std::unique_ptr& cinematicSequence) { + return cinematicSequence->GetName() == name; + }) != cinematicSequences.end()); +} +gd::CinematicSequence& Project::GetCinematicSequence(const gd::String& name) { + return *(*find_if(cinematicSequences.begin(), + cinematicSequences.end(), + [&name](const std::unique_ptr& cinematicSequence) { + return cinematicSequence->GetName() == name; + })); +} +const gd::CinematicSequence& Project::GetCinematicSequence( + const gd::String& name) const { + return *(*find_if(cinematicSequences.begin(), + cinematicSequences.end(), + [&name](const std::unique_ptr& cinematicSequence) { + return cinematicSequence->GetName() == name; + })); +} +gd::CinematicSequence& Project::GetCinematicSequence(std::size_t index) { + return *cinematicSequences[index]; +} +const gd::CinematicSequence& Project::GetCinematicSequence(std::size_t index) const { + return *cinematicSequences[index]; +} +std::size_t Project::GetCinematicSequencePosition(const gd::String& name) const { + for (std::size_t i = 0; i < cinematicSequences.size(); ++i) { + if (cinematicSequences[i]->GetName() == name) return i; + } + return gd::String::npos; +} + +std::size_t Project::GetCinematicSequencesCount() const { + return cinematicSequences.size(); +} + +gd::CinematicSequence& Project::InsertNewCinematicSequence(const gd::String& name, + std::size_t position) { + gd::CinematicSequence& newlyInsertedCinematicSequence = *(*(cinematicSequences.emplace( + position < cinematicSequences.size() ? cinematicSequences.begin() + position + : cinematicSequences.end(), + new gd::CinematicSequence()))); + + newlyInsertedCinematicSequence.SetName(name); + return newlyInsertedCinematicSequence; +} + +gd::CinematicSequence& Project::InsertCinematicSequence( + const gd::CinematicSequence& layout, std::size_t position) { + gd::CinematicSequence& newlyInsertedCinematicSequence = *(*(cinematicSequences.emplace( + position < cinematicSequences.size() ? cinematicSequences.begin() + position + : cinematicSequences.end(), + new gd::CinematicSequence(layout)))); + + return newlyInsertedCinematicSequence; +} + +void Project::RemoveCinematicSequence(const gd::String& name) { + std::vector >::iterator cinematicSequence = + find_if(cinematicSequences.begin(), + cinematicSequences.end(), + [&name](const std::unique_ptr& cinematicSequence) { + return cinematicSequence->GetName() == name; + }); + if (cinematicSequence == cinematicSequences.end()) return; + + cinematicSequences.erase(cinematicSequence); } void Project::SwapEventsFunctionsExtensions(std::size_t first, @@ -1215,7 +1326,318 @@ void Project::SerializeTo(SerializerElement& element) const { externalLayoutsElement.ConsiderAsArrayOf("externalLayout"); for (std::size_t i = 0; i < externalLayouts.size(); ++i) externalLayouts[i]->SerializeTo( - externalLayoutsElement.AddChild("externalLayout")); + externalLayoutsElement.AddChild("externalLayout")); + + SerializerElement& cinematicSequencesElement = + element.GetChild("cinematicSequences", 0, "CinematicSequences"); + cinematicSequencesElement.ConsiderAsArrayOf("cinematicSequence", "CinematicSequence"); + for (std::size_t i = 0; i < cinematicSequencesElement.GetChildrenCount(); ++i) { + const SerializerElement& cinematicSequenceElement = + cinematicSequencesElement.GetChild(i); + + gd::CinematicSequence& newCinematicSequence = + InsertNewCinematicSequence("", GetCinematicSequencesCount()); + newCinematicSequence.UnserializeFrom(*this, cinematicSequenceElement); + } +} + +void Project::UnserializeAndInsertExtensionsFrom( + const gd::SerializerElement &eventsFunctionsExtensionsElement) { + eventsFunctionsExtensionsElement.ConsiderAsArrayOf( + "eventsFunctionsExtension"); + + std::map extensionNameToElementIndex; + std::map objectTypeToVariantsElement; + + // First, only unserialize behaviors and objects names. + // As event based objects can contains custom behaviors and custom objects, + // this allows them to reference EventBasedBehavior and EventBasedObject + // respectively. + for (std::size_t i = 0; + i < eventsFunctionsExtensionsElement.GetChildrenCount(); + ++i) { + const SerializerElement& eventsFunctionsExtensionElement = + eventsFunctionsExtensionsElement.GetChild(i); + const gd::String& name = eventsFunctionsExtensionElement.GetStringAttribute("name"); + extensionNameToElementIndex[name] = i; + + gd::EventsFunctionsExtension& eventsFunctionsExtension = + HasEventsFunctionsExtensionNamed(name) + ? GetEventsFunctionsExtension(name) + : InsertNewEventsFunctionsExtension( + name, GetEventsFunctionsExtensionsCount()); + + // Backup the events-based object variants + for (auto &eventsBasedObject : + eventsFunctionsExtension.GetEventsBasedObjects().GetInternalVector()) { + gd::SerializerElement variantsElement; + eventsBasedObject->GetVariants().SerializeVariantsTo(variantsElement); + objectTypeToVariantsElement[gd::PlatformExtension::GetObjectFullType( + name, eventsBasedObject->GetName())] = variantsElement; + } + + eventsFunctionsExtension.UnserializeExtensionDeclarationFrom( + *this, eventsFunctionsExtensionElement); + } + + // Then unserialize functions, behaviors and objects content. + for (gd::String &extensionName : + GetUnserializingOrderExtensionNames(eventsFunctionsExtensionsElement)) { + + size_t extensionIndex = GetEventsFunctionsExtensionPosition(extensionName); + if (extensionIndex == gd::String::npos) { + // Should never happen because the extension was added in the first pass. + gd::LogError("Can't find extension " + extensionName + " in the list of extensions in second pass of unserialization."); + continue; + } + auto& partiallyLoadedExtension = eventsFunctionsExtensions.at(extensionIndex); + + if (extensionNameToElementIndex.find(extensionName) == extensionNameToElementIndex.end()) { + // Should never happen because the extension element is present. + gd::LogError("Can't find extension element to unserialize for " + extensionName + " in second pass of unserialization."); + continue; + } + size_t elementIndex = extensionNameToElementIndex[extensionName]; + const SerializerElement &eventsFunctionsExtensionElement = + eventsFunctionsExtensionsElement.GetChild(elementIndex); + + partiallyLoadedExtension + ->UnserializeExtensionImplementationFrom( + *this, eventsFunctionsExtensionElement); + + for (auto &pair : objectTypeToVariantsElement) { + auto &objectType = pair.first; + auto &variantsElement = pair.second; + + auto &eventsBasedObject = GetEventsBasedObject(objectType); + eventsBasedObject.GetVariants().UnserializeVariantsFrom(*this, + variantsElement); + } + } +} + +std::vector Project::GetUnserializingOrderExtensionNames( + const gd::SerializerElement &eventsFunctionsExtensionsElement) { + eventsFunctionsExtensionsElement.ConsiderAsArrayOf( + "eventsFunctionsExtension"); + + // Some extension have custom objects, which have child objects coming from other extension. + // These child objects must be loaded completely before the parent custom obejct can be unserialized. + // This implies: an order on the extension unserialization (and no cycles). + + // At the beginning, everything is yet to be loaded. + std::map extensionNameToElementIndex; + std::vector remainingExtensionNames( + eventsFunctionsExtensionsElement.GetChildrenCount()); + for (std::size_t i = 0; i < eventsFunctionsExtensionsElement.GetChildrenCount(); ++i) { + const SerializerElement& eventsFunctionsExtensionElement = + eventsFunctionsExtensionsElement.GetChild(i); + const gd::String& name = eventsFunctionsExtensionElement.GetStringAttribute("name"); + + remainingExtensionNames[i] = name; + extensionNameToElementIndex[name] = i; + } + + // Helper allowing to find if an extension has an object that depends on + // at least one other object from another extension that is not loaded yet. + auto isDependentFromRemainingExtensions = + [&remainingExtensionNames]( + const gd::SerializerElement &eventsFunctionsExtensionElement) { + auto &eventsBasedObjectsElement = + eventsFunctionsExtensionElement.GetChild("eventsBasedObjects"); + eventsBasedObjectsElement.ConsiderAsArrayOf("eventsBasedObject"); + for (std::size_t eventsBasedObjectsIndex = 0; + eventsBasedObjectsIndex < + eventsBasedObjectsElement.GetChildrenCount(); + ++eventsBasedObjectsIndex) { + auto &objectsElement = + eventsBasedObjectsElement.GetChild(eventsBasedObjectsIndex) + .GetChild("objects"); + objectsElement.ConsiderAsArrayOf("object"); + + for (std::size_t objectIndex = 0; + objectIndex < objectsElement.GetChildrenCount(); ++objectIndex) { + const gd::String &objectType = + objectsElement.GetChild(objectIndex).GetStringAttribute("type"); + + gd::String extensionName = + eventsFunctionsExtensionElement.GetStringAttribute("name"); + gd::String usedExtensionName = + gd::PlatformExtension::GetExtensionFromFullObjectType(objectType); + + if (usedExtensionName != extensionName && + std::find(remainingExtensionNames.begin(), + remainingExtensionNames.end(), + usedExtensionName) != remainingExtensionNames.end()) { + return true; + } + } + } + return false; + }; + + // Find the order of loading so that the extensions are loaded when all the other + // extensions they depend on are already loaded. + std::vector loadOrderExtensionNames; + bool foundAnyExtension = true; + while (foundAnyExtension) { + foundAnyExtension = false; + for (std::size_t i = 0; i < remainingExtensionNames.size(); ++i) { + auto extensionName = remainingExtensionNames[i]; + + size_t elementIndex = extensionNameToElementIndex[extensionName]; + const SerializerElement &eventsFunctionsExtensionElement = + eventsFunctionsExtensionsElement.GetChild(elementIndex); + + if (!isDependentFromRemainingExtensions( + eventsFunctionsExtensionElement)) { + loadOrderExtensionNames.push_back(extensionName); + remainingExtensionNames.erase(remainingExtensionNames.begin() + i); + i--; + foundAnyExtension = true; + } + } + } + return loadOrderExtensionNames; +} + +void Project::SerializeTo(SerializerElement& element) const { + SerializerElement& versionElement = element.AddChild("gdVersion"); + versionElement.SetAttribute("major", gd::VersionWrapper::Major()); + versionElement.SetAttribute("minor", gd::VersionWrapper::Minor()); + versionElement.SetAttribute("build", gd::VersionWrapper::Build()); + versionElement.SetAttribute("revision", gd::VersionWrapper::Revision()); + + SerializerElement& propElement = element.AddChild("properties"); + propElement.AddChild("name").SetValue(GetName()); + propElement.AddChild("description").SetValue(GetDescription()); + propElement.SetAttribute("version", GetVersion()); + propElement.AddChild("author").SetValue(GetAuthor()); + propElement.AddChild("windowWidth").SetValue(GetGameResolutionWidth()); + propElement.AddChild("windowHeight").SetValue(GetGameResolutionHeight()); + propElement.AddChild("latestCompilationDirectory") + .SetValue(GetLastCompilationDirectory()); + propElement.AddChild("maxFPS").SetValue(GetMaximumFPS()); + propElement.AddChild("minFPS").SetValue(GetMinimumFPS()); + propElement.AddChild("verticalSync") + .SetValue(IsVerticalSynchronizationEnabledByDefault()); + propElement.SetAttribute("scaleMode", scaleMode); + propElement.SetAttribute("pixelsRounding", pixelsRounding); + propElement.SetAttribute("adaptGameResolutionAtRuntime", + adaptGameResolutionAtRuntime); + propElement.SetAttribute("sizeOnStartupMode", sizeOnStartupMode); + propElement.SetAttribute("antialiasingMode", antialiasingMode); + propElement.SetAttribute("antialisingEnabledOnMobile", + isAntialisingEnabledOnMobile); + propElement.SetAttribute("projectUuid", projectUuid); + propElement.SetAttribute("folderProject", folderProject); + propElement.SetAttribute("packageName", packageName); + propElement.SetAttribute("templateSlug", templateSlug); + propElement.SetAttribute("orientation", orientation); + if (areEffectsHiddenInEditor) { + propElement.SetBoolAttribute("areEffectsHiddenInEditor", + areEffectsHiddenInEditor); + } + platformSpecificAssets.SerializeTo( + propElement.AddChild("platformSpecificAssets")); + loadingScreen.SerializeTo(propElement.AddChild("loadingScreen")); + watermark.SerializeTo(propElement.AddChild("watermark")); + + auto& authorIdsElement = propElement.AddChild("authorIds"); + authorIdsElement.ConsiderAsArray(); + for (const auto& authorId : authorIds) { + authorIdsElement.AddChild("").SetStringValue(authorId); + } + auto& authorUsernamesElement = propElement.AddChild("authorUsernames"); + authorUsernamesElement.ConsiderAsArray(); + for (const auto& authorUsername : authorUsernames) { + authorUsernamesElement.AddChild("").SetStringValue(authorUsername); + } + + auto& categoriesElement = propElement.AddChild("categories"); + categoriesElement.ConsiderAsArray(); + for (const auto& category : categories) { + categoriesElement.AddChild("").SetStringValue(category); + } + + auto& playableDevicesElement = propElement.AddChild("playableDevices"); + playableDevicesElement.ConsiderAsArray(); + if (isPlayableWithKeyboard) { + playableDevicesElement.AddChild("").SetStringValue("keyboard"); + } + if (isPlayableWithGamepad) { + playableDevicesElement.AddChild("").SetStringValue("gamepad"); + } + if (isPlayableWithMobile) { + playableDevicesElement.AddChild("").SetStringValue("mobile"); + } + + // Compatibility with GD <= 5.0.0-beta101 + if (useDeprecatedZeroAsDefaultZOrder) { + propElement.SetAttribute("useDeprecatedZeroAsDefaultZOrder", true); + } + // end of compatibility code + + extensionProperties.SerializeTo(propElement.AddChild("extensionProperties")); + + playableDevicesElement.AddChild("").SetStringValue("mobile"); + + SerializerElement& platformsElement = propElement.AddChild("platforms"); + platformsElement.ConsiderAsArrayOf("platform"); + for (std::size_t i = 0; i < platforms.size(); ++i) { + if (platforms[i] == NULL) { + std::cout << "ERROR: The project has a platform which is NULL."; + continue; + } + + platformsElement.AddChild("platform") + .SetAttribute("name", platforms[i]->GetName()); + } + if (currentPlatform != NULL) + propElement.AddChild("currentPlatform") + .SetValue(currentPlatform->GetName()); + else + std::cout << "ERROR: The project current platform is NULL."; + + if (sceneResourcesPreloading != "at-startup") { + propElement.SetAttribute("sceneResourcesPreloading", sceneResourcesPreloading); + } + if (sceneResourcesUnloading != "never") { + propElement.SetAttribute("sceneResourcesUnloading", sceneResourcesUnloading); + } + + resourcesContainer.SerializeTo(element.AddChild("resources")); + objectsContainer.SerializeObjectsTo(element.AddChild("objects")); + objectsContainer.SerializeFoldersTo(element.AddChild("objectsFolderStructure")); + objectsContainer.GetObjectGroups().SerializeTo(element.AddChild("objectsGroups")); + GetVariables().SerializeTo(element.AddChild("variables")); + + element.SetAttribute("firstLayout", firstLayout); + gd::SerializerElement& layoutsElement = element.AddChild("layouts"); + layoutsElement.ConsiderAsArrayOf("layout"); + for (std::size_t i = 0; i < GetLayoutsCount(); i++) + GetLayout(i).SerializeTo(layoutsElement.AddChild("layout")); + + SerializerElement& externalEventsElement = element.AddChild("externalEvents"); + externalEventsElement.ConsiderAsArrayOf("externalEvents"); + for (std::size_t i = 0; i < GetExternalEventsCount(); ++i) + GetExternalEvents(i).SerializeTo( + externalEventsElement.AddChild("externalEvents")); + + SerializerElement& eventsFunctionsExtensionsElement = + element.AddChild("eventsFunctionsExtensions"); + eventsFunctionsExtensionsElement.ConsiderAsArrayOf( + "eventsFunctionsExtension"); + for (std::size_t i = 0; i < eventsFunctionsExtensions.size(); ++i) + eventsFunctionsExtensions[i]->SerializeTo( + eventsFunctionsExtensionsElement.AddChild("eventsFunctionsExtension")); + + SerializerElement& cinematicSequencesElement = + element.AddChild("cinematicSequences"); + cinematicSequencesElement.ConsiderAsArrayOf("cinematicSequence"); + for (std::size_t i = 0; i < cinematicSequences.size(); ++i) + cinematicSequences[i]->SerializeTo( + cinematicSequencesElement.AddChild("cinematicSequence")); } bool Project::IsNameSafe(const gd::String& name) { @@ -1320,7 +1742,8 @@ void Project::Init(const gd::Project& game) { externalEvents = gd::Clone(game.externalEvents); - externalLayouts = gd::Clone(game.externalLayouts); + externalLayouts = gd::Clone(game.externalLayouts); + cinematicSequences = gd::Clone(game.cinematicSequences); eventsFunctionsExtensions = gd::Clone(game.eventsFunctionsExtensions); variables = game.GetVariables(); diff --git a/Core/GDCore/Project/Project.h b/Core/GDCore/Project/Project.h index 1c244fe8ec96..a2edf201348f 100644 --- a/Core/GDCore/Project/Project.h +++ b/Core/GDCore/Project/Project.h @@ -25,6 +25,7 @@ class Layout; class ExternalEvents; class ResourcesContainer; class ExternalLayout; +class CinematicSequence; class EventsFunctionsExtension; class EventsBasedObject; class EventsBasedBehavior; @@ -821,6 +822,82 @@ class GD_CORE_API Project { */ void RemoveExternalLayout(const gd::String& name); + ///@} + + /** \name Cinematic sequence management + * Members functions related to cinematic sequence management. + */ + ///@{ + + /** + * Return true if cinematic sequence called "name" exists. + */ + bool HasCinematicSequenceNamed(const gd::String& name) const; + + /** + * Return a reference to the cinematic sequence called "name". + */ + CinematicSequence& GetCinematicSequence(const gd::String& name); + + /** + * Return a reference to the cinematic sequence called "name". + */ + const CinematicSequence& GetCinematicSequence(const gd::String& name) const; + + /** + * Return a reference to the cinematic sequence at position "index" in the + * cinematic sequence list + */ + CinematicSequence& GetCinematicSequence(std::size_t index); + + /** + * Return a reference to the cinematic sequence at position "index" in the + * cinematic sequence list + */ + const CinematicSequence& GetCinematicSequence(std::size_t index) const; + + /** + * Return the position of the cinematic sequence called "name" in the + * cinematic sequence list + */ + std::size_t GetCinematicSequencePosition(const gd::String& name) const; + + /** + * Change the position of the specified cinematic sequence. + */ + void MoveCinematicSequence(std::size_t oldIndex, std::size_t newIndex); + + /** + * \brief Swap the specified cinematic sequences. + * + * Do nothing if indexes are not correct. + */ + void SwapCinematicSequences(std::size_t first, std::size_t second); + + /** + * Return the number of cinematic sequences. + */ + std::size_t GetCinematicSequencesCount() const; + + /** + * \brief Adds a new empty cinematic sequence called "name" at the specified + * position in the list. + */ + gd::CinematicSequence& InsertNewCinematicSequence(const gd::String& name, + std::size_t position); + + /** + * \brief Adds a new cinematic sequence constructed from the sequence passed + * as parameter. + */ + gd::CinematicSequence& InsertCinematicSequence( + const CinematicSequence& cinematicSequence, std::size_t position); + + /** + * \brief Delete cinematic sequence named "name". + */ + void RemoveCinematicSequence(const gd::String& name); + /** * Set the first layout of the project. */ @@ -1128,6 +1205,8 @@ class GD_CORE_API Project { gd::ObjectsContainer objectsContainer; std::vector > externalLayouts; ///< List of all externals layouts + std::vector > + cinematicSequences; ///< List of all cinematic sequences std::vector > eventsFunctionsExtensions; gd::ResourcesContainer diff --git a/Extensions/CinematicSequencer/JsExtension.js b/Extensions/CinematicSequencer/JsExtension.js new file mode 100644 index 000000000000..36a511812172 --- /dev/null +++ b/Extensions/CinematicSequencer/JsExtension.js @@ -0,0 +1,62 @@ +//@ts-check +/// + +/** @type {ExtensionModule} */ +module.exports = { + createExtension: function (_, gd) { + const extension = new gd.PlatformExtension(); + extension + .setExtensionInformation( + 'CinematicSequencer', + _('Cinematic Sequencer'), + _('Allows playing complex timeline animations exported from the Cinematic Sequencer editor.'), + 'Tech Shop', + 'MIT' + ) + .setShortDescription( + 'Play cinematic sequences dynamically in the game runtime.' + ); + extension + .addInstructionOrExpressionGroupMetadata(_('Cinematic Sequencer')) + .setIcon('res/actions/camera.png'); + + extension + .addAction( + 'PlayCinematicSequence', + _('Play cinematic sequence'), + _('Play a cinematic sequence by its name. Requires the sequence JSON data to be passed.'), + _('Play cinematic sequence _PARAM0_'), + _('Cinematic Sequencer'), + 'res/actions/camera.png', + 'res/actions/camera.png' + ) + .addParameter('string', _('Sequence Name/Data (JSON)'), '', false) + .getCodeExtraInformation() + .setIncludeFile( + 'Extensions/CinematicSequencer/cinematicsequencertools.js' + ) + .setFunctionName('gdjs.evtTools.cinematicSequencer.playSequence'); + + extension + .addCondition( + 'IsCinematicSequencePlaying', + _('Is cinematic sequence playing'), + _('Check if a cinematic sequence is currently playing.'), + _('Cinematic sequence _PARAM0_ is playing'), + _('Cinematic Sequencer'), + 'res/actions/camera.png', + 'res/actions/camera.png' + ) + .addParameter('string', _('Sequence Name'), '', false) + .getCodeExtraInformation() + .setIncludeFile( + 'Extensions/CinematicSequencer/cinematicsequencertools.js' + ) + .setFunctionName('gdjs.evtTools.cinematicSequencer.isPlaying'); + + return extension; + }, + runExtensionSanityTests: function (gd, extension) { + return []; + }, +}; diff --git a/Extensions/CinematicSequencer/cinematicsequencertools.js b/Extensions/CinematicSequencer/cinematicsequencertools.js new file mode 100644 index 000000000000..57130e3beb21 --- /dev/null +++ b/Extensions/CinematicSequencer/cinematicsequencertools.js @@ -0,0 +1,74 @@ +// @ts-check +gdjs.evtTools = gdjs.evtTools || {}; +gdjs.evtTools.cinematicSequencer = gdjs.evtTools.cinematicSequencer || {}; + +/** + * Global state to keep track of playing cinematics. + */ +gdjs.evtTools.cinematicSequencer.activeCinematics = {}; + +/** + * @param {gdjs.RuntimeScene} runtimeScene + * @param {string} sequenceJson + */ +gdjs.evtTools.cinematicSequencer.playSequence = function (runtimeScene, sequenceJson) { + if (!sequenceJson) return; + try { + const sequenceData = JSON.parse(sequenceJson); + const tracks = sequenceData.tracks || []; + + const seqName = sequenceData.name || "Cinematic_" + Date.now(); + gdjs.evtTools.cinematicSequencer.activeCinematics[seqName] = true; + + console.log("Playing Cinematic Sequence: " + seqName); + + // Simple implementation of Event Generation at Runtime + // For each track, schedule movements based on keyframes. + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]; + if (track.type === 'object') { + const objects = runtimeScene.getObjects(track.name); + if (!objects || objects.length === 0) continue; + + for (let j = 0; j < track.keyframes.length; j++) { + const kf = track.keyframes[j]; + const timeMs = kf.time * 1000; + + // Relying on JS Timers as a simple mapping of engine's Timer + setTimeout(() => { + for (let o = 0; o < objects.length; o++) { + // Apply keyframe value (e.g. {x, y, angle}) + if (kf.value && typeof kf.value === 'object') { + if (kf.value.x !== undefined) objects[o].setX(kf.value.x); + if (kf.value.y !== undefined) objects[o].setY(kf.value.y); + if (kf.value.angle !== undefined) objects[o].setAngle(kf.value.angle); + } + } + }, timeMs); + } + } + } + + const maxTime = tracks.reduce((max, t) => { + if (!t.keyframes || !t.keyframes.length) return max; + return Math.max(max, t.keyframes[t.keyframes.length - 1].time); + }, 0); + + setTimeout(() => { + gdjs.evtTools.cinematicSequencer.activeCinematics[seqName] = false; + console.log("Cinematic finished: " + seqName); + }, (maxTime * 1000) + 100); // 100ms padding + + } catch (e) { + console.error("Failed to parse or play cinematic:", e); + } +}; + +/** + * @param {gdjs.RuntimeScene} runtimeScene + * @param {string} sequenceName + * @returns {boolean} + */ +gdjs.evtTools.cinematicSequencer.isPlaying = function (runtimeScene, sequenceName) { + return !!gdjs.evtTools.cinematicSequencer.activeCinematics[sequenceName]; +}; diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 6af678ece6c7..ab050ed48d96 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -644,6 +644,16 @@ interface Project { void RemoveExternalLayout([Const] DOMString name); unsigned long GetExternalLayoutPosition([Const] DOMString name); + boolean HasCinematicSequenceNamed([Const] DOMString name); + [Ref] CinematicSequence GetCinematicSequence([Const] DOMString name); + [Ref] CinematicSequence GetCinematicSequenceAt(unsigned long index); + void MoveCinematicSequence(unsigned long oldIndex, unsigned long newIndex); + void SwapCinematicSequences(unsigned long first, unsigned long second); + unsigned long GetCinematicSequencesCount(); + [Ref] CinematicSequence InsertNewCinematicSequence([Const] DOMString name, unsigned long position); + void RemoveCinematicSequence([Const] DOMString name); + unsigned long GetCinematicSequencePosition([Const] DOMString name); + boolean HasEventsFunctionsExtensionNamed([Const] DOMString name); [Ref] EventsFunctionsExtension GetEventsFunctionsExtension([Const] DOMString name); [Ref] EventsFunctionsExtension GetEventsFunctionsExtensionAt(unsigned long index); diff --git a/newIDE/app/build_errors.txt b/newIDE/app/build_errors.txt new file mode 100644 index 000000000000..cc07e65136b7 Binary files /dev/null and b/newIDE/app/build_errors.txt differ diff --git a/newIDE/app/package-lock.json b/newIDE/app/package-lock.json index f8a1bf11aacc..a3642f469b60 100644 --- a/newIDE/app/package-lock.json +++ b/newIDE/app/package-lock.json @@ -2519,6 +2519,57 @@ "react": ">=16.8.0" } }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.18.20", "cpu": [ @@ -2534,6 +2585,312 @@ "node": ">=12" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "dev": true, @@ -9755,6 +10112,159 @@ "node": ">=10" } }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.3.92", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.92.tgz", + "integrity": "sha512-Q3XIgQfXyxxxms3bPN+xGgvwk0TtG9l89IomApu+yTKzaIIlf051mS+lGngjnh9L0aUiCp6ICyjDLtutWP54fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.3.92", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.92.tgz", + "integrity": "sha512-tnOCoCpNVXC+0FCfG84PBZJyLlz0Vfj9MQhyhCvlJz9hQmvpf8nTdKH7RHrOn8VfxtUBLdVi80dXgIFgbvl7qA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.3.92", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.92.tgz", + "integrity": "sha512-lFfGhX32w8h1j74Iyz0Wv7JByXIwX11OE9UxG+oT7lG0RyXkF4zKyxP8EoxfLrDXse4Oop434p95e3UNC3IfCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.3.92", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.92.tgz", + "integrity": "sha512-rOZtRcLj57MSAbiecMsqjzBcZDuaCZ8F6l6JDwGkQ7u1NYR57cqF0QDyU7RKS1Jq27Z/Vg21z5cwqoH5fLN+Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.3.92", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.92.tgz", + "integrity": "sha512-qptoMGnBL6v89x/Qpn+l1TH1Y0ed+v0qhNfAEVzZvCvzEMTFXphhlhYbDdpxbzRmCjH6GOGq7Y+xrWt9T1/ARg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.3.92", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.92.tgz", + "integrity": "sha512-g2KrJ43bZkCZHH4zsIV5ErojuV1OIpUHaEyW1gf7JWKaFBpWYVyubzFPvPkjcxHGLbMsEzO7w/NVfxtGMlFH/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.3.92", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.92.tgz", + "integrity": "sha512-3MCRGPAYDoQ8Yyd3WsCMc8eFSyKXY5kQLyg/R5zEqA0uthomo0m0F5/fxAJMZGaSdYkU1DgF73ctOWOf+Z/EzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.3.92", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.92.tgz", + "integrity": "sha512-zqTBKQhgfWm73SVGS8FKhFYDovyRl1f5dTX1IwSKynO0qHkRCqJwauFJv/yevkpJWsI2pFh03xsRs9HncTQKSA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.3.92", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.92.tgz", + "integrity": "sha512-41bE66ddr9o/Fi1FBh0sHdaKdENPTuDpv1IFHxSg0dJyM/jX8LbkjnpdInYXHBxhcLVAPraVRrNsC4SaoPw2Pg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, "node_modules/@swc/counter": { "version": "0.1.2", "dev": true, @@ -10266,91 +10776,20 @@ "@types/node": "*" } }, - "node_modules/@types/source-list-map": { - "version": "0.1.2", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/stack-utils": { "version": "2.0.1", "dev": true, "license": "MIT" }, - "node_modules/@types/tapable": { - "version": "1.0.8", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/trusted-types": { "version": "2.0.3", "dev": true, "license": "MIT" }, - "node_modules/@types/uglify-js": { - "version": "3.13.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "source-map": "^0.6.1" - } - }, - "node_modules/@types/uglify-js/node_modules/source-map": { - "version": "0.6.1", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@types/unist": { "version": "2.0.6", "license": "MIT" }, - "node_modules/@types/webpack": { - "version": "4.41.32", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "@types/tapable": "^1", - "@types/uglify-js": "*", - "@types/webpack-sources": "*", - "anymatch": "^3.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/@types/webpack-sources": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "@types/source-list-map": "*", - "source-map": "^0.7.3" - } - }, - "node_modules/@types/webpack/node_modules/source-map": { - "version": "0.6.1", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@types/ws": { "version": "8.5.5", "dev": true, @@ -16068,19 +16507,6 @@ "node": ">=0.4.x" } }, - "node_modules/eventsource": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "original": "^1.0.0" - }, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/execa": { "version": "0.7.0", "dev": true, @@ -25317,16 +25743,6 @@ "node": ">=4" } }, - "node_modules/original": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "url-parse": "^1.4.3" - } - }, "node_modules/os-homedir": { "version": "1.0.2", "dev": true, @@ -31236,56 +31652,6 @@ "websocket-driver": "^0.7.4" } }, - "node_modules/sockjs-client": { - "version": "1.6.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "^3.2.7", - "eventsource": "^1.1.0", - "faye-websocket": "^0.11.4", - "inherits": "^2.0.4", - "url-parse": "^1.5.10" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://tidelift.com/funding/github/npm/sockjs-client" - } - }, - "node_modules/sockjs-client/node_modules/debug": { - "version": "3.2.7", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/sockjs-client/node_modules/faye-websocket": { - "version": "0.11.4", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/sockjs-client/node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/sockjs/node_modules/uuid": { "version": "8.3.2", "dev": true, diff --git a/newIDE/app/public/external/yarn/yarn-editor.zip b/newIDE/app/public/external/yarn/yarn-editor.zip new file mode 100644 index 000000000000..4068f7d5beb5 Binary files /dev/null and b/newIDE/app/public/external/yarn/yarn-editor.zip differ diff --git a/newIDE/app/src/CinematicSequenceEditor/index.js b/newIDE/app/src/CinematicSequenceEditor/index.js new file mode 100644 index 000000000000..ecc166095a17 --- /dev/null +++ b/newIDE/app/src/CinematicSequenceEditor/index.js @@ -0,0 +1,251 @@ +// @flow +import * as React from 'react'; +import { I18n } from '@lingui/react'; +import { type I18n as I18nType } from '@lingui/core'; +import { t } from '@lingui/macro'; +import { Column, Line } from '../UI/Grid'; +import Text from '../UI/Text'; +import ScrollView from '../UI/ScrollView'; +import BackgroundText from '../UI/BackgroundText'; +import ThemeConsumer from '../UI/Theme/ThemeConsumer'; +import IconButton from '../UI/IconButton'; +import FlatButton from '../UI/FlatButton'; +import PlayArrow from '@material-ui/icons/PlayArrow'; +import Pause from '@material-ui/icons/Pause'; +import Stop from '@material-ui/icons/Stop'; +import Add from '@material-ui/icons/Add'; +import FastRewind from '@material-ui/icons/FastRewind'; +import Menu from '../UI/Menu/Menu'; +import ContextMenu from '../UI/Menu/ContextMenu'; + +const styles = { + container: { display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'var(--mosaic-window-body-bg)', overflow: 'hidden' }, + toolbar: { display: 'flex', alignItems: 'center', padding: '4px 8px', borderBottom: '1px solid var(--mosaic-border-color)' }, + mainArea: { display: 'flex', flex: 1, overflow: 'hidden' }, + trackList: { width: 250, borderRight: '1px solid var(--mosaic-border-color)', overflowY: 'auto' }, + timelineArea: { flex: 1, position: 'relative', overflowX: 'auto', overflowY: 'hidden' }, + trackHeader: { height: 40, display: 'flex', alignItems: 'center', padding: '0 8px', borderBottom: '1px solid var(--mosaic-border-color)' }, + trackRow: { height: 40, borderBottom: '1px solid var(--mosaic-border-color)', position: 'relative' }, + timeRulerContainer: { display: 'flex', flexDirection: 'column' }, + timeRuler: { height: 30, borderBottom: '1px solid var(--mosaic-border-color)', position: 'relative', overflow: 'hidden' }, + keyframe: { position: 'absolute', width: 12, height: 12, backgroundColor: '#007acc', transform: 'rotate(45deg) translateY(-50%)', top: '50%', cursor: 'grab', border: '1px solid #fff' }, + playhead: { position: 'absolute', width: 2, height: '100%', backgroundColor: '#ff0000', zIndex: 10, pointerEvents: 'none' }, +}; + +export default class CinematicSequenceEditor extends React.Component { + _playTimer = null; + _timelineRef = React.createRef(); + + constructor(props) { + super(props); + this.state = { + currentTime: 0, + isPlaying: false, + duration: 10, // Default 10 seconds + tracks: this._parseTracks(), + zoom: 100, // pixels per second + }; + } + + componentWillUnmount() { + this._stop(); + } + + _parseTracks() { + try { + const data = this.props.cinematicSequence.getSequenceData(); + if (!data) return []; + const parsed = JSON.parse(data); + if (parsed && Array.isArray(parsed.tracks)) { + return parsed.tracks; + } + return []; + } catch (err) { + console.error('Failed to parse cinematic sequence data', err); + return []; + } + } + + _saveTracks(tracks) { + const data = JSON.stringify({ tracks }); + this.props.cinematicSequence.setSequenceData(data); + this.props.onSequenceModified(); + this.setState({ tracks }); + } + + _play = () => { + if (this.state.isPlaying) return; + this.setState({ isPlaying: true }); + let lastTime = performance.now(); + this._playTimer = setInterval(() => { + const now = performance.now(); + const dt = (now - lastTime) / 1000; + lastTime = now; + this.setState(prevState => { + let nextTime = prevState.currentTime + dt; + if (nextTime >= prevState.duration) { + nextTime = 0; // Loop or stop + this._stop(); + return { currentTime: prevState.duration, isPlaying: false }; + } + return { currentTime: nextTime }; + }, () => { + if (this.props.onTimeChange) { + this.props.onTimeChange(this.state.currentTime); + } + }); + }, 16); + }; + + _pause = () => { + this._stop(); + this.setState({ isPlaying: false }); + }; + + _stop = () => { + if (this._playTimer) { + clearInterval(this._playTimer); + this._playTimer = null; + } + }; + + _resetPlayhead = () => { + this.setState({ currentTime: 0 }); + this._pause(); + if (this.props.onTimeChange) { + this.props.onTimeChange(0); + } + }; + + _addTrack = (type) => { + const newTrack = { + id: Math.random().toString(36).substring(7), + name: `New ${type} track`, + type: type, + keyframes: [], + }; + this._saveTracks([...this.state.tracks, newTrack]); + }; + + _addKeyframe = (trackId, time) => { + const tracks = this.state.tracks.map(t => { + if (t.id === trackId) { + return { + ...t, + keyframes: [...t.keyframes, { time, value: null }].sort((a, b) => a.time - b.time), + }; + } + return t; + }); + this._saveTracks(tracks); + }; + + _removeTrack = (trackId) => { + const tracks = this.state.tracks.filter(t => t.id !== trackId); + this._saveTracks(tracks); + }; + + _handleTimelineClick = (e) => { + if (!this._timelineRef.current) return; + const rect = this._timelineRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left + this._timelineRef.current.scrollLeft; + const time = Math.max(0, x / this.state.zoom); + this.setState({ currentTime: time }); + if (this.props.onTimeChange) { + this.props.onTimeChange(time); + } + }; + + render() { + const { currentTime, isPlaying, duration, tracks, zoom } = this.state; + const pxPerSecond = zoom; + const playheadX = currentTime * pxPerSecond; + const timelineWidth = Math.max(duration * pxPerSecond, 800); + + return ( + + {theme => ( + + {({ i18n }) => ( +
+ {/* TOOLBAR */} +
+ + + + + {isPlaying ? : } + + { this._resetPlayhead(); }} tooltip={i18n._(t`Stop`)}> + + + + {currentTime.toFixed(2)}s / {duration.toFixed(2)}s + + + } + label={i18n._(t`Add Track`)} + onClick={() => this._addTrack('object')} + /> +
+ + {/* MAIN AREA */} +
+ + {/* TRACK LIST (LEFT) */} +
+
+ {i18n._(t`Tracks`)} +
+ {tracks.length === 0 && ( +
+ {i18n._(t`No tracks added.`)} +
+ )} + {tracks.map(track => ( +
+
+ {track.name} + this._removeTrack(track.id)}>✕ +
+
+ ))} +
+ + {/* TIMELINE (RIGHT) */} +
+
+
+ {/* Ruler markings could go here */} +
+
+ + {tracks.map(track => ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + this._addKeyframe(track.id, x / zoom); + }}> + {track.keyframes.map((kf, i) => ( +
{ e.stopPropagation(); /* select keyframe */ }} + title={`${kf.time.toFixed(2)}s`} + /> + ))} +
+ ))} +
+
+ +
+
+ )} + + )} + + ); + } +} diff --git a/newIDE/app/src/MainFrame/EditorContainers/CinematicSequenceEditorContainer.js b/newIDE/app/src/MainFrame/EditorContainers/CinematicSequenceEditorContainer.js new file mode 100644 index 000000000000..c23c68d9e7bb --- /dev/null +++ b/newIDE/app/src/MainFrame/EditorContainers/CinematicSequenceEditorContainer.js @@ -0,0 +1,247 @@ +// @flow +import { Trans } from '@lingui/macro'; +import * as React from 'react'; +import { I18n } from '@lingui/react'; +import { Column, Line } from '../../UI/Grid'; +import Text from '../../UI/Text'; +import CinematicSequenceEditor from '../../CinematicSequenceEditor'; +import SceneEditor from '../../SceneEditor'; +import { ProjectScopedContainersAccessor } from '../../InstructionOrExpression/EventsScope'; +import Background from '../../UI/Background'; +import PlaceholderMessage from '../../UI/PlaceholderMessage'; +import RaisedButton from '../../UI/RaisedButton'; +import ExternalPropertiesDialog from './ExternalPropertiesDialog'; + +const styles = { + container: { display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'var(--mosaic-window-body-bg)' }, + topArea: { display: 'flex', flex: 2, position: 'relative', overflow: 'hidden' }, + bottomArea: { display: 'flex', flex: 1, borderTop: '2px solid var(--mosaic-border-color)' } +}; + +export class CinematicSequenceEditorContainer extends React.Component { + editor = null; + + state = { + externalPropertiesDialogOpen: false, + }; + + getProject() { + return this.props.project; + } + + saveUiSettings = () => { }; + + updateToolbar() { + if (this.editor) this.editor.updateToolbar(); + } + + forceUpdateEditor() { + if (this.editor) { + this.editor.forceUpdateObjectsList(); + this.editor.forceUpdateObjectGroupsList(); + this.editor.forceUpdateLayersList(); + } + } + + onUserInteraction = () => { }; + + getCinematicSequence() { + const { project, cinematicSequenceName } = this.props; + if (!project || !cinematicSequenceName) return null; + if (!project.hasCinematicSequenceNamed(cinematicSequenceName)) return null; + return project.getCinematicSequence(cinematicSequenceName); + } + + getAssociatedLayoutName() { + const seq = this.getCinematicSequence(); + if (!seq) return null; + return seq.getAssociatedLayout(); + } + + getLayout() { + const { project } = this.props; + if (!project) return null; + const layoutName = this.getAssociatedLayoutName(); + if (!layoutName) return null; + if (!project.hasLayoutNamed(layoutName)) return null; + return project.getLayout(layoutName); + } + + openSettings = () => { + this.setState({ externalPropertiesDialogOpen: true }); + }; + + saveSettings = (props) => { + const seq = this.getCinematicSequence(); + if (seq) { + seq.setAssociatedLayout(props.layoutName); + this.forceUpdate(); + } + this.setState({ externalPropertiesDialogOpen: false }); + }; + + onSequenceTimeChange = (time) => { + const seq = this.getCinematicSequence(); + const layout = this.getLayout(); + + if (seq && layout && this.editor) { + try { + const data = seq.getSequenceData(); + if (data) { + const parsed = JSON.parse(data); + if (parsed && Array.isArray(parsed.tracks)) { + const instances = layout.getInitialInstances(); + + parsed.tracks.forEach(track => { + if (track.type === 'object' && track.keyframes) { + // Find the closest keyframe before or at the current time + const kfs = track.keyframes.slice().sort((a, b) => a.time - b.time); + let currentKf = null; + for (let i = 0; i < kfs.length; i++) { + if (kfs[i].time <= time) currentKf = kfs[i]; + else break; + } + + if (currentKf && currentKf.value) { + // Apply to all instances of this object + for (let i = 0; i < instances.getInstancesCount(); i++) { + const instance = instances.getInstanceAt(i); + if (instance.getObjectName() === track.name) { + if (currentKf.value.x !== undefined) instance.setX(currentKf.value.x); + if (currentKf.value.y !== undefined) instance.setY(currentKf.value.y); + if (currentKf.value.angle !== undefined) instance.setAngle(currentKf.value.angle); + } + } + } + } + }); + + // Force instances re-render + if (this.editor.forceUpdateRenderedInstancesOfObject) { + parsed.tracks.forEach(track => { + if (track.type === 'object') { + const objName = track.name; + const project = this.getProject(); + if (project) { + let obj = null; + if (layout.hasObjectNamed(objName)) obj = layout.getObject(objName); + else if (project.hasObjectNamed(objName)) obj = project.getObject(objName); + + if (obj) { + this.editor.forceUpdateRenderedInstancesOfObject(obj); + } + } + } + }); + } + } + } + } catch (e) { + console.error("Failed to scrub sequence:", e); + } + } + }; + + render() { + const { project, cinematicSequenceName, isActive } = this.props; + const seq = this.getCinematicSequence(); + const layout = this.getLayout(); + + if (!project || !cinematicSequenceName || !seq) { + return null; + } + + const projectScopedContainersAccessor = layout ? new ProjectScopedContainersAccessor({ + project, layout + }) : null; + + return ( +
+
+ {layout && projectScopedContainersAccessor ? ( + { }} + onRestartInGameEditor={() => { }} + showRestartInGameEditorAfterErrorButton={false} + setToolbar={this.props.setToolbar} + resourceManagementProps={this.props.resourceManagementProps} + unsavedChanges={this.props.unsavedChanges} + hotReloadPreviewButtonProps={this.props.hotReloadPreviewButtonProps} + ref={editor => (this.editor = editor)} + project={project} + projectScopedContainersAccessor={projectScopedContainersAccessor} + layout={layout} + eventsFunctionsExtension={null} + eventsBasedObject={null} + eventsBasedObjectVariant={null} + globalObjectsContainer={project.getObjects()} + objectsContainer={layout.getObjects()} + layersContainer={layout.getLayers()} + initialInstances={layout.getInitialInstances()} + getInitialInstancesEditorSettings={() => null} + onOpenEvents={this.props.onOpenEvents} + onOpenMoreSettings={this.openSettings} + isActive={isActive} + previewDebuggerServer={null} + openBehaviorEvents={() => { }} + onExtractAsExternalLayout={() => { }} + onExtractAsEventBasedObject={() => { }} + onOpenEventBasedObjectEditor={() => { }} + onOpenEventBasedObjectVariantEditor={() => { }} + onObjectEdited={() => { }} + onObjectsDeleted={() => { }} + onObjectGroupEdited={() => { }} + onObjectGroupsDeleted={() => { }} + onEventsBasedObjectChildrenEdited={() => { }} + onWillInstallExtension={() => { }} + onExtensionInstalled={() => { }} + onDeleteEventsBasedObjectVariant={() => { }} + onEffectAdded={() => { }} + onObjectListsModified={() => { }} + triggerHotReloadInGameEditorIfNeeded={() => { }} + /> + ) : ( + + + + To edit the cinematic sequence, choose the scene in which it will be previewed. + + + Choose the scene} primary onClick={this.openSettings} /> + + + + )} +
+
+ { + if (this.props.unsavedChanges) { + this.props.unsavedChanges.triggerUnsavedChanges(); + } + }} + onTimeChange={this.onSequenceTimeChange} + /> +
+ + Configure Cinematic Sequence} + helpTexts={[Select the scene used to preview this sequence.]} + open={this.state.externalPropertiesDialogOpen} + project={project} + layoutName={this.getAssociatedLayoutName()} + onChoose={this.saveSettings} + onClose={() => this.setState({ externalPropertiesDialogOpen: false })} + /> +
+ ); + } +} + +export const renderCinematicSequenceEditorContainer = ( + props +) => ; diff --git a/newIDE/app/src/MainFrame/EditorTabs/EditorTabsHandler.js b/newIDE/app/src/MainFrame/EditorTabs/EditorTabsHandler.js index 4cb17c7a4f19..aa7da6b41cc1 100644 --- a/newIDE/app/src/MainFrame/EditorTabs/EditorTabsHandler.js +++ b/newIDE/app/src/MainFrame/EditorTabs/EditorTabsHandler.js @@ -6,6 +6,9 @@ import { DebuggerEditorContainer } from '../EditorContainers/DebuggerEditorConta import { EventsFunctionsExtensionEditorContainer } from '../EditorContainers/EventsFunctionsExtensionEditorContainer'; import { ExternalEventsEditorContainer } from '../EditorContainers/ExternalEventsEditorContainer'; import { ExternalLayoutEditorContainer } from '../EditorContainers/ExternalLayoutEditorContainer'; + +import { ExternalLayoutEditorContainer } from '../EditorContainers/ExternalLayoutEditorContainer'; +import { CinematicSequenceEditorContainer } from '../EditorContainers/CinematicSequenceEditorContainer'; import { ResourcesEditorContainer } from '../EditorContainers/ResourcesEditorContainer'; import { SceneEditorContainer } from '../EditorContainers/SceneEditorContainer'; import { type HomePageEditorInterface } from '../EditorContainers/HomePage'; @@ -24,6 +27,7 @@ type EditorRef = | EventsFunctionsExtensionEditorContainer | ExternalEventsEditorContainer | ExternalLayoutEditorContainer + | CinematicSequenceEditorContainer | ResourcesEditorContainer | SceneEditorContainer | HomePageEditorInterface @@ -46,62 +50,62 @@ export type EditorKind = export type EditorTab = {| /** The kind of editor. */ kind: EditorKind, - /** The function to render the tab editor. */ - renderEditorContainer: RenderEditorContainerPropsWithRef => React.Node, - /** A reference to the editor. */ - editorRef: ?EditorRef, - /** The label shown on the tab. */ - label?: string, - icon?: React.Node, - renderCustomIcon: ?(brightness: number) => React.Node, - /** the html dataset object to set on the tab button. */ - tabOptions?: TabOptions, - /** The name of the layout/external layout/external events/extension. */ - projectItemName: ?string, - /** A unique key for the tab. */ - key: string, - /** Extra props to pass to editors. */ - extraEditorProps: ?EditorContainerExtraProps, - /** If set to false, the tab can't be closed. */ - closable: boolean, + /** The function to render the tab editor. */ + renderEditorContainer: RenderEditorContainerPropsWithRef => React.Node, + /** A reference to the editor. */ + editorRef: ?EditorRef, + /** The label shown on the tab. */ + label ?: string, + icon ?: React.Node, + renderCustomIcon: ?(brightness: number) => React.Node, + /** the html dataset object to set on the tab button. */ + tabOptions ?: TabOptions, + /** The name of the layout/external layout/external events/extension. */ + projectItemName: ?string, + /** A unique key for the tab. */ + key: string, + /** Extra props to pass to editors. */ + extraEditorProps: ?EditorContainerExtraProps, + /** If set to false, the tab can't be closed. */ + closable: boolean, |}; export type EditorTabsState = {| panes: { - [paneIdentifier: string]: {| - editors: Array, + [paneIdentifier: string]: {| + editors: Array < EditorTab >, currentTab: number, |}, - }, +}, |}; type EditorTabPersistedState = {| /** The name of the layout/external layout/external events/extension. */ projectItemName: ?string, - /** The editor kind. */ - editorKind: EditorKind, + /** The editor kind. */ + editorKind: EditorKind, |}; export type EditorTabsPersistedState = {| - editors: Array, - currentTab: number, + editors: Array < EditorTabPersistedState >, + currentTab: number, |}; export type EditorOpeningOptions = {| kind: EditorKind, - paneIdentifier: string, - label?: string, - icon?: React.Node, - renderCustomIcon?: ?(brightness: number) => React.Node, - projectItemName: ?string, - tabOptions?: TabOptions, - renderEditorContainer: ( - props: RenderEditorContainerPropsWithRef - ) => React.Node, - key: string, - extraEditorProps?: EditorContainerExtraProps, - dontFocusTab?: boolean, - closable?: boolean, + paneIdentifier: string, + label ?: string, + icon ?: React.Node, + renderCustomIcon ?: ? (brightness: number) => React.Node, + projectItemName: ?string, + tabOptions ?: TabOptions, + renderEditorContainer: ( + props: RenderEditorContainerPropsWithRef + ) => React.Node, + key: string, + extraEditorProps ?: EditorContainerExtraProps, + dontFocusTab ?: boolean, + closable ?: boolean, |}; export const getEditorTabsInitialState = (): EditorTabsState => { @@ -329,6 +333,7 @@ export const saveUiSettings = (state: EditorTabsState) => { editorTab.editorRef && (editorTab.editorRef instanceof SceneEditorContainer || editorTab.editorRef instanceof ExternalLayoutEditorContainer || + editorTab.editorRef instanceof CinematicSequenceEditorContainer || editorTab.editorRef instanceof CustomObjectEditorContainer) ) { editorTab.editorRef.saveUiSettings(); @@ -373,6 +378,7 @@ export const closeLayoutTabs = ( editor instanceof EventsEditorContainer || editor instanceof ExternalEventsEditorContainer || editor instanceof ExternalLayoutEditorContainer || + editor instanceof CinematicSequenceEditorContainer || editor instanceof SceneEditorContainer ) { const editorLayout = editor.getLayout(); @@ -401,6 +407,13 @@ export const closeExternalLayoutTabs = ( ); } + if (editor instanceof CinematicSequenceEditorContainer) { + return ( + !editor.getCinematicSequence() || + editor.getCinematicSequence() !== externalLayout + ); + } + return true; }); }; @@ -443,7 +456,7 @@ export const closeEventsFunctionsExtensionTabs = ( return ( !editor.getEventsFunctionsExtensionName() || editor.getEventsFunctionsExtensionName() !== - eventsFunctionsExtensionName + eventsFunctionsExtensionName ); } return true; @@ -465,7 +478,7 @@ export const closeCustomObjectTab = ( return ( (!editor.getEventsFunctionsExtensionName() || editor.getEventsFunctionsExtensionName() !== - eventsFunctionsExtensionName) && + eventsFunctionsExtensionName) && (!editor.getEventsBasedObjectName() || editor.getEventsBasedObjectName() !== eventsBasedObjectName) ); @@ -490,7 +503,7 @@ export const closeEventsBasedObjectVariantTab = ( return ( (!editor.getEventsFunctionsExtensionName() || editor.getEventsFunctionsExtensionName() !== - eventsFunctionsExtensionName) && + eventsFunctionsExtensionName) && (!editor.getEventsBasedObjectName() || editor.getEventsBasedObjectName() !== eventsBasedObjectName) && (!editor.getVariantName() || @@ -505,7 +518,7 @@ export const getEventsFunctionsExtensionEditor = ( state: EditorTabsState, eventsFunctionsExtension: gdEventsFunctionsExtension ): ?{| - editor: EventsFunctionsExtensionEditorContainer, +editor: EventsFunctionsExtensionEditorContainer, paneIdentifier: string, tabIndex: number, |} => { @@ -531,7 +544,7 @@ export const getCustomObjectEditor = ( eventsBasedObject: gdEventsBasedObject, variantName: string ): ?{| - editor: CustomObjectEditorContainer, +editor: CustomObjectEditorContainer, paneIdentifier: string, tabIndex: number, |} => { diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index baea6ad0580f..28f897ef5f6c 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -52,6 +52,9 @@ import { renderEventsEditorContainer } from './EditorContainers/EventsEditorCont import { renderExternalEventsEditorContainer } from './EditorContainers/ExternalEventsEditorContainer'; import { renderSceneEditorContainer } from './EditorContainers/SceneEditorContainer'; import { renderExternalLayoutEditorContainer } from './EditorContainers/ExternalLayoutEditorContainer'; + + +import { renderCinematicSequenceEditorContainer } from './EditorContainers/CinematicSequenceEditorContainer'; import { renderEventsFunctionsExtensionEditorContainer } from './EditorContainers/EventsFunctionsExtensionEditorContainer'; import { renderCustomObjectEditorContainer } from './EditorContainers/CustomObjectEditorContainer'; import { renderHomePageContainer } from './EditorContainers/HomePage'; @@ -245,6 +248,9 @@ const editorKindToRenderer: { 'external events': renderExternalEventsEditorContainer, layout: renderSceneEditorContainer, 'external layout': renderExternalLayoutEditorContainer, + + + 'cinematic sequence': renderCinematicSequenceEditorContainer, 'events functions extension': renderEventsFunctionsExtensionEditorContainer, 'custom object': renderCustomObjectEditorContainer, 'start page': renderHomePageContainer, @@ -288,9 +294,9 @@ const findStorageProviderFor = ( */ const isCurrentProjectFresh = ( currentProjectRef: {| current: ?gdProject |}, - currentProject: gdProject +currentProject: gdProject ) => - currentProjectRef.current && +currentProjectRef.current && currentProject.ptr === currentProjectRef.current.ptr; /** @@ -310,16 +316,16 @@ const updateFileMetadataWithOpenedProject = ( export type State = {| currentProject: ?gdProject, - currentFileMetadata: ?FileMetadata, - editorTabs: EditorTabsState, - snackMessage: string, - snackMessageOpen: boolean, - snackDuration: ?number, - updateStatus: ElectronUpdateStatus, - openFromStorageProviderDialogOpen: boolean, - saveToStorageProviderDialogOpen: boolean, - gdjsDevelopmentWatcherEnabled: boolean, - toolbarButtons: Array, + currentFileMetadata: ?FileMetadata, + editorTabs: EditorTabsState, + snackMessage: string, + snackMessageOpen: boolean, + snackDuration: ?number, + updateStatus: ElectronUpdateStatus, + openFromStorageProviderDialogOpen: boolean, + saveToStorageProviderDialogOpen: boolean, + gdjsDevelopmentWatcherEnabled: boolean, + toolbarButtons: Array < ToolbarButtonConfig >, |}; const initialPreviewState: PreviewState = { @@ -332,9 +338,9 @@ const initialPreviewState: PreviewState = { const usePreviewLoadingState = () => { const forceUpdate = useForceUpdate(); - const previewLoadingRef = React.useRef< + const previewLoadingRef = React.useRef < null | 'preview' | 'hot-reload-for-in-game-edition' - >(null); + > (null); return { previewLoadingRef, @@ -354,1375 +360,1445 @@ export type Props = {| MainMenuCallbacks, MainMenuExtraCallbacks ) => React.Node, - renderPreviewLauncher?: ( - props: PreviewLauncherProps, - ref: (previewLauncher: ?PreviewLauncherInterface) => void - // $FlowFixMe[prop-missing] - ) => React.Element, - onEditObject?: gdObject => void, - storageProviders: Array, - resourceMover: ResourceMover, - resourceFetcher: ResourceFetcher, - getStorageProviderOperations: ( - storageProvider?: ?StorageProvider - ) => StorageProviderOperations, - getStorageProviderResourceOperations: () => ?ResourcesActionsMenuBuilder, - getStorageProvider: () => StorageProvider, - resourceSources: Array, - resourceExternalEditors: Array, - requestUpdate?: () => void, - renderShareDialog: ShareDialogWithoutExportsProps => React.Node, - renderGDJSDevelopmentWatcher?: ?({| - onGDJSUpdated: () => Promise | void, + renderPreviewLauncher ?: ( + props: PreviewLauncherProps, + ref: (previewLauncher: ?PreviewLauncherInterface) => void + // $FlowFixMe[prop-missing] + ) => React.Element < PreviewLauncherComponent >, + onEditObject ?: gdObject => void, + storageProviders: Array < StorageProvider >, + resourceMover: ResourceMover, + resourceFetcher: ResourceFetcher, + getStorageProviderOperations: ( + storageProvider?: ?StorageProvider + ) => StorageProviderOperations, + getStorageProviderResourceOperations: () => ? ResourcesActionsMenuBuilder, + getStorageProvider: () => StorageProvider, + resourceSources: Array < ResourceSource >, + resourceExternalEditors: Array < ResourceExternalEditor >, + requestUpdate ?: () => void, + renderShareDialog: ShareDialogWithoutExportsProps => React.Node, + renderGDJSDevelopmentWatcher ?: ? ({| + onGDJSUpdated: () => Promise < void> | void, |}) => React.Node, - extensionsLoader?: JsExtensionsLoader, + extensionsLoader ?: JsExtensionsLoader, initialFileMetadataToOpen: ?FileMetadata, - initialExampleSlugToOpen: ?string, - quickPublishOnlineWebExporter: Exporter, - i18n: I18n, + initialExampleSlugToOpen: ?string, + quickPublishOnlineWebExporter: Exporter, + i18n: I18n, |}; const MainFrame = (props: Props): React.MixedElement => { const [state, setState]: [ State, - ((State => State) | State) => Promise, + ((State => State) | State) => Promise < State >, ] = useStateWithCallback( - ({ - currentProject: null, - currentFileMetadata: null, - editorTabs: getEditorTabsInitialState(), - snackMessage: '', - snackMessageOpen: false, - snackDuration: defaultSnackbarAutoHideDuration, - updateStatus: { message: '', status: 'unknown' }, - openFromStorageProviderDialogOpen: false, - saveToStorageProviderDialogOpen: false, - gdjsDevelopmentWatcherEnabled: false, - toolbarButtons: [], - }: State) - ); - const authenticatedUser = React.useContext(AuthenticatedUserContext); - const [ - cloudProjectFileMetadataToRecover, - setCloudProjectFileMetadataToRecover, - ] = React.useState(null); - const [ - cloudProjectRecoveryOpenedVersionId, - setCloudProjectRecoveryOpenedVersionId, - ] = React.useState(null); - const [ - cloudProjectSaveChoiceOpen, - setCloudProjectSaveChoiceOpen, - ] = React.useState(false); - const [ - chooseResourceOptions, - setChooseResourceOptions, - ] = React.useState(null); - const [onResourceChosen, setOnResourceChosen] = React.useState, - selectedSourceName: string, - |}) => void>(null); - const _previewLauncher = React.useRef((null: ?PreviewLauncherInterface)); - const forceUpdate = useForceUpdate(); - const [isLoadingProject, setIsLoadingProject] = React.useState( - false - ); - const [isSavingProject, setIsSavingProject] = React.useState(false); - const [projectManagerOpen, openProjectManager] = React.useState( - false - ); - const [languageDialogOpen, openLanguageDialog] = React.useState( - false - ); - const [aboutDialogOpen, openAboutDialog] = React.useState(false); - const [profileDialogOpen, openProfileDialog] = React.useState(false); - const [ - preferencesDialogOpen, - openPreferencesDialog, - ] = React.useState(false); - const [ - newProjectSetupDialogOpen, - setNewProjectSetupDialogOpen, - ] = React.useState(false); - - const [isProjectOpening, setIsProjectOpening] = React.useState( - false + ({ + currentProject: null, + currentFileMetadata: null, + editorTabs: getEditorTabsInitialState(), + snackMessage: '', + snackMessageOpen: false, + snackDuration: defaultSnackbarAutoHideDuration, + updateStatus: { message: '', status: 'unknown' }, + openFromStorageProviderDialogOpen: false, + saveToStorageProviderDialogOpen: false, + gdjsDevelopmentWatcherEnabled: false, + toolbarButtons: [], + }: State) ); - const [ - isProjectClosedSoAvoidReloadingExtensions, - setIsProjectClosedSoAvoidReloadingExtensions, - ] = React.useState(false); - const [shareDialogOpen, setShareDialogOpen] = React.useState(false); - const [ - shareDialogInitialTab, - setShareDialogInitialTab, - ] = React.useState(null); - const [ - standaloneDialogOpen, - setStandaloneDialogOpen, - ] = React.useState(false); - const { - showConfirmation, - showAlert, - showDeleteConfirmation, - } = useAlertDialog(); - const preferences = React.useContext(PreferencesContext); - const { setHasProjectOpened } = preferences; - const { previewLoadingRef, setPreviewLoading } = usePreviewLoadingState(); - const shortcutMap = useShortcutMap(); - const [ - diagnosticReportDialogOpen, - setDiagnosticReportDialogOpen, - ] = React.useState(false); - - /** - * Checks for diagnostic errors in the project if blocking is enabled. - * Returns true if there are errors and the action should be blocked. - */ - const checkDiagnosticErrorsAndIfShouldBlock = React.useCallback( - async ( - project: ?gdProject, - actionType: 'preview' | 'export' - ): Promise => { - if ( - !project || - !preferences.getBlockPreviewAndExportOnDiagnosticErrors() - ) { - return false; - } - - try { - const validationErrors = scanProjectForValidationErrors(project); - if (validationErrors.length > 0) { - const openReport = await showConfirmation({ - title: t`Diagnostic errors found`, - message: - actionType === 'preview' - ? t`Your project has ${ - validationErrors.length - } diagnostic error(s). Please fix them before launching a preview.` - : t`Your project has ${ - validationErrors.length - } diagnostic error(s). Please fix them before exporting.`, - dismissButtonLabel: t`Close`, - confirmButtonLabel: t`Open report`, - }); - if (openReport) { - setDiagnosticReportDialogOpen(true); - } - return true; - } - } catch (error) { - console.error('Error scanning project for validation errors:', error); - } +const authenticatedUser = React.useContext(AuthenticatedUserContext); +const [ + cloudProjectFileMetadataToRecover, + setCloudProjectFileMetadataToRecover, +] = React.useState (null); +const [ + cloudProjectRecoveryOpenedVersionId, + setCloudProjectRecoveryOpenedVersionId, +] = React.useState (null); +const [ + cloudProjectSaveChoiceOpen, + setCloudProjectSaveChoiceOpen, +] = React.useState < boolean > (false); +const [ + chooseResourceOptions, + setChooseResourceOptions, +] = React.useState (null); +const [onResourceChosen, setOnResourceChosen] = React.useState , + selectedSourceName: string, + |}) => void> (null); +const _previewLauncher = React.useRef((null: ?PreviewLauncherInterface)); +const forceUpdate = useForceUpdate(); +const [isLoadingProject, setIsLoadingProject] = React.useState < boolean > ( + false +); +const [isSavingProject, setIsSavingProject] = React.useState < boolean > (false); +const [projectManagerOpen, openProjectManager] = React.useState < boolean > ( + false +); +const [languageDialogOpen, openLanguageDialog] = React.useState < boolean > ( + false +); +const [aboutDialogOpen, openAboutDialog] = React.useState < boolean > (false); +const [profileDialogOpen, openProfileDialog] = React.useState < boolean > (false); +const [ + preferencesDialogOpen, + openPreferencesDialog, +] = React.useState < boolean > (false); +const [ + newProjectSetupDialogOpen, + setNewProjectSetupDialogOpen, +] = React.useState < boolean > (false); + +const [isProjectOpening, setIsProjectOpening] = React.useState < boolean > ( + false +); +const [ + isProjectClosedSoAvoidReloadingExtensions, + setIsProjectClosedSoAvoidReloadingExtensions, +] = React.useState < boolean > (false); +const [shareDialogOpen, setShareDialogOpen] = React.useState < boolean > (false); +const [ + shareDialogInitialTab, + setShareDialogInitialTab, +] = React.useState (null); +const [ + standaloneDialogOpen, + setStandaloneDialogOpen, +] = React.useState < boolean > (false); +const { + showConfirmation, + showAlert, + showDeleteConfirmation, +} = useAlertDialog(); +const preferences = React.useContext(PreferencesContext); +const { setHasProjectOpened } = preferences; +const { previewLoadingRef, setPreviewLoading } = usePreviewLoadingState(); +const shortcutMap = useShortcutMap(); +const [ + diagnosticReportDialogOpen, + setDiagnosticReportDialogOpen, +] = React.useState < boolean > (false); +/** + * Checks for diagnostic errors in the project if blocking is enabled. + * Returns true if there are errors and the action should be blocked. + */ +const checkDiagnosticErrorsAndIfShouldBlock = React.useCallback( + async ( + project: ?gdProject, + actionType: 'preview' | 'export' + ): Promise => { + if ( + !project || + !preferences.getBlockPreviewAndExportOnDiagnosticErrors() + ) { return false; - }, - [preferences, showConfirmation, setDiagnosticReportDialogOpen] - ); - const [previewState, setPreviewState] = React.useState(initialPreviewState); - const commandPaletteRef = React.useRef((null: ?CommandPaletteInterface)); - const inAppTutorialOrchestratorRef = React.useRef( - null - ); - const [ - loaderModalOpeningMessage, - setLoaderModalOpeningMessage, - ] = React.useState(null); - - const eventsFunctionsExtensionsContext = React.useContext( - EventsFunctionsExtensionsContext - ); - const previewDebuggerServer = - _previewLauncher.current && - _previewLauncher.current.getPreviewDebuggerServer(); - const { - hasNonEditionPreviewsRunning, - gameHotReloadLogs, - editorHotReloadLogs, - editorUncaughtError, - clearGameHotReloadLogs, - clearEditorHotReloadLogs, - clearEditorUncaughtError, - hardReloadAllPreviews, - } = usePreviewDebuggerServerWatcher(previewDebuggerServer); - const { - ensureInteractionHappened, - renderOpenConfirmDialog, - } = useOpenConfirmDialog(); - const { - openLeaderboardReplacerDialogIfNeeded, - renderLeaderboardReplacerDialog, - } = useLeaderboardReplacer(); - const { - configureMultiplayerLobbiesIfNeeded, - } = useMultiplayerLobbyConfigurator(); - const eventsFunctionsExtensionsState = React.useContext( - EventsFunctionsExtensionsContext - ); - const unsavedChanges = React.useContext(UnsavedChangesContext); - const { - hasUnsavedChanges, - sealUnsavedChanges, - triggerUnsavedChanges, - } = unsavedChanges; - const { - currentlyRunningInAppTutorial, - getInAppTutorialShortHeader, - endTutorial: doEndTutorial, - startTutorial, - startStepIndex, - startProjectData, - } = React.useContext(InAppTutorialContext); - const [ - selectedInAppTutorialInfo, - setSelectedInAppTutorialInfo, - ] = React.useState(null); - const { - InAppTutorialOrchestrator, - orchestratorProps, - } = useInAppTutorialOrchestrator({ editorTabs: state.editorTabs }); - const [ - quitInAppTutorialDialogOpen, - setQuitInAppTutorialDialogOpen, - ] = React.useState(false); - const { setPendingEventNavigation } = useNavigationToEvent({ - editorTabs: state.editorTabs, - }); - const [ - fileMetadataOpeningProgress, - setFileMetadataOpeningProgress, - ] = React.useState(null); - const [ - fileMetadataOpeningMessage, - setFileMetadataOpeningMessage, - ] = React.useState(null); - const [ - quickCustomizationDialogOpenedFromGameId, - setQuickCustomizationDialogOpenedFromGameId, - ] = React.useState(null); + } - const [gameEditorMode, setGameEditorMode] = React.useState< - 'embedded-game' | 'instances-editor' - >('instances-editor'); + try { + const validationErrors = scanProjectForValidationErrors(project); + if (validationErrors.length > 0) { + const openReport = await showConfirmation({ + title: t`Diagnostic errors found`, + message: + actionType === 'preview' + ? t`Your project has ${validationErrors.length + } diagnostic error(s). Please fix them before launching a preview.` + : t`Your project has ${validationErrors.length + } diagnostic error(s). Please fix them before exporting.`, + dismissButtonLabel: t`Close`, + confirmButtonLabel: t`Open report`, + }); + if (openReport) { + setDiagnosticReportDialogOpen(true); + } + return true; + } + } catch (error) { + console.error('Error scanning project for validation errors:', error); + } - // This is just for testing, to check if we're getting the right state - // and gives us an idea about the number of re-renders. - // React.useEffect(() => { - // console.log(state); - // }); + return false; + }, + [preferences, showConfirmation, setDiagnosticReportDialogOpen] +); +const [previewState, setPreviewState] = React.useState(initialPreviewState); +const commandPaletteRef = React.useRef((null: ?CommandPaletteInterface)); +const inAppTutorialOrchestratorRef = React.useRef ( + null +); +const [ + loaderModalOpeningMessage, + setLoaderModalOpeningMessage, +] = React.useState (null); + +const eventsFunctionsExtensionsContext = React.useContext( + EventsFunctionsExtensionsContext +); +const previewDebuggerServer = + _previewLauncher.current && + _previewLauncher.current.getPreviewDebuggerServer(); +const { + hasNonEditionPreviewsRunning, + gameHotReloadLogs, + editorHotReloadLogs, + editorUncaughtError, + clearGameHotReloadLogs, + clearEditorHotReloadLogs, + clearEditorUncaughtError, + hardReloadAllPreviews, +} = usePreviewDebuggerServerWatcher(previewDebuggerServer); +const { + ensureInteractionHappened, + renderOpenConfirmDialog, +} = useOpenConfirmDialog(); +const { + openLeaderboardReplacerDialogIfNeeded, + renderLeaderboardReplacerDialog, +} = useLeaderboardReplacer(); +const { + configureMultiplayerLobbiesIfNeeded, +} = useMultiplayerLobbyConfigurator(); +const eventsFunctionsExtensionsState = React.useContext( + EventsFunctionsExtensionsContext +); +const unsavedChanges = React.useContext(UnsavedChangesContext); +const { + hasUnsavedChanges, + sealUnsavedChanges, + triggerUnsavedChanges, +} = unsavedChanges; +const { + currentlyRunningInAppTutorial, + getInAppTutorialShortHeader, + endTutorial: doEndTutorial, + startTutorial, + startStepIndex, + startProjectData, +} = React.useContext(InAppTutorialContext); +const [ + selectedInAppTutorialInfo, + setSelectedInAppTutorialInfo, +] = React.useState < null | {| + tutorialId: string, + userProgress: ?InAppTutorialUserProgress, + |}> (null); +const { + InAppTutorialOrchestrator, + orchestratorProps, +} = useInAppTutorialOrchestrator({ editorTabs: state.editorTabs }); +const [ + quitInAppTutorialDialogOpen, + setQuitInAppTutorialDialogOpen, +] = React.useState < boolean > (false); +const { setPendingEventNavigation } = useNavigationToEvent({ + editorTabs: state.editorTabs, +}); +const [ + fileMetadataOpeningProgress, + setFileMetadataOpeningProgress, +] = React.useState (null); +const [ + fileMetadataOpeningMessage, + setFileMetadataOpeningMessage, +] = React.useState (null); +const [ + quickCustomizationDialogOpenedFromGameId, + setQuickCustomizationDialogOpenedFromGameId, +] = React.useState (null); + +const [gameEditorMode, setGameEditorMode] = React.useState < + 'embedded-game' | 'instances-editor' + > ('instances-editor'); + +// This is just for testing, to check if we're getting the right state +// and gives us an idea about the number of re-renders. +// React.useEffect(() => { +// console.log(state); +// }); + +const { currentProject, currentFileMetadata, updateStatus } = state; +const { + renderShareDialog, + resourceSources, + renderPreviewLauncher, + resourceExternalEditors, + resourceMover, + resourceFetcher, + getStorageProviderOperations, + getStorageProviderResourceOperations, + getStorageProvider, + initialFileMetadataToOpen, + initialExampleSlugToOpen, + i18n, + renderGDJSDevelopmentWatcher, + renderMainMenu, + quickPublishOnlineWebExporter, +} = props; + +const { + ensureResourcesAreMoved, + renderResourceMoverDialog, +} = useResourceMover({ resourceMover }); +const { + ensureResourcesAreFetched, + renderResourceFetcherDialog, +} = useResourceFetcher({ resourceFetcher }); +useResourcesWatcher({ + getStorageProvider, + fileMetadata: currentFileMetadata, + isProjectSplitInMultipleFiles: currentProject + ? currentProject.isFolderProject() + : false, +}); - const { currentProject, currentFileMetadata, updateStatus } = state; - const { - renderShareDialog, - resourceSources, - renderPreviewLauncher, - resourceExternalEditors, - resourceMover, - resourceFetcher, - getStorageProviderOperations, - getStorageProviderResourceOperations, - getStorageProvider, - initialFileMetadataToOpen, - initialExampleSlugToOpen, - i18n, - renderGDJSDevelopmentWatcher, - renderMainMenu, - quickPublishOnlineWebExporter, - } = props; +const gamesList = useGamesList(); - const { - ensureResourcesAreMoved, - renderResourceMoverDialog, - } = useResourceMover({ resourceMover }); - const { - ensureResourcesAreFetched, - renderResourceFetcherDialog, - } = useResourceFetcher({ resourceFetcher }); - useResourcesWatcher({ - getStorageProvider, - fileMetadata: currentFileMetadata, - isProjectSplitInMultipleFiles: currentProject - ? currentProject.isFolderProject() - : false, - }); +const { + createCaptureOptionsForPreview, + onCaptureFinished, + onGameScreenshotsClaimed, + getGameUnverifiedScreenshotUrls, + getHotReloadPreviewLaunchCaptureOptions, +} = useCapturesManager({ project: currentProject, gamesList }); - const gamesList = useGamesList(); +const { getAuthenticatedPlayerForPreview } = useAuthenticatedPlayer({ + project: currentProject, + gamesList, +}); - const { - createCaptureOptionsForPreview, - onCaptureFinished, - onGameScreenshotsClaimed, - getGameUnverifiedScreenshotUrls, - getHotReloadPreviewLaunchCaptureOptions, - } = useCapturesManager({ project: currentProject, gamesList }); +const { + setExtensionLoadingResults, + hasExtensionLoadErrors, + renderExtensionLoadErrorDialog, +} = useExtensionLoadErrorDialog(); - const { getAuthenticatedPlayerForPreview } = useAuthenticatedPlayer({ - project: currentProject, - gamesList, - }); +/** + * This reference is useful to get the current opened project, + * even in the callback of a hook/promise - without risking to read "stale" data. + * This can be different from the `currentProject` (coming from the state) + * that an effect or a callback manipulates when a promise resolves for instance. + * See `isCurrentProjectFresh`. + */ +const currentProjectRef = useStableUpToDateRef(currentProject); - const { - setExtensionLoadingResults, - hasExtensionLoadErrors, - renderExtensionLoadErrorDialog, - } = useExtensionLoadErrorDialog(); - - /** - * This reference is useful to get the current opened project, - * even in the callback of a hook/promise - without risking to read "stale" data. - * This can be different from the `currentProject` (coming from the state) - * that an effect or a callback manipulates when a promise resolves for instance. - * See `isCurrentProjectFresh`. - */ - const currentProjectRef = useStableUpToDateRef(currentProject); - - /** - * Similar to `currentProjectRef`, an always fresh reference to the latest `currentFileMetadata`. - */ - const currentFileMetadataRef = useStableUpToDateRef(currentFileMetadata); - - const getEditorOpeningOptions = React.useCallback( - ({ - kind, - name, - dontFocusTab, - project, - paneIdentifier, - continueProcessingFunctionCallsOnMount, - }: { - kind: EditorKind, - name: string, - dontFocusTab?: boolean, - project?: ?gdProject, - paneIdentifier?: 'left' | 'center' | 'right', - continueProcessingFunctionCallsOnMount?: boolean, - }) => { - const label = - kind === 'resources' - ? i18n._(t`Resources`) - : kind === 'ask-ai' +/** + * Similar to `currentProjectRef`, an always fresh reference to the latest `currentFileMetadata`. + */ +const currentFileMetadataRef = useStableUpToDateRef(currentFileMetadata); + +const getEditorOpeningOptions = React.useCallback( + ({ + kind, + name, + dontFocusTab, + project, + paneIdentifier, + continueProcessingFunctionCallsOnMount, + }: { + kind: EditorKind, + name: string, + dontFocusTab?: boolean, + project?: ?gdProject, + paneIdentifier?: 'left' | 'center' | 'right', + continueProcessingFunctionCallsOnMount?: boolean, + }) => { + const label = + kind === 'resources' + ? i18n._(t`Resources`) + : kind === 'ask-ai' ? i18n._(t`Ask AI`) : kind === 'start page' - ? undefined - : kind === 'debugger' - ? i18n._(t`Debugger`) - : kind === 'layout events' - ? name + ` ${i18n._(t`(Events)`)}` - : kind === 'custom object' - ? name.split('::')[2] || - name.split('::')[1] + ` ${i18n._(t`(Object)`)}` - : name; - const tabOptions = - kind === 'layout' - ? { data: { scene: name, type: 'layout' } } - : kind === 'layout events' + ? undefined + : kind === 'debugger' + ? i18n._(t`Debugger`) + : kind === 'layout events' + ? name + ` ${i18n._(t`(Events)`)}` + : kind === 'custom object' + ? name.split('::')[2] || + name.split('::')[1] + ` ${i18n._(t`(Object)`)}` + : name; + const tabOptions = + kind === 'layout' + ? { data: { scene: name, type: 'layout' } } + : kind === 'layout events' ? { data: { scene: name, type: 'layout-events' } } : undefined; - const key = [ - 'layout', - 'layout events', - 'external events', - 'external layout', - 'events functions extension', - 'custom object', - ].includes(kind) - ? `${kind} ${name}` - : kind; - - let customIconUrl = ''; - if (kind === 'events functions extension' || kind === 'custom object') { - const extensionName = name.split('::')[0]; - if ( - project && - project.hasEventsFunctionsExtensionNamed(extensionName) - ) { - const eventsFunctionsExtension = project.getEventsFunctionsExtension( - extensionName - ); - customIconUrl = eventsFunctionsExtension.getIconUrl(); - } + const key = [ + 'layout', + 'layout events', + 'external events', + 'external layout', + 'events functions extension', + 'custom object', + ].includes(kind) + ? `${kind} ${name}` + : kind; + + let customIconUrl = ''; + if (kind === 'events functions extension' || kind === 'custom object') { + const extensionName = name.split('::')[0]; + if ( + project && + project.hasEventsFunctionsExtensionNamed(extensionName) + ) { + const eventsFunctionsExtension = project.getEventsFunctionsExtension( + extensionName + ); + customIconUrl = eventsFunctionsExtension.getIconUrl(); } - const icon = - kind === 'start page' ? ( - - ) : kind === 'debugger' ? ( - - ) : kind === 'resources' ? ( - - ) : kind === 'layout' ? ( - - ) : kind === 'layout events' ? ( - - ) : kind === 'external events' ? ( - - ) : kind === 'external layout' ? ( - - ) : kind === 'events functions extension' || - kind === 'custom object' ? ( - - ) : kind === 'ask-ai' ? ( - - ) : null; - - const closable = kind !== 'start page'; - const extraEditorProps = - kind === 'start page' - ? { storageProviders: props.storageProviders } - : kind === 'ask-ai' + } + const icon = + kind === 'start page' ? ( + + ) : kind === 'debugger' ? ( + + ) : kind === 'resources' ? ( + + ) : kind === 'layout' ? ( + + ) : kind === 'layout events' ? ( + + ) : kind === 'external events' ? ( + + ) : kind === 'external layout' ? ( + + ) : kind === 'events functions extension' || + kind === 'custom object' ? ( + + ) : kind === 'ask-ai' ? ( + + ) : null; + + const closable = kind !== 'start page'; + const extraEditorProps = + kind === 'start page' + ? { storageProviders: props.storageProviders } + : kind === 'ask-ai' ? { - continueProcessingFunctionCallsOnMount, - } + continueProcessingFunctionCallsOnMount, + } : undefined; - return { - icon, - renderCustomIcon: customIconUrl - ? (brightness: number) => ( - - ) - : null, - closable, - label, - projectItemName: name, - tabOptions, - kind, - renderEditorContainer: editorKindToRenderer[kind], - extraEditorProps, - key, - dontFocusTab, - paneIdentifier: paneIdentifier || 'center', - }; - }, - [i18n, props.storageProviders] - ); - - const setEditorTabs = React.useCallback( - // $FlowFixMe[missing-local-annot] - newEditorTabs => { - setState(state => ({ - ...state, - editorTabs: newEditorTabs, - })); - }, - [setState] - ); - - const { - hasAPreviousSaveForEditorTabsState, - openEditorTabsFromPersistedState, - } = useEditorTabsStateSaving({ - currentProjectId: state.currentProject - ? state.currentProject.getProjectUuid() - : null, - editorTabs: state.editorTabs, - setEditorTabs: setEditorTabs, - // $FlowFixMe[incompatible-type] - getEditorOpeningOptions, - }); + return { + icon, + renderCustomIcon: customIconUrl + ? (brightness: number) => ( + + ) + : null, + closable, + label, + projectItemName: name, + tabOptions, + kind, + renderEditorContainer: editorKindToRenderer[kind], + extraEditorProps, + key, + dontFocusTab, + paneIdentifier: paneIdentifier || 'center', + }; + }, + [i18n, props.storageProviders] +); - const _closeSnackMessage = React.useCallback( - () => { - setState(state => ({ - ...state, - snackMessageOpen: false, - snackDuration: defaultSnackbarAutoHideDuration, // Reset to default when closing the snackbar. - })); - }, - [setState] - ); +const setEditorTabs = React.useCallback( + // $FlowFixMe[missing-local-annot] + newEditorTabs => { + setState(state => ({ + ...state, + editorTabs: newEditorTabs, + })); + }, + [setState] +); + +const { + hasAPreviousSaveForEditorTabsState, + openEditorTabsFromPersistedState, +} = useEditorTabsStateSaving({ + currentProjectId: state.currentProject + ? state.currentProject.getProjectUuid() + : null, + editorTabs: state.editorTabs, + setEditorTabs: setEditorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions, +}); - const _showSnackMessage = React.useCallback( - (snackMessage: string, autoHideDuration?: number | null) => { - setState(state => ({ - ...state, - snackMessage, - snackMessageOpen: true, - snackDuration: - autoHideDuration !== undefined - ? autoHideDuration // Allow setting null, for infinite duration. - : defaultSnackbarAutoHideDuration, - })); - }, - [setState] - ); +const _closeSnackMessage = React.useCallback( + () => { + setState(state => ({ + ...state, + snackMessageOpen: false, + snackDuration: defaultSnackbarAutoHideDuration, // Reset to default when closing the snackbar. + })); + }, + [setState] +); - const _replaceSnackMessage = React.useCallback( - (snackMessage: string, autoHideDuration?: number | null) => { - _closeSnackMessage(); - setTimeout(() => _showSnackMessage(snackMessage, autoHideDuration), 200); - }, - [_closeSnackMessage, _showSnackMessage] - ); +const _showSnackMessage = React.useCallback( + (snackMessage: string, autoHideDuration?: number | null) => { + setState(state => ({ + ...state, + snackMessage, + snackMessageOpen: true, + snackDuration: + autoHideDuration !== undefined + ? autoHideDuration // Allow setting null, for infinite duration. + : defaultSnackbarAutoHideDuration, + })); + }, + [setState] +); + +const _replaceSnackMessage = React.useCallback( + (snackMessage: string, autoHideDuration?: number | null) => { + _closeSnackMessage(); + setTimeout(() => _showSnackMessage(snackMessage, autoHideDuration), 200); + }, + [_closeSnackMessage, _showSnackMessage] +); + +const openShareDialog = React.useCallback( + async (initialTab?: ShareTab) => { + if ( + await checkDiagnosticErrorsAndIfShouldBlock(currentProject, 'export') + ) { + return; + } - const openShareDialog = React.useCallback( - async (initialTab?: ShareTab) => { - if ( - await checkDiagnosticErrorsAndIfShouldBlock(currentProject, 'export') - ) { - return; - } + notifyPreviewOrExportWillStart(state.editorTabs); - notifyPreviewOrExportWillStart(state.editorTabs); + setShareDialogInitialTab(initialTab || null); + setShareDialogOpen(true); + }, + [state.editorTabs, currentProject, checkDiagnosticErrorsAndIfShouldBlock] +); - setShareDialogInitialTab(initialTab || null); - setShareDialogOpen(true); - }, - [state.editorTabs, currentProject, checkDiagnosticErrorsAndIfShouldBlock] - ); +const closeShareDialog = React.useCallback( + () => { + setShareDialogOpen(false); + setShareDialogInitialTab(null); + }, + [setShareDialogOpen, setShareDialogInitialTab] +); + +const openInitialFileMetadata = async () => { + if (!initialFileMetadataToOpen) return; + + // We use the current storage provider, as it's supposed to be able to open + // the initial file metadata. Indeed, it's the responsibility of the `ProjectStorageProviders` + // to set the initial storage provider if an initial file metadata is set. + const state = await openFromFileMetadata(initialFileMetadataToOpen); + if (state) + openSceneOrProjectManager({ + currentProject: state.currentProject, + editorTabs: state.editorTabs, + }); +}; - const closeShareDialog = React.useCallback( - () => { - setShareDialogOpen(false); - setShareDialogInitialTab(null); - }, - [setShareDialogOpen, setShareDialogInitialTab] +const _languageDidChange = () => { + // A change in the language will automatically be applied + // on all React components, as it's handled by GDI18nProvider. + // We still have this method that will be called when the language + // dialog is closed after a language change. We then reload GDevelop + // extensions so that they declare all objects/actions/condition/etc... + // using the new language. + console.info('Language changed, reloading extensions...'); + gd.MeasurementUnit.applyTranslation(); + gd.JsPlatform.get().reloadBuiltinExtensions(); + eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensions( + currentProject ); + _loadExtensions().catch(() => { }); +}; - const openInitialFileMetadata = async () => { - if (!initialFileMetadataToOpen) return; - - // We use the current storage provider, as it's supposed to be able to open - // the initial file metadata. Indeed, it's the responsibility of the `ProjectStorageProviders` - // to set the initial storage provider if an initial file metadata is set. - const state = await openFromFileMetadata(initialFileMetadataToOpen); - if (state) - openSceneOrProjectManager({ - currentProject: state.currentProject, - editorTabs: state.editorTabs, - }); - }; - - const _languageDidChange = () => { - // A change in the language will automatically be applied - // on all React components, as it's handled by GDI18nProvider. - // We still have this method that will be called when the language - // dialog is closed after a language change. We then reload GDevelop - // extensions so that they declare all objects/actions/condition/etc... - // using the new language. - console.info('Language changed, reloading extensions...'); - gd.MeasurementUnit.applyTranslation(); - gd.JsPlatform.get().reloadBuiltinExtensions(); - eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensions( - currentProject +const _loadExtensions = (): Promise => { + const { extensionsLoader, i18n } = props; + if (!extensionsLoader) { + console.info( + 'No extensions loader specified, skipping extensions loading.' ); - _loadExtensions().catch(() => {}); - }; + return Promise.reject(new Error('No extension loader specified.')); + } - const _loadExtensions = (): Promise => { - const { extensionsLoader, i18n } = props; - if (!extensionsLoader) { - console.info( - 'No extensions loader specified, skipping extensions loading.' - ); - return Promise.reject(new Error('No extension loader specified.')); - } + return extensionsLoader + .loadAllExtensions(getNotNullTranslationFunction(i18n)) + .then( + ({ + expectedNumberOfJSExtensionModulesLoaded, + results: loadingResults, + }) => { + const successLoadingResults = loadingResults.filter( + loadingResult => !loadingResult.result.error + ); + console.info( + `Loaded ${successLoadingResults.length + }/${expectedNumberOfJSExtensionModulesLoaded} JS extensions.` + ); - return extensionsLoader - .loadAllExtensions(getNotNullTranslationFunction(i18n)) - .then( - ({ + setExtensionLoadingResults({ expectedNumberOfJSExtensionModulesLoaded, results: loadingResults, - }) => { - const successLoadingResults = loadingResults.filter( - loadingResult => !loadingResult.result.error + }); + } + ); +}; + +useDiscordRichPresence(currentProject); + +const openAskAi = React.useCallback( + (options: ?OpenAskAiOptions) => { + const { + aiRequestId, + paneIdentifier, + continueProcessingFunctionCallsOnMount, + } = options || {}; + const newPaneIdentifier = + paneIdentifier || (currentProject ? 'right' : 'center'); + + setState(state => { + let openedEditor = getOpenedAskAiEditor(state.editorTabs); + let newEditorTabs = state.editorTabs; + if (openedEditor) { + if (openedEditor.paneIdentifier !== newPaneIdentifier) { + // The editor is opened, but not at the right position, close it. + // It will re-open in the right pane. + newEditorTabs = closeEditorTab( + newEditorTabs, + openedEditor.editorTab ); - console.info( - `Loaded ${ - successLoadingResults.length - }/${expectedNumberOfJSExtensionModulesLoaded} JS extensions.` + newEditorTabs = openEditorTab( + newEditorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ + kind: 'ask-ai', + name: '', + paneIdentifier: newPaneIdentifier, + continueProcessingFunctionCallsOnMount, + }) ); - - setExtensionLoadingResults({ - expectedNumberOfJSExtensionModulesLoaded, - results: loadingResults, - }); } + } + + newEditorTabs = openEditorTab( + newEditorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ + kind: 'ask-ai', + name: '', + paneIdentifier: newPaneIdentifier, + continueProcessingFunctionCallsOnMount, + }) ); - }; - useDiscordRichPresence(currentProject); + return { + ...state, + editorTabs: newEditorTabs, + }; + }).then(state => { + // Wait for the state to be updated before starting/opening the chat, + // as the editor needs to be mounted. + const params = aiRequestId === undefined ? undefined : { aiRequestId }; + const openedEditor = getOpenedAskAiEditor(state.editorTabs); + if (!openedEditor) { + console.error( + 'No Ask AI editor found after opening it. This should not happen.' + ); + return; + } + if (openedEditor.askAiEditor) { + openedEditor.askAiEditor.startOrOpenChat(params); + } + }); + }, + [setState, getEditorOpeningOptions, currentProject] +); - const openAskAi = React.useCallback( - (options: ?OpenAskAiOptions) => { - const { - aiRequestId, - paneIdentifier, - continueProcessingFunctionCallsOnMount, - } = options || {}; - const newPaneIdentifier = - paneIdentifier || (currentProject ? 'right' : 'center'); - - setState(state => { - let openedEditor = getOpenedAskAiEditor(state.editorTabs); - let newEditorTabs = state.editorTabs; - if (openedEditor) { - if (openedEditor.paneIdentifier !== newPaneIdentifier) { - // The editor is opened, but not at the right position, close it. - // It will re-open in the right pane. - newEditorTabs = closeEditorTab( - newEditorTabs, - openedEditor.editorTab - ); - newEditorTabs = openEditorTab( - newEditorTabs, - // $FlowFixMe[incompatible-type] - getEditorOpeningOptions({ - kind: 'ask-ai', - name: '', - paneIdentifier: newPaneIdentifier, - continueProcessingFunctionCallsOnMount, - }) - ); - } - } +const closeAskAi = React.useCallback( + () => { + setState(state => { + const openedEditor = getOpenedAskAiEditor(state.editorTabs); + if (!openedEditor) return state; - newEditorTabs = openEditorTab( - newEditorTabs, - // $FlowFixMe[incompatible-type] - getEditorOpeningOptions({ - kind: 'ask-ai', - name: '', - paneIdentifier: newPaneIdentifier, - continueProcessingFunctionCallsOnMount, - }) - ); + return { + ...state, + editorTabs: closeEditorTab(state.editorTabs, openedEditor.editorTab), + }; + }); + }, + [setState] +); + +const closeProject = React.useCallback( + async (): Promise => { + setHasProjectOpened(false); + setPreviewState(initialPreviewState); + + console.info('Closing project...'); + const previewLauncher = _previewLauncher.current; + if (previewLauncher && previewLauncher.closeAllPreviews) { + previewLauncher.closeAllPreviews(); + } + if (previewDebuggerServer) { + previewDebuggerServer.closeAllConnections(); + } - return { - ...state, - editorTabs: newEditorTabs, - }; - }).then(state => { - // Wait for the state to be updated before starting/opening the chat, - // as the editor needs to be mounted. - const params = aiRequestId === undefined ? undefined : { aiRequestId }; - const openedEditor = getOpenedAskAiEditor(state.editorTabs); - if (!openedEditor) { - console.error( - 'No Ask AI editor found after opening it. This should not happen.' - ); - return; - } - if (openedEditor.askAiEditor) { - openedEditor.askAiEditor.startOrOpenChat(params); - } - }); - }, - [setState, getEditorOpeningOptions, currentProject] - ); + // TODO Remove this state + // Instead: + // - Move the EventsFunctionsExtensionsLoader to Core + // - Add a dirty flag system to refresh on demand. + setIsProjectClosedSoAvoidReloadingExtensions(true); + + // While not strictly necessary, use `currentProjectRef` to be 100% + // sure to have the latest project (avoid risking any stale variable to an old + // `currentProject` from the state in case someone kept an old reference to `closeProject` + // somewhere). + const currentProject = currentProjectRef.current; + if (!currentProject) return; - const closeAskAi = React.useCallback( - () => { - setState(state => { - const openedEditor = getOpenedAskAiEditor(state.editorTabs); - if (!openedEditor) return state; + // Close the editors related to this project. + await setState(state => ({ + ...state, + currentProject: null, + currentFileMetadata: null, + editorTabs: closeProjectTabs(state.editorTabs, currentProject), + toolbarButtons: [], + })); - return { - ...state, - editorTabs: closeEditorTab(state.editorTabs, openedEditor.editorTab), - }; + // Delete the project from memory. All references to it have been dropped previously + // by the setState. + console.info('Deleting project from memory...'); + eventsFunctionsExtensionsState.unloadProjectEventsFunctionsExtensions( + currentProject + ); + await eventsFunctionsExtensionsState.ensureLoadFinished(); + currentProject.delete(); + sealUnsavedChanges(); + console.info('Project closed.'); + + // If AIEditor is opened on a side panel, then reposition it on the center. + const openedAskAIEditor = getOpenedAskAiEditor(state.editorTabs); + if (openedAskAIEditor && openedAskAIEditor.paneIdentifier !== 'center') { + openAskAi({ + paneIdentifier: 'center', }); - }, - [setState] - ); - - const closeProject = React.useCallback( - async (): Promise => { - setHasProjectOpened(false); - setPreviewState(initialPreviewState); + } + }, + [ + previewDebuggerServer, + currentProjectRef, + eventsFunctionsExtensionsState, + setHasProjectOpened, + setState, + sealUnsavedChanges, + openAskAi, + state.editorTabs, + ] +); + +const loadFromProject = React.useCallback( + async (project: gdProject, fileMetadata: ?FileMetadata): Promise => { + let updatedFileMetadata: ?FileMetadata = fileMetadata + ? // $FlowFixMe[incompatible-type] + updateFileMetadataWithOpenedProject(fileMetadata, project) + : null; + + if (updatedFileMetadata) { + const storageProvider = getStorageProvider(); + const storageProviderOperations = getStorageProviderOperations( + storageProvider + ); + const { onSaveProject } = storageProviderOperations; - console.info('Closing project...'); - const previewLauncher = _previewLauncher.current; - if (previewLauncher && previewLauncher.closeAllPreviews) { - previewLauncher.closeAllPreviews(); - } - if (previewDebuggerServer) { - previewDebuggerServer.closeAllConnections(); + // Only save the project in the recent files if the storage provider + // is able to save. Otherwise, it means nothing to consider this as + // a recent file: we must wait for the user to save in a "real" storage + // (like locally or on Google Drive). + if (onSaveProject) { + preferences.insertRecentProjectFile({ + fileMetadata: updatedFileMetadata, + storageProviderName: storageProvider.internalName, + }); } + } - // TODO Remove this state - // Instead: - // - Move the EventsFunctionsExtensionsLoader to Core - // - Add a dirty flag system to refresh on demand. - setIsProjectClosedSoAvoidReloadingExtensions(true); - - // While not strictly necessary, use `currentProjectRef` to be 100% - // sure to have the latest project (avoid risking any stale variable to an old - // `currentProject` from the state in case someone kept an old reference to `closeProject` - // somewhere). - const currentProject = currentProjectRef.current; - if (!currentProject) return; + await closeProject(); - // Close the editors related to this project. - await setState(state => ({ - ...state, - currentProject: null, - currentFileMetadata: null, - editorTabs: closeProjectTabs(state.editorTabs, currentProject), - toolbarButtons: [], - })); - - // Delete the project from memory. All references to it have been dropped previously - // by the setState. - console.info('Deleting project from memory...'); - eventsFunctionsExtensionsState.unloadProjectEventsFunctionsExtensions( - currentProject - ); - await eventsFunctionsExtensionsState.ensureLoadFinished(); - currentProject.delete(); - sealUnsavedChanges(); - console.info('Project closed.'); - - // If AIEditor is opened on a side panel, then reposition it on the center. - const openedAskAIEditor = getOpenedAskAiEditor(state.editorTabs); - if (openedAskAIEditor && openedAskAIEditor.paneIdentifier !== 'center') { - openAskAi({ - paneIdentifier: 'center', - }); - } - }, - [ - previewDebuggerServer, - currentProjectRef, - eventsFunctionsExtensionsState, - setHasProjectOpened, - setState, - sealUnsavedChanges, - openAskAi, - state.editorTabs, - ] - ); - - const loadFromProject = React.useCallback( - async (project: gdProject, fileMetadata: ?FileMetadata): Promise => { - let updatedFileMetadata: ?FileMetadata = fileMetadata - ? // $FlowFixMe[incompatible-type] - updateFileMetadataWithOpenedProject(fileMetadata, project) - : null; - - if (updatedFileMetadata) { - const storageProvider = getStorageProvider(); - const storageProviderOperations = getStorageProviderOperations( - storageProvider - ); - const { onSaveProject } = storageProviderOperations; - - // Only save the project in the recent files if the storage provider - // is able to save. Otherwise, it means nothing to consider this as - // a recent file: we must wait for the user to save in a "real" storage - // (like locally or on Google Drive). - if (onSaveProject) { - preferences.insertRecentProjectFile({ - fileMetadata: updatedFileMetadata, - storageProviderName: storageProvider.internalName, - }); - } - } + // Make sure that the ResourcesLoader cache is emptied, so that + // the URL to a resource with a name in the old project is not re-used + // for another resource with the same name in the new project. + ResourcesLoader.burstAllUrlsCache(); + PixiResourcesLoader.burstCache(); - await closeProject(); + const state = await setState(state => ({ + ...state, + currentProject: project, + currentFileMetadata: updatedFileMetadata, + })); - // Make sure that the ResourcesLoader cache is emptied, so that - // the URL to a resource with a name in the old project is not re-used - // for another resource with the same name in the new project. - ResourcesLoader.burstAllUrlsCache(); - PixiResourcesLoader.burstCache(); + // Load all the EventsFunctionsExtension when the game is loaded. If they are modified, + // their editor will take care of reloading them. + eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( + project + ); - const state = await setState(state => ({ - ...state, - currentProject: project, - currentFileMetadata: updatedFileMetadata, - })); + if (updatedFileMetadata) { + project.setProjectFile(updatedFileMetadata.fileIdentifier); - // Load all the EventsFunctionsExtension when the game is loaded. If they are modified, - // their editor will take care of reloading them. - eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( - project + const storageProvider = getStorageProvider(); + const storageProviderOperations = getStorageProviderOperations( + storageProvider ); - if (updatedFileMetadata) { - project.setProjectFile(updatedFileMetadata.fileIdentifier); + // Fetch the resources if needed, for example: + // - if opening a local file, with resources stored as URL + // (which can happen after downloading it from the web-app), + // in which case URLs will be downloaded. + // - if opening from a URL, with resources that are relative + // to this base URL and which will be converted to full URLs. + // ... + // See `ResourceFetcher` for all the cases. + await ensureResourcesAreFetched(() => ({ + project, + fileMetadata: updatedFileMetadata, + storageProvider, + storageProviderOperations, + authenticatedUser, + })); - const storageProvider = getStorageProvider(); - const storageProviderOperations = getStorageProviderOperations( - storageProvider + // Read and apply project settings from gdevelop-settings.yaml if it exists + try { + const rawSettings = await readProjectSettings( + updatedFileMetadata.fileIdentifier ); - - // Fetch the resources if needed, for example: - // - if opening a local file, with resources stored as URL - // (which can happen after downloading it from the web-app), - // in which case URLs will be downloaded. - // - if opening from a URL, with resources that are relative - // to this base URL and which will be converted to full URLs. - // ... - // See `ResourceFetcher` for all the cases. - await ensureResourcesAreFetched(() => ({ - project, - fileMetadata: updatedFileMetadata, - storageProvider, - storageProviderOperations, - authenticatedUser, - })); - - // Read and apply project settings from gdevelop-settings.yaml if it exists - try { - const rawSettings = await readProjectSettings( - updatedFileMetadata.fileIdentifier - ); - if (rawSettings) { - applyProjectPreferences(rawSettings.preferences, preferences); - setState(currentState => ({ - ...currentState, - toolbarButtons: rawSettings.toolbarButtons || [], - })); - } - } catch (error) { - console.warn( - '[MainFrame] Failed to read project settings:', - error.message - ); + if (rawSettings) { + applyProjectPreferences(rawSettings.preferences, preferences); + setState(currentState => ({ + ...currentState, + toolbarButtons: rawSettings.toolbarButtons || [], + })); } - - setIsProjectClosedSoAvoidReloadingExtensions(false); + } catch (error) { + console.warn( + '[MainFrame] Failed to read project settings:', + error.message + ); } - return state; - }, - [ - setState, - closeProject, - preferences, - eventsFunctionsExtensionsState, - getStorageProvider, - getStorageProviderOperations, - ensureResourcesAreFetched, - authenticatedUser, - ] - ); - - const loadFromSerializedProject = React.useCallback( - ( - serializedProject: gdSerializerElement, - fileMetadata: ?FileMetadata - ): Promise => { - const startTime = Date.now(); - const newProject = gd.ProjectHelper.createNewGDJSProject(); - newProject.unserializeFrom(serializedProject); - const duration = Date.now() - startTime; - console.info(`Unserialization took ${duration.toFixed(2)} ms`); - - return loadFromProject(newProject, fileMetadata); - }, - [loadFromProject] - ); + setIsProjectClosedSoAvoidReloadingExtensions(false); + } - const setLoaderModalProgress = ( - progress: ?number, - message: ?MessageDescriptor - ) => { - setFileMetadataOpeningProgress(progress); - setFileMetadataOpeningMessage(message); - }; + return state; + }, + [ + setState, + closeProject, + preferences, + eventsFunctionsExtensionsState, + getStorageProvider, + getStorageProviderOperations, + ensureResourcesAreFetched, + authenticatedUser, + ] +); + +const loadFromSerializedProject = React.useCallback( + ( + serializedProject: gdSerializerElement, + fileMetadata: ?FileMetadata + ): Promise => { + const startTime = Date.now(); + const newProject = gd.ProjectHelper.createNewGDJSProject(); + newProject.unserializeFrom(serializedProject); + const duration = Date.now() - startTime; + console.info(`Unserialization took ${duration.toFixed(2)} ms`); + + return loadFromProject(newProject, fileMetadata); + }, + [loadFromProject] +); + +const setLoaderModalProgress = ( + progress: ?number, + message: ?MessageDescriptor +) => { + setFileMetadataOpeningProgress(progress); + setFileMetadataOpeningMessage(message); +}; - const openFromFileMetadata = React.useCallback( - async ( - fileMetadata: FileMetadata, - options?: {| - openingMessage?: ?MessageDescriptor, - ignoreAutoSave?: boolean, +const openFromFileMetadata = React.useCallback( + async ( + fileMetadata: FileMetadata, + options?: {| + openingMessage?: ?MessageDescriptor, + ignoreAutoSave?: boolean, |} - ): Promise => { - const storageProviderOperations = getStorageProviderOperations(); + ): Promise => { + const storageProviderOperations = getStorageProviderOperations(); - const { - getAutoSaveCreationDate, - onGetAutoSave, - onOpen, - getOpenErrorMessage, - } = storageProviderOperations; - - if (!onOpen) { - console.error( - 'Tried to open a file for a storage without onOpen support:', - fileMetadata, - storageProviderOperations - ); - return; - } + const { + getAutoSaveCreationDate, + onGetAutoSave, + onOpen, + getOpenErrorMessage, + } = storageProviderOperations; + + if (!onOpen) { + console.error( + 'Tried to open a file for a storage without onOpen support:', + fileMetadata, + storageProviderOperations + ); + return; + } - const checkForAutosave = async (): Promise => { - if ( - !getAutoSaveCreationDate || - !onGetAutoSave || - (options && options.ignoreAutoSave) - ) { - return fileMetadata; - } + const checkForAutosave = async (): Promise => { + if ( + !getAutoSaveCreationDate || + !onGetAutoSave || + (options && options.ignoreAutoSave) + ) { + return fileMetadata; + } - const autoSaveCreationDate = await getAutoSaveCreationDate( - fileMetadata, - true - ); - if (!autoSaveCreationDate) return fileMetadata; - - await delay(200); // Ensure confirmation is shown on top of the loader. - const answer = await showConfirmation({ - title: t`This project has an auto-saved version`, - message: t`GDevelop automatically saved a newer version of this project on ${new Date( - autoSaveCreationDate - ).toLocaleString()}. This new version might differ from the one that you manually saved. Which version would you like to open?`, - dismissButtonLabel: t`My manual save`, - confirmButtonLabel: t`GDevelop auto-save`, - }); + const autoSaveCreationDate = await getAutoSaveCreationDate( + fileMetadata, + true + ); + if (!autoSaveCreationDate) return fileMetadata; + + await delay(200); // Ensure confirmation is shown on top of the loader. + const answer = await showConfirmation({ + title: t`This project has an auto-saved version`, + message: t`GDevelop automatically saved a newer version of this project on ${new Date( + autoSaveCreationDate + ).toLocaleString()}. This new version might differ from the one that you manually saved. Which version would you like to open?`, + dismissButtonLabel: t`My manual save`, + confirmButtonLabel: t`GDevelop auto-save`, + }); - if (!answer) return fileMetadata; - return onGetAutoSave(fileMetadata); - }; + if (!answer) return fileMetadata; + return onGetAutoSave(fileMetadata); + }; - const checkForAutosaveAfterFailure = async (): Promise => { - if ( - !getAutoSaveCreationDate || - !onGetAutoSave || - (options && options.ignoreAutoSave) - ) { - return null; - } + const checkForAutosaveAfterFailure = async (): Promise => { + if ( + !getAutoSaveCreationDate || + !onGetAutoSave || + (options && options.ignoreAutoSave) + ) { + return null; + } - const autoSaveCreationDate = await getAutoSaveCreationDate( - fileMetadata, - false - ); - if (!autoSaveCreationDate) return null; - - await delay(200); // Ensure confirmation is shown on top of the loader. - const answer = await showConfirmation({ - title: t`This project cannot be opened`, - message: t`The project file appears to be corrupted, but an autosave file exists (backup made automatically by GDevelop on ${new Date( - autoSaveCreationDate - ).toLocaleString()}). Would you like to try to load it instead?`, - confirmButtonLabel: t`Load autosave`, - }); - if (!answer) return null; - return onGetAutoSave(fileMetadata); - }; + const autoSaveCreationDate = await getAutoSaveCreationDate( + fileMetadata, + false + ); + if (!autoSaveCreationDate) return null; + + await delay(200); // Ensure confirmation is shown on top of the loader. + const answer = await showConfirmation({ + title: t`This project cannot be opened`, + message: t`The project file appears to be corrupted, but an autosave file exists (backup made automatically by GDevelop on ${new Date( + autoSaveCreationDate + ).toLocaleString()}). Would you like to try to load it instead?`, + confirmButtonLabel: t`Load autosave`, + }); + if (!answer) return null; + return onGetAutoSave(fileMetadata); + }; - if (options && options.openingMessage) { - setLoaderModalOpeningMessage(options.openingMessage); + if (options && options.openingMessage) { + setLoaderModalOpeningMessage(options.openingMessage); + } + setIsLoadingProject(true); + + // Try to find an autosave (and ask user if found) + try { + await delay(50); + let content; + let openingError: Error | null = null; + try { + const autoSaveFileMetadata = await checkForAutosave(); + const result = await onOpen( + autoSaveFileMetadata, + setLoaderModalProgress + ); + content = result.content; + } catch (error) { + openingError = error; + // onOpen failed, try to find again an autosave. + const autoSaveAfterFailureFileMetadata = await checkForAutosaveAfterFailure(); + if (autoSaveAfterFailureFileMetadata) { + const result = await onOpen(autoSaveAfterFailureFileMetadata); + content = result.content; } - setIsLoadingProject(true); - - // Try to find an autosave (and ask user if found) - try { - await delay(50); - let content; - let openingError: Error | null = null; - try { - const autoSaveFileMetadata = await checkForAutosave(); - const result = await onOpen( - autoSaveFileMetadata, - setLoaderModalProgress - ); - content = result.content; - } catch (error) { - openingError = error; - // onOpen failed, try to find again an autosave. - const autoSaveAfterFailureFileMetadata = await checkForAutosaveAfterFailure(); - if (autoSaveAfterFailureFileMetadata) { - const result = await onOpen(autoSaveAfterFailureFileMetadata); - content = result.content; - } - } finally { - setIsLoadingProject(false); - setLoaderModalOpeningMessage(null); - setLoaderModalProgress(null, null); - } - if (!content) { - throw openingError || - new Error( - 'The project file content could not be read. It might be corrupted/malformed.' - ); - } - if (!verifyProjectContent(i18n, content)) { - // The content is not recognized and the user was warned. Abort the opening. - return; - } + } finally { + setIsLoadingProject(false); + setLoaderModalOpeningMessage(null); + setLoaderModalProgress(null, null); + } + if (!content) { + throw openingError || + new Error( + 'The project file content could not be read. It might be corrupted/malformed.' + ); + } + if (!verifyProjectContent(i18n, content)) { + // The content is not recognized and the user was warned. Abort the opening. + return; + } - const serializedProject = gd.Serializer.fromJSObject(content); + const serializedProject = gd.Serializer.fromJSObject(content); - try { - const state = loadFromSerializedProject( - serializedProject, - // Note that fileMetadata is the original, unchanged one, even if we're loading - // an autosave. If we're for some reason loading an autosave, we still consider - // that we're opening the file that was originally requested by the user. - fileMetadata - ); - return state; - } finally { - sealUnsavedChanges(); - serializedProject.delete(); - } - } catch (error) { - if (error.name === 'CloudProjectReadingError') { - setCloudProjectFileMetadataToRecover(fileMetadata); - } else { - console.error('Failed to open the project:', error); - const errorMessage = getOpenErrorMessage - ? getOpenErrorMessage(error) - : t`Ensure that you are connected to internet and that the URL used is correct, then try again.`; - - await showAlert({ - title: t`Unable to open the project`, - message: errorMessage, - }); - throw error; - } - } - }, - [ - i18n, - getStorageProviderOperations, - loadFromSerializedProject, - showConfirmation, - showAlert, - sealUnsavedChanges, - ] + try { + const state = loadFromSerializedProject( + serializedProject, + // Note that fileMetadata is the original, unchanged one, even if we're loading + // an autosave. If we're for some reason loading an autosave, we still consider + // that we're opening the file that was originally requested by the user. + fileMetadata + ); + return state; + } finally { + sealUnsavedChanges(); + serializedProject.delete(); + } + } catch (error) { + if (error.name === 'CloudProjectReadingError') { + setCloudProjectFileMetadataToRecover(fileMetadata); + } else { + console.error('Failed to open the project:', error); + const errorMessage = getOpenErrorMessage + ? getOpenErrorMessage(error) + : t`Ensure that you are connected to internet and that the URL used is correct, then try again.`; + + await showAlert({ + title: t`Unable to open the project`, + message: errorMessage, + }); + throw error; + } + } +}, +[ + i18n, + getStorageProviderOperations, + loadFromSerializedProject, + showConfirmation, + showAlert, + sealUnsavedChanges, +] ); - const { - createEmptyProject, - createProjectFromExample, - createProjectFromPrivateGameTemplate, - createProjectFromInAppTutorial, - createProjectFromTutorial, - createProjectFromCourseChapter, - } = useCreateProject({ - beforeCreatingProject: () => { - setIsProjectOpening(true); - }, - getStorageProviderOperations, - getStorageProvider, - afterCreatingProject: async ({ - project, - editorTabs, - oldProjectId, - options, - }) => { - // Update the currentFileMetadata based on the updated project, as - // it can have been updated in the meantime (gameId, project name, etc...). - // Use the ref here to be sure to have the latest file metadata. - if (currentFileMetadataRef.current) { - // $FlowFixMe[incompatible-type] - const newFileMetadata: FileMetadata = updateFileMetadataWithOpenedProject( - currentFileMetadataRef.current, - project - ); - setState(state => ({ - ...state, - currentFileMetadata: newFileMetadata, - })); - } - setNewProjectSetupDialogOpen(false); - if (options.openQuickCustomizationDialog) { - setQuickCustomizationDialogOpenedFromGameId(oldProjectId); - } else { - // Replace leaderboards and configure multiplayer lobbies if needed. - // In the case of quick customization, this will be done later. - openLeaderboardReplacerDialogIfNeeded(project, oldProjectId); - configureMultiplayerLobbiesIfNeeded(project, oldProjectId); - } - options.openAllScenes || options.openQuickCustomizationDialog - ? openAllScenes({ - currentProject: project, - editorTabs, - }) - : openSceneOrProjectManager({ - currentProject: project, - editorTabs: editorTabs, - }); - // If Ask AI editor was opened, reposition it. - const openedAskAIEditor = getOpenedAskAiEditor(state.editorTabs); - if (openedAskAIEditor || options.forceOpenAskAiEditor) { - openAskAi({ - paneIdentifier: 'right', - continueProcessingFunctionCallsOnMount: true, - }); - } - setIsProjectClosedSoAvoidReloadingExtensions(false); - }, - onError: () => { - setIsProjectClosedSoAvoidReloadingExtensions(true); - }, - onSuccessOrError: () => { - // Stop the loading when we're successful or have failed. - setIsProjectOpening(false); - setIsLoadingProject(false); - setLoaderModalProgress(null, null); - }, - loadFromProject, - openFromFileMetadata, - onProjectSaved: fileMetadata => { +const { + createEmptyProject, + createProjectFromExample, + createProjectFromPrivateGameTemplate, + createProjectFromInAppTutorial, + createProjectFromTutorial, + createProjectFromCourseChapter, +} = useCreateProject({ + beforeCreatingProject: () => { + setIsProjectOpening(true); + }, + getStorageProviderOperations, + getStorageProvider, + afterCreatingProject: async ({ + project, + editorTabs, + oldProjectId, + options, + }) => { + // Update the currentFileMetadata based on the updated project, as + // it can have been updated in the meantime (gameId, project name, etc...). + // Use the ref here to be sure to have the latest file metadata. + if (currentFileMetadataRef.current) { + // $FlowFixMe[incompatible-type] + const newFileMetadata: FileMetadata = updateFileMetadataWithOpenedProject( + currentFileMetadataRef.current, + project + ); setState(state => ({ ...state, - currentFileMetadata: fileMetadata, + currentFileMetadata: newFileMetadata, })); - }, - ensureResourcesAreMoved, - onGameRegistered: gamesList.fetchGames, - }); + } + setNewProjectSetupDialogOpen(false); + if (options.openQuickCustomizationDialog) { + setQuickCustomizationDialogOpenedFromGameId(oldProjectId); + } else { + // Replace leaderboards and configure multiplayer lobbies if needed. + // In the case of quick customization, this will be done later. + openLeaderboardReplacerDialogIfNeeded(project, oldProjectId); + configureMultiplayerLobbiesIfNeeded(project, oldProjectId); + } + options.openAllScenes || options.openQuickCustomizationDialog + ? openAllScenes({ + currentProject: project, + editorTabs, + }) + : openSceneOrProjectManager({ + currentProject: project, + editorTabs: editorTabs, + }); + // If Ask AI editor was opened, reposition it. + const openedAskAIEditor = getOpenedAskAiEditor(state.editorTabs); + if (openedAskAIEditor || options.forceOpenAskAiEditor) { + openAskAi({ + paneIdentifier: 'right', + continueProcessingFunctionCallsOnMount: true, + }); + } + setIsProjectClosedSoAvoidReloadingExtensions(false); + }, + onError: () => { + setIsProjectClosedSoAvoidReloadingExtensions(true); + }, + onSuccessOrError: () => { + // Stop the loading when we're successful or have failed. + setIsProjectOpening(false); + setIsLoadingProject(false); + setLoaderModalProgress(null, null); + }, + loadFromProject, + openFromFileMetadata, + onProjectSaved: fileMetadata => { + setState(state => ({ + ...state, + currentFileMetadata: fileMetadata, + })); + }, + ensureResourcesAreMoved, + onGameRegistered: gamesList.fetchGames, +}); - const onOpenProfileDialog = React.useCallback( - () => { - openProfileDialog(true); - }, - [openProfileDialog] +const onOpenProfileDialog = React.useCallback( + () => { + openProfileDialog(true); + }, + [openProfileDialog] +); + +const closeApp = React.useCallback((): void => { + return Window.quit(); +}, []); + +const toggleProjectManager = React.useCallback( + () => { + openProjectManager(projectManagerOpen => !projectManagerOpen); + }, + [openProjectManager] +); + +const deleteLayout = (layout: gdLayout) => { + const { currentProject } = state; + const { i18n } = props; + if (!currentProject) return; + + const answer = Window.showConfirmDialog( + i18n._( + t`Are you sure you want to remove this scene? This can't be undone.` + ) ); + if (!answer) return; + + setState(state => ({ + ...state, + editorTabs: closeLayoutTabs(state.editorTabs, layout), + })).then(state => { + if (currentProject.getFirstLayout() === layout.getName()) + currentProject.setFirstLayout(''); + currentProject.removeLayout(layout.getName()); + _onProjectItemModified(); + }); +}; - const closeApp = React.useCallback((): void => { - return Window.quit(); - }, []); +const deleteExternalLayout = (externalLayout: gdExternalLayout) => { + const { currentProject } = state; + const { i18n } = props; + if (!currentProject) return; - const toggleProjectManager = React.useCallback( - () => { - openProjectManager(projectManagerOpen => !projectManagerOpen); - }, - [openProjectManager] + const answer = Window.showConfirmDialog( + i18n._( + t`Are you sure you want to remove this external layout? This can't be undone.` + ) ); + if (!answer) return; + + setState(state => ({ + ...state, + editorTabs: closeExternalLayoutTabs(state.editorTabs, externalLayout), + })).then(state => { + if (state.currentProject) + state.currentProject.removeExternalLayout(externalLayout.getName()); + _onProjectItemModified(); + }); +}; - const deleteLayout = (layout: gdLayout) => { - const { currentProject } = state; - const { i18n } = props; - if (!currentProject) return; +const deleteExternalEvents = (externalEvents: gdExternalEvents) => { + const { i18n } = props; + if (!state.currentProject) return; - const answer = Window.showConfirmDialog( - i18n._( - t`Are you sure you want to remove this scene? This can't be undone.` - ) - ); - if (!answer) return; + const answer = Window.showConfirmDialog( + i18n._( + t`Are you sure you want to remove these external events? This can't be undone.` + ) + ); + if (!answer) return; + + setState(state => ({ + ...state, + editorTabs: closeExternalEventsTabs(state.editorTabs, externalEvents), + })).then(state => { + if (state.currentProject) + state.currentProject.removeExternalEvents(externalEvents.getName()); + _onProjectItemModified(); + }); +}; - setState(state => ({ - ...state, - editorTabs: closeLayoutTabs(state.editorTabs, layout), - })).then(state => { - if (currentProject.getFirstLayout() === layout.getName()) - currentProject.setFirstLayout(''); - currentProject.removeLayout(layout.getName()); - _onProjectItemModified(); - }); - }; +const deleteEventsFunctionsExtension = async ( + eventsFunctionsExtension: gdEventsFunctionsExtension +) => { + const { currentProject } = state; + const { i18n } = props; + if (!currentProject) return; + + const dependentExtensionNames = gd.UsedExtensionsFinder.findExtensionsDependentOn( + currentProject, + eventsFunctionsExtension + ).toJSArray(); + + const deleteAnswer = await showDeleteConfirmation({ + title: t`Remove the extension`, + message: t`${dependentExtensionNames.length > 0 + ? i18n._( + `This extension is used by the following extensions:${'\n\n' + + dependentExtensionNames + .map( + extensionName => + `- ${(currentProject.hasEventsFunctionsExtensionNamed( + extensionName + ) + ? currentProject + .getEventsFunctionsExtension(extensionName) + .getFullName() + : extensionName) || extensionName}\n` + ) + .join('') + + '\n'}` + ) + : '' + }Are you sure you want to remove this extension? This can't be undone.`, + }); + if (!deleteAnswer) return; + + const extensionName = eventsFunctionsExtension.getName(); + const hasCustomObject = + eventsFunctionsExtension.getEventsBasedObjects().size() > 0; + setState(state => ({ + ...state, + editorTabs: closeEventsFunctionsExtensionTabs( + state.editorTabs, + extensionName + ), + })).then(async state => { + // Ensure no other previous call to this method is happening on an + // outdated extension list. + await eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( + currentProject + ); - const deleteExternalLayout = (externalLayout: gdExternalLayout) => { - const { currentProject } = state; - const { i18n } = props; - if (!currentProject) return; + // Unload the Platform extension that was generated from the events + // functions extension. + eventsFunctionsExtensionsState.unloadProjectEventsFunctionsExtension( + currentProject, + extensionName + ); + currentProject.removeEventsFunctionsExtension(extensionName); - const answer = Window.showConfirmDialog( - i18n._( - t`Are you sure you want to remove this external layout? This can't be undone.` - ) + // Reload extensions to make sure any extension that would have been relying + // on the unloaded extension is updated. + await eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensions( + currentProject ); - if (!answer) return; - setState(state => ({ - ...state, - editorTabs: closeExternalLayoutTabs(state.editorTabs, externalLayout), - })).then(state => { - if (state.currentProject) - state.currentProject.removeExternalLayout(externalLayout.getName()); - _onProjectItemModified(); - }); - }; + if (hasCustomObject) { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: true, + reasons: ['deleted-extension-with-custom-object'], + }); + } else { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['deleted-extension-without-custom-object'], + }); + } + _onProjectItemModified(); + }); +}; - const deleteExternalEvents = (externalEvents: gdExternalEvents) => { - const { i18n } = props; - if (!state.currentProject) return; +const onWillInstallExtension = (extensionNames: Array) => { + const { currentProject } = state; + if (!currentProject) return; - const answer = Window.showConfirmDialog( - i18n._( - t`Are you sure you want to remove these external events? This can't be undone.` - ) - ); - if (!answer) return; + for (const extensionName of extensionNames) { + // Close the extension tab before updating/reinstalling the extension. + // This is especially important when the extension tab in selected. + const eventsFunctionsExtensionName = extensionName; - setState(state => ({ - ...state, - editorTabs: closeExternalEventsTabs(state.editorTabs, externalEvents), - })).then(state => { - if (state.currentProject) - state.currentProject.removeExternalEvents(externalEvents.getName()); - _onProjectItemModified(); - }); - }; + if ( + currentProject.hasEventsFunctionsExtensionNamed( + eventsFunctionsExtensionName + ) + ) { + setState(state => ({ + ...state, + editorTabs: closeEventsFunctionsExtensionTabs( + state.editorTabs, + eventsFunctionsExtensionName + ), + })); + } + } +}; - const deleteEventsFunctionsExtension = async ( - eventsFunctionsExtension: gdEventsFunctionsExtension - ) => { - const { currentProject } = state; - const { i18n } = props; - if (!currentProject) return; +const onExtensionInstalled = (extensionNames: Array) => { + const { currentProject } = state; + if (!currentProject) { + return; + } + let hasEventsBasedObject = false; + for (const extensionName of extensionNames) { + const eventsBasedObjects = currentProject + .getEventsFunctionsExtension(extensionName) + .getEventsBasedObjects(); + for (let index = 0; index < eventsBasedObjects.getCount(); index++) { + const eventsBasedObject = eventsBasedObjects.getAt(index); + gd.EventsBasedObjectVariantHelper.complyVariantsToEventsBasedObject( + currentProject, + eventsBasedObject + ); + } - const dependentExtensionNames = gd.UsedExtensionsFinder.findExtensionsDependentOn( - currentProject, - eventsFunctionsExtension - ).toJSArray(); - - const deleteAnswer = await showDeleteConfirmation({ - title: t`Remove the extension`, - message: t`${ - dependentExtensionNames.length > 0 - ? i18n._( - `This extension is used by the following extensions:${'\n\n' + - dependentExtensionNames - .map( - extensionName => - `- ${(currentProject.hasEventsFunctionsExtensionNamed( - extensionName - ) - ? currentProject - .getEventsFunctionsExtension(extensionName) - .getFullName() - : extensionName) || extensionName}\n` - ) - .join('') + - '\n'}` - ) - : '' - }Are you sure you want to remove this extension? This can't be undone.`, - }); - if (!deleteAnswer) return; + // Close extension tab because `onInstallExtension` is not necessarily + // called when the extension tab is not selected. - const extensionName = eventsFunctionsExtension.getName(); - const hasCustomObject = - eventsFunctionsExtension.getEventsBasedObjects().size() > 0; + // TODO Open the closed tabs back + // It would be safer to close the tabs before the extension is installed + // but it would make opening them back more complicated. setState(state => ({ ...state, editorTabs: closeEventsFunctionsExtensionTabs( state.editorTabs, extensionName ), - })).then(async state => { - // Ensure no other previous call to this method is happening on an - // outdated extension list. - await eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( - currentProject - ); - - // Unload the Platform extension that was generated from the events - // functions extension. - eventsFunctionsExtensionsState.unloadProjectEventsFunctionsExtension( - currentProject, - extensionName - ); - currentProject.removeEventsFunctionsExtension(extensionName); - - // Reload extensions to make sure any extension that would have been relying - // on the unloaded extension is updated. - await eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensions( - currentProject - ); + })); - if (hasCustomObject) { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: true, - reasons: ['deleted-extension-with-custom-object'], - }); - } else { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['deleted-extension-without-custom-object'], - }); - } - _onProjectItemModified(); + hasEventsBasedObject = + hasEventsBasedObject || eventsBasedObjects.getCount() > 0; + } + if (hasEventsBasedObject) { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['installed-extension-with-custom-object'], }); - }; - - const onWillInstallExtension = (extensionNames: Array) => { - const { currentProject } = state; - if (!currentProject) return; - - for (const extensionName of extensionNames) { - // Close the extension tab before updating/reinstalling the extension. - // This is especially important when the extension tab in selected. - const eventsFunctionsExtensionName = extensionName; + } +}; - if ( - currentProject.hasEventsFunctionsExtensionNamed( - eventsFunctionsExtensionName - ) - ) { - setState(state => ({ - ...state, - editorTabs: closeEventsFunctionsExtensionTabs( - state.editorTabs, - eventsFunctionsExtensionName - ), - })); +const notifyChangesToInGameEditor = React.useCallback( + (hotReloadSteps: HotReloadSteps) => { + let hasReloadIfNeeded = false; + for (const paneIdentifier in state.editorTabs.panes) { + const currentTab = getCurrentTabForPane( + state.editorTabs, + paneIdentifier + ); + const editorRef = currentTab ? currentTab.editorRef : null; + if (editorRef) { + editorRef.notifyChangesToInGameEditor(hotReloadSteps); + hasReloadIfNeeded = true; } } - }; - - const onExtensionInstalled = (extensionNames: Array) => { - const { currentProject } = state; - if (!currentProject) { - return; + if (!hasReloadIfNeeded) { + setEditorHotReloadNeeded(hotReloadSteps); } - let hasEventsBasedObject = false; - for (const extensionName of extensionNames) { - const eventsBasedObjects = currentProject - .getEventsFunctionsExtension(extensionName) - .getEventsBasedObjects(); - for (let index = 0; index < eventsBasedObjects.getCount(); index++) { - const eventsBasedObject = eventsBasedObjects.getAt(index); - gd.EventsBasedObjectVariantHelper.complyVariantsToEventsBasedObject( - currentProject, - eventsBasedObject - ); - } - - // Close extension tab because `onInstallExtension` is not necessarily - // called when the extension tab is not selected. - - // TODO Open the closed tabs back - // It would be safer to close the tabs before the extension is installed - // but it would make opening them back more complicated. - setState(state => ({ - ...state, - editorTabs: closeEventsFunctionsExtensionTabs( - state.editorTabs, - extensionName - ), - })); + }, + [state.editorTabs] +); - hasEventsBasedObject = - hasEventsBasedObject || eventsBasedObjects.getCount() > 0; - } - if (hasEventsBasedObject) { +const triggerHotReloadInGameEditorIfNeeded = React.useCallback( + () => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: false, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['triggered-if-needed'], + }); + }, + [notifyChangesToInGameEditor] +); + +const [ + showRestartInGameEditorAfterErrorButton, + setShowRestartInGameEditorAfterErrorButton, +] = React.useState(false); +const onRestartInGameEditor = React.useCallback( + (reason: string) => { + setShowRestartInGameEditorAfterErrorButton(false); + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: true, + shouldReloadResources: true, + shouldHardReload: true, + reasons: [reason], + }); + }, + [notifyChangesToInGameEditor] +); + +React.useEffect( + () => { + if (gameEditorMode === 'embedded-game') { + // The in-game editor is never hot-reloaded: + // - in 2D mode + // - from a tab without 3D editor + // + // It triggers required hot-reload level when users: + // - switch to 3D mode + // - switch to a 3D editor tab + // + // Hot-reloads are triggered right away from a 3D editor. + // Which means this call won't do any hot-reload when switching between + // 2 3D editors but only switch the scene. notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: true, + shouldReloadProjectData: false, + shouldReloadLibraries: false, shouldReloadResources: false, shouldHardReload: false, - reasons: ['installed-extension-with-custom-object'], + reasons: ['switched-tab-while-using-3d-editor'], }); - } - }; - - const notifyChangesToInGameEditor = React.useCallback( - (hotReloadSteps: HotReloadSteps) => { - let hasReloadIfNeeded = false; + } else { + // Switch the 3D editor to the same scene as the 2D one. + // It allows to keep the 3D editor up to date for a fast switch + // between 2D and 3D. for (const paneIdentifier in state.editorTabs.panes) { const currentTab = getCurrentTabForPane( state.editorTabs, @@ -1730,1695 +1806,1584 @@ const MainFrame = (props: Props): React.MixedElement => { ); const editorRef = currentTab ? currentTab.editorRef : null; if (editorRef) { - editorRef.notifyChangesToInGameEditor(hotReloadSteps); - hasReloadIfNeeded = true; + editorRef.switchInGameEditorIfNoHotReloadIsNeeded(); } } - if (!hasReloadIfNeeded) { - setEditorHotReloadNeeded(hotReloadSteps); - } - }, - [state.editorTabs] - ); + } + }, + [gameEditorMode, state.editorTabs, notifyChangesToInGameEditor] +); - const triggerHotReloadInGameEditorIfNeeded = React.useCallback( - () => { +useAutomatedRegularInGameEditorRestart({ + onRestartInGameEditor, + gameEditorMode, +}); + +const onExternalLayoutAssociationChanged = React.useCallback( + () => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['external-layout-association-changed'], + }); + }, + [notifyChangesToInGameEditor] +); + +const onResourceExternallyChanged = React.useCallback( + () => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: false, + shouldReloadLibraries: false, + shouldReloadResources: true, + shouldHardReload: false, + reasons: ['resource-externally-changed'], + }); + }, + [notifyChangesToInGameEditor] +); + +const onResourceUsageChanged = React.useCallback( + () => { + if (isEditorHotReloadNeeded()) { notifyChangesToInGameEditor({ shouldReloadProjectData: false, shouldReloadLibraries: false, shouldReloadResources: false, shouldHardReload: false, - reasons: ['triggered-if-needed'], + reasons: ['resource-usage-changed'], }); - }, - [notifyChangesToInGameEditor] - ); - - const [ - showRestartInGameEditorAfterErrorButton, - setShowRestartInGameEditorAfterErrorButton, - ] = React.useState(false); - const onRestartInGameEditor = React.useCallback( - (reason: string) => { - setShowRestartInGameEditorAfterErrorButton(false); + } else { notifyChangesToInGameEditor({ shouldReloadProjectData: true, - shouldReloadLibraries: true, - shouldReloadResources: true, - shouldHardReload: true, - reasons: [reason], + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['resource-usage-changed'], }); - }, - [notifyChangesToInGameEditor] - ); + } + }, + [notifyChangesToInGameEditor] +); + +const onSceneAdded = React.useCallback( + () => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['scene-added'], + }); + }, + [notifyChangesToInGameEditor] +); + +const onExternalLayoutAdded = React.useCallback( + () => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['external-layout-added'], + }); + }, + [notifyChangesToInGameEditor] +); + +const onEffectAdded = React.useCallback( + () => { + // Ensure the effect implementation is exported. + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['effect-added'], + }); + }, + [notifyChangesToInGameEditor] +); + +const onObjectListsModified = React.useCallback( + ({ isNewObjectTypeUsed }: { isNewObjectTypeUsed: boolean }) => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: isNewObjectTypeUsed, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['object-lists-modified'], + }); + }, + [notifyChangesToInGameEditor] +); - React.useEffect( - () => { - if (gameEditorMode === 'embedded-game') { - // The in-game editor is never hot-reloaded: - // - in 2D mode - // - from a tab without 3D editor - // - // It triggers required hot-reload level when users: - // - switch to 3D mode - // - switch to a 3D editor tab - // - // Hot-reloads are triggered right away from a 3D editor. - // Which means this call won't do any hot-reload when switching between - // 2 3D editors but only switch the scene. - notifyChangesToInGameEditor({ - shouldReloadProjectData: false, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['switched-tab-while-using-3d-editor'], - }); - } else { - // Switch the 3D editor to the same scene as the 2D one. - // It allows to keep the 3D editor up to date for a fast switch - // between 2D and 3D. - for (const paneIdentifier in state.editorTabs.panes) { - const currentTab = getCurrentTabForPane( - state.editorTabs, - paneIdentifier - ); - const editorRef = currentTab ? currentTab.editorRef : null; - if (editorRef) { - editorRef.switchInGameEditorIfNoHotReloadIsNeeded(); - } - } - } - }, - [gameEditorMode, state.editorTabs, notifyChangesToInGameEditor] +const renameLayout = (oldName: string, newName: string) => { + const { currentProject } = state; + const { i18n } = props; + if (!currentProject) return; + + if (!currentProject.hasLayoutNamed(oldName) || newName === oldName) return; + + const uniqueNewName = newNameGenerator( + newName || i18n._(t`Unnamed`), + tentativeNewName => { + return currentProject.hasLayoutNamed(tentativeNewName); + } ); - useAutomatedRegularInGameEditorRestart({ - onRestartInGameEditor, - gameEditorMode, + const layout = currentProject.getLayout(oldName); + const shouldChangeProjectFirstLayout = + oldName === currentProject.getFirstLayout(); + setState(state => ({ + ...state, + editorTabs: closeLayoutTabs(state.editorTabs, layout), + })).then(state => { + layout.setName(uniqueNewName); + gd.WholeProjectRefactorer.renameLayout( + currentProject, + oldName, + uniqueNewName + ); + if (inAppTutorialOrchestratorRef.current) { + inAppTutorialOrchestratorRef.current.changeData(oldName, uniqueNewName); + } + if (shouldChangeProjectFirstLayout) { + currentProject.setFirstLayout(uniqueNewName); + } + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['renamed-scene'], + }); + _onProjectItemModified(); }); +}; - const onExternalLayoutAssociationChanged = React.useCallback( - () => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['external-layout-association-changed'], - }); - }, - [notifyChangesToInGameEditor] - ); +const renameExternalLayout = (oldName: string, newName: string) => { + const { currentProject } = state; + const { i18n } = props; + if (!currentProject) return; - const onResourceExternallyChanged = React.useCallback( - () => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: false, - shouldReloadLibraries: false, - shouldReloadResources: true, - shouldHardReload: false, - reasons: ['resource-externally-changed'], - }); - }, - [notifyChangesToInGameEditor] - ); + if (!currentProject.hasExternalLayoutNamed(oldName) || newName === oldName) + return; - const onResourceUsageChanged = React.useCallback( - () => { - if (isEditorHotReloadNeeded()) { - notifyChangesToInGameEditor({ - shouldReloadProjectData: false, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['resource-usage-changed'], - }); - } else { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['resource-usage-changed'], - }); - } - }, - [notifyChangesToInGameEditor] + const uniqueNewName = newNameGenerator( + newName || i18n._(t`Unnamed`), + tentativeNewName => { + return currentProject.hasExternalLayoutNamed(tentativeNewName); + } ); - const onSceneAdded = React.useCallback( - () => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['scene-added'], - }); - }, - [notifyChangesToInGameEditor] - ); + const externalLayout = currentProject.getExternalLayout(oldName); + setState(state => ({ + ...state, + editorTabs: closeExternalLayoutTabs(state.editorTabs, externalLayout), + })).then(state => { + externalLayout.setName(uniqueNewName); + gd.WholeProjectRefactorer.renameExternalLayout( + currentProject, + oldName, + uniqueNewName + ); + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['renamed-external-layout'], + }); + _onProjectItemModified(); + }); +}; - const onExternalLayoutAdded = React.useCallback( - () => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['external-layout-added'], - }); - }, - [notifyChangesToInGameEditor] - ); +const renameExternalEvents = (oldName: string, newName: string) => { + const { currentProject } = state; + const { i18n } = props; + if (!currentProject) return; - const onEffectAdded = React.useCallback( - () => { - // Ensure the effect implementation is exported. - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['effect-added'], - }); - }, - [notifyChangesToInGameEditor] - ); + if (!currentProject.hasExternalEventsNamed(oldName) || newName === oldName) + return; - const onObjectListsModified = React.useCallback( - ({ isNewObjectTypeUsed }: { isNewObjectTypeUsed: boolean }) => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: isNewObjectTypeUsed, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['object-lists-modified'], - }); - }, - [notifyChangesToInGameEditor] + const uniqueNewName = newNameGenerator( + newName || i18n._(t`Unnamed`), + tentativeNewName => { + return currentProject.hasExternalEventsNamed(tentativeNewName); + } ); - const renameLayout = (oldName: string, newName: string) => { - const { currentProject } = state; - const { i18n } = props; - if (!currentProject) return; + const externalEvents = currentProject.getExternalEvents(oldName); + setState(state => ({ + ...state, + editorTabs: closeExternalEventsTabs(state.editorTabs, externalEvents), + })).then(state => { + externalEvents.setName(uniqueNewName); + gd.WholeProjectRefactorer.renameExternalEvents( + currentProject, + oldName, + uniqueNewName + ); + _onProjectItemModified(); + }); +}; - if (!currentProject.hasLayoutNamed(oldName) || newName === oldName) return; +const renameEventsFunctionsExtension = (oldName: string, newName: string) => { + const { currentProject } = state; + if (!currentProject) return; - const uniqueNewName = newNameGenerator( - newName || i18n._(t`Unnamed`), - tentativeNewName => { - return currentProject.hasLayoutNamed(tentativeNewName); - } - ); + if ( + !currentProject.hasEventsFunctionsExtensionNamed(oldName) || + newName === oldName + ) + return; - const layout = currentProject.getLayout(oldName); - const shouldChangeProjectFirstLayout = - oldName === currentProject.getFirstLayout(); - setState(state => ({ - ...state, - editorTabs: closeLayoutTabs(state.editorTabs, layout), - })).then(state => { - layout.setName(uniqueNewName); - gd.WholeProjectRefactorer.renameLayout( - currentProject, - oldName, - uniqueNewName - ); - if (inAppTutorialOrchestratorRef.current) { - inAppTutorialOrchestratorRef.current.changeData(oldName, uniqueNewName); - } - if (shouldChangeProjectFirstLayout) { - currentProject.setFirstLayout(uniqueNewName); - } - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['renamed-scene'], - }); - _onProjectItemModified(); - }); - }; - - const renameExternalLayout = (oldName: string, newName: string) => { - const { currentProject } = state; - const { i18n } = props; - if (!currentProject) return; - - if (!currentProject.hasExternalLayoutNamed(oldName) || newName === oldName) - return; - - const uniqueNewName = newNameGenerator( - newName || i18n._(t`Unnamed`), - tentativeNewName => { - return currentProject.hasExternalLayoutNamed(tentativeNewName); - } - ); - - const externalLayout = currentProject.getExternalLayout(oldName); - setState(state => ({ - ...state, - editorTabs: closeExternalLayoutTabs(state.editorTabs, externalLayout), - })).then(state => { - externalLayout.setName(uniqueNewName); - gd.WholeProjectRefactorer.renameExternalLayout( - currentProject, - oldName, - uniqueNewName - ); - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['renamed-external-layout'], - }); - _onProjectItemModified(); - }); - }; + const safeAndUniqueNewName = newNameGenerator( + gd.Project.getSafeName(newName), + tentativeNewName => { + return isExtensionNameTaken(tentativeNewName, currentProject); + } + ); - const renameExternalEvents = (oldName: string, newName: string) => { - const { currentProject } = state; - const { i18n } = props; - if (!currentProject) return; + const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( + oldName + ); - if (!currentProject.hasExternalEventsNamed(oldName) || newName === oldName) - return; + // Refactor the project to update the instructions (and later expressions) + // of this extension: + gd.WholeProjectRefactorer.renameEventsFunctionsExtension( + currentProject, + eventsFunctionsExtension, + oldName, + safeAndUniqueNewName + ); + eventsFunctionsExtension.setName(safeAndUniqueNewName); + eventsFunctionsExtensionsState.unloadProjectEventsFunctionsExtension( + currentProject, + oldName + ); - const uniqueNewName = newNameGenerator( - newName || i18n._(t`Unnamed`), - tentativeNewName => { - return currentProject.hasExternalEventsNamed(tentativeNewName); - } + // TODO Replace the tabs instead on closing them. + setState(state => ({ + ...state, + editorTabs: closeEventsFunctionsExtensionTabs(state.editorTabs, oldName), + })).then(async state => { + await eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensions( + currentProject ); - - const externalEvents = currentProject.getExternalEvents(oldName); - setState(state => ({ - ...state, - editorTabs: closeExternalEventsTabs(state.editorTabs, externalEvents), - })).then(state => { - externalEvents.setName(uniqueNewName); - gd.WholeProjectRefactorer.renameExternalEvents( - currentProject, - oldName, - uniqueNewName - ); - _onProjectItemModified(); + notifyChangesToInGameEditor({ + shouldReloadProjectData: false, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['renamed-extension'], }); - }; - - const renameEventsFunctionsExtension = (oldName: string, newName: string) => { - const { currentProject } = state; - if (!currentProject) return; - - if ( - !currentProject.hasEventsFunctionsExtensionNamed(oldName) || - newName === oldName - ) - return; - - const safeAndUniqueNewName = newNameGenerator( - gd.Project.getSafeName(newName), - tentativeNewName => { - return isExtensionNameTaken(tentativeNewName, currentProject); - } - ); - - const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( - oldName - ); + _onProjectItemModified(); + }); +}; - // Refactor the project to update the instructions (and later expressions) - // of this extension: - gd.WholeProjectRefactorer.renameEventsFunctionsExtension( - currentProject, - eventsFunctionsExtension, - oldName, - safeAndUniqueNewName - ); - eventsFunctionsExtension.setName(safeAndUniqueNewName); - eventsFunctionsExtensionsState.unloadProjectEventsFunctionsExtension( - currentProject, +const onRenamedEventsBasedObject = ( + eventsFunctionsExtension: gdEventsFunctionsExtension, + oldName: string, + newName: string +) => { + // TODO Replace the tabs instead on closing them. + setState(state => ({ + ...state, + editorTabs: closeCustomObjectTab( + state.editorTabs, + eventsFunctionsExtension.getName(), oldName - ); - - // TODO Replace the tabs instead on closing them. - setState(state => ({ - ...state, - editorTabs: closeEventsFunctionsExtensionTabs(state.editorTabs, oldName), - })).then(async state => { - await eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensions( - currentProject - ); - notifyChangesToInGameEditor({ - shouldReloadProjectData: false, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['renamed-extension'], - }); - _onProjectItemModified(); - }); - }; - - const onRenamedEventsBasedObject = ( - eventsFunctionsExtension: gdEventsFunctionsExtension, - oldName: string, - newName: string - ) => { - // TODO Replace the tabs instead on closing them. - setState(state => ({ - ...state, - editorTabs: closeCustomObjectTab( - state.editorTabs, - eventsFunctionsExtension.getName(), - oldName - ), - })).then(state => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['renamed-custom-object'], - }); + ), + })).then(state => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['renamed-custom-object'], }); - }; + }); +}; - const onDeletedEventsBasedObject = ( - eventsFunctionsExtension: gdEventsFunctionsExtension, - name: string - ) => { - setState(state => ({ - ...state, - editorTabs: closeCustomObjectTab( - state.editorTabs, - eventsFunctionsExtension.getName(), - name - ), - })).then(state => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: true, - reasons: ['deleted-custom-object'], - }); +const onDeletedEventsBasedObject = ( + eventsFunctionsExtension: gdEventsFunctionsExtension, + name: string +) => { + setState(state => ({ + ...state, + editorTabs: closeCustomObjectTab( + state.editorTabs, + eventsFunctionsExtension.getName(), + name + ), + })).then(state => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: true, + reasons: ['deleted-custom-object'], }); - }; + }); +}; - const deleteEventsBasedObjectVariant = ( - eventsFunctionsExtension: gdEventsFunctionsExtension, - eventBasedObject: gdEventsBasedObject, - variant: gdEventsBasedObjectVariant - ): void => { - const variants = eventBasedObject.getVariants(); - const variantName = variant.getName(); - if (!variants.hasVariantNamed(variantName)) { - return; - } - variants.removeVariant(variantName); +const deleteEventsBasedObjectVariant = ( + eventsFunctionsExtension: gdEventsFunctionsExtension, + eventBasedObject: gdEventsBasedObject, + variant: gdEventsBasedObjectVariant +): void => { + const variants = eventBasedObject.getVariants(); + const variantName = variant.getName(); + if (!variants.hasVariantNamed(variantName)) { + return; + } + variants.removeVariant(variantName); - setState(state => ({ - ...state, - editorTabs: closeEventsBasedObjectVariantTab( - state.editorTabs, - eventsFunctionsExtension.getName(), - eventBasedObject.getName(), - variantName - ), - })).then(state => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['deleted-custom-object-variant'], - }); + setState(state => ({ + ...state, + editorTabs: closeEventsBasedObjectVariantTab( + state.editorTabs, + eventsFunctionsExtension.getName(), + eventBasedObject.getName(), + variantName + ), + })).then(state => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['deleted-custom-object-variant'], }); - }; + }); +}; - const setPreviewedLayout = ({ - layoutName, - externalLayoutName, - }: { - layoutName: string | null, - externalLayoutName: string | null, - }) => { - setPreviewState( - previewState => - ({ - ...previewState, - previewLayoutName: layoutName, - previewExternalLayoutName: externalLayoutName, - }: PreviewState) +const setPreviewedLayout = ({ + layoutName, + externalLayoutName, +}: { + layoutName: string | null, + externalLayoutName: string | null, +}) => { + setPreviewState( + previewState => + ({ + ...previewState, + previewLayoutName: layoutName, + previewExternalLayoutName: externalLayoutName, + }: PreviewState) ); }; - // $FlowFixMe[missing-local-annot] - const setPreviewOverride = ({ +// $FlowFixMe[missing-local-annot] +const setPreviewOverride = ({ + isPreviewOverriden, + overridenPreviewLayoutName, + overridenPreviewExternalLayoutName, +}) => { + setPreviewState(previewState => ({ + ...previewState, isPreviewOverriden, overridenPreviewLayoutName, overridenPreviewExternalLayoutName, - }) => { - setPreviewState(previewState => ({ - ...previewState, - isPreviewOverriden, - overridenPreviewLayoutName, - overridenPreviewExternalLayoutName, - })); - }; - - const autosaveProjectIfNeeded = React.useCallback( - async () => { - if (!currentProject) return; - - const storageProviderOperations = getStorageProviderOperations(); - if ( - hasUnsavedChanges && // Only create an autosave if there are unsaved changes. - preferences.values.autosaveOnPreview && - storageProviderOperations.onAutoSaveProject && - currentFileMetadata - ) { - try { - await storageProviderOperations.onAutoSaveProject( - currentProject, - currentFileMetadata - ); - } catch (err) { - console.error('Error while auto-saving the project: ', err); - _showSnackMessage( - i18n._( - t`There was an error while making an auto-save of the project. Verify that you have permissions to write in the project folder.` - ) - ); - } - } - }, - [ - i18n, - _showSnackMessage, - currentProject, - currentFileMetadata, - getStorageProviderOperations, - preferences.values.autosaveOnPreview, - hasUnsavedChanges, - ] - ); - - const inGameEditorSettings = useInGameEditorSettings(); + })); +}; - const _launchPreview = React.useCallback( - async ({ - networkPreview, - numberOfWindows, - hotReload, - shouldReloadProjectData, - shouldReloadLibraries, - shouldGenerateScenesEventsCode, - shouldReloadResources, - shouldHardReload, - fullLoadingScreen, - forceDiagnosticReport, - launchCaptureOptions, - isForInGameEdition, - }: LaunchPreviewOptions) => { - if (!currentProject) return; - if (currentProject.getLayoutsCount() === 0) return; +const autosaveProjectIfNeeded = React.useCallback( + async () => { + if (!currentProject) return; - if ( - await checkDiagnosticErrorsAndIfShouldBlock(currentProject, 'preview') - ) { - return; + const storageProviderOperations = getStorageProviderOperations(); + if ( + hasUnsavedChanges && // Only create an autosave if there are unsaved changes. + preferences.values.autosaveOnPreview && + storageProviderOperations.onAutoSaveProject && + currentFileMetadata + ) { + try { + await storageProviderOperations.onAutoSaveProject( + currentProject, + currentFileMetadata + ); + } catch (err) { + console.error('Error while auto-saving the project: ', err); + _showSnackMessage( + i18n._( + t`There was an error while making an auto-save of the project. Verify that you have permissions to write in the project folder.` + ) + ); } + } + }, + [ + i18n, + _showSnackMessage, + currentProject, + currentFileMetadata, + getStorageProviderOperations, + preferences.values.autosaveOnPreview, + hasUnsavedChanges, + ] +); + +const inGameEditorSettings = useInGameEditorSettings(); + +const _launchPreview = React.useCallback( + async ({ + networkPreview, + numberOfWindows, + hotReload, + shouldReloadProjectData, + shouldReloadLibraries, + shouldGenerateScenesEventsCode, + shouldReloadResources, + shouldHardReload, + fullLoadingScreen, + forceDiagnosticReport, + launchCaptureOptions, + isForInGameEdition, + }: LaunchPreviewOptions) => { + if (!currentProject) return; + if (currentProject.getLayoutsCount() === 0) return; - console.info( - `Launching a new ${ - isForInGameEdition ? 'in-game edition preview' : 'preview' - } with options:`, - { - networkPreview, - numberOfWindows, - hotReload, - shouldReloadProjectData, - shouldReloadLibraries, - shouldGenerateScenesEventsCode, - shouldReloadResources, - shouldHardReload, - fullLoadingScreen, - forceDiagnosticReport, - launchCaptureOptions, - isForInGameEdition, - } - ); - - const previewLauncher = _previewLauncher.current; - if (!previewLauncher) { - console.error('Preview launcher not found.'); - return; - } + if ( + await checkDiagnosticErrorsAndIfShouldBlock(currentProject, 'preview') + ) { + return; + } - if (previewLoadingRef.current) { - console.error( - 'Preview already loading. Ignoring but it should not be even possible to launch a preview while another one is loading, as this could break the game of the first preview when it is loading or reading files.' - ); - // Note that in an ideal situation, each previewed game could continue to load - // without being impacted by a new preview being worked on. - // The main issue currently is files being erased/copied by the second preview, - // which can break the game of the first preview, - // when the game is loading its resources or reading files. - return; + console.info( + `Launching a new ${isForInGameEdition ? 'in-game edition preview' : 'preview' + } with options:`, + { + networkPreview, + numberOfWindows, + hotReload, + shouldReloadProjectData, + shouldReloadLibraries, + shouldGenerateScenesEventsCode, + shouldReloadResources, + shouldHardReload, + fullLoadingScreen, + forceDiagnosticReport, + launchCaptureOptions, + isForInGameEdition, } + ); - // Open the preview windows immediately, if required by the preview launcher. - // This is because some browsers (like Safari or Firefox) will block the - // window opening if done after an asynchronous operation. - const previewWindows = previewLauncher.immediatelyPreparePreviewWindows - ? previewLauncher.immediatelyPreparePreviewWindows({ - project: currentProject, - hotReload: !!hotReload, - numberOfWindows: numberOfWindows || 1, - isForInGameEdition: !!isForInGameEdition, - }) - : null; + const previewLauncher = _previewLauncher.current; + if (!previewLauncher) { + console.error('Preview launcher not found.'); + return; + } - // Mark the preview as loading. Note that it's important that nothing is asynchronous - // before this point (no asynchronous work, no delay): - // - to ensure the state is changed as soon as possible (avoid wrongly launching two previews), - // - and to ensure preview windows are opened on browsers following a "user gesture". - setPreviewLoading( - isForInGameEdition && hotReload - ? 'hot-reload-for-in-game-edition' - : 'preview' + if (previewLoadingRef.current) { + console.error( + 'Preview already loading. Ignoring but it should not be even possible to launch a preview while another one is loading, as this could break the game of the first preview when it is loading or reading files.' ); + // Note that in an ideal situation, each previewed game could continue to load + // without being impacted by a new preview being worked on. + // The main issue currently is files being erased/copied by the second preview, + // which can break the game of the first preview, + // when the game is loading its resources or reading files. + return; + } + + // Open the preview windows immediately, if required by the preview launcher. + // This is because some browsers (like Safari or Firefox) will block the + // window opening if done after an asynchronous operation. + const previewWindows = previewLauncher.immediatelyPreparePreviewWindows + ? previewLauncher.immediatelyPreparePreviewWindows({ + project: currentProject, + hotReload: !!hotReload, + numberOfWindows: numberOfWindows || 1, + isForInGameEdition: !!isForInGameEdition, + }) + : null; + + // Mark the preview as loading. Note that it's important that nothing is asynchronous + // before this point (no asynchronous work, no delay): + // - to ensure the state is changed as soon as possible (avoid wrongly launching two previews), + // - and to ensure preview windows are opened on browsers following a "user gesture". + setPreviewLoading( + isForInGameEdition && hotReload + ? 'hot-reload-for-in-game-edition' + : 'preview' + ); - notifyPreviewOrExportWillStart(state.editorTabs); + notifyPreviewOrExportWillStart(state.editorTabs); - const sceneName = isForInGameEdition - ? isForInGameEdition.forcedSceneName - : previewState.isPreviewOverriden + const sceneName = isForInGameEdition + ? isForInGameEdition.forcedSceneName + : previewState.isPreviewOverriden ? previewState.overridenPreviewLayoutName : previewState.previewLayoutName; - const externalLayoutName = isForInGameEdition - ? isForInGameEdition.forcedExternalLayoutName - : previewState.isPreviewOverriden + const externalLayoutName = isForInGameEdition + ? isForInGameEdition.forcedExternalLayoutName + : previewState.isPreviewOverriden ? previewState.overridenPreviewExternalLayoutName : previewState.previewExternalLayoutName; - if (!isForInGameEdition) { - autosaveProjectIfNeeded().catch(err => { - console.error('Error while auto-saving the project. Ignoring.', err); - }); + if (!isForInGameEdition) { + autosaveProjectIfNeeded().catch(err => { + console.error('Error while auto-saving the project. Ignoring.', err); + }); + } + + // Note that in the future, this kind of checks could be done + // and stored in a "diagnostic report", rather than hiding errors + // from the user. + findAndLogProjectPreviewErrors(currentProject); + + const fallbackAuthor = authenticatedUser.profile + ? { + username: authenticatedUser.profile.username || '', + id: authenticatedUser.profile.id, } + : null; - // Note that in the future, this kind of checks could be done - // and stored in a "diagnostic report", rather than hiding errors - // from the user. - findAndLogProjectPreviewErrors(currentProject); + const [authenticatedPlayer, captureOptions] = await Promise.all([ + isForInGameEdition ? null : getAuthenticatedPlayerForPreview(), + isForInGameEdition + ? null + : createCaptureOptionsForPreview(launchCaptureOptions), + ]); - const fallbackAuthor = authenticatedUser.profile - ? { - username: authenticatedUser.profile.username || '', - id: authenticatedUser.profile.id, - } - : null; + try { + await eventsFunctionsExtensionsState.ensureLoadFinished(); - const [authenticatedPlayer, captureOptions] = await Promise.all([ - isForInGameEdition ? null : getAuthenticatedPlayerForPreview(), - isForInGameEdition - ? null - : createCaptureOptionsForPreview(launchCaptureOptions), - ]); + const startTime = Date.now(); + let inAppTutorialMessageInPreview = { message: '', position: '' }; + if (inAppTutorialOrchestratorRef.current) { + inAppTutorialMessageInPreview = + inAppTutorialOrchestratorRef.current.getPreviewMessage() || + inAppTutorialMessageInPreview; + } + await previewLauncher.launchPreview({ + project: currentProject, + sceneName: sceneName || currentProject.getLayoutAt(0).getName(), + externalLayoutName: externalLayoutName || null, + eventsBasedObjectType: isForInGameEdition + ? isForInGameEdition.eventsBasedObjectType + : null, + eventsBasedObjectVariantName: isForInGameEdition + ? isForInGameEdition.eventsBasedObjectVariantName + : null, + networkPreview: !!networkPreview, + hotReload: !!hotReload, + shouldReloadProjectData: + shouldReloadProjectData === undefined + ? true + : shouldReloadProjectData, + shouldReloadLibraries: + shouldReloadLibraries === undefined ? true : shouldReloadLibraries, + shouldGenerateScenesEventsCode: + shouldGenerateScenesEventsCode === undefined + ? true + : shouldGenerateScenesEventsCode, + shouldReloadResources: !!shouldReloadResources, + shouldHardReload: !!shouldHardReload, + fullLoadingScreen: !!fullLoadingScreen, + fallbackAuthor, + authenticatedPlayer, + getIsMenuBarHiddenInPreview: preferences.getIsMenuBarHiddenInPreview, + getIsAlwaysOnTopInPreview: preferences.getIsAlwaysOnTopInPreview, + numberOfWindows: numberOfWindows === undefined ? 1 : numberOfWindows, + isForInGameEdition: !!isForInGameEdition, + editorId: isForInGameEdition ? isForInGameEdition.editorId : '', + editorCameraState3D: isForInGameEdition + ? isForInGameEdition.editorCameraState3D + : null, + inGameEditorSettings: isForInGameEdition + ? inGameEditorSettings + : null, + inAppTutorialMessageInPreview: inAppTutorialMessageInPreview.message, + inAppTutorialMessagePositionInPreview: + inAppTutorialMessageInPreview.position, + captureOptions, + onCaptureFinished, - try { - await eventsFunctionsExtensionsState.ensureLoadFinished(); - - const startTime = Date.now(); - let inAppTutorialMessageInPreview = { message: '', position: '' }; - if (inAppTutorialOrchestratorRef.current) { - inAppTutorialMessageInPreview = - inAppTutorialOrchestratorRef.current.getPreviewMessage() || - inAppTutorialMessageInPreview; - } - await previewLauncher.launchPreview({ - project: currentProject, - sceneName: sceneName || currentProject.getLayoutAt(0).getName(), - externalLayoutName: externalLayoutName || null, - eventsBasedObjectType: isForInGameEdition - ? isForInGameEdition.eventsBasedObjectType - : null, - eventsBasedObjectVariantName: isForInGameEdition - ? isForInGameEdition.eventsBasedObjectVariantName - : null, + previewWindows, + }); + + setPreviewLoading(null); + + if (!isForInGameEdition) + sendPreviewStarted({ + quickCustomizationGameId: + quickCustomizationDialogOpenedFromGameId || null, networkPreview: !!networkPreview, hotReload: !!hotReload, - shouldReloadProjectData: - shouldReloadProjectData === undefined - ? true - : shouldReloadProjectData, - shouldReloadLibraries: - shouldReloadLibraries === undefined ? true : shouldReloadLibraries, - shouldGenerateScenesEventsCode: + projectDataOnlyExport: shouldGenerateScenesEventsCode === undefined - ? true - : shouldGenerateScenesEventsCode, - shouldReloadResources: !!shouldReloadResources, - shouldHardReload: !!shouldHardReload, + ? false + : !shouldGenerateScenesEventsCode, fullLoadingScreen: !!fullLoadingScreen, - fallbackAuthor, - authenticatedPlayer, - getIsMenuBarHiddenInPreview: preferences.getIsMenuBarHiddenInPreview, - getIsAlwaysOnTopInPreview: preferences.getIsAlwaysOnTopInPreview, - numberOfWindows: numberOfWindows === undefined ? 1 : numberOfWindows, - isForInGameEdition: !!isForInGameEdition, - editorId: isForInGameEdition ? isForInGameEdition.editorId : '', - editorCameraState3D: isForInGameEdition - ? isForInGameEdition.editorCameraState3D - : null, - inGameEditorSettings: isForInGameEdition - ? inGameEditorSettings - : null, - inAppTutorialMessageInPreview: inAppTutorialMessageInPreview.message, - inAppTutorialMessagePositionInPreview: - inAppTutorialMessageInPreview.position, - captureOptions, - onCaptureFinished, - - previewWindows, + numberOfWindows: numberOfWindows || 1, + forceDiagnosticReport: !!forceDiagnosticReport, + previewLaunchDuration: Date.now() - startTime, }); - setPreviewLoading(null); - - if (!isForInGameEdition) - sendPreviewStarted({ - quickCustomizationGameId: - quickCustomizationDialogOpenedFromGameId || null, - networkPreview: !!networkPreview, - hotReload: !!hotReload, - projectDataOnlyExport: - shouldGenerateScenesEventsCode === undefined - ? false - : !shouldGenerateScenesEventsCode, - fullLoadingScreen: !!fullLoadingScreen, - numberOfWindows: numberOfWindows || 1, - forceDiagnosticReport: !!forceDiagnosticReport, - previewLaunchDuration: Date.now() - startTime, - }); - - if (inAppTutorialOrchestratorRef.current) { - inAppTutorialOrchestratorRef.current.onPreviewLaunch(); - } - if (!currentlyRunningInAppTutorial) { - const wholeProjectDiagnosticReport = currentProject.getWholeProjectDiagnosticReport(); - if ( - !isForInGameEdition && - (forceDiagnosticReport || - preferences.values.openDiagnosticReportAutomatically) && - wholeProjectDiagnosticReport.hasAnyIssue() - ) { - setDiagnosticReportDialogOpen(true); - } + if (inAppTutorialOrchestratorRef.current) { + inAppTutorialOrchestratorRef.current.onPreviewLaunch(); + } + if (!currentlyRunningInAppTutorial) { + const wholeProjectDiagnosticReport = currentProject.getWholeProjectDiagnosticReport(); + if ( + !isForInGameEdition && + (forceDiagnosticReport || + preferences.values.openDiagnosticReportAutomatically) && + wholeProjectDiagnosticReport.hasAnyIssue() + ) { + setDiagnosticReportDialogOpen(true); } - } catch (error) { - setPreviewLoading(null); - console.error( - 'Error caught while launching preview, this should never happen.', - error - ); } - }, - [ - currentProject, - state.editorTabs, - previewState.isPreviewOverriden, - previewState.overridenPreviewLayoutName, - previewState.previewLayoutName, - previewState.overridenPreviewExternalLayoutName, - previewState.previewExternalLayoutName, - autosaveProjectIfNeeded, - authenticatedUser.profile, - eventsFunctionsExtensionsState, - preferences.getIsMenuBarHiddenInPreview, - preferences.getIsAlwaysOnTopInPreview, - preferences.values.openDiagnosticReportAutomatically, - currentlyRunningInAppTutorial, - getAuthenticatedPlayerForPreview, - quickCustomizationDialogOpenedFromGameId, - onCaptureFinished, - createCaptureOptionsForPreview, - inGameEditorSettings, - previewLoadingRef, - setPreviewLoading, - checkDiagnosticErrorsAndIfShouldBlock, - ] - ); - - const launchPreview = addCreateBadgePreHookIfNotClaimed( - authenticatedUser, - TRIVIAL_FIRST_PREVIEW, - _launchPreview - ); - - const launchNewPreview = React.useCallback( - // $FlowFixMe[missing-local-annot] - async options => { - const launchCaptureOptions = - currentProject && !hasNonEditionPreviewsRunning - ? // TODO Rename it getPreviewLaunchCaptureOptions - getHotReloadPreviewLaunchCaptureOptions( - currentProject.getProjectUuid() - ) - : undefined; + } catch (error) { + setPreviewLoading(null); + console.error( + 'Error caught while launching preview, this should never happen.', + error + ); + } + }, + [ + currentProject, + state.editorTabs, + previewState.isPreviewOverriden, + previewState.overridenPreviewLayoutName, + previewState.previewLayoutName, + previewState.overridenPreviewExternalLayoutName, + previewState.previewExternalLayoutName, + autosaveProjectIfNeeded, + authenticatedUser.profile, + eventsFunctionsExtensionsState, + preferences.getIsMenuBarHiddenInPreview, + preferences.getIsAlwaysOnTopInPreview, + preferences.values.openDiagnosticReportAutomatically, + currentlyRunningInAppTutorial, + getAuthenticatedPlayerForPreview, + quickCustomizationDialogOpenedFromGameId, + onCaptureFinished, + createCaptureOptionsForPreview, + inGameEditorSettings, + previewLoadingRef, + setPreviewLoading, + checkDiagnosticErrorsAndIfShouldBlock, + ] +); - const numberOfWindows = options ? options.numberOfWindows : 1; - await launchPreview({ - networkPreview: false, - numberOfWindows, - launchCaptureOptions, - }); - }, - [ - currentProject, - launchPreview, - getHotReloadPreviewLaunchCaptureOptions, - hasNonEditionPreviewsRunning, - ] - ); +const launchPreview = addCreateBadgePreHookIfNotClaimed( + authenticatedUser, + TRIVIAL_FIRST_PREVIEW, + _launchPreview +); - const launchHotReloadPreview = React.useCallback( - async () => { - const launchCaptureOptions = currentProject - ? getHotReloadPreviewLaunchCaptureOptions( - currentProject.getProjectUuid() - ) +const launchNewPreview = React.useCallback( + // $FlowFixMe[missing-local-annot] + async options => { + const launchCaptureOptions = + currentProject && !hasNonEditionPreviewsRunning + ? // TODO Rename it getPreviewLaunchCaptureOptions + getHotReloadPreviewLaunchCaptureOptions( + currentProject.getProjectUuid() + ) : undefined; - await launchPreview({ - networkPreview: false, - hotReload: true, - launchCaptureOptions, - }); - }, - [currentProject, launchPreview, getHotReloadPreviewLaunchCaptureOptions] - ); - - const launchNetworkPreview = React.useCallback( - () => launchPreview({ networkPreview: true, hotReload: false }), - [launchPreview] - ); - const launchPreviewWithDiagnosticReport = React.useCallback( - () => launchPreview({ forceDiagnosticReport: true }), - [launchPreview] - ); - - const onLaunchPreviewForInGameEdition = React.useCallback( - async ({ + const numberOfWindows = options ? options.numberOfWindows : 1; + await launchPreview({ + networkPreview: false, + numberOfWindows, + launchCaptureOptions, + }); + }, + [ + currentProject, + launchPreview, + getHotReloadPreviewLaunchCaptureOptions, + hasNonEditionPreviewsRunning, + ] +); + +const launchHotReloadPreview = React.useCallback( + async () => { + const launchCaptureOptions = currentProject + ? getHotReloadPreviewLaunchCaptureOptions( + currentProject.getProjectUuid() + ) + : undefined; + await launchPreview({ + networkPreview: false, + hotReload: true, + launchCaptureOptions, + }); + }, + [currentProject, launchPreview, getHotReloadPreviewLaunchCaptureOptions] +); + +const launchNetworkPreview = React.useCallback( + () => launchPreview({ networkPreview: true, hotReload: false }), + [launchPreview] +); + +const launchPreviewWithDiagnosticReport = React.useCallback( + () => launchPreview({ forceDiagnosticReport: true }), + [launchPreview] +); + +const onLaunchPreviewForInGameEdition = React.useCallback( + async({ + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + shouldReloadProjectData, + shouldReloadLibraries, + shouldReloadResources, + shouldHardReload, + editorCameraState3D, + }: {| + ...PreviewInGameEditorTarget, + ...HotReloadSteps, + editorCameraState3D: EditorCameraState | null, + |}) => { + await _launchPreview({ + networkPreview: false, + hotReload: true, + shouldReloadProjectData, + shouldReloadLibraries, + shouldGenerateScenesEventsCode: false, + shouldReloadResources, + shouldHardReload, + forceDiagnosticReport: false, + isForInGameEdition: { editorId, - sceneName, - externalLayoutName, + forcedSceneName: sceneName, + forcedExternalLayoutName: externalLayoutName, eventsBasedObjectType, eventsBasedObjectVariantName, - shouldReloadProjectData, - shouldReloadLibraries, - shouldReloadResources, - shouldHardReload, editorCameraState3D, - }: {| - ...PreviewInGameEditorTarget, - ...HotReloadSteps, - editorCameraState3D: EditorCameraState | null, - |}) => { - await _launchPreview({ - networkPreview: false, - hotReload: true, - shouldReloadProjectData, - shouldReloadLibraries, - shouldGenerateScenesEventsCode: false, - shouldReloadResources, - shouldHardReload, - forceDiagnosticReport: false, - isForInGameEdition: { - editorId, - forcedSceneName: sceneName, - forcedExternalLayoutName: externalLayoutName, - eventsBasedObjectType, - eventsBasedObjectVariantName, - editorCameraState3D, - }, - numberOfWindows: 0, - }); }, - [_launchPreview] + numberOfWindows: 0, + }); +}, +[_launchPreview] ); - const relaunchAndThenHardReloadAllPreviews = React.useCallback( - async () => { - // Build a new preview (so that any changes in runtime files are picked up) - // and then ask all previews to "hard reload" themselves (i.e: refresh their page). - await launchPreview({ - networkPreview: false, - hotReload: false, - forceDiagnosticReport: false, - numberOfWindows: 0, - }); +const relaunchAndThenHardReloadAllPreviews = React.useCallback( + async () => { + // Build a new preview (so that any changes in runtime files are picked up) + // and then ask all previews to "hard reload" themselves (i.e: refresh their page). + await launchPreview({ + networkPreview: false, + hotReload: false, + forceDiagnosticReport: false, + numberOfWindows: 0, + }); - hardReloadAllPreviews(); - }, - [hardReloadAllPreviews, launchPreview] - ); + hardReloadAllPreviews(); + }, + [hardReloadAllPreviews, launchPreview] +); + +const launchQuickCustomizationPreview = React.useCallback( + () => + launchPreview({ + networkPreview: false, + launchCaptureOptions: { + screenshots: [ + { delayTimeInSeconds: 1000 }, // Take one quickly in case the user closes the preview too fast. + { delayTimeInSeconds: 5000 }, // Take another one after longer into the game. + ], + }, + hotReload: true, + shouldGenerateScenesEventsCode: false, + }), + [launchPreview] +); - const launchQuickCustomizationPreview = React.useCallback( - () => +const hotReloadPreviewButtonProps: HotReloadPreviewButtonProps = React.useMemo( + () => ({ + hasPreviewsRunning: hasNonEditionPreviewsRunning, + launchProjectWithLoadingScreenPreview: () => + launchPreview({ fullLoadingScreen: true }), + launchProjectDataOnlyPreview: () => launchPreview({ - networkPreview: false, - launchCaptureOptions: { - screenshots: [ - { delayTimeInSeconds: 1000 }, // Take one quickly in case the user closes the preview too fast. - { delayTimeInSeconds: 5000 }, // Take another one after longer into the game. - ], - }, hotReload: true, shouldGenerateScenesEventsCode: false, }), - [launchPreview] - ); - - const hotReloadPreviewButtonProps: HotReloadPreviewButtonProps = React.useMemo( - () => ({ - hasPreviewsRunning: hasNonEditionPreviewsRunning, - launchProjectWithLoadingScreenPreview: () => - launchPreview({ fullLoadingScreen: true }), - launchProjectDataOnlyPreview: () => - launchPreview({ - hotReload: true, - shouldGenerateScenesEventsCode: false, - }), - launchProjectCodeAndDataPreview: () => - launchPreview({ - hotReload: true, - shouldGenerateScenesEventsCode: true, - }), - }), - [hasNonEditionPreviewsRunning, launchPreview] - ); - - const getEditorsTabStateWithScene = React.useCallback( - ( - editorTabs: EditorTabsState, - name: string, - { - openEventsEditor, - openSceneEditor, - focusWhenOpened, - }: {| - openEventsEditor: boolean, - openSceneEditor: boolean, - focusWhenOpened: - | 'scene-or-events-otherwise' - | 'scene' - | 'events' - | 'none', + launchProjectCodeAndDataPreview: () => + launchPreview({ + hotReload: true, + shouldGenerateScenesEventsCode: true, + }), + }), + [hasNonEditionPreviewsRunning, launchPreview] +); + +const getEditorsTabStateWithScene = React.useCallback( + ( + editorTabs: EditorTabsState, + name: string, + { + openEventsEditor, + openSceneEditor, + focusWhenOpened, + }: {| + openEventsEditor: boolean, + openSceneEditor: boolean, + focusWhenOpened: + | 'scene-or-events-otherwise' + | 'scene' + | 'events' + | 'none', |} ): EditorTabsState => { - const sceneEditorOptions = getEditorOpeningOptions({ - kind: 'layout', - name, - dontFocusTab: !( - focusWhenOpened === 'scene' || - focusWhenOpened === 'scene-or-events-otherwise' - ), - }); - const eventsEditorOptions = getEditorOpeningOptions({ - kind: 'layout events', - name, - dontFocusTab: !( - focusWhenOpened === 'events' || - (focusWhenOpened === 'scene-or-events-otherwise' && !openSceneEditor) - ), - }); + const sceneEditorOptions = getEditorOpeningOptions({ + kind: 'layout', + name, + dontFocusTab: !( + focusWhenOpened === 'scene' || + focusWhenOpened === 'scene-or-events-otherwise' + ), + }); + const eventsEditorOptions = getEditorOpeningOptions({ + kind: 'layout events', + name, + dontFocusTab: !( + focusWhenOpened === 'events' || + (focusWhenOpened === 'scene-or-events-otherwise' && !openSceneEditor) + ), + }); - const tabsWithSceneEditor = openSceneEditor - ? // $FlowFixMe[incompatible-type] - openEditorTab(editorTabs, sceneEditorOptions) - : editorTabs; - return openEventsEditor - ? // $FlowFixMe[incompatible-type] - openEditorTab(tabsWithSceneEditor, eventsEditorOptions) - : tabsWithSceneEditor; - }, - [getEditorOpeningOptions] + const tabsWithSceneEditor = openSceneEditor + ? // $FlowFixMe[incompatible-type] + openEditorTab(editorTabs, sceneEditorOptions) + : editorTabs; + return openEventsEditor + ? // $FlowFixMe[incompatible-type] + openEditorTab(tabsWithSceneEditor, eventsEditorOptions) + : tabsWithSceneEditor; +}, + [getEditorOpeningOptions] ); - const openLayout = React.useCallback( - ( - name: string, - options?: {| - openEventsEditor: boolean, - openSceneEditor: boolean, - focusWhenOpened: - | 'scene-or-events-otherwise' - | 'scene' - | 'events' - | 'none', +const openLayout = React.useCallback( + ( + name: string, + options?: {| + openEventsEditor: boolean, + openSceneEditor: boolean, + focusWhenOpened: + | 'scene-or-events-otherwise' + | 'scene' + | 'events' + | 'none', |} = { - openEventsEditor: true, - openSceneEditor: true, - focusWhenOpened: 'scene', + openEventsEditor: true, + openSceneEditor: true, + focusWhenOpened: 'scene', }, - editorTabs?: EditorTabsState +editorTabs ?: EditorTabsState ): void => { - setState(state => ({ - ...state, - editorTabs: getEditorsTabStateWithScene( - editorTabs || state.editorTabs, - name, - { - openEventsEditor: options.openEventsEditor, - openSceneEditor: options.openSceneEditor, - focusWhenOpened: options.focusWhenOpened, - } - ), - })); - }, - [setState, getEditorsTabStateWithScene] - ); - - const openExternalEvents = React.useCallback( - (name: string) => { - setState(state => ({ - ...state, - editorTabs: openEditorTab( - state.editorTabs, - // $FlowFixMe[incompatible-type] - getEditorOpeningOptions({ kind: 'external events', name }) - ), - })); - }, - [setState, getEditorOpeningOptions] + setState(state => ({ + ...state, + editorTabs: getEditorsTabStateWithScene( + editorTabs || state.editorTabs, + name, + { + openEventsEditor: options.openEventsEditor, + openSceneEditor: options.openSceneEditor, + focusWhenOpened: options.focusWhenOpened, + } + ), + })); +}, +[setState, getEditorsTabStateWithScene] ); - const openExternalLayout = React.useCallback( - (name: string) => { - setState(state => ({ - ...state, - editorTabs: openEditorTab( - state.editorTabs, - // $FlowFixMe[incompatible-type] - getEditorOpeningOptions({ kind: 'external layout', name }) - ), - })); - }, - [setState, getEditorOpeningOptions] - ); +const openExternalEvents = React.useCallback( + (name: string) => { + setState(state => ({ + ...state, + editorTabs: openEditorTab( + state.editorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ kind: 'external events', name }) + ), + })); + }, + [setState, getEditorOpeningOptions] +); - const openEventsFunctionsExtension = React.useCallback( - ( - name: string, - initiallyFocusedFunctionName?: ?string, - initiallyFocusedBehaviorName?: ?string, - initiallyFocusedObjectName?: ?string - ) => { - setState(state => ({ - ...state, +const openExternalLayout = React.useCallback( + (name: string) => { + setState(state => ({ + ...state, + editorTabs: openEditorTab( + state.editorTabs, // $FlowFixMe[incompatible-type] - editorTabs: openEditorTab(state.editorTabs, { - ...getEditorOpeningOptions({ - kind: 'events functions extension', - name, - project: currentProject, - }), - extraEditorProps: { - initiallyFocusedFunctionName, - initiallyFocusedBehaviorName, - initiallyFocusedObjectName, - }, + getEditorOpeningOptions({ kind: 'external layout', name }) + ), + })); + }, + [setState, getEditorOpeningOptions] +); + +const openEventsFunctionsExtension = React.useCallback( + ( + name: string, + initiallyFocusedFunctionName?: ?string, + initiallyFocusedBehaviorName?: ?string, + initiallyFocusedObjectName?: ?string + ) => { + setState(state => ({ + ...state, + // $FlowFixMe[incompatible-type] + editorTabs: openEditorTab(state.editorTabs, { + ...getEditorOpeningOptions({ + kind: 'events functions extension', + name, + project: currentProject, }), - })); - }, - [currentProject, setState, getEditorOpeningOptions] - ); - - const openResources = React.useCallback( - () => { - setState(state => ({ - ...state, - editorTabs: openEditorTab( - state.editorTabs, - // $FlowFixMe[incompatible-type] - getEditorOpeningOptions({ kind: 'resources', name: '' }) - ), - })); - }, - [getEditorOpeningOptions, setState] - ); - - const openHomePage = React.useCallback( - () => { - setState(state => ({ - ...state, - editorTabs: openEditorTab( - state.editorTabs, - // $FlowFixMe[incompatible-type] - getEditorOpeningOptions({ kind: 'start page', name: '' }) - ), - })); - }, - [setState, getEditorOpeningOptions] - ); - - const closeDialogsToOpenHomePage = React.useCallback(() => { - setShareDialogOpen(false); - }, []); - - const openStandaloneDialog = React.useCallback( - () => { - setStandaloneDialogOpen(true); - }, - [setStandaloneDialogOpen] - ); - - const { navigateToRoute } = useHomePageSwitch({ - openHomePage, - closeDialogs: closeDialogsToOpenHomePage, - }); - - const _openDebugger = React.useCallback( - () => { - setState(state => ({ - ...state, - editorTabs: openEditorTab( - state.editorTabs, - // $FlowFixMe[incompatible-type] - getEditorOpeningOptions({ kind: 'debugger', name: '' }) - ), - })); - }, - [getEditorOpeningOptions, setState] - ); - - const openDebugger = addCreateBadgePreHookIfNotClaimed( - authenticatedUser, - TRIVIAL_FIRST_DEBUG, - _openDebugger - ); + extraEditorProps: { + initiallyFocusedFunctionName, + initiallyFocusedBehaviorName, + initiallyFocusedObjectName, + }, + }), + })); + }, + [currentProject, setState, getEditorOpeningOptions] +); - const launchDebuggerAndPreview = React.useCallback( - () => { - openDebugger(); - launchNewPreview(); - }, - [openDebugger, launchNewPreview] - ); +const openResources = React.useCallback( + () => { + setState(state => ({ + ...state, + editorTabs: openEditorTab( + state.editorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ kind: 'resources', name: '' }) + ), + })); + }, + [getEditorOpeningOptions, setState] +); - const openInstructionOrExpression = ( - extension: gdPlatformExtension, - type: string - ) => { - const { currentProject, editorTabs } = state; - if (!currentProject) return; +const openHomePage = React.useCallback( + () => { + setState(state => ({ + ...state, + editorTabs: openEditorTab( + state.editorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ kind: 'start page', name: '' }) + ), + })); + }, + [setState, getEditorOpeningOptions] +); + +const closeDialogsToOpenHomePage = React.useCallback(() => { + setShareDialogOpen(false); +}, []); + +const openStandaloneDialog = React.useCallback( + () => { + setStandaloneDialogOpen(true); + }, + [setStandaloneDialogOpen] +); + +const { navigateToRoute } = useHomePageSwitch({ + openHomePage, + closeDialogs: closeDialogsToOpenHomePage, +}); - const extensionName = extension.getName(); - if (currentProject.hasEventsFunctionsExtensionNamed(extensionName)) { - // It's an events functions extension, open the editor for it. - const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( - extensionName - ); - const functionName = getFunctionNameFromType(type); - const eventsBasedEntityName = functionName.behaviorName; +const _openDebugger = React.useCallback( + () => { + setState(state => ({ + ...state, + editorTabs: openEditorTab( + state.editorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ kind: 'debugger', name: '' }) + ), + })); + }, + [getEditorOpeningOptions, setState] +); - let eventBasedBehaviorName = null; - let eventBasedObjectName = null; - if (eventsBasedEntityName) { - if ( - eventsFunctionsExtension - .getEventsBasedBehaviors() - .has(eventsBasedEntityName) - ) { - eventBasedBehaviorName = eventsBasedEntityName; - } else if ( - eventsFunctionsExtension - .getEventsBasedObjects() - .has(eventsBasedEntityName) - ) { - eventBasedObjectName = eventsBasedEntityName; - } - } +const openDebugger = addCreateBadgePreHookIfNotClaimed( + authenticatedUser, + TRIVIAL_FIRST_DEBUG, + _openDebugger +); + +const launchDebuggerAndPreview = React.useCallback( + () => { + openDebugger(); + launchNewPreview(); + }, + [openDebugger, launchNewPreview] +); + +const openInstructionOrExpression = ( + extension: gdPlatformExtension, + type: string +) => { + const { currentProject, editorTabs } = state; + if (!currentProject) return; + + const extensionName = extension.getName(); + if (currentProject.hasEventsFunctionsExtensionNamed(extensionName)) { + // It's an events functions extension, open the editor for it. + const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( + extensionName + ); - const foundTab = getEventsFunctionsExtensionEditor( - editorTabs, - eventsFunctionsExtension - ); - if (foundTab) { - // Open the given function and focus the tab - foundTab.editor.selectEventsFunctionByName( - functionName.name, - eventBasedBehaviorName, - eventBasedObjectName - ); + const openCinematicSequence = React.useCallback( + (name: string) => { setState(state => ({ ...state, - editorTabs: changeCurrentTab( - editorTabs, - foundTab.paneIdentifier, - foundTab.tabIndex + editorTabs: openEditorTab( + state.editorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ kind: 'external layout', name }) ), })); - } else { - // Open a new editor for the extension and the given function - openEventsFunctionsExtension( - extensionName, - functionName.name, - eventBasedBehaviorName, - eventBasedObjectName - ); - } - } else { - // It's not an events functions extension, we should not be here. - console.warn( - `Extension with name=${extensionName} can not be opened (no editor for this)` - ); - } - }; - - const openCustomObjectEditor = React.useCallback( - ( - eventsFunctionsExtension: gdEventsFunctionsExtension, - eventsBasedObject: gdEventsBasedObject, - variantName: string - ) => { - const { currentProject, editorTabs } = state; - if (!currentProject) return; + }, + [setState, getEditorOpeningOptions] + ); - const foundTab = getCustomObjectEditor( - editorTabs, - eventsFunctionsExtension, - eventsBasedObject, - variantName - ); - if (foundTab) { - setState(state => ({ - ...state, - editorTabs: changeCurrentTab( - editorTabs, - foundTab.paneIdentifier, - foundTab.tabIndex - ), - })); - } else { - // Open a new editor for the extension and the given function + const openEventsFunctionsExtension = React.useCallback( + ( + name: string, + initiallyFocusedFunctionName?: ?string, + initiallyFocusedBehaviorName?: ?string, + initiallyFocusedObjectName?: ?string + ) => { setState(state => ({ ...state, // $FlowFixMe[incompatible-type] editorTabs: openEditorTab(state.editorTabs, { - ...getEditorOpeningOptions({ - kind: 'custom object', - name: - eventsFunctionsExtension.getName() + - '::' + - eventsBasedObject.getName() + - (eventsBasedObject.getVariants().hasVariantNamed(variantName) - ? '::' + variantName - : ''), - project: currentProject, - }), - }), - })); - } - }, - [getEditorOpeningOptions, setState, state] - ); - - const openCustomObjectAndExtensionEditors = React.useCallback( - ( - eventsFunctionsExtension: gdEventsFunctionsExtension, - eventsBasedObject: gdEventsBasedObject, - variantName: string - ) => { - const { currentProject } = state; - if (!currentProject) return; - - // Open both tabs at the same time to avoid the extension tab to trigger - // a code generation when it loses the focus. - setState(state => ({ - ...state, - editorTabs: openEditorTab( - // $FlowFixMe[incompatible-type] - openEditorTab(state.editorTabs, { ...getEditorOpeningOptions({ kind: 'events functions extension', - name: eventsFunctionsExtension.getName(), + name, project: currentProject, }), extraEditorProps: { - initiallyFocusedFunctionName: null, - initiallyFocusedBehaviorName: null, - initiallyFocusedObjectName: eventsBasedObject.getName(), + initiallyFocusedFunctionName, + initiallyFocusedBehaviorName, + initiallyFocusedObjectName, }, }), - // $FlowFixMe[incompatible-type] - { - ...getEditorOpeningOptions({ - kind: 'custom object', - name: - eventsFunctionsExtension.getName() + - '::' + - eventsBasedObject.getName() + - (eventsBasedObject.getVariants().hasVariantNamed(variantName) - ? '::' + variantName - : ''), - project: currentProject, - }), - } - ), - })); - }, - [getEditorOpeningOptions, setState, state] - ); - - const openObjectEvents = (extensionName: string, objectName: string) => { - const { currentProject, editorTabs } = state; - if (!currentProject) return; - - if (currentProject.hasEventsFunctionsExtensionNamed(extensionName)) { - // It's an events functions extension, open the editor for it. - const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( - extensionName - ); + })); + }, + [currentProject, setState, getEditorOpeningOptions] + ); - const foundTab = getEventsFunctionsExtensionEditor( - editorTabs, - eventsFunctionsExtension - ); - if (foundTab) { - // Open the given function and focus the tab - foundTab.editor.selectEventsBasedBehaviorByName(objectName); + const openResources = React.useCallback( + () => { setState(state => ({ ...state, - editorTabs: changeCurrentTab( - editorTabs, - foundTab.paneIdentifier, - foundTab.tabIndex + editorTabs: openEditorTab( + state.editorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ kind: 'resources', name: '' }) ), })); - } else { - // Open a new editor for the extension and the given function - openEventsFunctionsExtension(extensionName, null, null, objectName); - } - } else { - // It's not an events functions extension, we should not be here. - console.warn( - `Extension with name=${extensionName} can not be opened (no editor for this)` - ); - } - }; - - const openBehaviorEvents = (extensionName: string, behaviorName: string) => { - const { currentProject, editorTabs } = state; - if (!currentProject) return; - - if (currentProject.hasEventsFunctionsExtensionNamed(extensionName)) { - // It's an events functions extension, open the editor for it. - const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( - extensionName - ); + }, + [getEditorOpeningOptions, setState] + ); - const foundTab = getEventsFunctionsExtensionEditor( - editorTabs, - eventsFunctionsExtension - ); - if (foundTab) { - // Open the given function and focus the tab - foundTab.editor.selectEventsBasedBehaviorByName(behaviorName); + const openHomePage = React.useCallback( + () => { setState(state => ({ ...state, - editorTabs: changeCurrentTab( - editorTabs, - foundTab.paneIdentifier, - foundTab.tabIndex + editorTabs: openEditorTab( + state.editorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ kind: 'start page', name: '' }) ), })); - } else { - // Open a new editor for the extension and the given function - openEventsFunctionsExtension(extensionName, null, behaviorName, null); - } - } else { - // It's not an events functions extension, we should not be here. - console.warn( - `Extension with name=${extensionName} can not be opened (no editor for this)` - ); - } - }; - - const onExtractAsExternalLayout = React.useCallback( - (name: string) => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['extracted-instances-to-external-layout'], - }); - openExternalLayout(name); - }, - [notifyChangesToInGameEditor, openExternalLayout] - ); - - const _onReloadEventsFunctionsExtensionsAsync = React.useCallback( - async () => { - if (isProjectClosedSoAvoidReloadingExtensions) { - return; - } - await eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensions( - currentProject - ); - notifyChangesToInGameEditor({ - shouldReloadProjectData: false, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['reloaded-extensions'], - }); - }, - [ - isProjectClosedSoAvoidReloadingExtensions, - currentProject, - eventsFunctionsExtensionsState, - notifyChangesToInGameEditor, - ] - ); - - const onReloadEventsFunctionsExtensions = React.useCallback( - () => { - _onReloadEventsFunctionsExtensionsAsync(); - }, - [_onReloadEventsFunctionsExtensionsAsync] - ); - - // TODO Check why we don't always use `onReloadEventsFunctionsExtensions`. - /** - * It's the same as `onReloadEventsFunctionsExtensions` but extensions are - * not unloaded first. - */ - const onLoadEventsFunctionsExtensions = React.useCallback( - async ({ shouldHotReloadEditor }: {| shouldHotReloadEditor: boolean |}) => { - if (isProjectClosedSoAvoidReloadingExtensions) { - return; - } - await eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( - currentProject - ); - if (shouldHotReloadEditor) { - notifyChangesToInGameEditor({ - shouldReloadProjectData: false, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['loaded-extensions'], - }); - } - }, - [ - isProjectClosedSoAvoidReloadingExtensions, - currentProject, - eventsFunctionsExtensionsState, - notifyChangesToInGameEditor, - ] - ); - - const _onOpenEventBasedObjectEditorAsync = React.useCallback( - async (extensionName: string, eventsBasedObjectName: string) => { - if ( - !currentProject || - !currentProject.hasEventsFunctionsExtensionNamed(extensionName) - ) { - return; - } - const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( - extensionName - ); - const eventsBasedObjects = eventsFunctionsExtension.getEventsBasedObjects(); - if (!eventsBasedObjects.has(eventsBasedObjectName)) { - return; - } - const eventsBasedObject = eventsBasedObjects.get(eventsBasedObjectName); + }, + [setState, getEditorOpeningOptions] + ); - // Trigger reloading of extensions as an extension was modified (or even added) - // to create the custom object. - await eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( - currentProject - ); - setEditorHotReloadNeeded({ - shouldReloadProjectData: false, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['opened-custom-object-editor'], - }); + const closeDialogsToOpenHomePage = React.useCallback(() => { + setShareDialogOpen(false); + }, []); - openCustomObjectAndExtensionEditors( - eventsFunctionsExtension, - eventsBasedObject, - '' - ); - }, - [ - currentProject, - openCustomObjectAndExtensionEditors, - eventsFunctionsExtensionsState, - ] - ); + const openStandaloneDialog = React.useCallback( + () => { + setStandaloneDialogOpen(true); + }, + [setStandaloneDialogOpen] + ); - const onOpenEventBasedObjectEditor = React.useCallback( - (extensionName: string, eventsBasedObjectName: string) => { - _onOpenEventBasedObjectEditorAsync(extensionName, eventsBasedObjectName); - }, - [_onOpenEventBasedObjectEditorAsync] - ); + const { navigateToRoute } = useHomePageSwitch({ + openHomePage, + closeDialogs: closeDialogsToOpenHomePage, + }); - const onEventBasedObjectTypeChanged = React.useCallback( - () => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['changed-custom-object-type'], - }); - }, - [notifyChangesToInGameEditor] - ); + const _openDebugger = React.useCallback( + () => { + setState(state => ({ + ...state, + editorTabs: openEditorTab( + state.editorTabs, + // $FlowFixMe[incompatible-type] + getEditorOpeningOptions({ kind: 'debugger', name: '' }) + ), + })); + }, + [getEditorOpeningOptions, setState] + ); - const _onExtractAsEventBasedObjectAsync = React.useCallback( - async (extensionName: string, eventsBasedObjectName: string) => { - // This method already trigger an hot-reload of the libraries after - // generation extension code. - await _onOpenEventBasedObjectEditorAsync( - extensionName, - eventsBasedObjectName - ); - }, - [_onOpenEventBasedObjectEditorAsync] - ); + const openDebugger = addCreateBadgePreHookIfNotClaimed( + authenticatedUser, + TRIVIAL_FIRST_DEBUG, + _openDebugger + ); - const onExtractAsEventBasedObject = React.useCallback( - (extensionName: string, eventsBasedObjectName: string) => { - _onExtractAsEventBasedObjectAsync(extensionName, eventsBasedObjectName); - }, - [_onExtractAsEventBasedObjectAsync] - ); + const launchDebuggerAndPreview = React.useCallback( + () => { + openDebugger(); + launchNewPreview(); + }, + [openDebugger, launchNewPreview] + ); - const _onOpenEventBasedObjectVariantEditorAsync = React.useCallback( - async ( - extensionName: string, - eventsBasedObjectName: string, - variantName: string + const openInstructionOrExpression = ( + extension: gdPlatformExtension, + type: string ) => { + const { currentProject, editorTabs } = state; if (!currentProject) return; - if (!currentProject.hasEventsFunctionsExtensionNamed(extensionName)) { - return; - } - const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( - extensionName - ); - const eventsBasedObjects = eventsFunctionsExtension.getEventsBasedObjects(); - if (!eventsBasedObjects.has(eventsBasedObjectName)) { - return; - } - const eventsBasedObject = eventsBasedObjects.get(eventsBasedObjectName); - - // Trigger reloading of extensions as an extension was modified (or even added) - // to create the custom object. - await eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( - currentProject - ); - setEditorHotReloadNeeded({ - shouldReloadProjectData: false, - shouldReloadLibraries: true, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['opened-custom-object-variant-editor'], - }); - openCustomObjectEditor( - eventsFunctionsExtension, - eventsBasedObject, - variantName - ); - }, - [currentProject, openCustomObjectEditor, eventsFunctionsExtensionsState] - ); - const onOpenEventBasedObjectVariantEditor = React.useCallback( - ( - extensionName: string, - eventsBasedObjectName: string, - variantName: string - ) => { - _onOpenEventBasedObjectVariantEditorAsync( - extensionName, - eventsBasedObjectName, - variantName - ); - }, - [_onOpenEventBasedObjectVariantEditorAsync] - ); + const extensionName = extension.getName(); + if (currentProject.hasEventsFunctionsExtensionNamed(extensionName)) { + // It's an events functions extension, open the editor for it. + const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( + extensionName + ); + const functionName = getFunctionNameFromType(type); + const eventsBasedEntityName = functionName.behaviorName; - const onEventsBasedObjectChildrenEdited = React.useCallback( - (eventsBasedObject: gdEventsBasedObject) => { - const project = state.currentProject; - if (!project) { - return; - } - gd.EventsBasedObjectVariantHelper.complyVariantsToEventsBasedObject( - project, - eventsBasedObject - ); + let eventBasedBehaviorName = null; + let eventBasedObjectName = null; + if (eventsBasedEntityName) { + if ( + eventsFunctionsExtension + .getEventsBasedBehaviors() + .has(eventsBasedEntityName) + ) { + eventBasedBehaviorName = eventsBasedEntityName; + } else if ( + eventsFunctionsExtension + .getEventsBasedObjects() + .has(eventsBasedEntityName) + ) { + eventBasedObjectName = eventsBasedEntityName; + } + } - for (const editor of getAllEditorTabs(state.editorTabs)) { - const { editorRef } = editor; - if (editorRef) { - editorRef.onEventsBasedObjectChildrenEdited(); + const foundTab = getEventsFunctionsExtensionEditor( + editorTabs, + eventsFunctionsExtension + ); + if (foundTab) { + // Open the given function and focus the tab + foundTab.editor.selectEventsFunctionByName( + functionName.name, + eventBasedBehaviorName, + eventBasedObjectName + ); + setState(state => ({ + ...state, + editorTabs: changeCurrentTab( + editorTabs, + foundTab.paneIdentifier, + foundTab.tabIndex + ), + })); + } else { + // Open a new editor for the extension and the given function + openEventsFunctionsExtension( + extensionName, + functionName.name, + eventBasedBehaviorName, + eventBasedObjectName + ); } + } else { + // It's not an events functions extension, we should not be here. + console.warn( + `Extension with name=${extensionName} can not be opened (no editor for this)` + ); } - }, - [state.editorTabs, state.currentProject] - ); + }; + + const openCustomObjectEditor = React.useCallback( + ( + eventsFunctionsExtension: gdEventsFunctionsExtension, + eventsBasedObject: gdEventsBasedObject, + variantName: string + ) => { + const { currentProject, editorTabs } = state; + if (!currentProject) return; + + const foundTab = getCustomObjectEditor( + editorTabs, + eventsFunctionsExtension, + eventsBasedObject, + variantName + ); + if (foundTab) { + setState(state => ({ + ...state, + editorTabs: changeCurrentTab( + editorTabs, + foundTab.paneIdentifier, + foundTab.tabIndex + ), + })); + } else { + // Open a new editor for the extension and the given function + setState(state => ({ + ...state, + // $FlowFixMe[incompatible-type] + editorTabs: openEditorTab(state.editorTabs, { + ...getEditorOpeningOptions({ + kind: 'custom object', + name: + eventsFunctionsExtension.getName() + + '::' + + eventsBasedObject.getName() + + (eventsBasedObject.getVariants().hasVariantNamed(variantName) + ? '::' + variantName + : ''), + project: currentProject, + }), + }), + })); + } + }, + [getEditorOpeningOptions, setState, state] + ); + + const openCustomObjectAndExtensionEditors = React.useCallback( + ( + eventsFunctionsExtension: gdEventsFunctionsExtension, + eventsBasedObject: gdEventsBasedObject, + variantName: string + ) => { + const { currentProject } = state; + if (!currentProject) return; + + // Open both tabs at the same time to avoid the extension tab to trigger + // a code generation when it loses the focus. + setState(state => ({ + ...state, + editorTabs: openEditorTab( + // $FlowFixMe[incompatible-type] + openEditorTab(state.editorTabs, { + ...getEditorOpeningOptions({ + kind: 'events functions extension', + name: eventsFunctionsExtension.getName(), + project: currentProject, + }), + extraEditorProps: { + initiallyFocusedFunctionName: null, + initiallyFocusedBehaviorName: null, + initiallyFocusedObjectName: eventsBasedObject.getName(), + }, + }), + // $FlowFixMe[incompatible-type] + { + ...getEditorOpeningOptions({ + kind: 'custom object', + name: + eventsFunctionsExtension.getName() + + '::' + + eventsBasedObject.getName() + + (eventsBasedObject.getVariants().hasVariantNamed(variantName) + ? '::' + variantName + : ''), + project: currentProject, + }), + } + ), + })); + }, + [getEditorOpeningOptions, setState, state] + ); + + const openObjectEvents = (extensionName: string, objectName: string) => { + const { currentProject, editorTabs } = state; + if (!currentProject) return; - const onSceneObjectEdited = React.useCallback( - (scene: gdLayout, objectWithContext: ObjectWithContext) => { - for (const editor of getAllEditorTabs(state.editorTabs)) { - const { editorRef } = editor; - if (editorRef) { - editorRef.onSceneObjectEdited(scene, objectWithContext); - } - } - }, - [state.editorTabs] - ); + if (currentProject.hasEventsFunctionsExtensionNamed(extensionName)) { + // It's an events functions extension, open the editor for it. + const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( + extensionName + ); - const onSceneObjectsDeleted = React.useCallback( - (scene: gdLayout) => { - for (const editor of getAllEditorTabs(state.editorTabs)) { - const { editorRef } = editor; - if (editorRef) { - editorRef.onSceneObjectsDeleted(scene); + const foundTab = getEventsFunctionsExtensionEditor( + editorTabs, + eventsFunctionsExtension + ); + if (foundTab) { + // Open the given function and focus the tab + foundTab.editor.selectEventsBasedBehaviorByName(objectName); + setState(state => ({ + ...state, + editorTabs: changeCurrentTab( + editorTabs, + foundTab.paneIdentifier, + foundTab.tabIndex + ), + })); + } else { + // Open a new editor for the extension and the given function + openEventsFunctionsExtension(extensionName, null, null, objectName); } + } else { + // It's not an events functions extension, we should not be here. + console.warn( + `Extension with name=${extensionName} can not be opened (no editor for this)` + ); } - }, - [state.editorTabs] - ); + }; - const onSceneEventsModifiedOutsideEditor = React.useCallback( - (changes: SceneEventsOutsideEditorChanges) => { - for (const editor of getAllEditorTabs(state.editorTabs)) { - const { editorRef } = editor; - if (editorRef) { - editorRef.onSceneEventsModifiedOutsideEditor(changes); - } - } - }, - [state.editorTabs] - ); + const openBehaviorEvents = (extensionName: string, behaviorName: string) => { + const { currentProject, editorTabs } = state; + if (!currentProject) return; - const onInstancesModifiedOutsideEditor = React.useCallback( - (changes: InstancesOutsideEditorChanges) => { - for (const editor of getAllEditorTabs(state.editorTabs)) { - const { editorRef } = editor; - if (editorRef) { - editorRef.onInstancesModifiedOutsideEditor(changes); - } - } - }, - [state.editorTabs] - ); + if (currentProject.hasEventsFunctionsExtensionNamed(extensionName)) { + // It's an events functions extension, open the editor for it. + const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( + extensionName + ); - const onObjectsModifiedOutsideEditor = React.useCallback( - (changes: ObjectsOutsideEditorChanges) => { - for (const editor of getAllEditorTabs(state.editorTabs)) { - const { editorRef } = editor; - if (editorRef) { - editorRef.onObjectsModifiedOutsideEditor(changes); + const foundTab = getEventsFunctionsExtensionEditor( + editorTabs, + eventsFunctionsExtension + ); + if (foundTab) { + // Open the given function and focus the tab + foundTab.editor.selectEventsBasedBehaviorByName(behaviorName); + setState(state => ({ + ...state, + editorTabs: changeCurrentTab( + editorTabs, + foundTab.paneIdentifier, + foundTab.tabIndex + ), + })); + } else { + // Open a new editor for the extension and the given function + openEventsFunctionsExtension(extensionName, null, behaviorName, null); } + } else { + // It's not an events functions extension, we should not be here. + console.warn( + `Extension with name=${extensionName} can not be opened (no editor for this)` + ); } - onObjectListsModified({ - isNewObjectTypeUsed: changes.isNewObjectTypeUsed, - }); - }, - [state.editorTabs, onObjectListsModified] - ); + }; - const onObjectGroupsModifiedOutsideEditor = React.useCallback( - (changes: ObjectGroupsOutsideEditorChanges) => { - for (const editor of getAllEditorTabs(state.editorTabs)) { - const { editorRef } = editor; - if (editorRef) { - editorRef.onObjectGroupsModifiedOutsideEditor(changes); + const onExtractAsExternalLayout = React.useCallback( + (name: string) => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['extracted-instances-to-external-layout'], + }); + openExternalLayout(name); + + openCinematicSequence(name); + }, + [notifyChangesToInGameEditor, openExternalLayout] + + [notifyChangesToInGameEditor, openCinematicSequence] + ); + + const _onReloadEventsFunctionsExtensionsAsync = React.useCallback( + async () => { + if (isProjectClosedSoAvoidReloadingExtensions) { + return; } - } - }, - [state.editorTabs] + await eventsFunctionsExtensionsState.reloadProjectEventsFunctionsExtensions( + currentProject + ); + notifyChangesToInGameEditor({ + shouldReloadProjectData: false, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['reloaded-extensions'], + }); + }, + [ + isProjectClosedSoAvoidReloadingExtensions, + currentProject, + eventsFunctionsExtensionsState, + notifyChangesToInGameEditor, + ] + ); + + const onReloadEventsFunctionsExtensions = React.useCallback( + () => { + _onReloadEventsFunctionsExtensionsAsync(); + }, + [_onReloadEventsFunctionsExtensionsAsync] + ); + + // TODO Check why we don't always use `onReloadEventsFunctionsExtensions`. + /** + * It's the same as `onReloadEventsFunctionsExtensions` but extensions are + * not unloaded first. + */ + const onLoadEventsFunctionsExtensions = React.useCallback( + async({ shouldHotReloadEditor }: {| shouldHotReloadEditor: boolean |}) => { + if (isProjectClosedSoAvoidReloadingExtensions) { + return; + } + await eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( + currentProject + ); + if (shouldHotReloadEditor) { + notifyChangesToInGameEditor({ + shouldReloadProjectData: false, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['loaded-extensions'], + }); + } +}, +[ + isProjectClosedSoAvoidReloadingExtensions, + currentProject, + eventsFunctionsExtensionsState, + notifyChangesToInGameEditor, +] ); - const _onProjectItemModified = () => { - triggerUnsavedChanges(); - forceUpdate(); - }; +const _onOpenEventBasedObjectEditorAsync = React.useCallback( + async (extensionName: string, eventsBasedObjectName: string) => { + if ( + !currentProject || + !currentProject.hasEventsFunctionsExtensionNamed(extensionName) + ) { + return; + } + const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( + extensionName + ); + const eventsBasedObjects = eventsFunctionsExtension.getEventsBasedObjects(); + if (!eventsBasedObjects.has(eventsBasedObjectName)) { + return; + } + const eventsBasedObject = eventsBasedObjects.get(eventsBasedObjectName); + + // Trigger reloading of extensions as an extension was modified (or even added) + // to create the custom object. + await eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( + currentProject + ); + setEditorHotReloadNeeded({ + shouldReloadProjectData: false, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['opened-custom-object-editor'], + }); - const onCreateEventsFunction = ( + openCustomObjectAndExtensionEditors( + eventsFunctionsExtension, + eventsBasedObject, + '' + ); + }, + [ + currentProject, + openCustomObjectAndExtensionEditors, + eventsFunctionsExtensionsState, + ] +); + +const onOpenEventBasedObjectEditor = React.useCallback( + (extensionName: string, eventsBasedObjectName: string) => { + _onOpenEventBasedObjectEditorAsync(extensionName, eventsBasedObjectName); + }, + [_onOpenEventBasedObjectEditorAsync] +); + +const onEventBasedObjectTypeChanged = React.useCallback( + () => { + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['changed-custom-object-type'], + }); + }, + [notifyChangesToInGameEditor] +); + +const _onExtractAsEventBasedObjectAsync = React.useCallback( + async (extensionName: string, eventsBasedObjectName: string) => { + // This method already trigger an hot-reload of the libraries after + // generation extension code. + await _onOpenEventBasedObjectEditorAsync( + extensionName, + eventsBasedObjectName + ); + }, + [_onOpenEventBasedObjectEditorAsync] +); + +const onExtractAsEventBasedObject = React.useCallback( + (extensionName: string, eventsBasedObjectName: string) => { + _onExtractAsEventBasedObjectAsync(extensionName, eventsBasedObjectName); + }, + [_onExtractAsEventBasedObjectAsync] +); + +const _onOpenEventBasedObjectVariantEditorAsync = React.useCallback( + async ( extensionName: string, - eventsFunction: gdEventsFunction, - editorIdentifier: - | 'scene-events-editor' - | 'extension-events-editor' - | 'external-events-editor' + eventsBasedObjectName: string, + variantName: string ) => { - const { currentProject } = state; if (!currentProject) return; - - sendEventsExtractedAsFunction({ - step: 'end', - parentEditor: editorIdentifier, - }); - - // Names are assumed to be already validated - const createNewExtension = !currentProject.hasEventsFunctionsExtensionNamed( + if (!currentProject.hasEventsFunctionsExtensionNamed(extensionName)) { + return; + } + const eventsFunctionsExtension = currentProject.getEventsFunctionsExtension( extensionName ); - const extension = createNewExtension - ? currentProject.insertNewEventsFunctionsExtension(extensionName, 0) - : currentProject.getEventsFunctionsExtension(extensionName); - - if (createNewExtension) { - extension.setFullName(extensionName); - extension.setDescription( - 'Originally automatically extracted from events of the project' - ); + const eventsBasedObjects = eventsFunctionsExtension.getEventsBasedObjects(); + if (!eventsBasedObjects.has(eventsBasedObjectName)) { + return; } + const eventsBasedObject = eventsBasedObjects.get(eventsBasedObjectName); - extension.getEventsFunctions().insertEventsFunction(eventsFunction, 0); - eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( + // Trigger reloading of extensions as an extension was modified (or even added) + // to create the custom object. + await eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( currentProject ); setEditorHotReloadNeeded({ @@ -3426,230 +3391,300 @@ const MainFrame = (props: Props): React.MixedElement => { shouldReloadLibraries: true, shouldReloadResources: false, shouldHardReload: false, - reasons: ['created-events-function'], + reasons: ['opened-custom-object-variant-editor'], }); - }; - - const openOpenFromStorageProviderDialog = React.useCallback( - (open: boolean = true) => { - setState(state => ({ - ...state, - openFromStorageProviderDialogOpen: open, - })); - }, - [setState] - ); + openCustomObjectEditor( + eventsFunctionsExtension, + eventsBasedObject, + variantName + ); + }, + [currentProject, openCustomObjectEditor, eventsFunctionsExtensionsState] +); - // When opening a project, we always open a scene to avoid confusing the user. - // If it has no scene (new project), we create one and open it. - // If it has one scene, we open it. - // If it has more than one scene, we open the first one and we also open the project manager. - const openSceneOrProjectManager = React.useCallback( - (newState: {| - currentProject: ?gdProject, - editorTabs: EditorTabsState, - |}) => { - const { currentProject, editorTabs } = newState; - if (!currentProject) return; +const onOpenEventBasedObjectVariantEditor = React.useCallback( + ( + extensionName: string, + eventsBasedObjectName: string, + variantName: string + ) => { + _onOpenEventBasedObjectVariantEditorAsync( + extensionName, + eventsBasedObjectName, + variantName + ); + }, + [_onOpenEventBasedObjectVariantEditorAsync] +); + +const onEventsBasedObjectChildrenEdited = React.useCallback( + (eventsBasedObject: gdEventsBasedObject) => { + const project = state.currentProject; + if (!project) { + return; + } + gd.EventsBasedObjectVariantHelper.complyVariantsToEventsBasedObject( + project, + eventsBasedObject + ); - if (currentProject.getLayoutsCount() === 0) { - const layoutName = i18n._(t`Untitled scene`); - currentProject.insertNewLayout(layoutName, 0); - const layout = currentProject.getLayout(layoutName); - addDefaultLightToAllLayers(layout); + for (const editor of getAllEditorTabs(state.editorTabs)) { + const { editorRef } = editor; + if (editorRef) { + editorRef.onEventsBasedObjectChildrenEdited(); } - openLayout( - currentProject.getLayoutAt(0).getName(), - { - openSceneEditor: true, - openEventsEditor: true, - focusWhenOpened: 'scene', - }, - editorTabs - ); - setIsLoadingProject(false); - setLoaderModalProgress(null, null); - - if (currentProject.getLayoutsCount() > 1) { - openProjectManager(true); - } else { - openProjectManager(false); + } + }, + [state.editorTabs, state.currentProject] +); + +const onSceneObjectEdited = React.useCallback( + (scene: gdLayout, objectWithContext: ObjectWithContext) => { + for (const editor of getAllEditorTabs(state.editorTabs)) { + const { editorRef } = editor; + if (editorRef) { + editorRef.onSceneObjectEdited(scene, objectWithContext); } - }, - [openLayout, i18n] + } + }, + [state.editorTabs] +); + +const onSceneObjectsDeleted = React.useCallback( + (scene: gdLayout) => { + for (const editor of getAllEditorTabs(state.editorTabs)) { + const { editorRef } = editor; + if (editorRef) { + editorRef.onSceneObjectsDeleted(scene); + } + } + }, + [state.editorTabs] +); + +const onSceneEventsModifiedOutsideEditor = React.useCallback( + (changes: SceneEventsOutsideEditorChanges) => { + for (const editor of getAllEditorTabs(state.editorTabs)) { + const { editorRef } = editor; + if (editorRef) { + editorRef.onSceneEventsModifiedOutsideEditor(changes); + } + } + }, + [state.editorTabs] +); + +const onInstancesModifiedOutsideEditor = React.useCallback( + (changes: InstancesOutsideEditorChanges) => { + for (const editor of getAllEditorTabs(state.editorTabs)) { + const { editorRef } = editor; + if (editorRef) { + editorRef.onInstancesModifiedOutsideEditor(changes); + } + } + }, + [state.editorTabs] +); + +const onObjectsModifiedOutsideEditor = React.useCallback( + (changes: ObjectsOutsideEditorChanges) => { + for (const editor of getAllEditorTabs(state.editorTabs)) { + const { editorRef } = editor; + if (editorRef) { + editorRef.onObjectsModifiedOutsideEditor(changes); + } + } + onObjectListsModified({ + isNewObjectTypeUsed: changes.isNewObjectTypeUsed, + }); + }, + [state.editorTabs, onObjectListsModified] +); + +const onObjectGroupsModifiedOutsideEditor = React.useCallback( + (changes: ObjectGroupsOutsideEditorChanges) => { + for (const editor of getAllEditorTabs(state.editorTabs)) { + const { editorRef } = editor; + if (editorRef) { + editorRef.onObjectGroupsModifiedOutsideEditor(changes); + } + } + }, + [state.editorTabs] +); + +const _onProjectItemModified = () => { + triggerUnsavedChanges(); + forceUpdate(); +}; + +const onCreateEventsFunction = ( + extensionName: string, + eventsFunction: gdEventsFunction, + editorIdentifier: + | 'scene-events-editor' + | 'extension-events-editor' + | 'external-events-editor' +) => { + const { currentProject } = state; + if (!currentProject) return; + + sendEventsExtractedAsFunction({ + step: 'end', + parentEditor: editorIdentifier, + }); + + // Names are assumed to be already validated + const createNewExtension = !currentProject.hasEventsFunctionsExtensionNamed( + extensionName ); + const extension = createNewExtension + ? currentProject.insertNewEventsFunctionsExtension(extensionName, 0) + : currentProject.getEventsFunctionsExtension(extensionName); + + if (createNewExtension) { + extension.setFullName(extensionName); + extension.setDescription( + 'Originally automatically extracted from events of the project' + ); + } - const getEditorsTabStateWithAllScenes = React.useCallback( - (newState: {| - currentProject: ?gdProject, - editorTabs: EditorTabsState, - |}): EditorTabsState => { - const { currentProject, editorTabs } = newState; - if (!currentProject) return editorTabs; - const layoutsCount = currentProject.getLayoutsCount(); - if (layoutsCount === 0) return editorTabs; - - let editorTabsWithAllScenes = editorTabs; - for (let layoutIndex = 0; layoutIndex < layoutsCount; layoutIndex++) { - editorTabsWithAllScenes = getEditorsTabStateWithScene( - editorTabsWithAllScenes, - currentProject.getLayoutAt(layoutIndex).getName(), - { - openSceneEditor: true, - openEventsEditor: true, - focusWhenOpened: 'scene', - } - ); - } - return editorTabsWithAllScenes; - }, - [getEditorsTabStateWithScene] + extension.getEventsFunctions().insertEventsFunction(eventsFunction, 0); + eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions( + currentProject ); + setEditorHotReloadNeeded({ + shouldReloadProjectData: false, + shouldReloadLibraries: true, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['created-events-function'], + }); +}; - const openAllScenes = React.useCallback( - (newState: {| - currentProject: ?gdProject, - editorTabs: EditorTabsState, +const openOpenFromStorageProviderDialog = React.useCallback( + (open: boolean = true) => { + setState(state => ({ + ...state, + openFromStorageProviderDialogOpen: open, + })); + }, + [setState] +); + +// When opening a project, we always open a scene to avoid confusing the user. +// If it has no scene (new project), we create one and open it. +// If it has one scene, we open it. +// If it has more than one scene, we open the first one and we also open the project manager. +const openSceneOrProjectManager = React.useCallback( + (newState: {| + currentProject: ?gdProject, + editorTabs: EditorTabsState, |}) => { - const { currentProject } = newState; - if (!currentProject) return; - const layoutsCount = currentProject.getLayoutsCount(); - if (layoutsCount === 0) return; + const { currentProject, editorTabs } = newState; + if (!currentProject) return; + + if (currentProject.getLayoutsCount() === 0) { + const layoutName = i18n._(t`Untitled scene`); + currentProject.insertNewLayout(layoutName, 0); + const layout = currentProject.getLayout(layoutName); + addDefaultLightToAllLayers(layout); + } + openLayout( + currentProject.getLayoutAt(0).getName(), + { + openSceneEditor: true, + openEventsEditor: true, + focusWhenOpened: 'scene', + }, + editorTabs + ); + setIsLoadingProject(false); + setLoaderModalProgress(null, null); - setState(state => ({ - ...state, - editorTabs: getEditorsTabStateWithAllScenes(newState), - })); + if (currentProject.getLayoutsCount() > 1) { + openProjectManager(true); + } else { + openProjectManager(false); + } +}, +[openLayout, i18n] + ); - // Re-open first layout as this is usually the entry point for a game. - const firstLayout = - currentProject.getFirstLayout() || // First layout can be empty - currentProject.getLayoutAt(0).getName(); - openLayout(firstLayout, { +const getEditorsTabStateWithAllScenes = React.useCallback( + (newState: {| + currentProject: ?gdProject, + editorTabs: EditorTabsState, + |}): EditorTabsState => { + const { currentProject, editorTabs } = newState; + if (!currentProject) return editorTabs; + const layoutsCount = currentProject.getLayoutsCount(); + if (layoutsCount === 0) return editorTabs; + + let editorTabsWithAllScenes = editorTabs; + for (let layoutIndex = 0; layoutIndex < layoutsCount; layoutIndex++) { + editorTabsWithAllScenes = getEditorsTabStateWithScene( + editorTabsWithAllScenes, + currentProject.getLayoutAt(layoutIndex).getName(), + { openSceneEditor: true, openEventsEditor: true, focusWhenOpened: 'scene', - }); - - setIsLoadingProject(false); - setLoaderModalProgress(null, null); - openProjectManager(false); - }, - [getEditorsTabStateWithAllScenes, setState, openLayout] + } + ); + } + return editorTabsWithAllScenes; +}, + [getEditorsTabStateWithScene] ); - const chooseProjectWithStorageProviderPicker = React.useCallback( - () => { - const storageProviderOperations = getStorageProviderOperations(); - - if (!storageProviderOperations.onOpenWithPicker) return; - - return storageProviderOperations - .onOpenWithPicker() - .then(fileMetadata => { - if (!fileMetadata) return; +const openAllScenes = React.useCallback( + (newState: {| + currentProject: ?gdProject, + editorTabs: EditorTabsState, + |}) => { + const { currentProject } = newState; + if (!currentProject) return; + const layoutsCount = currentProject.getLayoutsCount(); + if (layoutsCount === 0) return; + + setState(state => ({ + ...state, + editorTabs: getEditorsTabStateWithAllScenes(newState), + })); + + // Re-open first layout as this is usually the entry point for a game. + const firstLayout = + currentProject.getFirstLayout() || // First layout can be empty + currentProject.getLayoutAt(0).getName(); + openLayout(firstLayout, { + openSceneEditor: true, + openEventsEditor: true, + focusWhenOpened: 'scene', + }); - return openFromFileMetadata(fileMetadata).then(state => { - if (state) { - const { currentProject } = state; - if ( - currentProject && - hasAPreviousSaveForEditorTabsState(currentProject) - ) { - const openedEditorsCount = openEditorTabsFromPersistedState( - currentProject - ); - if (openedEditorsCount === 0) { - openSceneOrProjectManager({ - currentProject: currentProject, - editorTabs: state.editorTabs, - }); - } else { - setIsLoadingProject(false); - setLoaderModalProgress(null, null); - openProjectManager(false); - } - } else { - openSceneOrProjectManager({ - currentProject: currentProject, - editorTabs: state.editorTabs, - }); - } - const currentStorageProvider = getStorageProvider(); - if (currentStorageProvider.internalName === 'LocalFile') { - setHasProjectOpened(true); - } - } - }); - }) - .catch(error => { - const errorMessage = storageProviderOperations.getOpenErrorMessage - ? storageProviderOperations.getOpenErrorMessage(error) - : t`Verify that you have the authorization for reading the file you're trying to access.`; - showErrorBox({ - message: [ - i18n._(t`Unable to open the project.`), - i18n._(errorMessage), - ].join('\n'), - errorId: 'project-open-with-picker-error', - rawError: error, - }); - }); - }, - [ - i18n, - hasAPreviousSaveForEditorTabsState, - openEditorTabsFromPersistedState, - getStorageProviderOperations, - openFromFileMetadata, - openSceneOrProjectManager, - getStorageProvider, - setHasProjectOpened, - ] + setIsLoadingProject(false); + setLoaderModalProgress(null, null); + openProjectManager(false); +}, +[getEditorsTabStateWithAllScenes, setState, openLayout] ); - const openFromFileMetadataWithStorageProvider = React.useCallback( - async ( - fileMetadataAndStorageProviderName: FileMetadataAndStorageProviderName, - options: ?{| - openAllScenes?: boolean, - ignoreUnsavedChanges?: boolean, - ignoreAutoSave?: boolean, - openingMessage?: ?MessageDescriptor, - |} - ): Promise => { - if (hasUnsavedChanges && !(options && options.ignoreUnsavedChanges)) { - const answer = Window.showConfirmDialog( - i18n._( - t`Open a new project? Any changes that have not been saved will be lost.` - ) - ); - if (!answer) return; - } +const chooseProjectWithStorageProviderPicker = React.useCallback( + () => { + const storageProviderOperations = getStorageProviderOperations(); - const { fileMetadata } = fileMetadataAndStorageProviderName; - const storageProvider = findStorageProviderFor( - i18n, - props.storageProviders, - fileMetadataAndStorageProviderName - ); + if (!storageProviderOperations.onOpenWithPicker) return; - if (!storageProvider) return; + return storageProviderOperations + .onOpenWithPicker() + .then(fileMetadata => { + if (!fileMetadata) return; - getStorageProviderOperations(storageProvider); - await openFromFileMetadata(fileMetadata, { - openingMessage: (options && options.openingMessage) || null, - ignoreAutoSave: (options && options.ignoreAutoSave) || false, - }) - .then(state => { + return openFromFileMetadata(fileMetadata).then(state => { if (state) { const { currentProject } = state; - if (options && options.openAllScenes) { - openAllScenes({ - currentProject: currentProject, - editorTabs: state.editorTabs, - }); - } else if ( + if ( currentProject && hasAPreviousSaveForEditorTabsState(currentProject) ) { @@ -3676,1798 +3711,1897 @@ const MainFrame = (props: Props): React.MixedElement => { if (currentStorageProvider.internalName === 'LocalFile') { setHasProjectOpened(true); } - // If AIEditor is opened in the center, ensure we reposition it on the side. - const openedAskAIEditor = getOpenedAskAiEditor(state.editorTabs); - if ( - openedAskAIEditor && - openedAskAIEditor.paneIdentifier === 'center' - ) { - openAskAi({ - paneIdentifier: 'right', - }); - } } - }) - .catch(error => { - /* Ignore error, it was already surfaced to the user. */ - }); - }, - [ - i18n, - openFromFileMetadata, - openSceneOrProjectManager, - props.storageProviders, - getStorageProviderOperations, - hasUnsavedChanges, - - getStorageProvider, - setHasProjectOpened, - openAllScenes, - hasAPreviousSaveForEditorTabsState, - openEditorTabsFromPersistedState, - openAskAi, - ] + }); + }) + .catch(error => { + const errorMessage = storageProviderOperations.getOpenErrorMessage + ? storageProviderOperations.getOpenErrorMessage(error) + : t`Verify that you have the authorization for reading the file you're trying to access.`; + showErrorBox({ + message: [ + i18n._(t`Unable to open the project.`), + i18n._(errorMessage), + ].join('\n'), + errorId: 'project-open-with-picker-error', + rawError: error, + }); + }); + }, + [ + i18n, + hasAPreviousSaveForEditorTabsState, + openEditorTabsFromPersistedState, + getStorageProviderOperations, + openFromFileMetadata, + openSceneOrProjectManager, + getStorageProvider, + setHasProjectOpened, + ] +); + +const openFromFileMetadataWithStorageProvider = React.useCallback( + async ( + fileMetadataAndStorageProviderName: FileMetadataAndStorageProviderName, + options: ?{| + openAllScenes?: boolean, + ignoreUnsavedChanges?: boolean, + ignoreAutoSave?: boolean, + openingMessage?: ?MessageDescriptor, + |} + ): Promise < void> => { + if (hasUnsavedChanges && !(options && options.ignoreUnsavedChanges)) { + const answer = Window.showConfirmDialog( + i18n._( + t`Open a new project? Any changes that have not been saved will be lost.` + ) + ); + if (!answer) return; + } + + const { fileMetadata } = fileMetadataAndStorageProviderName; + const storageProvider = findStorageProviderFor( + i18n, + props.storageProviders, + fileMetadataAndStorageProviderName + ); + + if (!storageProvider) return; + + getStorageProviderOperations(storageProvider); + await openFromFileMetadata(fileMetadata, { + openingMessage: (options && options.openingMessage) || null, + ignoreAutoSave: (options && options.ignoreAutoSave) || false, + }) + .then(state => { + if (state) { + const { currentProject } = state; + if (options && options.openAllScenes) { + openAllScenes({ + currentProject: currentProject, + editorTabs: state.editorTabs, + }); + } else if ( + currentProject && + hasAPreviousSaveForEditorTabsState(currentProject) + ) { + const openedEditorsCount = openEditorTabsFromPersistedState( + currentProject + ); + if (openedEditorsCount === 0) { + openSceneOrProjectManager({ + currentProject: currentProject, + editorTabs: state.editorTabs, + }); + } else { + setIsLoadingProject(false); + setLoaderModalProgress(null, null); + openProjectManager(false); + } + } else { + openSceneOrProjectManager({ + currentProject: currentProject, + editorTabs: state.editorTabs, + }); + } + const currentStorageProvider = getStorageProvider(); + if (currentStorageProvider.internalName === 'LocalFile') { + setHasProjectOpened(true); + } + // If AIEditor is opened in the center, ensure we reposition it on the side. + const openedAskAIEditor = getOpenedAskAiEditor(state.editorTabs); + if ( + openedAskAIEditor && + openedAskAIEditor.paneIdentifier === 'center' + ) { + openAskAi({ + paneIdentifier: 'right', + }); + } + } + }) + .catch(error => { + /* Ignore error, it was already surfaced to the user. */ + }); +}, +[ + i18n, + openFromFileMetadata, + openSceneOrProjectManager, + props.storageProviders, + getStorageProviderOperations, + hasUnsavedChanges, + + getStorageProvider, + setHasProjectOpened, + openAllScenes, + hasAPreviousSaveForEditorTabsState, + openEditorTabsFromPersistedState, + openAskAi, +] ); - const onOpenCloudProjectOnSpecificVersion = React.useCallback( - ({ - fileMetadata, - versionId, - ignoreUnsavedChanges, - ignoreAutoSave, - openingMessage, - }: {| - fileMetadata: FileMetadata, - versionId: string, - ignoreUnsavedChanges: boolean, - ignoreAutoSave: boolean, - openingMessage: MessageDescriptor, - |}): Promise => { - return openFromFileMetadataWithStorageProvider( - { - storageProviderName: 'Cloud', - fileMetadata: { - ...fileMetadata, - version: versionId, - }, - }, - { ignoreUnsavedChanges, ignoreAutoSave, openingMessage } - ); +const onOpenCloudProjectOnSpecificVersion = React.useCallback( + ({ + fileMetadata, + versionId, + ignoreUnsavedChanges, + ignoreAutoSave, + openingMessage, + }: {| +fileMetadata: FileMetadata, + versionId: string, + ignoreUnsavedChanges: boolean, + ignoreAutoSave: boolean, + openingMessage: MessageDescriptor, + |}): Promise < void> => { + return openFromFileMetadataWithStorageProvider( + { + storageProviderName: 'Cloud', + fileMetadata: { + ...fileMetadata, + version: versionId, + }, }, - [openFromFileMetadataWithStorageProvider] + { ignoreUnsavedChanges, ignoreAutoSave, openingMessage } + ); +}, +[openFromFileMetadataWithStorageProvider] ); - const { - renderVersionHistoryPanel, - openVersionHistoryPanel, - checkedOutVersionStatus, - onQuitVersionHistory, - onCheckoutVersion, - getOrLoadProjectVersion, - } = useVersionHistory({ - getStorageProvider, - isSavingProject, - fileMetadata: currentFileMetadata, - onOpenCloudProjectOnSpecificVersion, - }); +const { + renderVersionHistoryPanel, + openVersionHistoryPanel, + checkedOutVersionStatus, + onQuitVersionHistory, + onCheckoutVersion, + getOrLoadProjectVersion, +} = useVersionHistory({ + getStorageProvider, + isSavingProject, + fileMetadata: currentFileMetadata, + onOpenCloudProjectOnSpecificVersion, +}); - const openSaveToStorageProviderDialog = React.useCallback( - (open: boolean = true) => { - if (open) { - // Ensure the project manager is closed as Google Drive storage provider - // display a picker that does not play nice with material-ui's overlays. - openProjectManager(false); - } - setState(state => ({ ...state, saveToStorageProviderDialogOpen: open })); - }, - [setState] +const openSaveToStorageProviderDialog = React.useCallback( + (open: boolean = true) => { + if (open) { + // Ensure the project manager is closed as Google Drive storage provider + // display a picker that does not play nice with material-ui's overlays. + openProjectManager(false); + } + setState(state => ({ ...state, saveToStorageProviderDialogOpen: open })); + }, + [setState] +); + +const saveProjectAsWithStorageProvider = React.useCallback( + async ( + options: ?{| + requestedStorageProvider?: StorageProvider, + forcedSavedAsLocation?: SaveAsLocation, + createdProject?: gdProject, + |} + ): Promise => { + // In some cases (ex: when a project is created by the AI), the project in + // the mainframe state is not updated yet, so we use the provided one. + const upToDateProject = + options && options.createdProject + ? options.createdProject + : currentProject; + if (!upToDateProject) return; + // Prevent saving if there are errors in the extension modules, as + // this can lead to corrupted projects. + if (hasExtensionLoadErrors) return; + + saveUiSettings(state.editorTabs); + + // Protect against concurrent saves, which can trigger issues with the + // file system. + if (isSavingProject) { + console.info('Project is already being saved, not triggering save.'); + return; + } + + // Remember the old storage provider, as we may need to use it to get access + // to resources. + const oldStorageProvider = getStorageProvider(); + const oldStorageProviderOperations = getStorageProviderOperations(); + + // Get the methods to save the project using the *new* storage provider. + const requestedStorageProvider = + options && options.requestedStorageProvider; + const newStorageProviderOperations = getStorageProviderOperations( + requestedStorageProvider ); + const newStorageProvider = getStorageProvider(); - const saveProjectAsWithStorageProvider = React.useCallback( - async ( - options: ?{| - requestedStorageProvider?: StorageProvider, - forcedSavedAsLocation?: SaveAsLocation, - createdProject?: gdProject, - |} - ): Promise => { - // In some cases (ex: when a project is created by the AI), the project in - // the mainframe state is not updated yet, so we use the provided one. - const upToDateProject = - options && options.createdProject - ? options.createdProject - : currentProject; - if (!upToDateProject) return; - // Prevent saving if there are errors in the extension modules, as - // this can lead to corrupted projects. - if (hasExtensionLoadErrors) return; - - saveUiSettings(state.editorTabs); - - // Protect against concurrent saves, which can trigger issues with the - // file system. - if (isSavingProject) { - console.info('Project is already being saved, not triggering save.'); - return; - } + const { + onSaveProjectAs, + onChooseSaveProjectAsLocation, + getWriteErrorMessage, + canFileMetadataBeSafelySavedAs, + } = newStorageProviderOperations; + if (!onSaveProjectAs) { + // The new storage provider can't even save as. It's strange that it was even + // selected here. + return; + } - // Remember the old storage provider, as we may need to use it to get access - // to resources. - const oldStorageProvider = getStorageProvider(); - const oldStorageProviderOperations = getStorageProviderOperations(); + setIsSavingProject(true); - // Get the methods to save the project using the *new* storage provider. - const requestedStorageProvider = - options && options.requestedStorageProvider; - const newStorageProviderOperations = getStorageProviderOperations( - requestedStorageProvider - ); - const newStorageProvider = getStorageProvider(); + // At the end of the promise below, currentProject and storageProvider + // may have changed (if the user opened another project). So we read and + // store their values in variables now. + const storageProviderInternalName = newStorageProvider.internalName; + try { + let newSaveAsLocation: ?SaveAsLocation = + options && options.forcedSavedAsLocation; + let newSaveAsOptions: ?SaveAsOptions = null; + if (onChooseSaveProjectAsLocation && !newSaveAsLocation) { const { - onSaveProjectAs, - onChooseSaveProjectAsLocation, - getWriteErrorMessage, - canFileMetadataBeSafelySavedAs, - } = newStorageProviderOperations; - if (!onSaveProjectAs) { - // The new storage provider can't even save as. It's strange that it was even - // selected here. + saveAsLocation, + saveAsOptions, + } = await onChooseSaveProjectAsLocation({ + project: upToDateProject, + fileMetadata: currentFileMetadata, + displayOptionToGenerateNewProjectUuid: + // No need to display the option if current file metadata doesn't have + // a gameId... + !!currentFileMetadata && + !!currentFileMetadata.gameId && + // ... or if the project is opened from a URL. + oldStorageProvider.internalName !== 'UrlStorageProvider', + }); + if (!saveAsLocation) { + // Save as was cancelled. + // Restore former storage provider. This is useful in case a user + // cancels the "save as" operation and then saves again. If the + // storage provider was kept selected, it would directly save the project + // if it's possible (LocalFile storage provider allows it). + getStorageProviderOperations(oldStorageProvider); return; } + newSaveAsLocation = saveAsLocation; + newSaveAsOptions = saveAsOptions; + } - setIsSavingProject(true); - - // At the end of the promise below, currentProject and storageProvider - // may have changed (if the user opened another project). So we read and - // store their values in variables now. - const storageProviderInternalName = newStorageProvider.internalName; - - try { - let newSaveAsLocation: ?SaveAsLocation = - options && options.forcedSavedAsLocation; - let newSaveAsOptions: ?SaveAsOptions = null; - if (onChooseSaveProjectAsLocation && !newSaveAsLocation) { - const { - saveAsLocation, - saveAsOptions, - } = await onChooseSaveProjectAsLocation({ - project: upToDateProject, - fileMetadata: currentFileMetadata, - displayOptionToGenerateNewProjectUuid: - // No need to display the option if current file metadata doesn't have - // a gameId... - !!currentFileMetadata && - !!currentFileMetadata.gameId && - // ... or if the project is opened from a URL. - oldStorageProvider.internalName !== 'UrlStorageProvider', - }); - if (!saveAsLocation) { - // Save as was cancelled. - // Restore former storage provider. This is useful in case a user - // cancels the "save as" operation and then saves again. If the - // storage provider was kept selected, it would directly save the project - // if it's possible (LocalFile storage provider allows it). - getStorageProviderOperations(oldStorageProvider); - return; - } - newSaveAsLocation = saveAsLocation; - newSaveAsOptions = saveAsOptions; + if (canFileMetadataBeSafelySavedAs && currentFileMetadata) { + const canProjectBeSafelySavedAs = await canFileMetadataBeSafelySavedAs( + currentFileMetadata, + { + showAlert, + showConfirmation, } + ); - if (canFileMetadataBeSafelySavedAs && currentFileMetadata) { - const canProjectBeSafelySavedAs = await canFileMetadataBeSafelySavedAs( - currentFileMetadata, - { - showAlert, - showConfirmation, - } - ); - - if (!canProjectBeSafelySavedAs) return; - } + if (!canProjectBeSafelySavedAs) return; + } - let originalProjectUuid = null; - if (newSaveAsOptions && newSaveAsOptions.generateNewProjectUuid) { - originalProjectUuid = upToDateProject.getProjectUuid(); - upToDateProject.resetProjectUuid(); - } - let originalProjectName = null; - const newProjectName = - newSaveAsLocation && newSaveAsLocation.name - ? newSaveAsLocation.name - : null; - if (newProjectName) { - originalProjectName = upToDateProject.getName(); - upToDateProject.setName(newProjectName); - } + let originalProjectUuid = null; + if (newSaveAsOptions && newSaveAsOptions.generateNewProjectUuid) { + originalProjectUuid = upToDateProject.getProjectUuid(); + upToDateProject.resetProjectUuid(); + } + let originalProjectName = null; + const newProjectName = + newSaveAsLocation && newSaveAsLocation.name + ? newSaveAsLocation.name + : null; + if (newProjectName) { + originalProjectName = upToDateProject.getName(); + upToDateProject.setName(newProjectName); + } - const { wasSaved, fileMetadata } = await onSaveProjectAs( - upToDateProject, - newSaveAsLocation, - { - onStartSaving: () => - _replaceSnackMessage(i18n._(t`Saving...`), null), - onMoveResources: async ({ newFileMetadata }) => { - if (currentFileMetadata) - await ensureResourcesAreMoved({ - project: upToDateProject, - newFileMetadata, - newStorageProvider, - newStorageProviderOperations, - oldFileMetadata: currentFileMetadata, - oldStorageProvider, - oldStorageProviderOperations, - authenticatedUser, - }); - }, - } - ); + const { wasSaved, fileMetadata } = await onSaveProjectAs( + upToDateProject, + newSaveAsLocation, + { + onStartSaving: () => + _replaceSnackMessage(i18n._(t`Saving...`), null), + onMoveResources: async ({ newFileMetadata }) => { + if (currentFileMetadata) + await ensureResourcesAreMoved({ + project: upToDateProject, + newFileMetadata, + newStorageProvider, + newStorageProviderOperations, + oldFileMetadata: currentFileMetadata, + oldStorageProvider, + oldStorageProviderOperations, + authenticatedUser, + }); + }, + } + ); - if (!wasSaved) { - _replaceSnackMessage(i18n._(t`An error occurred. Please try again.`)); - if (originalProjectName) upToDateProject.setName(originalProjectName); - if (originalProjectUuid) - upToDateProject.setProjectUuid(originalProjectUuid); - return; - } + if (!wasSaved) { + _replaceSnackMessage(i18n._(t`An error occurred. Please try again.`)); + if (originalProjectName) upToDateProject.setName(originalProjectName); + if (originalProjectUuid) + upToDateProject.setProjectUuid(originalProjectUuid); + return; + } - sealUnsavedChanges(); - _replaceSnackMessage(i18n._(t`Project properly saved`)); - setCloudProjectSaveChoiceOpen(false); - setCloudProjectRecoveryOpenedVersionId(null); + sealUnsavedChanges(); + _replaceSnackMessage(i18n._(t`Project properly saved`)); + setCloudProjectSaveChoiceOpen(false); + setCloudProjectRecoveryOpenedVersionId(null); - if (!fileMetadata) { - // Some storage provider like "DownloadFile" don't have file metadata, because - // it's more like an "export". - return; - } + if (!fileMetadata) { + // Some storage provider like "DownloadFile" don't have file metadata, because + // it's more like an "export". + return; + } - if (fileMetadata.gameId) { - await gamesList.markGameAsSavedIfRelevant(fileMetadata.gameId); - } + if (fileMetadata.gameId) { + await gamesList.markGameAsSavedIfRelevant(fileMetadata.gameId); + } - // Save was done on a new file/location, so save it in the - // recent projects and in the state. - const fileMetadataAndStorageProviderName = { - fileMetadata, - storageProviderName: storageProviderInternalName, - }; - preferences.insertRecentProjectFile(fileMetadataAndStorageProviderName); - if ( - currentlyRunningInAppTutorial && - !currentlyRunningInAppTutorial.isMiniTutorial && // Don't save the progress of mini-tutorials - inAppTutorialOrchestratorRef.current - ) { - preferences.saveTutorialProgress({ - tutorialId: currentlyRunningInAppTutorial.id, - userId: authenticatedUser.profile - ? authenticatedUser.profile.id - : null, - ...inAppTutorialOrchestratorRef.current.getProgress(), - fileMetadataAndStorageProviderName, - }); - } + // Save was done on a new file/location, so save it in the + // recent projects and in the state. + const fileMetadataAndStorageProviderName = { + fileMetadata, + storageProviderName: storageProviderInternalName, + }; + preferences.insertRecentProjectFile(fileMetadataAndStorageProviderName); + if ( + currentlyRunningInAppTutorial && + !currentlyRunningInAppTutorial.isMiniTutorial && // Don't save the progress of mini-tutorials + inAppTutorialOrchestratorRef.current + ) { + preferences.saveTutorialProgress({ + tutorialId: currentlyRunningInAppTutorial.id, + userId: authenticatedUser.profile + ? authenticatedUser.profile.id + : null, + ...inAppTutorialOrchestratorRef.current.getProgress(), + fileMetadataAndStorageProviderName, + }); + } - // Refresh user cloud projects in case they saved as on Cloud storage provider - // so that it appears immediately in the list. - authenticatedUser.onCloudProjectsChanged(); + // Refresh user cloud projects in case they saved as on Cloud storage provider + // so that it appears immediately in the list. + authenticatedUser.onCloudProjectsChanged(); - // Ensure resources are re-loaded from their new location. - ResourcesLoader.burstAllUrlsCache(); + // Ensure resources are re-loaded from their new location. + ResourcesLoader.burstAllUrlsCache(); - if (isCurrentProjectFresh(currentProjectRef, upToDateProject)) { - // We do not want to change the current file metadata if the - // project has changed since the beginning of the save, which - // can happen if another project was loaded in the meantime. - setState(state => ({ - ...state, - currentFileMetadata: fileMetadata, - })); - } + if (isCurrentProjectFresh(currentProjectRef, upToDateProject)) { + // We do not want to change the current file metadata if the + // project has changed since the beginning of the save, which + // can happen if another project was loaded in the meantime. + setState(state => ({ + ...state, + currentFileMetadata: fileMetadata, + })); + } - return fileMetadata; - } catch (rawError) { - _closeSnackMessage(); - const errorMessage = getWriteErrorMessage - ? getWriteErrorMessage(rawError) - : t`An error occurred when saving the project. Please try again later.`; - showErrorBox({ - message: i18n._(errorMessage), - rawError, - errorId: 'project-save-as-error', - }); - } finally { - setIsSavingProject(false); - } - }, - [ - i18n, - isSavingProject, - currentProject, - currentProjectRef, - currentFileMetadata, - getStorageProviderOperations, - sealUnsavedChanges, - setState, - state.editorTabs, - _replaceSnackMessage, - _closeSnackMessage, - getStorageProvider, - preferences, - ensureResourcesAreMoved, - authenticatedUser, - currentlyRunningInAppTutorial, - showAlert, - showConfirmation, - gamesList, - hasExtensionLoadErrors, - ] + return fileMetadata; + } catch (rawError) { + _closeSnackMessage(); + const errorMessage = getWriteErrorMessage + ? getWriteErrorMessage(rawError) + : t`An error occurred when saving the project. Please try again later.`; + showErrorBox({ + message: i18n._(errorMessage), + rawError, + errorId: 'project-save-as-error', + }); + } finally { + setIsSavingProject(false); + } +}, +[ + i18n, + isSavingProject, + currentProject, + currentProjectRef, + currentFileMetadata, + getStorageProviderOperations, + sealUnsavedChanges, + setState, + state.editorTabs, + _replaceSnackMessage, + _closeSnackMessage, + getStorageProvider, + preferences, + ensureResourcesAreMoved, + authenticatedUser, + currentlyRunningInAppTutorial, + showAlert, + showConfirmation, + gamesList, + hasExtensionLoadErrors, +] ); - // Prevent "save project as" when no current project or when the opened project - // is a previous version (cloud project only) of the current project. - const canSaveProjectAs = !!currentProject && !checkedOutVersionStatus; - const saveProjectAs = React.useCallback( - () => { - if (!canSaveProjectAs) { - return; - } - // Prevent saving if there are errors in the extension modules, as - // this can lead to corrupted projects. - if (hasExtensionLoadErrors) return; +// Prevent "save project as" when no current project or when the opened project +// is a previous version (cloud project only) of the current project. +const canSaveProjectAs = !!currentProject && !checkedOutVersionStatus; +const saveProjectAs = React.useCallback( + () => { + if (!canSaveProjectAs) { + return; + } + // Prevent saving if there are errors in the extension modules, as + // this can lead to corrupted projects. + if (hasExtensionLoadErrors) return; - if (cloudProjectRecoveryOpenedVersionId && !cloudProjectSaveChoiceOpen) { - setCloudProjectSaveChoiceOpen(true); - return; - } + if (cloudProjectRecoveryOpenedVersionId && !cloudProjectSaveChoiceOpen) { + setCloudProjectSaveChoiceOpen(true); + return; + } - const storageProviderOperations = getStorageProviderOperations(); - if ( - props.storageProviders.filter( - ({ hiddenInSaveDialog }) => !hiddenInSaveDialog - ).length > 1 || - !storageProviderOperations.onSaveProjectAs - ) { - openSaveToStorageProviderDialog(); - } else { - saveProjectAsWithStorageProvider(); - } - }, - [ - getStorageProviderOperations, - openSaveToStorageProviderDialog, - props.storageProviders, - saveProjectAsWithStorageProvider, - cloudProjectRecoveryOpenedVersionId, - cloudProjectSaveChoiceOpen, - canSaveProjectAs, - hasExtensionLoadErrors, - ] - ); + const storageProviderOperations = getStorageProviderOperations(); + if ( + props.storageProviders.filter( + ({ hiddenInSaveDialog }) => !hiddenInSaveDialog + ).length > 1 || + !storageProviderOperations.onSaveProjectAs + ) { + openSaveToStorageProviderDialog(); + } else { + saveProjectAsWithStorageProvider(); + } + }, + [ + getStorageProviderOperations, + openSaveToStorageProviderDialog, + props.storageProviders, + saveProjectAsWithStorageProvider, + cloudProjectRecoveryOpenedVersionId, + cloudProjectSaveChoiceOpen, + canSaveProjectAs, + hasExtensionLoadErrors, + ] +); + +// const saveWithBackgroundSerializer = +// preferences.values.useBackgroundSerializerForSaving; +// Hardcode to false for now as libGD.js is not loaded properly by the worker in production (file:// protocol). +const saveWithBackgroundSerializer = false; +const saveProject = React.useCallback( + async (options?: {| + skipNewVersionWarning: boolean, + |}): Promise => { + if (!currentProject) return; + // Prevent saving if there are errors in the extension modules, as + // this can lead to corrupted projects. + if (hasExtensionLoadErrors) return; + + if (!currentFileMetadata) { + return saveProjectAs(); + } + const isProjectOwnedBySomeoneElse = !!currentFileMetadata.ownerId; + if (isProjectOwnedBySomeoneElse) return; - // const saveWithBackgroundSerializer = - // preferences.values.useBackgroundSerializerForSaving; - // Hardcode to false for now as libGD.js is not loaded properly by the worker in production (file:// protocol). - const saveWithBackgroundSerializer = false; - const saveProject = React.useCallback( - async (options?: {| - skipNewVersionWarning: boolean, - |}): Promise => { - if (!currentProject) return; - // Prevent saving if there are errors in the extension modules, as - // this can lead to corrupted projects. - if (hasExtensionLoadErrors) return; + if (cloudProjectRecoveryOpenedVersionId && !cloudProjectSaveChoiceOpen) { + setCloudProjectSaveChoiceOpen(true); + return; + } - if (!currentFileMetadata) { - return saveProjectAs(); - } - const isProjectOwnedBySomeoneElse = !!currentFileMetadata.ownerId; - if (isProjectOwnedBySomeoneElse) return; + const storageProviderOperations = getStorageProviderOperations(); + const { onSaveProject } = storageProviderOperations; + if (!onSaveProject) { + return saveProjectAs(); + } - if (cloudProjectRecoveryOpenedVersionId && !cloudProjectSaveChoiceOpen) { - setCloudProjectSaveChoiceOpen(true); - return; - } + saveUiSettings(state.editorTabs); - const storageProviderOperations = getStorageProviderOperations(); - const { onSaveProject } = storageProviderOperations; - if (!onSaveProject) { - return saveProjectAs(); - } + // Protect against concurrent saves, which can trigger issues with the + // file system. + if (isSavingProject) { + console.info('Project is already being saved, not triggering save.'); + return; + } - saveUiSettings(state.editorTabs); + if (checkedOutVersionStatus) { + const shouldRestoreCheckedOutVersion = await showConfirmation({ + title: t`Restore this version`, + message: t`You're trying to save changes made to a previous version of your project. If you continue, it will be used as the new latest version.`, + }); + if (!shouldRestoreCheckedOutVersion) return; + } - // Protect against concurrent saves, which can trigger issues with the - // file system. - if (isSavingProject) { - console.info('Project is already being saved, not triggering save.'); - return; + _showSnackMessage(i18n._(t`Saving...`), null); + setIsSavingProject(true); + + try { + const saveStartTime = performance.now(); + + // At the end of the promise below, currentProject and storageProvider + // may have changed (if the user opened another project). So we read and + // store their values in variables now. + const storageProviderInternalName = getStorageProvider().internalName; + + const saveOptions: SaveProjectOptions = { + useBackgroundSerializer: saveWithBackgroundSerializer, + skipNewVersionWarning: + !!checkedOutVersionStatus || + (options && options.skipNewVersionWarning), + }; + if (cloudProjectRecoveryOpenedVersionId) { + saveOptions.previousVersion = cloudProjectRecoveryOpenedVersionId; + } else { + saveOptions.previousVersion = currentFileMetadata.version; + } + if (checkedOutVersionStatus) { + saveOptions.restoredFromVersionId = + checkedOutVersionStatus.version.id; + } + const { wasSaved, fileMetadata } = await onSaveProject( + currentProject, + currentFileMetadata, + saveOptions, + { + showAlert, + showConfirmation, } + ); - if (checkedOutVersionStatus) { - const shouldRestoreCheckedOutVersion = await showConfirmation({ - title: t`Restore this version`, - message: t`You're trying to save changes made to a previous version of your project. If you continue, it will be used as the new latest version.`, - }); - if (!shouldRestoreCheckedOutVersion) return; + if (wasSaved) { + console.info( + `Project saved in ${performance.now() - saveStartTime}ms.` + ); + // If project was saved, and a game is registered, ensure the game is + // marked as saved. + if (fileMetadata.gameId) { + await gamesList.markGameAsSavedIfRelevant(fileMetadata.gameId); } - _showSnackMessage(i18n._(t`Saving...`), null); - setIsSavingProject(true); - - try { - const saveStartTime = performance.now(); - - // At the end of the promise below, currentProject and storageProvider - // may have changed (if the user opened another project). So we read and - // store their values in variables now. - const storageProviderInternalName = getStorageProvider().internalName; - - const saveOptions: SaveProjectOptions = { - useBackgroundSerializer: saveWithBackgroundSerializer, - skipNewVersionWarning: - !!checkedOutVersionStatus || - (options && options.skipNewVersionWarning), - }; - if (cloudProjectRecoveryOpenedVersionId) { - saveOptions.previousVersion = cloudProjectRecoveryOpenedVersionId; - } else { - saveOptions.previousVersion = currentFileMetadata.version; - } - if (checkedOutVersionStatus) { - saveOptions.restoredFromVersionId = - checkedOutVersionStatus.version.id; - } - const { wasSaved, fileMetadata } = await onSaveProject( - currentProject, - currentFileMetadata, - saveOptions, - { - showAlert, - showConfirmation, - } - ); - - if (wasSaved) { - console.info( - `Project saved in ${performance.now() - saveStartTime}ms.` - ); - // If project was saved, and a game is registered, ensure the game is - // marked as saved. - if (fileMetadata.gameId) { - await gamesList.markGameAsSavedIfRelevant(fileMetadata.gameId); - } - - setCloudProjectSaveChoiceOpen(false); - setCloudProjectRecoveryOpenedVersionId(null); - - const fileMetadataAndStorageProviderName = { - fileMetadata: fileMetadata, - storageProviderName: storageProviderInternalName, - }; - preferences.insertRecentProjectFile( - fileMetadataAndStorageProviderName - ); - if ( - currentlyRunningInAppTutorial && - !currentlyRunningInAppTutorial.isMiniTutorial && // Don't save the progress of mini-tutorials - inAppTutorialOrchestratorRef.current - ) { - preferences.saveTutorialProgress({ - tutorialId: currentlyRunningInAppTutorial.id, - userId: authenticatedUser.profile - ? authenticatedUser.profile.id - : null, - ...inAppTutorialOrchestratorRef.current.getProgress(), - fileMetadataAndStorageProviderName, - }); - } - if (isCurrentProjectFresh(currentProjectRef, currentProject)) { - // We do not want to change the current file metadata if the - // project has changed since the beginning of the save, which - // can happen if another project was loaded in the meantime. - setState(state => ({ - ...state, - currentFileMetadata: fileMetadata, - })); - } - - sealUnsavedChanges(); - _replaceSnackMessage(i18n._(t`Project properly saved`)); + setCloudProjectSaveChoiceOpen(false); + setCloudProjectRecoveryOpenedVersionId(null); - // Return the new file metadata, to allow further operations, - // without having to wait for the state to be updated. - return fileMetadata; - } - } catch (error) { - const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( - error - ); - const message = - extractedStatusAndCode && extractedStatusAndCode.status === 403 - ? t`You don't have permissions to save this project. Please choose another location.` - : t`An error occurred when saving the project. Please try again later.`; - showAlert({ - title: t`Unable to save the project`, - message, + const fileMetadataAndStorageProviderName = { + fileMetadata: fileMetadata, + storageProviderName: storageProviderInternalName, + }; + preferences.insertRecentProjectFile( + fileMetadataAndStorageProviderName + ); + if ( + currentlyRunningInAppTutorial && + !currentlyRunningInAppTutorial.isMiniTutorial && // Don't save the progress of mini-tutorials + inAppTutorialOrchestratorRef.current + ) { + preferences.saveTutorialProgress({ + tutorialId: currentlyRunningInAppTutorial.id, + userId: authenticatedUser.profile + ? authenticatedUser.profile.id + : null, + ...inAppTutorialOrchestratorRef.current.getProgress(), + fileMetadataAndStorageProviderName, }); - _closeSnackMessage(); - } finally { - setIsSavingProject(false); } - }, - [ - saveWithBackgroundSerializer, - isSavingProject, - currentProject, - currentProjectRef, - currentFileMetadata, - getStorageProviderOperations, - _showSnackMessage, - _closeSnackMessage, - _replaceSnackMessage, - i18n, - sealUnsavedChanges, - saveProjectAs, - state.editorTabs, - getStorageProvider, - preferences, - setState, - authenticatedUser, - currentlyRunningInAppTutorial, - cloudProjectRecoveryOpenedVersionId, - cloudProjectSaveChoiceOpen, - showAlert, - showConfirmation, - checkedOutVersionStatus, - gamesList, - hasExtensionLoadErrors, - ] - ); - - const renderSaveReminder = useSaveReminder({ - onSave: saveProject, - project: currentProject, - isInQuickCustomization: !!quickCustomizationDialogOpenedFromGameId, - }); - - /** - * Returns true if the project has been closed and false if the user refused to close it. - */ - const askToCloseProject = React.useCallback( - async (): Promise => { - if (!currentProject) return true; - - if (hasUnsavedChanges) { - const answer = Window.showConfirmDialog( - i18n._( - t`Close the project? Any changes that have not been saved will be lost.` - ) - ); - if (!answer) return false; + if (isCurrentProjectFresh(currentProjectRef, currentProject)) { + // We do not want to change the current file metadata if the + // project has changed since the beginning of the save, which + // can happen if another project was loaded in the meantime. + setState(state => ({ + ...state, + currentFileMetadata: fileMetadata, + })); } - await closeProject(); - return true; - }, - [currentProject, hasUnsavedChanges, i18n, closeProject] - ); - const endTutorial = React.useCallback( - async (shouldCloseProject?: boolean) => { - if (shouldCloseProject) { - await closeProject(); - doEndTutorial(); - } else { - doEndTutorial(); - } - // Open the homepage, so that the user can start a new tutorial. - openHomePage(); - }, - [doEndTutorial, closeProject, openHomePage] - ); + sealUnsavedChanges(); + _replaceSnackMessage(i18n._(t`Project properly saved`)); - const selectInAppTutorial = React.useCallback( - (tutorialId: string) => { - const userProgress = preferences.getTutorialProgress({ - tutorialId, - userId: authenticatedUser.profile - ? authenticatedUser.profile.id - : undefined, - }); - setSelectedInAppTutorialInfo({ tutorialId, userProgress }); - }, - [preferences, authenticatedUser.profile] + // Return the new file metadata, to allow further operations, + // without having to wait for the state to be updated. + return fileMetadata; + } + } catch (error) { + const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode( + error + ); + const message = + extractedStatusAndCode && extractedStatusAndCode.status === 403 + ? t`You don't have permissions to save this project. Please choose another location.` + : t`An error occurred when saving the project. Please try again later.`; + showAlert({ + title: t`Unable to save the project`, + message, + }); + _closeSnackMessage(); + } finally { + setIsSavingProject(false); + } +}, +[ + saveWithBackgroundSerializer, + isSavingProject, + currentProject, + currentProjectRef, + currentFileMetadata, + getStorageProviderOperations, + _showSnackMessage, + _closeSnackMessage, + _replaceSnackMessage, + i18n, + sealUnsavedChanges, + saveProjectAs, + state.editorTabs, + getStorageProvider, + preferences, + setState, + authenticatedUser, + currentlyRunningInAppTutorial, + cloudProjectRecoveryOpenedVersionId, + cloudProjectSaveChoiceOpen, + showAlert, + showConfirmation, + checkedOutVersionStatus, + gamesList, + hasExtensionLoadErrors, +] ); - useOpenInitialDialog({ - openInAppTutorialDialog: selectInAppTutorial, - openProfileDialog: onOpenProfileDialog, - openAskAi, - openStandaloneDialog, - }); +const renderSaveReminder = useSaveReminder({ + onSave: saveProject, + project: currentProject, + isInQuickCustomization: !!quickCustomizationDialogOpenedFromGameId, +}); - const onChangeProjectName = async (newName: string): Promise => { - if (!currentProject || !currentFileMetadata) return; - const storageProviderOperations = getStorageProviderOperations(); - let newFileMetadata = { ...currentFileMetadata, name: newName }; - if (storageProviderOperations.onChangeProjectProperty) { - const fileMetadataNewAttributes = await storageProviderOperations.onChangeProjectProperty( - currentProject, - currentFileMetadata, - { name: newName } +/** + * Returns true if the project has been closed and false if the user refused to close it. + */ +const askToCloseProject = React.useCallback( + async (): Promise => { + if (!currentProject) return true; + + if (hasUnsavedChanges) { + const answer = Window.showConfirmDialog( + i18n._( + t`Close the project? Any changes that have not been saved will be lost.` + ) ); - if (fileMetadataNewAttributes) { - sealUnsavedChanges(); - newFileMetadata = { ...newFileMetadata, ...fileMetadataNewAttributes }; - } + if (!answer) return false; } - // $FlowFixMe[incompatible-type] - await setState(state => ({ - ...state, - currentFileMetadata: newFileMetadata, - })); - }; + await closeProject(); + return true; + }, + [currentProject, hasUnsavedChanges, i18n, closeProject] +); - const onSaveProjectProperties = async (options: { - newName?: string, - }): Promise => { - const storageProvider = getStorageProvider(); - if (storageProvider.internalName === 'Cloud' && options.newName) { - return showConfirmation({ - title: t`Project name changed`, - message: t`Your project name has changed, this will also save the whole project, continue?`, - confirmButtonLabel: t`Save and continue`, - }); +const endTutorial = React.useCallback( + async (shouldCloseProject?: boolean) => { + if (shouldCloseProject) { + await closeProject(); + doEndTutorial(); + } else { + doEndTutorial(); } - return true; - }; + // Open the homepage, so that the user can start a new tutorial. + openHomePage(); + }, + [doEndTutorial, closeProject, openHomePage] +); + +const selectInAppTutorial = React.useCallback( + (tutorialId: string) => { + const userProgress = preferences.getTutorialProgress({ + tutorialId, + userId: authenticatedUser.profile + ? authenticatedUser.profile.id + : undefined, + }); + setSelectedInAppTutorialInfo({ tutorialId, userProgress }); + }, + [preferences, authenticatedUser.profile] +); + +useOpenInitialDialog({ + openInAppTutorialDialog: selectInAppTutorial, + openProfileDialog: onOpenProfileDialog, + openAskAi, + openStandaloneDialog, +}); - const onOpenCloudProjectOnSpecificVersionForRecovery = React.useCallback( - (versionId: string) => { - if (!cloudProjectFileMetadataToRecover) return; - onOpenCloudProjectOnSpecificVersion({ - fileMetadata: cloudProjectFileMetadataToRecover, - versionId, - ignoreUnsavedChanges: false, - ignoreAutoSave: true, - openingMessage: t`Recovering older version...`, - }); - setCloudProjectFileMetadataToRecover(null); - setCloudProjectRecoveryOpenedVersionId(versionId); - }, - [cloudProjectFileMetadataToRecover, onOpenCloudProjectOnSpecificVersion] - ); +const onChangeProjectName = async (newName: string): Promise => { + if (!currentProject || !currentFileMetadata) return; + const storageProviderOperations = getStorageProviderOperations(); + let newFileMetadata = { ...currentFileMetadata, name: newName }; + if (storageProviderOperations.onChangeProjectProperty) { + const fileMetadataNewAttributes = await storageProviderOperations.onChangeProjectProperty( + currentProject, + currentFileMetadata, + { name: newName } + ); + if (fileMetadataNewAttributes) { + sealUnsavedChanges(); + newFileMetadata = { ...newFileMetadata, ...fileMetadataNewAttributes }; + } + } + // $FlowFixMe[incompatible-type] + await setState(state => ({ + ...state, + currentFileMetadata: newFileMetadata, + })); +}; - const canInstallPrivateAsset = React.useCallback( - () => { - const storageProvider = getStorageProvider(); - // A private asset can always be installed locally, as it will be downloaded. - // Or on the cloud if the user has saved their project as a cloud project. - return ( - storageProvider.internalName === 'LocalFile' || - storageProvider.internalName === 'Cloud' - ); - }, - [getStorageProvider] - ); +const onSaveProjectProperties = async (options: { + newName?: string, +}): Promise => { + const storageProvider = getStorageProvider(); + if (storageProvider.internalName === 'Cloud' && options.newName) { + return showConfirmation({ + title: t`Project name changed`, + message: t`Your project name has changed, this will also save the whole project, continue?`, + confirmButtonLabel: t`Save and continue`, + }); + } + return true; +}; - const onChooseResource: ChooseResourceFunction = React.useCallback( - (options: ChooseResourceOptions) => { - return new Promise(resolve => { - setChooseResourceOptions(options); - const onResourceChosenSetter: () => ({| - selectedResources: Array, +const onOpenCloudProjectOnSpecificVersionForRecovery = React.useCallback( + (versionId: string) => { + if (!cloudProjectFileMetadataToRecover) return; + onOpenCloudProjectOnSpecificVersion({ + fileMetadata: cloudProjectFileMetadataToRecover, + versionId, + ignoreUnsavedChanges: false, + ignoreAutoSave: true, + openingMessage: t`Recovering older version...`, + }); + setCloudProjectFileMetadataToRecover(null); + setCloudProjectRecoveryOpenedVersionId(versionId); + }, + [cloudProjectFileMetadataToRecover, onOpenCloudProjectOnSpecificVersion] +); + +const canInstallPrivateAsset = React.useCallback( + () => { + const storageProvider = getStorageProvider(); + // A private asset can always be installed locally, as it will be downloaded. + // Or on the cloud if the user has saved their project as a cloud project. + return ( + storageProvider.internalName === 'LocalFile' || + storageProvider.internalName === 'Cloud' + ); + }, + [getStorageProvider] +); + +const onChooseResource: ChooseResourceFunction = React.useCallback( + (options: ChooseResourceOptions) => { + return new Promise(resolve => { + setChooseResourceOptions(options); + const onResourceChosenSetter: () => ({| + selectedResources: Array, selectedSourceName: string, |}) => void = () => resolve; - setOnResourceChosen(onResourceChosenSetter); + setOnResourceChosen(onResourceChosenSetter); }); }, - [setOnResourceChosen, setChooseResourceOptions] - ); + [setOnResourceChosen, setChooseResourceOptions] + ); const setElectronUpdateStatus = (updateStatus: ElectronUpdateStatus) => { - setState(state => ({ ...state, updateStatus })); + setState(state => ({ ...state, updateStatus })); - // TODO: use i18n to translate title and body in notification. - // Also, find a way to use preferences to know if user deactivated auto-update. - const notificationTitle = getElectronUpdateNotificationTitle(updateStatus); - const notificationBody = getElectronUpdateNotificationBody(updateStatus); - if (notificationTitle) { + // TODO: use i18n to translate title and body in notification. + // Also, find a way to use preferences to know if user deactivated auto-update. + const notificationTitle = getElectronUpdateNotificationTitle(updateStatus); + const notificationBody = getElectronUpdateNotificationBody(updateStatus); + if (notificationTitle) { const notification = new window.Notification(notificationTitle, { - body: notificationBody, + body: notificationBody, }); notification.onclick = () => openAboutDialog(true); } }; - const openTemplateFromTutorial = React.useCallback( + const openTemplateFromTutorial = React.useCallback( async (tutorialId: string) => { const projectIsClosed = await askToCloseProject(); - if (!projectIsClosed) { + if (!projectIsClosed) { return; } - try { - await createProjectFromTutorial(tutorialId, { - storageProvider: emptyStorageProvider, - saveAsLocation: null, - creationSource: 'in-app-tutorial', - // Remaining will be set by the template. - }); + try { + await createProjectFromTutorial(tutorialId, { + storageProvider: emptyStorageProvider, + saveAsLocation: null, + creationSource: 'in-app-tutorial', + // Remaining will be set by the template. + }); } catch (error) { - showErrorBox({ - message: i18n._( - t`Unable to create a new project for the tutorial. Try again later.` - ), - rawError: new Error( - `Can't create project from template of tutorial "${tutorialId}"` - ), - errorId: 'cannot-create-project-from-tutorial-template', - }); - return; + showErrorBox({ + message: i18n._( + t`Unable to create a new project for the tutorial. Try again later.` + ), + rawError: new Error( + `Can't create project from template of tutorial "${tutorialId}"` + ), + errorId: 'cannot-create-project-from-tutorial-template', + }); + return; } }, - [askToCloseProject, createProjectFromTutorial, i18n] - ); + [askToCloseProject, createProjectFromTutorial, i18n] + ); - const openTemplateFromCourseChapter = React.useCallback( + const openTemplateFromCourseChapter = React.useCallback( async (courseChapter: CourseChapter, templateId?: string) => { const projectIsClosed = await askToCloseProject(); - if (!projectIsClosed) { + if (!projectIsClosed) { return; } - const newProjectSetup: NewProjectSetup = { - storageProvider: emptyStorageProvider, - saveAsLocation: null, - creationSource: 'course-chapter', + const newProjectSetup: NewProjectSetup = { + storageProvider: emptyStorageProvider, + saveAsLocation: null, + creationSource: 'course-chapter', // Remaining will be set by the template. }; - try { - await createProjectFromCourseChapter({ - courseChapter, - templateId, - newProjectSetup, - }); + try { + await createProjectFromCourseChapter({ + courseChapter, + templateId, + newProjectSetup, + }); } catch (error) { - showErrorBox({ - message: i18n._( - t`Unable to create a new project for the course chapter. Try again later.` - ), - rawError: new Error( - `Can't create project from template of course chapter "${ - courseChapter.id - }"` - ), - errorId: 'cannot-create-project-from-course-chapter-template', - }); - return; + showErrorBox({ + message: i18n._( + t`Unable to create a new project for the course chapter. Try again later.` + ), + rawError: new Error( + `Can't create project from template of course chapter "${courseChapter.id + }"` + ), + errorId: 'cannot-create-project-from-course-chapter-template', + }); + return; } }, - [askToCloseProject, createProjectFromCourseChapter, i18n] - ); + [askToCloseProject, createProjectFromCourseChapter, i18n] + ); - const startSelectedTutorial = React.useCallback( + const startSelectedTutorial = React.useCallback( async (scenario: 'resume' | 'startOver' | 'start') => { if (!selectedInAppTutorialInfo) return; - const { userProgress, tutorialId } = selectedInAppTutorialInfo; - const fileMetadataAndStorageProviderName = userProgress - ? userProgress.fileMetadataAndStorageProviderName - : null; - if ( - userProgress && - scenario === 'resume' && - fileMetadataAndStorageProviderName // The user can only resume if the project was saved to a storage provider. - ) { + const {userProgress, tutorialId} = selectedInAppTutorialInfo; + const fileMetadataAndStorageProviderName = userProgress + ? userProgress.fileMetadataAndStorageProviderName + : null; + if ( + userProgress && + scenario === 'resume' && + fileMetadataAndStorageProviderName // The user can only resume if the project was saved to a storage provider. + ) { if (currentProject) { // If there's a project opened, check if this is the one we should open // for the stored tutorial userProgress. if ( - currentFileMetadata && - currentFileMetadata.fileIdentifier !== - fileMetadataAndStorageProviderName.fileMetadata.fileIdentifier + currentFileMetadata && + currentFileMetadata.fileIdentifier !== + fileMetadataAndStorageProviderName.fileMetadata.fileIdentifier ) { const projectIsClosed = await askToCloseProject(); - if (!projectIsClosed) { + if (!projectIsClosed) { return; } - openFromFileMetadataWithStorageProvider( - fileMetadataAndStorageProviderName, - { openAllScenes: true } - ); + openFromFileMetadataWithStorageProvider( + fileMetadataAndStorageProviderName, + {openAllScenes: true } + ); } else { // If the current project is the same stored for the tutorial, // open all scenes. openAllScenes({ currentProject, editorTabs: state.editorTabs }); } } else { - openFromFileMetadataWithStorageProvider( - fileMetadataAndStorageProviderName, - { openAllScenes: true } - ); + openFromFileMetadataWithStorageProvider( + fileMetadataAndStorageProviderName, + { openAllScenes: true } + ); } } else { const projectIsClosed = await askToCloseProject(); - if (!projectIsClosed) { + if (!projectIsClosed) { return; } } - const selectedInAppTutorialShortHeader = getInAppTutorialShortHeader( - tutorialId - ); - if (!selectedInAppTutorialShortHeader) return; + const selectedInAppTutorialShortHeader = getInAppTutorialShortHeader( + tutorialId + ); + if (!selectedInAppTutorialShortHeader) return; - // If the tutorial has a template, create a new project from it. - const initialTemplateUrl = - selectedInAppTutorialShortHeader.initialTemplateUrl; - if (initialTemplateUrl) { + // If the tutorial has a template, create a new project from it. + const initialTemplateUrl = + selectedInAppTutorialShortHeader.initialTemplateUrl; + if (initialTemplateUrl) { try { - await createProjectFromInAppTutorial( - selectedInAppTutorialShortHeader.id, - { - storageProvider: emptyStorageProvider, - saveAsLocation: null, - creationSource: 'in-app-tutorial', - // Remaining will be set by the template. - } - ); + await createProjectFromInAppTutorial( + selectedInAppTutorialShortHeader.id, + { + storageProvider: emptyStorageProvider, + saveAsLocation: null, + creationSource: 'in-app-tutorial', + // Remaining will be set by the template. + } + ); } catch (error) { - showErrorBox({ - message: i18n._( - t`Unable to create a new project for the tutorial. Try again later.` - ), - rawError: new Error( - `Can't create project from template "${initialTemplateUrl}"` - ), - errorId: 'cannot-create-project-from-template', - }); + showErrorBox({ + message: i18n._( + t`Unable to create a new project for the tutorial. Try again later.` + ), + rawError: new Error( + `Can't create project from template "${initialTemplateUrl}"` + ), + errorId: 'cannot-create-project-from-template', + }); return; } } - const initialStepIndex = - userProgress && scenario === 'resume' ? userProgress.step : 0; - const initialProjectData = - userProgress && scenario === 'resume' + const initialStepIndex = + userProgress && scenario === 'resume' ? userProgress.step : 0; + const initialProjectData = + userProgress && scenario === 'resume' ? userProgress.projectData - : selectedInAppTutorialShortHeader.initialProjectData || {}; + : selectedInAppTutorialShortHeader.initialProjectData || { }; - await startTutorial({ - tutorialId, - initialStepIndex, - initialProjectData, + await startTutorial({ + tutorialId, + initialStepIndex, + initialProjectData, }); - sendInAppTutorialStarted({ - tutorialId, - scenario, - isUIRestricted: !!selectedInAppTutorialShortHeader.shouldRestrictUI, + sendInAppTutorialStarted({ + tutorialId, + scenario, + isUIRestricted: !!selectedInAppTutorialShortHeader.shouldRestrictUI, }); - setSelectedInAppTutorialInfo(null); + setSelectedInAppTutorialInfo(null); }, - [ - i18n, - getInAppTutorialShortHeader, - createProjectFromInAppTutorial, - askToCloseProject, - startTutorial, - selectedInAppTutorialInfo, - openFromFileMetadataWithStorageProvider, - state.editorTabs, - currentProject, - currentFileMetadata, - openAllScenes, - ] - ); + [ + i18n, + getInAppTutorialShortHeader, + createProjectFromInAppTutorial, + askToCloseProject, + startTutorial, + selectedInAppTutorialInfo, + openFromFileMetadataWithStorageProvider, + state.editorTabs, + currentProject, + currentFileMetadata, + openAllScenes, + ] + ); - const fetchNewlyAddedResources = React.useCallback( - async (): Promise => { + const fetchNewlyAddedResources = React.useCallback( + async (): Promise => { if (!currentProjectRef.current || !currentFileMetadataRef.current) return; await ensureResourcesAreFetched(() => ({ - // Use the refs to the `currentProject` and `currentFileMetadata` to ensure - // that we never fetch resources for a stale project or file metadata, even - // if it changed in the meantime (like, a save took a long time before updating - // the fileMetadata). - project: currentProjectRef.current, - fileMetadata: currentFileMetadataRef.current, - storageProvider: getStorageProvider(), - storageProviderOperations: getStorageProviderOperations(), - authenticatedUser, + // Use the refs to the `currentProject` and `currentFileMetadata` to ensure + // that we never fetch resources for a stale project or file metadata, even + // if it changed in the meantime (like, a save took a long time before updating + // the fileMetadata). + project: currentProjectRef.current, + fileMetadata: currentFileMetadataRef.current, + storageProvider: getStorageProvider(), + storageProviderOperations: getStorageProviderOperations(), + authenticatedUser, })); }, - [ - currentProjectRef, - currentFileMetadataRef, - ensureResourcesAreFetched, - getStorageProvider, - getStorageProviderOperations, - authenticatedUser, - ] - ); + [ + currentProjectRef, + currentFileMetadataRef, + ensureResourcesAreFetched, + getStorageProvider, + getStorageProviderOperations, + authenticatedUser, + ] + ); - /** (Stable) callback to launch the fetching of the resources of the project. */ - // $FlowFixMe[underconstrained-implicit-instantiation] - const onFetchNewlyAddedResources = useStableUpToDateCallback( - fetchNewlyAddedResources - ); + /** (Stable) callback to launch the fetching of the resources of the project. */ + // $FlowFixMe[underconstrained-implicit-instantiation] + const onFetchNewlyAddedResources = useStableUpToDateCallback( + fetchNewlyAddedResources + ); - const onNewResourcesAdded = React.useCallback( + const onNewResourcesAdded = React.useCallback( () => { - notifyChangesToInGameEditor({ - shouldReloadProjectData: true, - shouldReloadLibraries: false, - shouldReloadResources: false, - shouldHardReload: false, - reasons: ['added-new-resources'], - }); + notifyChangesToInGameEditor({ + shouldReloadProjectData: true, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['added-new-resources'], + }); }, - [notifyChangesToInGameEditor] - ); + [notifyChangesToInGameEditor] + ); - useKeyboardShortcuts({ - previewDebuggerServer, - onRunCommand: commandPaletteRef.current - ? commandPaletteRef.current.launchCommand - : () => {}, + useKeyboardShortcuts({ + previewDebuggerServer, + onRunCommand: commandPaletteRef.current + ? commandPaletteRef.current.launchCommand + : () => { }, }); const openCommandPalette = React.useCallback(() => { if (commandPaletteRef.current) { - commandPaletteRef.current.open(); + commandPaletteRef.current.open(); } }, []); - const { - configureNewProjectActions: configureNewProjectActionsForProfile, + const { + configureNewProjectActions: configureNewProjectActionsForProfile, } = React.useContext(PublicProfileContext); - React.useEffect( + React.useEffect( () => { - openHomePage(); - GD_STARTUP_TIMES.push(['MainFrameComponentDidMount', performance.now()]); - _loadExtensions() + openHomePage(); + GD_STARTUP_TIMES.push(['MainFrameComponentDidMount', performance.now()]); + _loadExtensions() .then(() => // Enable the GDJS development watcher *after* the extensions are loaded, // to avoid the watcher interfering with the extension loading (by updating GDJS, // which could lead in the extension loading failing for some extensions as file // are removed/copied). setState(state => ({ - ...state, - gdjsDevelopmentWatcherEnabled: true, + ...state, + gdjsDevelopmentWatcherEnabled: true, })) - ) + ) .then(async state => { - GD_STARTUP_TIMES.push([ - 'MainFrameComponentDidMountFinished', - performance.now(), - ]); + GD_STARTUP_TIMES.push([ + 'MainFrameComponentDidMountFinished', + performance.now(), + ]); - console.info('Startup times:', getStartupTimesSummary()); + console.info('Startup times:', getStartupTimesSummary()); - const { - getAutoOpenMostRecentProject, - getRecentProjectFiles, - hadProjectOpenedDuringLastSession, + const { + getAutoOpenMostRecentProject, + getRecentProjectFiles, + hadProjectOpenedDuringLastSession, } = preferences; - if (initialFileMetadataToOpen) { + if (initialFileMetadataToOpen) { // Open the initial file metadata (i.e: the file that was passed // as argument and recognized by a storage provider). Note that the storage // provider is assumed to be already set to the proper one. const storageProviderOperations = getStorageProviderOperations(); const proceed = await ensureInteractionHappened( - storageProviderOperations + storageProviderOperations ); if (proceed) openInitialFileMetadata(); } else if (initialExampleSlugToOpen) { - await fetchAndOpenNewProjectSetupDialogForExample( - initialExampleSlugToOpen - ); + await fetchAndOpenNewProjectSetupDialogForExample( + initialExampleSlugToOpen + ); } else if ( getAutoOpenMostRecentProject() && hadProjectOpenedDuringLastSession() && getRecentProjectFiles()[0] - ) { + ) { // Re-open the last opened project, if any and if asked to. const fileMetadataAndStorageProviderName = getRecentProjectFiles()[0]; const storageProvider = findStorageProviderFor( - i18n, - props.storageProviders, - fileMetadataAndStorageProviderName + i18n, + props.storageProviders, + fileMetadataAndStorageProviderName ); if (!storageProvider) return; const storageProviderOperations = getStorageProviderOperations( - storageProvider + storageProvider ); const proceed = await ensureInteractionHappened( - storageProviderOperations + storageProviderOperations ); if (proceed) - openFromFileMetadataWithStorageProvider( - fileMetadataAndStorageProviderName - ); + openFromFileMetadataWithStorageProvider( + fileMetadataAndStorageProviderName + ); } - configureNewProjectActionsForProfile({ - fetchAndOpenNewProjectSetupDialogForExample, + configureNewProjectActionsForProfile({ + fetchAndOpenNewProjectSetupDialogForExample, }); }) .catch(() => { - /* Ignore errors */ - }); + /* Ignore errors */ + }); }, - // We want to run this effect only when the component did mount. - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); + // We want to run this effect only when the component did mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); - // Register the onResourceExternallyChanged with an up to date context. - React.useEffect( + // Register the onResourceExternallyChanged with an up to date context. + React.useEffect( () => { const callbackId = registerOnResourceExternallyChangedCallback( - onResourceExternallyChanged - ); + onResourceExternallyChanged + ); return () => { - unregisterOnResourceExternallyChangedCallback(callbackId); + unregisterOnResourceExternallyChangedCallback(callbackId); }; }, - [onResourceExternallyChanged] - ); + [onResourceExternallyChanged] + ); - useMainFrameCommands({ - i18n, - project: state.currentProject, - previewEnabled: + useMainFrameCommands({ + i18n, + project: state.currentProject, + previewEnabled: !!state.currentProject && state.currentProject.getLayoutsCount() > 0, - onOpenProjectManager: toggleProjectManager, - hasPreviewsRunning: hasNonEditionPreviewsRunning, - allowNetworkPreview: - !!_previewLauncher.current && - _previewLauncher.current.canDoNetworkPreview(), - onLaunchPreview: launchNewPreview, - onHotReloadPreview: launchHotReloadPreview, - onLaunchDebugPreview: launchDebuggerAndPreview, - onLaunchNetworkPreview: launchNetworkPreview, - onLaunchPreviewWithDiagnosticReport: launchPreviewWithDiagnosticReport, + onOpenProjectManager: toggleProjectManager, + hasPreviewsRunning: hasNonEditionPreviewsRunning, + allowNetworkPreview: + !!_previewLauncher.current && + _previewLauncher.current.canDoNetworkPreview(), + onLaunchPreview: launchNewPreview, + onHotReloadPreview: launchHotReloadPreview, + onLaunchDebugPreview: launchDebuggerAndPreview, + onLaunchNetworkPreview: launchNetworkPreview, + onLaunchPreviewWithDiagnosticReport: launchPreviewWithDiagnosticReport, onOpenDiagnosticReport: () => setDiagnosticReportDialogOpen(true), - onOpenHomePage: openHomePage, + onOpenHomePage: openHomePage, onCreateProject: () => setNewProjectSetupDialogOpen(true), onOpenProject: () => openOpenFromStorageProviderDialog(), - onSaveProject: saveProject, - onSaveProjectAs: saveProjectAs, - onCloseApp: closeApp, + onSaveProject: saveProject, + onSaveProjectAs: saveProjectAs, + onCloseApp: closeApp, onCloseProject: async () => { - askToCloseProject(); + askToCloseProject(); }, onExportGame: () => { - openShareDialog('publish'); + openShareDialog('publish'); }, onInviteCollaborators: () => { - openShareDialog('invite'); + openShareDialog('invite'); }, onOpenLayout: name => { - openLayout(name); + openLayout(name); }, - onOpenExternalEvents: openExternalEvents, - onOpenExternalLayout: openExternalLayout, - onOpenEventsFunctionsExtension: openEventsFunctionsExtension, - onOpenCommandPalette: openCommandPalette, - onOpenProfile: onOpenProfileDialog, - onRestartInGameEditor, + onOpenExternalEvents: openExternalEvents, + onOpenExternalLayout: openExternalLayout, + + onOpenCinematicSequence: openCinematicSequence, + onOpenEventsFunctionsExtension: openEventsFunctionsExtension, + onOpenCommandPalette: openCommandPalette, + onOpenProfile: onOpenProfileDialog, + onRestartInGameEditor, }); - const resourceManagementProps: ResourceManagementProps = React.useMemo( + const resourceManagementProps: ResourceManagementProps = React.useMemo( () => ({ - resourceSources, - onChooseResource, - resourceExternalEditors, - getStorageProvider, - onFetchNewlyAddedResources, - getStorageProviderResourceOperations, - canInstallPrivateAsset, - onNewResourcesAdded, - onResourceUsageChanged, + resourceSources, + onChooseResource, + resourceExternalEditors, + getStorageProvider, + onFetchNewlyAddedResources, + getStorageProviderResourceOperations, + canInstallPrivateAsset, + onNewResourcesAdded, + onResourceUsageChanged, }), - [ - resourceSources, - onChooseResource, - resourceExternalEditors, - getStorageProvider, - onFetchNewlyAddedResources, - getStorageProviderResourceOperations, - canInstallPrivateAsset, - onNewResourcesAdded, - onResourceUsageChanged, - ] - ); + [ + resourceSources, + onChooseResource, + resourceExternalEditors, + getStorageProvider, + onFetchNewlyAddedResources, + getStorageProviderResourceOperations, + canInstallPrivateAsset, + onNewResourcesAdded, + onResourceUsageChanged, + ] + ); - const projectScopedContainersAccessor: ProjectScopedContainersAccessor | null = React.useMemo( + const projectScopedContainersAccessor: ProjectScopedContainersAccessor | null = React.useMemo( () => - currentProject - ? new ProjectScopedContainersAccessor({ project: currentProject }) - : null, - [currentProject] - ); + currentProject + ? new ProjectScopedContainersAccessor({project: currentProject }) + : null, + [currentProject] + ); - const { - onSelectExampleShortHeader, - onSelectPrivateGameTemplateListingData, - renderNewProjectDialog, - fetchAndOpenNewProjectSetupDialogForExample, - openNewProjectDialog, + const { + onSelectExampleShortHeader, + onSelectPrivateGameTemplateListingData, + renderNewProjectDialog, + fetchAndOpenNewProjectSetupDialogForExample, + openNewProjectDialog, } = useNewProjectDialog({ - project: state.currentProject, - fileMetadata: currentFileMetadata, - isProjectOpening, - newProjectSetupDialogOpen, - setNewProjectSetupDialogOpen, - createEmptyProject, - createProjectFromExample, - createProjectFromPrivateGameTemplate, - closeAskAi, - storageProviders: props.storageProviders, - storageProvider: getStorageProvider(), - resourceManagementProps, + project: state.currentProject, + fileMetadata: currentFileMetadata, + isProjectOpening, + newProjectSetupDialogOpen, + setNewProjectSetupDialogOpen, + createEmptyProject, + createProjectFromExample, + createProjectFromPrivateGameTemplate, + closeAskAi, + storageProviders: props.storageProviders, + storageProvider: getStorageProvider(), + resourceManagementProps, onOpenLayout: (name, options) => openLayout(name, options), - onWillInstallExtension, - onExtensionInstalled, + onWillInstallExtension, + onExtensionInstalled, }); - const gamesPlatformFrameTools = useGamesPlatformFrame({ - fetchAndOpenNewProjectSetupDialogForExample, - onOpenProfileDialog, + const gamesPlatformFrameTools = useGamesPlatformFrame({ + fetchAndOpenNewProjectSetupDialogForExample, + onOpenProfileDialog, }); - const previewLoading = previewLoadingRef.current; - const hideAskAi = - !!authenticatedUser.limits && - !!authenticatedUser.limits.capabilities.classrooms && - authenticatedUser.limits.capabilities.classrooms.hideAskAi; - const showLoaderAfterDelay = - previewLoading === 'hot-reload-for-in-game-edition'; - const showLoaderImmediately = - isProjectOpening || isLoadingProject || previewLoading === 'preview'; - - const buildMainMenuProps = { - i18n: i18n, - project: state.currentProject, - canSaveProjectAs, - recentProjectFiles: preferences.getRecentProjectFiles({ limit: 20 }), - shortcutMap, - isApplicationTopLevelMenu: false, - hideAskAi, + const previewLoading = previewLoadingRef.current; + const hideAskAi = + !!authenticatedUser.limits && + !!authenticatedUser.limits.capabilities.classrooms && + authenticatedUser.limits.capabilities.classrooms.hideAskAi; + const showLoaderAfterDelay = + previewLoading === 'hot-reload-for-in-game-edition'; + const showLoaderImmediately = + isProjectOpening || isLoadingProject || previewLoading === 'preview'; + + const buildMainMenuProps = { + i18n: i18n, + project: state.currentProject, + canSaveProjectAs, + recentProjectFiles: preferences.getRecentProjectFiles({limit: 20 }), + shortcutMap, + isApplicationTopLevelMenu: false, + hideAskAi, }; - const mainMenuCallbacks = { - onChooseProject: () => openOpenFromStorageProviderDialog(), - onOpenRecentFile: openFromFileMetadataWithStorageProvider, - onSaveProject: saveProject, - onSaveProjectAs: saveProjectAs, - onShowVersionHistory: openVersionHistoryPanel, - onCloseProject: askToCloseProject, - onCloseApp: closeApp, + const mainMenuCallbacks = { + onChooseProject: () => openOpenFromStorageProviderDialog(), + onOpenRecentFile: openFromFileMetadataWithStorageProvider, + onSaveProject: saveProject, + onSaveProjectAs: saveProjectAs, + onShowVersionHistory: openVersionHistoryPanel, + onCloseProject: askToCloseProject, + onCloseApp: closeApp, onExportProject: () => { - openShareDialog('publish'); + openShareDialog('publish'); }, onInviteCollaborators: () => { - openShareDialog('invite'); + openShareDialog('invite'); }, onCreateProject: () => setNewProjectSetupDialogOpen(true), onOpenProjectManager: () => openProjectManager(true), - onOpenHomePage: openHomePage, - onOpenDebugger: openDebugger, + onOpenHomePage: openHomePage, + onOpenDebugger: openDebugger, onOpenAbout: () => openAboutDialog(true), onOpenPreferences: () => openPreferencesDialog(true), onOpenLanguage: () => openLanguageDialog(true), - onOpenProfile: onOpenProfileDialog, - onOpenAskAi: openAskAi, - setElectronUpdateStatus: setElectronUpdateStatus, + onOpenProfile: onOpenProfileDialog, + onOpenAskAi: openAskAi, + setElectronUpdateStatus: setElectronUpdateStatus, }; - const isProjectOwnedBySomeoneElse = - !!currentFileMetadata && !!currentFileMetadata.ownerId; - const canSave = - !!state.currentProject && - !isSavingProject && - (!currentFileMetadata || !isProjectOwnedBySomeoneElse); - - const editorTabsPaneProps: EditorTabsPaneCommonProps = { - gameEditorMode, - setGameEditorMode, - editorTabs: state.editorTabs, - currentProject: currentProject, - currentFileMetadata: currentFileMetadata, - canSave: canSave, - isSavingProject: isSavingProject, - isSharingEnabled: - !checkedOutVersionStatus && !cloudProjectRecoveryOpenedVersionId, - hasPreviewsRunning: hasNonEditionPreviewsRunning, - previewState: previewState, - checkedOutVersionStatus: checkedOutVersionStatus, - canDoNetworkPreview: - !!_previewLauncher.current && - _previewLauncher.current.canDoNetworkPreview(), - gamesPlatformFrameTools: gamesPlatformFrameTools, - toggleProjectManager: toggleProjectManager, - setEditorTabs: setEditorTabs, - saveProject: saveProject, - saveProjectAsWithStorageProvider: saveProjectAsWithStorageProvider, - onCheckoutVersion: onCheckoutVersion, - getOrLoadProjectVersion: getOrLoadProjectVersion, + const isProjectOwnedBySomeoneElse = + !!currentFileMetadata && !!currentFileMetadata.ownerId; + const canSave = + !!state.currentProject && + !isSavingProject && + (!currentFileMetadata || !isProjectOwnedBySomeoneElse); + + const editorTabsPaneProps: EditorTabsPaneCommonProps = { + gameEditorMode, + setGameEditorMode, + editorTabs: state.editorTabs, + currentProject: currentProject, + currentFileMetadata: currentFileMetadata, + canSave: canSave, + isSavingProject: isSavingProject, + isSharingEnabled: + !checkedOutVersionStatus && !cloudProjectRecoveryOpenedVersionId, + hasPreviewsRunning: hasNonEditionPreviewsRunning, + previewState: previewState, + checkedOutVersionStatus: checkedOutVersionStatus, + canDoNetworkPreview: + !!_previewLauncher.current && + _previewLauncher.current.canDoNetworkPreview(), + gamesPlatformFrameTools: gamesPlatformFrameTools, + toggleProjectManager: toggleProjectManager, + setEditorTabs: setEditorTabs, + saveProject: saveProject, + saveProjectAsWithStorageProvider: saveProjectAsWithStorageProvider, + onCheckoutVersion: onCheckoutVersion, + getOrLoadProjectVersion: getOrLoadProjectVersion, openShareDialog: tab => { - openShareDialog(tab); + openShareDialog(tab); }, - launchDebuggerAndPreview: launchDebuggerAndPreview, - launchNewPreview: launchNewPreview, - launchNetworkPreview: launchNetworkPreview, - launchHotReloadPreview: launchHotReloadPreview, - launchPreviewWithDiagnosticReport: launchPreviewWithDiagnosticReport, - setPreviewOverride: setPreviewOverride, - openVersionHistoryPanel: openVersionHistoryPanel, - onQuitVersionHistory: onQuitVersionHistory, - onOpenAskAi: openAskAi, - onCloseAskAi: closeAskAi, - getStorageProvider: getStorageProvider, - // $FlowFixMe[incompatible-type] - setPreviewedLayout: setPreviewedLayout, - openExternalEvents: openExternalEvents, - openLayout: openLayout, - openTemplateFromTutorial: openTemplateFromTutorial, - openTemplateFromCourseChapter: openTemplateFromCourseChapter, - previewDebuggerServer: previewDebuggerServer, - hotReloadPreviewButtonProps: hotReloadPreviewButtonProps, - resourceManagementProps: resourceManagementProps, - onCreateEventsFunction: onCreateEventsFunction, - openInstructionOrExpression: openInstructionOrExpression, - onOpenCustomObjectEditor: openCustomObjectEditor, - onRenamedEventsBasedObject: onRenamedEventsBasedObject, - onDeletedEventsBasedObject: onDeletedEventsBasedObject, - openObjectEvents: openObjectEvents, - canOpen: !!props.storageProviders.filter( - ({ hiddenInOpenDialog }) => !hiddenInOpenDialog - ).length, - openOpenFromStorageProviderDialog: openOpenFromStorageProviderDialog, - openFromFileMetadataWithStorageProvider: openFromFileMetadataWithStorageProvider, - openNewProjectDialog: openNewProjectDialog, - openProjectManager: openProjectManager, - askToCloseProject: askToCloseProject, - closeProject: closeProject, - onSelectExampleShortHeader: onSelectExampleShortHeader, - onSelectPrivateGameTemplateListingData: onSelectPrivateGameTemplateListingData, - createEmptyProject: createEmptyProject, - createProjectFromExample: createProjectFromExample, - onOpenProfileDialog: onOpenProfileDialog, - openLanguageDialog: openLanguageDialog, - openPreferencesDialog: openPreferencesDialog, - openAboutDialog: openAboutDialog, - selectInAppTutorial: selectInAppTutorial, - eventsFunctionsExtensionsState: eventsFunctionsExtensionsState, - isProjectClosedSoAvoidReloadingExtensions: isProjectClosedSoAvoidReloadingExtensions, - renameResourcesInProject: renameResourcesInProject, - openBehaviorEvents: openBehaviorEvents, - onExtractAsExternalLayout: onExtractAsExternalLayout, - onExtractAsEventBasedObject: onExtractAsEventBasedObject, - onEventBasedObjectTypeChanged: onEventBasedObjectTypeChanged, - onOpenEventBasedObjectEditor: onOpenEventBasedObjectEditor, - onOpenEventBasedObjectVariantEditor: onOpenEventBasedObjectVariantEditor, - deleteEventsBasedObjectVariant: deleteEventsBasedObjectVariant, - onEventsBasedObjectChildrenEdited: onEventsBasedObjectChildrenEdited, - onLoadEventsFunctionsExtensions: onLoadEventsFunctionsExtensions, - onSceneObjectEdited: onSceneObjectEdited, - onSceneObjectsDeleted: onSceneObjectsDeleted, - onSceneEventsModifiedOutsideEditor: onSceneEventsModifiedOutsideEditor, - onInstancesModifiedOutsideEditor: onInstancesModifiedOutsideEditor, - onObjectsModifiedOutsideEditor: onObjectsModifiedOutsideEditor, - onObjectGroupsModifiedOutsideEditor: onObjectGroupsModifiedOutsideEditor, - onWillInstallExtension: onWillInstallExtension, - onExtensionInstalled: onExtensionInstalled, - onEffectAdded: onEffectAdded, - onObjectListsModified: onObjectListsModified, - onExternalLayoutAssociationChanged, - gamesList: gamesList, - triggerHotReloadInGameEditorIfNeeded, - onRestartInGameEditor, - showRestartInGameEditorAfterErrorButton, - toolbarButtons: state.toolbarButtons, - projectPath: currentFileMetadata - ? getProjectDirectory(currentFileMetadata.fileIdentifier) - : null, + launchDebuggerAndPreview: launchDebuggerAndPreview, + launchNewPreview: launchNewPreview, + launchNetworkPreview: launchNetworkPreview, + launchHotReloadPreview: launchHotReloadPreview, + launchPreviewWithDiagnosticReport: launchPreviewWithDiagnosticReport, + setPreviewOverride: setPreviewOverride, + openVersionHistoryPanel: openVersionHistoryPanel, + onQuitVersionHistory: onQuitVersionHistory, + onOpenAskAi: openAskAi, + onCloseAskAi: closeAskAi, + getStorageProvider: getStorageProvider, + // $FlowFixMe[incompatible-type] + setPreviewedLayout: setPreviewedLayout, + openExternalEvents: openExternalEvents, + openLayout: openLayout, + openTemplateFromTutorial: openTemplateFromTutorial, + openTemplateFromCourseChapter: openTemplateFromCourseChapter, + previewDebuggerServer: previewDebuggerServer, + hotReloadPreviewButtonProps: hotReloadPreviewButtonProps, + resourceManagementProps: resourceManagementProps, + onCreateEventsFunction: onCreateEventsFunction, + openInstructionOrExpression: openInstructionOrExpression, + onOpenCustomObjectEditor: openCustomObjectEditor, + onRenamedEventsBasedObject: onRenamedEventsBasedObject, + onDeletedEventsBasedObject: onDeletedEventsBasedObject, + openObjectEvents: openObjectEvents, + canOpen: !!props.storageProviders.filter( + ({hiddenInOpenDialog}) => !hiddenInOpenDialog + ).length, + openOpenFromStorageProviderDialog: openOpenFromStorageProviderDialog, + openFromFileMetadataWithStorageProvider: openFromFileMetadataWithStorageProvider, + openNewProjectDialog: openNewProjectDialog, + openProjectManager: openProjectManager, + askToCloseProject: askToCloseProject, + closeProject: closeProject, + onSelectExampleShortHeader: onSelectExampleShortHeader, + onSelectPrivateGameTemplateListingData: onSelectPrivateGameTemplateListingData, + createEmptyProject: createEmptyProject, + createProjectFromExample: createProjectFromExample, + onOpenProfileDialog: onOpenProfileDialog, + openLanguageDialog: openLanguageDialog, + openPreferencesDialog: openPreferencesDialog, + openAboutDialog: openAboutDialog, + selectInAppTutorial: selectInAppTutorial, + eventsFunctionsExtensionsState: eventsFunctionsExtensionsState, + isProjectClosedSoAvoidReloadingExtensions: isProjectClosedSoAvoidReloadingExtensions, + renameResourcesInProject: renameResourcesInProject, + openBehaviorEvents: openBehaviorEvents, + onExtractAsExternalLayout: onExtractAsExternalLayout, + onExtractAsEventBasedObject: onExtractAsEventBasedObject, + onEventBasedObjectTypeChanged: onEventBasedObjectTypeChanged, + onOpenEventBasedObjectEditor: onOpenEventBasedObjectEditor, + onOpenEventBasedObjectVariantEditor: onOpenEventBasedObjectVariantEditor, + deleteEventsBasedObjectVariant: deleteEventsBasedObjectVariant, + onEventsBasedObjectChildrenEdited: onEventsBasedObjectChildrenEdited, + onLoadEventsFunctionsExtensions: onLoadEventsFunctionsExtensions, + onSceneObjectEdited: onSceneObjectEdited, + onSceneObjectsDeleted: onSceneObjectsDeleted, + onSceneEventsModifiedOutsideEditor: onSceneEventsModifiedOutsideEditor, + onInstancesModifiedOutsideEditor: onInstancesModifiedOutsideEditor, + onObjectsModifiedOutsideEditor: onObjectsModifiedOutsideEditor, + onObjectGroupsModifiedOutsideEditor: onObjectGroupsModifiedOutsideEditor, + onWillInstallExtension: onWillInstallExtension, + onExtensionInstalled: onExtensionInstalled, + onEffectAdded: onEffectAdded, + onObjectListsModified: onObjectListsModified, + onExternalLayoutAssociationChanged, + gamesList: gamesList, + triggerHotReloadInGameEditorIfNeeded, + onRestartInGameEditor, + showRestartInGameEditorAfterErrorButton, + toolbarButtons: state.toolbarButtons, + projectPath: currentFileMetadata + ? getProjectDirectory(currentFileMetadata.fileIdentifier) + : null, }; - const hasEditorsInLeftPane = hasEditorsInPane(state.editorTabs, 'left'); - const hasEditorsInRightPane = hasEditorsInPane(state.editorTabs, 'right'); + const hasEditorsInLeftPane = hasEditorsInPane(state.editorTabs, 'left'); + const hasEditorsInRightPane = hasEditorsInPane(state.editorTabs, 'right'); - return ( -
- {!!renderPreviewLauncher && - renderPreviewLauncher( - { - crashReportUploadLevel: - preferences.values.previewCrashReportUploadLevel || - 'exclude-javascript-code-events', - previewContext: quickCustomizationDialogOpenedFromGameId - ? 'preview-quick-customization' - : 'preview', - sourceGameId: quickCustomizationDialogOpenedFromGameId || '', - getIncludeFileHashs: - eventsFunctionsExtensionsContext.getIncludeFileHashs, - onExport: () => { - openShareDialog('publish'); - }, - onCaptureFinished, - }, - (previewLauncher: ?PreviewLauncherInterface) => { - _previewLauncher.current = previewLauncher; - } - )} - - {!!renderMainMenu && - renderMainMenu( - { ...buildMainMenuProps, isApplicationTopLevelMenu: true }, - mainMenuCallbacks, - { - onClosePreview: - _previewLauncher.current && _previewLauncher.current.closePreview - ? _previewLauncher.current.closePreview - : null, - } - )} - - - openLayout(name, options)} - onOpenExternalLayout={openExternalLayout} - onOpenEventsFunctionsExtension={openEventsFunctionsExtension} - onDeleteLayout={deleteLayout} - onDeleteExternalLayout={deleteExternalLayout} - onDeleteEventsFunctionsExtension={deleteEventsFunctionsExtension} - onDeleteExternalEvents={deleteExternalEvents} - onRenameLayout={renameLayout} - onRenameExternalLayout={renameExternalLayout} - onRenameEventsFunctionsExtension={renameEventsFunctionsExtension} - onRenameExternalEvents={renameExternalEvents} - onOpenResources={openResources} - onReloadEventsFunctionsExtensions={onReloadEventsFunctionsExtensions} - onWillInstallExtension={onWillInstallExtension} - onExtensionInstalled={onExtensionInstalled} - onSceneAdded={onSceneAdded} - onExternalLayoutAdded={onExternalLayoutAdded} - onShareProject={() => { - openShareDialog(); - }} - isOpen={projectManagerOpen} - hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} - resourceManagementProps={resourceManagementProps} - projectScopedContainersAccessor={projectScopedContainersAccessor} - gamesList={gamesList} - onOpenHomePage={openHomePage} - toggleProjectManager={toggleProjectManager} - mainMenuCallbacks={mainMenuCallbacks} - // $FlowFixMe[incompatible-type] - buildMainMenuProps={buildMainMenuProps} - /> - - {// Render games platform frame before the editors, so the editor have priority - // in what to display (ex: Loader of play section) - gamesPlatformFrameTools.renderGamesPlatformFrame()} - - ( - - )} - /> - - - - {state.snackMessage}} - /> - {shareDialogOpen && - renderShareDialog({ - onClose: closeShareDialog, - onChangeSubscription: closeShareDialog, - project: state.currentProject, - onSaveProject: saveProject, - isSavingProject: isSavingProject, - fileMetadata: currentFileMetadata, - storageProvider: getStorageProvider(), - initialTab: shareDialogInitialTab, - gamesList, - })} - {chooseResourceOptions && onResourceChosen && !!currentProject && ( - { - setOnResourceChosen(null); - setChooseResourceOptions(null); - onResourceChosen(resourcesOptions); - }} - onClose={() => { - setOnResourceChosen(null); - setChooseResourceOptions(null); - onResourceChosen({ - selectedResources: [], - selectedSourceName: '', - }); - }} - options={chooseResourceOptions} - /> - )} - {profileDialogOpen && ( - // ProfileDialog is dependent on multiple contexts, - // which are dependent of AuthenticatedUserContext. - // So it cannot be moved inside the AuthenticatedUserProvider, - // otherwise, those contexts would not be correctly mounted, - // as they are defined after the AuthenticatedUserProvider in Providers.js. - { - openProfileDialog(false); - }} - /> - )} - {authenticatedUser.claimedProductOptions && ( - // PurchaseClaimDialog is dependent on SubscriptionContext, - // which is defined after the AuthenticatedUserProvider in Providers.js. - // So it cannot be rendered inside the AuthenticatedUserProvider. - - )} - {renderNewProjectDialog()} - {cloudProjectFileMetadataToRecover && ( - setCloudProjectFileMetadataToRecover(null)} - onOpenPreviousVersion={onOpenCloudProjectOnSpecificVersionForRecovery} - /> - )} - {cloudProjectSaveChoiceOpen && ( - setCloudProjectSaveChoiceOpen(false)} - onSaveAsMainVersion={saveProject} - onSaveAsDuplicate={saveProjectAs} - /> - )} - {preferencesDialogOpen && ( - { - openPreferencesDialog(false); - if (options.languageDidChange) _languageDidChange(); - }} - onOpenQuickCustomizationDialog={() => - setQuickCustomizationDialogOpenedFromGameId( - 'fake-source-game-id-for-testing' - ) - } - /> - )} - {languageDialogOpen && ( - { - openLanguageDialog(false); - if (options.languageDidChange) _languageDidChange(); - }} - /> - )} - {aboutDialogOpen && ( - openAboutDialog(false)} - updateStatus={updateStatus} - /> - )} - {state.openFromStorageProviderDialogOpen && ( - openOpenFromStorageProviderDialog(false)} - storageProviders={props.storageProviders} - onChooseProvider={storageProvider => { - openOpenFromStorageProviderDialog(false); - getStorageProviderOperations(storageProvider); - chooseProjectWithStorageProviderPicker(); - }} - /> - )} - {state.saveToStorageProviderDialogOpen && ( - openSaveToStorageProviderDialog(false)} - storageProviders={props.storageProviders} - onChooseProvider={storageProvider => { - openSaveToStorageProviderDialog(false); - saveProjectAsWithStorageProvider({ - requestedStorageProvider: storageProvider, - }); - }} - /> - )} - {renderOpenConfirmDialog()} - {renderLeaderboardReplacerDialog()} - {renderResourceMoverDialog()} - {renderResourceFetcherDialog()} - {renderVersionHistoryPanel()} - {renderSaveReminder()} - {renderExtensionLoadErrorDialog()} - - - {selectedInAppTutorialInfo && ( - item === 100 - ) - ? 'complete' - : 'started' - } - tutorialId={selectedInAppTutorialInfo.tutorialId} - startTutorial={startSelectedTutorial} - onClose={() => { - setSelectedInAppTutorialInfo(null); - }} - isProjectOpening={isProjectOpening} - /> - )} - {state.gdjsDevelopmentWatcherEnabled && - renderGDJSDevelopmentWatcher && - renderGDJSDevelopmentWatcher({ - onGDJSUpdated: relaunchAndThenHardReloadAllPreviews, - })} - {gameHotReloadLogs.length > 0 && ( - { - clearGameHotReloadLogs(); - launchNewPreview(); - }} - /> - )} - {(editorHotReloadLogs.length > 0 || editorUncaughtError !== null) && ( - { - clearEditorHotReloadLogs(); - clearEditorUncaughtError(); - setShowRestartInGameEditorAfterErrorButton(true); - }} - onLaunchNewPreview={() => { - clearEditorHotReloadLogs(); - clearEditorUncaughtError(); - onRestartInGameEditor( - 'relaunched-after-uncaught-error-or-hot-reload-error' - ); - }} - /> - )} - {currentlyRunningInAppTutorial && ( - + {!!renderPreviewLauncher && + renderPreviewLauncher( + { + crashReportUploadLevel: + preferences.values.previewCrashReportUploadLevel || + 'exclude-javascript-code-events', + previewContext: quickCustomizationDialogOpenedFromGameId + ? 'preview-quick-customization' + : 'preview', + sourceGameId: quickCustomizationDialogOpenedFromGameId || '', + getIncludeFileHashs: + eventsFunctionsExtensionsContext.getIncludeFileHashs, + onExport: () => { + openShareDialog('publish'); + }, + onCaptureFinished, + }, + (previewLauncher: ?PreviewLauncherInterface) => { + _previewLauncher.current = previewLauncher; + } + )} + + {!!renderMainMenu && + renderMainMenu( + { ...buildMainMenuProps, isApplicationTopLevelMenu: true }, + mainMenuCallbacks, + { + onClosePreview: + _previewLauncher.current && _previewLauncher.current.closePreview + ? _previewLauncher.current.closePreview + : null, + } + )} + + + openLayout(name, options)} + onOpenExternalLayout={openExternalLayout} + + onOpenCinematicSequence={openCinematicSequence} + onOpenEventsFunctionsExtension={openEventsFunctionsExtension} + onDeleteLayout={deleteLayout} + onDeleteExternalLayout={deleteExternalLayout} + onDeleteEventsFunctionsExtension={deleteEventsFunctionsExtension} + onDeleteExternalEvents={deleteExternalEvents} + onRenameLayout={renameLayout} + onRenameExternalLayout={renameExternalLayout} + onRenameEventsFunctionsExtension={renameEventsFunctionsExtension} + onRenameExternalEvents={renameExternalEvents} + onOpenResources={openResources} + onReloadEventsFunctionsExtensions={onReloadEventsFunctionsExtensions} + onWillInstallExtension={onWillInstallExtension} + onExtensionInstalled={onExtensionInstalled} + onSceneAdded={onSceneAdded} + onExternalLayoutAdded={onExternalLayoutAdded} + onShareProject={() => { + openShareDialog(); + }} + isOpen={projectManagerOpen} + hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} + resourceManagementProps={resourceManagementProps} + projectScopedContainersAccessor={projectScopedContainersAccessor} + gamesList={gamesList} + onOpenHomePage={openHomePage} + toggleProjectManager={toggleProjectManager} + mainMenuCallbacks={mainMenuCallbacks} + // $FlowFixMe[incompatible-type] + buildMainMenuProps={buildMainMenuProps} + /> + + {// Render games platform frame before the editors, so the editor have priority + // in what to display (ex: Loader of play section) + gamesPlatformFrameTools.renderGamesPlatformFrame()} + + ( + + )} + /> + + + + {state.snackMessage}} + /> + {shareDialogOpen && + renderShareDialog({ + onClose: closeShareDialog, + onChangeSubscription: closeShareDialog, + project: state.currentProject, + onSaveProject: saveProject, + isSavingProject: isSavingProject, + fileMetadata: currentFileMetadata, + storageProvider: getStorageProvider(), + initialTab: shareDialogInitialTab, + gamesList, + })} + {chooseResourceOptions && onResourceChosen && !!currentProject && ( + { + setOnResourceChosen(null); + setChooseResourceOptions(null); + onResourceChosen(resourcesOptions); + }} + onClose={() => { + setOnResourceChosen(null); + setChooseResourceOptions(null); + onResourceChosen({ + selectedResources: [], + selectedSourceName: '', + }); + }} + options={chooseResourceOptions} + /> + )} + {profileDialogOpen && ( + // ProfileDialog is dependent on multiple contexts, + // which are dependent of AuthenticatedUserContext. + // So it cannot be moved inside the AuthenticatedUserProvider, + // otherwise, those contexts would not be correctly mounted, + // as they are defined after the AuthenticatedUserProvider in Providers.js. + { + openProfileDialog(false); + }} + /> + )} + {authenticatedUser.claimedProductOptions && ( + // PurchaseClaimDialog is dependent on SubscriptionContext, + // which is defined after the AuthenticatedUserProvider in Providers.js. + // So it cannot be rendered inside the AuthenticatedUserProvider. + + )} + {renderNewProjectDialog()} + {cloudProjectFileMetadataToRecover && ( + setCloudProjectFileMetadataToRecover(null)} + onOpenPreviousVersion={onOpenCloudProjectOnSpecificVersionForRecovery} + /> + )} + {cloudProjectSaveChoiceOpen && ( + setCloudProjectSaveChoiceOpen(false)} + onSaveAsMainVersion={saveProject} + onSaveAsDuplicate={saveProjectAs} + /> + )} + {preferencesDialogOpen && ( + { + openPreferencesDialog(false); + if (options.languageDidChange) _languageDidChange(); + }} + onOpenQuickCustomizationDialog={() => + setQuickCustomizationDialogOpenedFromGameId( + 'fake-source-game-id-for-testing' + ) + } + /> + )} + {languageDialogOpen && ( + { + openLanguageDialog(false); + if (options.languageDidChange) _languageDidChange(); + }} + /> + )} + {aboutDialogOpen && ( + openAboutDialog(false)} + updateStatus={updateStatus} + /> + )} + {state.openFromStorageProviderDialogOpen && ( + openOpenFromStorageProviderDialog(false)} + storageProviders={props.storageProviders} + onChooseProvider={storageProvider => { + openOpenFromStorageProviderDialog(false); + getStorageProviderOperations(storageProvider); + chooseProjectWithStorageProviderPicker(); + }} + /> + )} + {state.saveToStorageProviderDialogOpen && ( + openSaveToStorageProviderDialog(false)} + storageProviders={props.storageProviders} + onChooseProvider={storageProvider => { + openSaveToStorageProviderDialog(false); + saveProjectAsWithStorageProvider({ + requestedStorageProvider: storageProvider, + }); + }} + /> + )} + {renderOpenConfirmDialog()} + {renderLeaderboardReplacerDialog()} + {renderResourceMoverDialog()} + {renderResourceFetcherDialog()} + {renderVersionHistoryPanel()} + {renderSaveReminder()} + {renderExtensionLoadErrorDialog()} + + + {selectedInAppTutorialInfo && ( + item === 100 + ) + ? 'complete' + : 'started' + } + tutorialId={selectedInAppTutorialInfo.tutorialId} + startTutorial={startSelectedTutorial} + onClose={() => { + setSelectedInAppTutorialInfo(null); + }} + isProjectOpening={isProjectOpening} + /> + )} + {state.gdjsDevelopmentWatcherEnabled && + renderGDJSDevelopmentWatcher && + renderGDJSDevelopmentWatcher({ + onGDJSUpdated: relaunchAndThenHardReloadAllPreviews, + })} + {gameHotReloadLogs.length > 0 && ( + { + clearGameHotReloadLogs(); + launchNewPreview(); + }} + /> + )} + {(editorHotReloadLogs.length > 0 || editorUncaughtError !== null) && ( + { + clearEditorHotReloadLogs(); + clearEditorUncaughtError(); + setShowRestartInGameEditorAfterErrorButton(true); + }} + onLaunchNewPreview={() => { + clearEditorHotReloadLogs(); + clearEditorUncaughtError(); + onRestartInGameEditor( + 'relaunched-after-uncaught-error-or-hot-reload-error' + ); + }} + /> + )} + {currentlyRunningInAppTutorial && ( + { if ( shouldWarnAboutUnsavedChanges && currentProject && (!currentFileMetadata || hasUnsavedChanges) - ) { - setQuitInAppTutorialDialogOpen(true); + ) { + setQuitInAppTutorialDialogOpen(true); } else { - endTutorial(shouldCloseProject); - } - }} - {...orchestratorProps} - /> - )} - {quitInAppTutorialDialogOpen && ( - setQuitInAppTutorialDialogOpen(false)} - isSavingProject={isSavingProject} - canEndTutorial={!!currentFileMetadata && !hasUnsavedChanges} - endTutorial={() => { - endTutorial(true); - }} - /> - )} - {diagnosticReportDialogOpen && currentProject && ( - setDiagnosticReportDialogOpen(false)} - onNavigateToLayoutEvent={(layoutName, eventPath) => { - setPendingEventNavigation({ - name: layoutName, - locationType: 'layout', - eventPath, - }); - openLayout(layoutName, { - openEventsEditor: true, - openSceneEditor: false, - focusWhenOpened: 'events', - }); - }} - onNavigateToExternalEventsEvent={(externalEventsName, eventPath) => { - setPendingEventNavigation({ - name: externalEventsName, - locationType: 'external-events', - eventPath, - }); - openExternalEvents(externalEventsName); - }} - /> - )} - {standaloneDialogOpen && ( - setStandaloneDialogOpen(false)} /> - )} - {quickCustomizationDialogOpenedFromGameId && currentProject && ( - { - if (hasUnsavedChanges) { - const response = await showConfirmation({ - title: t`Leave the customization?`, - message: t`Do you want to quit the customization? All your changes will be lost.`, - confirmButtonLabel: t`Leave`, - }); - - if (!response) { - return; - } - } - - setQuickCustomizationDialogOpenedFromGameId(null); - await closeProject(); - openHomePage(); - if (!hasUnsavedChanges) { - navigateToRoute('build'); - } - }} - onlineWebExporter={quickPublishOnlineWebExporter} - isRequiredToSaveAsNewCloudProject={() => { - const storageProvider = getStorageProvider(); - return storageProvider.internalName !== 'Cloud'; - }} - onSaveProject={async () => { - // Automatically try to save project to the cloud. - const storageProvider = getStorageProvider(); - if (storageProvider.internalName === 'Cloud') { - saveProject(); - return; - } - - if ( - !['Empty', 'UrlStorageProvider'].includes( - storageProvider.internalName - ) - ) { - console.error( - `Unexpected storage provider ${ - storageProvider.internalName - } when saving project from quick customization dialog. Saving anyway as a new cloud project.` - ); + endTutorial(shouldCloseProject); } - - saveProjectAsWithStorageProvider({ - requestedStorageProvider: CloudStorageProvider, - forcedSavedAsLocation: { - name: currentProject.getName(), - }, - }); - return; }} - isSavingProject={isSavingProject} - canClose - sourceGameId={quickCustomizationDialogOpenedFromGameId} - gameScreenshotUrls={getGameUnverifiedScreenshotUrls( - currentProject.getProjectUuid() - )} - onScreenshotsClaimed={onGameScreenshotsClaimed} - onWillInstallExtension={onWillInstallExtension} - onExtensionInstalled={onExtensionInstalled} + {...orchestratorProps} /> )} - -
- ); + {quitInAppTutorialDialogOpen && ( + setQuitInAppTutorialDialogOpen(false)} + isSavingProject={isSavingProject} + canEndTutorial={!!currentFileMetadata && !hasUnsavedChanges} + endTutorial={() => { + endTutorial(true); + }} + /> + )} + {diagnosticReportDialogOpen && currentProject && ( + setDiagnosticReportDialogOpen(false)} + onNavigateToLayoutEvent={(layoutName, eventPath) => { + setPendingEventNavigation({ + name: layoutName, + locationType: 'layout', + eventPath, + }); + openLayout(layoutName, { + openEventsEditor: true, + openSceneEditor: false, + focusWhenOpened: 'events', + }); + }} + onNavigateToExternalEventsEvent={(externalEventsName, eventPath) => { + setPendingEventNavigation({ + name: externalEventsName, + locationType: 'external-events', + eventPath, + }); + openExternalEvents(externalEventsName); + }} + /> + )} + {standaloneDialogOpen && ( + setStandaloneDialogOpen(false)} /> + )} + {quickCustomizationDialogOpenedFromGameId && currentProject && ( + { + if (hasUnsavedChanges) { + const response = await showConfirmation({ + title: t`Leave the customization?`, + message: t`Do you want to quit the customization? All your changes will be lost.`, + confirmButtonLabel: t`Leave`, + }); + + if (!response) { + return; + } + } + + setQuickCustomizationDialogOpenedFromGameId(null); + await closeProject(); + openHomePage(); + if (!hasUnsavedChanges) { + navigateToRoute('build'); + } + }} + onlineWebExporter={quickPublishOnlineWebExporter} + isRequiredToSaveAsNewCloudProject={() => { + const storageProvider = getStorageProvider(); + return storageProvider.internalName !== 'Cloud'; + }} + onSaveProject={async () => { + // Automatically try to save project to the cloud. + const storageProvider = getStorageProvider(); + if (storageProvider.internalName === 'Cloud') { + saveProject(); + return; + } + + if ( + !['Empty', 'UrlStorageProvider'].includes( + storageProvider.internalName + ) + ) { + console.error( + `Unexpected storage provider ${storageProvider.internalName + } when saving project from quick customization dialog. Saving anyway as a new cloud project.` + ); + } + + saveProjectAsWithStorageProvider({ + requestedStorageProvider: CloudStorageProvider, + forcedSavedAsLocation: { + name: currentProject.getName(), + }, + }); + return; + }} + isSavingProject={isSavingProject} + canClose + sourceGameId={quickCustomizationDialogOpenedFromGameId} + gameScreenshotUrls={getGameUnverifiedScreenshotUrls( + currentProject.getProjectUuid() + )} + onScreenshotsClaimed={onGameScreenshotsClaimed} + onWillInstallExtension={onWillInstallExtension} + onExtensionInstalled={onExtensionInstalled} + /> + )} + +
+ ); }; -export default MainFrame; + export default MainFrame; diff --git a/newIDE/app/src/ProjectManager/CinematicSequenceTreeViewItemContent.js b/newIDE/app/src/ProjectManager/CinematicSequenceTreeViewItemContent.js new file mode 100644 index 000000000000..f5d81ee0eff4 --- /dev/null +++ b/newIDE/app/src/ProjectManager/CinematicSequenceTreeViewItemContent.js @@ -0,0 +1,231 @@ +// @flow +import { type I18n as I18nType } from '@lingui/core'; +import { t } from '@lingui/macro'; + +import * as React from 'react'; +import newNameGenerator from '../Utils/NewNameGenerator'; +import Clipboard from '../Utils/Clipboard'; +import { SafeExtractor } from '../Utils/SafeExtractor'; +import { + serializeToJSObject, + unserializeFromJSObject, +} from '../Utils/Serializer'; +import { + // $FlowFixMe[import-type-as-value] + TreeViewItemContent, + type TreeItemProps, + cinematicSequencesRootFolderId, +} from '.'; +import { type HTMLDataset } from '../Utils/HTMLDataset'; + +const CINEMATIC_SEQUENCE_CLIPBOARD_KIND = 'Cinematic sequence'; + +export type CinematicSequenceTreeViewItemCallbacks = {| + onCinematicSequenceAdded: () => void, + onDeleteCinematicSequence: gdCinematicSequence => void, + onRenameCinematicSequence: (string, string) => void, + onOpenCinematicSequence: string => void, +|}; + +export type CinematicSequenceTreeViewItemCommonProps = {| + ...TreeItemProps, + ...CinematicSequenceTreeViewItemCallbacks, +|}; + +export type CinematicSequenceTreeViewItemProps = {| + ...CinematicSequenceTreeViewItemCommonProps, + project: gdProject, +|}; + +export const getCinematicSequenceTreeViewItemId = ( + cinematicSequence: gdCinematicSequence +): string => { + // Pointers are used because they stay the same even when the names are + // changed. + return `cinematic-sequence-${cinematicSequence.ptr}`; +}; + +export class CinematicSequenceTreeViewItemContent implements TreeViewItemContent { + cinematicSequence: gdCinematicSequence; + props: CinematicSequenceTreeViewItemProps; + + constructor( + cinematicSequence: gdCinematicSequence, + props: CinematicSequenceTreeViewItemProps + ) { + this.cinematicSequence = cinematicSequence; + this.props = props; + } + + isDescendantOf(itemContent: TreeViewItemContent): boolean { + return itemContent.getId() === cinematicSequencesRootFolderId; + } + + getRootId(): string { + return cinematicSequencesRootFolderId; + } + + getName(): string | React.Node { + return this.cinematicSequence.getName(); + } + + getId(): string { + return getCinematicSequenceTreeViewItemId(this.cinematicSequence); + } + + getHtmlId(index: number): ?string { + return `cinematic-sequence-item-${index}`; + } + + getDataSet(): ?HTMLDataset { + return { + 'cinematic-sequence': this.cinematicSequence.getName(), + }; + } + + getThumbnail(): ?string { + return 'res/icons_default/camera_black.svg'; + } + + onClick(): void { + this.props.onOpenCinematicSequence(this.cinematicSequence.getName()); + } + + rename(newName: string): void { + const oldName = this.cinematicSequence.getName(); + if (oldName === newName) { + return; + } + this.props.onRenameCinematicSequence(oldName, newName); + } + + edit(): void { + this.props.editName(this.getId()); + } + + buildMenuTemplate(i18n: I18nType, index: number): any { + return [ + { + label: i18n._(t`Rename`), + click: () => this.edit(), + accelerator: 'F2', + }, + { + label: i18n._(t`Delete`), + click: () => this.delete(), + accelerator: 'Backspace', + }, + { + type: 'separator', + }, + { + label: i18n._(t`Copy`), + click: () => this.copy(), + accelerator: 'CmdOrCtrl+C', + }, + { + label: i18n._(t`Cut`), + click: () => this.cut(), + accelerator: 'CmdOrCtrl+X', + }, + { + label: i18n._(t`Paste`), + enabled: Clipboard.has(CINEMATIC_SEQUENCE_CLIPBOARD_KIND), + click: () => this.paste(), + accelerator: 'CmdOrCtrl+V', + }, + { + label: i18n._(t`Duplicate`), + click: () => this._duplicate(), + }, + ]; + } + + renderRightComponent(i18n: I18nType): ?React.Node { + return null; + } + + delete(): void { + this.props.onDeleteCinematicSequence(this.cinematicSequence); + } + + getIndex(): number { + return this.props.project.getCinematicSequencePosition( + this.cinematicSequence.getName() + ); + } + + moveAt(destinationIndex: number): void { + const originIndex = this.getIndex(); + if (destinationIndex !== originIndex) { + this.props.project.moveCinematicSequence( + originIndex, + // When moving the item down, it must not be counted. + destinationIndex + (destinationIndex <= originIndex ? 0 : -1) + ); + this._onProjectItemModified(); + } + } + + copy(): void { + Clipboard.set(CINEMATIC_SEQUENCE_CLIPBOARD_KIND, { + cinematicSequence: serializeToJSObject(this.cinematicSequence), + name: this.cinematicSequence.getName(), + }); + } + + cut(): void { + this.copy(); + this.delete(); + } + + paste(): void { + if (!Clipboard.has(CINEMATIC_SEQUENCE_CLIPBOARD_KIND)) return; + + const clipboardContent = Clipboard.get(CINEMATIC_SEQUENCE_CLIPBOARD_KIND); + const copiedCinematicSequence = SafeExtractor.extractObjectProperty( + clipboardContent, + 'cinematicSequence' + ); + const name = SafeExtractor.extractStringProperty(clipboardContent, 'name'); + if (!name || !copiedCinematicSequence) return; + + const project = this.props.project; + const newName = newNameGenerator(name, name => + project.hasCinematicSequenceNamed(name) + ); + + const newCinematicSequence = project.insertNewCinematicSequence( + newName, + this.getIndex() + 1 + ); + + unserializeFromJSObject( + newCinematicSequence, + copiedCinematicSequence, + 'unserializeFrom', + project + ); + // Unserialization has overwritten the name. + newCinematicSequence.setName(newName); + + this._onProjectItemModified(); + this.props.editName(getCinematicSequenceTreeViewItemId(newCinematicSequence)); + this.props.onCinematicSequenceAdded(); + } + + _duplicate(): void { + this.copy(); + this.paste(); + } + + _onProjectItemModified() { + if (this.props.unsavedChanges) + this.props.unsavedChanges.triggerUnsavedChanges(); + this.props.forceUpdate(); + } + + getRightButton(i18n: I18nType): any { + return null; + } +} diff --git a/newIDE/app/src/ProjectManager/index.js b/newIDE/app/src/ProjectManager/index.js index f2b23519fceb..acd5878069ba 100644 --- a/newIDE/app/src/ProjectManager/index.js +++ b/newIDE/app/src/ProjectManager/index.js @@ -65,6 +65,12 @@ import { type ExternalLayoutTreeViewItemProps, type ExternalLayoutTreeViewItemCallbacks, } from './ExternalLayoutTreeViewItemContent'; +import { + CinematicSequenceTreeViewItemContent, + getCinematicSequenceTreeViewItemId, + type CinematicSequenceTreeViewItemProps, + type CinematicSequenceTreeViewItemCallbacks, +} from './CinematicSequenceTreeViewItemContent'; import { type MenuItemTemplate } from '../UI/Menu/Menu.flow'; import useAlertDialog from '../UI/Alert/useAlertDialog'; import { type ShowConfirmDeleteDialogOptions } from '../UI/Alert/AlertContext'; @@ -105,11 +111,15 @@ export const externalEventsRootFolderId: string = getProjectManagerItemId( export const externalLayoutsRootFolderId: string = getProjectManagerItemId( 'external-layout' ); +export const cinematicSequencesRootFolderId: string = getProjectManagerItemId( + 'cinematic-sequences' +); const scenesEmptyPlaceholderId = 'scenes-placeholder'; const extensionsEmptyPlaceholderId = 'extensions-placeholder'; const externalEventsEmptyPlaceholderId = 'external-events-placeholder'; const externalLayoutEmptyPlaceholderId = 'external-layout-placeholder'; +const cinematicSequenceEmptyPlaceholderId = 'cinematic-sequence-placeholder'; const styles = { listContainer: { @@ -150,21 +160,21 @@ interface TreeViewItem { isRoot?: boolean; isPlaceholder?: boolean; +content: TreeViewItemContent; - getChildren(i18n: I18nType): ?Array; +getChildren(i18n: I18nType): ?Array < TreeViewItem >; } export type TreeItemProps = {| forceUpdate: () => void, - forceUpdateList: () => void, - unsavedChanges?: ?UnsavedChanges, - preferences: Preferences, - gdevelopTheme: GDevelopTheme, - project: gdProject, - editName: (itemId: string) => void, - scrollToItem: (itemId: string) => void, - showDeleteConfirmation: ( - options: ShowConfirmDeleteDialogOptions - ) => Promise, + forceUpdateList: () => void, + unsavedChanges ?: ? UnsavedChanges, + preferences: Preferences, + gdevelopTheme: GDevelopTheme, + project: gdProject, + editName: (itemId: string) => void, + scrollToItem: (itemId: string) => void, + showDeleteConfirmation: ( + options: ShowConfirmDeleteDialogOptions + ) => Promise < boolean >, |}; class LeafTreeViewItem implements TreeViewItem { @@ -213,12 +223,12 @@ class LabelTreeViewItemContent implements TreeViewItemContent { this.buildMenuTemplateFunction = (i18n: I18nType, index: number) => rightButton ? [ - { - id: rightButton.id, - label: rightButton.label, - click: rightButton.click, - }, - ] + { + id: rightButton.id, + label: rightButton.label, + click: rightButton.click, + }, + ] : []; this.rightButton = rightButton; } @@ -247,7 +257,7 @@ class LabelTreeViewItemContent implements TreeViewItemContent { return null; } - onClick(): void {} + onClick(): void { } // $FlowFixMe[missing-local-annot] buildMenuTemplate(i18n: I18nType, index: number) { @@ -258,23 +268,23 @@ class LabelTreeViewItemContent implements TreeViewItemContent { return null; } - rename(newName: string): void {} + rename(newName: string): void { } - edit(): void {} + edit(): void { } - delete(): void {} + delete(): void { } - copy(): void {} + copy(): void { } - paste(): void {} + paste(): void { } - cut(): void {} + cut(): void { } getIndex(): number { return 0; } - moveAt(destinationIndex: number): void {} + moveAt(destinationIndex: number): void { } isDescendantOf(itemContent: TreeViewItemContent): boolean { return false; @@ -349,23 +359,23 @@ class ActionTreeViewItemContent implements TreeViewItemContent { return null; } - rename(newName: string): void {} + rename(newName: string): void { } - edit(): void {} + edit(): void { } - delete(): void {} + delete(): void { } - copy(): void {} + copy(): void { } - paste(): void {} + paste(): void { } - cut(): void {} + cut(): void { } getIndex(): number { return 0; } - moveAt(destinationIndex: number): void {} + moveAt(destinationIndex: number): void { } isDescendantOf(itemContent: TreeViewItemContent): boolean { return false; @@ -410,43 +420,47 @@ const getTreeViewItemRightButton = (i18n: I18nType) => (item: TreeViewItem) => export type ProjectManagerInterface = {| forceUpdateList: () => void, - focusSearchBar: () => void, + focusSearchBar: () => void, |}; type Props = {| project: ?gdProject, - onChangeProjectName: string => Promise, - onSaveProjectProperties: (options: { newName?: string }) => Promise, + onChangeProjectName: string => Promise < void>, + onSaveProjectProperties: (options: { newName?: string }) => Promise < boolean >, ...SceneTreeViewItemCallbacks, ...ExtensionTreeViewItemCallbacks, ...ExternalEventsTreeViewItemCallbacks, ...ExternalLayoutTreeViewItemCallbacks, + + ...CinematicSequenceTreeViewItemCallbacks, onOpenResources: () => void, - onReloadEventsFunctionsExtensions: () => void, - isOpen: boolean, - hotReloadPreviewButtonProps: HotReloadPreviewButtonProps, - onShareProject: () => void, - onOpenHomePage: () => void, - toggleProjectManager: () => void, - onWillInstallExtension: (extensionNames: Array) => void, - onExtensionInstalled: (extensionNames: Array) => void, - onSceneAdded: () => void, - onExternalLayoutAdded: () => void, - - // Main menu - mainMenuCallbacks: MainMenuCallbacks, - buildMainMenuProps: BuildMainMenuProps, - - projectScopedContainersAccessor: ProjectScopedContainersAccessor | null, - - // For resources: - resourceManagementProps: ResourceManagementProps, - - // Games - gamesList: GamesList, + onReloadEventsFunctionsExtensions: () => void, + isOpen: boolean, + hotReloadPreviewButtonProps: HotReloadPreviewButtonProps, + onShareProject: () => void, + onOpenHomePage: () => void, + toggleProjectManager: () => void, + onWillInstallExtension: (extensionNames: Array) => void, + onExtensionInstalled: (extensionNames: Array) => void, + onSceneAdded: () => void, + onExternalLayoutAdded: () => void, + + onCinematicSequenceAdded: () => void, + + // Main menu + mainMenuCallbacks: MainMenuCallbacks, + buildMainMenuProps: BuildMainMenuProps, + + projectScopedContainersAccessor: ProjectScopedContainersAccessor | null, + + // For resources: + resourceManagementProps: ResourceManagementProps, + + // Games + gamesList: GamesList, |}; -const ProjectManager = React.forwardRef( +const ProjectManager = React.forwardRef < Props, ProjectManagerInterface> ( ( { project, @@ -480,12 +494,14 @@ const ProjectManager = React.forwardRef( onExtensionInstalled, onSceneAdded, onExternalLayoutAdded, + + onCinematicSequenceAdded, }, ref ) => { - const [selectedItems, setSelectedItems] = React.useState< - Array - >([]); + const [selectedItems, setSelectedItems] = React.useState < + Array < TreeViewItem > + > ([]); const unsavedChanges = React.useContext(UnsavedChangesContext); const { triggerUnsavedChanges } = unsavedChanges; const preferences = React.useContext(PreferencesContext); @@ -493,7 +509,7 @@ const ProjectManager = React.forwardRef( const { currentlyRunningInAppTutorial } = React.useContext( InAppTutorialContext ); - const treeViewRef = React.useRef>(null); + const treeViewRef = React.useRef > (null); const forceUpdate = useForceUpdate(); const { isMobile } = useResponsiveWindowSize(); const { showDeleteConfirmation } = useAlertDialog(); @@ -590,11 +606,11 @@ const ProjectManager = React.forwardRef( const [ editedPropertiesLayout, setEditedPropertiesLayout, - ] = React.useState(null); + ] = React.useState (null); const [ editedVariablesLayout, setEditedVariablesLayout, - ] = React.useState(null); + ] = React.useState (null); const onOpenLayoutProperties = React.useCallback((layout: ?gdLayout) => { setEditedPropertiesLayout(layout); }, []); @@ -615,7 +631,7 @@ const ProjectManager = React.forwardRef( ] = React.useState(null); const [openedExtensionName, setOpenedExtensionName] = React.useState(null); - const searchBarRef = React.useRef(null); + const searchBarRef = React.useRef (null); React.useImperativeHandle(ref, () => ({ forceUpdateList: () => { @@ -796,777 +812,868 @@ const ProjectManager = React.forwardRef( i18n._(t`Untitled external layout`), name => project.hasExternalLayoutNamed(name) ); - const newExternalLayout = project.insertNewExternalLayout( - newName, - index + 1 - ); - onExternalLayoutAdded(); - - onProjectItemModified(); - - const externalLayoutItemId = getExternalLayoutTreeViewItemId( - newExternalLayout + const addCinematicSequence = React.useCallback( + (index: number, i18n: I18nType) => { + if (!project) return; + + const newName = newNameGenerator( + i18n._(t`Untitled external layout`), + name => project.hasCinematicSequenceNamed(name) + ); + const newExternalLayout = project.insertNewExternalLayout( + newName, + index + 1 + ); + + onExternalLayoutAdded(); + + onProjectItemModified(); + + const externalLayoutItemId = getExternalLayoutTreeViewItemId( + newExternalLayout + ); + if (treeViewRef.current) { + treeViewRef.current.openItems([ + externalLayoutItemId, + externalLayoutsRootFolderId, + + cinematicSequencesRootFolderId, + ]); + } + // Scroll to the new behavior. + // Ideally, we'd wait for the list to be updated to scroll, but + // to simplify the code, we just wait a few ms for a new render + // to be done. + setTimeout(() => { + scrollToItem(externalLayoutItemId); + }, 100); // A few ms is enough for a new render to be done. + + // We focus it so the user can edit the name directly. + editName(externalLayoutItemId); + }, + [ + project, + onProjectItemModified, + editName, + scrollToItem, + onExternalLayoutAdded, + ] ); - if (treeViewRef.current) { - treeViewRef.current.openItems([ - externalLayoutItemId, - externalLayoutsRootFolderId, - ]); - } - // Scroll to the new behavior. - // Ideally, we'd wait for the list to be updated to scroll, but - // to simplify the code, we just wait a few ms for a new render - // to be done. - setTimeout(() => { - scrollToItem(externalLayoutItemId); - }, 100); // A few ms is enough for a new render to be done. - - // We focus it so the user can edit the name directly. - editName(externalLayoutItemId); - }, - [ - project, - onProjectItemModified, - editName, - scrollToItem, - onExternalLayoutAdded, - ] - ); - const onTreeModified = React.useCallback( - (shouldForceUpdateList: boolean) => { - triggerUnsavedChanges(); + const onTreeModified = React.useCallback( + (shouldForceUpdateList: boolean) => { + triggerUnsavedChanges(); - if (shouldForceUpdateList) forceUpdateList(); - else forceUpdate(); - }, - [forceUpdate, forceUpdateList, triggerUnsavedChanges] - ); + if (shouldForceUpdateList) forceUpdateList(); + else forceUpdate(); + }, + [forceUpdate, forceUpdateList, triggerUnsavedChanges] + ); - // Initialize keyboard shortcuts as empty. - // onDelete callback is set outside because it deletes the selected - // item (that is a props). As it is stored in a ref, the keyboard shortcut - // instance does not update with selectedItems changes. - const keyboardShortcutsRef = React.useRef( - new KeyboardShortcuts({ - shortcutCallbacks: {}, - }) - ); - React.useEffect( - () => { - if (keyboardShortcutsRef.current) { - keyboardShortcutsRef.current.setShortcutCallback('onDelete', () => { - if (selectedItems.length > 0) { - deleteItem(selectedItems[0]); - } - }); - keyboardShortcutsRef.current.setShortcutCallback('onRename', () => { - if (selectedItems.length > 0) { - editName(selectedItems[0].content.getId()); - } - }); - keyboardShortcutsRef.current.setShortcutCallback('onCopy', () => { - if (selectedItems.length > 0) { - selectedItems[0].content.copy(); - } - }); - keyboardShortcutsRef.current.setShortcutCallback('onPaste', () => { - if (selectedItems.length > 0) { - selectedItems[0].content.paste(); - } - }); - keyboardShortcutsRef.current.setShortcutCallback('onCut', () => { - if (selectedItems.length > 0) { - selectedItems[0].content.cut(); + // Initialize keyboard shortcuts as empty. + // onDelete callback is set outside because it deletes the selected + // item (that is a props). As it is stored in a ref, the keyboard shortcut + // instance does not update with selectedItems changes. + const keyboardShortcutsRef = React.useRef < KeyboardShortcuts > ( + new KeyboardShortcuts({ + shortcutCallbacks: {}, + }) + ); + React.useEffect( + () => { + if (keyboardShortcutsRef.current) { + keyboardShortcutsRef.current.setShortcutCallback('onDelete', () => { + if (selectedItems.length > 0) { + deleteItem(selectedItems[0]); + } + }); + keyboardShortcutsRef.current.setShortcutCallback('onRename', () => { + if (selectedItems.length > 0) { + editName(selectedItems[0].content.getId()); + } + }); + keyboardShortcutsRef.current.setShortcutCallback('onCopy', () => { + if (selectedItems.length > 0) { + selectedItems[0].content.copy(); + } + }); + keyboardShortcutsRef.current.setShortcutCallback('onPaste', () => { + if (selectedItems.length > 0) { + selectedItems[0].content.paste(); + } + }); + keyboardShortcutsRef.current.setShortcutCallback('onCut', () => { + if (selectedItems.length > 0) { + selectedItems[0].content.cut(); + } + }); } - }); - } - }, - [editName, selectedItems] - ); + }, + [editName, selectedItems] + ); - const sceneTreeViewItemProps = React.useMemo( - () => - project - ? { - project, - unsavedChanges, - preferences, - gdevelopTheme, - forceUpdate, - forceUpdateList, - showDeleteConfirmation, - editName, - scrollToItem, - onSceneAdded, - onDeleteLayout, - onRenameLayout, - onOpenLayout, - onOpenLayoutProperties, - onOpenLayoutVariables, - } - : null, - [ - project, - unsavedChanges, - preferences, - gdevelopTheme, - forceUpdate, - forceUpdateList, - showDeleteConfirmation, - editName, - scrollToItem, - onSceneAdded, - onDeleteLayout, - onRenameLayout, - onOpenLayout, - onOpenLayoutProperties, - onOpenLayoutVariables, - ] - ); + const sceneTreeViewItemProps = React.useMemo ( + () => + project + ? { + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onSceneAdded, + onDeleteLayout, + onRenameLayout, + onOpenLayout, + onOpenLayoutProperties, + onOpenLayoutVariables, + } + : null, + [ + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onSceneAdded, + onDeleteLayout, + onRenameLayout, + onOpenLayout, + onOpenLayoutProperties, + onOpenLayoutVariables, + ] + ); - const extensionTreeViewItemProps = React.useMemo( - () => - project - ? { - project, - unsavedChanges, - preferences, - gdevelopTheme, - forceUpdate, - forceUpdateList, - showDeleteConfirmation, - editName, - scrollToItem, - onDeleteEventsFunctionsExtension, - onRenameEventsFunctionsExtension, - onOpenEventsFunctionsExtension, - onReloadEventsFunctionsExtensions, - onEditEventsFunctionExtensionOrSeeDetails, - } - : null, - [ - project, - unsavedChanges, - preferences, - gdevelopTheme, - forceUpdate, - forceUpdateList, - showDeleteConfirmation, - editName, - scrollToItem, - onDeleteEventsFunctionsExtension, - onRenameEventsFunctionsExtension, - onOpenEventsFunctionsExtension, - onReloadEventsFunctionsExtensions, - onEditEventsFunctionExtensionOrSeeDetails, - ] - ); + const extensionTreeViewItemProps = React.useMemo ( + () => + project + ? { + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onDeleteEventsFunctionsExtension, + onRenameEventsFunctionsExtension, + onOpenEventsFunctionsExtension, + onReloadEventsFunctionsExtensions, + onEditEventsFunctionExtensionOrSeeDetails, + } + : null, + [ + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onDeleteEventsFunctionsExtension, + onRenameEventsFunctionsExtension, + onOpenEventsFunctionsExtension, + onReloadEventsFunctionsExtensions, + onEditEventsFunctionExtensionOrSeeDetails, + ] + ); - const externalEventsTreeViewItemProps = React.useMemo( - () => - project - ? { - project, - unsavedChanges, - preferences, - gdevelopTheme, - forceUpdate, - forceUpdateList, - showDeleteConfirmation, - editName, - scrollToItem, - onDeleteExternalEvents, - onRenameExternalEvents, - onOpenExternalEvents, - } - : null, - [ - project, - unsavedChanges, - preferences, - gdevelopTheme, - forceUpdate, - forceUpdateList, - showDeleteConfirmation, - editName, - scrollToItem, - onDeleteExternalEvents, - onRenameExternalEvents, - onOpenExternalEvents, - ] - ); + const externalEventsTreeViewItemProps = React.useMemo ( + () => + project + ? { + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onDeleteExternalEvents, + onRenameExternalEvents, + onOpenExternalEvents, + } + : null, + [ + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onDeleteExternalEvents, + onRenameExternalEvents, + onOpenExternalEvents, + ] + ); - const externalLayoutTreeViewItemProps = React.useMemo( - () => - project - ? { - project, - unsavedChanges, - preferences, - gdevelopTheme, - forceUpdate, - forceUpdateList, - showDeleteConfirmation, - editName, - scrollToItem, - onExternalLayoutAdded, - onDeleteExternalLayout, - onRenameExternalLayout, - onOpenExternalLayout, - } - : null, - [ - project, - unsavedChanges, - preferences, - gdevelopTheme, - forceUpdate, - forceUpdateList, - showDeleteConfirmation, - editName, - scrollToItem, - onExternalLayoutAdded, - onDeleteExternalLayout, - onRenameExternalLayout, - onOpenExternalLayout, - ] - ); + const externalLayoutTreeViewItemProps = React.useMemo ( + () => + project + ? { + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onExternalLayoutAdded, + onDeleteExternalLayout, + onRenameExternalLayout, + onOpenExternalLayout, + } + : null, + [ + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onExternalLayoutAdded, + onDeleteExternalLayout, + onRenameExternalLayout, + onOpenExternalLayout, + ] + ); - const getTreeViewData = React.useCallback( - (i18n: I18nType): Array => { - return !project || - !sceneTreeViewItemProps || - !extensionTreeViewItemProps || - !externalEventsTreeViewItemProps || - !externalLayoutTreeViewItemProps - ? [] - : [ - { - isRoot: true, - content: new LabelTreeViewItemContent( - gameSettingsRootFolderId, - i18n._(t`Game settings`) - ), - getChildren(i18n: I18nType): ?Array { - return [ - new LeafTreeViewItem( - new ActionTreeViewItemContent( - gamePropertiesItemId, - i18n._(t`Properties & Icons`), - openProjectProperties, - 'res/icons_default/properties_black.svg' - ) - ), - new LeafTreeViewItem( - new ActionTreeViewItemContent( - globalVariablesItemId, - i18n._(t`Global variables`), - openProjectVariables, - 'res/icons_default/global_variable24_black.svg' - ) - ), - new LeafTreeViewItem( - new ActionTreeViewItemContent( - gameResourcesItemId, - i18n._(t`Resources`), - onOpenResources, - 'res/icons_default/project_resources_black.svg' - ) - ), - new LeafTreeViewItem( - new ActionTreeViewItemContent( - gameDashboardItemId, - i18n._(t`Game Dashboard`), - onOpenGamesDashboardDialog, - 'res/icons_default/graphs_black.svg' - ) - ), - ]; - }, - }, - { - isRoot: true, - content: new LabelTreeViewItemContent( - scenesRootFolderId, - i18n._(t`Scenes`), - { - icon: , - label: i18n._(t`Add a scene`), - click: () => { - // TODO Add after selected scene? - const index = project.getLayoutsCount() - 1; - addNewScene(index, i18n); - }, - id: 'add-new-scene-button', - } - ), - getChildren(i18n: I18nType): ?Array { - if (project.getLayoutsCount() === 0) { + const cinematicSequenceTreeViewItemProps = React.useMemo ( + () => + project + ? { + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onCinematicSequenceAdded, + onDeleteCinematicSequence, + onRenameCinematicSequence, + onOpenCinematicSequence, + } + : null, + [ + project, + unsavedChanges, + preferences, + gdevelopTheme, + forceUpdate, + forceUpdateList, + showDeleteConfirmation, + editName, + scrollToItem, + onCinematicSequenceAdded, + onDeleteCinematicSequence, + onRenameCinematicSequence, + onOpenCinematicSequence, + ] + ); + + const getTreeViewData = React.useCallback( + (i18n: I18nType): Array => { + return !project || + !sceneTreeViewItemProps || + !extensionTreeViewItemProps || + !externalEventsTreeViewItemProps || + !externalLayoutTreeViewItemProps + ? [] + : [ + { + isRoot: true, + content: new LabelTreeViewItemContent( + gameSettingsRootFolderId, + i18n._(t`Game settings`) + ), + getChildren(i18n: I18nType): ?Array { return [ - new PlaceHolderTreeViewItem( - scenesEmptyPlaceholderId, - i18n._(t`Start by adding a new scene.`) + new LeafTreeViewItem( + new ActionTreeViewItemContent( + gamePropertiesItemId, + i18n._(t`Properties & Icons`), + openProjectProperties, + 'res/icons_default/properties_black.svg' + ) ), - ]; - } - return mapFor( - 0, - project.getLayoutsCount(), - i => new LeafTreeViewItem( - new SceneTreeViewItemContent( - project.getLayoutAt(i), - sceneTreeViewItemProps + new ActionTreeViewItemContent( + globalVariablesItemId, + i18n._(t`Global variables`), + openProjectVariables, + 'res/icons_default/global_variable24_black.svg' ) - ) - ); - }, - }, - { - isRoot: true, - content: new LabelTreeViewItemContent( - extensionsRootFolderId, - i18n._(t`Extensions`), - { - icon: , - label: i18n._(t`Create or search for new extensions`), - click: openSearchExtensionDialog, - id: 'project-manager-extension-search-or-create', - } - ), - getChildren(i18n: I18nType): ?Array { - if (project.getEventsFunctionsExtensionsCount() === 0) { - return [ - new PlaceHolderTreeViewItem( - extensionsEmptyPlaceholderId, - i18n._(t`Start by adding a new function.`) ), - ]; - } - return mapFor( - 0, - project.getEventsFunctionsExtensionsCount(), - i => new LeafTreeViewItem( - new ExtensionTreeViewItemContent( - project.getEventsFunctionsExtensionAt(i), - extensionTreeViewItemProps + new ActionTreeViewItemContent( + gameResourcesItemId, + i18n._(t`Resources`), + onOpenResources, + 'res/icons_default/project_resources_black.svg' ) - ) - ); - }, - }, - { - isRoot: true, - content: new LabelTreeViewItemContent( - externalEventsRootFolderId, - i18n._(t`External events`), - { - icon: , - label: i18n._(t`Add external events`), - click: () => { - // TODO Add after selected scene? - const index = project.getExternalEventsCount() - 1; - addExternalEvents(index, i18n); - }, - id: 'add-new-external-events-button', - } - ), - getChildren(i18n: I18nType): ?Array { - if (project.getExternalEventsCount() === 0) { - return [ - new PlaceHolderTreeViewItem( - externalEventsEmptyPlaceholderId, - i18n._(t`Start by adding new external events.`) ), - ]; - } - return mapFor( - 0, - project.getExternalEventsCount(), - i => new LeafTreeViewItem( - new ExternalEventsTreeViewItemContent( - project.getExternalEventsAt(i), - externalEventsTreeViewItemProps + new ActionTreeViewItemContent( + gameDashboardItemId, + i18n._(t`Game Dashboard`), + onOpenGamesDashboardDialog, + 'res/icons_default/graphs_black.svg' ) - ) - ); - }, - }, - { - isRoot: true, - content: new LabelTreeViewItemContent( - externalLayoutsRootFolderId, - i18n._(t`External layouts`), - { - icon: , - label: i18n._(t`Add an external layout`), - click: () => { - // TODO Add after selected scene? - const index = project.getExternalLayoutsCount() - 1; - addExternalLayout(index, i18n); - }, - id: 'add-new-external-layout-button', - } - ), - getChildren(i18n: I18nType): ?Array { - if (project.getExternalLayoutsCount() === 0) { - return [ - new PlaceHolderTreeViewItem( - externalLayoutEmptyPlaceholderId, - i18n._(t`Start by adding a new external layout.`) ), ]; - } - return mapFor( - 0, - project.getExternalLayoutsCount(), - i => - new LeafTreeViewItem( - new ExternalLayoutTreeViewItemContent( - project.getExternalLayoutAt(i), - externalLayoutTreeViewItemProps + }, + }, + { + isRoot: true, + content: new LabelTreeViewItemContent( + scenesRootFolderId, + i18n._(t`Scenes`), + { + icon: , + label: i18n._(t`Add a scene`), + click: () => { + // TODO Add after selected scene? + const index = project.getLayoutsCount() - 1; + addNewScene(index, i18n); + }, + id: 'add-new-scene-button', + } + ), + getChildren(i18n: I18nType): ?Array { + if (project.getLayoutsCount() === 0) { + return [ + new PlaceHolderTreeViewItem( + scenesEmptyPlaceholderId, + i18n._(t`Start by adding a new scene.`) + ), + ]; + } + return mapFor( + 0, + project.getLayoutsCount(), + i => + new LeafTreeViewItem( + new SceneTreeViewItemContent( + project.getLayoutAt(i), + sceneTreeViewItemProps + ) ) - ) - ); + ); + }, }, - }, - ]; - }, - [ - addExternalEvents, - addExternalLayout, - addNewScene, - extensionTreeViewItemProps, - externalEventsTreeViewItemProps, - externalLayoutTreeViewItemProps, - onOpenGamesDashboardDialog, - onOpenResources, - openProjectProperties, - openProjectVariables, - openSearchExtensionDialog, - project, - sceneTreeViewItemProps, - ] - ); - - const canMoveSelectionTo = React.useCallback( - (destinationItem: TreeViewItem, where: 'before' | 'inside' | 'after') => - selectedItems.every(item => { - return ( - // Project and game settings children `getRootId` return an empty string. - item.content.getRootId().length > 0 && - item.content.getRootId() === destinationItem.content.getRootId() - ); - }), - [selectedItems] - ); + { + isRoot: true, + content: new LabelTreeViewItemContent( + extensionsRootFolderId, + i18n._(t`Extensions`), + { + icon: , + label: i18n._(t`Create or search for new extensions`), + click: openSearchExtensionDialog, + id: 'project-manager-extension-search-or-create', + } + ), + getChildren(i18n: I18nType): ?Array { + if (project.getEventsFunctionsExtensionsCount() === 0) { + return [ + new PlaceHolderTreeViewItem( + extensionsEmptyPlaceholderId, + i18n._(t`Start by adding a new function.`) + ), + ]; + } + return mapFor( + 0, + project.getEventsFunctionsExtensionsCount(), + i => + new LeafTreeViewItem( + new ExtensionTreeViewItemContent( + project.getEventsFunctionsExtensionAt(i), + extensionTreeViewItemProps + ) + ) + ); + }, + }, + { + isRoot: true, + content: new LabelTreeViewItemContent( + externalEventsRootFolderId, + i18n._(t`External events`), + { + icon: , + label: i18n._(t`Add external events`), + click: () => { + // TODO Add after selected scene? + const index = project.getExternalEventsCount() - 1; + addExternalEvents(index, i18n); + }, + id: 'add-new-external-events-button', + } + ), + getChildren(i18n: I18nType): ?Array { + if (project.getExternalEventsCount() === 0) { + return [ + new PlaceHolderTreeViewItem( + externalEventsEmptyPlaceholderId, + i18n._(t`Start by adding new external events.`) + ), + ]; + } + return mapFor( + 0, + project.getExternalEventsCount(), + i => + new LeafTreeViewItem( + new ExternalEventsTreeViewItemContent( + project.getExternalEventsAt(i), + externalEventsTreeViewItemProps + ) + ) + ); + }, + }, + { + isRoot: true, + content: new LabelTreeViewItemContent( + externalLayoutsRootFolderId, + i18n._(t`External layouts`), + { + icon: , + label: i18n._(t`Add an external layout`), + click: () => { + // TODO Add after selected scene? + const index = project.getExternalLayoutsCount() - 1; + addExternalLayout(index, i18n); + }, + id: 'add-new-external-layout-button', + } + ), + getChildren(i18n: I18nType): ?Array { + if (project.getExternalLayoutsCount() === 0) { + return [ + new PlaceHolderTreeViewItem( + externalLayoutEmptyPlaceholderId, + i18n._(t`Start by adding a new external layout.`) + ), + ]; + } + return mapFor( + 0, + project.getExternalLayoutsCount(), + i => + new LeafTreeViewItem( + new ExternalLayoutTreeViewItemContent( + project.getExternalLayoutAt(i), + externalLayoutTreeViewItemProps + ) + ) + ); + }, + }, + { + isRoot: true, + content: new LabelTreeViewItemContent( + cinematicSequencesRootFolderId, + i18n._(t`Cinematic Sequences`), + { + icon: , + label: i18n._(t`Add a cinematic sequence`), + click: () => { + const index = project.getCinematicSequencesCount() - 1; + // We can just rely on a new function or inline it. + // For now, let's call the passed prop `onCinematicSequenceAdded` + if (onCinematicSequenceAdded) onCinematicSequenceAdded(); + else { + const newSequence = project.insertNewCinematicSequence('New sequence', index); + } + forceUpdateList(); + }, + id: 'add-new-cinematic-sequence-button', + } + ), + getChildren(i18n: I18nType): ?Array { + if (project.getCinematicSequencesCount() === 0) { + return [ + new PlaceHolderTreeViewItem( + cinematicSequenceEmptyPlaceholderId, + i18n._(t`Start by adding a new cinematic sequence.`) + ), + ]; + } + return mapFor( + 0, + project.getCinematicSequencesCount(), + i => + new LeafTreeViewItem( + new CinematicSequenceTreeViewItemContent( + project.getCinematicSequenceAt(i), + cinematicSequenceTreeViewItemProps + ) + ) + ); + }, + }, + ]; + }, + [ + addExternalEvents, + addExternalLayout, + addNewScene, + extensionTreeViewItemProps, + externalEventsTreeViewItemProps, + externalLayoutTreeViewItemProps, + cinematicSequenceTreeViewItemProps, + onOpenGamesDashboardDialog, + onOpenResources, + openProjectProperties, + openProjectVariables, + openSearchExtensionDialog, + project, + sceneTreeViewItemProps, + ] + ); - const moveSelectionTo = React.useCallback( - ( - i18n: I18nType, - destinationItem: TreeViewItem, - where: 'before' | 'inside' | 'after' - ) => { - if (selectedItems.length === 0) { - return; - } - const selectedItem = selectedItems[0]; - selectedItem.content.moveAt( - destinationItem.content.getIndex() + (where === 'after' ? 1 : 0) + const canMoveSelectionTo = React.useCallback( + (destinationItem: TreeViewItem, where: 'before' | 'inside' | 'after') => + selectedItems.every(item => { + return ( + // Project and game settings children `getRootId` return an empty string. + item.content.getRootId().length > 0 && + item.content.getRootId() === destinationItem.content.getRootId() + ); + }), + [selectedItems] ); - onTreeModified(true); - }, - [onTreeModified, selectedItems] - ); - /** - * Unselect item if one of the parent is collapsed (folded) so that the item - * does not stay selected and not visible to the user. - */ - const onCollapseItem = React.useCallback( - (item: TreeViewItem) => { - if (selectedItems.length !== 1 || item.isPlaceholder) { - return; - } - if (selectedItems[0].content.isDescendantOf(item.content)) { - setSelectedItems([]); - } - }, - [selectedItems] - ); + const moveSelectionTo = React.useCallback( + ( + i18n: I18nType, + destinationItem: TreeViewItem, + where: 'before' | 'inside' | 'after' + ) => { + if (selectedItems.length === 0) { + return; + } + const selectedItem = selectedItems[0]; + selectedItem.content.moveAt( + destinationItem.content.getIndex() + (where === 'after' ? 1 : 0) + ); + onTreeModified(true); + }, + [onTreeModified, selectedItems] + ); - // Force List component to be mounted again if project - // has been changed. Avoid accessing to invalid objects that could - // crash the app. - const listKey = project ? project.ptr : 'no-project'; - const initiallyOpenedNodeIds = [ - gameSettingsRootFolderId, - scenesRootFolderId, - extensionsRootFolderId, - externalEventsRootFolderId, - externalLayoutsRootFolderId, - ]; + /** + * Unselect item if one of the parent is collapsed (folded) so that the item + * does not stay selected and not visible to the user. + */ + const onCollapseItem = React.useCallback( + (item: TreeViewItem) => { + if (selectedItems.length !== 1 || item.isPlaceholder) { + return; + } + if (selectedItems[0].content.isDescendantOf(item.content)) { + setSelectedItems([]); + } + }, + [selectedItems] + ); - const [ - selectedMainMenuItemIndices, - setSelectedMainMenuItemIndices, - // $FlowFixMe[missing-empty-array-annot] - ] = React.useState([]); - const isNavigatingInMainMenuItem = selectedMainMenuItemIndices.length > 0; - const shouldHideMainMenu = isMacLike() && !!electron; - - // Unselect items when the project manager is closed. - React.useEffect( - () => { - if (!isOpen) { - setSelectedMainMenuItemIndices([]); - } - }, - [isOpen] - ); + // Force List component to be mounted again if project + // has been changed. Avoid accessing to invalid objects that could + // crash the app. + const listKey = project ? project.ptr : 'no-project'; + const initiallyOpenedNodeIds = [ + gameSettingsRootFolderId, + scenesRootFolderId, + extensionsRootFolderId, + externalEventsRootFolderId, + externalLayoutsRootFolderId, + ]; + + const [ + selectedMainMenuItemIndices, + setSelectedMainMenuItemIndices, + // $FlowFixMe[missing-empty-array-annot] + ] = React.useState([]); + const isNavigatingInMainMenuItem = selectedMainMenuItemIndices.length > 0; + const shouldHideMainMenu = isMacLike() && !!electron; + + // Unselect items when the project manager is closed. + React.useEffect( + () => { + if (!isOpen) { + setSelectedMainMenuItemIndices([]); + } + }, + [isOpen] + ); - return ( - - - - - {!shouldHideMainMenu && ( - - )} - {!isNavigatingInMainMenuItem && project && ( - - - {}} - onChange={setSearchText} - placeholder={t`Search in project`} + return ( + + + + + {!shouldHideMainMenu && ( + - - - )} - - {({ i18n }) => ( - <> - {isNavigatingInMainMenuItem ? null : project ? ( -
- - {({ height }) => ( - // $FlowFixMe[incompatible-type] - // $FlowFixMe[incompatible-exact] - { - const itemToSelect = items[0]; - if (!itemToSelect) return; - if (itemToSelect.isRoot) return; - setSelectedItems(items); - }} - onClickItem={onClickItem} - onRenameItem={renameItem} - buildMenuTemplate={buildMenuTemplate(i18n)} - getItemRightButton={getTreeViewItemRightButton( - i18n - )} - renderRightComponent={renderTreeViewItemRightComponent( - i18n + )} + {!isNavigatingInMainMenuItem && project && ( + + + { }} + onChange={setSearchText} + placeholder={t`Search in project`} + /> + + + )} + + {({ i18n }) => ( + <> + {isNavigatingInMainMenuItem ? null : project ? ( +
+ + {({ height }) => ( + // $FlowFixMe[incompatible-type] + // $FlowFixMe[incompatible-exact] + { + const itemToSelect = items[0]; + if (!itemToSelect) return; + if (itemToSelect.isRoot) return; + setSelectedItems(items); + }} + onClickItem={onClickItem} + onRenameItem={renameItem} + buildMenuTemplate={buildMenuTemplate(i18n)} + getItemRightButton={getTreeViewItemRightButton( + i18n + )} + renderRightComponent={renderTreeViewItemRightComponent( + i18n + )} + onMoveSelectionToItem={(destinationItem, where) => + moveSelectionTo(i18n, destinationItem, where) + } + canMoveSelectionToItem={canMoveSelectionTo} + reactDndType={extensionItemReactDndType} + initiallyOpenedNodeIds={initiallyOpenedNodeIds} + forceDefaultDraggingPreview + shouldHideMenuIcon={item => + !item.content.getRootId() + } + /> )} - onMoveSelectionToItem={(destinationItem, where) => - moveSelectionTo(i18n, destinationItem, where) + +
+ ) : ( + + To begin, open or create a new project. + + )} + {projectPropertiesDialogOpen && + project && + projectScopedContainersAccessor && ( + setProjectPropertiesDialogOpen(false)} + onApply={onSaveProjectProperties} + onPropertiesApplied={onProjectPropertiesApplied} + resourceManagementProps={resourceManagementProps} + projectScopedContainersAccessor={ + projectScopedContainersAccessor } - canMoveSelectionToItem={canMoveSelectionTo} - reactDndType={extensionItemReactDndType} - initiallyOpenedNodeIds={initiallyOpenedNodeIds} - forceDefaultDraggingPreview - shouldHideMenuIcon={item => - !item.content.getRootId() + hotReloadPreviewButtonProps={ + hotReloadPreviewButtonProps } + i18n={i18n} /> )} -
-
- ) : ( - - To begin, open or create a new project. - - )} - {projectPropertiesDialogOpen && - project && - projectScopedContainersAccessor && ( - setProjectPropertiesDialogOpen(false)} - onApply={onSaveProjectProperties} - onPropertiesApplied={onProjectPropertiesApplied} - resourceManagementProps={resourceManagementProps} - projectScopedContainersAccessor={ - projectScopedContainersAccessor - } - hotReloadPreviewButtonProps={ - hotReloadPreviewButtonProps - } - i18n={i18n} - /> - )} - {project && projectVariablesEditorOpen && ( - setProjectVariablesEditorOpen(false)} - onApply={() => { - triggerUnsavedChanges(); - setProjectVariablesEditorOpen(false); - }} - hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} - isListLocked={false} - /> - )} - {!!editedPropertiesLayout && - project && - projectScopedContainersAccessor && ( - { - triggerUnsavedChanges(); - onOpenLayoutProperties(null); - }} - onClose={() => onOpenLayoutProperties(null)} - onEditVariables={() => { - onOpenLayoutVariables(editedPropertiesLayout); - onOpenLayoutProperties(null); - }} - resourceManagementProps={resourceManagementProps} - projectScopedContainersAccessor={ - projectScopedContainersAccessor - } - onBackgroundColorChanged={() => { - // TODO This can probably wait the rework of scene properties. - }} - /> - )} - {project && !!editedVariablesLayout && ( - onOpenLayoutVariables(null)} - onApply={() => { - triggerUnsavedChanges(); - onOpenLayoutVariables(null); - }} - hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} - isListLocked={false} - /> - )} - {project && extensionsSearchDialogOpen && ( - setExtensionsSearchDialogOpen(false)} - onWillInstallExtension={onWillInstallExtension} - onCreateNew={() => { - onCreateNewExtension(project, i18n); - }} - onExtensionInstalled={onExtensionInstalled} - /> + {project && projectVariablesEditorOpen && ( + setProjectVariablesEditorOpen(false)} + onApply={() => { + triggerUnsavedChanges(); + setProjectVariablesEditorOpen(false); + }} + hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} + isListLocked={false} + /> + )} + {!!editedPropertiesLayout && + project && + projectScopedContainersAccessor && ( + { + triggerUnsavedChanges(); + onOpenLayoutProperties(null); + }} + onClose={() => onOpenLayoutProperties(null)} + onEditVariables={() => { + onOpenLayoutVariables(editedPropertiesLayout); + onOpenLayoutProperties(null); + }} + resourceManagementProps={resourceManagementProps} + projectScopedContainersAccessor={ + projectScopedContainersAccessor + } + onBackgroundColorChanged={() => { + // TODO This can probably wait the rework of scene properties. + }} + /> + )} + {project && !!editedVariablesLayout && ( + onOpenLayoutVariables(null)} + onApply={() => { + triggerUnsavedChanges(); + onOpenLayoutVariables(null); + }} + hotReloadPreviewButtonProps={hotReloadPreviewButtonProps} + isListLocked={false} + /> + )} + {project && extensionsSearchDialogOpen && ( + setExtensionsSearchDialogOpen(false)} + onWillInstallExtension={onWillInstallExtension} + onCreateNew={() => { + onCreateNewExtension(project, i18n); + }} + onExtensionInstalled={onExtensionInstalled} + /> + )} + {project && + openedExtensionShortHeader && + openedExtensionName && ( + { + setOpenedExtensionShortHeader(null); + setOpenedExtensionName(null); + }} + onOpenEventsFunctionsExtension={ + onOpenEventsFunctionsExtension + } + extensionShortHeader={openedExtensionShortHeader} + extensionName={openedExtensionName} + onWillInstallExtension={onWillInstallExtension} + onExtensionInstalled={onExtensionInstalled} + /> + )} + )} - {project && - openedExtensionShortHeader && - openedExtensionName && ( - { - setOpenedExtensionShortHeader(null); - setOpenedExtensionName(null); - }} - onOpenEventsFunctionsExtension={ - onOpenEventsFunctionsExtension - } - extensionShortHeader={openedExtensionShortHeader} - extensionName={openedExtensionName} - onWillInstallExtension={onWillInstallExtension} - onExtensionInstalled={onExtensionInstalled} - /> - )} - - )} -
-
-
-
+ + + + + ); + } ); - } -); - -const arePropsEqual = (prevProps: Props, nextProps: Props): boolean => - // The component is costly to render, so avoid any re-rendering as much - // as possible. - // We make the assumption that no changes to the tree is made outside - // from the component. - // If a change is made, the component won't notice it: you have to manually - // call forceUpdate. - !nextProps.isOpen; -// $FlowFixMe[incompatible-type] -const MemoizedProjectManager = React.memo( - // $FlowFixMe[incompatible-type] - // $FlowFixMe[incompatible-exact] - ProjectManager, - arePropsEqual -); + const arePropsEqual = (prevProps: Props, nextProps: Props): boolean => + // The component is costly to render, so avoid any re-rendering as much + // as possible. + // We make the assumption that no changes to the tree is made outside + // from the component. + // If a change is made, the component won't notice it: you have to manually + // call forceUpdate. + !nextProps.isOpen; + + // $FlowFixMe[incompatible-type] + const MemoizedProjectManager = React.memo < Props, ProjectManagerInterface> ( + // $FlowFixMe[incompatible-type] + // $FlowFixMe[incompatible-exact] + ProjectManager, + arePropsEqual + ); -const ProjectManagerWithErrorBoundary: React.ComponentType<{ + const ProjectManagerWithErrorBoundary: React.ComponentType<{ ...Props, - +ref?: React.RefSetter, -}> = React.forwardRef((props, outerRef) => { - const projectManagerRef = React.useRef(null); + +ref ?: React.RefSetter < ProjectManagerInterface >, +}> = React.forwardRef < Props, ProjectManagerInterface > ((props, outerRef) => { + const projectManagerRef = React.useRef (null); const shouldAutofocusInput = useShouldAutofocusInput(); React.useEffect(