Compare commits

...

182 Commits

Author SHA1 Message Date
AlexandreSi
e02bebb0dd Remove objects list responsibility to open/close items on arrow navigation 2023-10-03 15:24:50 +02:00
AlexandreSi
d54a301b72 Remove useless let 2023-10-03 15:11:55 +02:00
AlexandreSi
342bd0e02b Extract logic in method 2023-10-03 15:11:24 +02:00
AlexandreSi
52f42b8d57 Add method to check if objectFolderOrObject is a root folder 2023-10-03 14:40:44 +02:00
AlexandreSi
e5103ed4f6 Add comment 2023-10-03 12:39:17 +02:00
AlexandreSi
24736cc4e9 Review changes in ObjectFolderOrObject c++ implementation 2023-10-03 12:34:38 +02:00
AlexandreSi
da7fe3d6a9 Clean address equality 2023-10-03 09:58:02 +02:00
AlexandreSi
c7d98eaa92 Refactor styles in instruction or object selector 2023-10-03 09:49:13 +02:00
AlexandreSi
3f31174975 Improve legibility 2023-10-03 09:42:21 +02:00
AlexandreSi
6e5de52d8f Fix areEqual condition 2023-10-03 09:38:44 +02:00
AlexandreSi
144bd97a03 Remove legacy onboarding guided lesson 2023-10-03 09:38:07 +02:00
AlexandreSi
4ee0c7bed2 Change set as global copy 2023-10-03 09:34:09 +02:00
AlexandreSi
5a0aac1a47 Review changes 2023-10-02 18:14:22 +02:00
AlexandreSi
96cfec78f0 Define object selection when using arrows and nothing is visually selected 2023-10-02 17:09:09 +02:00
AlexandreSi
d6902df992 Prettier 2023-10-02 10:53:42 +02:00
AlexandreSi
acc960a6cf Restart delay to edit name with a click after a renaming has already been done 2023-10-02 10:53:42 +02:00
AlexandreSi
4578353655 Open parent when moving item to it 2023-10-02 10:53:42 +02:00
AlexandreSi
fd248961a5 Fix bug when removing object in root objectFolderOrObject 2023-10-02 10:53:42 +02:00
AlexandreSi
ba31d47e54 Do not display drop indicator for root tree view row 2023-10-02 10:53:42 +02:00
AlexandreSi
c44bc582e2 Use alert dialog in object groups list 2023-10-02 10:53:42 +02:00
AlexandreSi
164380baee Use delete confirm dialog in objects group list 2023-10-02 10:53:42 +02:00
AlexandreSi
39b6bf9a62 Transform ObjectGroupsList component into functional component 2023-10-02 10:53:42 +02:00
AlexandreSi
34136d2ea4 Use alert dialog when removing an object 2023-10-02 10:53:42 +02:00
AlexandreSi
a9dbe5c4af Make confirm text optional in ConfirmDeleteDialog 2023-10-02 10:53:42 +02:00
AlexandreSi
396e451677 Add YesNoCancelDialog in alert context 2023-10-02 10:53:42 +02:00
AlexandreSi
9ddeb7dccf Clear selection after deleting something 2023-10-02 10:53:41 +02:00
AlexandreSi
0be95b91d6 Fix display bug when global and local object have the same name 2023-10-02 10:53:41 +02:00
AlexandreSi
6551c07971 Add link in Global objects placeholder 2023-10-02 10:53:41 +02:00
AlexandreSi
f69a77a57e Refocus navigating key element after item ended renaming 2023-10-02 10:53:41 +02:00
AlexandreSi
699664d715 Improve copy 2023-10-02 10:53:41 +02:00
AlexandreSi
4ea397d92b Fix selected value for objects in folders 2023-10-02 10:53:41 +02:00
AlexandreSi
3c4eb5a1ca Prevent navigating in tree view when renaming item 2023-10-02 10:53:41 +02:00
AlexandreSi
e9a0027f78 Fix drop in objects groups list 2023-10-02 10:53:41 +02:00
AlexandreSi
c2d620069f Add possibility to drop item below folder 2023-10-02 10:53:41 +02:00
AlexandreSi
f0ff63fd07 Display folder and groups content in InstructionOrObjectSelector 2023-10-02 10:53:41 +02:00
AlexandreSi
058dac9faa Fix bug when arrow key navigating if no selected item 2023-10-02 10:53:41 +02:00
AlexandreSi
3b69c52250 Create folder inside selected folder 2023-10-02 10:53:41 +02:00
AlexandreSi
098aef12d9 clean components style 2023-10-02 10:53:41 +02:00
AlexandreSi
0281f643b2 Add possibility to remove selected element using delete key 2023-10-02 10:53:41 +02:00
AlexandreSi
58fa8c6ccc Rename some methods 2023-10-02 10:53:41 +02:00
AlexandreSi
f2e5fd7cf6 Select all object/folder name when edting its name 2023-10-02 10:53:41 +02:00
AlexandreSi
da354676bd Add delay before being able to edit object/folder name with a click 2023-10-02 10:53:41 +02:00
AlexandreSi
df4a282aa2 Select and edit foldername after creating it 2023-10-02 10:53:41 +02:00
AlexandreSi
d8874ddf6f Scroll with item when navigating with arrow keys 2023-10-02 10:53:41 +02:00
AlexandreSi
af29c088c2 Make it possible to pass from one part of the tree to another with arrow keys 2023-10-02 10:53:41 +02:00
AlexandreSi
6d902d0920 Use cleaner condition to display item as folder 2023-10-02 10:53:41 +02:00
AlexandreSi
826c55f8a7 Add basic arrow key navigation on tree 2023-10-02 10:53:41 +02:00
AlexandreSi
8320ef81ae Improve tree view row icon display 2023-10-02 10:53:41 +02:00
AlexandreSi
79bf6dd5c8 Highlight last selected instance in object list 2023-10-02 10:53:41 +02:00
AlexandreSi
16e3bf6612 Add method to find objectFolderOrObject by object name 2023-10-02 10:53:41 +02:00
AlexandreSi
6043618fbc Add aria attributes 2023-10-02 10:53:41 +02:00
AlexandreSi
ff04514338 Render a maximum of objects in the list when in a guided lesson 2023-10-02 10:53:41 +02:00
AlexandreSi
1122b17908 Improve folder chevron icons 2023-10-02 10:53:41 +02:00
AlexandreSi
45183ff7d4 Change naming 2023-10-02 10:53:41 +02:00
AlexandreSi
1fa366a657 Prettier 2023-10-02 10:53:41 +02:00
AlexandreSi
8193c13fd9 Remove feature to select items in the objects list using instances objects names 2023-10-02 10:53:41 +02:00
AlexandreSi
5224df51cb Remove tags from objects and any reference to them in the editor 2023-10-02 10:53:41 +02:00
AlexandreSi
896d090b12 Add possibility to paste object inside folder 2023-10-02 10:53:41 +02:00
AlexandreSi
6bc39f751f Adapt EBO editor to the new objects list 2023-10-02 10:53:41 +02:00
AlexandreSi
72cd0aca4e Fix click on placeholder crashing 2023-10-02 10:53:41 +02:00
AlexandreSi
f4c94aaaac Don't use folder name in id 2023-10-02 10:53:41 +02:00
AlexandreSi
c53bd9a01d Do not validate folder name the same way as for objects 2023-10-02 10:53:41 +02:00
AlexandreSi
eea3fa9266 Improve text input usability 2023-10-02 10:53:41 +02:00
AlexandreSi
3cb801617d Fix memory issue 2023-10-02 10:53:40 +02:00
AlexandreSi
b0f5938b52 Move folder name setting method out of declaration file 2023-10-02 10:53:40 +02:00
AlexandreSi
bce8f794c9 Change separator 2023-10-02 10:53:40 +02:00
AlexandreSi
310b2249fc Add documentation in C++ class 2023-10-02 10:53:40 +02:00
AlexandreSi
2062297a85 Improve drop indicator on folders 2023-10-02 10:53:40 +02:00
AlexandreSi
d37253e908 Fine-tune padding for a better understanding with a glance 2023-10-02 10:53:40 +02:00
AlexandreSi
e55a87012a Improve movements of folders 2023-10-02 10:53:40 +02:00
AlexandreSi
a5fed207ab Add security before moving item in another folder 2023-10-02 10:53:40 +02:00
AlexandreSi
083868a62c Add method to check if objectFolderOrObject is descendant of another one 2023-10-02 10:53:40 +02:00
AlexandreSi
b27da452fc Add possibility to drop item in folder 2023-10-02 10:53:40 +02:00
AlexandreSi
96ed9b9886 Add context menu item to remove folder 2023-10-02 10:53:40 +02:00
AlexandreSi
4690504e53 Add possibility to remove folder child 2023-10-02 10:53:40 +02:00
AlexandreSi
0d597c3d10 Prevent move of folder to project container 2023-10-02 10:53:40 +02:00
AlexandreSi
2f353b7a82 Add context menu item to expand all sub folders 2023-10-02 10:53:40 +02:00
AlexandreSi
a27de6fe5e Change interface to open a list of nodes 2023-10-02 10:53:40 +02:00
AlexandreSi
973d620f4d Add context menu to folders 2023-10-02 10:53:40 +02:00
AlexandreSi
a88aece778 Only move child from within folder when possible 2023-10-02 10:53:40 +02:00
AlexandreSi
7c01eb5358 Add method to move child in folder 2023-10-02 10:53:40 +02:00
AlexandreSi
bc672d050b Add possibility to move object from context menu to another folder 2023-10-02 10:53:40 +02:00
AlexandreSi
51676c7da5 Select object with new context after moving to global objects 2023-10-02 10:53:40 +02:00
AlexandreSi
7499c05930 Add missing parent setting when unserializing object folder or object 2023-10-02 10:53:40 +02:00
AlexandreSi
af409f0b24 Add missing force update 2023-10-02 10:53:40 +02:00
AlexandreSi
7c65500e93 Fix unserialization 2023-10-02 10:53:40 +02:00
AlexandreSi
d116fdab9c Add folder icon to create folder 2023-10-02 10:53:40 +02:00
AlexandreSi
ded48c0000 Open Scene and/or Global objects/groups at start 2023-10-02 10:53:40 +02:00
AlexandreSi
87f1651820 Fix canMove method 2023-10-02 10:53:40 +02:00
AlexandreSi
bb88379bbb Remove former way to find object where possible 2023-10-02 10:53:40 +02:00
AlexandreSi
a468059ff1 Change method to move an ObjectFolderOrObject from a container to the other 2023-10-02 10:53:40 +02:00
AlexandreSi
e1514949d1 Clean 2023-10-02 10:53:40 +02:00
AlexandreSi
c42e4127d0 Adapt object duplicate and paste 2023-10-02 10:53:40 +02:00
AlexandreSi
45b6b25b27 Add method to get child position in folder 2023-10-02 10:53:40 +02:00
AlexandreSi
38e6b116ee Add method to ObjectsContainer to insert new object in given folder 2023-10-02 10:53:40 +02:00
AlexandreSi
1369508b18 Add method to get child 2023-10-02 10:53:40 +02:00
AlexandreSi
f6de51f8f2 Adapt Scene editor to use objectFolderOrObject items 2023-10-02 10:53:40 +02:00
AlexandreSi
6cba06d1c6 Add method to populate objects container root folder after unserialization 2023-10-02 10:53:40 +02:00
AlexandreSi
83daec703e Add serialization of folders to project and EBO serialization methods 2023-10-02 10:53:40 +02:00
AlexandreSi
a41176b02a Add method to get child 2023-10-02 10:53:40 +02:00
AlexandreSi
f94d3b1976 Add method to get children count 2023-10-02 10:53:40 +02:00
AlexandreSi
a478dec68b Add serialization and unserialization methods 2023-10-02 10:53:40 +02:00
AlexandreSi
5ec7c37766 Remove unused method on ObjectsContainer 2023-10-02 10:53:40 +02:00
AlexandreSi
d37699d767 Add parent in constructors 2023-10-02 10:53:39 +02:00
AlexandreSi
6695a42475 Add possibility to rename folder 2023-10-02 10:53:39 +02:00
AlexandreSi
bdae82633f Remove ObjectOrObjectFolder from root folder when removing object from objects container 2023-10-02 10:53:39 +02:00
AlexandreSi
968416a7f7 Add method to add subfolder 2023-10-02 10:53:39 +02:00
AlexandreSi
fcf83bfdba Change object storing in ObjectFolderOrObject 2023-10-02 10:53:39 +02:00
AlexandreSi
f8e38bf719 Initiate ObjectFolderOrObject class 2023-10-02 10:53:39 +02:00
AlexandreSi
9b145a53b9 Fo not focus back input after cancelling search 2023-10-02 10:53:39 +02:00
AlexandreSi
2a6aa72c87 Add possibility to drop object group after target group 2023-10-02 10:53:39 +02:00
Alex
56a4b723e6 Open root folder on click on div 2023-10-02 10:53:39 +02:00
AlexandreSi
545f40930a Prevent renaming item on click when on mobile 2023-10-02 10:53:39 +02:00
AlexandreSi
09d64ac134 Allow setting of delay on long touch props hook 2023-10-02 10:53:39 +02:00
AlexandreSi
979473a2b1 Allow selection of text with double click on item name input 2023-10-02 10:53:39 +02:00
AlexandreSi
f33bb36ae4 Add possibility to drop item after target item 2023-10-02 10:53:39 +02:00
AlexandreSi
ab76528a6e Add possibility to get hover information on DragSourceAndDropTarget component 2023-10-02 10:53:39 +02:00
AlexandreSi
a4d4df68c2 Fix react window version 2023-10-02 10:53:39 +02:00
Alex
edfa6ba98c Add objectAddedInLayout trigger for guided lessons to adapt to new object list 2023-10-02 10:53:39 +02:00
AlexandreSi
514e143364 Open context menu on right click 2023-10-02 10:53:39 +02:00
AlexandreSi
2140cdb17f Force open global and scene objects when in a guided lesson 2023-10-02 10:53:39 +02:00
AlexandreSi
d7a91ad68d Add parameter to force all nodes to be opened 2023-10-02 10:53:39 +02:00
AlexandreSi
b7c8f3266b Open scene objects when adding an object from the asset store 2023-10-02 10:53:39 +02:00
AlexandreSi
e7c365043a Add html id back to support guided lessons 2023-10-02 10:53:39 +02:00
AlexandreSi
e4863cc4cd add possibility to drop scene item on empty placeholder in global section 2023-10-02 10:53:39 +02:00
AlexandreSi
733406bd91 Set group as global when dropping in global groups 2023-10-02 10:53:39 +02:00
AlexandreSi
957a6db6ba Set object as global when dropping in global objects 2023-10-02 10:53:39 +02:00
AlexandreSi
9124ec62e8 Remove filtering of objects external to treeview 2023-10-02 10:53:39 +02:00
AlexandreSi
299a4afdda Add indication of item name being edited 2023-10-02 10:53:39 +02:00
AlexandreSi
07a1961aec Fix flow 2023-10-02 10:53:39 +02:00
AlexandreSi
3224cb0285 Prettier 2023-10-02 10:53:39 +02:00
AlexandreSi
54fdc7ee72 Scroll to moved item to global 2023-10-02 10:53:39 +02:00
AlexandreSi
b5ad0b265c Open root folder when an item is added in group or object list 2023-10-02 10:53:39 +02:00
AlexandreSi
1239f56430 Remove former condition to drop scene object on first global object bis 2023-10-02 10:53:39 +02:00
AlexandreSi
de8fa30278 Add possibility to display placeholder in treeview + handle group renaming 2023-10-02 10:53:39 +02:00
AlexandreSi
6611ad0800 Use TreeView in ObjectsGroupsList component 2023-10-02 10:53:39 +02:00
AlexandreSi
b6b787e779 Improve display when items overflow list width 2023-10-02 10:53:39 +02:00
AlexandreSi
95c904c3af Remove former condition to drop scene object on first global object 2023-10-02 10:53:39 +02:00
AlexandreSi
787d850a73 Remove control of renamed item from outside the object list component 2023-10-02 10:53:39 +02:00
AlexandreSi
2845da7774 More efficient scrollToItem 2023-10-02 10:53:39 +02:00
AlexandreSi
017d228542 Add interface to tree view 2023-10-02 10:53:39 +02:00
AlexandreSi
56fb3901cd Add html dataset to treeview row 2023-10-02 10:53:39 +02:00
AlexandreSi
227bbbd44c Define objects list as a single TreeView 2023-10-02 10:53:39 +02:00
AlexandreSi
1b170cd428 Add a notion of top level row 2023-10-02 10:53:39 +02:00
AlexandreSi
78629374fa WIP: Replace SortableVirtualizedItemList in objects list with TreeView 2023-10-02 10:53:39 +02:00
AlexandreSi
ab9ab5587d Remove effect 2023-10-02 10:53:39 +02:00
AlexandreSi
67b9a4d44d Add react dnd type as props 2023-10-02 10:53:39 +02:00
AlexandreSi
5878bd3749 Add props for item renaming 2023-10-02 10:53:39 +02:00
AlexandreSi
570d373dbd Use callbacks 2023-10-02 10:53:38 +02:00
AlexandreSi
e5b5190e2b Add context menu opening on long touch 2023-10-02 10:53:38 +02:00
AlexandreSi
c85e172586 Hide menu button on mobile 2023-10-02 10:53:38 +02:00
AlexandreSi
0946097293 Add possibility to double click on item to edit it 2023-10-02 10:53:38 +02:00
AlexandreSi
619489e6b5 Add props to handle drag n drop 2023-10-02 10:53:38 +02:00
AlexandreSi
55938796b7 Add parameter for multiselection 2023-10-02 10:53:38 +02:00
AlexandreSi
5bd4d08749 Move item selection state holding to parent 2023-10-02 10:53:38 +02:00
AlexandreSi
b4c47d5a0b Improve naming 2023-10-02 10:53:38 +02:00
AlexandreSi
cf7926206b Fix search 2023-10-02 10:53:38 +02:00
AlexandreSi
39e29250ab Improve drag preview 2023-10-02 10:53:38 +02:00
AlexandreSi
c7938540ff Extract tree view row into own file 2023-10-02 10:53:38 +02:00
AlexandreSi
cb1fa39f46 Increase delay before opening folder 2023-10-02 10:53:38 +02:00
AlexandreSi
2f4ff772f5 Fine tune row selection 2023-10-02 10:53:38 +02:00
AlexandreSi
7b2a6c3d23 Add possibility to open context menu on each row 2023-10-02 10:53:38 +02:00
AlexandreSi
c858bbfc68 Simplify css 2023-10-02 10:53:38 +02:00
AlexandreSi
5d91d151f2 Add possibility to rename an item 2023-10-02 10:53:38 +02:00
AlexandreSi
f56f2dc6e5 Full typing 2023-10-02 10:53:38 +02:00
AlexandreSi
3fc4224efd Set up css for tree view and remove inline styling 2023-10-02 10:53:38 +02:00
AlexandreSi
af280d13ab Remove possibility to open node that is already opened due to search 2023-10-02 10:53:38 +02:00
AlexandreSi
510b9e738d Filter list with search 2023-10-02 10:53:38 +02:00
AlexandreSi
731592e1e9 Add support for item thumbnail 2023-10-02 10:53:38 +02:00
AlexandreSi
70934ebb1d Remove offset of image in ListIcon component relative to parent 2023-10-02 10:53:38 +02:00
AlexandreSi
2950abaa45 Use a generic item in props 2023-10-02 10:53:38 +02:00
AlexandreSi
271b722cd2 Extract height ann width from TreeView 2023-10-02 10:53:38 +02:00
AlexandreSi
04c2a83023 Abstract haptic feeback util 2023-10-02 10:53:38 +02:00
AlexandreSi
72a8ed7b4d Rename component 2023-10-02 10:53:38 +02:00
AlexandreSi
c295b66a26 Open folder when dragging item over 2023-10-02 10:53:38 +02:00
AlexandreSi
a6a2f55c11 Use correct styling for selected rows 2023-10-02 10:53:38 +02:00
AlexandreSi
a4b26f0530 Improve display 2023-10-02 10:53:38 +02:00
AlexandreSi
e46a72b0ed Add haptic feedback when dragging starts 2023-10-02 10:53:38 +02:00
AlexandreSi
c35dd97e4f Drag whole row 2023-10-02 10:53:38 +02:00
AlexandreSi
82677da025 Add delay on mobile before drag 2023-10-02 10:53:38 +02:00
AlexandreSi
944a13c5a5 Start treeview 2023-10-02 10:53:38 +02:00
AlexandreSi
d4a9749ff5 Install react-window 2023-10-02 10:53:38 +02:00
75 changed files with 5477 additions and 2234 deletions

View File

@@ -17,7 +17,7 @@ EventsBasedObject::EventsBasedObject()
}
EventsBasedObject::~EventsBasedObject() {}
EventsBasedObject::EventsBasedObject(const gd::EventsBasedObject &_eventBasedObject)
: AbstractEventsBasedEntity(_eventBasedObject) {
// TODO Add a copy constructor in ObjectsContainer.
@@ -30,14 +30,19 @@ void EventsBasedObject::SerializeTo(SerializerElement& element) const {
AbstractEventsBasedEntity::SerializeTo(element);
SerializeObjectsTo(element.AddChild("objects"));
SerializeFoldersTo(element.AddChild("objectsFolderStructure"));
}
void EventsBasedObject::UnserializeFrom(gd::Project& project,
const SerializerElement& element) {
const SerializerElement& element) {
defaultName = element.GetStringAttribute("defaultName");
AbstractEventsBasedEntity::UnserializeFrom(project, element);
UnserializeObjectsFrom(project, element.GetChild("objects"));
if (element.HasChild("objectsFolderStructure")) {
UnserializeFoldersFrom(project, element.GetChild("objectsFolderStructure", 0));
}
AddMissingObjectsInRootFolder();
}
} // namespace gd

View File

@@ -294,6 +294,7 @@ void Layout::SerializeTo(SerializerElement& element) const {
GetVariables().SerializeTo(element.AddChild("variables"));
GetInitialInstances().SerializeTo(element.AddChild("instances"));
SerializeObjectsTo(element.AddChild("objects"));
SerializeFoldersTo(element.AddChild("objectsFolderStructure"));
gd::EventsListSerialization::SerializeEventsTo(events,
element.AddChild("events"));
@@ -353,6 +354,11 @@ void Layout::UnserializeFrom(gd::Project& project,
project, GetEvents(), element.GetChild("events", 0, "Events"));
UnserializeObjectsFrom(project, element.GetChild("objects", 0, "Objets"));
if (element.HasChild("objectsFolderStructure")) {
UnserializeFoldersFrom(project, element.GetChild("objectsFolderStructure", 0));
}
AddMissingObjectsInRootFolder();
initialInstances.UnserializeFrom(
element.GetChild("instances", 0, "Positions"));
variables.UnserializeFrom(element.GetChild("variables", 0, "Variables"));

View File

@@ -40,7 +40,6 @@ void Object::Init(const gd::Object& object) {
name = object.name;
assetStoreId = object.assetStoreId;
objectVariables = object.objectVariables;
tags = object.tags;
effectsContainer = object.effectsContainer;
behaviors.clear();
@@ -134,7 +133,6 @@ void Object::UnserializeFrom(gd::Project& project,
SetType(element.GetStringAttribute("type"));
assetStoreId = element.GetStringAttribute("assetStoreId");
name = element.GetStringAttribute("name", name, "nom");
tags = element.GetStringAttribute("tags");
objectVariables.UnserializeFrom(
element.GetChild("variables", 0, "Variables"));
@@ -207,7 +205,6 @@ void Object::SerializeTo(SerializerElement& element) const {
element.SetAttribute("name", GetName());
element.SetAttribute("assetStoreId", GetAssetStoreId());
element.SetAttribute("type", GetType());
element.SetAttribute("tags", GetTags());
objectVariables.SerializeTo(element.AddChild("variables"));
effectsContainer.SerializeTo(element.AddChild("effects"));

View File

@@ -120,14 +120,6 @@ class GD_CORE_API Object {
*/
const gd::String& GetType() const { return configuration->GetType(); }
/** \brief Change the tags of the object.
*/
void SetTags(const gd::String& tags_) { tags = tags_; }
/** \brief Return the tags of the object.
*/
const gd::String& GetTags() const { return tags; }
/** \brief Shortcut to check if the object is a 3D object.
*/
bool Is3DObject() const { return configuration->Is3DObject(); }
@@ -268,7 +260,6 @@ class GD_CORE_API Object {
///< object.
gd::VariablesContainer
objectVariables; ///< List of the variables of the object
gd::String tags; ///< Comma-separated list of tags
gd::EffectsContainer
effectsContainer; ///< The effects container for the object.
mutable gd::String persistentUuid; ///< A persistent random version 4 UUID,

View File

@@ -0,0 +1,244 @@
/*
* GDevelop Core
* Copyright 2008-2023 Florian Rival (Florian.Rival@gmail.com). All rights
* reserved. This project is released under the MIT License.
*/
#include "GDCore/Project/ObjectFolderOrObject.h"
#include <memory>
#include "GDCore/Project/Object.h"
#include "GDCore/Project/ObjectsContainer.h"
#include "GDCore/Serialization/SerializerElement.h"
#include "GDCore/Tools/Log.h"
using namespace std;
namespace gd {
ObjectFolderOrObject ObjectFolderOrObject::badObjectFolderOrObject;
ObjectFolderOrObject::ObjectFolderOrObject()
: folderName("__NULL"), object(nullptr) {}
ObjectFolderOrObject::ObjectFolderOrObject(gd::String folderName_,
ObjectFolderOrObject* parent_)
: folderName(folderName_), parent(parent_), object(nullptr) {}
ObjectFolderOrObject::ObjectFolderOrObject(gd::Object* object_,
ObjectFolderOrObject* parent_)
: object(object_), parent(parent_) {}
ObjectFolderOrObject::~ObjectFolderOrObject() {}
bool ObjectFolderOrObject::HasObjectNamed(const gd::String& name) {
if (IsFolder()) {
return std::any_of(
children.begin(),
children.end(),
[&name](
std::unique_ptr<gd::ObjectFolderOrObject>& objectFolderOrObject) {
return objectFolderOrObject->HasObjectNamed(name);
});
}
if (!object) return false;
return object->GetName() == name;
}
ObjectFolderOrObject& ObjectFolderOrObject::GetObjectNamed(
const gd::String& name) {
if (object && object->GetName() == name) {
return *this;
}
if (IsFolder()) {
for (std::size_t j = 0; j < children.size(); j++) {
ObjectFolderOrObject& foundInChild = children[j]->GetObjectNamed(name);
if (&(foundInChild) != &badObjectFolderOrObject) {
return foundInChild;
}
}
}
return badObjectFolderOrObject;
}
void ObjectFolderOrObject::SetFolderName(const gd::String& name) {
if (!IsFolder()) return;
folderName = name;
}
ObjectFolderOrObject& ObjectFolderOrObject::GetChildAt(std::size_t index) {
if (index >= children.size()) return badObjectFolderOrObject;
return *children[index];
}
ObjectFolderOrObject& ObjectFolderOrObject::GetObjectChild(
const gd::String& name) {
for (std::size_t j = 0; j < children.size(); j++) {
if (!children[j]->IsFolder()) {
if (children[j]->GetObject().GetName() == name) return *children[j];
};
}
return badObjectFolderOrObject;
}
void ObjectFolderOrObject::InsertObject(gd::Object* insertedObject,
std::size_t position) {
auto objectFolderOrObject =
gd::make_unique<ObjectFolderOrObject>(insertedObject, this);
if (position < children.size()) {
children.insert(children.begin() + position,
std::move(objectFolderOrObject));
} else {
children.push_back(std::move(objectFolderOrObject));
}
}
std::size_t ObjectFolderOrObject::GetChildPosition(
const ObjectFolderOrObject& child) const {
for (std::size_t j = 0; j < children.size(); j++) {
if (children[j].get() == &child) return j;
}
return gd::String::npos;
}
ObjectFolderOrObject& ObjectFolderOrObject::InsertNewFolder(
const gd::String& newFolderName, std::size_t position) {
auto newFolderPtr =
gd::make_unique<ObjectFolderOrObject>(newFolderName, this);
gd::ObjectFolderOrObject& newFolder = *(*(children.insert(
position < children.size() ? children.begin() + position : children.end(),
std::move(newFolderPtr))));
return newFolder;
};
void ObjectFolderOrObject::RemoveRecursivelyObjectNamed(
const gd::String& name) {
if (IsFolder()) {
children.erase(
std::remove_if(children.begin(),
children.end(),
[&name](std::unique_ptr<gd::ObjectFolderOrObject>&
objectFolderOrObject) {
return !objectFolderOrObject->IsFolder() &&
objectFolderOrObject->GetObject().GetName() ==
name;
}),
children.end());
for (auto& it : children) {
it->RemoveRecursivelyObjectNamed(name);
}
}
};
bool ObjectFolderOrObject::IsADescendantOf(
const ObjectFolderOrObject& otherObjectFolderOrObject) {
if (parent == nullptr) return false;
if (&(*parent) == &otherObjectFolderOrObject) return true;
return parent->IsADescendantOf(otherObjectFolderOrObject);
}
void ObjectFolderOrObject::MoveChild(std::size_t oldIndex,
std::size_t newIndex) {
if (!IsFolder()) return;
if (oldIndex >= children.size() || newIndex >= children.size()) return;
std::unique_ptr<gd::ObjectFolderOrObject> objectFolderOrObject =
std::move(children[oldIndex]);
children.erase(children.begin() + oldIndex);
children.insert(children.begin() + newIndex, std::move(objectFolderOrObject));
}
void ObjectFolderOrObject::RemoveFolderChild(
const ObjectFolderOrObject& childToRemove) {
if (!IsFolder() || !childToRemove.IsFolder() ||
childToRemove.GetChildrenCount() > 0) {
return;
}
std::vector<std::unique_ptr<gd::ObjectFolderOrObject>>::iterator it = find_if(
children.begin(),
children.end(),
[&childToRemove](std::unique_ptr<gd::ObjectFolderOrObject>& child) {
return child.get() == &childToRemove;
});
if (it == children.end()) return;
children.erase(it);
}
void ObjectFolderOrObject::MoveObjectFolderOrObjectToAnotherFolder(
gd::ObjectFolderOrObject& objectFolderOrObject,
gd::ObjectFolderOrObject& newParentFolder,
std::size_t newPosition) {
if (!newParentFolder.IsFolder()) return;
if (newParentFolder.IsADescendantOf(objectFolderOrObject)) return;
std::vector<std::unique_ptr<gd::ObjectFolderOrObject>>::iterator it =
find_if(children.begin(),
children.end(),
[&objectFolderOrObject](std::unique_ptr<gd::ObjectFolderOrObject>&
childObjectFolderOrObject) {
return childObjectFolderOrObject.get() == &objectFolderOrObject;
});
if (it == children.end()) return;
std::unique_ptr<gd::ObjectFolderOrObject> objectFolderOrObjectPtr =
std::move(*it);
children.erase(it);
objectFolderOrObjectPtr->parent = &newParentFolder;
newParentFolder.children.insert(
newPosition < newParentFolder.children.size()
? newParentFolder.children.begin() + newPosition
: newParentFolder.children.end(),
std::move(objectFolderOrObjectPtr));
}
void ObjectFolderOrObject::SerializeTo(SerializerElement& element) const {
if (IsFolder()) {
element.SetAttribute("folderName", GetFolderName());
if (children.size() > 0) {
SerializerElement& childrenElement = element.AddChild("children");
childrenElement.ConsiderAsArrayOf("objectFolderOrObject");
for (std::size_t j = 0; j < children.size(); j++) {
children[j]->SerializeTo(
childrenElement.AddChild("objectFolderOrObject"));
}
}
} else {
element.SetAttribute("objectName", GetObject().GetName());
}
}
void ObjectFolderOrObject::UnserializeFrom(
gd::Project& project,
const SerializerElement& element,
gd::ObjectsContainer& objectsContainer) {
children.clear();
gd::String potentialFolderName = element.GetStringAttribute("folderName", "");
if (!potentialFolderName.empty()) {
object = nullptr;
folderName = potentialFolderName;
if (element.HasChild("children")) {
const SerializerElement& childrenElements =
element.GetChild("children", 0);
childrenElements.ConsiderAsArrayOf("objectFolderOrObject");
for (std::size_t i = 0; i < childrenElements.GetChildrenCount(); ++i) {
std::unique_ptr<ObjectFolderOrObject> childObjectFolderOrObject =
make_unique<ObjectFolderOrObject>();
childObjectFolderOrObject->UnserializeFrom(
project, childrenElements.GetChild(i), objectsContainer);
childObjectFolderOrObject->parent = this;
children.push_back(std::move(childObjectFolderOrObject));
}
}
} else {
folderName = "";
gd::String objectName = element.GetStringAttribute("objectName");
if (objectsContainer.HasObjectNamed(objectName)) {
object = &objectsContainer.GetObject(objectName);
} else {
gd::LogError("Object with name " + objectName +
" not found in objects container.");
object = nullptr;
}
}
};
} // namespace gd

View File

@@ -0,0 +1,199 @@
/*
* GDevelop Core
* Copyright 2008-2023 Florian Rival (Florian.Rival@gmail.com). All rights
* reserved. This project is released under the MIT License.
*/
#ifndef GDCORE_OBJECTFOLDEROROBJECT_H
#define GDCORE_OBJECTFOLDEROROBJECT_H
#include <memory>
#include <vector>
#include "GDCore/Serialization/SerializerElement.h"
#include "GDCore/String.h"
namespace gd {
class Project;
class Object;
class SerializerElement;
class ObjectsContainer;
} // namespace gd
namespace gd {
/**
* \brief Class representing a folder structure in order to organize objects
* in folders (to be used with an ObjectsContainer.)
*
* \see gd::ObjectsContainer
*/
class GD_CORE_API ObjectFolderOrObject {
public:
/**
* \brief Default constructor creating an empty instance. Useful for the null
* object pattern.
*/
ObjectFolderOrObject();
virtual ~ObjectFolderOrObject();
/**
* \brief Constructor for creating an instance representing a folder.
*/
ObjectFolderOrObject(gd::String folderName_,
ObjectFolderOrObject* parent_ = nullptr);
/**
* \brief Constructor for creating an instance representing an object.
*/
ObjectFolderOrObject(gd::Object* object_,
ObjectFolderOrObject* parent_ = nullptr);
/**
* \brief Returns the object behind the instance.
*/
gd::Object& GetObject() const { return *object; }
/**
* \brief Returns true if the instance represents a folder.
*/
bool IsFolder() const { return !folderName.empty(); }
/**
* \brief Returns the name of the folder.
*/
const gd::String& GetFolderName() const { return folderName; }
/**
* \brief Set the folder name. Does nothing if called on an instance not
* representing a folder.
*/
void SetFolderName(const gd::String& name);
/**
* \brief Returns true if the instance represents the object with the given
* name or if any of the children does (recursive search).
*/
bool HasObjectNamed(const gd::String& name);
/**
* \brief Returns the child instance holding the object with the given name
* (recursive search).
*/
ObjectFolderOrObject& GetObjectNamed(const gd::String& name);
/**
* \brief Returns the number of children. Returns 0 if the instance represents
* an object.
*/
std::size_t GetChildrenCount() const {
if (IsFolder()) return children.size();
return 0;
}
/**
* \brief Returns the child ObjectFolderOrObject at the given index.
*/
ObjectFolderOrObject& GetChildAt(std::size_t index);
/**
* \brief Returns the child ObjectFolderOrObject that represents the object
* with the given name. To use only if sure that the instance holds the object
* in its direct children (no recursive search).
*
* \note The equivalent method to get a folder by its name cannot be
* implemented because there is no unicity enforced on the folder name.
*/
ObjectFolderOrObject& GetObjectChild(const gd::String& name);
/**
* \brief Returns the parent of the instance. If the instance has no parent
* (root folder), the null object is returned.
*/
ObjectFolderOrObject& GetParent() {
if (parent == nullptr) {
return badObjectFolderOrObject;
}
return *parent;
};
/**
* \brief Returns true if the instance is a root folder (that's to say it
* has no parent).
*/
bool IsRootFolder() { return !object && !parent; }
/**
* \brief Moves a child from a position to a new one.
*/
void MoveChild(std::size_t oldIndex, std::size_t newIndex);
/**
* \brief Removes the given child from the instance's children. If the given
* child contains children of its own, does nothing.
*/
void RemoveFolderChild(const ObjectFolderOrObject& childToRemove);
/**
* \brief Removes the child representing the object with the given name from
* the instance children and recursively does it for every folder children.
*/
void RemoveRecursivelyObjectNamed(const gd::String& name);
/**
* \brief Inserts an instance representing the given object at the given
* position.
*/
void InsertObject(gd::Object* insertedObject,
std::size_t position = (size_t)-1);
/**
* \brief Inserts an instance representing a folder with the given name at the
* given position.
*/
ObjectFolderOrObject& InsertNewFolder(const gd::String& newFolderName,
std::size_t position);
/**
* \brief Returns true if the instance is a descendant of the given instance
* of ObjectFolderOrObject.
*/
bool IsADescendantOf(const ObjectFolderOrObject& otherObjectFolderOrObject);
/**
* \brief Returns the position of the given instance of ObjectFolderOrObject
* in the instance's children.
*/
std::size_t GetChildPosition(const ObjectFolderOrObject& child) const;
/**
* \brief Moves the given child ObjectFolderOrObject to the given folder at
* the given position.
*/
void MoveObjectFolderOrObjectToAnotherFolder(
gd::ObjectFolderOrObject& objectFolderOrObject,
gd::ObjectFolderOrObject& newParentFolder,
std::size_t newPosition);
/** \name Saving and loading
* Members functions related to saving and loading the objects of the class.
*/
///@{
/**
* \brief Serialize the ObjectFolderOrObject instance.
*/
void SerializeTo(SerializerElement& element) const;
/**
* \brief Unserialize the ObjectFolderOrObject instance.
*/
void UnserializeFrom(gd::Project& project,
const SerializerElement& element,
ObjectsContainer& objectsContainer);
///@}
private:
static gd::ObjectFolderOrObject badObjectFolderOrObject;
gd::ObjectFolderOrObject*
parent; // nullptr if root folder, points to the parent folder otherwise.
// Representing an object:
gd::Object* object; // nullptr if folderName is set.
// or representing a folder:
gd::String folderName; // Empty if object is set.
std::vector<std::unique_ptr<ObjectFolderOrObject>>
children; // Folder children.
};
} // namespace gd
#endif // GDCORE_OBJECTFOLDEROROBJECT_H

View File

@@ -9,12 +9,15 @@
#include "GDCore/Extensions/Platform.h"
#include "GDCore/Project/Object.h"
#include "GDCore/Project/ObjectFolderOrObject.h"
#include "GDCore/Project/Project.h"
#include "GDCore/Serialization/SerializerElement.h"
namespace gd {
ObjectsContainer::ObjectsContainer() {}
ObjectsContainer::ObjectsContainer() {
rootFolder = gd::make_unique<gd::ObjectFolderOrObject>("__ROOT");
}
ObjectsContainer::~ObjectsContainer() {}
@@ -24,6 +27,22 @@ void ObjectsContainer::SerializeObjectsTo(SerializerElement& element) const {
initialObjects[j]->SerializeTo(element.AddChild("object"));
}
}
void ObjectsContainer::SerializeFoldersTo(SerializerElement& element) const {
rootFolder->SerializeTo(element);
}
void ObjectsContainer::UnserializeFoldersFrom(
gd::Project& project, const SerializerElement& element) {
rootFolder->UnserializeFrom(project, element, *this);
}
void ObjectsContainer::AddMissingObjectsInRootFolder() {
for (std::size_t i = 0; i < initialObjects.size(); ++i) {
if (!rootFolder->HasObjectNamed(initialObjects[i]->GetName())) {
rootFolder->InsertObject(&(*initialObjects[i]));
}
}
}
void ObjectsContainer::UnserializeObjectsFrom(
gd::Project& project, const SerializerElement& element) {
@@ -53,7 +72,9 @@ bool ObjectsContainer::HasObjectNamed(const gd::String& name) const {
gd::Object& ObjectsContainer::GetObject(const gd::String& name) {
return *(*find_if(initialObjects.begin(),
initialObjects.end(),
bind2nd(gd::ObjectHasName(), name)));
[&name](std::unique_ptr<gd::Object>& object) {
return object->GetName() == name;
}));
}
const gd::Object& ObjectsContainer::GetObject(const gd::String& name) const {
return *(*find_if(initialObjects.begin(),
@@ -84,6 +105,22 @@ gd::Object& ObjectsContainer::InsertNewObject(const gd::Project& project,
: initialObjects.end(),
project.CreateObject(objectType, name))));
rootFolder->InsertObject(&newlyCreatedObject);
return newlyCreatedObject;
}
gd::Object& ObjectsContainer::InsertNewObjectInFolder(
const gd::Project& project,
const gd::String& objectType,
const gd::String& name,
gd::ObjectFolderOrObject& objectFolderOrObject,
std::size_t position) {
gd::Object& newlyCreatedObject = *(*(initialObjects.insert(
initialObjects.end(), project.CreateObject(objectType, name))));
objectFolderOrObject.InsertObject(&newlyCreatedObject, position);
return newlyCreatedObject;
}
@@ -97,16 +134,6 @@ gd::Object& ObjectsContainer::InsertObject(const gd::Object& object,
return newlyCreatedObject;
}
void ObjectsContainer::SwapObjects(std::size_t firstObjectIndex,
std::size_t secondObjectIndex) {
if (firstObjectIndex >= initialObjects.size() ||
secondObjectIndex >= initialObjects.size())
return;
std::iter_swap(initialObjects.begin() + firstObjectIndex,
initialObjects.begin() + secondObjectIndex);
}
void ObjectsContainer::MoveObject(std::size_t oldIndex, std::size_t newIndex) {
if (oldIndex >= initialObjects.size() || newIndex >= initialObjects.size())
return;
@@ -120,30 +147,38 @@ void ObjectsContainer::RemoveObject(const gd::String& name) {
std::vector<std::unique_ptr<gd::Object>>::iterator objectIt =
find_if(initialObjects.begin(),
initialObjects.end(),
bind2nd(ObjectHasName(), name));
[&name](std::unique_ptr<gd::Object>& object) {
return object->GetName() == name;
});
if (objectIt == initialObjects.end()) return;
rootFolder->RemoveRecursivelyObjectNamed(name);
initialObjects.erase(objectIt);
}
void ObjectsContainer::MoveObjectToAnotherContainer(
const gd::String& name,
void ObjectsContainer::MoveObjectFolderOrObjectToAnotherContainerInFolder(
gd::ObjectFolderOrObject& objectFolderOrObject,
gd::ObjectsContainer& newContainer,
gd::ObjectFolderOrObject& newParentFolder,
std::size_t newPosition) {
std::vector<std::unique_ptr<gd::Object>>::iterator objectIt =
find_if(initialObjects.begin(),
initialObjects.end(),
bind2nd(ObjectHasName(), name));
if (objectFolderOrObject.IsFolder() || !newParentFolder.IsFolder()) return;
std::vector<std::unique_ptr<gd::Object>>::iterator objectIt = find_if(
initialObjects.begin(),
initialObjects.end(),
[&objectFolderOrObject](std::unique_ptr<gd::Object>& object) {
return object->GetName() == objectFolderOrObject.GetObject().GetName();
});
if (objectIt == initialObjects.end()) return;
std::unique_ptr<gd::Object> object = std::move(*objectIt);
initialObjects.erase(objectIt);
newContainer.initialObjects.insert(
newPosition < newContainer.initialObjects.size()
? newContainer.initialObjects.begin() + newPosition
: newContainer.initialObjects.end(),
std::move(object));
newContainer.initialObjects.push_back(std::move(object));
objectFolderOrObject.GetParent().MoveObjectFolderOrObjectToAnotherFolder(
objectFolderOrObject, newParentFolder, newPosition);
}
} // namespace gd

View File

@@ -9,11 +9,12 @@
#include <vector>
#include "GDCore/String.h"
#include "GDCore/Project/ObjectGroupsContainer.h"
#include "GDCore/Project/ObjectFolderOrObject.h"
namespace gd {
class Object;
class Project;
class SerializerElement;
}
} // namespace gd
#undef GetObject // Disable an annoying macro
namespace gd {
@@ -98,6 +99,19 @@ class GD_CORE_API ObjectsContainer {
const gd::String& objectType,
const gd::String& name,
std::size_t position);
/**
* \brief Add a new empty object of type \a objectType called \a name in the
* given folder at the specified position.<br>
*
* \note The object is created using the project's current platform.
* \return A reference to the object in the list.
*/
gd::Object& InsertNewObjectInFolder(
const gd::Project& project,
const gd::String& objectType,
const gd::String& name,
gd::ObjectFolderOrObject& objectFolderOrObject,
std::size_t position);
/**
* \brief Add a new object to the list
@@ -125,18 +139,18 @@ class GD_CORE_API ObjectsContainer {
void MoveObject(std::size_t oldIndex, std::size_t newIndex);
/**
* \brief Swap the position of the specified objects.
*/
void SwapObjects(std::size_t firstObjectIndex, std::size_t secondObjectIndex);
/**
* Move the specified object to another container, removing it from the current one
* and adding it to the new one at the specified position.
* Move the specified object to another container, removing it from the
* current one and adding it to the new one at the specified position in the
* given folder.
*
* \note This does not invalidate the references to the object (object is not moved in memory,
* as referenced by smart pointers internally).
* \note This does not invalidate the references to the object (object is not
* moved in memory, as referenced by smart pointers internally).
*/
void MoveObjectToAnotherContainer(const gd::String& name, gd::ObjectsContainer & newContainer, std::size_t newPosition);
void MoveObjectFolderOrObjectToAnotherContainerInFolder(
gd::ObjectFolderOrObject& objectFolderOrObject,
gd::ObjectsContainer& newContainer,
gd::ObjectFolderOrObject& newParentFolder,
std::size_t newPosition);
/**
* Provide a raw access to the vector containing the objects
@@ -153,20 +167,36 @@ class GD_CORE_API ObjectsContainer {
}
///@}
gd::ObjectFolderOrObject& GetRootFolder() {
return *rootFolder;
}
void AddMissingObjectsInRootFolder();
/** \name Saving and loading
* Members functions related to saving and loading the objects of the class.
*/
///@{
/**
* \brief Serialize instances container.
* \brief Serialize the objects container.
*/
void SerializeObjectsTo(SerializerElement& element) const;
/**
* \brief Unserialize the instances container.
* \brief Unserialize the objects container.
*/
void UnserializeObjectsFrom(gd::Project& project,
const SerializerElement& element);
/**
* \brief Serialize folder structure.
*/
void SerializeFoldersTo(SerializerElement& element) const;
/**
* \brief Unserialize folder structure.
*/
void UnserializeFoldersFrom(gd::Project& project,
const SerializerElement& element);
///@}
/** \name Objects groups management
@@ -190,6 +220,9 @@ class GD_CORE_API ObjectsContainer {
std::vector<std::unique_ptr<gd::Object> >
initialObjects; ///< Objects contained.
gd::ObjectGroupsContainer objectGroups;
private:
std::unique_ptr<gd::ObjectFolderOrObject> rootFolder;
};
} // namespace gd

View File

@@ -125,7 +125,7 @@ Project::CreateObject(const gd::String &objectType, const gd::String &name) cons
}
}
return std::move(object);
}
@@ -849,6 +849,11 @@ void Project::UnserializeFrom(const SerializerElement& element) {
resourcesManager.UnserializeFrom(
element.GetChild("resources", 0, "Resources"));
UnserializeObjectsFrom(*this, element.GetChild("objects", 0, "Objects"));
if (element.HasChild("objectsFolderStructure")) {
UnserializeFoldersFrom(*this, element.GetChild("objectsFolderStructure", 0));
}
AddMissingObjectsInRootFolder();
GetVariables().UnserializeFrom(element.GetChild("variables", 0, "Variables"));
scenes.clear();
@@ -1000,6 +1005,7 @@ void Project::SerializeTo(SerializerElement& element) const {
resourcesManager.SerializeTo(element.AddChild("resources"));
SerializeObjectsTo(element.AddChild("objects"));
SerializeFoldersTo(element.AddChild("objectsFolderStructure"));
GetObjectGroups().SerializeTo(element.AddChild("objectsGroups"));
GetVariables().SerializeTo(element.AddChild("variables"));

View File

@@ -396,21 +396,42 @@ interface Watermark {
void UnserializeFrom([Const, Ref] SerializerElement element);
};
interface ObjectFolderOrObject {
void ObjectFolderOrObject();
boolean IsFolder();
boolean IsRootFolder();
[Ref] gdObject GetObject();
[Const, Ref] DOMString GetFolderName();
void SetFolderName([Const] DOMString name);
boolean HasObjectNamed([Const] DOMString name);
[Ref] ObjectFolderOrObject GetObjectNamed([Const] DOMString name);
unsigned long GetChildrenCount();
[Ref] ObjectFolderOrObject GetChildAt(unsigned long pos);
[Ref] ObjectFolderOrObject GetObjectChild([Const] DOMString name);
unsigned long GetChildPosition([Const, Ref] ObjectFolderOrObject child);
[Ref] ObjectFolderOrObject GetParent();
[Ref] ObjectFolderOrObject InsertNewFolder([Const] DOMString name, unsigned long newPosition);
void MoveObjectFolderOrObjectToAnotherFolder([Ref] ObjectFolderOrObject objectFolderOrObject, [Ref] ObjectFolderOrObject newParentFolder, unsigned long newPosition);
void MoveChild(unsigned long oldIndex, unsigned long newIndex);
void RemoveFolderChild([Const, Ref] ObjectFolderOrObject childToRemove);
boolean IsADescendantOf([Const, Ref] ObjectFolderOrObject otherObjectFolderOrObject);
};
interface ObjectsContainer {
void ObjectsContainer();
[Ref] gdObject InsertNewObject([Ref] Project project, [Const] DOMString type, [Const] DOMString name, unsigned long pos);
[Ref] gdObject InsertNewObjectInFolder([Ref] Project project, [Const] DOMString type, [Const] DOMString name, [Ref] ObjectFolderOrObject folder, unsigned long pos);
[Ref] gdObject InsertObject([Const, Ref] gdObject obj, unsigned long pos);
boolean HasObjectNamed([Const] DOMString name);
[Ref] gdObject GetObject([Const] DOMString name);
[Ref] gdObject GetObjectAt(unsigned long pos);
unsigned long GetObjectPosition([Const] DOMString name);
void RemoveObject([Const] DOMString name);
void SwapObjects(unsigned long first, unsigned long second);
void MoveObject(unsigned long oldIndex, unsigned long newIndex);
void MoveObjectToAnotherContainer([Const] DOMString name, [Ref] ObjectsContainer newObjectsContainer, unsigned long newPosition);
void MoveObjectFolderOrObjectToAnotherContainerInFolder([Ref] ObjectFolderOrObject objectFolderOrObject, [Ref] ObjectsContainer newObjectsContainer, [Ref] ObjectFolderOrObject parentObjectFolderOrObject, unsigned long newPosition);
unsigned long GetObjectsCount();
[Ref] ObjectFolderOrObject GetRootFolder();
[Ref] ObjectGroupsContainer GetObjectGroups();
};
@@ -544,17 +565,17 @@ interface Project {
//Inherited from gd::ObjectsContainer
[Ref] gdObject InsertNewObject([Ref] Project project, [Const] DOMString type, [Const] DOMString name, unsigned long pos);
[Ref] gdObject InsertNewObjectInFolder([Ref] Project project, [Const] DOMString type, [Const] DOMString name, [Ref] ObjectFolderOrObject folder, unsigned long pos);
[Ref] gdObject InsertObject([Const, Ref] gdObject obj, unsigned long pos);
boolean HasObjectNamed([Const] DOMString name);
[Ref] gdObject GetObject([Const] DOMString name);
[Ref] gdObject GetObjectAt(unsigned long pos);
unsigned long GetObjectPosition([Const] DOMString name);
void RemoveObject([Const] DOMString name);
void SwapObjects(unsigned long first, unsigned long second);
void MoveObject(unsigned long oldIndex, unsigned long newIndex);
void MoveObjectToAnotherContainer([Const] DOMString name, [Ref] ObjectsContainer newObjectsContainer, unsigned long newPosition);
void MoveObjectFolderOrObjectToAnotherContainerInFolder([Ref] ObjectFolderOrObject objectFolderOrObject, [Ref] ObjectsContainer newObjectsContainer, [Ref] ObjectFolderOrObject parentObjectFolderOrObject, unsigned long newPosition);
unsigned long GetObjectsCount();
[Ref] ObjectFolderOrObject GetRootFolder();
[Ref] ObjectGroupsContainer GetObjectGroups();
};
@@ -687,8 +708,6 @@ interface gdObject {
[Const, Ref] DOMString GetAssetStoreId();
void SetType([Const] DOMString type);
[Const, Ref] DOMString GetType();
void SetTags([Const] DOMString tags);
[Const, Ref] DOMString GetTags();
boolean Is3DObject();
[Ref] ObjectConfiguration GetConfiguration();
@@ -790,17 +809,17 @@ interface Layout {
//Inherited from gd::ObjectsContainer
[Ref] gdObject InsertNewObject([Ref] Project project, [Const] DOMString type, [Const] DOMString name, unsigned long pos);
[Ref] gdObject InsertNewObjectInFolder([Ref] Project project, [Const] DOMString type, [Const] DOMString name, [Ref] ObjectFolderOrObject folder, unsigned long pos);
[Ref] gdObject InsertObject([Const, Ref] gdObject obj, unsigned long pos);
boolean HasObjectNamed([Const] DOMString name);
[Ref] gdObject GetObject([Const] DOMString name);
[Ref] gdObject GetObjectAt(unsigned long pos);
unsigned long GetObjectPosition([Const] DOMString name);
void RemoveObject([Const] DOMString name);
void SwapObjects(unsigned long first, unsigned long second);
void MoveObject(unsigned long oldIndex, unsigned long newIndex);
void MoveObjectToAnotherContainer([Const] DOMString name, [Ref] ObjectsContainer newObjectsContainer, unsigned long newPosition);
void MoveObjectFolderOrObjectToAnotherContainerInFolder([Ref] ObjectFolderOrObject objectFolderOrObject, [Ref] ObjectsContainer newObjectsContainer, [Ref] ObjectFolderOrObject parentObjectFolderOrObject, unsigned long newPosition);
unsigned long GetObjectsCount();
[Ref] ObjectFolderOrObject GetRootFolder();
[Ref] ObjectGroupsContainer GetObjectGroups();
};
@@ -2749,17 +2768,18 @@ interface EventsBasedObject {
// Inherited from gd::ObjectsContainer
[Ref] gdObject InsertNewObject([Ref] Project project, [Const] DOMString type, [Const] DOMString name, unsigned long pos);
[Ref] gdObject InsertNewObjectInFolder([Ref] Project project, [Const] DOMString type, [Const] DOMString name, [Ref] ObjectFolderOrObject folder, unsigned long pos);
[Ref] gdObject InsertObject([Const, Ref] gdObject obj, unsigned long pos);
boolean HasObjectNamed([Const] DOMString name);
[Ref] gdObject GetObject([Const] DOMString name);
[Ref] gdObject GetObjectAt(unsigned long pos);
unsigned long GetObjectPosition([Const] DOMString name);
void RemoveObject([Const] DOMString name);
void SwapObjects(unsigned long first, unsigned long second);
void MoveObject(unsigned long oldIndex, unsigned long newIndex);
void MoveObjectToAnotherContainer([Const] DOMString name, [Ref] ObjectsContainer newObjectsContainer, unsigned long newPosition);
void MoveObjectFolderOrObjectToAnotherContainerInFolder([Ref] ObjectFolderOrObject objectFolderOrObject, [Ref] ObjectsContainer newObjectsContainer, [Ref] ObjectFolderOrObject parentObjectFolderOrObject, unsigned long newPosition);
unsigned long GetObjectsCount();
[Ref] ObjectFolderOrObject GetRootFolder();
[Ref] ObjectGroupsContainer GetObjectGroups();
};
EventsBasedObject implements AbstractEventsBasedEntity;

View File

@@ -72,6 +72,7 @@
#include <GDCore/Project/MeasurementUnitElement.h>
#include <GDCore/Project/NamedPropertyDescriptor.h>
#include <GDCore/Project/Object.h>
#include <GDCore/Project/ObjectFolderOrObject.h>
#include <GDCore/Project/ObjectConfiguration.h>
#include <GDCore/Project/CustomObjectConfiguration.h>
#include <GDCore/Project/Project.h>

View File

@@ -4,6 +4,7 @@ const {
makeFakeAbstractFileSystem,
} = require('../TestUtils/FakeAbstractFileSystem');
const extend = require('extend');
const { mapFor } = require('../../newIDE/app/src/Utils/MapFor.js');
describe('libGD.js', function () {
let gd = null;
@@ -440,7 +441,10 @@ describe('libGD.js', function () {
// Prepare two containers, one with 3 objects and one empty
const objectsContainer1 = new gd.ObjectsContainer();
const rootFolder1 = objectsContainer1.getRootFolder();
const objectsContainer2 = new gd.ObjectsContainer();
const rootFolder2 = objectsContainer2.getRootFolder();
const subFolder2 = rootFolder2.insertNewFolder('Folder', 1);
const mySpriteObject = objectsContainer1.insertNewObject(
project,
'Sprite',
@@ -460,9 +464,11 @@ describe('libGD.js', function () {
2
);
// Find the pointer to the objects in memory
expect(objectsContainer1.getObjectsCount()).toBe(3);
expect(objectsContainer2.getObjectsCount()).toBe(0);
expect(rootFolder1.getChildrenCount()).toBe(3);
expect(rootFolder2.getChildrenCount()).toBe(1);
// Find the pointer to the objects in memory
const mySpriteObjectPtr = gd.getPointer(objectsContainer1.getObjectAt(0));
const mySprite2ObjectPtr = gd.getPointer(
objectsContainer1.getObjectAt(1)
@@ -470,11 +476,24 @@ describe('libGD.js', function () {
const mySprite3ObjectPtr = gd.getPointer(
objectsContainer1.getObjectAt(2)
);
const mySpriteObjectFolderOrObject = rootFolder1.getChildAt(0);
const mySprite2ObjectFolderOrObject = rootFolder1.getChildAt(1);
const mySprite3ObjectFolderOrObject = rootFolder1.getChildAt(2);
const mySpriteObjectFolderOrObjectPtr = gd.getPointer(
mySpriteObjectFolderOrObject
);
const mySprite2ObjectFolderOrObjectPtr = gd.getPointer(
mySprite2ObjectFolderOrObject
);
const mySprite3ObjectFolderOrObjectPtr = gd.getPointer(
mySprite3ObjectFolderOrObject
);
// Move objects between containers
objectsContainer1.moveObjectToAnotherContainer(
'MySprite2',
objectsContainer1.moveObjectFolderOrObjectToAnotherContainerInFolder(
mySprite2ObjectFolderOrObject,
objectsContainer2,
rootFolder2,
0
);
expect(objectsContainer1.getObjectsCount()).toBe(2);
@@ -482,17 +501,34 @@ describe('libGD.js', function () {
expect(objectsContainer1.getObjectAt(1).getName()).toBe('MySprite3');
expect(objectsContainer2.getObjectsCount()).toBe(1);
expect(objectsContainer2.getObjectAt(0).getName()).toBe('MySprite2');
expect(rootFolder2.hasObjectNamed('MySprite2')).toBe(true);
expect(rootFolder2.getChildrenCount()).toBe(2);
expect(gd.getPointer(rootFolder2.getObjectChild('MySprite2'))).toBe(
mySprite2ObjectFolderOrObjectPtr
);
expect(rootFolder2.getObjectChild('MySprite2')).toBe(
mySprite2ObjectFolderOrObject
);
expect(mySprite2ObjectFolderOrObject.getParent()).toBe(rootFolder2);
objectsContainer1.moveObjectToAnotherContainer(
'MySprite3',
// Move object in sub folder.
objectsContainer1.moveObjectFolderOrObjectToAnotherContainerInFolder(
mySprite3ObjectFolderOrObject,
objectsContainer2,
1
subFolder2,
0
);
expect(objectsContainer1.getObjectsCount()).toBe(1);
expect(objectsContainer1.getObjectAt(0).getName()).toBe('MySprite');
expect(objectsContainer2.getObjectsCount()).toBe(2);
expect(objectsContainer2.getObjectAt(0).getName()).toBe('MySprite2');
expect(objectsContainer2.getObjectAt(1).getName()).toBe('MySprite3');
expect(subFolder2.hasObjectNamed('MySprite3')).toBe(true);
expect(subFolder2.getChildrenCount()).toBe(1);
expect(gd.getPointer(subFolder2.getObjectChild('MySprite3'))).toBe(
mySprite3ObjectFolderOrObjectPtr
);
expect(mySprite3ObjectFolderOrObject.getParent()).toBe(subFolder2);
// Check that the object in memory are the same, even if moved to another container
expect(gd.getPointer(objectsContainer1.getObjectAt(0))).toBe(
@@ -505,27 +541,53 @@ describe('libGD.js', function () {
mySprite3ObjectPtr
);
objectsContainer2.moveObjectToAnotherContainer(
'MySprite2',
expect(gd.getPointer(rootFolder2.getObjectChild('MySprite2'))).toBe(
mySprite2ObjectFolderOrObjectPtr
);
expect(rootFolder2.getObjectChild('MySprite2')).toBe(
mySprite2ObjectFolderOrObject
);
// Move back first object to first container
objectsContainer2.moveObjectFolderOrObjectToAnotherContainerInFolder(
mySprite2ObjectFolderOrObject,
objectsContainer1,
rootFolder1,
0
);
expect(objectsContainer1.getObjectsCount()).toBe(2);
expect(objectsContainer1.getObjectAt(0).getName()).toBe('MySprite2');
expect(objectsContainer1.getObjectAt(1).getName()).toBe('MySprite');
expect(objectsContainer1.getObjectAt(0).getName()).toBe('MySprite');
expect(objectsContainer1.getObjectAt(1).getName()).toBe('MySprite2');
expect(objectsContainer2.getObjectsCount()).toBe(1);
expect(objectsContainer2.getObjectAt(0).getName()).toBe('MySprite3');
expect(rootFolder2.hasObjectNamed('MySprite2')).toBe(false);
expect(rootFolder2.getChildrenCount()).toBe(1);
expect(rootFolder1.getChildrenCount()).toBe(2);
expect(rootFolder1.getChildAt(0).getObject().getName()).toBe('MySprite2');
expect(rootFolder1.getChildAt(1).getObject().getName()).toBe('MySprite');
expect(rootFolder1.hasObjectNamed('MySprite2')).toBe(true);
expect(mySprite2ObjectFolderOrObject.getParent()).toBe(rootFolder1);
// Check again that the object in memory are the same, even if moved to another container
expect(gd.getPointer(objectsContainer1.getObjectAt(0))).toBe(
mySprite2ObjectPtr
mySpriteObjectPtr
);
expect(gd.getPointer(objectsContainer1.getObjectAt(1))).toBe(
mySpriteObjectPtr
mySprite2ObjectPtr
);
expect(gd.getPointer(objectsContainer2.getObjectAt(0))).toBe(
mySprite3ObjectPtr
);
expect(gd.getPointer(rootFolder1.getObjectChild('MySprite2'))).toBe(
mySprite2ObjectFolderOrObjectPtr
);
expect(gd.getPointer(rootFolder1.getObjectChild('MySprite'))).toBe(
mySpriteObjectFolderOrObjectPtr
);
expect(gd.getPointer(subFolder2.getObjectChild('MySprite3'))).toBe(
mySprite3ObjectFolderOrObjectPtr
);
project.delete();
});
});
@@ -2865,13 +2927,11 @@ describe('libGD.js', function () {
});
describe('gd.SpriteObject', function () {
it('is a gd.Object and can have tags', function () {
it('is a gd.Object', function () {
const project = new gd.ProjectHelper.createNewGDJSProject();
let object = project.insertNewObject(project, 'Sprite', 'MySpriteObject');
expect(object instanceof gd.Object).toBe(true);
object.setTags('tag1, tag2, tag3');
expect(object.getTags()).toBe('tag1, tag2, tag3');
expect(object.getVariables()).toBeTruthy();
project.delete();
});
@@ -4537,4 +4597,276 @@ Array [
).toBe(true);
});
});
describe('gd.ObjectFolderOrObject (using gd.ObjectsContainer)', () => {
let project = null;
let layout = null;
beforeAll(() => {
project = gd.ProjectHelper.createNewGDJSProject();
});
afterEach(() => {
project.removeLayout('Scene');
});
beforeEach(() => {
layout = project.insertNewLayout('Scene', 0);
});
test('objects container has a root ObjectFolderOrObject', () => {
const rootFolder = layout.getRootFolder();
expect(rootFolder.isFolder()).toBe(true);
expect(rootFolder.isRootFolder()).toBe(true);
expect(rootFolder.getParent().isFolder()).toBe(true);
expect(rootFolder.getParent().getFolderName()).toEqual('__NULL');
expect(rootFolder.getChildrenCount()).toEqual(0);
});
test('an object added to the object container is added to the root ObjectFolderOrObject', () => {
let object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
const rootFolder = layout.getRootFolder();
expect(rootFolder.hasObjectNamed('MyObject')).toBe(true);
expect(rootFolder.isRootFolder()).toBe(true);
expect(rootFolder.getChildrenCount()).toEqual(1);
layout.removeObject('MyObject');
expect(rootFolder.hasObjectNamed('MyObject')).toBe(false);
expect(rootFolder.getChildrenCount()).toEqual(0);
});
test('a folder can be added to the root folder', () => {
const rootFolder = layout.getRootFolder();
const subFolder = rootFolder.insertNewFolder('Enemies', 1);
expect(subFolder.getFolderName()).toEqual('Enemies');
expect(subFolder.isRootFolder()).toBe(false);
subFolder.setFolderName('Players');
expect(subFolder.getFolderName()).toEqual('Players');
expect(subFolder.getParent()).toBe(rootFolder);
expect(rootFolder.getChildrenCount()).toEqual(1);
});
test('an object can be added to a specific folder', () => {
const rootFolder = layout.getRootFolder();
const subFolder = rootFolder.insertNewFolder('Enemies', 0);
const subSubFolder = subFolder.insertNewFolder('Turtles', 0);
layout.insertNewObjectInFolder(
project,
'Sprite',
'RedTurtle',
subSubFolder,
0
);
expect(layout.hasObjectNamed('RedTurtle')).toBe(true);
expect(subSubFolder.hasObjectNamed('RedTurtle')).toBe(true);
});
test('an ObjectFolderOrObject can be serialized and unserialized', () => {
const rootFolder = layout.getRootFolder();
const object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
const subFolder = rootFolder.insertNewFolder('Enemies', 1);
const object2 = layout.insertNewObject(
project,
'Sprite',
'OtherObject',
1
);
const object3 = layout.insertNewObject(project, 'Sprite', 'SubObject', 2);
rootFolder.moveObjectFolderOrObjectToAnotherFolder(
rootFolder.getObjectChild('SubObject'),
subFolder,
0
);
expect(rootFolder.hasObjectNamed('MyObject')).toBe(true);
expect(rootFolder.hasObjectNamed('OtherObject')).toBe(true);
expect(rootFolder.getChildrenCount()).toEqual(3);
expect(
rootFolder.getChildPosition(rootFolder.getObjectChild('MyObject'))
).toEqual(0);
expect(rootFolder.getChildPosition(subFolder)).toEqual(1);
expect(
rootFolder.getChildPosition(rootFolder.getObjectChild('OtherObject'))
).toEqual(2);
expect(rootFolder.hasObjectNamed('SubObject')).toBe(true);
expect(subFolder.hasObjectNamed('SubObject')).toBe(true);
const element = new gd.SerializerElement();
layout.serializeTo(element);
project.removeLayout('Scene');
const layout2 = project.insertNewLayout('Scene2', 0);
layout2.unserializeFrom(project, element);
expect(layout2.hasObjectNamed('MyObject')).toBe(true);
expect(layout2.hasObjectNamed('OtherObject')).toBe(true);
const rootFolder2 = layout.getRootFolder();
expect(rootFolder2.hasObjectNamed('MyObject')).toBe(true);
expect(rootFolder2.hasObjectNamed('OtherObject')).toBe(true);
expect(rootFolder2.getChildrenCount()).toEqual(3);
const parentEqualities = mapFor(
0,
rootFolder2.getChildrenCount(),
(i) => {
const childObjectFolderOrObject = rootFolder2.getChildAt(i);
return childObjectFolderOrObject.getParent() === rootFolder2;
}
);
expect(parentEqualities.every((equality) => equality)).toBe(true);
const subFolder2 = rootFolder2.getChildAt(1);
expect(subFolder2.isFolder()).toBe(true);
const subObject = subFolder2.getObjectChild('SubObject');
expect(subObject.getParent()).toBe(subFolder2);
});
test('an ObjectFolderOrObject can be serialized and unserialized and missing object folders or objects are added', () => {
const rootFolder = layout.getRootFolder();
const object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
const subFolder = rootFolder.insertNewFolder('Enemies', 1);
const object2 = layout.insertNewObject(
project,
'Sprite',
'OtherObject',
1
);
const object3 = layout.insertNewObject(project, 'Sprite', 'SubObject', 2);
rootFolder.moveObjectFolderOrObjectToAnotherFolder(
rootFolder.getObjectChild('SubObject'),
subFolder,
0
);
expect(rootFolder.hasObjectNamed('MyObject')).toBe(true);
expect(rootFolder.hasObjectNamed('OtherObject')).toBe(true);
expect(rootFolder.getChildrenCount()).toEqual(3);
expect(rootFolder.hasObjectNamed('SubObject')).toBe(true);
expect(subFolder.hasObjectNamed('SubObject')).toBe(true);
const element = new gd.SerializerElement();
layout.serializeTo(element);
const layoutObject = JSON.parse(gd.Serializer.toJSON(element));
delete layoutObject.objectsFolderStructure;
project.removeLayout('Scene');
const layout2 = project.insertNewLayout('Scene2', 0);
layout2.unserializeFrom(
project,
gd.Serializer.fromJSObject(layoutObject)
);
expect(layout2.hasObjectNamed('MyObject')).toBe(true);
expect(layout2.hasObjectNamed('OtherObject')).toBe(true);
const rootFolder2 = layout.getRootFolder();
expect(rootFolder2.hasObjectNamed('MyObject')).toBe(true);
expect(rootFolder2.hasObjectNamed('OtherObject')).toBe(true);
expect(rootFolder2.getChildrenCount()).toEqual(3);
const parentEqualities = mapFor(
0,
rootFolder2.getChildrenCount(),
(i) => {
const childObjectFolderOrObject = rootFolder2.getChildAt(i);
return childObjectFolderOrObject.getParent() === rootFolder2;
}
);
expect(parentEqualities.every((equality) => equality)).toBe(true);
});
test('a folder can be removed from its parent if empty', () => {
const rootFolder = layout.getRootFolder();
const object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
let subFolder = rootFolder.insertNewFolder('Enemies', 1);
const object2 = layout.insertNewObject(
project,
'Sprite',
'OtherObject',
2
);
rootFolder.moveObjectFolderOrObjectToAnotherFolder(
rootFolder.getObjectChild('OtherObject'),
subFolder,
0
);
rootFolder.removeFolderChild(subFolder);
// Check subfolder is still here since it was not empty.
expect(rootFolder.getChildrenCount()).toEqual(2);
subFolder = rootFolder.getChildAt(1);
expect(subFolder.isFolder()).toBe(true);
expect(subFolder.getChildrenCount()).toEqual(1);
expect(subFolder.hasObjectNamed('OtherObject')).toBe(true);
// Empty subfolder and remove it.
subFolder.moveObjectFolderOrObjectToAnotherFolder(
subFolder.getObjectChild('OtherObject'),
rootFolder,
0
);
rootFolder.removeFolderChild(subFolder);
expect(rootFolder.getChildrenCount()).toEqual(2);
const objectFolderOrObject = rootFolder.getChildAt(1)
const otherObjectFolderOrObject = rootFolder.getChildAt(0)
expect(otherObjectFolderOrObject.isFolder()).toBe(false);
expect(otherObjectFolderOrObject.isRootFolder()).toBe(false);
expect(otherObjectFolderOrObject.getObject().getName()).toBe('OtherObject');
expect(objectFolderOrObject.isFolder()).toBe(false);
expect(objectFolderOrObject.isRootFolder()).toBe(false);
expect(objectFolderOrObject.getObject().getName()).toBe('MyObject');
});
test("an ObjectFolderOrObject can test if it's a descendant of another one", () => {
const rootFolder = layout.getRootFolder();
const subFolder = rootFolder.insertNewFolder('Depth1', 0);
const subSubFolder = subFolder.insertNewFolder('Depth2', 0);
const object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
rootFolder.moveObjectFolderOrObjectToAnotherFolder(
rootFolder.getObjectChild('MyObject'),
subSubFolder,
0
);
const objectFolderOrObject = subSubFolder.getChildAt(0);
expect(objectFolderOrObject.isFolder()).toBe(false);
expect(objectFolderOrObject.getObject().getName()).toEqual('MyObject');
expect(objectFolderOrObject.isADescendantOf(subSubFolder)).toBe(true);
expect(objectFolderOrObject.isADescendantOf(subFolder)).toBe(true);
expect(objectFolderOrObject.isADescendantOf(rootFolder)).toBe(true);
expect(subSubFolder.isADescendantOf(subFolder)).toBe(true);
expect(subSubFolder.isADescendantOf(rootFolder)).toBe(true);
expect(subFolder.isADescendantOf(rootFolder)).toBe(true);
expect(rootFolder.isADescendantOf(objectFolderOrObject)).toBe(false);
expect(rootFolder.isADescendantOf(subSubFolder)).toBe(false);
expect(rootFolder.isADescendantOf(subFolder)).toBe(false);
expect(rootFolder.isADescendantOf(rootFolder)).toBe(false);
expect(subFolder.isADescendantOf(objectFolderOrObject)).toBe(false);
expect(subFolder.isADescendantOf(subSubFolder)).toBe(false);
expect(subFolder.isADescendantOf(subFolder)).toBe(false);
expect(subSubFolder.isADescendantOf(objectFolderOrObject)).toBe(false);
expect(subSubFolder.isADescendantOf(subSubFolder)).toBe(false);
expect(objectFolderOrObject.isADescendantOf(objectFolderOrObject)).toBe(
false
);
});
test("an ObjectFolderOrObject representing an object can be retrieved using the object name only", () => {
const rootFolder = layout.getRootFolder();
const subFolder = rootFolder.insertNewFolder('Depth1', 0);
const subSubFolder = subFolder.insertNewFolder('Depth2', 0);
const object = layout.insertNewObject(project, 'Sprite', 'MyObject', 0);
rootFolder.moveObjectFolderOrObjectToAnotherFolder(
rootFolder.getObjectChild('MyObject'),
subSubFolder,
0
);
const objectFolderOrObject = subSubFolder.getChildAt(0);
expect(objectFolderOrObject.isRootFolder()).toBe(false);
const objectFolderOrObjectFoundByName = rootFolder.getObjectNamed('MyObject');
expect(objectFolderOrObjectFoundByName.isRootFolder()).toBe(false);
expect(objectFolderOrObjectFoundByName).toBe(objectFolderOrObject);
});
});
});

View File

@@ -14,16 +14,17 @@ declare class gdEventsBasedObject extends gdAbstractEventsBasedEntity {
static getPropertyExpressionName(propertyName: string): string;
static getPropertyToggleActionName(propertyName: string): string;
insertNewObject(project: gdProject, type: string, name: string, pos: number): gdObject;
insertNewObjectInFolder(project: gdProject, type: string, name: string, folder: gdObjectFolderOrObject, pos: number): gdObject;
insertObject(obj: gdObject, pos: number): gdObject;
hasObjectNamed(name: string): boolean;
getObject(name: string): gdObject;
getObjectAt(pos: number): gdObject;
getObjectPosition(name: string): number;
removeObject(name: string): void;
swapObjects(first: number, second: number): void;
moveObject(oldIndex: number, newIndex: number): void;
moveObjectToAnotherContainer(name: string, newObjectsContainer: gdObjectsContainer, newPosition: number): void;
moveObjectFolderOrObjectToAnotherContainerInFolder(objectFolderOrObject: gdObjectFolderOrObject, newObjectsContainer: gdObjectsContainer, parentObjectFolderOrObject: gdObjectFolderOrObject, newPosition: number): void;
getObjectsCount(): number;
getRootFolder(): gdObjectFolderOrObject;
getObjectGroups(): gdObjectGroupsContainer;
delete(): void;
ptr: number;

View File

@@ -33,16 +33,17 @@ declare class gdLayout extends gdObjectsContainer {
setStopSoundsOnStartup(enable: boolean): void;
stopSoundsOnStartup(): boolean;
insertNewObject(project: gdProject, type: string, name: string, pos: number): gdObject;
insertNewObjectInFolder(project: gdProject, type: string, name: string, folder: gdObjectFolderOrObject, pos: number): gdObject;
insertObject(obj: gdObject, pos: number): gdObject;
hasObjectNamed(name: string): boolean;
getObject(name: string): gdObject;
getObjectAt(pos: number): gdObject;
getObjectPosition(name: string): number;
removeObject(name: string): void;
swapObjects(first: number, second: number): void;
moveObject(oldIndex: number, newIndex: number): void;
moveObjectToAnotherContainer(name: string, newObjectsContainer: gdObjectsContainer, newPosition: number): void;
moveObjectFolderOrObjectToAnotherContainerInFolder(objectFolderOrObject: gdObjectFolderOrObject, newObjectsContainer: gdObjectsContainer, parentObjectFolderOrObject: gdObjectFolderOrObject, newPosition: number): void;
getObjectsCount(): number;
getRootFolder(): gdObjectFolderOrObject;
getObjectGroups(): gdObjectGroupsContainer;
delete(): void;
ptr: number;

View File

@@ -8,8 +8,6 @@ declare class gdObject {
getAssetStoreId(): string;
setType(type: string): void;
getType(): string;
setTags(tags: string): void;
getTags(): string;
is3DObject(): boolean;
getConfiguration(): gdObjectConfiguration;
getVariables(): gdVariablesContainer;

View File

@@ -0,0 +1,23 @@
// Automatically generated by GDevelop.js/scripts/generate-types.js
declare class gdObjectFolderOrObject {
constructor(): void;
isFolder(): boolean;
isRootFolder(): boolean;
getObject(): gdObject;
getFolderName(): string;
setFolderName(name: string): void;
hasObjectNamed(name: string): boolean;
getObjectNamed(name: string): gdObjectFolderOrObject;
getChildrenCount(): number;
getChildAt(pos: number): gdObjectFolderOrObject;
getObjectChild(name: string): gdObjectFolderOrObject;
getChildPosition(child: gdObjectFolderOrObject): number;
getParent(): gdObjectFolderOrObject;
insertNewFolder(name: string, newPosition: number): gdObjectFolderOrObject;
moveObjectFolderOrObjectToAnotherFolder(objectFolderOrObject: gdObjectFolderOrObject, newParentFolder: gdObjectFolderOrObject, newPosition: number): void;
moveChild(oldIndex: number, newIndex: number): void;
removeFolderChild(childToRemove: gdObjectFolderOrObject): void;
isADescendantOf(otherObjectFolderOrObject: gdObjectFolderOrObject): boolean;
delete(): void;
ptr: number;
};

View File

@@ -2,16 +2,17 @@
declare class gdObjectsContainer {
constructor(): void;
insertNewObject(project: gdProject, type: string, name: string, pos: number): gdObject;
insertNewObjectInFolder(project: gdProject, type: string, name: string, folder: gdObjectFolderOrObject, pos: number): gdObject;
insertObject(obj: gdObject, pos: number): gdObject;
hasObjectNamed(name: string): boolean;
getObject(name: string): gdObject;
getObjectAt(pos: number): gdObject;
getObjectPosition(name: string): number;
removeObject(name: string): void;
swapObjects(first: number, second: number): void;
moveObject(oldIndex: number, newIndex: number): void;
moveObjectToAnotherContainer(name: string, newObjectsContainer: gdObjectsContainer, newPosition: number): void;
moveObjectFolderOrObjectToAnotherContainerInFolder(objectFolderOrObject: gdObjectFolderOrObject, newObjectsContainer: gdObjectsContainer, parentObjectFolderOrObject: gdObjectFolderOrObject, newPosition: number): void;
getObjectsCount(): number;
getRootFolder(): gdObjectFolderOrObject;
getObjectGroups(): gdObjectGroupsContainer;
delete(): void;
ptr: number;

View File

@@ -114,16 +114,17 @@ declare class gdProject extends gdObjectsContainer {
getTypeOfBehaviorInObjectOrGroup(layout: gdLayout, objectOrGroupName: string, behaviorName: string, searchInGroups: boolean): string;
getBehaviorNamesInObjectOrGroup(layout: gdLayout, objectOrGroupName: string, behaviorType: string, searchInGroups: boolean): gdVectorString;
insertNewObject(project: gdProject, type: string, name: string, pos: number): gdObject;
insertNewObjectInFolder(project: gdProject, type: string, name: string, folder: gdObjectFolderOrObject, pos: number): gdObject;
insertObject(obj: gdObject, pos: number): gdObject;
hasObjectNamed(name: string): boolean;
getObject(name: string): gdObject;
getObjectAt(pos: number): gdObject;
getObjectPosition(name: string): number;
removeObject(name: string): void;
swapObjects(first: number, second: number): void;
moveObject(oldIndex: number, newIndex: number): void;
moveObjectToAnotherContainer(name: string, newObjectsContainer: gdObjectsContainer, newPosition: number): void;
moveObjectFolderOrObjectToAnotherContainerInFolder(objectFolderOrObject: gdObjectFolderOrObject, newObjectsContainer: gdObjectsContainer, parentObjectFolderOrObject: gdObjectFolderOrObject, newPosition: number): void;
getObjectsCount(): number;
getRootFolder(): gdObjectFolderOrObject;
getObjectGroups(): gdObjectGroupsContainer;
delete(): void;
ptr: number;

View File

@@ -71,6 +71,7 @@ declare class libGDevelop {
PlatformSpecificAssets: Class<gdPlatformSpecificAssets>;
LoadingScreen: Class<gdLoadingScreen>;
Watermark: Class<gdWatermark>;
ObjectFolderOrObject: Class<gdObjectFolderOrObject>;
ObjectsContainer: Class<gdObjectsContainer>;
Project: Class<gdProject>;
ObjectsContainersList: Class<gdObjectsContainersList>;

View File

@@ -11,6 +11,10 @@
# almost always during development (any change in GDJS/extensions, or any restart of the app).
<PROJECT_ROOT>/node_modules/GDJS-for-web-app-only/.*
[untyped]
# react-window has some errors
<PROJECT_ROOT>/node_modules/react-window/*
[declarations]
# lingui-js triggers some Flow errors
<PROJECT_ROOT>/node_modules/@lingui/core/.*

View File

@@ -54,6 +54,7 @@
"react-sortable-tree": "2.6.2",
"react-test-renderer": "16.14.0",
"react-virtualized": "9.21.1",
"react-window": "1.8.9",
"recharts": "^2.1.10",
"remark-gfm": "^3.0.1",
"remark-parse": "^10.0.2",
@@ -24909,6 +24910,11 @@
"node": ">= 4.0.0"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"node_modules/memoizerific": {
"version": "1.11.3",
"dev": true,
@@ -30706,6 +30712,22 @@
"@babel/runtime": "^7.1.2"
}
},
"node_modules/react-window": {
"version": "1.8.9",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.9.tgz",
"integrity": "sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/reactcss": {
"version": "1.2.3",
"license": "MIT",
@@ -53649,6 +53671,11 @@
"fs-monkey": "^1.0.4"
}
},
"memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"memoizerific": {
"version": "1.11.3",
"dev": true,
@@ -57427,6 +57454,15 @@
}
}
},
"react-window": {
"version": "1.8.9",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.9.tgz",
"integrity": "sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==",
"requires": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
}
},
"reactcss": {
"version": "1.2.3",
"requires": {

View File

@@ -84,6 +84,7 @@
"react-sortable-tree": "2.6.2",
"react-test-renderer": "16.14.0",
"react-virtualized": "9.21.1",
"react-window": "1.8.9",
"recharts": "^2.1.10",
"remark-gfm": "^3.0.1",
"remark-parse": "^10.0.2",

View File

@@ -13,6 +13,10 @@ import ObjectEditorDialog from '../ObjectEditor/ObjectEditorDialog';
import { type ObjectEditorTab } from '../ObjectEditor/ObjectEditorDialog';
import { emptyStorageProvider } from '../ProjectsStorage/ProjectStorageProviders';
import newNameGenerator from '../Utils/NewNameGenerator';
import {
getObjectFolderOrObjectUnifiedName,
type ObjectFolderOrObjectWithContext,
} from '../ObjectsList/EnumerateObjectFolderOrObject';
const gd: libGDevelop = global.gd;
@@ -26,8 +30,7 @@ type Props = {|
type State = {|
editedObjectWithContext: ?ObjectWithContext,
editedObjectInitialTab: ?ObjectEditorTab,
selectedObjectsWithContext: ObjectWithContext[],
renamedObjectWithContext: ?ObjectWithContext,
selectedObjectFolderOrObjectsWithContext: ObjectFolderOrObjectWithContext[],
|};
export default class EventBasedObjectChildrenEditor extends React.Component<
@@ -39,8 +42,7 @@ export default class EventBasedObjectChildrenEditor extends React.Component<
state = {
editedObjectWithContext: null,
editedObjectInitialTab: 'properties',
selectedObjectsWithContext: [],
renamedObjectWithContext: null,
selectedObjectFolderOrObjectsWithContext: [],
};
_onDeleteObject = (i18n: I18nType) => (
@@ -72,7 +74,7 @@ export default class EventBasedObjectChildrenEditor extends React.Component<
done(true);
};
_getValidatedObjectOrGroupName = (newName: string, i18n: I18nType) => {
_getValidatedObjectOrGroupName = (newName: string) => {
const { eventsBasedObject } = this.props;
const safeAndUniqueNewName = newNameGenerator(
@@ -94,37 +96,15 @@ export default class EventBasedObjectChildrenEditor extends React.Component<
return safeAndUniqueNewName;
};
_onRenameObjectStart = (objectWithContext: ?ObjectWithContext) => {
const selectedObjectsWithContext = [];
if (objectWithContext) {
selectedObjectsWithContext.push(objectWithContext);
}
this.setState(
{
renamedObjectWithContext: objectWithContext,
selectedObjectsWithContext,
},
() => {
this.forceUpdateObjectsList();
}
);
};
_onRenameEditedObject = (newName: string, i18n: I18nType) => {
_onRenameEditedObject = (newName: string) => {
const { editedObjectWithContext } = this.state;
if (editedObjectWithContext) {
this._onRenameObject(editedObjectWithContext, newName, () => {}, i18n);
this._onRenameObject(editedObjectWithContext, newName);
}
};
_onRenameObject = (
objectWithContext: ObjectWithContext,
newName: string,
done: boolean => void,
i18n: I18nType
) => {
_onRenameObject = (objectWithContext: ObjectWithContext, newName: string) => {
const { object } = objectWithContext;
const { project, globalObjectsContainer, eventsBasedObject } = this.props;
@@ -143,6 +123,40 @@ export default class EventBasedObjectChildrenEditor extends React.Component<
}
object.setName(newName);
};
_onRenameObjectFolderOrObjectFinish = (
objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext,
newName: string,
done: boolean => void
) => {
const { objectFolderOrObject, global } = objectFolderOrObjectWithContext;
const unifiedName = getObjectFolderOrObjectUnifiedName(
objectFolderOrObject
);
// Avoid triggering renaming refactoring if name has not really changed
if (unifiedName === newName) {
this._onObjectFolderOrObjectWithContextSelected(
objectFolderOrObjectWithContext
);
done(false);
return;
}
// newName is supposed to have been already validated.
if (objectFolderOrObject.isFolder()) {
objectFolderOrObject.setFolderName(newName);
done(true);
return;
}
const object = objectFolderOrObject.getObject();
this._onRenameObject({ object, global }, newName);
this._onObjectFolderOrObjectWithContextSelected(
objectFolderOrObjectWithContext
);
done(true);
};
@@ -163,15 +177,19 @@ export default class EventBasedObjectChildrenEditor extends React.Component<
}
};
_onObjectSelected = (objectWithContext: ?ObjectWithContext = null) => {
const selectedObjectsWithContext = [];
if (objectWithContext) {
selectedObjectsWithContext.push(objectWithContext);
_onObjectFolderOrObjectWithContextSelected = (
objectFolderOrObjectWithContext: ?ObjectFolderOrObjectWithContext = null
) => {
const selectedObjectFolderOrObjectsWithContext = [];
if (objectFolderOrObjectWithContext) {
selectedObjectFolderOrObjectsWithContext.push(
objectFolderOrObjectWithContext
);
}
this.setState(
{
selectedObjectsWithContext,
selectedObjectFolderOrObjectsWithContext,
},
() => {
this.forceUpdateObjectsList();
@@ -198,10 +216,7 @@ export default class EventBasedObjectChildrenEditor extends React.Component<
render() {
const { eventsBasedObject, project, eventsFunctionsExtension } = this.props;
const selectedObjectNames = this.state.selectedObjectsWithContext.map(
objWithContext => objWithContext.object.getName()
);
const { selectedObjectFolderOrObjectsWithContext } = this.state;
// TODO EBO When adding an object, filter the object types to excludes
// object that depend (transitively) on this object to avoid cycles.
@@ -230,32 +245,28 @@ export default class EventBasedObjectChildrenEditor extends React.Component<
onFetchNewlyAddedResources: async () => {},
getStorageProviderResourceOperations: () => null,
}}
selectedObjectNames={selectedObjectNames}
selectedObjectFolderOrObjectsWithContext={
selectedObjectFolderOrObjectsWithContext
}
onEditObject={this.editObject}
// Don't allow export as there is no assets.
onExportObject={() => {}}
onDeleteObject={this._onDeleteObject(i18n)}
getValidatedObjectOrGroupName={newName =>
this._getValidatedObjectOrGroupName(newName, i18n)
getValidatedObjectOrGroupName={
this._getValidatedObjectOrGroupName
}
// Nothing special to do.
onObjectCreated={() => {}}
onObjectSelected={this._onObjectSelected}
renamedObjectWithContext={this.state.renamedObjectWithContext}
onRenameObjectStart={this._onRenameObjectStart}
onRenameObjectFinish={(objectWithContext, newName, done) =>
this._onRenameObject(objectWithContext, newName, done, i18n)
onObjectFolderOrObjectWithContextSelected={
this._onObjectFolderOrObjectWithContextSelected
}
onRenameObjectFolderOrObjectWithContextFinish={
this._onRenameObjectFolderOrObjectFinish
}
// Instances can't be created from this context.
onAddObjectInstance={() => {}}
onObjectPasted={() => this.updateBehaviorsSharedData()}
selectedObjectTags={[]}
onChangeSelectedObjectTags={selectedObjectTags => {}}
getAllObjectTags={() => []}
ref={
// $FlowFixMe Make this component functional.
objectsList => (this._objectsList = objectsList)
}
ref={objectsList => (this._objectsList = objectsList)}
unsavedChanges={null}
// TODO EBO Hide the preview button or implement it.
// Note that it will be hard to do hot reload as extensions need
@@ -300,12 +311,10 @@ export default class EventBasedObjectChildrenEditor extends React.Component<
onCancel={() => {
this.editObject(null);
}}
getValidatedObjectOrGroupName={newName =>
this._getValidatedObjectOrGroupName(newName, i18n)
getValidatedObjectOrGroupName={
this._getValidatedObjectOrGroupName
}
onRename={newName => {
this._onRenameEditedObject(newName, i18n);
}}
onRename={this._onRenameEditedObject}
onApply={() => {
this.editObject(null);
this.updateBehaviorsSharedData();

View File

@@ -55,7 +55,6 @@ export default class BrowserEventsFunctionsExtensionWriter {
filename: string
): Promise<void> => {
const exportedObject = customObject.clone().get();
exportedObject.setTags('');
exportedObject.getVariables().clear();
exportedObject.getEffects().clear();
exportedObject

View File

@@ -89,7 +89,6 @@ export default class LocalEventsFunctionsExtensionWriter {
filepath: string
): Promise<void> => {
const exportedObject = customObject.clone().get();
exportedObject.setTags('');
exportedObject.getVariables().clear();
exportedObject.getEffects().clear();
exportedObject

View File

@@ -29,3 +29,5 @@ export const handle = 'move-handle';
export const linkContainer = 'link-container';
export const nameAndIconContainer = 'name-and-icon-container';
export const treeView = 'tree-view';

View File

@@ -435,7 +435,7 @@ const Instruction = (props: Props) => {
},
[onContextMenu]
),
'events-tree-event-component'
{ context: 'events-tree-event-component' }
);
return (

View File

@@ -171,7 +171,7 @@ const EventContainer = (props: EventsContainerProps) => {
},
[onEventContextMenu]
),
'events-tree-event-component'
{ context: 'events-tree-event-component' }
);
const EventComponent = EventsRenderingService.getEventComponent(event);

View File

@@ -6,7 +6,6 @@ import { t } from '@lingui/macro';
import Fuse from 'fuse.js';
import * as React from 'react';
import Chip from '@material-ui/core/Chip';
import {
createTree,
type InstructionOrExpressionTreeNode,
@@ -28,12 +27,9 @@ import { Tabs } from '../../UI/Tabs';
import Subheader from '../../UI/Subheader';
import {
enumerateObjectsAndGroups,
filterObjectByTags,
type ObjectWithContext,
type GroupWithContext,
enumerateObjects,
} from '../../ObjectsList/EnumerateObjects';
import TagChips from '../../UI/TagChips';
import RaisedButton from '../../UI/RaisedButton';
import { ResponsiveLineStackLayout } from '../../UI/Layout';
import { renderGroupObjectsListItem } from './SelectorListItems/SelectorGroupObjectsListItem';
@@ -41,10 +37,6 @@ import { renderObjectListItem } from './SelectorListItems/SelectorObjectListItem
import { renderInstructionOrExpressionListItem } from './SelectorListItems/SelectorInstructionOrExpressionListItem';
import { renderInstructionOrExpressionTree } from './SelectorListItems/SelectorInstructionsTreeListItem';
import EmptyMessage from '../../UI/EmptyMessage';
import {
buildTagsMenuTemplate,
getTagsFromString,
} from '../../Utils/TagsHelper';
import {
getObjectOrObjectGroupListItemValue,
getInstructionListItemValue,
@@ -59,10 +51,21 @@ import {
} from '../../UI/Search/UseSearchStructuredItem';
import { Column, Line } from '../../UI/Grid';
import Add from '../../UI/CustomSvgIcons/Add';
import getObjectByName from '../../Utils/GetObjectByName';
import {
enumerateFoldersInContainer,
getObjectsInFolder,
} from '../../ObjectsList/EnumerateObjectFolderOrObject';
import { renderFolderListItem } from './SelectorListItems/FolderListItem';
import Text from '../../UI/Text';
const gd: libGDevelop = global.gd;
const DISPLAYED_INSTRUCTIONS_MAX_LENGTH = 20;
export const styles = {
noObjectsText: { opacity: 0.7 },
indentedListItem: { paddingLeft: 45 },
};
export type TabName = 'objects' | 'free-instructions';
@@ -71,12 +74,15 @@ type State = {|
searchResults: {
objects: Array<SearchResult<ObjectWithContext>>,
groups: Array<SearchResult<GroupWithContext>>,
tags: Array<SearchResult<string>>,
instructions: Array<SearchResult<EnumeratedInstructionMetadata>>,
folders: Array<
SearchResult<{|
path: string,
folder: gdObjectFolderOrObject,
global: boolean,
|}>
>,
},
// State for tags of objects:
selectedObjectTags: Array<string>,
|};
type Props = {|
@@ -111,8 +117,7 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
> {
state = {
searchText: '',
selectedObjectTags: [],
searchResults: { objects: [], groups: [], instructions: [], tags: [] },
searchResults: { objects: [], groups: [], instructions: [], folders: [] },
};
_searchBar = React.createRef<SearchBarInterface>();
_scrollView = React.createRef<ScrollViewInterface>();
@@ -137,7 +142,7 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
instructionSearchApi = null;
objectSearchApi = null;
groupSearchApi = null;
tagSearchApi = null;
folderSearchApi = null;
reEnumerateInstructions = (i18n: I18nType) => {
this.freeInstructionsInfo = filterEnumeratedInstructionOrExpressionMetadataByScope(
@@ -167,6 +172,15 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
this.props.objectsContainer
);
const allFolders = [
...enumerateFoldersInContainer(this.props.globalObjectsContainer).map(
folderWithPath => ({ ...folderWithPath, global: true })
),
...enumerateFoldersInContainer(this.props.objectsContainer).map(
folderWithPath => ({ ...folderWithPath, global: false })
),
];
this.instructionSearchApi = new Fuse(
deduplicateInstructionsList(this.allInstructionsInfo),
{
@@ -187,8 +201,10 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
getFn: (item, property) => item.group.getName(),
keys: ['name'], // Not used as we only use the name of the group
});
this.tagSearchApi = new Fuse(this._getAllObjectTags(), {
this.folderSearchApi = new Fuse(allFolders, {
...sharedFuseConfiguration,
getFn: (item, property) => item.path,
keys: ['name'], // Not used as we only use the path to the folder
});
}
@@ -211,6 +227,12 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
matches: tuneMatches(result, searchText),
}))
: [],
folders: this.folderSearchApi
? this.folderSearchApi.search(extendedSearchText).map(result => ({
item: result.item,
matches: tuneMatches(result, searchText),
}))
: [],
instructions: this.instructionSearchApi
? this.instructionSearchApi
.search(
@@ -224,47 +246,6 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
matches: tuneMatches(result, searchText),
}))
: [],
tags: this.tagSearchApi
? this.tagSearchApi.search(extendedSearchText).map(result => ({
item: result.item,
matches: tuneMatches(result, searchText),
}))
: [],
},
});
};
_selectTag = (tag: string) => {
this.setState({
selectedObjectTags: [...this.state.selectedObjectTags, tag],
searchText: '',
});
this._searchBar.current && this._searchBar.current.focus();
};
_getAllObjectTags = (): Array<string> => {
const { globalObjectsContainer, objectsContainer } = this.props;
const tagsSet: Set<string> = new Set();
enumerateObjects(
globalObjectsContainer,
objectsContainer
).allObjectsList.forEach(({ object }) => {
getTagsFromString(object.getTags()).forEach(tag => tagsSet.add(tag));
});
return Array.from(tagsSet);
};
_buildObjectTagsMenuTemplate = (i18n: I18nType): Array<any> => {
const { selectedObjectTags } = this.state;
return buildTagsMenuTemplate({
noTagLabel: i18n._(t`No tags - add a tag to an object first`),
getAllTags: this._getAllObjectTags,
selectedTags: selectedObjectTags,
onChange: selectedObjectTags => {
this.setState({ selectedObjectTags });
},
});
};
@@ -285,7 +266,7 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
onSearchStartOrReset,
onClickMore,
} = this.props;
const { searchText, selectedObjectTags, searchResults } = this.state;
const { searchText, searchResults } = this.state;
// If the global objects container is not the project, consider that we're
// not in the events of a layout or an external events sheet - but in an extension.
@@ -300,13 +281,13 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
let filteredObjectsList = [];
let displayedObjectGroupsList = [];
let filteredInstructionsList = [];
let displayedTags = [];
let filteredFoldersList = [];
if (isSearching) {
filteredObjectsList = searchResults.objects;
displayedObjectGroupsList = searchResults.groups;
filteredInstructionsList = searchResults.instructions;
displayedTags = searchResults.tags;
filteredFoldersList = searchResults.folders;
} else {
filteredObjectsList = allObjectsList.map(object => ({
item: object,
@@ -317,10 +298,6 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
matches: [],
}));
}
const displayedObjectsList = filteredObjectsList.filter(searchResult =>
filterObjectByTags(searchResult.item, selectedObjectTags)
);
const displayedInstructionsList = filteredInstructionsList.slice(
0,
DISPLAYED_INSTRUCTIONS_MAX_LENGTH
@@ -335,20 +312,18 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
const hasResults =
!isSearching ||
!!displayedObjectsList.length ||
!!filteredObjectsList.length ||
!!displayedObjectGroupsList.length ||
!!displayedInstructionsList.length ||
!!displayedTags.length;
!!filteredFoldersList;
const onSubmitSearch = () => {
if (!isSearching) return;
if (displayedObjectsList.length > 0) {
onChooseObject(displayedObjectsList[0].item.object.getName());
if (filteredObjectsList.length > 0) {
onChooseObject(filteredObjectsList[0].item.object.getName());
} else if (displayedObjectGroupsList.length > 0) {
onChooseObject(displayedObjectGroupsList[0].item.group.getName());
} else if (displayedTags.length > 0) {
this._selectTag(displayedTags[0].item);
} else if (displayedInstructionsList.length > 0) {
onChooseInstruction(
displayedInstructionsList[0].item.type,
@@ -358,7 +333,7 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
};
return (
<I18n key="tags">
<I18n>
{({ i18n }) => (
<div
id="instruction-or-object-selector"
@@ -388,7 +363,6 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
}
}}
onRequestSearch={onSubmitSearch}
buildMenuTemplate={() => this._buildObjectTagsMenuTemplate(i18n)}
ref={this._searchBar}
autoFocus={this.props.focusOnMount ? 'desktop' : undefined}
placeholder={
@@ -422,21 +396,11 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
</Line>
)}
<ScrollView ref={this._scrollView} autoHideScrollbar>
{!isSearching && currentTab === 'objects' && (
<TagChips
tags={selectedObjectTags}
onChange={selectedObjectTags =>
this.setState({
selectedObjectTags,
})
}
/>
)}
{hasResults && (
<List>
{(isSearching || currentTab === 'objects') && (
<React.Fragment>
{displayedObjectsList.map(
{filteredObjectsList.map(
({ item: objectWithContext, matches }, index) =>
renderObjectListItem({
project: project,
@@ -467,42 +431,145 @@ export default class InstructionOrObjectSelector extends React.PureComponent<
</Subheader>
)}
{displayedObjectGroupsList.map(
({ item: groupWithContext, matches }) =>
renderGroupObjectsListItem({
groupWithContext: groupWithContext,
iconSize: iconSize,
onClick: () =>
onChooseObject(groupWithContext.group.getName()),
matchesCoordinates: matches.length
? matches[0].indices // Only field for groups is their name
: [],
selectedValue: chosenObjectName
? getObjectOrObjectGroupListItemValue(
chosenObjectName
)
: undefined,
})
({ item: groupWithContext, matches }) => {
const results = [];
results.push(
renderGroupObjectsListItem({
groupWithContext,
iconSize,
onClick: () =>
onChooseObject(
groupWithContext.group.getName()
),
matchesCoordinates: matches.length
? matches[0].indices // Only field for groups is their name
: [],
selectedValue: chosenObjectName
? getObjectOrObjectGroupListItemValue(
chosenObjectName
)
: undefined,
})
);
if (isSearching) {
const { group, global } = groupWithContext;
const groupName = group.getName();
const objectsInGroup = group
.getAllObjectsNames()
.toJSArray()
.map(objectName => {
// A global object group can contain scene objects so we cannot use
// the group context to get directly get the object knowing the
// appropriate container.
const object = getObjectByName(
globalObjectsContainer,
objectsContainer,
objectName
);
if (!object) return null;
return renderObjectListItem({
project,
objectWithContext: {
object,
global,
},
keyPrefix: `group-${groupName}`,
withIndent: true,
iconSize,
onClick: () => onChooseObject(objectName),
matchesCoordinates: [],
selectedValue: chosenObjectName
? getObjectOrObjectGroupListItemValue(
chosenObjectName
)
: undefined,
});
})
.filter(Boolean);
if (objectsInGroup.length === 0) {
results.push(
<ListItem
key={`${group.getName()}-empty`}
primaryText={
<Text style={styles.noObjectsText} noMargin>
<Trans>No objects in the group</Trans>
</Text>
}
style={styles.indentedListItem}
/>
);
} else {
results.push(...objectsInGroup);
}
}
return results;
}
)}
{filteredFoldersList.length > 0 && (
<Subheader>
<Trans>Folders</Trans>
</Subheader>
)}
{filteredFoldersList.map(
({ item: folderWithPath, matches }) => {
const results = [];
results.push(
renderFolderListItem({
folderWithPath,
iconSize,
matchesCoordinates: matches.length
? matches[0].indices
: [],
})
);
const objectsInFolder = getObjectsInFolder(
folderWithPath.folder
);
if (objectsInFolder.length === 0) {
results.push(
<ListItem
key={`${folderWithPath.path}-empty`}
primaryText={
<Text style={styles.noObjectsText} noMargin>
<Trans>No objects in the folder</Trans>
</Text>
}
style={styles.indentedListItem}
/>
);
} else {
results.push(
...objectsInFolder.map(object =>
renderObjectListItem({
project,
selectedValue: chosenObjectName
? getObjectOrObjectGroupListItemValue(
chosenObjectName
)
: undefined,
keyPrefix: `folder-${folderWithPath.path}`,
iconSize,
matchesCoordinates: [],
objectWithContext: {
object,
global: folderWithPath.global,
},
withIndent: true,
onClick: () =>
onChooseObject(object.getName()),
})
)
);
}
return results;
}
)}
</React.Fragment>
)}
{isSearching &&
currentTab === 'objects' &&
displayedTags.length > 0 && (
<Subheader>
<Trans>Object tags</Trans>
</Subheader>
)}
{currentTab === 'objects' &&
displayedTags.map(({ item: tag, matches }) => (
<ListItem
key={tag}
primaryText={<Chip label={tag} />}
onClick={() => {
this._selectTag(tag);
}}
disableAutoTranslate
/>
))}
{isSearching && displayedInstructionsList.length > 0 && (
<Subheader>
{isCondition ? (

View File

@@ -0,0 +1,37 @@
// @flow
import * as React from 'react';
import { ListItem } from '../../../UI/List';
import HighlightedText from '../../../UI/Search/HighlightedText';
import Folder from '../../../UI/CustomSvgIcons/Folder';
type Props = {|
folderWithPath: {|
path: string,
folder: gdObjectFolderOrObject,
global: boolean,
|},
iconSize: number,
matchesCoordinates: number[][],
|};
export const renderFolderListItem = ({
folderWithPath,
iconSize,
matchesCoordinates,
}: Props) => {
const folderPath: string = folderWithPath.path;
return (
<ListItem
key={folderPath}
selected={false}
primaryText={
<HighlightedText
text={folderPath}
matchesCoordinates={matchesCoordinates}
/>
}
leftIcon={<Folder width={iconSize} />}
disableAutoTranslate
/>
);
};

View File

@@ -9,6 +9,7 @@ import {
getObjectListItemKey,
} from './Keys';
import HighlightedText from '../../../UI/Search/HighlightedText';
import { styles } from '../InstructionOrObjectSelector';
import { type HTMLDataset } from '../../../Utils/HTMLDataset';
type Props = {|
@@ -20,6 +21,8 @@ type Props = {|
matchesCoordinates: number[][],
id?: ?string,
data?: HTMLDataset,
withIndent?: boolean,
keyPrefix?: string,
|};
export const renderObjectListItem = ({
@@ -31,16 +34,19 @@ export const renderObjectListItem = ({
matchesCoordinates,
id,
data,
withIndent,
keyPrefix,
}: Props) => {
const objectName: string = objectWithContext.object.getName();
return (
<ListItem
id={id}
data={data}
key={getObjectListItemKey(objectWithContext)}
key={(keyPrefix || '') + getObjectListItemKey(objectWithContext)}
selected={
selectedValue === getObjectOrObjectGroupListItemValue(objectName)
}
style={withIndent ? styles.indentedListItem : undefined}
primaryText={
<HighlightedText
text={objectName}

View File

@@ -36,6 +36,7 @@ const selectorInterpolationProjectDataAccessors = {
objectInObjectOrResourceSelector: 'objectInObjectOrResourceSelector:',
editorTab: 'editorTab:',
};
const legacyItemInObjectListDomSelectorPattern = /#object-item-[0-9]{1,2}$/;
const getPhasesStartIndices = (endIndices: Array<number>): Array<number> =>
endIndices.map((_, i) => {
@@ -132,6 +133,22 @@ const interpolateEditorTabActiveTrigger = (
}"]${sceneNameFilter}`;
};
const countObjectsInScene = ({
project,
sceneName,
}: {|
project: gdProject,
sceneName: string,
|}): ?number => {
if (project.getLayoutsCount() === 0) return;
const layout = project.hasLayoutNamed(sceneName)
? project.getLayout(sceneName)
: project.getLayoutAt(0);
return layout.getObjectsCount();
};
export const getEditorTabSelector = ({
editor,
sceneName,
@@ -238,6 +255,9 @@ const isDomBasedTriggerComplete = (
if (!trigger) return false;
if (
trigger.presenceOfElement &&
!trigger.presenceOfElement.match(
legacyItemInObjectListDomSelectorPattern
) &&
document.querySelector(
interpolateElementId(trigger.presenceOfElement, data)
)
@@ -386,6 +406,9 @@ const InAppTutorialOrchestrator = React.forwardRef<
const [data, setData] = React.useState<{| [key: string]: string |}>(
startProjectData
);
const objectCountBySceneRef = React.useRef<{|
[sceneName: string]: number,
|}>({});
const [displayEndDialog, setDisplayEndDialog] = React.useState<boolean>(
false
);
@@ -412,6 +435,10 @@ const InAppTutorialOrchestrator = React.forwardRef<
objectName: string,
count?: number,
|}>(null);
const [
sceneObjectCountToWatch,
setSceneObjectCountToWatch,
] = React.useState<boolean>(false);
const domObserverRef = React.useRef<?MutationObserver>(null);
const [
shouldWatchProjectChanges,
@@ -464,7 +491,13 @@ const InAppTutorialOrchestrator = React.forwardRef<
);
const goToStep = React.useCallback(
(stepIndex: number) => {
({
stepIndex,
gatherData,
}: {
stepIndex: number,
gatherData?: boolean,
}) => {
if (stepIndex >= stepCount) {
setDisplayEndDialog(true);
return;
@@ -486,12 +519,23 @@ const InAppTutorialOrchestrator = React.forwardRef<
nextStepIndex += 1;
else break;
}
if (gatherData) {
const newData = gatherProjectDataOnMultipleSteps({
flow,
startIndex: currentStepIndex,
endIndex: nextStepIndex - 1,
data,
project,
});
setData(newData);
}
changeStep(nextStepIndex);
},
[flow, changeStep, stepCount, data]
[flow, changeStep, stepCount, data, project, currentStepIndex]
);
// Compute phases start positions on flow change.
React.useEffect(
() => {
const indices = [];
@@ -531,6 +575,24 @@ const InAppTutorialOrchestrator = React.forwardRef<
[currentStepIndex, endIndicesPerPhase]
);
const hasCurrentSceneObjectsCountIncreased = React.useCallback(
(): boolean => {
if (!project || project.getLayoutsCount() === 0 || !currentSceneName)
return false;
const count = countObjectsInScene({
project,
sceneName: currentSceneName,
});
const initialCount = objectCountBySceneRef.current[currentSceneName];
return (
typeof initialCount === 'number' &&
typeof count === 'number' &&
count > initialCount
);
},
[project, currentSceneName]
);
const getProgress = () => {
return {
step: currentStepIndex,
@@ -573,8 +635,17 @@ const InAppTutorialOrchestrator = React.forwardRef<
if (!shortcuts) return;
for (let shortcutStep of shortcuts) {
// Find the first shortcut in the list that can be triggered.
// TODO: Add support for not-dom based triggers
if (isDomBasedTriggerComplete(shortcutStep.trigger, data)) {
// TODO: Add support for all triggers types
if (
isDomBasedTriggerComplete(shortcutStep.trigger, data) ||
(shortcutStep.trigger &&
(shortcutStep.trigger.objectAddedInLayout ||
(shortcutStep.trigger.presenceOfElement &&
shortcutStep.trigger.presenceOfElement.match(
legacyItemInObjectListDomSelectorPattern
))) &&
hasCurrentSceneObjectsCountIncreased())
) {
shouldGoToStepAtIndex = flow.findIndex(
step => step.id === shortcutStep.stepId
);
@@ -589,30 +660,25 @@ const InAppTutorialOrchestrator = React.forwardRef<
break;
}
}
if (shouldGoToStepAtIndex == null) return;
if (shouldGoToStepAtIndex === null) return;
}
// If a change of step is going to happen, first record the data for
// all the steps that are about to be closed.
const newData = gatherProjectDataOnMultipleSteps({
flow,
startIndex: currentStepIndex,
endIndex: shouldGoToStepAtIndex - 1,
data,
project,
});
setData(newData);
goToStep(shouldGoToStepAtIndex);
goToStep({ stepIndex: shouldGoToStepAtIndex, gatherData: true });
},
[currentStepIndex, project, goToStep, data, flow]
[
currentStepIndex,
goToStep,
data,
flow,
hasCurrentSceneObjectsCountIncreased,
]
);
const handleDomMutation = useDebounce(watchDomForNextStepTrigger, 200);
const goToNextStep = React.useCallback(
() => {
goToStep(currentStepIndex + 1);
(gatherData?: boolean) => {
goToStep({ stepIndex: currentStepIndex + 1, gatherData });
},
[currentStepIndex, goToStep]
);
@@ -687,6 +753,7 @@ const InAppTutorialOrchestrator = React.forwardRef<
setElementWithValueToWatchIfChanged(null);
setElementWithValueToWatchIfEquals(null);
setObjectSceneInstancesToWatch(null);
setSceneObjectCountToWatch(false);
setShouldWatchProjectChanges(false);
// If index out of bounds, display end dialog.
if (currentStepIndex >= stepCount) {
@@ -696,6 +763,20 @@ const InAppTutorialOrchestrator = React.forwardRef<
[currentStep, currentStepIndex, stepCount, editorSwitches]
);
// Update some refs on each step change and on current scene change.
React.useEffect(
() => {
if (!currentStep || !currentSceneName || !project) return;
const count = countObjectsInScene({
project,
sceneName: currentSceneName,
});
if (typeof count !== 'number') return;
objectCountBySceneRef.current[currentSceneName] = count;
},
[currentStep, currentSceneName, project]
);
// Set up watchers if the next step trigger is not dom-based.
React.useEffect(
() => {
@@ -726,6 +807,15 @@ const InAppTutorialOrchestrator = React.forwardRef<
sceneName,
count: nextStepTrigger.instancesCount,
});
} else if (
nextStepTrigger &&
(nextStepTrigger.objectAddedInLayout ||
(nextStepTrigger.presenceOfElement &&
nextStepTrigger.presenceOfElement.match(
legacyItemInObjectListDomSelectorPattern
)))
) {
setSceneObjectCountToWatch(true);
}
},
[currentStep, data]
@@ -819,6 +909,20 @@ const InAppTutorialOrchestrator = React.forwardRef<
[project, goToNextStep, objectSceneInstancesToWatch]
);
const watchSceneObjects = React.useCallback(
() => {
if (!sceneObjectCountToWatch) return;
if (hasCurrentSceneObjectsCountIncreased()) {
goToNextStep(true);
}
},
[
hasCurrentSceneObjectsCountIncreased,
goToNextStep,
sceneObjectCountToWatch,
]
);
useInterval(forceUpdate, shouldWatchProjectChanges ? 500 : null);
useInterval(
watchInputChanges,
@@ -832,6 +936,7 @@ const InAppTutorialOrchestrator = React.forwardRef<
watchSceneInstanceChanges,
objectSceneInstancesToWatch ? 500 : null
);
useInterval(watchSceneObjects, sceneObjectCountToWatch ? 1000 : null);
useInterval(
watchDomForNextStepTrigger,
currentStep && currentStep.isTriggerFlickering ? 500 : null

View File

@@ -1,7 +1,6 @@
// @flow
import * as React from 'react';
import InAppTutorialContext from './InAppTutorialContext';
import legacyOnboardingTutorial from './Tutorials/OnboardingTutorial';
import { setCurrentlyRunningInAppTutorial } from '../Utils/Analytics/EventSender';
import {
fetchInAppTutorial,
@@ -44,14 +43,6 @@ const InAppTutorialProvider = (props: Props) => {
initialStepIndex: number,
initialProjectData: { [key: string]: string },
|}) => {
if (tutorialId === legacyOnboardingTutorial.id) {
setStartStepIndex(initialStepIndex);
setStartProjectData(initialProjectData);
setTutorial(legacyOnboardingTutorial);
setCurrentlyRunningInAppTutorial(tutorialId);
return;
}
if (!inAppTutorialShortHeaders) return;
const inAppTutorialShortHeader = getInAppTutorialShortHeader(tutorialId);

View File

@@ -1,482 +0,0 @@
// @flow
import { t } from '@lingui/macro';
import { type InAppTutorial } from '../../Utils/GDevelopServices/InAppTutorial';
const inAppTutorial: InAppTutorial = {
id: 'onboarding',
editorSwitches: {
GoToBuildSection: { editor: 'Home' },
ClickOnNewObjectButtonForCharacter: { editor: 'Scene' },
ClickOnNewEvent: { editor: 'EventsSheet' },
},
endDialog: {
content: [
{
messageDescriptor: t`## Congratulations! 🎉`,
},
{
messageDescriptor: t`### Youve built your first game! 😊`,
},
{
messageDescriptor: t`Youre now ready to learn the basics of GDevelop.`,
},
{
messageDescriptor: t`Click the image to start!`,
},
{
messageDescriptor: t`👇👇👇`,
},
{
image: {
imageSource: 'https://i3.ytimg.com/vi/bR2BjT7JG0k/mqdefault.jpg',
linkHref:
'https://www.youtube.com/watch?v=bR2BjT7JG0k&list=PL3YlZTdKiS89Kj7IQVPoNElJCWrjZaCC8',
},
},
{
messageDescriptor: t`### Want to skip the basics?`,
},
{
messageDescriptor: t`Go to the "Learn" section on the app to explore advanced materials.`,
},
{
messageDescriptor: t`Have fun!`,
},
],
},
flow: [
{
id: 'GoToBuildSection',
elementToHighlightId: '#home-build-tab',
nextStepTrigger: { presenceOfElement: '#home-create-project-button' },
tooltip: {
description: {
messageDescriptor: t`Head over to the **Build section**`,
},
placement: 'right',
},
},
{
id: 'CreateProject',
elementToHighlightId: '#home-create-project-button',
nextStepTrigger: { presenceOfElement: '#create-project-button' },
tooltip: {
description: {
messageDescriptor: t`We'll create a simple game with **a character that can collect coins**.
${'\n'}${'\n'}Let's create a new project!`,
},
},
},
{
id: 'ValidateProjectCreation',
elementToHighlightId: '#create-project-button',
nextStepTrigger: {
presenceOfElement: '[id^=tab-layout]:not([id^=tab-layout-events])',
},
tooltip: {
description: { messageDescriptor: t`Let's go!` },
},
isOnClosableDialog: true,
},
{
id: 'ClickOnNewObjectButtonForCharacter',
elementToHighlightId: '#add-new-object-button',
nextStepTrigger: { presenceOfElement: '#new-object-dialog' },
tooltip: {
placement: 'left',
title: { messageDescriptor: t`Let's create an **object**` },
description: {
messageDescriptor: t`👉 Everything you see in a game is an **object**: your character, the enemies, coins and potions, platforms or trees, ...`,
},
},
},
{
id: 'OpenAssetTab',
elementToHighlightId: '#asset-store-tab',
nextStepTrigger: { presenceOfElement: '#asset-store' },
tooltip: {
description: {
messageDescriptor: t`Let's choose an object from the asset store.`,
},
placement: 'bottom',
},
skippable: true,
isOnClosableDialog: true,
},
{
id: 'ClickOnSearchBar',
elementToHighlightId: '#asset-store-search-bar',
nextStepTrigger: { valueHasChanged: true },
tooltip: {
title: {
messageDescriptor: t`Choose an asset to represent your main character!`,
},
description: { messageDescriptor: t`Tip: search for “wizard”.` },
},
skippable: true,
isOnClosableDialog: true,
},
{
id: 'WaitForUserToSelectAsset',
nextStepTrigger: { presenceOfElement: '#add-asset-button' },
isOnClosableDialog: true,
},
{
id: 'AddAsset',
elementToHighlightId: '#add-asset-button',
isTriggerFlickering: true,
nextStepTrigger: { presenceOfElement: '#object-item-0' },
tooltip: {
description: { messageDescriptor: t`Add this asset to your project.` },
},
mapProjectData: {
firstObject: 'lastProjectObjectName',
},
isOnClosableDialog: true,
},
{
id: 'CloseAssetStore',
elementToHighlightId: '#new-object-dialog #close-button',
nextStepTrigger: { absenceOfElement: '#new-object-dialog' },
tooltip: {
description: {
messageDescriptor: t`Great! Our game now has an **object**, let's see what we can do with it.`,
},
},
},
{
id: 'DragObjectToScene',
elementToHighlightId: '#object-item-0',
nextStepTrigger: { instanceAddedOnScene: 'firstObject' },
tooltip: {
description: {
messageDescriptor: t`Drag $(firstObject) from the menu to the canvas.`,
},
placement: 'left',
},
},
{
id: 'OpenBehaviors',
elementToHighlightId: '#object-item-0',
nextStepTrigger: { presenceOfElement: '#object-editor-dialog' },
tooltip: {
title: { messageDescriptor: t`Let's make our character move! 🛹` },
description: {
messageDescriptor: t`Here, right-click on it and click “Edit **behaviors**”`,
},
placement: 'left',
},
},
{
id: 'OpenBehaviorTab',
elementToHighlightId: '#behaviors-tab',
nextStepTrigger: { presenceOfElement: '#add-behavior-button' },
tooltip: {
description: {
messageDescriptor: t`See the **behaviors** of your object here.`,
},
placement: 'bottom',
},
skippable: true,
isOnClosableDialog: true,
},
{
id: 'AddBehavior',
elementToHighlightId: '#add-behavior-button',
nextStepTrigger: {
presenceOfElement:
'#behavior-item-TopDownMovementBehavior--TopDownMovementBehavior',
},
tooltip: {
title: { messageDescriptor: t`Lets add a **behavior**!` },
description: {
messageDescriptor: t`👉 Behaviors add features to objects in a matter of clicks. They are very powerful!`,
},
placement: 'bottom',
},
isOnClosableDialog: true,
},
{
id: 'SelectTopDownBehavior',
elementToHighlightId:
'#behavior-item-TopDownMovementBehavior--TopDownMovementBehavior',
nextStepTrigger: {
presenceOfElement: '#behavior-parameters-TopDownMovement',
},
tooltip: {
description: {
messageDescriptor: t`Add the "Top down movement" **behavior**.`,
},
placement: 'bottom',
},
isOnClosableDialog: true,
},
{
id: 'ApplyBehavior',
elementToHighlightId: '#object-editor-dialog #apply-button',
nextStepTrigger: {
absenceOfElement: '#object-editor-dialog',
},
tooltip: {
description: {
messageDescriptor: t`The parameters above help you customise the **behavior**, but let's ignore them for now.`,
},
placement: 'top',
},
isOnClosableDialog: true,
},
{
id: 'LaunchPreviewCharacterOnly',
elementToHighlightId: '#toolbar-preview-button',
nextStepTrigger: { previewLaunched: true },
tooltip: {
title: { messageDescriptor: t`Let's play! 🎮` },
description: {
messageDescriptor: t`Click on "**Preview**" and move your character with the **arrow keys**!`,
},
placement: 'bottom',
},
},
{
id: 'WaitForUserToHavePlayed',
elementToHighlightId: '#toolbar-preview-button',
nextStepTrigger: {
clickOnTooltipButton: { messageDescriptor: t`I'm done` },
},
tooltip: {
description: {
messageDescriptor: t`Once you're done testing, close the **preview** and come back here.`,
},
placement: 'bottom',
},
},
{
id: 'ClickOnNewObjectButtonForCoin',
elementToHighlightId: '#add-new-object-button',
nextStepTrigger: { presenceOfElement: '#new-object-dialog' },
tooltip: {
placement: 'left',
title: {
messageDescriptor: t`Let's now add another **object** that $(firstObject) can collect!`,
},
},
},
{
id: 'OpenAssetTabForCoin',
elementToHighlightId: '#asset-store-tab',
nextStepTrigger: { presenceOfElement: '#asset-store' },
tooltip: {
description: {
messageDescriptor: t`Let's choose an object from the asset store.`,
},
placement: 'bottom',
},
skippable: true,
isOnClosableDialog: true,
},
{
id: 'ClickOnSearchBarForCoin',
elementToHighlightId: '#asset-store-search-bar',
nextStepTrigger: { valueHasChanged: true },
tooltip: {
description: {
messageDescriptor: t`Search for “coin” (or a potion, food, ...).`,
},
},
isOnClosableDialog: true,
shortcuts: [
{
stepId: 'CloseAssetStoreForCoin',
trigger: { presenceOfElement: '#object-item-1' },
},
],
},
{
id: 'WaitForUserToSelectAssetForCoin',
nextStepTrigger: { presenceOfElement: '#add-asset-button' },
isOnClosableDialog: true,
},
{
id: 'AddAssetForCoin',
elementToHighlightId: '#add-asset-button',
isTriggerFlickering: true,
nextStepTrigger: { presenceOfElement: '#object-item-1' },
mapProjectData: {
secondObject: 'lastProjectObjectName',
},
isOnClosableDialog: true,
},
{
id: 'CloseAssetStoreForCoin',
elementToHighlightId: '#new-object-dialog #close-button',
nextStepTrigger: { absenceOfElement: '#new-object-dialog' },
tooltip: {
description: {
messageDescriptor: t`Great! Our game now has 2 **objects**, let's see what we can do with them.`,
},
},
},
{
id: 'DragObjectToScene',
elementToHighlightId: '#object-item-1',
nextStepTrigger: { instanceAddedOnScene: 'secondObject' },
tooltip: {
description: {
messageDescriptor: t`Place a few $(secondObject) in the scene by dragging them to the canvas.`,
},
placement: 'left',
},
},
{
id: 'SwitchToEventsSheet',
elementToHighlightId: 'editorTab::EventsSheet',
nextStepTrigger: { editorIsActive: ':EventsSheet' },
tooltip: {
description: {
messageDescriptor: t`Now let's make $(firstObject) collect the $(secondObject)! Go to the **events** tab of the **scene**.`,
},
placement: 'bottom',
},
},
{
id: 'ClickOnNewEvent',
elementToHighlightId: '#add-event-button',
nextStepTrigger: { presenceOfElement: '#add-condition-button-empty' },
tooltip: {
title: { messageDescriptor: t`Lets add an **event**!` },
description: {
messageDescriptor: t`👉 **Events** are the logic to your game.`,
},
placement: 'bottom',
},
},
{
id: 'ClickOnNewCondition',
elementToHighlightId: '#add-condition-button-empty',
nextStepTrigger: { presenceOfElement: '#instruction-editor-dialog' },
tooltip: {
description: {
messageDescriptor: t`**Events** are made of a condition and an action:
${'\n'}${'\n'}Condition: "**If** $(firstObject) touches the $(secondObject)..."
${'\n'}${'\n'}Action: "... **then** the $(secondObject) disappears"
${'\n'}${'\n'}**Click "Add condition**"`,
},
placement: 'bottom',
},
},
{
id: 'ChooseCharacterForCondition',
elementToHighlightId: '#instruction-editor-dialog #object-item-0',
nextStepTrigger: { presenceOfElement: '#object-instruction-selector' },
tooltip: {
description: { messageDescriptor: t`Choose $(firstObject)` },
placement: 'bottom',
},
isOnClosableDialog: true,
},
{
id: 'ChooseCondition',
elementToHighlightId: '#instruction-item-CollisionNP',
nextStepTrigger: {
presenceOfElement: '#instruction-parameters-container',
},
tooltip: {
description: {
messageDescriptor: t`Then the condition we want to use: **"Collision"**.`,
},
placement: 'bottom',
},
isOnClosableDialog: true,
},
{
id: 'SetParameter',
elementToHighlightId: '#parameter-1-object-selector',
nextStepTrigger: { valueHasChanged: true },
tooltip: {
description: {
messageDescriptor: t`Finally, select the target **object** ($(secondObject)).`,
},
placement: 'top',
},
isOnClosableDialog: true,
},
{
id: 'CloseInstructionEditorForCondition',
elementToHighlightId: '#instruction-editor-dialog #ok-button',
nextStepTrigger: { absenceOfElement: '#instruction-editor-dialog' },
tooltip: {
description: { messageDescriptor: t`We're good.` },
placement: 'top',
},
},
{
id: 'ClickOnNewAction',
elementToHighlightId: '#add-action-button-empty',
nextStepTrigger: { presenceOfElement: '#instruction-editor-dialog' },
tooltip: {
description: {
messageDescriptor: t`Let's add **what happens when the condition is met**: make $(secondObject) disappear.`,
},
placement: 'bottom',
},
},
{
id: 'ChoseCoinForAction',
elementToHighlightId: '#instruction-editor-dialog #object-item-1',
nextStepTrigger: { presenceOfElement: '#object-instruction-selector' },
tooltip: {
description: { messageDescriptor: t`Choose $(secondObject)` },
placement: 'bottom',
},
isOnClosableDialog: true,
},
{
id: 'ChooseAction',
elementToHighlightId: '#instruction-item-Delete',
nextStepTrigger: {
presenceOfElement: '#instruction-parameters-container',
},
tooltip: {
description: {
messageDescriptor: t`Then choose the **action** $(secondObject) will receive : "Delete", as we want to remove it.`,
},
placement: 'bottom',
},
isOnClosableDialog: true,
},
{
id: 'CloseInstructionEditorForAction',
elementToHighlightId: '#instruction-editor-dialog #ok-button',
nextStepTrigger: { absenceOfElement: '#instruction-editor-dialog' },
tooltip: {
description: { messageDescriptor: t`Nothing more is needed!` },
placement: 'top',
},
},
{
id: 'LaunchPreviewWithCoinCollection',
elementToHighlightId: '#toolbar-preview-button',
nextStepTrigger: { previewLaunched: true },
tooltip: {
title: { messageDescriptor: t`Let's see how it works! 🎮` },
placement: 'bottom',
},
},
{
id: 'WaitForUserToHavePlayedWithCoinCollection',
elementToHighlightId: '#toolbar-preview-button',
nextStepTrigger: {
clickOnTooltipButton: { messageDescriptor: t`I'm done` },
},
tooltip: {
description: {
messageDescriptor: t`Once you're done testing, close the **preview** and come back here.`,
},
placement: 'bottom',
},
},
],
};
export default inAppTutorial;

View File

@@ -97,14 +97,12 @@ const GetStartedSection = ({
inAppTutorialShortHeaders,
inAppTutorialsFetchingError,
fetchInAppTutorials,
currentlyRunningInAppTutorial,
} = React.useContext(InAppTutorialContext);
const { getTutorialProgress } = React.useContext(PreferencesContext);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const windowWidth = useResponsiveWindowWidth();
const isMobile = windowWidth === 'small';
const { currentlyRunningInAppTutorial } = React.useContext(
InAppTutorialContext
);
const items: {
key: string,
title: React.Node,

View File

@@ -78,13 +78,11 @@ const GuidedLessons = ({ selectInAppTutorial }: Props) => {
inAppTutorialShortHeaders,
inAppTutorialsFetchingError,
fetchInAppTutorials,
currentlyRunningInAppTutorial,
} = React.useContext(InAppTutorialContext);
const { getTutorialProgress } = React.useContext(PreferencesContext);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const windowWidth = useResponsiveWindowWidth();
const { currentlyRunningInAppTutorial } = React.useContext(
InAppTutorialContext
);
const getTutorialPartProgress = ({ tutorialId }: { tutorialId: string }) => {
const tutorialProgress = getTutorialProgress({

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
// @flow
import { mapFor } from '../Utils/MapFor';
export type ObjectFolderOrObjectWithContext = {|
objectFolderOrObject: gdObjectFolderOrObject,
global: boolean,
|};
export const getObjectFolderOrObjectUnifiedName = (
objectFolderOrObject: gdObjectFolderOrObject
) =>
objectFolderOrObject.isFolder()
? objectFolderOrObject.getFolderName()
: objectFolderOrObject.getObject().getName();
export const enumerateObjectFolderOrObjects = (
project: gdObjectsContainer,
objectsContainer: gdObjectsContainer
): {|
containerObjectFolderOrObjectsList: ObjectFolderOrObjectWithContext[],
projectObjectFolderOrObjectsList: ObjectFolderOrObjectWithContext[],
|} => {
const projectRootFolder = project.getRootFolder();
const containerRootFolder = objectsContainer.getRootFolder();
const containerObjectFolderOrObjectsList: ObjectFolderOrObjectWithContext[] = mapFor(
0,
containerRootFolder.getChildrenCount(),
i => {
const objectFolderOrObject = containerRootFolder.getChildAt(i);
return objectFolderOrObject;
}
).map(
(
objectFolderOrObject: gdObjectFolderOrObject
): ObjectFolderOrObjectWithContext => {
const item = {
objectFolderOrObject,
global: false,
};
return item;
}
);
const projectObjectFolderOrObjectsList: ObjectFolderOrObjectWithContext[] = mapFor(
0,
projectRootFolder.getChildrenCount(),
i => {
const objectFolderOrObject = projectRootFolder.getChildAt(i);
return objectFolderOrObject;
}
).map(
(
objectFolderOrObject: gdObjectFolderOrObject
): ObjectFolderOrObjectWithContext => {
const item = {
objectFolderOrObject,
global: true,
};
return item;
}
);
return {
containerObjectFolderOrObjectsList,
projectObjectFolderOrObjectsList,
};
};
const recursivelyEnumerateFoldersInFolder = (
folder: gdObjectFolderOrObject,
prefix: string,
result: {| path: string, folder: gdObjectFolderOrObject |}[]
) => {
mapFor(0, folder.getChildrenCount(), i => {
const child = folder.getChildAt(i);
if (child.isFolder()) {
const newPrefix = prefix
? prefix + ' > ' + child.getFolderName()
: child.getFolderName();
result.push({
path: newPrefix,
folder: child,
});
recursivelyEnumerateFoldersInFolder(child, newPrefix, result);
}
});
};
export const enumerateFoldersInFolder = (folder: gdObjectFolderOrObject) => {
if (!folder.isFolder()) return [];
const result = [];
recursivelyEnumerateFoldersInFolder(folder, '', result);
return result;
};
export const enumerateFoldersInContainer = (
container: gdObjectsContainer
): {| path: string, folder: gdObjectFolderOrObject |}[] => {
const rootFolder = container.getRootFolder();
const result = [];
recursivelyEnumerateFoldersInFolder(rootFolder, '', result);
return result;
};
export const getObjectsInFolder = (
objectFolderOrObject: gdObjectFolderOrObject
): gdObject[] => {
if (!objectFolderOrObject.isFolder()) return [];
return mapFor(0, objectFolderOrObject.getChildrenCount(), i => {
const child = objectFolderOrObject.getChildAt(i);
if (child.isFolder()) {
return null;
}
return child.getObject();
}).filter(Boolean);
};

View File

@@ -1,7 +1,6 @@
// @flow
import { mapFor } from '../Utils/MapFor';
import flatten from 'lodash/flatten';
import { type SelectedTags, hasStringAllTags } from '../Utils/TagsHelper';
import { type RequiredExtension } from '../AssetStore/InstallAsset';
const gd: libGDevelop = global.gd;
@@ -158,37 +157,22 @@ export const enumerateObjectTypes = (
export type ObjectFilteringOptions = {|
searchText: string,
selectedTags: SelectedTags,
hideExactMatches?: boolean,
|};
export const filterObjectByTags = (
objectWithContext: ObjectWithContext,
selectedTags: SelectedTags
): boolean => {
if (!selectedTags.length) return true;
const objectTags = objectWithContext.object.getTags();
return hasStringAllTags(objectTags, selectedTags);
};
export const filterObjectsList = (
list: ObjectWithContextList,
{ searchText, selectedTags, hideExactMatches }: ObjectFilteringOptions
{ searchText, hideExactMatches }: ObjectFilteringOptions
): ObjectWithContextList => {
if (!searchText && !selectedTags.length) return list;
if (!searchText) return list;
return list
.filter(objectWithContext =>
filterObjectByTags(objectWithContext, selectedTags)
)
.filter((objectWithContext: ObjectWithContext) => {
const objectName = objectWithContext.object.getName();
return list.filter((objectWithContext: ObjectWithContext) => {
const objectName = objectWithContext.object.getName();
if (hideExactMatches && searchText === objectName) return undefined;
if (hideExactMatches && searchText === objectName) return undefined;
return objectName.toLowerCase().indexOf(searchText.toLowerCase()) !== -1;
});
return objectName.toLowerCase().indexOf(searchText.toLowerCase()) !== -1;
});
};
export type GroupFilteringOptions = {|

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import { type InstancesEditorShortcutsCallbacks } from '../InstancesEditor';
import { type EditorId } from '.';
import Rectangle from '../Utils/Rectangle';
import ViewPosition from '../InstancesEditor/ViewPosition';
import { type ObjectFolderOrObjectWithContext } from '../ObjectsList/EnumerateObjectFolderOrObject';
export type SceneEditorsDisplayProps = {|
project: gdProject,
@@ -31,8 +32,7 @@ export type SceneEditorsDisplayProps = {|
editInstanceVariables: (instance: ?gdInitialInstance) => void,
editObjectByName: (objectName: string, initialTab?: ObjectEditorTab) => void,
onEditObject: gdObject => void,
selectedObjectNames: string[],
renamedObjectWithContext: ?ObjectWithContext,
selectedObjectFolderOrObjectsWithContext: ObjectFolderOrObjectWithContext[],
onSelectLayer: (layerName: string) => void,
editLayerEffects: (layer: ?gdLayer) => void,
editLayer: (layer: ?gdLayer) => void,
@@ -43,7 +43,9 @@ export type SceneEditorsDisplayProps = {|
done: (boolean) => void
) => void,
onObjectCreated: gdObject => void,
onObjectSelected: (?ObjectWithContext) => void,
onObjectFolderOrObjectWithContextSelected: (
?ObjectFolderOrObjectWithContext
) => void,
onExportObject: (object: ?gdObject) => void,
onDeleteObject: (
i18n: I18nType,
@@ -54,9 +56,8 @@ export type SceneEditorsDisplayProps = {|
objectName: string,
targetPosition?: 'center' | 'upperCenter'
) => void,
onRenameObjectStart: (?ObjectWithContext) => void,
onRenameObjectFinish: (
objectWithContext: ObjectWithContext,
onRenameObjectFolderOrObjectWithContextFinish: (
objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext,
newName: string,
done: (boolean) => void
) => void,
@@ -121,6 +122,7 @@ export type SceneEditorsDisplayInterface = {|
openNewObjectDialog: () => void,
toggleEditorView: (editorId: EditorId) => void,
isEditorVisible: (editorId: EditorId) => boolean,
renameObjectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext => void,
viewControls: {|
zoomBy: (factor: number) => void,
setZoomFactor: (factor: number) => void,

View File

@@ -3,7 +3,6 @@
import * as React from 'react';
import { t } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import { useResponsiveWindowWidth } from '../../UI/Reponsive/ResponsiveWindowMeasurer';
import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext';
@@ -14,19 +13,14 @@ import InstancePropertiesEditor, {
} from '../../InstancesEditor/InstancePropertiesEditor';
import LayersList, { type LayersListInterface } from '../../LayersList';
import FullSizeInstancesEditorWithScrollbars from '../../InstancesEditor/FullSizeInstancesEditorWithScrollbars';
import TagsButton from '../../UI/EditorMosaic/TagsButton';
import CloseButton from '../../UI/EditorMosaic/CloseButton';
import ObjectsList, { type ObjectsListInterface } from '../../ObjectsList';
import ObjectGroupsList from '../../ObjectGroupsList';
import ObjectGroupsList, {
type ObjectGroupsListInterface,
} from '../../ObjectGroupsList';
import InstancesList from '../../InstancesEditor/InstancesList';
import ObjectsRenderingService from '../../ObjectsRendering/ObjectsRenderingService';
import {
getTagsFromString,
buildTagsMenuTemplate,
type SelectedTags,
} from '../../Utils/TagsHelper';
import { enumerateObjects } from '../../ObjectsList/EnumerateObjects';
import Rectangle from '../../Utils/Rectangle';
import { type EditorId } from '..';
import {
@@ -95,10 +89,6 @@ const MosaicEditorsDisplay = React.forwardRef<
setDefaultEditorMosaicNode,
} = React.useContext(PreferencesContext);
const selectedInstances = props.instancesSelection.getSelectedInstances();
const [
selectedObjectTags,
setSelectedObjectTags,
] = React.useState<SelectedTags>([]);
const instancesPropertiesEditorRef = React.useRef<?InstancePropertiesEditorInterface>(
null
@@ -108,7 +98,7 @@ const MosaicEditorsDisplay = React.forwardRef<
const editorRef = React.useRef<?InstancesEditor>(null);
const objectsListRef = React.useRef<?ObjectsListInterface>(null);
const editorMosaicRef = React.useRef<?EditorMosaic>(null);
const objectGroupsListRef = React.useRef<?ObjectGroupsList>(null);
const objectGroupsListRef = React.useRef<?ObjectGroupsListInterface>(null);
const forceUpdateInstancesPropertiesEditor = React.useCallback(() => {
if (instancesPropertiesEditorRef.current)
@@ -150,6 +140,15 @@ const MosaicEditorsDisplay = React.forwardRef<
if (!editorMosaicRef.current) return false;
return editorMosaicRef.current.getOpenedEditorNames().includes(editorId);
}, []);
const renameObjectFolderOrObjectWithContext = React.useCallback(
objectWithContext => {
if (objectsListRef.current)
objectsListRef.current.renameObjectFolderOrObjectWithContext(
objectWithContext
);
},
[]
);
React.useImperativeHandle(ref, () => {
const { current: editor } = editorRef;
@@ -163,6 +162,7 @@ const MosaicEditorsDisplay = React.forwardRef<
openNewObjectDialog,
toggleEditorView,
isEditorVisible,
renameObjectFolderOrObjectWithContext,
viewControls: {
zoomBy: editor ? editor.zoomBy : noop,
setZoomFactor: editor ? editor.setZoomFactor : noop,
@@ -212,29 +212,13 @@ const MosaicEditorsDisplay = React.forwardRef<
]
);
const getAllObjectTags = React.useCallback(
(): Array<string> => {
const tagsSet: Set<string> = new Set();
enumerateObjects(project, layout).allObjectsList.forEach(({ object }) => {
getTagsFromString(object.getTags()).forEach(tag => tagsSet.add(tag));
});
return Array.from(tagsSet);
},
[project, layout]
);
const buildObjectTagsMenuTemplate = React.useCallback(
(i18n: I18nType): Array<any> => {
return buildTagsMenuTemplate({
noTagLabel: i18n._(t`No tags - add a tag to an object first`),
getAllTags: getAllObjectTags,
selectedTags: selectedObjectTags,
onChange: setSelectedObjectTags,
});
},
[selectedObjectTags, getAllObjectTags]
);
const selectedObjectNames = props.selectedObjectFolderOrObjectsWithContext
.map(objectFolderOrObjectWithContext => {
const { objectFolderOrObject } = objectFolderOrObjectWithContext;
if (objectFolderOrObject.isFolder()) return null;
return objectFolderOrObject.getObject().getName();
})
.filter(Boolean);
const editors = {
properties: {
@@ -312,7 +296,7 @@ const MosaicEditorsDisplay = React.forwardRef<
onInstancesMoved={props.onInstancesMoved}
onInstancesResized={props.onInstancesResized}
onInstancesRotated={props.onInstancesRotated}
selectedObjectNames={props.selectedObjectNames}
selectedObjectNames={selectedObjectNames}
onContextMenu={props.onContextMenu}
isInstanceOf3DObject={props.isInstanceOf3DObject}
instancesEditorShortcutsCallbacks={
@@ -328,13 +312,7 @@ const MosaicEditorsDisplay = React.forwardRef<
'objects-list': {
type: 'secondary',
title: t`Objects`,
toolbarControls: [
<TagsButton
key="tags"
buildMenuTemplate={buildObjectTagsMenuTemplate}
/>,
<CloseButton key="close" />,
],
toolbarControls: [<CloseButton key="close" />],
renderEditor: () => (
<I18n>
{({ i18n }) => (
@@ -350,7 +328,9 @@ const MosaicEditorsDisplay = React.forwardRef<
props.onSelectAllInstancesOfObjectInLayout
}
resourceManagementProps={props.resourceManagementProps}
selectedObjectNames={props.selectedObjectNames}
selectedObjectFolderOrObjectsWithContext={
props.selectedObjectFolderOrObjectsWithContext
}
canInstallPrivateAsset={props.canInstallPrivateAsset}
onEditObject={props.onEditObject}
onExportObject={props.onExportObject}
@@ -361,18 +341,17 @@ const MosaicEditorsDisplay = React.forwardRef<
props.getValidatedObjectOrGroupName(newName, global, i18n)
}
onObjectCreated={props.onObjectCreated}
onObjectSelected={props.onObjectSelected}
renamedObjectWithContext={props.renamedObjectWithContext}
onRenameObjectStart={props.onRenameObjectStart}
onRenameObjectFinish={props.onRenameObjectFinish}
onObjectFolderOrObjectWithContextSelected={
props.onObjectFolderOrObjectWithContextSelected
}
onRenameObjectFolderOrObjectWithContextFinish={
props.onRenameObjectFolderOrObjectWithContextFinish
}
onAddObjectInstance={props.onAddObjectInstance}
onObjectPasted={props.updateBehaviorsSharedData}
selectedObjectTags={selectedObjectTags}
beforeSetAsGlobalObject={objectName =>
props.canObjectOrGroupBeGlobal(i18n, objectName)
}
onChangeSelectedObjectTags={setSelectedObjectTags}
getAllObjectTags={getAllObjectTags}
ref={objectsListRef}
unsavedChanges={props.unsavedChanges}
hotReloadPreviewButtonProps={props.hotReloadPreviewButtonProps}

View File

@@ -98,7 +98,7 @@ type Props = {|
title: React.Node,
openingState: DrawerOpeningState,
setOpeningState: DrawerOpeningState => void,
topBarControls: ?React.Node,
topBarControls?: ?React.Node,
|};
const SwipeableDrawer = (props: Props) => {

View File

@@ -1,27 +1,21 @@
// @flow
import * as React from 'react';
import { Trans, t } from '@lingui/macro';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import InstancesEditor from '../../InstancesEditor';
import InstancePropertiesEditor, {
type InstancePropertiesEditorInterface,
} from '../../InstancesEditor/InstancePropertiesEditor';
import LayersList, { type LayersListInterface } from '../../LayersList';
import TagsButton from '../../UI/EditorMosaic/TagsButton';
import ObjectsList, { type ObjectsListInterface } from '../../ObjectsList';
import ObjectGroupsList from '../../ObjectGroupsList';
import ObjectGroupsList, {
type ObjectGroupsListInterface,
} from '../../ObjectGroupsList';
import InstancesList from '../../InstancesEditor/InstancesList';
import ObjectsRenderingService from '../../ObjectsRendering/ObjectsRenderingService';
import {
getTagsFromString,
buildTagsMenuTemplate,
type SelectedTags,
} from '../../Utils/TagsHelper';
import { enumerateObjects } from '../../ObjectsList/EnumerateObjects';
import Rectangle from '../../Utils/Rectangle';
import SwipeableDrawer from './SwipeableDrawer';
import BottomToolbar from './BottomToolbar';
@@ -64,10 +58,6 @@ const SwipeableDrawerEditorsDisplay = React.forwardRef<
onSelectInstances,
} = props;
const selectedInstances = props.instancesSelection.getSelectedInstances();
const [
selectedObjectTags,
setSelectedObjectTags,
] = React.useState<SelectedTags>([]);
const { values } = React.useContext(PreferencesContext);
const screenType = useScreenType();
@@ -78,7 +68,7 @@ const SwipeableDrawerEditorsDisplay = React.forwardRef<
const instancesListRef = React.useRef<?InstancesList>(null);
const editorRef = React.useRef<?InstancesEditor>(null);
const objectsListRef = React.useRef<?ObjectsListInterface>(null);
const objectGroupsListRef = React.useRef<?ObjectGroupsList>(null);
const objectGroupsListRef = React.useRef<?ObjectGroupsListInterface>(null);
const [selectedEditorId, setSelectedEditorId] = React.useState<?EditorId>(
null
@@ -136,6 +126,15 @@ const SwipeableDrawerEditorsDisplay = React.forwardRef<
},
[selectedEditorId, drawerOpeningState]
);
const renameObjectFolderOrObjectWithContext = React.useCallback(
objectWithContext => {
if (objectsListRef.current)
objectsListRef.current.renameObjectFolderOrObjectWithContext(
objectWithContext
);
},
[]
);
React.useImperativeHandle(ref, () => {
const { current: editor } = editorRef;
@@ -150,6 +149,7 @@ const SwipeableDrawerEditorsDisplay = React.forwardRef<
openNewObjectDialog,
toggleEditorView: halfOpenOrCloseDrawerOnEditor,
isEditorVisible,
renameObjectFolderOrObjectWithContext,
viewControls: {
zoomBy: editor ? editor.zoomBy : noop,
setZoomFactor: editor ? editor.setZoomFactor : noop,
@@ -199,29 +199,13 @@ const SwipeableDrawerEditorsDisplay = React.forwardRef<
]
);
const getAllObjectTags = React.useCallback(
(): Array<string> => {
const tagsSet: Set<string> = new Set();
enumerateObjects(project, layout).allObjectsList.forEach(({ object }) => {
getTagsFromString(object.getTags()).forEach(tag => tagsSet.add(tag));
});
return Array.from(tagsSet);
},
[project, layout]
);
const buildObjectTagsMenuTemplate = React.useCallback(
(i18n: I18nType): Array<any> => {
return buildTagsMenuTemplate({
noTagLabel: i18n._(t`No tags - add a tag to an object first`),
getAllTags: getAllObjectTags,
selectedTags: selectedObjectTags,
onChange: setSelectedObjectTags,
});
},
[selectedObjectTags, getAllObjectTags]
);
const selectedObjectNames = props.selectedObjectFolderOrObjectsWithContext
.map(objectFolderOrObjectWithContext => {
const { objectFolderOrObject } = objectFolderOrObjectWithContext;
if (objectFolderOrObject.isFolder()) return null;
return objectFolderOrObject.getObject().getName();
})
.filter(Boolean);
return (
<FullSizeMeasurer>
@@ -247,7 +231,7 @@ const SwipeableDrawerEditorsDisplay = React.forwardRef<
onInstancesMoved={props.onInstancesMoved}
onInstancesResized={props.onInstancesResized}
onInstancesRotated={props.onInstancesRotated}
selectedObjectNames={props.selectedObjectNames}
selectedObjectNames={selectedObjectNames}
onContextMenu={props.onContextMenu}
isInstanceOf3DObject={props.isInstanceOf3DObject}
instancesEditorShortcutsCallbacks={
@@ -264,17 +248,6 @@ const SwipeableDrawerEditorsDisplay = React.forwardRef<
}
openingState={drawerOpeningState}
setOpeningState={setDrawerOpeningState}
topBarControls={
selectedEditorId === 'objects-list'
? [
<TagsButton
key="tags"
size="small"
buildMenuTemplate={buildObjectTagsMenuTemplate}
/>,
]
: null
}
>
{selectedEditorId === 'objects-list' && (
<I18n>
@@ -291,7 +264,9 @@ const SwipeableDrawerEditorsDisplay = React.forwardRef<
props.onSelectAllInstancesOfObjectInLayout
}
resourceManagementProps={props.resourceManagementProps}
selectedObjectNames={props.selectedObjectNames}
selectedObjectFolderOrObjectsWithContext={
props.selectedObjectFolderOrObjectsWithContext
}
canInstallPrivateAsset={props.canInstallPrivateAsset}
onEditObject={props.onEditObject}
onExportObject={props.onExportObject}
@@ -306,20 +281,19 @@ const SwipeableDrawerEditorsDisplay = React.forwardRef<
)
}
onObjectCreated={props.onObjectCreated}
onObjectSelected={props.onObjectSelected}
renamedObjectWithContext={props.renamedObjectWithContext}
onRenameObjectStart={props.onRenameObjectStart}
onRenameObjectFinish={props.onRenameObjectFinish}
onObjectFolderOrObjectWithContextSelected={
props.onObjectFolderOrObjectWithContextSelected
}
onRenameObjectFolderOrObjectWithContextFinish={
props.onRenameObjectFolderOrObjectWithContextFinish
}
onAddObjectInstance={objectName =>
props.onAddObjectInstance(objectName, 'upperCenter')
}
onObjectPasted={props.updateBehaviorsSharedData}
selectedObjectTags={selectedObjectTags}
beforeSetAsGlobalObject={objectName =>
props.canObjectOrGroupBeGlobal(i18n, objectName)
}
onChangeSelectedObjectTags={setSelectedObjectTags}
getAllObjectTags={getAllObjectTags}
ref={objectsListRef}
unsavedChanges={props.unsavedChanges}
hotReloadPreviewButtonProps={

View File

@@ -5,7 +5,6 @@ import { type I18n as I18nType } from '@lingui/core';
import { t } from '@lingui/macro';
import * as React from 'react';
import uniq from 'lodash/uniq';
import LayerRemoveDialog from '../LayersList/LayerRemoveDialog';
import LayerEditorDialog from '../LayersList/LayerEditorDialog';
import VariablesEditorDialog from '../VariablesList/VariablesEditorDialog';
@@ -43,10 +42,8 @@ import PixiResourcesLoader from '../ObjectsRendering/PixiResourcesLoader';
import {
type ObjectWithContext,
type GroupWithContext,
enumerateObjects,
} from '../ObjectsList/EnumerateObjects';
import InfoBar from '../UI/Messages/InfoBar';
import { type SelectedTags } from '../Utils/TagsHelper';
import { type UnsavedChanges } from '../MainFrame/UnsavedChangesContext';
import SceneVariablesDialog from './SceneVariablesDialog';
import { onObjectAdded, onInstanceAdded } from '../Hints/ObjectsAdditionalWork';
@@ -62,6 +59,11 @@ import MosaicEditorsDisplay from './MosaicEditorsDisplay';
import SwipeableDrawerEditorsDisplay from './SwipeableDrawerEditorsDisplay';
import { type SceneEditorsDisplayInterface } from './EditorsDisplay.flow';
import newNameGenerator from '../Utils/NewNameGenerator';
import {
getObjectFolderOrObjectUnifiedName,
type ObjectFolderOrObjectWithContext,
} from '../ObjectsList/EnumerateObjectFolderOrObject';
import uniq from 'lodash/uniq';
const gd: libGDevelop = global.gd;
@@ -130,11 +132,7 @@ type State = {|
showAdditionalWorkInfoBar: boolean,
additionalWorkInfoBar: InfoBarDetails,
// State for tags of objects:
selectedObjectTags: SelectedTags,
renamedObjectWithContext: ?ObjectWithContext,
selectedObjectsWithContext: Array<ObjectWithContext>,
selectedObjectFolderOrObjectsWithContext: Array<ObjectFolderOrObjectWithContext>,
selectedLayer: string,
|};
@@ -187,10 +185,7 @@ export default class SceneEditor extends React.Component<Props, State> {
touchScreenMessage: '',
},
selectedObjectTags: [],
renamedObjectWithContext: null,
selectedObjectsWithContext: [],
selectedObjectFolderOrObjectsWithContext: [],
selectedLayer: BASE_LAYER_NAME,
invisibleLayerOnWhichInstancesHaveJustBeenAdded: null,
};
@@ -242,7 +237,9 @@ export default class SceneEditor extends React.Component<Props, State> {
redo={this.redo}
onOpenSettings={this.openSceneProperties}
settingsIcon={editSceneIconReactNode}
canRenameObject={this.state.selectedObjectsWithContext.length === 1}
canRenameObject={
this.state.selectedObjectFolderOrObjectsWithContext.length === 1
}
onRenameObject={this._startRenamingSelectedObject}
/>
);
@@ -271,7 +268,9 @@ export default class SceneEditor extends React.Component<Props, State> {
redo={this.redo}
onOpenSettings={this.openSceneProperties}
settingsIcon={editSceneIconReactNode}
canRenameObject={this.state.selectedObjectsWithContext.length === 1}
canRenameObject={
this.state.selectedObjectFolderOrObjectsWithContext.length === 1
}
onRenameObject={this._startRenamingSelectedObject}
/>
);
@@ -466,15 +465,19 @@ export default class SceneEditor extends React.Component<Props, State> {
);
};
_onObjectSelected = (objectWithContext: ?ObjectWithContext = null) => {
const selectedObjectsWithContext = [];
if (objectWithContext) {
selectedObjectsWithContext.push(objectWithContext);
_onObjectFolderOrObjectWithContextSelected = (
objectFolderOrObjectWithContext: ?ObjectFolderOrObjectWithContext = null
) => {
const selectedObjectFolderOrObjectsWithContext = [];
if (objectFolderOrObjectWithContext) {
selectedObjectFolderOrObjectsWithContext.push(
objectFolderOrObjectWithContext
);
}
this.setState(
{
selectedObjectsWithContext,
selectedObjectFolderOrObjectsWithContext,
},
() => {
// We update the toolbar because we need to update the objects selected
@@ -574,23 +577,38 @@ export default class SceneEditor extends React.Component<Props, State> {
};
_onInstancesSelected = (instances: Array<gdInitialInstance>) => {
if (instances.length === 0) {
this.setState({ selectedObjectFolderOrObjectsWithContext: [] });
return;
}
const { project, layout } = this.props;
const instancesObjectNames = uniq(
instances.map(instance => instance.getObjectName())
);
const selectedObjectsWithContext = enumerateObjects(project, layout, {
names: instancesObjectNames,
}).allObjectsList;
this.setState(
{
selectedObjectsWithContext,
},
() => {
this.updateToolbar();
}
);
// TODO: Find a way to select efficiently the ObjectFolderOrObject instances
// representing all the instances selected.
const lastSelectedInstance = instances[instances.length - 1];
const objectName = lastSelectedInstance.getObjectName();
if (project.hasObjectNamed(objectName)) {
this.setState({
selectedObjectFolderOrObjectsWithContext: [
{
objectFolderOrObject: project
.getRootFolder()
.getObjectNamed(objectName),
global: true,
},
],
});
} else if (layout.hasObjectNamed(objectName)) {
this.setState({
selectedObjectFolderOrObjectsWithContext: [
{
objectFolderOrObject: layout
.getRootFolder()
.getObjectNamed(objectName),
global: false,
},
],
});
}
};
_onInstanceDoubleClicked = (instance: gdInitialInstance) => {
@@ -750,25 +768,16 @@ export default class SceneEditor extends React.Component<Props, State> {
});
};
_onRenameObjectStart = (objectWithContext: ?ObjectWithContext) => {
const selectedObjectsWithContext = [];
if (objectWithContext) {
selectedObjectsWithContext.push(objectWithContext);
}
this.setState(
{
renamedObjectWithContext: objectWithContext,
selectedObjectsWithContext,
},
() => {
this.updateToolbar();
}
);
};
_startRenamingSelectedObject = () => {
this._onRenameObjectStart(this.state.selectedObjectsWithContext[0]);
const firstSelectedObjectFolderOrObject = this.state
.selectedObjectFolderOrObjectsWithContext[0];
if (!firstSelectedObjectFolderOrObject) return;
if (this.editorDisplay)
this.editorDisplay.renameObjectFolderOrObjectWithContext(
firstSelectedObjectFolderOrObject
);
this.updateToolbar();
};
_onRenameLayer = (
@@ -884,42 +893,75 @@ export default class SceneEditor extends React.Component<Props, State> {
const { editedObjectWithContext } = this.state;
if (editedObjectWithContext) {
this._onRenameObjectFinish(editedObjectWithContext, newName, () => {});
this._onRenameObjectFinish(editedObjectWithContext, newName);
}
};
_onRenameObjectFinish = (
objectWithContext: ObjectWithContext,
newName: string,
done: boolean => void
newName: string
) => {
const { object, global } = objectWithContext;
const { project, layout } = this.props;
// newName is supposed to have been already validated.
// Avoid triggering renaming refactoring if name has not really changed
if (object.getName() !== newName) {
if (global) {
gd.WholeProjectRefactorer.globalObjectOrGroupRenamed(
project,
object.getName(),
newName,
/* isObjectGroup=*/ false
);
} else {
gd.WholeProjectRefactorer.objectOrGroupRenamedInLayout(
project,
layout,
object.getName(),
newName,
/* isObjectGroup=*/ false
);
}
if (object.getName() === newName) {
return;
}
if (global) {
gd.WholeProjectRefactorer.globalObjectOrGroupRenamed(
project,
object.getName(),
newName,
/* isObjectGroup=*/ false
);
} else {
gd.WholeProjectRefactorer.objectOrGroupRenamedInLayout(
project,
layout,
object.getName(),
newName,
/* isObjectGroup=*/ false
);
}
object.setName(newName);
this._onObjectSelected(objectWithContext);
};
_onRenameObjectFolderOrObjectWithContextFinish = (
objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext,
newName: string,
done: boolean => void
) => {
const { objectFolderOrObject, global } = objectFolderOrObjectWithContext;
const unifiedName = getObjectFolderOrObjectUnifiedName(
objectFolderOrObject
);
// Avoid triggering renaming refactoring if name has not really changed
if (unifiedName === newName) {
this._onObjectFolderOrObjectWithContextSelected(
objectFolderOrObjectWithContext
);
done(false);
return;
}
// newName is supposed to have been already validated.
if (objectFolderOrObject.isFolder()) {
objectFolderOrObject.setFolderName(newName);
done(true);
return;
}
const object = objectFolderOrObject.getObject();
this._onRenameObjectFinish({ object, global }, newName);
this._onObjectFolderOrObjectWithContextSelected(
objectFolderOrObjectWithContext
);
done(true);
};
@@ -1076,7 +1118,7 @@ export default class SceneEditor extends React.Component<Props, State> {
this.setState(
{
selectedObjectsWithContext: [],
selectedObjectFolderOrObjectsWithContext: [],
history: saveToHistory(
this.state.history,
this.props.initialInstances,
@@ -1136,6 +1178,76 @@ export default class SceneEditor extends React.Component<Props, State> {
];
};
getContextMenuLayoutItems = (i18n: I18nType) => [
{
label: i18n._(t`Open scene events`),
click: () => this.props.onOpenEvents(this.props.layout.getName()),
},
{
label: i18n._(t`Open scene properties`),
click: () => this.openSceneProperties(true),
},
];
getContextMenuInstancesWiseItems = (i18n: I18nType) => {
const hasSelectedInstances = this.instancesSelection.hasSelectedInstances();
return [
{
label: i18n._(t`Copy`),
click: () => this.copySelection(),
enabled: hasSelectedInstances,
accelerator: 'CmdOrCtrl+C',
},
{
label: i18n._(t`Cut`),
click: () => this.cutSelection(),
enabled: hasSelectedInstances,
accelerator: 'CmdOrCtrl+X',
},
{
label: i18n._(t`Paste`),
click: () => this.paste(),
enabled: Clipboard.has(INSTANCES_CLIPBOARD_KIND),
accelerator: 'CmdOrCtrl+V',
},
{
label: i18n._(t`Duplicate`),
enabled: hasSelectedInstances,
click: () => {
this.duplicateSelection();
},
accelerator: 'CmdOrCtrl+D',
},
{ type: 'separator' },
{
label: i18n._(t`Bring to front`),
enabled: hasSelectedInstances,
click: () => {
this._onMoveInstancesZOrder('front');
},
},
{
label: i18n._(t`Send to back`),
enabled: hasSelectedInstances,
click: () => {
this._onMoveInstancesZOrder('back');
},
},
{ type: 'separator' },
{
label: i18n._(t`Show/Hide instance properties`),
click: () => this.toggleProperties(),
enabled: hasSelectedInstances,
},
{
label: i18n._(t`Delete`),
click: () => this.deleteSelection(),
enabled: hasSelectedInstances,
accelerator: 'Delete',
},
];
};
setZoomFactor = (zoomFactor: number) => {
if (this.editorDisplay)
this.editorDisplay.viewControls.setZoomFactor(zoomFactor);
@@ -1170,13 +1282,11 @@ export default class SceneEditor extends React.Component<Props, State> {
};
buildContextMenu = (i18n: I18nType, layout: gdLayout, options: any) => {
let contextMenuItems = [];
if (
options.ignoreSelectedObjectsForContextMenu ||
this.state.selectedObjectsWithContext.length === 0
!this.instancesSelection.hasSelectedInstances()
) {
contextMenuItems = [
...contextMenuItems,
return [
{
label: i18n._(t`Paste`),
click: () => this.paste(),
@@ -1190,64 +1300,28 @@ export default class SceneEditor extends React.Component<Props, State> {
},
{ type: 'separator' },
...this.getContextMenuZoomItems(i18n),
{ type: 'separator' },
...this.getContextMenuLayoutItems(i18n),
];
} else {
const objectName = this.state.selectedObjectsWithContext[0].object.getName();
contextMenuItems = [
...contextMenuItems,
{
label: i18n._(t`Copy`),
click: () => this.copySelection(),
enabled: this.instancesSelection.hasSelectedInstances(),
accelerator: 'CmdOrCtrl+C',
},
{
label: i18n._(t`Cut`),
click: () => this.cutSelection(),
enabled: this.instancesSelection.hasSelectedInstances(),
accelerator: 'CmdOrCtrl+X',
},
{
label: i18n._(t`Paste`),
click: () => this.paste(),
enabled: Clipboard.has(INSTANCES_CLIPBOARD_KIND),
accelerator: 'CmdOrCtrl+V',
},
{
label: i18n._(t`Duplicate`),
enabled: this.instancesSelection.hasSelectedInstances(),
click: () => {
this.duplicateSelection();
},
accelerator: 'CmdOrCtrl+D',
},
{ type: 'separator' },
{
label: i18n._(t`Bring to front`),
enabled: this.instancesSelection.hasSelectedInstances(),
click: () => {
this._onMoveInstancesZOrder('front');
},
},
{
label: i18n._(t`Send to back`),
enabled: this.instancesSelection.hasSelectedInstances(),
click: () => {
this._onMoveInstancesZOrder('back');
},
},
{ type: 'separator' },
{
label: i18n._(t`Show/Hide instance properties`),
click: () => this.toggleProperties(),
enabled: this.instancesSelection.hasSelectedInstances(),
},
{
label: i18n._(t`Delete`),
click: () => this.deleteSelection(),
enabled: this.instancesSelection.hasSelectedInstances(),
accelerator: 'Delete',
},
}
const instances = this.instancesSelection.getSelectedInstances();
if (
instances.length === 1 ||
uniq(instances.map(instance => instance.getObjectName())).length === 1
) {
const { project, layout } = this.props;
const objectName = instances[0].getObjectName();
const object = getObjectByName(project, layout, objectName);
const objectMetadata = object
? gd.MetadataProvider.getObjectMetadata(
project.getCurrentPlatform(),
object.getType()
)
: null;
return [
...this.getContextMenuInstancesWiseItems(i18n),
{ type: 'separator' },
{
label: i18n._(t`Edit object ${shortenString(objectName, 14)}`),
@@ -1261,27 +1335,24 @@ export default class SceneEditor extends React.Component<Props, State> {
label: i18n._(t`Edit behaviors`),
click: () => this.editObjectByName(objectName, 'behaviors'),
},
{
label: i18n._(t`Edit effects`),
click: () => this.editObjectByName(objectName, 'effects'),
},
];
objectMetadata
? {
label: i18n._(t`Edit effects`),
click: () => this.editObjectByName(objectName, 'effects'),
enabled: objectMetadata.hasDefaultBehavior(
'EffectCapability::EffectBehavior'
),
}
: null,
{ type: 'separator' },
...this.getContextMenuLayoutItems(i18n),
].filter(Boolean);
}
contextMenuItems = [
...contextMenuItems,
return [
...this.getContextMenuInstancesWiseItems(i18n),
{ type: 'separator' },
{
label: i18n._(t`Open scene events`),
click: () => this.props.onOpenEvents(layout.getName()),
},
{
label: i18n._(t`Open scene properties`),
click: () => this.openSceneProperties(true),
},
...this.getContextMenuLayoutItems(i18n),
];
return contextMenuItems;
};
copySelection = ({
@@ -1464,7 +1535,10 @@ export default class SceneEditor extends React.Component<Props, State> {
resourceManagementProps,
isActive,
} = this.props;
const { editedObjectWithContext } = this.state;
const {
editedObjectWithContext,
selectedObjectFolderOrObjectsWithContext,
} = this.state;
const variablesEditedAssociatedObjectName = this.state
.variablesEditedInstance
? this.state.variablesEditedInstance.getObjectName()
@@ -1472,9 +1546,7 @@ export default class SceneEditor extends React.Component<Props, State> {
const variablesEditedAssociatedObject = variablesEditedAssociatedObjectName
? getObjectByName(project, layout, variablesEditedAssociatedObjectName)
: null;
const selectedObjectNames = this.state.selectedObjectsWithContext.map(
objWithContext => objWithContext.object.getName()
);
// Deactivate prettier on this variable to prevent spaces to be added by
// line breaks.
// prettier-ignore
@@ -1527,8 +1599,9 @@ export default class SceneEditor extends React.Component<Props, State> {
editLayerEffects={this.editLayerEffects}
editInstanceVariables={this.editInstanceVariables}
editObjectByName={this.editObjectByName}
selectedObjectNames={selectedObjectNames}
renamedObjectWithContext={this.state.renamedObjectWithContext}
selectedObjectFolderOrObjectsWithContext={
selectedObjectFolderOrObjectsWithContext
}
onRenameLayer={this._onRenameLayer}
onRemoveLayer={this._onRemoveLayer}
onSelectLayer={(layer: string) =>
@@ -1545,10 +1618,13 @@ export default class SceneEditor extends React.Component<Props, State> {
canObjectOrGroupBeGlobal={this.canObjectOrGroupBeGlobal}
updateBehaviorsSharedData={this.updateBehaviorsSharedData}
onEditObject={this.props.onEditObject || this.editObject}
onRenameObjectStart={this._onRenameObjectStart}
onRenameObjectFinish={this._onRenameObjectFinish}
onRenameObjectFolderOrObjectWithContextFinish={
this._onRenameObjectFolderOrObjectWithContextFinish
}
onObjectCreated={this._onObjectCreated}
onObjectSelected={this._onObjectSelected}
onObjectFolderOrObjectWithContextSelected={
this._onObjectFolderOrObjectWithContextSelected
}
canInstallPrivateAsset={this.props.canInstallPrivateAsset}
historyHandler={{
undo: this.undo,

View File

@@ -35,8 +35,8 @@ export type ShowConfirmDeleteDialogOptions = {|
confirmButtonLabel?: MessageDescriptor,
dismissButtonLabel?: MessageDescriptor,
message: MessageDescriptor,
fieldMessage: MessageDescriptor,
confirmText: string,
fieldMessage?: MessageDescriptor,
confirmText?: string,
|};
export type ShowConfirmDeleteDialogOptionsWithCallback = {|
...ShowConfirmDeleteDialogOptions,
@@ -44,16 +44,31 @@ export type ShowConfirmDeleteDialogOptionsWithCallback = {|
|};
export type ShowConfirmDeleteFunction = ShowConfirmDeleteDialogOptions => Promise<boolean>;
// Yes No Cancel
export type ShowYesNoCancelDialogOptions = {|
title: MessageDescriptor,
yesButtonLabel?: MessageDescriptor,
noButtonLabel?: MessageDescriptor,
cancelButtonLabel?: MessageDescriptor,
message: MessageDescriptor,
|};
export type ShowYesNoCancelDialogOptionsWithCallback = {|
...ShowYesNoCancelDialogOptions,
callback: Function,
|};
export type ConfirmState = {|
showAlertDialog: ShowAlertDialogOptionsWithCallback => void,
showConfirmDialog: ShowConfirmDialogOptionsWithCallback => void,
showConfirmDeleteDialog: ShowConfirmDeleteDialogOptionsWithCallback => void,
showYesNoCancelDialog: ShowYesNoCancelDialogOptionsWithCallback => void,
|};
const initialConfirmState = {
showAlertDialog: ShowAlertDialogOptionsWithCallback => {},
showConfirmDialog: ShowConfirmDialogOptionsWithCallback => {},
showConfirmDeleteDialog: ShowConfirmDeleteDialogOptionsWithCallback => {},
showYesNoCancelDialog: ShowYesNoCancelDialogOptionsWithCallback => {},
};
const AlertContext = React.createContext<ConfirmState>(initialConfirmState);

View File

@@ -8,7 +8,9 @@ import {
type ShowAlertDialogOptionsWithCallback,
type ShowConfirmDeleteDialogOptionsWithCallback,
type ShowConfirmDialogOptionsWithCallback,
type ShowYesNoCancelDialogOptionsWithCallback,
} from './AlertContext';
import YesNoCancelDialog from './YesNoCancelDialog';
type Props = {| children: React.Node |};
@@ -60,12 +62,30 @@ function ConfirmProvider({ children }: Props) {
[]
);
// Confirm
const [
yesNoCancelDialogOpen,
setYesNoCancelDialogOpen,
] = React.useState<boolean>(false);
const [
yesNoCancelDialogConfig,
setYesNoCancelDialogConfig,
] = React.useState<?ShowYesNoCancelDialogOptionsWithCallback>(null);
const openYesNoCancelDialog = React.useCallback(
(options: ShowYesNoCancelDialogOptionsWithCallback) => {
setYesNoCancelDialogOpen(true);
setYesNoCancelDialogConfig(options);
},
[]
);
return (
<AlertContext.Provider
value={{
showAlertDialog: openAlertDialog,
showConfirmDialog: openConfirmDialog,
showConfirmDeleteDialog: openConfirmDeleteDialog,
showYesNoCancelDialog: openYesNoCancelDialog,
}}
>
{children}
@@ -121,6 +141,28 @@ function ConfirmProvider({ children }: Props) {
confirmText={confirmDeleteDialogConfig.confirmText}
/>
)}
{yesNoCancelDialogConfig && (
<YesNoCancelDialog
open={yesNoCancelDialogOpen}
onClickYes={() => {
setYesNoCancelDialogOpen(false);
yesNoCancelDialogConfig.callback(0);
}}
yesButtonLabel={yesNoCancelDialogConfig.yesButtonLabel}
onClickNo={() => {
setYesNoCancelDialogOpen(false);
yesNoCancelDialogConfig.callback(1);
}}
noButtonLabel={yesNoCancelDialogConfig.noButtonLabel}
onClickCancel={() => {
setYesNoCancelDialogOpen(false);
yesNoCancelDialogConfig.callback(2);
}}
cancelButtonLabel={yesNoCancelDialogConfig.cancelButtonLabel}
title={yesNoCancelDialogConfig.title}
message={yesNoCancelDialogConfig.message}
/>
)}
</AlertContext.Provider>
);
}

View File

@@ -14,8 +14,8 @@ type Props = {|
open: boolean,
title: MessageDescriptor,
message: MessageDescriptor,
fieldMessage: MessageDescriptor,
confirmText: string,
fieldMessage?: MessageDescriptor,
confirmText?: string,
onConfirm: () => void,
onDismiss: () => void,
confirmButtonLabel?: MessageDescriptor,
@@ -24,7 +24,7 @@ type Props = {|
function ConfirmDeleteDialog(props: Props) {
const [textInput, setTextInput] = React.useState<string>('');
const canConfirm = textInput === props.confirmText;
const canConfirm = props.confirmText ? textInput === props.confirmText : true;
const onConfirm = () => {
if (!canConfirm) return;
@@ -75,15 +75,19 @@ function ConfirmDeleteDialog(props: Props) {
<Text size="body" style={{ userSelect: 'text' }}>
{i18n._(props.message)}
</Text>
<LargeSpacer />
<TextField
autoFocus="desktop"
floatingLabelFixed
floatingLabelText={i18n._(props.fieldMessage)}
value={textInput}
onChange={(e, text) => setTextInput(text)}
hintText={props.confirmText}
/>
{props.confirmText && props.fieldMessage && (
<>
<LargeSpacer />
<TextField
autoFocus="desktop"
floatingLabelFixed
floatingLabelText={i18n._(props.fieldMessage)}
value={textInput}
onChange={(e, text) => setTextInput(text)}
hintText={props.confirmText}
/>
</>
)}
</Dialog>
)}
</I18n>

View File

@@ -0,0 +1,80 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { type MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow';
import Dialog, { DialogPrimaryButton } from '../Dialog';
import FlatButton from '../FlatButton';
import { MarkdownText } from '../MarkdownText';
type Props = {|
open: boolean,
title: MessageDescriptor,
message: MessageDescriptor,
onClickYes: () => void,
onClickNo: () => void,
onClickCancel: () => void,
yesButtonLabel?: MessageDescriptor,
noButtonLabel?: MessageDescriptor,
cancelButtonLabel?: MessageDescriptor,
|};
function YesNoCancelDialog(props: Props) {
return (
<I18n>
{({ i18n }) => (
<Dialog
title={i18n._(props.title)}
open={props.open}
actions={[
<FlatButton
key="no"
keyboardFocused
label={
props.noButtonLabel ? (
i18n._(props.noButtonLabel)
) : (
<Trans>No</Trans>
)
}
onClick={props.onClickNo}
/>,
<DialogPrimaryButton
key="yes"
label={
props.yesButtonLabel ? (
i18n._(props.yesButtonLabel)
) : (
<Trans>Yes</Trans>
)
}
onClick={props.onClickYes}
primary
/>,
]}
secondaryActions={[
<FlatButton
key="cancel"
keyboardFocused
label={
props.cancelButtonLabel ? (
i18n._(props.cancelButtonLabel)
) : (
<Trans>Cancel</Trans>
)
}
onClick={props.onClickCancel}
/>,
]}
maxWidth="xs"
noMobileFullScreen
>
<MarkdownText translatableSource={props.message} isStandaloneText />
</Dialog>
)}
</I18n>
);
}
export default YesNoCancelDialog;

View File

@@ -5,6 +5,7 @@ import {
type ShowAlertDialogOptions,
type ShowConfirmDeleteDialogOptions,
type ShowConfirmDialogOptions,
type ShowYesNoCancelDialogOptions,
} from './AlertContext';
const useAlertDialog = () => {
@@ -12,6 +13,7 @@ const useAlertDialog = () => {
showAlertDialog,
showConfirmDialog,
showConfirmDeleteDialog,
showYesNoCancelDialog,
} = React.useContext(AlertContext);
const showAlert = React.useCallback(
@@ -38,10 +40,26 @@ const useAlertDialog = () => {
[showConfirmDeleteDialog]
);
/**
* Displays a 3-choice alert dialog (Defaults to Yes No Cancel).
* Callback will be called with:
* - 0 for yes (primary button)
* - 1 for no (flat button next to primary button)
* - 2 for cancel (secondary action)
*/
const showYesNoCancel = React.useCallback(
(options: ShowYesNoCancelDialogOptions): Promise<boolean> =>
new Promise(resolve => {
showYesNoCancelDialog({ callback: resolve, ...options });
}),
[showYesNoCancelDialog]
);
return {
showAlert,
showConfirmation,
showDeleteConfirmation,
showYesNoCancel,
};
};

View File

@@ -0,0 +1,31 @@
import React from 'react';
import SvgIcon from '@material-ui/core/SvgIcon';
export default React.memo(props => (
<SvgIcon {...props} width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 7.75C4 7.33579 4.33579 7 4.75 7H17.25C18.7688 7 20 8.23122 20 9.75V12.25C20 12.6642 19.6642 13 19.25 13C18.8358 13 18.5 12.6642 18.5 12.25V9.75C18.5 9.05964 17.9404 8.5 17.25 8.5H5.5V17.25C5.5 17.9404 6.05964 18.5 6.75 18.5H12.25C12.6642 18.5 13 18.8358 13 19.25C13 19.6642 12.6642 20 12.25 20H6.75C5.23122 20 4 18.7688 4 17.25V7.75Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 6.75C4 5.23122 5.23122 4 6.75 4H10.8127C11.819 4 12.7451 4.54965 13.227 5.43322L14.1584 7.14085C14.3568 7.50449 14.2228 7.96007 13.8591 8.15842C13.4955 8.35677 13.0399 8.22278 12.8416 7.85915L11.9101 6.15145C11.6911 5.74995 11.2702 5.5 10.8127 5.5H6.75C6.05964 5.5 5.5 6.05964 5.5 6.75V11C5.5 11.4142 5.16421 11.75 4.75 11.75C4.33579 11.75 4 11.4142 4 11V6.75Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17 14C17.4142 14 17.75 14.3358 17.75 14.75V19.25C17.75 19.6642 17.4142 20 17 20C16.5858 20 16.25 19.6642 16.25 19.25V14.75C16.25 14.3358 16.5858 14 17 14Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14 17C14 16.5858 14.3358 16.25 14.75 16.25H19.25C19.6642 16.25 20 16.5858 20 17C20 17.4142 19.6642 17.75 19.25 17.75H14.75C14.3358 17.75 14 17.4142 14 17Z"
fill="currentColor"
/>
</SvgIcon>
));

View File

@@ -0,0 +1,11 @@
import React from 'react';
import SvgIcon from '@material-ui/core/SvgIcon';
export default React.memo(props => (
<SvgIcon {...props} width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M5.39101 6.6665C5.12374 6.6665 4.98989 6.98965 5.17888 7.17864L7.78795 9.78771C7.90511 9.90486 8.09506 9.90486 8.21221 9.7877L10.8213 7.17864C11.0103 6.98965 10.8764 6.6665 10.6091 6.6665H5.39101Z"
fill="currentColor"
/>
</SvgIcon>
));

View File

@@ -0,0 +1,11 @@
import React from 'react';
import SvgIcon from '@material-ui/core/SvgIcon';
export default React.memo(props => (
<SvgIcon {...props} width="17" height="16" viewBox="0 0 17 16" fill="none">
<path
d="M6.66699 10.6089C6.66699 10.8761 6.99013 11.01 7.17912 10.821L9.78819 8.21193C9.90535 8.09477 9.90535 7.90482 9.78819 7.78767L7.17912 5.1786C6.99013 4.98961 6.66699 5.12346 6.66699 5.39073L6.66699 10.6089Z"
fill="currentColor"
/>
</SvgIcon>
));

View File

@@ -1,8 +1,24 @@
import { Component } from 'react';
import MultiBackend from 'react-dnd-multi-backend';
import HTML5toTouch from 'react-dnd-multi-backend/lib/HTML5toTouch';
import HTML5Backend from 'react-dnd-html5-backend';
import TouchBackend from 'react-dnd-touch-backend';
import MultiBackend, { TouchTransition } from 'react-dnd-multi-backend';
import { DragDropContext } from 'react-dnd';
// react-dnd-multi-backend/lib/HTML5toTouch is not used directly in order to
// be able to specify the delayTouchStart parameter of the TouchBackend.
const HTML5toTouch = {
backends: [
{
backend: HTML5Backend,
},
{
backend: TouchBackend({ delayTouchStart: 100 }),
preview: true,
transition: TouchTransition,
},
],
};
class DragAndDropContextProvider extends Component {
render() {
return this.props.children;

View File

@@ -11,6 +11,7 @@ import {
type ConnectDropTarget,
type ConnectDragPreview,
} from 'react-dnd';
import { hapticFeedback } from '../../Utils/Haptic';
type Props<DraggedItemType> = {|
children: ({|
@@ -26,6 +27,7 @@ type Props<DraggedItemType> = {|
canDrop: (item: DraggedItemType) => boolean,
drop: () => void,
endDrag?: () => void,
hover?: (monitor: DropTargetMonitor) => void,
|};
type DragSourceProps = {|
@@ -47,8 +49,11 @@ type InnerDragSourceAndDropTargetProps<DraggedItemType> = {|
...DropTargetProps,
|};
type Options = {| vibrate?: number |};
export const makeDragSourceAndDropTarget = <DraggedItemType>(
reactDndType: string
reactDndType: string,
options: ?Options
): ((Props<DraggedItemType>) => React.Node) => {
const sourceSpec = {
canDrag(props: Props<DraggedItemType>, monitor: DragSourceMonitor) {
@@ -58,6 +63,9 @@ export const makeDragSourceAndDropTarget = <DraggedItemType>(
return true;
},
beginDrag(props: InnerDragSourceAndDropTargetProps<DraggedItemType>) {
if (hapticFeedback && options && options.vibrate) {
hapticFeedback({ durationInMs: options.vibrate });
}
return props.beginDrag();
},
endDrag(props: Props<DraggedItemType>, monitor: DragSourceMonitor) {
@@ -87,6 +95,9 @@ export const makeDragSourceAndDropTarget = <DraggedItemType>(
}
props.drop();
},
hover(props: Props<DraggedItemType>, monitor: DropTargetMonitor) {
if (props.hover) props.hover(monitor);
},
};
function targetCollect(

View File

@@ -1,106 +0,0 @@
// @flow
import * as React from 'react';
import { t } from '@lingui/macro';
import Dialog, { DialogPrimaryButton } from './Dialog';
import TextField, { type TextFieldInterface } from './TextField';
import FlatButton from './FlatButton';
import { Trans } from '@lingui/macro';
import { type Tags, getTagsFromString } from '../Utils/TagsHelper';
import { shouldValidate } from './KeyboardShortcuts/InteractionKeys';
type Props = {|
tagsString: string,
onCancel: () => void,
onEdit: (tags: Tags) => void,
|};
type State = {|
tagsString: string,
|};
/**
* Dialog to edit tags, with keyboard support (auto focus of tags field,
* enter to validate, esc to dismiss dialog).
*/
export default class EditTagsDialog extends React.Component<Props, State> {
state = {
tagsString: this.props.tagsString,
};
_tagsField = React.createRef<TextFieldInterface>();
componentDidMount() {
setTimeout(() => {
if (this._tagsField && this._tagsField.current) {
this._tagsField.current.focus();
}
}, 10);
}
_canEdit = () => {
const { tagsString } = this.state;
const tags = getTagsFromString(tagsString);
return !!this.props.tagsString || !!tags.length;
};
_onEdit = (tags: Tags) => {
if (!this._canEdit()) return;
this.props.onEdit(tags);
};
render() {
const { onCancel, onEdit } = this.props;
const { tagsString } = this.state;
const tags = getTagsFromString(tagsString);
return (
<Dialog
title={<Trans>Edit object tags</Trans>}
actions={[
<FlatButton
key="close"
label={<Trans>Cancel</Trans>}
primary={false}
onClick={onCancel}
/>,
<DialogPrimaryButton
key="add"
label={
this.props.tagsString && !tags.length ? (
<Trans>Remove all tags</Trans>
) : (
<Trans>Add/update {tags.length} tag(s)</Trans>
)
}
primary
onClick={() => this._onEdit(tags)}
disabled={!this._canEdit()}
/>,
]}
onRequestClose={onCancel}
onApply={() => this._onEdit(tags)}
open
>
<TextField
fullWidth
value={tagsString}
onChange={(e, tagsString) =>
this.setState({
tagsString,
})
}
floatingLabelText="Tag(s) (comma-separated)"
translatableHintText={t`For example: player, spaceship, inventory...`}
onKeyPress={event => {
if (shouldValidate(event)) {
onEdit(tags);
}
}}
ref={this._tagsField}
/>
</Dialog>
);
}
}

View File

@@ -1,48 +0,0 @@
// @flow
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import IconButton from '../IconButton';
import ElementWithMenu from '../Menu/ElementWithMenu';
import { type MenuItemTemplate } from '../Menu/Menu.flow';
import Filter from '../CustomSvgIcons/Filter';
const styles = {
mediumContainer: {
padding: 0,
width: 32,
height: 32,
},
smallContainer: {
padding: 0,
width: 16,
height: 16,
},
icon: {
width: 16,
height: 16,
},
};
type Props = {|
buildMenuTemplate: (i18n: I18nType) => Array<MenuItemTemplate>,
size?: 'small',
|};
export default function TagsButton(props: Props) {
return (
<ElementWithMenu
element={
<IconButton
style={
props.size === 'small'
? styles.smallContainer
: styles.mediumContainer
}
>
<Filter htmlColor="inherit" style={styles.icon} />
</IconButton>
}
buildMenuTemplate={props.buildMenuTemplate}
/>
);
}

View File

@@ -32,22 +32,22 @@ const ESC_KEY = 27;
const MID_MOUSE_BUTTON = 1;
type ShortcutCallbacks = {|
onDelete?: () => void,
onMove?: (number, number) => void,
onCopy?: () => void,
onCut?: () => void,
onPaste?: () => void,
onDuplicate?: () => void,
onUndo?: () => void,
onRedo?: () => void,
onSearch?: () => void,
onZoomOut?: KeyboardEvent => void,
onZoomIn?: KeyboardEvent => void,
onEscape?: () => void,
onShift1?: () => void,
onShift2?: () => void,
onShift3?: () => void,
onToggleGrabbingTool?: (isEnabled: boolean) => void,
onDelete?: () => void | Promise<void>,
onMove?: (number, number) => void | Promise<void>,
onCopy?: () => void | Promise<void>,
onCut?: () => void | Promise<void>,
onPaste?: () => void | Promise<void>,
onDuplicate?: () => void | Promise<void>,
onUndo?: () => void | Promise<void>,
onRedo?: () => void | Promise<void>,
onSearch?: () => void | Promise<void>,
onZoomOut?: KeyboardEvent => void | Promise<void>,
onZoomIn?: KeyboardEvent => void | Promise<void>,
onEscape?: () => void | Promise<void>,
onShift1?: () => void | Promise<void>,
onShift2?: () => void | Promise<void>,
onShift3?: () => void | Promise<void>,
onToggleGrabbingTool?: (isEnabled: boolean) => void | Promise<void>,
|};
type ConstructorArgs = {|
@@ -81,6 +81,13 @@ export default class KeyboardShortcuts {
this._isActive = isActive;
}
setShortcutCallback(
key: $Keys<ShortcutCallbacks>,
callback: () => void | Promise<void>
) {
this._shortcutCallbacks[key] = callback;
}
shouldCloneInstances() {
return this._isControlOrCmdPressed();
}

View File

@@ -104,6 +104,7 @@ type ListItemProps = {|
backgroundColor?: string,
borderBottom?: string,
opacity?: number,
paddingLeft?: number,
|},
leftIcon?: React.Node,
@@ -115,7 +116,7 @@ type ListItemProps = {|
data?: HTMLDataset,
|};
export type ListItemRefType = any; // Should be a material-ui ListIten
export type ListItemRefType = any; // Should be a material-ui ListItem
/**
* A ListItem to be used in a List.

View File

@@ -14,6 +14,7 @@ import TagChips from './TagChips';
import { I18n } from '@lingui/react';
import { useDebounce } from '../Utils/UseDebounce';
import SearchBarContainer from './SearchBarContainer';
import { useResponsiveWindowWidth } from './Reponsive/ResponsiveWindowMeasurer';
type TagsHandler = {|
remove: string => void,
@@ -91,6 +92,8 @@ const SearchBar = React.forwardRef<Props, SearchBarInterface>(
textField.current.blur();
}
};
const windowWidth = useResponsiveWindowWidth();
const isMobile = windowWidth === 'small';
const [isInputFocused, setIsInputFocused] = React.useState(false);
@@ -175,7 +178,7 @@ const SearchBar = React.forwardRef<Props, SearchBarInterface>(
const handleCancel = () => {
changeValueImmediately('');
focus();
if (!isMobile) focus();
};
const handleKeyPressed = (event: SyntheticKeyboardEvent<>) => {

View File

@@ -1,7 +1,8 @@
// @flow
import React from 'react';
import Chip from '../UI/Chip';
import { type Tags, removeTag } from '../Utils/TagsHelper';
type Tags = Array<string>;
const styles = {
chipContainer: {
@@ -19,11 +20,10 @@ const styles = {
type Props = {|
tags: Tags,
onChange?: Tags => void,
onRemove?: string => void,
onRemove: string => void,
|};
const TagChips = ({ tags, onChange, onRemove }: Props) => {
const TagChips = ({ tags, onRemove }: Props) => {
const [focusedTag, setFocusedTag] = React.useState<?string>(null);
const tagsRefs = React.useRef([]);
@@ -51,8 +51,7 @@ const TagChips = ({ tags, onChange, onRemove }: Props) => {
newTagToFocus.current.focus();
}
}
if (onChange) onChange(removeTag(tags, tag));
else if (onRemove) onRemove(tag);
onRemove(tag);
};
if (!tags.length) return null;
@@ -69,7 +68,7 @@ const TagChips = ({ tags, onChange, onRemove }: Props) => {
style={getChipStyle(tag)}
onBlur={() => setFocusedTag(null)}
onFocus={() => setFocusedTag(tag)}
onDelete={onChange || onRemove ? handleDeleteTag(tag) : null}
onDelete={handleDeleteTag(tag)}
label={tag}
ref={newRef}
/>

View File

@@ -11,6 +11,7 @@ export function getRootClassNames(theme: string) {
return {
mosaicRootClassName: theme,
eventsSheetRootClassName: theme,
treeViewRootClassName: theme,
tableRootClassName: theme,
markdownRootClassName: theme,
uiRootClassName: theme,

View File

@@ -0,0 +1,126 @@
.tree-view .full-height-flex-container {
display: flex;
height: 100%;
}
.tree-view .full-height-flex-container.with-divider {
border-top: 1px solid var(--theme-list-item-separator-color);
}
.tree-view .full-space-container {
flex: 1;
height: 100%;
width: 100%;
}
.tree-view .row-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
width: 100%;
}
.tree-view .row-content-side {
display: flex;
align-items: center;
height: 100%;
position: relative;
}
.tree-view .row-content-side.row-content-extra-padding {
/* Used for a better dragging preview. */
padding: 0 6px;
}
.tree-view .row-content-side.row-content-side-left {
/* Necessary for item-name to be overflown. */
width: 80%;
}
.tree-view .row-content-side.row-content-side-right {
/* Used for a better dragging preview. */
position: absolute;
right: 0;
z-index: 1;
}
.tree-view .row-container {
display: flex;
flex: 1;
border-radius: 6px;
width: 100%;
}
.tree-view:focus-visible {
/**
* Remove browser-specific style around focused element when hitting an arrow key.
*/
outline: none;
}
.tree-view .row-container.with-can-drop-inside-indicator {
outline: 1px solid var(--theme-drop-indicator-can-drop-color);
}
.tree-view .row-container.with-cannot-drop-inside-indicator {
outline: 1px solid var(--theme-drop-indicator-cannot-drop-color);
}
.tree-view .row-container.selected {
background-color: var(--table-row-selected-background-color);
}
.tree-view .item-name,
.tree-view .item-name-input {
font-family: var(--gdevelop-modern-font-family) !important;
font-size: 14px;
}
.tree-view .item-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
user-select: none;
}
.tree-view .item-name.editable {
cursor: text;
}
.tree-view .item-name.placeholder {
color: var(--theme-text-secondary-color);
font-size: 14px;
}
.tree-view .item-name.root-folder {
font-weight: bold;
font-size: 16px;
}
.tree-view .folder-icon {
margin-right: 4px;
width: 20px;
}
.tree-view .thumbnail {
margin-right: 6px;
}
.tree-view .item-name-input {
outline: none;
border: none;
padding: 0;
background-image: none;
background-color: transparent;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
color: inherit;
width: 100%;
}
.tree-view .item-name-input-container:before {
bottom: -1px;
left: 0;
content: '\00a0';
width: 100%;
border-bottom: 1px solid var(--theme-text-default-color);
display: block;
position: absolute;
height: 1px;
}
.tree-view .item-name-input-container {
position: relative;
flex: 1;
}

View File

@@ -0,0 +1,461 @@
// @flow
import * as React from 'react';
import DropIndicator from '../SortableVirtualizedItemList/DropIndicator';
import memoizeOne from 'memoize-one';
import { areEqual } from 'react-window';
import IconButton from '../IconButton';
import ArrowHeadBottom from '../CustomSvgIcons/ArrowHeadBottom';
import ArrowHeadRight from '../CustomSvgIcons/ArrowHeadRight';
import Folder from '../CustomSvgIcons/Folder';
import ListIcon from '../ListIcon';
import './TreeView.css';
import {
shouldCloseOrCancel,
shouldValidate,
} from '../KeyboardShortcuts/InteractionKeys';
import ThreeDotsMenu from '../CustomSvgIcons/ThreeDotsMenu';
import { type ItemData, type ItemBaseAttributes, navigationKeys } from '.';
import { useLongTouch } from '../../Utils/UseLongTouch';
import { dataObjectToProps } from '../../Utils/HTMLDataset';
const stopPropagation = e => e.stopPropagation();
const DELAY_BEFORE_OPENING_FOLDER_ON_DRAG_HOVER = 800;
const DELAY_BEFORE_ENABLING_NAME_EDITION_AFTER_SELECTION = 1000;
const onInputKeyDown = (event: KeyboardEvent) => {
if (navigationKeys.includes(event.key)) {
// Prevent navigating in the tree view when renaming an item.
event.stopPropagation();
} else if (shouldCloseOrCancel(event)) {
// Prevent closing dialog if TreeView is displayed in dialog.
event.stopPropagation();
}
};
const SemiControlledRowInput = ({
initialValue,
onEndRenaming,
onBlur,
}: {
initialValue: string,
onEndRenaming: (newName: string) => void,
onBlur: () => void,
}) => {
const [value, setValue] = React.useState<string>(initialValue);
const inputRef = React.useRef<?HTMLInputElement>(null);
/**
* When mounting the component, select content.
*/
React.useEffect(() => {
if (inputRef.current) {
inputRef.current.select();
}
}, []);
/**
* When unmounting the component, call onBlur. If props.onBlur is called
* at the end of onKeyUp, focus might before the component is mounted.
* This would trigger the blur callback on the input, calling onEndRenaming
* with the current value, even if the user hit Escape key and expected the
* initialValue to be set.
*/
React.useEffect(
() => {
return onBlur;
},
[onBlur]
);
return (
<div className="item-name-input-container">
<input
autoFocus
ref={inputRef}
type="text"
className="item-name-input"
value={value}
onChange={e => {
setValue(e.currentTarget.value);
}}
onClick={stopPropagation}
onDoubleClick={stopPropagation}
onBlur={() => {
onEndRenaming(value);
}}
onKeyDown={onInputKeyDown}
onKeyUp={e => {
if (shouldCloseOrCancel(e)) {
// Prevent closing dialog if TreeView is displayed in dialog.
e.preventDefault();
onEndRenaming(initialValue);
} else if (shouldValidate(e)) {
onEndRenaming(value);
}
}}
/>
</div>
);
};
const memoized = memoizeOne((initialValue, getContainerYPosition) =>
getContainerYPosition()
);
type Props<Item> = {|
index: number,
style: any,
data: ItemData<Item>,
/** Used by react-window. */
isScrolling?: boolean,
|};
const TreeViewRow = <Item: ItemBaseAttributes>(props: Props<Item>) => {
const { data, index, style } = props;
const {
flattenedData,
onOpen,
onSelect,
onBlurField,
onStartRenaming,
onEndRenaming,
renamedItemId,
onContextMenu,
canDrop,
onDrop,
onEditItem,
isMobileScreen,
DragSourceAndDropTarget,
getItemHtmlId,
} = data;
const node = flattenedData[index];
const left = node.depth * 15;
const [isStayingOver, setIsStayingOver] = React.useState<boolean>(false);
const [
hasDelayPassedBeforeEditingName,
setHasDelayPassedBeforeEditingName,
] = React.useState<boolean>(false);
const openWhenOverTimeoutId = React.useRef<?TimeoutID>(null);
const [whereToDrop, setWhereToDrop] = React.useState<
'before' | 'after' | 'inside'
>('before');
const containerRef = React.useRef<?HTMLDivElement>(null);
const openContextMenu = React.useCallback(
({ clientX, clientY }) => {
onContextMenu({
index: index,
item: node.item,
x: clientX,
y: clientY,
});
},
[onContextMenu, index, node.item]
);
const longTouchForContextMenuProps = useLongTouch(openContextMenu, {
delay: 1000,
});
const onClick = React.useCallback(
event => {
if (!node || node.item.isPlaceholder) return;
if (node.item.isRoot) {
onOpen(node);
return;
}
onSelect({ node, exclusive: !(event.metaKey || event.ctrlKey) });
},
[onSelect, node, onOpen]
);
const selectAndOpenContextMenu = React.useCallback(
(event: MouseEvent) => {
onClick(event);
openContextMenu(event);
},
[onClick, openContextMenu]
);
/**
* Effect that opens the node if the user is dragging another node and stays
* over the node.
*/
React.useEffect(
() => {
if (
isStayingOver &&
!openWhenOverTimeoutId.current &&
node.canHaveChildren &&
node.collapsed
) {
openWhenOverTimeoutId.current = setTimeout(() => {
onOpen(node);
}, DELAY_BEFORE_OPENING_FOLDER_ON_DRAG_HOVER);
return () => {
clearTimeout(openWhenOverTimeoutId.current);
openWhenOverTimeoutId.current = null;
};
}
},
[isStayingOver, onOpen, node]
);
/**
* Effect allows editing the name of the node after a delay has passed
* after its selection by the user.
*/
React.useEffect(
() => {
if (isMobileScreen) return;
if (node.selected) {
if (!hasDelayPassedBeforeEditingName) {
const timeoutId = setTimeout(() => {
setHasDelayPassedBeforeEditingName(true);
}, DELAY_BEFORE_ENABLING_NAME_EDITION_AFTER_SELECTION);
return () => clearTimeout(timeoutId);
}
} else {
setHasDelayPassedBeforeEditingName(false);
}
},
[node.selected, isMobileScreen, hasDelayPassedBeforeEditingName]
);
const endRenaming = React.useCallback(
(newValue: string) => {
setHasDelayPassedBeforeEditingName(false);
onEndRenaming(node.item, newValue);
},
[onEndRenaming, node.item]
);
const getContainerYPosition = React.useCallback(() => {
if (containerRef.current) {
return containerRef.current.getBoundingClientRect().top;
}
}, []);
const displayAsFolder = node.canHaveChildren;
return (
<div style={style} ref={containerRef}>
<DragSourceAndDropTarget
beginDrag={() => {
if (!node.selected) onSelect({ node, exclusive: !node.selected });
return {};
}}
canDrag={() =>
// Prevent dragging of root folder.
!node.item.isRoot &&
// Prevent dragging of item whose name is edited, allowing to select text with click and drag on text.
renamedItemId !== node.id
}
canDrop={canDrop ? () => canDrop(node.item) : () => true}
drop={() => {
onDrop(node.item, whereToDrop);
}}
hover={monitor => {
if (node.item.isRoot) {
if (whereToDrop !== 'inside') setWhereToDrop('inside');
return;
}
const { y } = monitor.getClientOffset();
// Use a cached version of container position to avoid recomputing bounding rectangle.
// Doing this, the position is computed every second the user hovers the target.
const containerYPosition = memoized(
Math.floor(Date.now() / 1000),
getContainerYPosition
);
if (containerYPosition) {
if (displayAsFolder) {
if (node.collapsed) {
setWhereToDrop(
y - containerYPosition <= 6
? 'before'
: y - containerYPosition <= 26
? 'inside'
: 'after'
);
} else {
// If the folder is open, do not suggest to drop after as
// the drop indicator can be misleading (displayed under the row
// although dropping the element after would put it below the last
// displayed child of the folder).
setWhereToDrop(
y - containerYPosition <= 6 ? 'before' : 'inside'
);
}
} else {
setWhereToDrop(y - containerYPosition <= 16 ? 'before' : 'after');
}
}
}}
>
{({
connectDragSource,
connectDropTarget,
connectDragPreview,
isOver,
canDrop,
}) => {
setIsStayingOver(isOver);
return (
<div
style={{ paddingLeft: left }}
className={`full-height-flex-container${
node.item.isRoot && index > 0 ? ' with-divider' : ''
}`}
>
{connectDropTarget(
<div
id={
getItemHtmlId ? getItemHtmlId(node.item, index) : undefined
}
onClick={onClick}
className={
'row-container' +
(node.selected ? ' selected' : '') +
(isOver &&
whereToDrop === 'inside' &&
displayAsFolder &&
!node.item.isRoot
? canDrop
? ' with-can-drop-inside-indicator'
: ' with-cannot-drop-inside-indicator'
: '')
}
aria-selected={node.selected}
aria-expanded={displayAsFolder ? !node.collapsed : false}
{...dataObjectToProps(node.dataset)}
>
{connectDragSource(
<div className="full-space-container">
{isOver && whereToDrop === 'before' && (
<DropIndicator canDrop={canDrop} />
)}
<div
className="row-content"
onDoubleClick={
onEditItem ? () => onEditItem(node.item) : undefined
}
onContextMenu={selectAndOpenContextMenu}
{...longTouchForContextMenuProps}
>
{connectDragPreview(
<div
className={`row-content-side${
node.item.isRoot ? '' : ' row-content-side-left'
}${
displayAsFolder
? ''
: ' row-content-extra-padding'
}`}
>
{displayAsFolder ? (
<>
<IconButton
size="small"
onClick={e => {
e.stopPropagation();
onOpen(node);
}}
disabled={node.disableCollapse}
>
{node.collapsed ? (
<ArrowHeadRight fontSize="small" />
) : (
<ArrowHeadBottom fontSize="small" />
)}
</IconButton>
{!node.item.isRoot && (
<Folder className="folder-icon" />
)}
</>
) : node.thumbnailSrc ? (
<div className="thumbnail">
<ListIcon
iconSize={20}
src={node.thumbnailSrc}
/>
</div>
) : null}
{renamedItemId === node.id ? (
<SemiControlledRowInput
initialValue={node.name}
onEndRenaming={endRenaming}
onBlur={onBlurField}
/>
) : (
<span
className={`item-name${
node.item.isRoot
? ' root-folder'
: node.item.isPlaceholder
? ' placeholder'
: ''
}${
node.item.isRoot || node.item.isPlaceholder
? ''
: hasDelayPassedBeforeEditingName
? ' editable'
: ''
}`}
onClick={
node.item.isRoot ||
node.item.isPlaceholder ||
isMobileScreen ||
!hasDelayPassedBeforeEditingName
? null
: e => {
if (!e.metaKey && !e.shiftKey) {
e.stopPropagation();
onStartRenaming(node.id);
}
}
}
>
{node.name}
</span>
)}
</div>
)}
{!isMobileScreen &&
!node.item.isRoot &&
!node.item.isPlaceholder && (
<div className="row-content-side row-content-side-right">
<IconButton
size="small"
onClick={e => {
e.stopPropagation();
onContextMenu({
item: node.item,
index,
x: e.clientX,
y: e.clientY,
});
}}
>
<ThreeDotsMenu />
</IconButton>
</div>
)}
</div>
{isOver && whereToDrop === 'after' && (
<DropIndicator canDrop={canDrop} />
)}
</div>
)}
</div>
)}
</div>
);
}}
</DragSourceAndDropTarget>
</div>
);
};
// $FlowFixMe - memo does not support having a generic in the props.
export default React.memo<Props>(TreeViewRow, areEqual);

View File

@@ -0,0 +1,613 @@
// @flow
import * as React from 'react';
import { FixedSizeList } from 'react-window';
import memoizeOne from 'memoize-one';
import GDevelopThemeContext from '../Theme/GDevelopThemeContext';
import { treeView } from '../../EventsSheet/EventsTree/ClassNames';
import './TreeView.css';
import ContextMenu, { type ContextMenuInterface } from '../Menu/ContextMenu';
import { useResponsiveWindowWidth } from '../Reponsive/ResponsiveWindowMeasurer';
import TreeViewRow from './TreeViewRow';
import { makeDragSourceAndDropTarget } from '../DragAndDrop/DragSourceAndDropTarget';
import { type HTMLDataset } from '../../Utils/HTMLDataset';
import useForceUpdate from '../../Utils/UseForceUpdate';
export const navigationKeys = [
'ArrowDown',
'ArrowUp',
'ArrowRight',
'ArrowLeft',
];
export type ItemBaseAttributes = {
+isRoot?: boolean,
+isPlaceholder?: boolean,
};
type FlattenedNode<Item> = {|
id: string,
name: string,
hasChildren: boolean,
canHaveChildren: boolean,
depth: number,
dataset?: ?HTMLDataset,
collapsed: boolean,
selected: boolean,
disableCollapse: boolean,
thumbnailSrc?: ?string,
item: Item,
|};
export type ItemData<Item> = {|
onOpen: (FlattenedNode<Item>) => void,
onSelect: ({| node: FlattenedNode<Item>, exclusive?: boolean |}) => void,
onBlurField: () => void,
flattenedData: FlattenedNode<Item>[],
onStartRenaming: (nodeId: ?string) => void,
onEndRenaming: (item: Item, newName: string) => void,
onContextMenu: ({|
item: Item,
index: number,
x: number,
y: number,
|}) => void,
renamedItemId: ?string,
canDrop?: ?(Item) => boolean,
onDrop: (Item, where: 'before' | 'inside' | 'after') => void,
onEditItem?: Item => void,
isMobileScreen: boolean,
DragSourceAndDropTarget: any => React.Node,
getItemHtmlId?: (Item, index: number) => ?string,
|};
const getItemProps = memoizeOne(
<Item>(
flattenedData: FlattenedNode<Item>[],
onOpen: (FlattenedNode<Item>) => void,
onSelect: ({| node: FlattenedNode<Item>, exclusive?: boolean |}) => void,
onBlurField: () => void,
onStartRenaming: (nodeId: ?string) => void,
onEndRenaming: (item: Item, newName: string) => void,
renamedItemId: ?string,
onContextMenu: ({|
item: Item,
index: number,
x: number,
y: number,
|}) => void,
canDrop?: ?(Item) => boolean,
onDrop: (Item, where: 'before' | 'inside' | 'after') => void,
onEditItem?: Item => void,
isMobileScreen: boolean,
DragSourceAndDropTarget: any => React.Node,
getItemHtmlId?: (Item, index: number) => ?string
): ItemData<Item> => ({
onOpen,
onSelect,
onBlurField,
flattenedData,
onStartRenaming,
onEndRenaming,
renamedItemId,
onContextMenu,
canDrop,
onDrop,
onEditItem,
isMobileScreen,
DragSourceAndDropTarget,
getItemHtmlId,
})
);
export type TreeViewInterface<Item> = {|
forceUpdateList: () => void,
scrollToItem: Item => void,
renameItem: Item => void,
openItems: (string[]) => void,
closeItems: (string[]) => void,
|};
type Props<Item> = {|
height: number,
width?: number,
items: Item[],
getItemName: Item => string,
getItemId: Item => string,
getItemHtmlId?: (Item, index: number) => ?string,
getItemChildren: Item => ?(Item[]),
getItemThumbnail?: Item => ?string,
getItemDataset?: Item => ?HTMLDataset,
onEditItem?: Item => void,
buildMenuTemplate: (Item, index: number) => any,
searchText?: string,
selectedItems: $ReadOnlyArray<Item>,
onSelectItems: (Item[]) => void,
multiSelect: boolean,
onRenameItem: (Item, newName: string) => void,
onMoveSelectionToItem: (
destinationItem: Item,
where: 'before' | 'inside' | 'after'
) => void,
canMoveSelectionToItem?: ?(destinationItem: Item) => boolean,
reactDndType: string,
forceAllOpened?: boolean,
initiallyOpenedNodeIds?: string[],
renderHiddenElements?: boolean,
arrowKeyNavigationProps?: {|
onArrowRight: (item: Item) => ?Item,
onArrowLeft: (item: Item) => ?Item,
|},
|};
const TreeView = <Item: ItemBaseAttributes>(
{
height,
width,
items,
searchText,
getItemName,
getItemId,
getItemHtmlId,
getItemChildren,
getItemThumbnail,
getItemDataset,
onEditItem,
buildMenuTemplate,
selectedItems,
onSelectItems,
multiSelect,
onRenameItem,
onMoveSelectionToItem,
canMoveSelectionToItem,
reactDndType,
forceAllOpened,
initiallyOpenedNodeIds,
renderHiddenElements,
arrowKeyNavigationProps,
}: Props<Item>,
ref: TreeViewInterface<Item>
) => {
const selectedNodeIds = selectedItems.map(item => getItemId(item));
const [openedNodeIds, setOpenedNodeIds] = React.useState<string[]>(
initiallyOpenedNodeIds || []
);
const [renamedItemId, setRenamedItemId] = React.useState<?string>(null);
const contextMenuRef = React.useRef<?ContextMenuInterface>(null);
const containerRef = React.useRef<?HTMLDivElement>(null);
const listRef = React.useRef<?FixedSizeList>(null);
const [
openedDuringSearchNodeIds,
setOpenedDuringSearchNodeIds,
] = React.useState<string[]>([]);
const theme = React.useContext(GDevelopThemeContext);
const windowWidth = useResponsiveWindowWidth();
const forceUpdate = useForceUpdate();
const isMobileScreen = windowWidth === 'small';
const isSearching = !!searchText;
const flattenNode = React.useCallback(
(
item: Item,
depth: number,
searchText: ?string,
forceOpen: boolean
): FlattenedNode<Item>[] => {
const id = getItemId(item);
const children = getItemChildren(item);
const canHaveChildren = Array.isArray(children);
const collapsed = !forceAllOpened && !openedNodeIds.includes(id);
const openedDuringSearch = openedDuringSearchNodeIds.includes(id);
let flattenedChildren = [];
/*
* Compute children nodes flattening if:
* - node has children;
* and if either one of these conditions are true:
* - the nodes are force-opened (props)
* - the node is opened (not collapsed)
* - the user is searching
* - the user opened the node during the search
*/
if (
children &&
(forceAllOpened || !collapsed || !!searchText || openedDuringSearch)
) {
flattenedChildren = children
.map(child =>
flattenNode(child, depth + 1, searchText, openedDuringSearch)
)
.flat();
}
const name = getItemName(item);
const dataset = getItemDataset ? getItemDataset(item) : undefined;
/*
* Append node to result if either:
* - the user is not searching
* - the nodes are force-opened (props)
* - the node is force-opened (if user opened the node during the search)
* - the node name matches the search
* - the node contains children that should be displayed
*/
if (
!searchText ||
forceAllOpened ||
forceOpen ||
name.toLowerCase().includes(searchText) ||
flattenedChildren.length > 0
) {
const thumbnailSrc = getItemThumbnail ? getItemThumbnail(item) : null;
const selected = selectedNodeIds.includes(id);
return [
{
id,
name,
hasChildren: !!children && children.length > 0,
canHaveChildren,
depth,
selected,
thumbnailSrc,
dataset,
item,
/*
* If the user is searching, the node should be opened if either:
* - it has children that should be displayed
* - the user opened it
*/
collapsed: !!searchText
? flattenedChildren.length === 0 || !openedDuringSearch
: collapsed,
/*
* Disable opening of the node if:
* - the user is searching
* - the node has children to be displayed but it's not because the user opened it
*/
disableCollapse:
!!searchText &&
flattenedChildren.length > 0 &&
!openedDuringSearch,
},
...flattenedChildren,
];
}
return [];
},
[
getItemChildren,
getItemId,
getItemName,
getItemThumbnail,
getItemDataset,
openedDuringSearchNodeIds,
openedNodeIds,
selectedNodeIds,
forceAllOpened,
]
);
const flattenOpened = React.useCallback(
(items: Item[], searchText: ?string): FlattenedNode<Item>[] => {
return items.map(item => flattenNode(item, 0, searchText, false)).flat();
},
[flattenNode]
);
const onOpen = React.useCallback(
(node: FlattenedNode<Item>) => {
if (isSearching) {
if (node.collapsed) {
setOpenedDuringSearchNodeIds([...openedDuringSearchNodeIds, node.id]);
} else {
if (!forceAllOpened)
setOpenedDuringSearchNodeIds(
openedDuringSearchNodeIds.filter(id => id !== node.id)
);
}
} else {
if (node.collapsed) {
setOpenedNodeIds([...openedNodeIds, node.id]);
} else {
if (!forceAllOpened)
setOpenedNodeIds(openedNodeIds.filter(id => id !== node.id));
}
}
},
[openedDuringSearchNodeIds, openedNodeIds, isSearching, forceAllOpened]
);
const onSelect = React.useCallback(
({
node,
exclusive,
}: {|
node: FlattenedNode<Item>,
exclusive?: boolean,
|}) => {
if (multiSelect) {
if (node.selected) {
if (exclusive) {
if (selectedItems.length === 1) return;
onSelectItems([node.item]);
} else
onSelectItems(selectedItems.filter(item => item !== node.item));
} else {
if (exclusive) onSelectItems([node.item]);
else onSelectItems([...selectedItems, node.item]);
}
} else {
if (node.selected && selectedItems.length === 1) return;
onSelectItems([node.item]);
}
},
[multiSelect, onSelectItems, selectedItems]
);
const onEndRenaming = (item: Item, newName: string) => {
const trimmedNewName = newName.trim();
setRenamedItemId(null);
if (!trimmedNewName) return;
if (getItemName(item) === trimmedNewName) return;
onRenameItem(item, trimmedNewName);
};
let flattenedData = React.useMemo(
() => flattenOpened(items, searchText ? searchText.toLowerCase() : null),
[flattenOpened, items, searchText]
);
const scrollToItem = React.useCallback(
(item: Item) => {
const list = listRef.current;
if (list) {
const itemId = getItemId(item);
// Browse flattenedData in reverse order since scrollToItem is mainly used
// to scroll to newly added object that is appended at the end of the list.
// $FlowFixMe - Method introduced in 2022.
const index = flattenedData.findLastIndex(node => node.id === itemId);
if (index >= 0) {
list.scrollToItem(index, 'smart');
}
}
},
[getItemId, flattenedData]
);
const renameItem = React.useCallback(
(item: Item) => {
setRenamedItemId(getItemId(item));
},
[getItemId]
);
const openItems = React.useCallback(
(itemIds: string[]) => {
const notAlreadyOpenedNodeIds = itemIds.filter(
itemId => !openedNodeIds.includes(itemId)
);
if (notAlreadyOpenedNodeIds.length > 0)
setOpenedNodeIds([...openedNodeIds, ...notAlreadyOpenedNodeIds]);
},
[openedNodeIds]
);
const closeItems = React.useCallback(
(itemIds: string[]) => {
const newOpenedNodesIds = openedNodeIds.filter(
openedNodeId => !itemIds.includes(openedNodeId)
);
setOpenedNodeIds(newOpenedNodesIds);
},
[openedNodeIds]
);
React.useImperativeHandle(
// $FlowFixMe
ref,
() => ({
forceUpdateList: forceUpdate,
scrollToItem,
renameItem,
openItems,
closeItems,
})
);
const DragSourceAndDropTarget = React.useMemo(
() =>
makeDragSourceAndDropTarget(reactDndType, {
vibrate: 100,
}),
[reactDndType]
);
const openContextMenu = React.useCallback(
({
x,
y,
item,
index,
}: {|
item: Item,
index: number,
x: number,
y: number,
|}) => {
if (contextMenuRef.current) {
contextMenuRef.current.open(x, y, { item, index });
}
},
[]
);
const onBlurField = React.useCallback(() => {
if (containerRef.current) {
containerRef.current.focus();
}
}, []);
const itemData: ItemData<Item> = getItemProps<Item>(
flattenedData,
onOpen,
onSelect,
onBlurField,
setRenamedItemId,
onEndRenaming,
renamedItemId,
openContextMenu,
canMoveSelectionToItem,
onMoveSelectionToItem,
onEditItem,
isMobileScreen,
DragSourceAndDropTarget,
getItemHtmlId
);
// Reset opened nodes during search when user stops searching
// or when the search text changes.
React.useEffect(
() => {
if (!searchText || searchText.length > 0) {
setOpenedDuringSearchNodeIds([]);
}
},
[searchText]
);
const onKeyDown = React.useCallback(
(event: KeyboardEvent) => {
if (!navigationKeys.includes(event.key)) return;
let newFocusedItem;
const item = selectedItems[0];
let itemIndexInFlattenedData = -1;
if (item) {
itemIndexInFlattenedData = flattenedData.findIndex(
node => node.id === getItemId(item)
);
}
if (itemIndexInFlattenedData === -1) {
// If no row is selected, start from the first row that is selectable.
let i = 0;
let newFocusedNode = flattenedData[i];
while (
newFocusedNode &&
(newFocusedNode.item.isRoot || newFocusedNode.item.isPlaceholder)
) {
i += 1;
if (i > flattenedData.length - 1) {
newFocusedNode = null;
}
newFocusedNode = flattenedData[i];
}
if (newFocusedNode) {
newFocusedItem = newFocusedNode.item;
}
} else if (event.key === 'ArrowDown') {
event.preventDefault();
if (itemIndexInFlattenedData < flattenedData.length - 1) {
let delta = 1;
let newFocusedNode = flattenedData[itemIndexInFlattenedData + delta];
while (
newFocusedNode &&
(newFocusedNode.item.isRoot || newFocusedNode.item.isPlaceholder)
) {
if (itemIndexInFlattenedData + delta > flattenedData.length - 1) {
newFocusedNode = null;
}
delta += 1;
newFocusedNode = flattenedData[itemIndexInFlattenedData + delta];
}
if (newFocusedNode) {
newFocusedItem = newFocusedNode.item;
}
}
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (itemIndexInFlattenedData > 0) {
let delta = -1;
let newFocusedNode = flattenedData[itemIndexInFlattenedData + delta];
while (
newFocusedNode &&
(newFocusedNode.item.isRoot || newFocusedNode.item.isPlaceholder)
) {
if (itemIndexInFlattenedData + delta < 0) {
newFocusedNode = null;
}
delta -= 1;
newFocusedNode = flattenedData[itemIndexInFlattenedData + delta];
}
if (newFocusedNode) {
newFocusedItem = newFocusedNode.item;
}
}
} else if (event.key === 'ArrowRight' && arrowKeyNavigationProps) {
event.preventDefault();
const node = flattenedData[itemIndexInFlattenedData];
if (node.canHaveChildren && node.collapsed) {
openItems([node.id]);
} else {
newFocusedItem = arrowKeyNavigationProps.onArrowRight(item);
}
} else if (event.key === 'ArrowLeft' && arrowKeyNavigationProps) {
event.preventDefault();
const node = flattenedData[itemIndexInFlattenedData];
if (node.canHaveChildren && !node.collapsed) {
closeItems([node.id]);
} else {
newFocusedItem = arrowKeyNavigationProps.onArrowLeft(item);
}
}
if (newFocusedItem) {
scrollToItem(newFocusedItem);
onSelectItems([newFocusedItem]);
}
},
[
flattenedData,
arrowKeyNavigationProps,
getItemId,
onSelectItems,
selectedItems,
scrollToItem,
openItems,
closeItems,
]
);
return (
<>
<div
tabIndex={0}
className={`${treeView} ${theme.treeViewRootClassName}`}
onKeyDown={onKeyDown}
ref={containerRef}
>
<FixedSizeList
height={height}
itemCount={flattenedData.length}
itemSize={32}
width={typeof width === 'number' ? width : '100%'}
itemKey={index => flattenedData[index].id}
// Flow does not seem to accept the generic used in FixedSizeList
// can itself use a generic.
// $FlowFixMe
itemData={itemData}
ref={listRef}
overscanCount={renderHiddenElements ? 20 : 2}
>
{TreeViewRow}
</FixedSizeList>
</div>
<ContextMenu
ref={contextMenuRef}
buildMenuTemplate={(i18n, options) =>
buildMenuTemplate(options.item, options.index)
}
/>
</>
);
};
// $FlowFixMe
export default React.forwardRef(TreeView);

View File

@@ -57,6 +57,9 @@ export type TranslatedText =
type InAppTutorialFlowStepDOMChangeTrigger =
| {| presenceOfElement: string |}
| {| absenceOfElement: string |};
type InAppTutorialFlowStepShortcutTrigger =
| InAppTutorialFlowStepDOMChangeTrigger
| {| objectAddedInLayout: true |};
export type InAppTutorialFlowStepTrigger =
| InAppTutorialFlowStepDOMChangeTrigger
@@ -64,6 +67,7 @@ export type InAppTutorialFlowStepTrigger =
| {| valueHasChanged: true |}
| {| valueEquals: string |}
| {| instanceAddedOnScene: string, instancesCount?: number |}
| {| objectAddedInLayout: true |}
| {| previewLaunched: true |}
| {| clickOnTooltipButton: TranslatedText |};
@@ -72,6 +76,7 @@ export type InAppTutorialFlowStepFormattedTrigger =
| {| valueEquals: string |}
| {| valueHasChanged: true |}
| {| instanceAddedOnScene: string, instancesCount?: number |}
| {| objectAddedInLayout: true |}
| {| previewLaunched: true |}
| {| clickOnTooltipButton: string |};
@@ -107,7 +112,7 @@ export type InAppTutorialFlowStep = {|
shortcuts?: Array<{|
stepId: string,
// TODO: Adapt provider to make it possible to use other triggers as shortcuts
trigger: InAppTutorialFlowStepDOMChangeTrigger,
trigger: InAppTutorialFlowStepShortcutTrigger,
|}>,
dialog?: InAppTutorialDialog,
mapProjectData?: {

View File

@@ -0,0 +1,17 @@
// @flow
import { isNativeMobileApp } from './Platform';
export const hapticFeedback: ?({
durationInMs: number,
}) => void = !isNativeMobileApp()
? ({ durationInMs }) => {
try {
if (window.navigator && window.navigator.vibrate) {
window.navigator.vibrate(durationInMs);
}
} catch (error) {
console.warn('Vibration API not supported:', error);
}
}
: null;

View File

@@ -1,89 +0,0 @@
// @flow
// Helpers to manipulate tags. See also EditTagsDialog.js
export type Tags = Array<string>;
export type SelectedTags = Tags;
export const removeTag = (tags: Tags, tag: string): Tags => {
return tags.filter(selectedTag => selectedTag !== tag);
};
export const addTags = (tags: Tags, newTags: Tags): Tags => {
return Array.from(new Set([...tags, ...newTags]));
};
export type BuildTagsMenuTemplateOptions = {|
noTagLabel: string,
getAllTags: () => Array<string>,
selectedTags: SelectedTags,
onChange: SelectedTags => void,
onEditTags?: () => void,
editTagsLabel?: string,
|};
export const buildTagsMenuTemplate = ({
noTagLabel,
getAllTags,
selectedTags,
onChange,
onEditTags,
editTagsLabel,
}: BuildTagsMenuTemplateOptions): Array<any> => {
const allTags = getAllTags();
const footerMenuItems =
onEditTags && editTagsLabel
? [
{
type: 'separator',
},
{
label: editTagsLabel,
click: onEditTags,
},
]
: [];
if (!allTags.length) {
return [
{
label: noTagLabel,
enabled: false,
},
...footerMenuItems,
];
}
return allTags
.map(tag => ({
type: 'checkbox',
label: tag,
checked: selectedTags.includes(tag),
click: () => {
if (selectedTags.includes(tag)) {
onChange(removeTag(selectedTags, tag));
} else {
onChange(addTags(selectedTags, [tag]));
}
},
}))
.concat(footerMenuItems);
};
export const getTagsFromString = (tagsString: string): Tags => {
if (tagsString.trim() === '') return [];
return tagsString.split(',').map(tag => tag.trim());
};
export const getStringFromTags = (tags: Tags): string => {
return tags.join(', ');
};
export const hasStringAllTags = (tagsString: string, tags: Tags) => {
for (const tag of tags) {
if (tagsString.indexOf(tag) === -1) return false;
}
return true;
};

View File

@@ -23,7 +23,7 @@ const getClientXY = (event: TouchEvent): CallbackEvent => {
};
};
const delay = 600; // ms
const defaultDelay = 600; // ms
const moveTolerance = 10; // px
const contextLocks: { [string]: true } = {};
@@ -37,13 +37,18 @@ const contextLocks: { [string]: true } = {};
*/
export const useLongTouch = (
callback: (e: CallbackEvent) => void,
/**
* To be set when nested elements with watched touches events are in conflict to run a callback.
* Priority will be given to the nested element.
*/
context?: string
options?: {
/**
* To be set when nested elements with watched touches events are in conflict to run a callback.
* Priority will be given to the nested element.
*/
context?: string,
delay?: number,
}
) => {
const timeout = React.useRef<?TimeoutID>(null);
const context = options && options.context ? options.context : null;
const delay = options && options.delay ? options.delay : defaultDelay;
const currentTouchCallbackEvent = React.useRef<CallbackEvent>({
clientX: 0,
clientY: 0,
@@ -98,7 +103,7 @@ export const useLongTouch = (
callback(currentTouchCallbackEvent.current);
}, delay);
},
[callback, context]
[callback, context, delay]
);
const onMove = React.useCallback(

View File

@@ -346,10 +346,6 @@ export const makeTestProject = (gd /*: libGDevelop */) /*: TestProject */ => {
'Draggable'
);
// Add some tags
tiledSpriteObject.setTags('Tag1');
spriteObject.setTags('Tag1, Tag2');
const group1 = new gd.ObjectGroup();
group1.setName('GroupOfSprites');
group1.addObject('MySpriteObject');

View File

@@ -16,6 +16,7 @@ export const Default = () => {
showAlert,
showConfirmation,
showDeleteConfirmation,
showYesNoCancel,
} = useAlertDialog();
const onOpenAlertDialog = async () => {
@@ -26,6 +27,20 @@ export const Default = () => {
action('Dismissed')();
};
const onOpenYesNoCancelDialog = async () => {
const answer = await showYesNoCancel({
title: t`Warning`,
message: t`Do you want to refactor your project?`,
});
if (answer === 0) {
action('Yes')();
} else if (answer === 1) {
action('No')();
} else {
action('Cancel')();
}
};
const onOpenConfirmDialog = async () => {
const answer = await showConfirmation({
title: t`You are about to delete an object`,
@@ -48,6 +63,16 @@ export const Default = () => {
else action('Delete Dismissed')();
};
const onOpenConfirmDeleteWithoutConfirmTextDialog = async () => {
const answer = await showDeleteConfirmation({
title: t`Do you really want to permanently delete your account?`,
message: t`Youre about to permanently delete your GDevelop account username@mail.com. You will no longer be able to log into the app with this email address.`,
confirmButtonLabel: t`Delete my account`,
});
if (answer) action('Delete Confirmed')();
else action('Delete Dismissed')();
};
return (
<Column alignItems="flex-start">
<RaisedButton label="Open alert dialog" onClick={onOpenAlertDialog} />
@@ -58,6 +83,16 @@ export const Default = () => {
label="Open confirm delete dialog"
onClick={onOpenConfirmDeleteDialog}
/>
<LargeSpacer />
<RaisedButton
label="Open confirm delete dialog without confirm text"
onClick={onOpenConfirmDeleteWithoutConfirmTextDialog}
/>
<LargeSpacer />
<RaisedButton
label="Open yes no cancel dialog"
onClick={onOpenYesNoCancelDialog}
/>
</Column>
);
};

View File

@@ -268,19 +268,16 @@ export const WithObjectsList = () => (
onEditObject={action('On edit object')}
onExportObject={action('On export object')}
onAddObjectInstance={action('On add instance to the scene')}
selectedObjectNames={[]}
selectedObjectTags={[]}
onChangeSelectedObjectTags={() => {}}
getAllObjectTags={() => []}
selectedObjectFolderOrObjectsWithContext={[]}
getValidatedObjectOrGroupName={newName => newName}
onDeleteObject={(objectWithContext, cb) => cb(true)}
onRenameObjectStart={() => {}}
onRenameObjectFinish={(objectWithContext, newName, cb) =>
cb(true)
}
onRenameObjectFolderOrObjectWithContextFinish={(
objectFolderOrObjectWithContext,
newName,
cb
) => cb(true)}
onObjectCreated={() => {}}
onObjectSelected={() => {}}
renamedObjectWithContext={null}
onObjectFolderOrObjectWithContextSelected={() => {}}
hotReloadPreviewButtonProps={hotReloadPreviewButtonProps}
canInstallPrivateAsset={() => false}
/>

View File

@@ -1,12 +1,14 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
// Keep first as it creates the `global.gd` object:
import { testProject } from '../../GDevelopJsInitializerDecorator';
import muiDecorator from '../../ThemeDecorator';
import paperDecorator from '../../PaperDecorator';
import alertDecorator from '../../AlertDecorator';
import ObjectGroupsList from '../../../ObjectGroupsList';
import DragAndDropContextProvider from '../../../UI/DragAndDrop/DragAndDropContextProvider';
import SerializedObjectDisplay from '../../SerializedObjectDisplay';
@@ -14,7 +16,7 @@ import SerializedObjectDisplay from '../../SerializedObjectDisplay';
export default {
title: 'LayoutEditor/ObjectGroupsList',
component: ObjectGroupsList,
decorators: [paperDecorator, muiDecorator],
decorators: [alertDecorator, paperDecorator, muiDecorator],
};
export const Default = () => (
@@ -24,7 +26,9 @@ export const Default = () => (
<ObjectGroupsList
globalObjectGroups={testProject.project.getObjectGroups()}
objectGroups={testProject.testLayout.getObjectGroups()}
onEditGroup={() => {}}
onEditGroup={action('onEditGroup')}
onRenameGroup={action('onRenameGroup')}
onDeleteGroup={action('onDeleteGroup')}
getValidatedObjectOrGroupName={newName => newName}
/>
</div>

View File

@@ -9,6 +9,7 @@ import { testProject } from '../../GDevelopJsInitializerDecorator';
import fakeHotReloadPreviewButtonProps from '../../FakeHotReloadPreviewButtonProps';
import muiDecorator from '../../ThemeDecorator';
import paperDecorator from '../../PaperDecorator';
import alertDecorator from '../../AlertDecorator';
import ObjectsList from '../../../ObjectsList';
import DragAndDropContextProvider from '../../../UI/DragAndDrop/DragAndDropContextProvider';
import SerializedObjectDisplay from '../../SerializedObjectDisplay';
@@ -17,7 +18,7 @@ import fakeResourceManagementProps from '../../FakeResourceManagement';
export default {
title: 'LayoutEditor/ObjectsList',
component: ObjectsList,
decorators: [paperDecorator, muiDecorator],
decorators: [alertDecorator, paperDecorator, muiDecorator],
};
export const Default = () => (
@@ -33,16 +34,15 @@ export const Default = () => (
onExportObject={action('On export object')}
onAddObjectInstance={action('On add instance to the scene')}
onObjectCreated={action('On object created')}
selectedObjectNames={[]}
selectedObjectTags={[]}
onChangeSelectedObjectTags={selectedObjectTags => {}}
getAllObjectTags={() => []}
selectedObjectFolderOrObjectsWithContext={[]}
getValidatedObjectOrGroupName={newName => newName}
onDeleteObject={(objectWithContext, cb) => cb(true)}
onRenameObjectStart={() => {}}
onRenameObjectFinish={(objectWithContext, newName, cb) => cb(true)}
onObjectSelected={() => {}}
renamedObjectWithContext={null}
onRenameObjectFolderOrObjectWithContextFinish={(
objectWithContext,
newName,
cb
) => cb(true)}
onObjectFolderOrObjectWithContextSelected={() => {}}
hotReloadPreviewButtonProps={fakeHotReloadPreviewButtonProps}
canInstallPrivateAsset={() => false}
/>
@@ -64,16 +64,15 @@ export const WithSerializedObjectView = () => (
onExportObject={action('On export object')}
onAddObjectInstance={action('On add instance to the scene')}
onObjectCreated={action('On object created')}
selectedObjectNames={[]}
selectedObjectTags={[]}
onChangeSelectedObjectTags={selectedObjectTags => {}}
getAllObjectTags={() => []}
selectedObjectFolderOrObjectsWithContext={[]}
getValidatedObjectOrGroupName={newName => newName}
onDeleteObject={(objectWithContext, cb) => cb(true)}
onRenameObjectStart={() => {}}
onRenameObjectFinish={(objectWithContext, newName, cb) => cb(true)}
onObjectSelected={() => {}}
renamedObjectWithContext={null}
onRenameObjectFolderOrObjectWithContextFinish={(
objectWithContext,
newName,
cb
) => cb(true)}
onObjectFolderOrObjectWithContextSelected={() => {}}
hotReloadPreviewButtonProps={fakeHotReloadPreviewButtonProps}
canInstallPrivateAsset={() => false}
/>
@@ -81,38 +80,3 @@ export const WithSerializedObjectView = () => (
</SerializedObjectDisplay>
</DragAndDropContextProvider>
);
export const WithTags = () => (
<DragAndDropContextProvider>
<div style={{ height: 250 }}>
<ObjectsList
getThumbnail={() => 'res/unknown32.png'}
project={testProject.project}
objectsContainer={testProject.testLayout}
layout={testProject.testLayout}
resourceManagementProps={fakeResourceManagementProps}
onEditObject={action('On edit object')}
onExportObject={action('On export object')}
onAddObjectInstance={action('On add instance to the scene')}
onObjectCreated={action('On object created')}
selectedObjectNames={[]}
selectedObjectTags={['Tag1', 'Tag2']}
onChangeSelectedObjectTags={action('on change selected object tags')}
getAllObjectTags={() => [
'Tag1',
'Tag2',
'Looooooooooong Tag 3',
'Unselected Tag 4',
]}
getValidatedObjectOrGroupName={newName => newName}
onDeleteObject={(objectWithContext, cb) => cb(true)}
onRenameObjectStart={() => {}}
onRenameObjectFinish={(objectWithContext, newName, cb) => cb(true)}
onObjectSelected={() => {}}
renamedObjectWithContext={null}
hotReloadPreviewButtonProps={fakeHotReloadPreviewButtonProps}
canInstallPrivateAsset={() => false}
/>
</div>
</DragAndDropContextProvider>
);

View File

@@ -0,0 +1,524 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import muiDecorator from '../../ThemeDecorator';
import { getPaperDecorator } from '../../PaperDecorator';
import FixedHeightFlexContainer from '../../FixedHeightFlexContainer';
import DragAndDropContextProvider from '../../../UI/DragAndDrop/DragAndDropContextProvider';
import { AutoSizer } from 'react-virtualized';
import TreeView from '../../../UI/TreeView';
import { Column, Line } from '../../../UI/Grid';
import TextField from '../../../UI/TextField';
import sample from 'lodash/sample';
import Toggle from '../../../UI/Toggle';
import Text from '../../../UI/Text';
import { ResponsiveLineStackLayout } from '../../../UI/Layout';
export default {
title: 'UI Building Blocks/TreeView',
component: TreeView,
decorators: [getPaperDecorator('dark'), muiDecorator],
};
type Node = {|
name: string,
id: string,
isRoot?: boolean,
children?: Node[],
|};
const nodes: Node[] = [
{
name: 'Section 1',
id: 'section',
isRoot: true,
children: [
{
name: 'Root #1',
id: 'root-1',
children: [
{
children: [
{ id: 'child-2', name: 'Child #2' },
{ id: 'child-3', name: 'Child #3' },
],
id: 'child-4',
name: 'Child #4',
},
{
children: [{ id: 'child-5', name: 'Child #5' }],
id: 'child-6',
name: 'Child #6',
},
],
},
{
name: 'Root #2',
id: 'root-2',
children: [
{
children: [
{ id: 'child-7', name: 'Child #7' },
{ id: 'child-8', name: 'Child #8' },
],
id: 'child-9',
name: 'Child #9',
},
{
children: [{ id: 'child-10', name: 'Child #10' }],
id: 'child-11',
name: 'Child #11',
},
],
},
{
name: 'Root #3',
id: 'root-3',
children: [
{
children: [
{ id: 'child-12', name: 'Child #12' },
{ id: 'child-13', name: 'Child #13' },
],
id: 'child-14',
name: 'Child #14',
},
{
children: [{ id: 'child-15', name: 'Child #15' }],
id: 'child-16',
name: 'Child #16',
},
],
},
{
name: 'Root #4',
id: 'root-4',
children: [
{
children: [
{ id: 'child-17', name: 'Child #17' },
{ id: 'child-18', name: 'Child #18' },
],
id: 'child-19',
name: 'Child #19',
},
{
children: [{ id: 'child-20', name: 'Child #20' }],
id: 'child-21',
name: 'Child #21',
},
],
},
{
name: 'Root #5',
id: 'root-5',
children: [
{
children: [
{ id: 'child-22', name: 'Child #22' },
{ id: 'child-23', name: 'Child #23' },
],
id: 'child-24',
name: 'Child #24',
},
{
children: [{ id: 'child-25', name: 'Child #25' }],
id: 'child-26',
name: 'Child #26',
},
],
},
],
},
{
name: 'Section 2',
id: 'section-2',
isRoot: true,
children: [
{
name: 'Root #6',
id: 'root-6',
children: [
{
children: [
{ id: 'child-27', name: 'Child #27' },
{ id: 'child-28', name: 'Child #28' },
],
id: 'child-29',
name: 'Child #29',
},
{
children: [{ id: 'child-30', name: 'Child #30' }],
id: 'child-31',
name: 'Child #31',
},
],
},
{
name: 'Root #7',
id: 'root-7',
children: [
{
children: [
{ id: 'child-32', name: 'Child #32' },
{ id: 'child-33', name: 'Child #33' },
],
id: 'child-34',
name: 'Child #34',
},
{
children: [{ id: 'child-35', name: 'Child #35' }],
id: 'child-36',
name: 'Child #36',
},
],
},
{
name: 'Root #8',
id: 'root-8',
children: [
{
children: [
{ id: 'child-37', name: 'Child #37' },
{ id: 'child-38', name: 'Child #38' },
],
id: 'child-39',
name: 'Child #39',
},
{
children: [{ id: 'child-40', name: 'Child #40' }],
id: 'child-41',
name: 'Child #41',
},
],
},
{
name: 'Root #9',
id: 'root-9',
children: [
{
children: [
{ id: 'child-42', name: 'Child #42' },
{ id: 'child-43', name: 'Child #43' },
],
id: 'child-44',
name: 'Child #44',
},
{
children: [{ id: 'child-45', name: 'Child #45' }],
id: 'child-46',
name: 'Child #46',
},
],
},
{
name: 'Root #10',
id: 'root-10',
children: [
{
children: [
{ id: 'child-47', name: 'Child #47' },
{ id: 'child-48', name: 'Child #48' },
],
id: 'child-49',
name: 'Child #49',
},
{
children: [{ id: 'child-50', name: 'Child #50' }],
id: 'child-51',
name: 'Child #51',
},
],
},
{
name: 'Root #11',
id: 'root-11',
children: [
{
children: [
{ id: 'child-52', name: 'Child #52' },
{ id: 'child-53', name: 'Child #53' },
],
id: 'child-54',
name: 'Child #54',
},
{
children: [{ id: 'child-55', name: 'Child #55' }],
id: 'child-56',
name: 'Child #56',
},
],
},
{
name: 'Root #12',
id: 'root-12',
children: [
{
children: [
{ id: 'child-57', name: 'Child #57' },
{ id: 'child-58', name: 'Child #58' },
],
id: 'child-59',
name: 'Child #59',
},
{
children: [{ id: 'child-60', name: 'Child #60' }],
id: 'child-61',
name: 'Child #61',
},
],
},
{
name: 'Root #13',
id: 'root-13',
children: [
{
children: [
{ id: 'child-62', name: 'Child #62' },
{ id: 'child-63', name: 'Child #63' },
],
id: 'child-64',
name: 'Child #64',
},
{
children: [{ id: 'child-65', name: 'Child #65' }],
id: 'child-66',
name: 'Child #66',
},
],
},
],
},
{
name: 'Root #14',
id: 'root-14',
children: [
{
children: [
{ id: 'child-67', name: 'Child #67' },
{ id: 'child-68', name: 'Child #68' },
],
id: 'child-69',
name: 'Child #69',
},
{
children: [{ id: 'child-70', name: 'Child #70' }],
id: 'child-71',
name: 'Child #71',
},
],
},
{
name: 'Root #15',
id: 'root-15',
children: [
{
children: [
{ id: 'child-72', name: 'Child #72' },
{ id: 'child-73', name: 'Child #73' },
],
id: 'child-74',
name: 'Child #74',
},
{
children: [{ id: 'child-75', name: 'Child #75' }],
id: 'child-76',
name: 'Child #76',
},
],
},
{
name: 'Root #16',
id: 'root-16',
children: [
{
children: [
{ id: 'child-77', name: 'Child #77' },
{ id: 'child-78', name: 'Child #78' },
],
id: 'child-79',
name: 'Child #79',
},
{
children: [{ id: 'child-80', name: 'Child #80' }],
id: 'child-81',
name: 'Child #81',
},
],
},
{
name: 'Root #17',
id: 'root-17',
children: [
{
children: [
{ id: 'child-82', name: 'Child #82' },
{ id: 'child-83', name: 'Child #83' },
],
id: 'child-84',
name: 'Child #84',
},
{
children: [{ id: 'child-85', name: 'Child #85' }],
id: 'child-86',
name: 'Child #86',
},
],
},
{
name: 'Root #18',
id: 'root-18',
children: [
{
children: [
{ id: 'child-87', name: 'Child #87' },
{ id: 'child-88', name: 'Child #88' },
],
id: 'child-89',
name: 'Child #89',
},
{
children: [{ id: 'child-90', name: 'Child #90' }],
id: 'child-91',
name: 'Child #91',
},
],
},
{
name: 'Root #19',
id: 'root-19',
children: [
{
children: [
{ id: 'child-92', name: 'Child #92' },
{ id: 'child-93', name: 'Child #93' },
],
id: 'child-94',
name: 'Child #94',
},
{
children: [{ id: 'child-95', name: 'Child #95' }],
id: 'child-96',
name: 'Child #96',
},
],
},
{
name: 'Root #20',
id: 'root-20',
children: [
{
children: [
{ id: 'child-97', name: 'Child #97' },
{ id: 'child-98', name: 'Child #98' },
],
id: 'child-99',
name: 'Child #99',
},
{
children: [{ id: 'child-100', name: 'Child #100' }],
id: 'child-101',
name: 'Child #101',
},
],
},
{
name: 'Root #21',
id: 'root-21',
children: [
{
children: [
{ id: 'child-102', name: 'Child #102' },
{ id: 'child-103', name: 'Child #103' },
],
id: 'child-104',
name: 'Child #104',
},
{
children: [{ id: 'child-105', name: 'Child #105' }],
id: 'child-106',
name: 'Child #106',
},
],
},
{
name: 'Root #200',
id: 'root-22000',
},
];
export const Default = () => {
const [searchText, setSearchText] = React.useState<string>('');
const [multiSelect, setMultiSelect] = React.useState<boolean>(true);
const [selectedItems, setSelectedItems] = React.useState<Node[]>([]);
const onSelectItems = (items: Node[]) => {
setSelectedItems(items.filter(item => !item.isRoot));
};
return (
<DragAndDropContextProvider>
<Column noMargin expand>
<ResponsiveLineStackLayout expand>
<Line expand noMargin>
<TextField
fullWidth
type="text"
value={searchText}
onChange={(e, text) => {
setSearchText(text);
}}
hintText={'Filter'}
/>
</Line>
<Line noMargin>
<Toggle
label={<Text>Allow multi selection</Text>}
labelPosition="right"
toggled={multiSelect}
onToggle={() => setMultiSelect(!multiSelect)}
/>
</Line>
</ResponsiveLineStackLayout>
<FixedHeightFlexContainer height={400}>
<AutoSizer>
{({ height, width }) => (
<Line expand>
<Column expand noMargin>
<TreeView
multiSelect={multiSelect}
height={height}
width={width}
items={nodes}
searchText={searchText}
getItemId={node => node.id}
getItemName={node => node.name}
onEditItem={action('Edit item')}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
onRenameItem={action('Rename item')}
getItemThumbnail={node =>
node.children
? null
: sample([
'res/unknown32.png',
'res/view24.png',
'res/bug24.png',
'res/save_all24.png',
])
}
// $FlowIgnore
getItemChildren={node => node.children}
buildMenuTemplate={() => [{ label: 'salut' }]}
onMoveSelectionToItem={action('Drop selection on item')}
canMoveSelectionToItem={() => Math.random() > 0.2}
reactDndType="demo"
/>
</Column>
</Line>
)}
</AutoSizer>
</FixedHeightFlexContainer>
</Column>
</DragAndDropContextProvider>
);
};