Skip to content

Commit f9998ee

Browse files
authored
Fix multi-selection interaction bugs in the Objects panel (follow-up to #8959) (#9002)
Don't show in changelog
1 parent e7b4a78 commit f9998ee

14 files changed

Lines changed: 795 additions & 190 deletions

newIDE/app/src/ObjectsList/EnumerateObjectFolderOrObject.js

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,10 @@ const itemMatchesSearchText = (
166166

167167
/**
168168
* Same case-insensitive substring match as TreeView's search filter.
169-
* Folders that only appear as ancestors of a match, or that still contain a
170-
* non-matching descendant, are not selectable: bulk ops would otherwise act
171-
* on hidden children.
169+
* Used by "Select all" while searching: folders that only appear as ancestors
170+
* of a match, or that still contain a non-matching descendant, are skipped so
171+
* that this implicit selection cannot act on hidden children. Explicitly
172+
* clicked rows are never filtered: a visible row is always selectable.
172173
*/
173174
export const isSelectableWhileSearching = (
174175
objectFolderOrObject: gdObjectFolderOrObject,
@@ -203,25 +204,28 @@ export const enumerateAllChildrenInFolderMatchingSearch = (
203204
};
204205

205206
/**
206-
* After a Ctrl+click that removes a folder from the selection, also drop
207+
* After a Ctrl+click that explicitly deselects a folder, also drop its
207208
* descendants that Select All (or a previous range) had added. Otherwise
208209
* nested objects stay selected inside a collapsed folder.
210+
*
211+
* `removedObjectFolderOrObjects` must be the items explicitly toggled off by
212+
* the gesture (as reported by TreeView), NOT the difference between the
213+
* previous and next selections: a plain click on the child of a selected
214+
* folder also "removes" the folder from the selection, but the clicked child
215+
* must obviously stay selected.
209216
*/
210217
export const dropDescendantsOfRemovedFolders = (
211-
previous: Array<ObjectFolderOrObjectWithContext>,
218+
removedObjectFolderOrObjects: Array<gdObjectFolderOrObject>,
212219
next: Array<ObjectFolderOrObjectWithContext>
213220
): Array<ObjectFolderOrObjectWithContext> => {
214-
const nextPtrs = new Set(next.map(item => item.objectFolderOrObject.ptr));
215-
const removedFolders = previous.filter(
216-
item =>
217-
item.objectFolderOrObject.isFolder() &&
218-
!nextPtrs.has(item.objectFolderOrObject.ptr)
221+
const removedFolders = removedObjectFolderOrObjects.filter(
222+
objectFolderOrObject => objectFolderOrObject.isFolder()
219223
);
220224
if (removedFolders.length === 0) return next;
221225
return next.filter(
222226
item =>
223227
!removedFolders.some(folder =>
224-
item.objectFolderOrObject.isADescendantOf(folder.objectFolderOrObject)
228+
item.objectFolderOrObject.isADescendantOf(folder)
225229
)
226230
);
227231
};

newIDE/app/src/ObjectsList/ObjectFolderOrObjectsClipboard.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
import { t } from '@lingui/macro';
1515
import { type I18n as I18nType } from '@lingui/core';
1616

17+
const gd: libGDevelop = global.gd;
18+
1719
export const OBJECT_FOLDER_OR_OBJECTS_CLIPBOARD_KIND = 'ObjectFolderOrObjects';
1820

1921
// Kept for backward compatibility: read (but never written anymore) so that
@@ -317,3 +319,70 @@ export const pasteObjectFolderOrObjectsFromClipboard = ({
317319

318320
return { createdObjects, topLevelObjectFolderOrObjects };
319321
};
322+
323+
/**
324+
* Paste the content of the clipboard at the given position and run the whole
325+
* notification sequence shared by every paste entry point of the Objects
326+
* panel: mark the project as modified, fire the object hooks (only when
327+
* actual objects were created - pasting empty folders produces top-level
328+
* items but no objects), and select the pasted items.
329+
* Returns true when something was pasted.
330+
*/
331+
export const pasteObjectFolderOrObjectsAndNotify = ({
332+
project,
333+
globalObjectsContainer,
334+
objectsContainer,
335+
global,
336+
destinationFolder,
337+
positionInFolder,
338+
onObjectModified,
339+
onObjectPasted,
340+
onObjectCreated,
341+
selectObjectFolderOrObjectsWithContext,
342+
}: {|
343+
project: gdProject,
344+
globalObjectsContainer: gdObjectsContainer | null,
345+
objectsContainer: gdObjectsContainer,
346+
global: boolean,
347+
destinationFolder: gdObjectFolderOrObject,
348+
positionInFolder: number,
349+
onObjectModified: (shouldForceUpdateList: boolean) => void,
350+
onObjectPasted: ?(object: gdObject) => void,
351+
onObjectCreated: (
352+
objects: Array<gdObject>,
353+
isTheFirstOfItsTypeInProject: boolean
354+
) => void,
355+
selectObjectFolderOrObjectsWithContext: (
356+
items: Array<ObjectFolderOrObjectWithContext>
357+
) => void,
358+
|}): boolean => {
359+
const isTheFirstOfItsTypeInProject = getObjectFolderOrObjectsClipboardObjectTypes().some(
360+
objectType => !gd.UsedObjectTypeFinder.scanProject(project, objectType)
361+
);
362+
363+
const pastedContent = pasteObjectFolderOrObjectsFromClipboard({
364+
project,
365+
globalObjectsContainer,
366+
objectsContainer,
367+
global,
368+
destinationFolder,
369+
positionInFolder,
370+
});
371+
if (!pastedContent) return false;
372+
const { createdObjects, topLevelObjectFolderOrObjects } = pastedContent;
373+
if (topLevelObjectFolderOrObjects.length === 0) return false;
374+
375+
// onObjectModified(true) already calls forceUpdateList internally.
376+
onObjectModified(true);
377+
if (createdObjects.length > 0) {
378+
if (onObjectPasted) onObjectPasted(createdObjects[0]);
379+
onObjectCreated(createdObjects, isTheFirstOfItsTypeInProject);
380+
}
381+
selectObjectFolderOrObjectsWithContext(
382+
topLevelObjectFolderOrObjects.map(objectFolderOrObject => ({
383+
objectFolderOrObject,
384+
global,
385+
}))
386+
);
387+
return true;
388+
};

newIDE/app/src/ObjectsList/ObjectFolderOrObjectsClipboard.spec.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,20 +175,30 @@ describe('ObjectFolderOrObjectsClipboard', () => {
175175
const objectInFolder = subFolder.getChildAt(0);
176176
const sibling = rootFolder.getChildAt(0);
177177

178-
const previous = [
179-
{ global: false, objectFolderOrObject: subFolder },
180-
{ global: false, objectFolderOrObject: objectInFolder },
181-
{ global: false, objectFolderOrObject: sibling },
182-
];
183178
const nextAfterDeselectingFolder = [
184179
{ global: false, objectFolderOrObject: objectInFolder },
185180
{ global: false, objectFolderOrObject: sibling },
186181
];
187182

183+
// Ctrl+click deselected the folder: its still-selected child is dropped.
188184
expect(
189-
dropDescendantsOfRemovedFolders(previous, nextAfterDeselectingFolder)
185+
dropDescendantsOfRemovedFolders([subFolder], nextAfterDeselectingFolder)
190186
).toEqual([{ global: false, objectFolderOrObject: sibling }]);
191187

188+
// A plain click on the child of a selected folder is not a toggle-off
189+
// gesture (no removed items): the clicked child stays selected.
190+
expect(
191+
dropDescendantsOfRemovedFolders(
192+
[],
193+
[{ global: false, objectFolderOrObject: objectInFolder }]
194+
)
195+
).toEqual([{ global: false, objectFolderOrObject: objectInFolder }]);
196+
197+
// Deselecting a plain object never drops anything.
198+
expect(
199+
dropDescendantsOfRemovedFolders([sibling], nextAfterDeselectingFolder)
200+
).toEqual(nextAfterDeselectingFolder);
201+
192202
project.delete();
193203
});
194204

newIDE/app/src/ObjectsList/ObjectFolderTreeViewItemContent.js

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ import {
1616
writeObjectFolderOrObjectsToClipboard,
1717
hasObjectFolderOrObjectsInClipboard,
1818
getObjectFolderOrObjectsClipboardSummaryName,
19-
getObjectFolderOrObjectsClipboardObjectTypes,
20-
pasteObjectFolderOrObjectsFromClipboard,
19+
pasteObjectFolderOrObjectsAndNotify,
2120
} from './ObjectFolderOrObjectsClipboard';
2221
import { duplicateObjectFolderOrObjects } from './ObjectFolderOrObjectsDuplicate';
2322
import { renderQuickCustomizationMenuItems } from '../QuickCustomization/QuickCustomizationMenuItems';
@@ -458,37 +457,22 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent {
458457
selectObjectFolderOrObjectsWithContext,
459458
} = this.props;
460459

461-
const isTheFirstOfItsTypeInProject = getObjectFolderOrObjectsClipboardObjectTypes().some(
462-
objectType => !gd.UsedObjectTypeFinder.scanProject(project, objectType)
463-
);
464-
465-
const pastedContent = pasteObjectFolderOrObjectsFromClipboard({
460+
const pasted = pasteObjectFolderOrObjectsAndNotify({
466461
project,
467462
globalObjectsContainer,
468463
objectsContainer,
469464
global: this._isGlobal,
470465
destinationFolder: this.objectFolder,
471466
positionInFolder: this.objectFolder.getChildrenCount(),
467+
onObjectModified,
468+
onObjectPasted,
469+
onObjectCreated,
470+
selectObjectFolderOrObjectsWithContext,
472471
});
473-
if (!pastedContent) return;
474-
const { createdObjects, topLevelObjectFolderOrObjects } = pastedContent;
475-
if (topLevelObjectFolderOrObjects.length === 0) return;
476-
477-
if (createdObjects.length > 0) {
478-
onObjectCreated(createdObjects, isTheFirstOfItsTypeInProject);
479-
}
480-
onObjectModified(true);
481-
if (onObjectPasted && createdObjects.length > 0)
482-
onObjectPasted(createdObjects[0]);
472+
if (!pasted) return;
483473
expandFolders([
484474
{ objectFolderOrObject: this.objectFolder, global: this._isGlobal },
485475
]);
486-
selectObjectFolderOrObjectsWithContext(
487-
topLevelObjectFolderOrObjects.map(pastedObjectFolderOrObject => ({
488-
objectFolderOrObject: pastedObjectFolderOrObject,
489-
global: this._isGlobal,
490-
}))
491-
);
492476
}
493477

494478
duplicate(): void {

newIDE/app/src/ObjectsList/ObjectTreeViewItemContent.js

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ import {
2121
writeObjectFolderOrObjectsToClipboard,
2222
hasObjectFolderOrObjectsInClipboard,
2323
getObjectFolderOrObjectsClipboardSummaryName,
24-
getObjectFolderOrObjectsClipboardObjectTypes,
25-
pasteObjectFolderOrObjectsFromClipboard,
24+
pasteObjectFolderOrObjectsAndNotify,
2625
} from './ObjectFolderOrObjectsClipboard';
2726
import { type ObjectEditorTab } from '../ObjectEditor/ObjectEditorDialog';
2827
import type { ObjectWithContext } from '../ObjectsList/EnumerateObjects';
@@ -293,9 +292,11 @@ export class ObjectTreeViewItemContent implements TreeViewItemContent {
293292

294293
onClick(): void {
295294
// Selection itself is entirely handled by TreeView (single click, Ctrl/Cmd
296-
// toggle, Shift range) through `onSelectItems`. Do not select here, as it
297-
// would override that selection (in particular, it would collapse any
298-
// multi-selection back to this single item).
295+
// toggle, Shift range) through `onSelectItems` - including a plain click
296+
// on the already-selected row, which is re-notified so the parent can
297+
// bring the selection back to the front of the properties panel. Do not
298+
// select here, as it would override that selection (in particular, it
299+
// would collapse any multi-selection back to this single item).
299300
}
300301

301302
rename(newName: string): void {
@@ -647,45 +648,32 @@ export class ObjectTreeViewItemContent implements TreeViewItemContent {
647648
const objectFolderOrObject = this._getAliveObjectFolderOrObject();
648649
if (!objectFolderOrObject) return;
649650

650-
const { project, globalObjectsContainer, objectsContainer } = this.props;
651651
const parentFolder = exceptionallyGuardAgainstDeadObject(
652652
objectFolderOrObject.getParent()
653653
);
654654
if (!parentFolder) return;
655655

656-
const isTheFirstOfItsTypeInProject = getObjectFolderOrObjectsClipboardObjectTypes().some(
657-
objectType => !gd.UsedObjectTypeFinder.scanProject(project, objectType)
658-
);
659-
660-
const pastedContent = pasteObjectFolderOrObjectsFromClipboard({
656+
const {
657+
project,
658+
globalObjectsContainer,
659+
objectsContainer,
660+
onObjectPasted,
661+
onObjectModified,
662+
onObjectCreated,
663+
selectObjectFolderOrObjectsWithContext,
664+
} = this.props;
665+
pasteObjectFolderOrObjectsAndNotify({
661666
project,
662667
globalObjectsContainer,
663668
objectsContainer,
664669
global: this._isGlobal,
665670
destinationFolder: parentFolder,
666671
positionInFolder: parentFolder.getChildPosition(objectFolderOrObject) + 1,
667-
});
668-
if (!pastedContent) return;
669-
const { createdObjects, topLevelObjectFolderOrObjects } = pastedContent;
670-
if (topLevelObjectFolderOrObjects.length === 0) return;
671-
672-
const {
673-
onObjectPasted,
674672
onObjectModified,
673+
onObjectPasted,
675674
onObjectCreated,
676675
selectObjectFolderOrObjectsWithContext,
677-
} = this.props;
678-
onObjectModified(true);
679-
if (createdObjects.length > 0) {
680-
if (onObjectPasted) onObjectPasted(createdObjects[0]);
681-
onObjectCreated(createdObjects, isTheFirstOfItsTypeInProject);
682-
}
683-
selectObjectFolderOrObjectsWithContext(
684-
topLevelObjectFolderOrObjects.map(pastedObjectFolderOrObject => ({
685-
objectFolderOrObject: pastedObjectFolderOrObject,
686-
global: this._isGlobal,
687-
}))
688-
);
676+
});
689677
}
690678

691679
duplicate(): void {

newIDE/app/src/ObjectsList/UseBulkObjectOperations.js

Lines changed: 20 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ import {
1616
serializeObjectFolderOrObjectsForClipboard,
1717
writeObjectFolderOrObjectsToClipboard,
1818
hasObjectFolderOrObjectsInClipboard,
19-
getObjectFolderOrObjectsClipboardObjectTypes,
20-
pasteObjectFolderOrObjectsFromClipboard,
19+
pasteObjectFolderOrObjectsAndNotify,
2120
getPasteMenuLabel,
2221
getUniqueFolderName,
2322
} from './ObjectFolderOrObjectsClipboard';
@@ -250,36 +249,18 @@ function useBulkObjectOperations({
250249
? objectFolderOrObject.getChildrenCount()
251250
: destinationFolder.getChildPosition(objectFolderOrObject) + 1;
252251

253-
const isTheFirstOfItsTypeInProject = getObjectFolderOrObjectsClipboardObjectTypes().some(
254-
objectType => !gd.UsedObjectTypeFinder.scanProject(project, objectType)
255-
);
256-
257-
const pastedContent = pasteObjectFolderOrObjectsFromClipboard({
252+
pasteObjectFolderOrObjectsAndNotify({
258253
project,
259254
globalObjectsContainer,
260255
objectsContainer,
261256
global,
262257
destinationFolder,
263258
positionInFolder,
259+
onObjectModified,
260+
onObjectPasted,
261+
onObjectCreated,
262+
selectObjectFolderOrObjectsWithContext,
264263
});
265-
if (!pastedContent) return;
266-
const { createdObjects, topLevelObjectFolderOrObjects } = pastedContent;
267-
if (topLevelObjectFolderOrObjects.length === 0) return;
268-
269-
// onObjectModified(true) already calls forceUpdateList internally.
270-
onObjectModified(true);
271-
// Only fire object hooks when actual objects were created; pasting
272-
// empty folders produces topLevelObjectFolderOrObjects but no objects.
273-
if (createdObjects.length > 0) {
274-
if (onObjectPasted) onObjectPasted(createdObjects[0]);
275-
onObjectCreated(createdObjects, isTheFirstOfItsTypeInProject);
276-
}
277-
selectObjectFolderOrObjectsWithContext(
278-
topLevelObjectFolderOrObjects.map(pastedObjectFolderOrObject => ({
279-
objectFolderOrObject: pastedObjectFolderOrObject,
280-
global,
281-
}))
282-
);
283264
},
284265
[
285266
isListLocked,
@@ -306,25 +287,31 @@ function useBulkObjectOperations({
306287
);
307288
if (candidates.length === 0) return;
308289

309-
// Filter out items that cannot be promoted, showing a warning per
310-
// item rather than aborting the whole operation.
290+
// Filter out items that cannot be promoted, then show a single warning
291+
// listing all the name conflicts rather than one blocking dialog each.
292+
const conflictingObjectNames = [];
311293
const objectItems = candidates.filter(item => {
312294
const objectName = item.objectFolderOrObject.getObject().getName();
313295
if (!objectsContainer.hasObjectNamed(objectName)) return false;
314296
if (globalObjectsContainer.hasObjectNamed(objectName)) {
315-
showWarningBox(
316-
i18n._(
317-
t`A global object with this name already exists. Please change the object name before setting it as a global object`
318-
),
319-
{ delayToNextTick: true }
320-
);
297+
conflictingObjectNames.push(objectName);
321298
return false;
322299
}
323300
if (beforeSetAsGlobalObject && !beforeSetAsGlobalObject(objectName)) {
324301
return false;
325302
}
326303
return true;
327304
});
305+
if (conflictingObjectNames.length > 0) {
306+
showWarningBox(
307+
i18n._(
308+
t`Global objects with these names already exist: ${conflictingObjectNames.join(
309+
', '
310+
)}. Please rename the objects before setting them as global objects.`
311+
),
312+
{ delayToNextTick: true }
313+
);
314+
}
328315
if (objectItems.length === 0) return;
329316

330317
const answer = Window.showConfirmDialog(

0 commit comments

Comments
 (0)