From c587a5d93c120521fef34a2d7ef32b644b42bd05 Mon Sep 17 00:00:00 2001 From: robypown Date: Sun, 14 Jun 2026 14:28:55 +0200 Subject: [PATCH 1/2] Add folder color feature --- daInviare/ObjectFolderTreeViewItemContent.js | 551 +++++ daInviare/TreeViewRow.js | 636 ++++++ daInviare/index.js | 1934 ++++++++++++++++++ 3 files changed, 3121 insertions(+) create mode 100644 daInviare/ObjectFolderTreeViewItemContent.js create mode 100644 daInviare/TreeViewRow.js create mode 100644 daInviare/index.js diff --git a/daInviare/ObjectFolderTreeViewItemContent.js b/daInviare/ObjectFolderTreeViewItemContent.js new file mode 100644 index 000000000000..94da42039bd9 --- /dev/null +++ b/daInviare/ObjectFolderTreeViewItemContent.js @@ -0,0 +1,551 @@ +// @flow +import { type I18n as I18nType } from '@lingui/core'; +import { t } from '@lingui/macro'; +import * as React from 'react'; +import Clipboard from '../Utils/Clipboard'; +import { SafeExtractor } from '../Utils/SafeExtractor'; +import { type TreeViewItemContent } from '.'; +import { + enumerateFoldersInContainer, + enumerateFoldersInFolder, + enumerateObjectsInFolder, + type ObjectFolderOrObjectWithContext, +} from './EnumerateObjectFolderOrObject'; +import { + addSerializedObjectToObjectsContainer, + OBJECT_CLIPBOARD_KIND, +} from './ObjectTreeViewItemContent'; +import { renderQuickCustomizationMenuItems } from '../QuickCustomization/QuickCustomizationMenuItems'; +import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow'; +import type { ObjectWithContext } from '../ObjectsList/EnumerateObjects'; +import { type HTMLDataset } from '../Utils/HTMLDataset'; +import { exceptionallyGuardAgainstDeadObject } from '../Utils/IsNullPtr'; + +const gd: libGDevelop = global.gd; + +// Builds a stable path key like "Root/Enemies/Boss" by walking up the folder tree. +// This key survives page reloads, unlike objectFolder.ptr which is a memory address. +const getFolderStableKey = (objectFolder, isGlobal) => { + const parts = []; + let current = objectFolder; + try { + while (current && !current.isRoot()) { + parts.unshift(current.getFolderName()); + current = current.getParent(); + } + } catch (e) { + // fallback: if walking fails, use the folder name only + parts.unshift(objectFolder.getFolderName()); + } + const scope = isGlobal ? 'global' : 'scene'; + return scope + '/' + parts.join('/'); +}; + +export const folderColors = { + get(objectFolder, isGlobal) { + try { + const key = getFolderStableKey(objectFolder, isGlobal); + const saved = localStorage.getItem('gdevelop_custom_folder_colors'); + const colors = saved ? JSON.parse(saved) : {}; + return colors[key] || null; + } catch (e) { + return null; + } + }, + set(objectFolder, isGlobal, color) { + try { + const key = getFolderStableKey(objectFolder, isGlobal); + const saved = localStorage.getItem('gdevelop_custom_folder_colors'); + const colors = saved ? JSON.parse(saved) : {}; + colors[key] = color; + localStorage.setItem('gdevelop_custom_folder_colors', JSON.stringify(colors)); + } catch (e) { + console.error("Error saving folder color:", e); + } + }, + remove(objectFolder, isGlobal) { + try { + const key = getFolderStableKey(objectFolder, isGlobal); + const saved = localStorage.getItem('gdevelop_custom_folder_colors'); + if (saved) { + const colors = JSON.parse(saved); + delete colors[key]; + localStorage.setItem('gdevelop_custom_folder_colors', JSON.stringify(colors)); + } + } catch (e) { + console.error("Error removing folder color:", e); + } + } +}; + +export const expandAllSubfolders = ( + objectFolder: gdObjectFolderOrObject, + isGlobal: boolean, + expandFolders: ( + objectFolderOrObjectWithContexts: Array + ) => void +) => { + const subFolders = enumerateFoldersInFolder(objectFolder).map( + folderAndPath => folderAndPath.folder + ); + expandFolders( + [objectFolder, ...subFolders].map(folder => ({ + objectFolderOrObject: folder, + global: isGlobal, + })) + ); +}; + +export type ObjectFolderTreeViewItemCallbacks = {| + onObjectPasted?: gdObject => void, + onRenameObjectFolderOrObjectWithContextFinish: ( + objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext, + newName: string, + cb: (boolean) => void + ) => void, + onDeleteObjects: ( + objectWithContext: ObjectWithContext[], + cb: (boolean) => void + ) => void, +|}; + +export type ObjectFolderTreeViewItemProps = {| + ...ObjectFolderTreeViewItemCallbacks, + project: gdProject, + globalObjectsContainer: gdObjectsContainer | null, + objectsContainer: gdObjectsContainer, + editName: (itemId: string) => void, + onObjectModified: (shouldForceUpdateList: boolean) => void, + onObjectCreated: ( + objects: Array, + isTheFirstOfItsTypeInProject: boolean + ) => void, + expandFolders: ( + objectFolderOrObjectWithContexts: Array + ) => void, + addFolder: (items: Array) => void, + onAddNewObject: (item: ObjectFolderOrObjectWithContext | null) => void, + onMovedObjectFolderOrObjectToAnotherFolderInSameContainer: ( + objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext + ) => void, + showDeleteConfirmation: (options: any) => Promise, + selectObjectFolderOrObjectWithContext: ( + objectFolderOrObjectWithContext: ?ObjectFolderOrObjectWithContext + ) => void, + forceUpdateList: () => void, + forceUpdate: () => void, + isListLocked: boolean, + openColorPicker: (objectFolder: gdObjectFolderOrObject, isGlobal: boolean) => void, +|}; + +export const getObjectFolderTreeViewItemId = ( + objectFolder: gdObjectFolderOrObject +): string => { + return `object-folder-${objectFolder.ptr}`; +}; + +export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { + objectFolder: gdObjectFolderOrObject; + _isGlobal: boolean; + props: ObjectFolderTreeViewItemProps; + + constructor( + objectFolder: gdObjectFolderOrObject, + isGlobal: boolean, + props: ObjectFolderTreeViewItemProps + ) { + this.objectFolder = objectFolder; + this._isGlobal = isGlobal; + this.props = props; + } + + getObjectFolderOrObject(): gdObjectFolderOrObject | null { + return this.objectFolder; + } + + isDescendantOf(treeViewItemContent: TreeViewItemContent): boolean { + const objectFolderOrObject = treeViewItemContent.getObjectFolderOrObject(); + return ( + !!objectFolderOrObject && + this.objectFolder.isADescendantOf(objectFolderOrObject) + ); + } + + isSibling(treeViewItemContent: TreeViewItemContent): boolean { + const objectFolderOrObject = treeViewItemContent.getObjectFolderOrObject(); + return ( + !!objectFolderOrObject && + this.objectFolder.getParent() === objectFolderOrObject.getParent() + ); + } + + getIndex(): number { + return this.objectFolder.getParent().getChildPosition(this.objectFolder); + } + + isGlobal(): boolean { + return this._isGlobal; + } + + is3D(): boolean { + return false; + } + +_getFolderName(): string { + if (!exceptionallyGuardAgainstDeadObject(this.objectFolder)) return ''; + return this.objectFolder.getFolderName(); + } + + getName(): string { + if (!exceptionallyGuardAgainstDeadObject(this.objectFolder)) return ''; + return this.objectFolder.getFolderName(); + } + + getId(): ?string { + return getObjectFolderTreeViewItemId(this.objectFolder); + } + + getHtmlId(index: number): ?string { + return null; + } + + getDataSet(): ?HTMLDataset { + if (!exceptionallyGuardAgainstDeadObject(this.objectFolder)) return null; + const color = folderColors.get(this.objectFolder, this._isGlobal); + return { + folderName: this.objectFolder.getFolderName(), + global: this._isGlobal.toString(), + ...(color ? { folderColor: color } : {}), + }; + } + getThumbnail(): ?string { + const color = folderColors.get(this.objectFolder, this._isGlobal); + if (color) { + return 'NO_ICON'; + } + return 'FOLDER'; + } + + onClick(): void {} + + rename(newName: string): void { + const safeNewName = newName.replaceAll('/', '-'); + if (this._getFolderName() === safeNewName) { + return; + } + // Save the current color before renaming (the key will change with the new name) + const currentColor = folderColors.get(this.objectFolder, this._isGlobal); + this.props.onRenameObjectFolderOrObjectWithContextFinish( + { objectFolderOrObject: this.objectFolder, global: this._isGlobal }, + safeNewName, + doRename => { + if (!doRename) return; + // After rename, the folder path key has changed: remove old key and save with new key + if (currentColor) { + folderColors.remove(this.objectFolder, this._isGlobal); + folderColors.set(this.objectFolder, this._isGlobal, currentColor); + } + this.props.onObjectModified(false); + } + ); + } + + edit(): void {} + + _openColorPicker(): void { + this.props.openColorPicker(this.objectFolder, this._isGlobal); + } + + getPasteLabel( + i18n: I18nType, + { + isGlobalObject, + isFolder, + }: {| isGlobalObject: boolean, isFolder: boolean |} + ): any { + let translation = t`Paste`; + if (Clipboard.has(OBJECT_CLIPBOARD_KIND)) { + const clipboardContent = Clipboard.get(OBJECT_CLIPBOARD_KIND); + const clipboardObjectName = + SafeExtractor.extractStringProperty(clipboardContent, 'name') || ''; + translation = isGlobalObject + ? t`Paste ${clipboardObjectName} as a Global Object inside folder` + : t`Paste ${clipboardObjectName} inside folder`; + } + return i18n._(translation); + } + + buildMenuTemplate(i18n: I18nType, index: number): any { + const { + globalObjectsContainer, + objectsContainer, + expandFolders, + addFolder, + onAddNewObject, + onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, + forceUpdate, + isListLocked, + } = this.props; + + const container = this._isGlobal + ? globalObjectsContainer + : objectsContainer; + + if (!container) { + return []; + } + + const folderAndPathsInContainer = enumerateFoldersInContainer(container); + folderAndPathsInContainer.unshift({ + path: i18n._(t`Root folder`), + folder: container.getRootFolder(), + }); + + const filteredFolderAndPathsInContainer = folderAndPathsInContainer.filter( + folderAndPath => + !folderAndPath.folder.isADescendantOf(this.objectFolder) && + folderAndPath.folder !== this.objectFolder + ); + + return [ + { + label: this.getPasteLabel(i18n, { + isGlobalObject: this._isGlobal, + isFolder: true, + }), + enabled: Clipboard.has(OBJECT_CLIPBOARD_KIND) && !isListLocked, + click: () => this.paste(), + }, + { + label: i18n._(t`Rename`), + click: () => this.props.editName(this.getId()), + accelerator: 'F2', + enabled: !isListLocked, + }, + { + label: i18n._(t`Change folder color`), + click: () => this._openColorPicker(), + enabled: !isListLocked, + }, + { + label: i18n._(t`Delete`), + click: () => this.delete(), + accelerator: 'Backspace', + enabled: !isListLocked, + }, + isListLocked + ? { + label: i18n._('Move to folder'), + enabled: false, + } + : { + label: i18n._('Move to folder'), + submenu: [ + ...filteredFolderAndPathsInContainer.map(({ folder, path }) => ({ + label: path, + enabled: folder !== this.objectFolder.getParent(), + click: () => { + if (folder === this.objectFolder.getParent()) return; + this.objectFolder + .getParent() + .moveObjectFolderOrObjectToAnotherFolder( + this.objectFolder, + folder, + 0 + ); + onMovedObjectFolderOrObjectToAnotherFolderInSameContainer({ + objectFolderOrObject: folder, + global: this._isGlobal, + }); + }, + })), + { type: 'separator' }, + { + label: i18n._(t`Create new folder...`), + click: () => + addFolder([ + { + objectFolderOrObject: this.objectFolder.getParent(), + global: this._isGlobal, + }, + ]), + }, + ], + }, + ...renderQuickCustomizationMenuItems({ + i18n, + visibility: this.objectFolder.getQuickCustomizationVisibility(), + onChangeVisibility: visibility => { + this.objectFolder.setQuickCustomizationVisibility(visibility); + forceUpdate(); + }, + }), + { type: 'separator' }, + { + label: i18n._(t`Add a new object`), + click: () => + onAddNewObject({ + objectFolderOrObject: this.objectFolder, + global: this._isGlobal, + }), + enabled: !isListLocked, + }, + { + label: i18n._(t`Add a new folder`), + click: () => + addFolder([ + { objectFolderOrObject: this.objectFolder, global: this._isGlobal }, + ]), + enabled: !isListLocked, + }, + { type: 'separator' }, + { + label: i18n._(t`Expand all sub folders`), + click: () => + expandAllSubfolders(this.objectFolder, this._isGlobal, expandFolders), + }, + ]; + } + + renderRightComponent(i18n: I18nType): ?React.Node { + return null; + } + + delete(): void { + this._delete(); + } + + async _delete(): Promise { + const { + globalObjectsContainer, + objectsContainer, + onObjectModified, + forceUpdateList, + showDeleteConfirmation, + onDeleteObjects, + selectObjectFolderOrObjectWithContext, + } = this.props; + + const objectsToDelete = enumerateObjectsInFolder(this.objectFolder); + + if (objectsToDelete.length === 0) { + selectObjectFolderOrObjectWithContext(null); + folderColors.remove(this.objectFolder, this._isGlobal); + this.objectFolder.getParent().removeFolderChild(this.objectFolder); + forceUpdateList(); + return; + } + + let message: MessageDescriptor; + let title: MessageDescriptor; + + if (objectsToDelete.length === 1) { + message = t`Are you sure you want to remove this folder and with it the object ${objectsToDelete[0].getName()}? This can't be undone.`; + title = t`Remove folder and object`; + } else { + message = t`Are you sure you want to remove this folder and all its content (objects ${objectsToDelete + .map(object => object.getName()) + .join(', ')})? This can't be undone.`; + title = t`Remove folder and objects`; + } + + const answer = await showDeleteConfirmation({ message, title }); + if (!answer) return; + + const objectsWithContext = objectsToDelete.map(object => ({ + object, + global: this._isGlobal, + })); + + selectObjectFolderOrObjectWithContext(null); + + const folderToDelete = this.objectFolder; + onDeleteObjects(objectsWithContext, doRemove => { + if (!doRemove) return; + const container = this._isGlobal + ? globalObjectsContainer + : objectsContainer; + if (container) { + objectsToDelete.forEach(object => { + container.removeObject(object.getName()); + }); + } + + folderColors.remove(folderToDelete, this._isGlobal); + folderToDelete.getParent().removeFolderChild(folderToDelete); + forceUpdateList(); + + onObjectModified(false); + }); + } + + copy(): void {} + cut(): void {} + + paste(): void { + if (!Clipboard.has(OBJECT_CLIPBOARD_KIND)) return; + + const clipboardContent = Clipboard.get(OBJECT_CLIPBOARD_KIND); + const serializedObject = SafeExtractor.extractObjectProperty( + clipboardContent, + 'object' + ); + const objectName = SafeExtractor.extractStringProperty( + clipboardContent, + 'name' + ); + const objectType = SafeExtractor.extractStringProperty( + clipboardContent, + 'type' + ); + + if (!objectName || !objectType || !serializedObject) return; + + const { + project, + globalObjectsContainer, + objectsContainer, + onObjectPasted, + expandFolders, + onObjectModified, + onObjectCreated, + } = this.props; + + const isTheFirstOfItsTypeInProject = !gd.UsedObjectTypeFinder.scanProject( + project, + objectType + ); + + const newObjectWithContext = addSerializedObjectToObjectsContainer({ + project, + globalObjectsContainer, + objectsContainer, + objectName, + positionObjectFolderOrObjectWithContext: { + objectFolderOrObject: this.objectFolder, + global: this._isGlobal, + }, + objectType, + serializedObject, + addInsideFolder: true, + }); + + onObjectCreated( + [newObjectWithContext.object], + isTheFirstOfItsTypeInProject + ); + + onObjectModified(false); + if (onObjectPasted) onObjectPasted(newObjectWithContext.object); + + expandFolders([ + { objectFolderOrObject: this.objectFolder, global: this._isGlobal }, + ]); + } + + duplicate(): void {} + + getRightButton(i18n: I18nType): any { + return null; + } +} \ No newline at end of file diff --git a/daInviare/TreeViewRow.js b/daInviare/TreeViewRow.js new file mode 100644 index 000000000000..04ad8f3e0bbe --- /dev/null +++ b/daInviare/TreeViewRow.js @@ -0,0 +1,636 @@ +// @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 ChevronArrowBottom from '../CustomSvgIcons/ChevronArrowBottom'; +import ChevronArrowRight from '../CustomSvgIcons/ChevronArrowRight'; +import Folder from '../CustomSvgIcons/Folder'; +import ListIcon from '../ListIcon'; +import useForceUpdate from '../../Utils/UseForceUpdate'; +import classes from './TreeView.module.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 { useDragDropManager } from 'react-dnd'; +import { dataObjectToProps } from '../../Utils/HTMLDataset'; +import { type DraggedItem } from '../DragAndDrop/DragSourceAndDropTarget'; +import classNames from 'classnames'; +import { TreeViewRightPrimaryButton } from './TreeViewRightPrimaryButton'; + +// $FlowFixMe[missing-local-annot] +const stopPropagation = e => e.stopPropagation(); + +const DELAY_BEFORE_OPENING_FOLDER_ON_DRAG_HOVER = 800; +const DELAY_BEFORE_OPENING_CONTEXT_MENU_ON_MOBILE = 1000; +export const TREE_VIEW_ROW_HEIGHT = 32; +const COLLAPSABLE_LINE_SIDE_DROP_ZONE_HEIGHT = 6; + +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(initialValue); + const inputRef = React.useRef(null); + + /** + * When mounting the component, focus and select content. + * We use setTimeout to ensure this runs after any deferred focus restoration + * from MUI Modal (used by the context menu on web), which runs in a useEffect + * cleanup. Without this, MUI's focus restoration steals focus from the input + * right after it mounts (introduced in React 18). + */ + React.useEffect(() => { + const id = setTimeout(() => { + const input = inputRef.current; + if (input) { + // We focus and select the text here, and not with autoFocus on the input, + // to avoid issues with focus restoration from MUI Modal (used by the context menu on web) + input.focus(); + input.select(); + } + }, 0); + return () => clearTimeout(id); + }, []); + + /** + * 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 ( +
+ { + setValue(e.currentTarget.value); + }} + onClick={stopPropagation} + onDoubleClick={stopPropagation} + onContextMenu={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); + } + }} + /> +
+ ); +}; + +// $FlowFixMe[missing-local-annot] +const memoized = memoizeOne((initialValue, callback) => callback()); + +type Props = {| + index: number, + style: any, + data: ItemData, + /** Used by react-window. */ + isScrolling?: boolean, +|}; + +// $FlowFixMe[missing-local-annot] +const TreeViewRow = (props: Props) => { + const { data, index, style } = props; + const { + flattenedData, + onOpen, + onClick, + onSelect, + onBlurField, + onEndRenaming, + renamedItemId, + onContextMenu, + canDrop, + onDrop, + onEditItem, + isMobile, + DragSourceAndDropTarget, + getItemHtmlId, + forceDefaultDraggingPreview, + shouldSelectUponContextMenuOpening, + } = data; + const node = flattenedData[index]; + const left = node.depth * 16; + const forceUpdate = useForceUpdate(); + const isStayingOverRef = React.useRef(false); + const openWhenOverTimeoutId = React.useRef(null); + const [whereToDrop, setWhereToDrop] = React.useState< + 'before' | 'after' | 'inside' + >('before'); + const containerRef = React.useRef(null); + const dragDropManager = useDragDropManager(); + const openContextMenu = React.useCallback( + // $FlowFixMe[missing-local-annot] + ({ clientX, clientY }) => { + // When the context menu opens it intercepts subsequent touch events, + // so the drag backend never receives touchend/touchcancel and the drag + // stays active indefinitely. End it explicitly before opening the menu. + if (dragDropManager.getMonitor().isDragging()) { + dragDropManager.getActions().endDrag(); + } + onContextMenu({ + index: index, + item: node.item, + x: clientX, + y: clientY, + }); + }, + [dragDropManager, onContextMenu, index, node.item] + ); + + const { contextMenuProps: longTouchForContextMenuProps } = useLongTouch( + openContextMenu, + { + delay: DELAY_BEFORE_OPENING_CONTEXT_MENU_ON_MOBILE, + } + ); + + const onClickItem = React.useCallback( + // $FlowFixMe[missing-local-annot] + event => { + if (!node || node.item.isPlaceholder) return; + if (node.item.isRoot) { + onOpen(node); + return; + } + onSelect({ node, exclusive: !(event.metaKey || event.ctrlKey) }); + onClick(node); + }, + [onClick, onSelect, node, onOpen] + ); + + const onDoubleClickItem = React.useCallback( + // $FlowFixMe[missing-local-annot] + e => { + if (!node || !node.hasChildren || node.disableCollapse) return; + onOpen(node); + }, + [node, onOpen] + ); + + const selectAndOpenContextMenu = React.useCallback( + (event: MouseEvent) => { + if (!node.item.isRoot) onClickItem(event); + openContextMenu(event); + }, + [node, onClickItem, openContextMenu] + ); + + const setIsStayingOver = React.useCallback( + (active: boolean, canDrop: boolean) => { + // Do not open folder if cannot drop into it. It's implied that if it + // cannot be dropped in the folder, it cannot be dropped in any subfolders + // of the folder. + if (!canDrop && active) return; + if (active !== isStayingOverRef.current) { + isStayingOverRef.current = active; + forceUpdate(); + } + }, + [forceUpdate] + ); + + /** + * Effect that opens the node if the user is dragging another node and stays + * over the node. + */ + React.useEffect( + () => { + if ( + isStayingOverRef.current && + !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; + }; + } + }, + // We want to have isStayingOverRef.current as dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + [onOpen, node, forceUpdate, isStayingOverRef.current] + ); + + const endRenaming = React.useCallback( + (newValue: string) => { + onEndRenaming(node.item, newValue); + }, + [onEndRenaming, node.item] + ); + + const getContainerYPosition = React.useCallback(() => { + if (containerRef.current) { + return containerRef.current.getBoundingClientRect().top; + } + }, []); + + const displayAsFolder = node.canHaveChildren; + + // Create an empty pixel image once to override the default drag preview of all items. + const emptyImage = new Image(); + emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; + + return ( +
+ { + if (!node.selected) onSelect({ node, exclusive: !node.selected }); + + if (forceDefaultDraggingPreview) { + return {}; + } + + // We return the item name and thumbnail to be used by the + // drag preview if this is not a folder. + // We can't use the item itself because it's not serializable. + if (typeof node.name === 'string' && !displayAsFolder) { + const draggedItem: DraggedItem = { + name: node.name, + thumbnail: node.thumbnailSrc || undefined, + is3D: + // $FlowFixMe[incompatible-type] + // $FlowFixMe[prop-missing] + !!node.item.content && + !!node.item.content.is3D && + // $FlowFixMe[incompatible-type] + node.item.content.is3D(), + }; + return draggedItem; + } + + // Otherwise, we let the default drag preview be displayed. + return {}; + }} + canDrag={() => + // Prevent dragging of root folder or placeholder. + !node.item.isRoot && + !node.item.isPlaceholder && + // 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, whereToDrop) : () => 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 <= + COLLAPSABLE_LINE_SIDE_DROP_ZONE_HEIGHT + ? 'before' + : y - containerYPosition <= + TREE_VIEW_ROW_HEIGHT + + COLLAPSABLE_LINE_SIDE_DROP_ZONE_HEIGHT + ? '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 <= + COLLAPSABLE_LINE_SIDE_DROP_ZONE_HEIGHT + ? 'before' + : 'inside' + ); + } + } else { + setWhereToDrop( + y - containerYPosition <= TREE_VIEW_ROW_HEIGHT / 2 + ? 'before' + : 'after' + ); + } + } + }} + > + {({ + connectDragSource, + connectDropTarget, + connectDragPreview, + isOver, + canDrop, + }) => { + setIsStayingOver(isOver, canDrop); + + const isRenaming = renamedItemId === node.id; + + let itemRow = ( +
+ {displayAsFolder ? ( + <> + { + e.stopPropagation(); + onOpen(node); + }} + disabled={node.disableCollapse} + > + {node.collapsed ? ( + + ) : ( + + )} + + {node.thumbnailSrc === 'NO_ICON' ? ( + node.dataset && node.dataset.folderColor ? ( + + + + + ) : null + ) : node.thumbnailSrc && node.thumbnailSrc !== 'FOLDER' ? ( +
+ +
+ ) : ( + !node.item.isRoot && ( + + ) + )} + + ) : node.thumbnailSrc ? ( +
+ +
+ ) : null} + {renamedItemId === node.id && typeof node.name === 'string' ? ( + + ) : ( + + {node.name} + + )} +
+ ); + + // If this is an object, connect the drag preview with an empty image + // to override the default drag preview. + if (typeof node.name === 'string' && !displayAsFolder) { + connectDragPreview(emptyImage); + } else { + // If not (folder for instance), just use the item row as drag preview. + itemRow = connectDragPreview(itemRow); + } + + const rightButtons = Array.isArray(node.rightButton) + ? node.rightButton + : node.rightButton + ? [node.rightButton] + : []; + + const shouldDisplayMenu = + !node.shouldHideMenuIcon && + !isMobile && + (!node.item.isRoot || node.shouldHideMenuIcon !== null) && + !node.item.isPlaceholder; + + const dragSource = connectDragSource( +
+ {isOver && whereToDrop === 'before' && ( + + )} +
onEditItem(node.item) : undefined + } + onContextMenu={ + isRenaming + ? undefined + : shouldSelectUponContextMenuOpening + ? selectAndOpenContextMenu + : openContextMenu + } + {...(isRenaming ? {} : longTouchForContextMenuProps)} + > + {itemRow} + {(node.rightComponent || + rightButtons.length > 0 || + shouldDisplayMenu) && ( +
+ {node.rightComponent} + {rightButtons.map(rightButton => + rightButton.primary ? ( + { + e.stopPropagation(); + if (rightButton.click) { + rightButton.click(); + } + }} + label={ + rightButton.showPrimaryLabel + ? rightButton.label + : null + } + disabled={ + rightButton.enabled === undefined + ? false + : !rightButton.enabled + } + icon={rightButton.icon} + /> + ) : ( + { + e.stopPropagation(); + if (rightButton.click) { + rightButton.click(); + } + }} + tooltip={rightButton.label} + disabled={ + rightButton.enabled === undefined + ? false + : !rightButton.enabled + } + > + {rightButton.icon} + + ) + )} + {shouldDisplayMenu && ( + { + e.stopPropagation(); + onContextMenu({ + item: node.item, + index, + x: e.clientX, + y: e.clientY, + }); + }} + > + + + )} +
+ )} +
+ {isOver && whereToDrop === 'after' && ( + + )} +
+ ); + + const shouldDisplayDropIndicator = + isOver && + whereToDrop === 'inside' && + displayAsFolder && + !node.item.isRoot; + const dropIndicatorClassName = shouldDisplayDropIndicator + ? canDrop + ? classes.withCanDropInsideIndicator + : classes.withCannotDropInsideIndicator + : null; + + const dropTarget = connectDropTarget( +
+ {dragSource} +
+ ); + + return ( +
0, + })} + > + {dropTarget} +
+ ); + }} +
+
+ ); +}; + +// $FlowFixMe[incompatible-type] - memo does not support having a generic in the props. +// $FlowFixMe[missing-type-arg] +export default (React.memo( + TreeViewRow, + areEqual +): React.ComponentType); diff --git a/daInviare/index.js b/daInviare/index.js new file mode 100644 index 000000000000..6ed7b68d7dff --- /dev/null +++ b/daInviare/index.js @@ -0,0 +1,1934 @@ +// @flow +import { Trans } from '@lingui/macro'; +import { I18n } from '@lingui/react'; +import { type I18n as I18nType } from '@lingui/core'; +import { t } from '@lingui/macro'; + +import * as React from 'react'; +import { AutoSizer } from 'react-virtualized'; +import Background from '../UI/Background'; +import CompactSearchBar from '../UI/CompactSearchBar'; +import NewObjectDialog from '../AssetStore/NewObjectDialog'; +import AssetSwappingDialog from '../AssetStore/AssetSwappingDialog'; +import newNameGenerator from '../Utils/NewNameGenerator'; +import Window from '../Utils/Window'; +import { showWarningBox } from '../UI/Messages/MessageBox'; +import { type ObjectEditorTab } from '../ObjectEditor/ObjectEditorDialog'; +import type { ObjectWithContext } from '../ObjectsList/EnumerateObjects'; +import TreeView, { + type TreeViewInterface, + type MenuButton, +} from '../UI/TreeView'; +import { type MenuItemTemplate } from '../UI/Menu/Menu.flow'; +import { type UnsavedChanges } from '../MainFrame/UnsavedChangesContext'; +import { type HotReloadPreviewButtonProps } from '../HotReload/HotReloadPreviewButton'; +import useForceUpdate from '../Utils/UseForceUpdate'; +import { type ResourceManagementProps } from '../ResourcesList/ResourceSource'; +import { Column } from '../UI/Grid'; +import Add from '../UI/CustomSvgIcons/Add'; +import InAppTutorialContext from '../InAppTutorial/InAppTutorialContext'; +import { + getFoldersAscendanceWithoutRootFolder, + type ObjectFolderOrObjectWithContext, +} from './EnumerateObjectFolderOrObject'; +import { mapFor } from '../Utils/MapFor'; +import { LineStackLayout } from '../UI/Layout'; +import KeyboardShortcuts from '../UI/KeyboardShortcuts'; +import Link from '../UI/Link'; +import { getHelpLink } from '../Utils/HelpLink'; +import useAlertDialog from '../UI/Alert/useAlertDialog'; +import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; +import ErrorBoundary from '../UI/ErrorBoundary'; +import { getInsertionParentAndPositionFromSelection } from '../Utils/ObjectFolders'; +import { + ObjectTreeViewItemContent, + getObjectTreeViewItemId, + type ObjectTreeViewItemProps, +} from './ObjectTreeViewItemContent'; +import { + ObjectFolderTreeViewItemContent, + getObjectFolderTreeViewItemId, + expandAllSubfolders, + folderColors, + type ObjectFolderTreeViewItemProps, +} from './ObjectFolderTreeViewItemContent'; +import { ProjectScopedContainersAccessor } from '../InstructionOrExpression/EventsScope'; +import { type HTMLDataset } from '../Utils/HTMLDataset'; +import type { MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow'; +import type { EventsScope } from '../InstructionOrExpression/EventsScope'; +import { type InstallAssetOutput } from '../AssetStore/InstallAsset'; +import { exceptionallyGuardAgainstDeadObject } from '../Utils/IsNullPtr'; + +const gd: libGDevelop = global.gd; + +const sceneObjectsRootFolderId = 'scene-objects'; +const globalObjectsRootFolderId = 'global-objects'; +const globalObjectsEmptyPlaceholderId = 'global-empty-placeholder'; +const sceneObjectsEmptyPlaceholderId = 'scene-empty-placeholder'; + +const globalObjectsWikiLink = getHelpLink( + '/interface/scene-editor/global-objects/', + ':~:text=Global%20objects%20are%20objects%20which,are%20usable%20by%20all%20Scenes' +); + +const styles = { + listContainer: { + flex: 1, + display: 'flex', + flexDirection: 'column', + }, + autoSizerContainer: { flex: 1 }, + autoSizer: { width: '100%' }, + colorPickerCard: { + position: 'fixed', + zIndex: 1000, + background: '#1e1e1e', + borderRadius: 12, + border: '0.5px solid rgba(255,255,255,0.1)', + width: 300, + overflow: 'hidden', + boxShadow: '0 8px 32px rgba(0,0,0,0.6)', + userSelect: 'none', + }, + colorPickerHeader: { + padding: '14px 16px 12px', + borderBottom: '0.5px solid rgba(255,255,255,0.08)', + display: 'flex', + alignItems: 'center', + gap: 10, + cursor: 'grab', + }, + colorPickerBody: { + padding: 16, + }, + colorPickerGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(7, 1fr)', + gap: 6, + marginBottom: 14, + }, + colorPickerHexRow: { + display: 'flex', + alignItems: 'center', + gap: 8, + marginBottom: 14, + background: 'rgba(255,255,255,0.05)', + border: '0.5px solid rgba(255,255,255,0.1)', + borderRadius: 8, + padding: '7px 10px', + }, + colorPickerHexDot: { + width: 22, + height: 22, + borderRadius: 4, + flexShrink: 0, + }, + colorPickerHexText: { + fontSize: 13, + color: 'rgba(255,255,255,0.45)', + fontFamily: 'monospace', + }, + colorPickerCustomBtn: { + marginLeft: 'auto', + display: 'flex', + alignItems: 'center', + gap: 5, + padding: '4px 10px', + borderRadius: 6, + border: '0.5px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.07)', + color: 'rgba(255,255,255,0.6)', + fontSize: 12, + cursor: 'pointer', + }, + colorPickerActions: { + display: 'flex', + gap: 8, + }, + colorPickerBtnConfirm: { + flex: 1, + padding: '8px', + borderRadius: 8, + border: 'none', + background: '#6d28d9', + color: 'white', + fontSize: 13, + fontWeight: 500, + cursor: 'pointer', + }, + colorPickerBtnReset: { + padding: '8px 14px', + borderRadius: 8, + border: '0.5px solid rgba(255,255,255,0.15)', + background: 'transparent', + color: 'rgba(255,255,255,0.5)', + fontSize: 20, + cursor: 'pointer', + lineHeight: 1, + }, + colorPickerBtnCancel: { + padding: '8px 14px', + borderRadius: 8, + border: '0.5px solid rgba(255,255,255,0.15)', + background: 'transparent', + color: 'rgba(255,255,255,0.5)', + fontSize: 13, + cursor: 'pointer', + }, +}; + +export const getLabelsForObjectsAndGroupsLists = ( + scope: EventsScope +): {| + localScopeObjectsTitle: MessageDescriptor, + higherScopeObjectsTitle: MessageDescriptor | null, + localScopeGroupsTitle: MessageDescriptor | null, + higherScopeGroupsTitle: MessageDescriptor | null, +|} => { + if (scope.layout) { + return { + localScopeObjectsTitle: t`Scene Objects`, + higherScopeObjectsTitle: t`Global Objects`, + localScopeGroupsTitle: t`Scene Groups`, + higherScopeGroupsTitle: t`Global Groups`, + }; + } else if (scope.eventsBasedObject) { + return { + localScopeObjectsTitle: t`Object's children`, + higherScopeObjectsTitle: null, + localScopeGroupsTitle: t`Object's groups`, + higherScopeGroupsTitle: null, + }; + } + + throw new Error('Scope not recognized.'); +}; + +export const getTreeViewItemIdFromObjectFolderOrObject = ( + objectFolderOrObject: gdObjectFolderOrObject +): string => { + if (objectFolderOrObject.isFolder()) { + return getObjectFolderTreeViewItemId(objectFolderOrObject); + } + const object = exceptionallyGuardAgainstDeadObject( + objectFolderOrObject.getObject() + ); + if (!object) return `deleted-${objectFolderOrObject.ptr}`; + return getObjectTreeViewItemId(object); +}; + +export interface TreeViewItemContent { + getName(): string | React.Node; + getId(): string; + getHtmlId(index: number): ?string; + getDataSet(): ?HTMLDataset; + getThumbnail(): ?string; + onClick(): void; + buildMenuTemplate(i18n: I18nType, index: number): Array; + getRightButton(i18n: I18nType): ?MenuButton; + renderRightComponent(i18n: I18nType): ?React.Node; + rename(newName: string): void; + edit(): void; + delete(): void; + copy(): void; + paste(): void; + cut(): void; + duplicate(): void; + getIndex(): number; + isDescendantOf(treeViewItemContent: TreeViewItemContent): boolean; + isSibling(treeViewItemContent: TreeViewItemContent): boolean; + isGlobal(): boolean; + is3D(): boolean; + getObjectFolderOrObject(): gdObjectFolderOrObject | null; +} + +interface TreeViewItem { + isRoot?: boolean; + isPlaceholder?: boolean; + +content: TreeViewItemContent; + getChildren(i18n: I18nType): ?Array; +} + +class LeafTreeViewItem implements TreeViewItem { + content: TreeViewItemContent; + + constructor(content: TreeViewItemContent) { + this.content = content; + } + + getChildren(i18n: I18nType): ?Array { + return null; + } +} + +// $FlowFixMe[incompatible-type] +class PlaceHolderTreeViewItem implements TreeViewItem { + isPlaceholder = true; + content: TreeViewItemContent; + + constructor(id: string, label: string | React.Node) { + this.content = new LabelTreeViewItemContent(id, label); + } + + getChildren(i18n: I18nType): ?Array { + return null; + } +} + +const createTreeViewItem = ({ + objectFolderOrObject, + isGlobal, + objectFolderTreeViewItemProps, + objectTreeViewItemProps, +}: {| + objectFolderOrObject: gdObjectFolderOrObject, + isGlobal: boolean, + objectFolderTreeViewItemProps: ObjectFolderTreeViewItemProps, + objectTreeViewItemProps: ObjectTreeViewItemProps, +|}): TreeViewItem => { + if (objectFolderOrObject.isFolder()) { + return new ObjectFolderTreeViewItem({ + objectFolderOrObject: objectFolderOrObject, + global: isGlobal, + isRoot: false, + objectFolderTreeViewItemProps, + objectTreeViewItemProps, + content: new ObjectFolderTreeViewItemContent( + objectFolderOrObject, + isGlobal, + objectFolderTreeViewItemProps + ), + }); + } else { + return new LeafTreeViewItem( + new ObjectTreeViewItemContent( + objectFolderOrObject, + isGlobal, + objectTreeViewItemProps + ) + ); + } +}; + +// $FlowFixMe[incompatible-type] +class ObjectFolderTreeViewItem implements TreeViewItem { + isRoot: boolean; + global: boolean; + isPlaceholder = false; + content: TreeViewItemContent; + objectFolderOrObject: gdObjectFolderOrObject; + placeholder: ?PlaceHolderTreeViewItem; + objectFolderTreeViewItemProps: ObjectFolderTreeViewItemProps; + objectTreeViewItemProps: ObjectTreeViewItemProps; + + constructor({ + objectFolderOrObject, + global, + isRoot, + content, + placeholder, + objectFolderTreeViewItemProps, + objectTreeViewItemProps, + }: {| + objectFolderOrObject: gdObjectFolderOrObject, + global: boolean, + isRoot: boolean, + content: TreeViewItemContent, + placeholder?: PlaceHolderTreeViewItem, + objectFolderTreeViewItemProps: ObjectFolderTreeViewItemProps, + objectTreeViewItemProps: ObjectTreeViewItemProps, + |}) { + this.isRoot = isRoot; + this.global = global; + this.content = content; + this.objectFolderOrObject = objectFolderOrObject; + this.placeholder = placeholder; + this.objectFolderTreeViewItemProps = objectFolderTreeViewItemProps; + this.objectTreeViewItemProps = objectTreeViewItemProps; + } + + getChildren(i18n: I18nType): ?Array { + if (!exceptionallyGuardAgainstDeadObject(this.objectFolderOrObject)) + return this.placeholder ? [this.placeholder] : []; + + if (this.objectFolderOrObject.getChildrenCount() === 0) { + return this.placeholder ? [this.placeholder] : []; + } + return mapFor(0, this.objectFolderOrObject.getChildrenCount(), i => { + const child = this.objectFolderOrObject.getChildAt(i); + return createTreeViewItem({ + objectFolderOrObject: child, + isGlobal: this.global, + objectFolderTreeViewItemProps: this.objectFolderTreeViewItemProps, + objectTreeViewItemProps: this.objectTreeViewItemProps, + }); + }); + } +} + +class LabelTreeViewItemContent implements TreeViewItemContent { + id: string; + label: string | React.Node; + dataSet: { [string]: string }; + buildMenuTemplateFunction: ( + i18n: I18nType, + index: number + ) => Array; + rightButton: ?MenuButton; + + constructor( + id: string, + label: string | React.Node, + rightButton?: MenuButton | null, + buildMenuTemplateFunction?: () => Array + ) { + this.id = id; + this.label = label; + this.buildMenuTemplateFunction = (i18n: I18nType, index: number) => + // $FlowFixMe[incompatible-type] + [ + rightButton + ? { + id: rightButton.id, + label: i18n._(rightButton.label), + click: rightButton.click, + enabled: rightButton.enabled, + } + : null, + ...(buildMenuTemplateFunction ? buildMenuTemplateFunction() : []), + ].filter(Boolean); + this.rightButton = rightButton; + } + + getName(): string | React.Node { + return this.label; + } + + getId(): string { + return this.id; + } + + getRightButton(i18n: I18nType): ?MenuButton { + return this.rightButton; + } + + getHtmlId(index: number): ?string { + return this.id; + } + + getDataSet(): ?HTMLDataset { + return {}; + } + + getThumbnail(): ?string { + return null; + } + + onClick(): void {} + + // $FlowFixMe[missing-local-annot] + buildMenuTemplate(i18n: I18nType, index: number) { + return this.buildMenuTemplateFunction(i18n, index); + } + + renderRightComponent(i18n: I18nType): ?React.Node { + return null; + } + + rename(newName: string): void {} + + edit(): void {} + + delete(): void {} + + copy(): void {} + + paste(): void {} + + cut(): void {} + + duplicate(): void {} + + getIndex(): number { + return 0; + } + + isDescendantOf(treeViewItemContent: TreeViewItemContent): boolean { + return false; + } + + isSibling(treeViewItemContent: TreeViewItemContent): boolean { + return false; + } + + isGlobal(): boolean { + return false; + } + + is3D(): boolean { + return false; + } + + getObjectFolderOrObject(): gdObjectFolderOrObject | null { + return null; + } +} + +const getTreeViewItemName = (item: TreeViewItem) => item.content.getName(); +const getTreeViewItemId = (item: TreeViewItem) => item.content.getId(); +const getTreeViewItemHtmlId = (item: TreeViewItem, index: number) => + item.content.getHtmlId(index); +const getTreeViewItemChildren = (i18n: I18nType) => (item: TreeViewItem) => + item.getChildren(i18n); +const getTreeViewItemThumbnail = (item: TreeViewItem) => + item.content.getThumbnail(); +const getTreeViewItemDataSet = (item: TreeViewItem) => + item.content.getDataSet(); +const buildMenuTemplate = (i18n: I18nType) => ( + item: TreeViewItem, + index: number +) => item.content.buildMenuTemplate(i18n, index); +const renderTreeViewItemRightComponent = (i18n: I18nType) => ( + item: TreeViewItem +) => item.content.renderRightComponent(i18n); +const renameItem = (item: TreeViewItem, newName: string) => { + item.content.rename(newName); +}; +const onClickItem = (item: TreeViewItem) => { + item.content.onClick(); +}; +const editItem = (item: TreeViewItem) => { + item.content.edit(); +}; +const deleteItem = (item: TreeViewItem) => { + item.content.delete(); +}; +const duplicateItem = (item: TreeViewItem) => { + item.content.duplicate(); +}; +const getTreeViewItemRightButton = (i18n: I18nType) => (item: TreeViewItem) => + item.content.getRightButton(i18n); + +export const objectWithContextReactDndType = 'GD_OBJECT_WITH_CONTEXT'; + +const objectTypeToDefaultName = { + Sprite: 'NewSprite', + 'TiledSpriteObject::TiledSprite': 'NewTiledSprite', + 'ParticleSystem::ParticleEmitter': 'NewParticlesEmitter', + 'PanelSpriteObject::PanelSprite': 'NewPanelSprite', + 'PrimitiveDrawing::Drawer': 'NewShapePainter', + 'TextObject::Text': 'NewText', + 'BBText::BBText': 'NewBBText', + 'BitmapText::BitmapTextObject': 'NewBitmapText', + 'TextEntryObject::TextEntry': 'NewTextEntry', + 'TileMap::SimpleTileMap': 'NewTileMap', + 'TileMap::TileMap': 'NewExternalTileMap', + 'TileMap::CollisionMask': 'NewExternalTileMapMask', + 'MyDummyExtension::DummyObject': 'NewDummyObject', + 'Lighting::LightObject': 'NewLight', + 'TextInput::TextInputObject': 'NewTextInput', + 'Scene3D::Model3DObject': 'New3DModel', + 'Scene3D::Cube3DObject': 'New3DBox', + 'SpineObject::SpineObject': 'NewSpine', + 'Video::VideoObject': 'NewVideo', +}; + +export type ObjectsListInterface = {| + forceUpdateList: () => void, + openNewObjectDialog: () => void, + closeNewObjectDialog: () => void, +|}; + +type Props = {| + project: gdProject, + layout: ?gdLayout, + eventsFunctionsExtension: gdEventsFunctionsExtension | null, + eventsBasedObject: gdEventsBasedObject | null, + initialInstances?: gdInitialInstancesContainer, + projectScopedContainersAccessor: ProjectScopedContainersAccessor, + globalObjectsContainer: gdObjectsContainer | null, + objectsContainer: gdObjectsContainer, + + onSelectAllInstancesOfObjectInLayout?: string => void, + resourceManagementProps: ResourceManagementProps, + onDeleteObjects: ( + objectWithContext: ObjectWithContext[], + cb: (boolean) => void + ) => void, + onRenameObjectFolderOrObjectWithContextFinish: ( + objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext, + newName: string, + cb: (boolean) => void + ) => void, + selectedObjectFolderOrObjectsWithContext: Array, + + beforeSetAsGlobalObject?: (objectName: string) => boolean, + canSetAsGlobalObject?: boolean, + onSetAsGlobalObject: (object: gdObject) => void, + + onEditObject: (object: gdObject, initialTab: ?ObjectEditorTab) => void, + onOpenEventBasedObjectEditor: ( + extensionName: string, + eventsBasedObjectName: string + ) => void, + onOpenEventBasedObjectVariantEditor: ( + extensionName: string, + eventsBasedObjectName: string, + variantName: string + ) => void, + onExportAssets: () => void, + onImportAssets: () => void, + onObjectCreated: ( + objects: Array, + isTheFirstOfItsTypeInProject: boolean + ) => void, + onObjectEdited: ( + objectWithContext: ObjectWithContext, + hasResourceChanged: boolean + ) => void, + onObjectFolderOrObjectWithContextSelected: ( + ?ObjectFolderOrObjectWithContext + ) => void, + onObjectPasted?: gdObject => void, + getValidatedObjectOrGroupName: (newName: string, global: boolean) => string, + onAddObjectInstance: (objectName: string) => void, + onWillInstallExtension: (extensionNames: Array) => void, + onExtensionInstalled: (extensionNames: Array) => void, + + getThumbnail: ( + project: gdProject, + objectConfiguration: gdObjectConfiguration + ) => string, + unsavedChanges?: ?UnsavedChanges, + hotReloadPreviewButtonProps: HotReloadPreviewButtonProps, + isListLocked: boolean, +|}; + +// ─── Componente ColorPickerDialog ──────────────────────────────────────────── + +const PRESET_COLORS = [ + '#ef4444', '#f97316', '#eab308', '#22c55e', + '#3b82f6', '#a855f7', '#ec4899', '#14b8a6', + '#6366f1', '#f43f5e', '#84cc16', '#06b6d4', + '#f59e0b', '#78716c', +]; + +type ColorPickerDialogProps = {| + objectFolder: gdObjectFolderOrObject, + isGlobal: boolean, + onClose: () => void, + onForceUpdate: () => void, +|}; + +const ColorPickerDialog = ({ + objectFolder, + isGlobal, + onClose, + onForceUpdate, +}: ColorPickerDialogProps) => { + const currentColor = folderColors.get(objectFolder, isGlobal) || '#a855f7'; + const [selectedColor, setSelectedColor] = React.useState(currentColor); + const nativeInputRef = React.useRef(null); + + const [pos, setPos] = React.useState({ + x: window.innerWidth / 2 - 150, + y: window.innerHeight / 2 - 160, + }); + const dragging = React.useRef(false); + const dragOffset = React.useRef({ x: 0, y: 0 }); + + const onMouseDown = (e) => { + dragging.current = true; + dragOffset.current = { x: e.clientX - pos.x, y: e.clientY - pos.y }; + e.preventDefault(); + }; + + React.useEffect(() => { + const onMouseMove = (e) => { + if (!dragging.current) return; + setPos({ x: e.clientX - dragOffset.current.x, y: e.clientY - dragOffset.current.y }); + }; + const onMouseUp = () => { dragging.current = false; }; + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + }, []); + + const handleConfirm = () => { + folderColors.set(objectFolder, isGlobal, selectedColor); + onForceUpdate(); + onClose(); + }; + + const handleReset = () => { + // Reset to default grey instead of removing, so the SVG folder icon is kept + folderColors.set(objectFolder, isGlobal, '#6b7280'); + onForceUpdate(); + onClose(); + }; + + const handleCustomPick = () => { + if (nativeInputRef.current) nativeInputRef.current.click(); + }; + + const folderName = objectFolder.getFolderName(); + + return ( +
e.stopPropagation()} + > +
+ + + + + Folder color + {folderName} +
+ +
+
+ {PRESET_COLORS.map(color => ( +
setSelectedColor(color)} + /> + ))} +
+ +
+
+ {selectedColor} + + setSelectedColor(e.target.value)} + style={{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }} + /> +
+ +
+ + + +
+
+
+ ); +}; +// ───────────────────────────────────────────────────────────────────────────── + +const ObjectsList = React.forwardRef( + ( + { + project, + layout, + eventsFunctionsExtension, + eventsBasedObject, + initialInstances, + projectScopedContainersAccessor, + globalObjectsContainer, + objectsContainer, + resourceManagementProps, + onSelectAllInstancesOfObjectInLayout, + onDeleteObjects, + onRenameObjectFolderOrObjectWithContextFinish, + selectedObjectFolderOrObjectsWithContext, + + beforeSetAsGlobalObject, + canSetAsGlobalObject, + onSetAsGlobalObject, + + onEditObject, + onOpenEventBasedObjectEditor, + onOpenEventBasedObjectVariantEditor, + onExportAssets, + onImportAssets, + onObjectCreated, + onObjectEdited, + onObjectFolderOrObjectWithContextSelected, + onObjectPasted, + getValidatedObjectOrGroupName, + onAddObjectInstance, + onWillInstallExtension, + onExtensionInstalled, + + getThumbnail, + unsavedChanges, + hotReloadPreviewButtonProps, + isListLocked, + }: Props, + ref + ) => { + const { currentlyRunningInAppTutorial } = React.useContext( + InAppTutorialContext + ); + const [searchText, setSearchText] = React.useState(''); + const { showDeleteConfirmation } = useAlertDialog(); + const treeViewRef = React.useRef>(null); + const forceUpdate = useForceUpdate(); + const { isMobile } = useResponsiveWindowSize(); + + const forceUpdateList = React.useCallback( + () => { + forceUpdate(); + if (treeViewRef.current) treeViewRef.current.forceUpdateList(); + }, + [forceUpdate] + ); + + const [newObjectDialogOpen, setNewObjectDialogOpen] = React.useState<{ + from: ObjectFolderOrObjectWithContext | null, + } | null>(null); + + // ── STATO COLOR PICKER ────────────────────────────────────────────────── + const [colorPickerOpen, setColorPickerOpen] = React.useState<{ + objectFolder: gdObjectFolderOrObject, + isGlobal: boolean, + } | null>(null); + // ──────────────────────────────────────────────────────────────────────── + + React.useImperativeHandle(ref, () => ({ + forceUpdateList: () => { + forceUpdate(); + if (treeViewRef.current) treeViewRef.current.forceUpdateList(); + }, + openNewObjectDialog: () => { + setNewObjectDialogOpen({ from: null }); + }, + closeNewObjectDialog: () => { + setNewObjectDialogOpen(null); + }, + })); + + const [ + objectAssetSwappingDialogOpen, + setObjectAssetSwappingDialogOpen, + ] = React.useState<{ objectWithContext: ObjectWithContext } | null>(null); + + const keyboardShortcutsRef = React.useRef( + new KeyboardShortcuts({ + shortcutCallbacks: {}, + }) + ); + + const scrollToItem = React.useCallback((itemId: string) => { + if (treeViewRef.current) { + treeViewRef.current.scrollToItemFromId(itemId); + } + }, []); + + const addObject = React.useCallback( + (objectType: string) => { + const defaultName = project.hasEventsBasedObject(objectType) + ? 'New' + + (project.getEventsBasedObject(objectType).getDefaultName() || + project.getEventsBasedObject(objectType).getName()) + : // $FlowFixMe[invalid-computed-prop] + objectTypeToDefaultName[objectType] || 'NewObject'; + const name = newNameGenerator( + defaultName, + name => + objectsContainer.hasObjectNamed(name) || + (!!globalObjectsContainer && + globalObjectsContainer.hasObjectNamed(name)) + ); + + const isTheFirstOfItsTypeInProject = !gd.UsedObjectTypeFinder.scanProject( + project, + objectType + ); + + let object; + let objectFolderOrObjectWithContext; + if ( + newObjectDialogOpen && + newObjectDialogOpen.from && + !newObjectDialogOpen.from.global + ) { + const selectedItem = newObjectDialogOpen.from.objectFolderOrObject; + const { + folder: parentFolder, + position, + } = getInsertionParentAndPositionFromSelection(selectedItem); + + object = objectsContainer.insertNewObjectInFolder( + project, + objectType, + name, + parentFolder, + position + ); + objectFolderOrObjectWithContext = { + objectFolderOrObject: parentFolder.getObjectChild(name), + global: false, + }; + + if (treeViewRef.current) { + treeViewRef.current.openItems([ + getObjectFolderTreeViewItemId(parentFolder), + ]); + } + } else { + object = objectsContainer.insertNewObject( + project, + objectType, + name, + objectsContainer.getObjectsCount() + ); + objectFolderOrObjectWithContext = { + objectFolderOrObject: objectsContainer + .getRootFolder() + .getObjectChild(name), + global: false, + }; + } + + if (treeViewRef.current) + treeViewRef.current.openItems([sceneObjectsRootFolderId]); + + setTimeout(() => { + scrollToItem(getObjectTreeViewItemId(object)); + }, 100); + + setNewObjectDialogOpen(null); + // $FlowFixMe[constant-condition] + if (onEditObject) { + onEditObject(object); + onObjectFolderOrObjectWithContextSelected( + objectFolderOrObjectWithContext + ); + } + onObjectCreated([object], isTheFirstOfItsTypeInProject); + }, + [ + project, + newObjectDialogOpen, + onEditObject, + objectsContainer, + globalObjectsContainer, + scrollToItem, + onObjectCreated, + onObjectFolderOrObjectWithContextSelected, + ] + ); + + const onObjectsAddedFromAssets = React.useCallback( + ({ + createdObjects: objects, + isTheFirstOfItsTypeInProject, + }: InstallAssetOutput) => { + if (objects.length === 0) return; + + onObjectCreated(objects, isTheFirstOfItsTypeInProject); + + const lastObject = objects[objects.length - 1]; + + if (newObjectDialogOpen && newObjectDialogOpen.from) { + const { + objectFolderOrObject: selectedObjectFolderOrObject, + } = newObjectDialogOpen.from; + if (treeViewRef.current) { + treeViewRef.current.openItems( + getFoldersAscendanceWithoutRootFolder( + selectedObjectFolderOrObject + ).map(folder => getObjectFolderTreeViewItemId(folder)) + ); + } + } else { + if (treeViewRef.current) { + treeViewRef.current.openItems([sceneObjectsRootFolderId]); + } + } + setTimeout(() => { + scrollToItem(getObjectTreeViewItemId(lastObject)); + }, 100); + }, + [onObjectCreated, scrollToItem, newObjectDialogOpen] + ); + + const swapObjectAsset = React.useCallback( + (objectWithContext: ObjectWithContext) => { + setObjectAssetSwappingDialogOpen({ objectWithContext }); + }, + [] + ); + + const onAddNewObject = React.useCallback( + (item: ObjectFolderOrObjectWithContext | null) => { + setNewObjectDialogOpen({ from: item }); + }, + [] + ); + + // ── CALLBACK OPEN COLOR PICKER ────────────────────────────────────────── + const openColorPicker = React.useCallback( + (objectFolder: gdObjectFolderOrObject, isGlobal: boolean) => { + setColorPickerOpen({ objectFolder, isGlobal }); + }, + [] + ); + // ──────────────────────────────────────────────────────────────────────── + + const onObjectModified = React.useCallback( + (shouldForceUpdateList: boolean) => { + if (unsavedChanges) unsavedChanges.triggerUnsavedChanges(); + + if (shouldForceUpdateList) forceUpdateList(); + else forceUpdate(); + }, + [forceUpdate, forceUpdateList, unsavedChanges] + ); + + const selectObjectFolderOrObjectWithContext = React.useCallback( + (objectFolderOrObjectWithContext: ?ObjectFolderOrObjectWithContext) => { + onObjectFolderOrObjectWithContextSelected( + objectFolderOrObjectWithContext + ); + }, + [onObjectFolderOrObjectWithContextSelected] + ); + + const editName = React.useCallback( + (itemId: string) => { + const treeView = treeViewRef.current; + if (treeView) { + if (isMobile) { + treeView.scrollToItemFromId(itemId, 'start'); + } + treeView.renameItemFromId(itemId); + } + }, + [isMobile] + ); + + const getClosestVisibleParentId = ( + objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext + ): ?string => { + const treeView = treeViewRef.current; + if (!treeView) return null; + const { objectFolderOrObject } = objectFolderOrObjectWithContext; + const topToBottomAscendanceId = getFoldersAscendanceWithoutRootFolder( + objectFolderOrObject + ) + .reverse() + .map(parent => getObjectFolderTreeViewItemId(objectFolderOrObject)); + const topToBottomAscendanceOpenness = treeView.areItemsOpenFromId( + topToBottomAscendanceId + ); + const firstClosedFolderIndex = topToBottomAscendanceOpenness.indexOf( + false + ); + if (firstClosedFolderIndex === -1) { + return getTreeViewItemIdFromObjectFolderOrObject(objectFolderOrObject); + } + // $FlowFixMe[incompatible-type] + return topToBottomAscendanceId[firstClosedFolderIndex]; + }; + + const setAsGlobalObject = React.useCallback( + ({ + i18n, + objectFolderOrObject, + index, + folder, + }: { + i18n: I18nType, + objectFolderOrObject: gdObjectFolderOrObject, + index?: number, + folder?: gdObjectFolderOrObject, + }) => { + if (!globalObjectsContainer) { + return; + } + const destinationFolder = + folder && folder.isFolder() + ? folder + : globalObjectsContainer.getRootFolder(); + if (objectFolderOrObject.isFolder()) return; + const object = objectFolderOrObject.getObject(); + + const objectName: string = object.getName(); + if (!objectsContainer.hasObjectNamed(objectName)) return; + + if (globalObjectsContainer.hasObjectNamed(objectName)) { + showWarningBox( + i18n._( + t`A global object with this name already exists. Please change the object name before setting it as a global object` + ), + { delayToNextTick: true } + ); + return; + } + + if (beforeSetAsGlobalObject && !beforeSetAsGlobalObject(objectName)) { + return; + } + + const answer = Window.showConfirmDialog( + i18n._( + t`Global elements help manage objects across multiple scenes and are recommended for frequently used objects. This action cannot be undone. + + Do you want to set this as global object?` + ) + ); + if (!answer) return; + + objectsContainer.moveObjectFolderOrObjectToAnotherContainerInFolder( + objectFolderOrObject, + globalObjectsContainer, + destinationFolder, + typeof index === 'number' + ? index + : globalObjectsContainer.getObjectsCount() + ); + gd.WholeProjectRefactorer.updateBehaviorsSharedData(project); + onObjectModified(true); + onSetAsGlobalObject(object); + + const newObjectFolderOrObjectWithContext = { + objectFolderOrObject, + global: true, + }; + selectObjectFolderOrObjectWithContext( + newObjectFolderOrObjectWithContext + ); + + setTimeout(() => { + scrollToItem(getObjectTreeViewItemId(object)); + }, 100); + }, + [ + project, + globalObjectsContainer, + objectsContainer, + beforeSetAsGlobalObject, + onSetAsGlobalObject, + onObjectModified, + selectObjectFolderOrObjectWithContext, + scrollToItem, + ] + ); + + const addFolder = React.useCallback( + (items: Array) => { + let newObjectFolderOrObjectWithContext; + if (items.length === 1) { + const { + objectFolderOrObject: selectedObjectFolderOrObject, + global, + } = items[0]; + if (selectedObjectFolderOrObject.isFolder()) { + const newFolder = selectedObjectFolderOrObject.insertNewFolder( + 'NewFolder', + 0 + ); + newObjectFolderOrObjectWithContext = { + objectFolderOrObject: newFolder, + global, + }; + // Assign default grey color to new folder + folderColors.set(newFolder, global, '#6b7280'); + if (treeViewRef.current) { + treeViewRef.current.openItems([ + getObjectFolderTreeViewItemId(items[0].objectFolderOrObject), + ]); + } + } else { + const parentFolder = selectedObjectFolderOrObject.getParent(); + const newFolder = parentFolder.insertNewFolder( + 'NewFolder', + parentFolder.getChildPosition(selectedObjectFolderOrObject) + 1 + ); + newObjectFolderOrObjectWithContext = { + objectFolderOrObject: newFolder, + global, + }; + // Assign default grey color to new folder + folderColors.set(newFolder, global, '#6b7280'); + } + } else { + const rootFolder = objectsContainer.getRootFolder(); + const newFolder = rootFolder.insertNewFolder('NewFolder', 0); + newObjectFolderOrObjectWithContext = { + objectFolderOrObject: newFolder, + global: false, + }; + // Assign default grey color to new folder + folderColors.set(newFolder, false, '#6b7280'); + } + selectObjectFolderOrObjectWithContext( + newObjectFolderOrObjectWithContext + ); + const itemsToOpen = getFoldersAscendanceWithoutRootFolder( + newObjectFolderOrObjectWithContext.objectFolderOrObject + ).map(folder => getObjectFolderTreeViewItemId(folder)); + itemsToOpen.push( + newObjectFolderOrObjectWithContext.global + ? globalObjectsRootFolderId + : sceneObjectsRootFolderId + ); + if (treeViewRef.current) treeViewRef.current.openItems(itemsToOpen); + + editName( + getObjectFolderTreeViewItemId( + newObjectFolderOrObjectWithContext.objectFolderOrObject + ) + ); + forceUpdateList(); + }, + [ + forceUpdateList, + objectsContainer, + selectObjectFolderOrObjectWithContext, + editName, + ] + ); + + const onMovedObjectFolderOrObjectToAnotherFolderInSameContainer = React.useCallback( + (objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext) => { + const treeView = treeViewRef.current; + if (treeView) { + const closestVisibleParentId = getClosestVisibleParentId( + objectFolderOrObjectWithContext + ); + if (closestVisibleParentId) { + treeView.animateItemFromId(closestVisibleParentId); + } + } + onObjectModified(true); + }, + [onObjectModified] + ); + + const expandFolders = React.useCallback( + ( + objectFolderOrObjectWithContexts: Array + ) => { + if (treeViewRef.current) { + treeViewRef.current.openItems( + objectFolderOrObjectWithContexts.map( + objectFolderOrObjectWithContext => + getObjectFolderTreeViewItemId( + objectFolderOrObjectWithContext.objectFolderOrObject + ) + ) + ); + } + }, + [] + ); + + const objectTreeViewItemProps = React.useMemo( + () => ({ + project, + globalObjectsContainer, + objectsContainer, + onObjectPasted, + onSelectAllInstancesOfObjectInLayout, + editName, + onEditObject, + onDeleteObjects, + onAddObjectInstance, + initialInstances, + onOpenEventBasedObjectEditor, + onOpenEventBasedObjectVariantEditor, + getValidatedObjectOrGroupName, + onRenameObjectFolderOrObjectWithContextFinish, + onObjectModified, + onObjectCreated, + swapObjectAsset, + onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, + canSetAsGlobalObject, + setAsGlobalObject, + getThumbnail, + showDeleteConfirmation, + selectObjectFolderOrObjectWithContext, + addFolder, + forceUpdateList, + forceUpdate, + isListLocked, + }), + [ + project, + globalObjectsContainer, + objectsContainer, + onObjectPasted, + onSelectAllInstancesOfObjectInLayout, + editName, + onEditObject, + onDeleteObjects, + onAddObjectInstance, + initialInstances, + onOpenEventBasedObjectEditor, + onOpenEventBasedObjectVariantEditor, + getValidatedObjectOrGroupName, + onRenameObjectFolderOrObjectWithContextFinish, + onObjectModified, + onObjectCreated, + swapObjectAsset, + onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, + canSetAsGlobalObject, + setAsGlobalObject, + getThumbnail, + showDeleteConfirmation, + selectObjectFolderOrObjectWithContext, + addFolder, + forceUpdateList, + forceUpdate, + isListLocked, + ] + ); + + const objectFolderTreeViewItemProps = React.useMemo( + () => ({ + project, + globalObjectsContainer, + objectsContainer, + onObjectPasted, + onObjectModified, + onObjectCreated, + editName, + expandFolders, + addFolder, + onAddNewObject, + onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, + onRenameObjectFolderOrObjectWithContextFinish, + onDeleteObjects, + selectObjectFolderOrObjectWithContext, + showDeleteConfirmation, + forceUpdateList, + forceUpdate, + isListLocked, + openColorPicker, // <-- AGGIUNTO + }), + [ + project, + globalObjectsContainer, + objectsContainer, + onObjectPasted, + onObjectModified, + onObjectCreated, + editName, + expandFolders, + addFolder, + onAddNewObject, + onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, + onRenameObjectFolderOrObjectWithContextFinish, + onDeleteObjects, + selectObjectFolderOrObjectWithContext, + showDeleteConfirmation, + forceUpdateList, + forceUpdate, + isListLocked, + openColorPicker, // <-- AGGIUNTO + ] + ); + + const globalObjectsRootFolder = globalObjectsContainer + ? globalObjectsContainer.getRootFolder() + : null; + const objectsRootFolder = objectsContainer.getRootFolder(); + const labels = React.useMemo( + () => + getLabelsForObjectsAndGroupsLists( + projectScopedContainersAccessor.getScope() + ), + [projectScopedContainersAccessor] + ); + + const isEntirelyEmpty = + objectsContainer.getObjectsCount() === 0 && + (!globalObjectsContainer || + globalObjectsContainer.getObjectsCount() === 0); + + const getTreeViewData = React.useCallback( + (i18n: I18nType): Array => { + const treeViewItems = [ + globalObjectsRootFolder && + new ObjectFolderTreeViewItem({ + objectFolderOrObject: globalObjectsRootFolder, + global: true, + isRoot: true, + content: new LabelTreeViewItemContent( + globalObjectsRootFolderId, + i18n._(labels.higherScopeObjectsTitle), + null, + () => [ + { + label: i18n._(t`Add a folder`), + click: () => + addFolder([ + { + objectFolderOrObject: globalObjectsRootFolder, + global: true, + }, + ]), + }, + { type: 'separator' }, + { + label: i18n._(t`Expand all sub folders`), + click: () => + expandAllSubfolders( + globalObjectsRootFolder, + true, + expandFolders + ), + }, + ] + ), + placeholder: new PlaceHolderTreeViewItem( + globalObjectsEmptyPlaceholderId, + ( + + There is no{' '} + + Window.openExternalURL(globalObjectsWikiLink) + } + > + global object + {' '} + yet. + + ) + ), + objectTreeViewItemProps, + objectFolderTreeViewItemProps, + }), + new ObjectFolderTreeViewItem({ + objectFolderOrObject: objectsRootFolder, + global: false, + isRoot: true, + content: new LabelTreeViewItemContent( + sceneObjectsRootFolderId, + i18n._(labels.localScopeObjectsTitle), + { + primary: true, + showPrimaryLabel: isEntirelyEmpty, + icon: , + label: t`Add object`, + click: () => { + onAddNewObject(selectedObjectFolderOrObjectsWithContext[0]); + }, + id: 'add-new-object-button', + enabled: !isListLocked, + }, + () => [ + { + label: i18n._(t`Add a folder`), + click: () => + addFolder([ + { + objectFolderOrObject: objectsRootFolder, + global: false, + }, + ]), + enabled: !isListLocked, + }, + { type: 'separator' }, + { + label: i18n._(t`Expand all sub folders`), + click: () => + expandAllSubfolders( + objectsRootFolder, + false, + expandFolders + ), + }, + { type: 'separator' }, + { + label: i18n._(t`Export as assets`), + click: () => onExportAssets(), + }, + { + label: i18n._(t`Import assets`), + click: () => onImportAssets(), + }, + ] + ), + placeholder: new PlaceHolderTreeViewItem( + sceneObjectsEmptyPlaceholderId, + i18n._(t`Start by adding a new object.`) + ), + objectTreeViewItemProps, + objectFolderTreeViewItemProps, + }), + ].filter(Boolean); + // $FlowFixMe[incompatible-type] + return treeViewItems; + }, + [ + globalObjectsRootFolder, + labels.higherScopeObjectsTitle, + labels.localScopeObjectsTitle, + objectTreeViewItemProps, + objectFolderTreeViewItemProps, + objectsRootFolder, + isEntirelyEmpty, + isListLocked, + addFolder, + expandFolders, + onAddNewObject, + selectedObjectFolderOrObjectsWithContext, + onExportAssets, + onImportAssets, + ] + ); + + const selectedItems = React.useMemo( + () => { + return selectedObjectFolderOrObjectsWithContext.map( + ({ objectFolderOrObject, global }) => { + return createTreeViewItem({ + objectFolderOrObject, + isGlobal: global, + objectFolderTreeViewItemProps, + objectTreeViewItemProps, + }); + } + ); + }, + [ + selectedObjectFolderOrObjectsWithContext, + objectFolderTreeViewItemProps, + objectTreeViewItemProps, + ] + ); + + React.useEffect( + () => { + if (keyboardShortcutsRef.current) { + keyboardShortcutsRef.current.setShortcutCallback('onDelete', () => { + if (!isListLocked) { + deleteItem(selectedItems[0]); + } + }); + keyboardShortcutsRef.current.setShortcutCallback( + 'onDuplicate', + () => { + if (!isListLocked) { + duplicateItem(selectedItems[0]); + } + } + ); + keyboardShortcutsRef.current.setShortcutCallback('onRename', () => { + if (!isListLocked) { + editName(selectedItems[0].content.getId()); + } + }); + } + }, + [ + selectedObjectFolderOrObjectsWithContext, + editName, + selectedItems, + isListLocked, + ] + ); + + const canMoveSelectionTo = React.useCallback( + (destinationItem: TreeViewItem) => { + if (destinationItem.isRoot) return false; + if (destinationItem.isPlaceholder) { + if ( + destinationItem.content.getId() === + globalObjectsEmptyPlaceholderId && + selectedItems.length === 1 && + !selectedItems[0].content.isGlobal() + ) { + const objectFolderOrObject = selectedItems[0].content.getObjectFolderOrObject(); + return !!objectFolderOrObject && !objectFolderOrObject.isFolder(); + } + return false; + } + if ( + selectedItems.every( + selectedItem => + selectedItem.content.isGlobal() === + destinationItem.content.isGlobal() + ) + ) { + if ( + selectedItems[0] && + destinationItem.content.isDescendantOf(selectedItems[0].content) + ) { + return false; + } + return true; + } else if ( + selectedItems.length === 1 && + selectedItems.every( + selectedObject => selectedObject.content.isGlobal() === false + ) && + destinationItem.content.isGlobal() + ) { + const objectFolderOrObject = selectedItems[0].content.getObjectFolderOrObject(); + return !!objectFolderOrObject && !objectFolderOrObject.isFolder(); + } + + return false; + }, + [selectedItems] + ); + + const moveSelectionTo = React.useCallback( + ( + i18n: I18nType, + destinationItem: TreeViewItem, + where: 'before' | 'inside' | 'after' + ) => { + if (destinationItem.isRoot || selectedItems.length !== 1) { + return; + } + const selectedItem = selectedItems[0]; + const selectedObjectFolderOrObject = selectedItem.content.getObjectFolderOrObject(); + + if ( + !selectedObjectFolderOrObject || + destinationItem.content.getId() === selectedItem.content.getId() + ) { + return; + } + + if (destinationItem.isPlaceholder) { + if ( + destinationItem.content.getId() === + globalObjectsEmptyPlaceholderId && + selectedItems.length === 1 && + !selectedItem.content.isGlobal() + ) { + setAsGlobalObject({ + i18n, + objectFolderOrObject: selectedObjectFolderOrObject, + }); + } + return; + } + + const destinationObjectFolderOrObject = destinationItem.content.getObjectFolderOrObject(); + if (!destinationObjectFolderOrObject) { + return; + } + if ( + selectedItem.content.isGlobal() === false && + destinationItem.content.isGlobal() + ) { + let parent, index; + if ( + where === 'inside' && + destinationObjectFolderOrObject.isFolder() + ) { + parent = destinationObjectFolderOrObject; + index = 0; + } else { + parent = destinationObjectFolderOrObject.getParent(); + index = + destinationItem.content.getIndex() + (where === 'after' ? 1 : 0); + } + setAsGlobalObject({ + i18n, + objectFolderOrObject: selectedObjectFolderOrObject, + folder: parent, + index, + }); + return; + } + + if ( + selectedItem.content.isGlobal() === destinationItem.content.isGlobal() + ) { + let parent; + if ( + where === 'inside' && + destinationObjectFolderOrObject.isFolder() + ) { + parent = destinationObjectFolderOrObject; + } else { + parent = destinationObjectFolderOrObject.getParent(); + } + const selectedObjectFolderOrObjectParent = selectedObjectFolderOrObject.getParent(); + if (parent === selectedObjectFolderOrObjectParent) { + const fromIndex = selectedItem.content.getIndex(); + let toIndex = destinationItem.content.getIndex(); + if (toIndex > fromIndex) toIndex -= 1; + if (where === 'after') toIndex += 1; + selectedObjectFolderOrObjectParent.moveChild(fromIndex, toIndex); + } else { + if (destinationItem.content.isDescendantOf(selectedItem.content)) { + return; + } + const position = + where === 'inside' + ? 0 + : destinationItem.content.getIndex() + + (where === 'after' ? 1 : 0); + selectedObjectFolderOrObjectParent.moveObjectFolderOrObjectToAnotherFolder( + selectedObjectFolderOrObject, + parent, + position + ); + const treeView = treeViewRef.current; + if (treeView) { + const closestVisibleParentId = getClosestVisibleParentId({ + objectFolderOrObject: parent, + global: destinationItem.content.isGlobal(), + }); + if (closestVisibleParentId) { + treeView.animateItemFromId(closestVisibleParentId); + } + } + } + } else { + return; + } + onObjectModified(true); + }, + [onObjectModified, selectedItems, setAsGlobalObject] + ); + + const onCollapseItem = React.useCallback( + (item: TreeViewItem) => { + if (!selectedItems || selectedItems.length !== 1) return; + const selectedItem = selectedItems[0]; + if (!selectedItem) return; + if (selectedItem.content.isDescendantOf(item.content)) { + selectObjectFolderOrObjectWithContext(null); + } + }, + [selectObjectFolderOrObjectWithContext, selectedItems] + ); + + const listKey = project.ptr + ';' + objectsContainer.ptr; + const initiallyOpenedNodeIds = [ + globalObjectsRootFolder && globalObjectsRootFolder.getChildrenCount() > 0 + ? globalObjectsRootFolderId + : null, + sceneObjectsRootFolderId, + ].filter(Boolean); + + const arrowKeyNavigationProps = React.useMemo( + () => ({ + onGetItemInside: (item: TreeViewItem): ?TreeViewItem => { + if (item.isPlaceholder || item.isRoot) return null; + const objectFolderOrObject = item.content.getObjectFolderOrObject(); + if (!objectFolderOrObject) return null; + if (!objectFolderOrObject.isFolder()) return null; + else { + if (objectFolderOrObject.getChildrenCount() === 0) return null; + return createTreeViewItem({ + objectFolderOrObject: objectFolderOrObject.getChildAt(0), + isGlobal: item.content.isGlobal(), + objectFolderTreeViewItemProps, + objectTreeViewItemProps, + }); + } + }, + onGetItemOutside: (item: TreeViewItem): ?TreeViewItem => { + if (item.isPlaceholder || item.isRoot) return null; + const objectFolderOrObject = item.content.getObjectFolderOrObject(); + if (!objectFolderOrObject) return null; + const parent = objectFolderOrObject.getParent(); + if (parent.isRootFolder()) return null; + return createTreeViewItem({ + objectFolderOrObject: parent, + isGlobal: item.content.isGlobal(), + objectFolderTreeViewItemProps, + objectTreeViewItemProps, + }); + }, + }), + [objectFolderTreeViewItemProps, objectTreeViewItemProps] + ); + + return ( + + + + + + +
+ + {({ i18n }) => ( +
+ + {({ height }) => ( + // $FlowFixMe[incompatible-type] + // $FlowFixMe[incompatible-exact] + { + if (!items) { + selectObjectFolderOrObjectWithContext(null); + return; + } + const itemContentToSelect = items[0].content; + const objectFolderOrObjectToSelect = itemContentToSelect.getObjectFolderOrObject(); + if (objectFolderOrObjectToSelect) { + selectObjectFolderOrObjectWithContext({ + objectFolderOrObject: objectFolderOrObjectToSelect, + global: itemContentToSelect.isGlobal(), + }); + } else { + selectObjectFolderOrObjectWithContext(null); + } + }} + onRenameItem={renameItem} + buildMenuTemplate={buildMenuTemplate(i18n)} + onMoveSelectionToItem={(destinationItem, where) => + moveSelectionTo(i18n, destinationItem, where) + } + canMoveSelectionToItem={canMoveSelectionTo} + reactDndType={objectWithContextReactDndType} + initiallyOpenedNodeIds={initiallyOpenedNodeIds} + arrowKeyNavigationProps={arrowKeyNavigationProps} + shouldSelectUponContextMenuOpening + getItemRightButton={getTreeViewItemRightButton(i18n)} + renderRightComponent={renderTreeViewItemRightComponent( + i18n + )} + /> + )} + +
+ )} +
+
+ + {/* Dialog nuovi oggetti */} + {newObjectDialogOpen && ( + setNewObjectDialogOpen(null)} + onCreateNewObject={addObject} + onObjectsAddedFromAssets={onObjectsAddedFromAssets} + project={project} + layout={layout} + eventsFunctionsExtension={eventsFunctionsExtension} + eventsBasedObject={eventsBasedObject} + objectsContainer={objectsContainer} + resourceManagementProps={resourceManagementProps} + targetObjectFolderOrObjectWithContext={newObjectDialogOpen.from} + onWillInstallExtension={onWillInstallExtension} + onExtensionInstalled={onExtensionInstalled} + /> + )} + + {/* Dialog swap asset */} + {objectAssetSwappingDialogOpen && ( + { + setObjectAssetSwappingDialogOpen(null); + if (swappingDone) + onObjectEdited( + objectAssetSwappingDialogOpen.objectWithContext, + true + ); + }} + project={project} + layout={layout} + eventsBasedObject={eventsBasedObject} + objectsContainer={objectsContainer} + object={objectAssetSwappingDialogOpen.objectWithContext.object} + resourceManagementProps={resourceManagementProps} + onWillInstallExtension={onWillInstallExtension} + onExtensionInstalled={onExtensionInstalled} + /> + )} + + {/* ── COLOR PICKER DIALOG ── */} + {colorPickerOpen && ( + setColorPickerOpen(null)} + onForceUpdate={forceUpdateList} + /> + )} + {/* ───────────────────────── */} +
+ ); + } +); + +const arePropsEqual = (prevProps: Props, nextProps: Props): boolean => + prevProps.selectedObjectFolderOrObjectsWithContext === + nextProps.selectedObjectFolderOrObjectsWithContext && + prevProps.project === nextProps.project && + prevProps.globalObjectsContainer === nextProps.globalObjectsContainer && + prevProps.objectsContainer === nextProps.objectsContainer; + +// $FlowFixMe[incompatible-type] +const MemoizedObjectsList = React.memo( + // $FlowFixMe[incompatible-type] + // $FlowFixMe[incompatible-exact] + ObjectsList, + arePropsEqual +); + +const ObjectsListWithErrorBoundary: React.ComponentType<{ + ...Props, + +ref?: React.RefSetter, +}> = React.forwardRef((props, ref) => ( + Objects list} + scope="scene-editor-objects-list" + > + {/* $FlowFixMe[incompatible-type] */} + + +)); + +export default ObjectsListWithErrorBoundary; From 698440be5ea262785accf4b6cf6a77712cd87bd8 Mon Sep 17 00:00:00 2001 From: robypown Date: Tue, 16 Jun 2026 14:22:53 +0200 Subject: [PATCH 2/2] fix(ObjectsList): Move folder color feature to correct source paths and fix Flow types - Move files from daInviare/ to correct source locations - Add Flow type annotations to getFolderStableKey and folderColors methods - Fix isRoot() -> isRootFolder() (correct GDevelop API method) - Fix getId() return type to string (required by TreeViewItemContent interface) - Add type annotations for mouse event handlers - Fix React.useRef(null) for color input ref - Remove daInviare/ folder --- daInviare/ObjectFolderTreeViewItemContent.js | 551 ----- daInviare/TreeViewRow.js | 636 ------ daInviare/index.js | 1934 ----------------- .../ObjectFolderTreeViewItemContent.js | 133 +- newIDE/app/src/ObjectsList/index.js | 410 +++- newIDE/app/src/UI/TreeView/TreeViewRow.js | 20 +- 6 files changed, 469 insertions(+), 3215 deletions(-) delete mode 100644 daInviare/ObjectFolderTreeViewItemContent.js delete mode 100644 daInviare/TreeViewRow.js delete mode 100644 daInviare/index.js diff --git a/daInviare/ObjectFolderTreeViewItemContent.js b/daInviare/ObjectFolderTreeViewItemContent.js deleted file mode 100644 index 94da42039bd9..000000000000 --- a/daInviare/ObjectFolderTreeViewItemContent.js +++ /dev/null @@ -1,551 +0,0 @@ -// @flow -import { type I18n as I18nType } from '@lingui/core'; -import { t } from '@lingui/macro'; -import * as React from 'react'; -import Clipboard from '../Utils/Clipboard'; -import { SafeExtractor } from '../Utils/SafeExtractor'; -import { type TreeViewItemContent } from '.'; -import { - enumerateFoldersInContainer, - enumerateFoldersInFolder, - enumerateObjectsInFolder, - type ObjectFolderOrObjectWithContext, -} from './EnumerateObjectFolderOrObject'; -import { - addSerializedObjectToObjectsContainer, - OBJECT_CLIPBOARD_KIND, -} from './ObjectTreeViewItemContent'; -import { renderQuickCustomizationMenuItems } from '../QuickCustomization/QuickCustomizationMenuItems'; -import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow'; -import type { ObjectWithContext } from '../ObjectsList/EnumerateObjects'; -import { type HTMLDataset } from '../Utils/HTMLDataset'; -import { exceptionallyGuardAgainstDeadObject } from '../Utils/IsNullPtr'; - -const gd: libGDevelop = global.gd; - -// Builds a stable path key like "Root/Enemies/Boss" by walking up the folder tree. -// This key survives page reloads, unlike objectFolder.ptr which is a memory address. -const getFolderStableKey = (objectFolder, isGlobal) => { - const parts = []; - let current = objectFolder; - try { - while (current && !current.isRoot()) { - parts.unshift(current.getFolderName()); - current = current.getParent(); - } - } catch (e) { - // fallback: if walking fails, use the folder name only - parts.unshift(objectFolder.getFolderName()); - } - const scope = isGlobal ? 'global' : 'scene'; - return scope + '/' + parts.join('/'); -}; - -export const folderColors = { - get(objectFolder, isGlobal) { - try { - const key = getFolderStableKey(objectFolder, isGlobal); - const saved = localStorage.getItem('gdevelop_custom_folder_colors'); - const colors = saved ? JSON.parse(saved) : {}; - return colors[key] || null; - } catch (e) { - return null; - } - }, - set(objectFolder, isGlobal, color) { - try { - const key = getFolderStableKey(objectFolder, isGlobal); - const saved = localStorage.getItem('gdevelop_custom_folder_colors'); - const colors = saved ? JSON.parse(saved) : {}; - colors[key] = color; - localStorage.setItem('gdevelop_custom_folder_colors', JSON.stringify(colors)); - } catch (e) { - console.error("Error saving folder color:", e); - } - }, - remove(objectFolder, isGlobal) { - try { - const key = getFolderStableKey(objectFolder, isGlobal); - const saved = localStorage.getItem('gdevelop_custom_folder_colors'); - if (saved) { - const colors = JSON.parse(saved); - delete colors[key]; - localStorage.setItem('gdevelop_custom_folder_colors', JSON.stringify(colors)); - } - } catch (e) { - console.error("Error removing folder color:", e); - } - } -}; - -export const expandAllSubfolders = ( - objectFolder: gdObjectFolderOrObject, - isGlobal: boolean, - expandFolders: ( - objectFolderOrObjectWithContexts: Array - ) => void -) => { - const subFolders = enumerateFoldersInFolder(objectFolder).map( - folderAndPath => folderAndPath.folder - ); - expandFolders( - [objectFolder, ...subFolders].map(folder => ({ - objectFolderOrObject: folder, - global: isGlobal, - })) - ); -}; - -export type ObjectFolderTreeViewItemCallbacks = {| - onObjectPasted?: gdObject => void, - onRenameObjectFolderOrObjectWithContextFinish: ( - objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext, - newName: string, - cb: (boolean) => void - ) => void, - onDeleteObjects: ( - objectWithContext: ObjectWithContext[], - cb: (boolean) => void - ) => void, -|}; - -export type ObjectFolderTreeViewItemProps = {| - ...ObjectFolderTreeViewItemCallbacks, - project: gdProject, - globalObjectsContainer: gdObjectsContainer | null, - objectsContainer: gdObjectsContainer, - editName: (itemId: string) => void, - onObjectModified: (shouldForceUpdateList: boolean) => void, - onObjectCreated: ( - objects: Array, - isTheFirstOfItsTypeInProject: boolean - ) => void, - expandFolders: ( - objectFolderOrObjectWithContexts: Array - ) => void, - addFolder: (items: Array) => void, - onAddNewObject: (item: ObjectFolderOrObjectWithContext | null) => void, - onMovedObjectFolderOrObjectToAnotherFolderInSameContainer: ( - objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext - ) => void, - showDeleteConfirmation: (options: any) => Promise, - selectObjectFolderOrObjectWithContext: ( - objectFolderOrObjectWithContext: ?ObjectFolderOrObjectWithContext - ) => void, - forceUpdateList: () => void, - forceUpdate: () => void, - isListLocked: boolean, - openColorPicker: (objectFolder: gdObjectFolderOrObject, isGlobal: boolean) => void, -|}; - -export const getObjectFolderTreeViewItemId = ( - objectFolder: gdObjectFolderOrObject -): string => { - return `object-folder-${objectFolder.ptr}`; -}; - -export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { - objectFolder: gdObjectFolderOrObject; - _isGlobal: boolean; - props: ObjectFolderTreeViewItemProps; - - constructor( - objectFolder: gdObjectFolderOrObject, - isGlobal: boolean, - props: ObjectFolderTreeViewItemProps - ) { - this.objectFolder = objectFolder; - this._isGlobal = isGlobal; - this.props = props; - } - - getObjectFolderOrObject(): gdObjectFolderOrObject | null { - return this.objectFolder; - } - - isDescendantOf(treeViewItemContent: TreeViewItemContent): boolean { - const objectFolderOrObject = treeViewItemContent.getObjectFolderOrObject(); - return ( - !!objectFolderOrObject && - this.objectFolder.isADescendantOf(objectFolderOrObject) - ); - } - - isSibling(treeViewItemContent: TreeViewItemContent): boolean { - const objectFolderOrObject = treeViewItemContent.getObjectFolderOrObject(); - return ( - !!objectFolderOrObject && - this.objectFolder.getParent() === objectFolderOrObject.getParent() - ); - } - - getIndex(): number { - return this.objectFolder.getParent().getChildPosition(this.objectFolder); - } - - isGlobal(): boolean { - return this._isGlobal; - } - - is3D(): boolean { - return false; - } - -_getFolderName(): string { - if (!exceptionallyGuardAgainstDeadObject(this.objectFolder)) return ''; - return this.objectFolder.getFolderName(); - } - - getName(): string { - if (!exceptionallyGuardAgainstDeadObject(this.objectFolder)) return ''; - return this.objectFolder.getFolderName(); - } - - getId(): ?string { - return getObjectFolderTreeViewItemId(this.objectFolder); - } - - getHtmlId(index: number): ?string { - return null; - } - - getDataSet(): ?HTMLDataset { - if (!exceptionallyGuardAgainstDeadObject(this.objectFolder)) return null; - const color = folderColors.get(this.objectFolder, this._isGlobal); - return { - folderName: this.objectFolder.getFolderName(), - global: this._isGlobal.toString(), - ...(color ? { folderColor: color } : {}), - }; - } - getThumbnail(): ?string { - const color = folderColors.get(this.objectFolder, this._isGlobal); - if (color) { - return 'NO_ICON'; - } - return 'FOLDER'; - } - - onClick(): void {} - - rename(newName: string): void { - const safeNewName = newName.replaceAll('/', '-'); - if (this._getFolderName() === safeNewName) { - return; - } - // Save the current color before renaming (the key will change with the new name) - const currentColor = folderColors.get(this.objectFolder, this._isGlobal); - this.props.onRenameObjectFolderOrObjectWithContextFinish( - { objectFolderOrObject: this.objectFolder, global: this._isGlobal }, - safeNewName, - doRename => { - if (!doRename) return; - // After rename, the folder path key has changed: remove old key and save with new key - if (currentColor) { - folderColors.remove(this.objectFolder, this._isGlobal); - folderColors.set(this.objectFolder, this._isGlobal, currentColor); - } - this.props.onObjectModified(false); - } - ); - } - - edit(): void {} - - _openColorPicker(): void { - this.props.openColorPicker(this.objectFolder, this._isGlobal); - } - - getPasteLabel( - i18n: I18nType, - { - isGlobalObject, - isFolder, - }: {| isGlobalObject: boolean, isFolder: boolean |} - ): any { - let translation = t`Paste`; - if (Clipboard.has(OBJECT_CLIPBOARD_KIND)) { - const clipboardContent = Clipboard.get(OBJECT_CLIPBOARD_KIND); - const clipboardObjectName = - SafeExtractor.extractStringProperty(clipboardContent, 'name') || ''; - translation = isGlobalObject - ? t`Paste ${clipboardObjectName} as a Global Object inside folder` - : t`Paste ${clipboardObjectName} inside folder`; - } - return i18n._(translation); - } - - buildMenuTemplate(i18n: I18nType, index: number): any { - const { - globalObjectsContainer, - objectsContainer, - expandFolders, - addFolder, - onAddNewObject, - onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, - forceUpdate, - isListLocked, - } = this.props; - - const container = this._isGlobal - ? globalObjectsContainer - : objectsContainer; - - if (!container) { - return []; - } - - const folderAndPathsInContainer = enumerateFoldersInContainer(container); - folderAndPathsInContainer.unshift({ - path: i18n._(t`Root folder`), - folder: container.getRootFolder(), - }); - - const filteredFolderAndPathsInContainer = folderAndPathsInContainer.filter( - folderAndPath => - !folderAndPath.folder.isADescendantOf(this.objectFolder) && - folderAndPath.folder !== this.objectFolder - ); - - return [ - { - label: this.getPasteLabel(i18n, { - isGlobalObject: this._isGlobal, - isFolder: true, - }), - enabled: Clipboard.has(OBJECT_CLIPBOARD_KIND) && !isListLocked, - click: () => this.paste(), - }, - { - label: i18n._(t`Rename`), - click: () => this.props.editName(this.getId()), - accelerator: 'F2', - enabled: !isListLocked, - }, - { - label: i18n._(t`Change folder color`), - click: () => this._openColorPicker(), - enabled: !isListLocked, - }, - { - label: i18n._(t`Delete`), - click: () => this.delete(), - accelerator: 'Backspace', - enabled: !isListLocked, - }, - isListLocked - ? { - label: i18n._('Move to folder'), - enabled: false, - } - : { - label: i18n._('Move to folder'), - submenu: [ - ...filteredFolderAndPathsInContainer.map(({ folder, path }) => ({ - label: path, - enabled: folder !== this.objectFolder.getParent(), - click: () => { - if (folder === this.objectFolder.getParent()) return; - this.objectFolder - .getParent() - .moveObjectFolderOrObjectToAnotherFolder( - this.objectFolder, - folder, - 0 - ); - onMovedObjectFolderOrObjectToAnotherFolderInSameContainer({ - objectFolderOrObject: folder, - global: this._isGlobal, - }); - }, - })), - { type: 'separator' }, - { - label: i18n._(t`Create new folder...`), - click: () => - addFolder([ - { - objectFolderOrObject: this.objectFolder.getParent(), - global: this._isGlobal, - }, - ]), - }, - ], - }, - ...renderQuickCustomizationMenuItems({ - i18n, - visibility: this.objectFolder.getQuickCustomizationVisibility(), - onChangeVisibility: visibility => { - this.objectFolder.setQuickCustomizationVisibility(visibility); - forceUpdate(); - }, - }), - { type: 'separator' }, - { - label: i18n._(t`Add a new object`), - click: () => - onAddNewObject({ - objectFolderOrObject: this.objectFolder, - global: this._isGlobal, - }), - enabled: !isListLocked, - }, - { - label: i18n._(t`Add a new folder`), - click: () => - addFolder([ - { objectFolderOrObject: this.objectFolder, global: this._isGlobal }, - ]), - enabled: !isListLocked, - }, - { type: 'separator' }, - { - label: i18n._(t`Expand all sub folders`), - click: () => - expandAllSubfolders(this.objectFolder, this._isGlobal, expandFolders), - }, - ]; - } - - renderRightComponent(i18n: I18nType): ?React.Node { - return null; - } - - delete(): void { - this._delete(); - } - - async _delete(): Promise { - const { - globalObjectsContainer, - objectsContainer, - onObjectModified, - forceUpdateList, - showDeleteConfirmation, - onDeleteObjects, - selectObjectFolderOrObjectWithContext, - } = this.props; - - const objectsToDelete = enumerateObjectsInFolder(this.objectFolder); - - if (objectsToDelete.length === 0) { - selectObjectFolderOrObjectWithContext(null); - folderColors.remove(this.objectFolder, this._isGlobal); - this.objectFolder.getParent().removeFolderChild(this.objectFolder); - forceUpdateList(); - return; - } - - let message: MessageDescriptor; - let title: MessageDescriptor; - - if (objectsToDelete.length === 1) { - message = t`Are you sure you want to remove this folder and with it the object ${objectsToDelete[0].getName()}? This can't be undone.`; - title = t`Remove folder and object`; - } else { - message = t`Are you sure you want to remove this folder and all its content (objects ${objectsToDelete - .map(object => object.getName()) - .join(', ')})? This can't be undone.`; - title = t`Remove folder and objects`; - } - - const answer = await showDeleteConfirmation({ message, title }); - if (!answer) return; - - const objectsWithContext = objectsToDelete.map(object => ({ - object, - global: this._isGlobal, - })); - - selectObjectFolderOrObjectWithContext(null); - - const folderToDelete = this.objectFolder; - onDeleteObjects(objectsWithContext, doRemove => { - if (!doRemove) return; - const container = this._isGlobal - ? globalObjectsContainer - : objectsContainer; - if (container) { - objectsToDelete.forEach(object => { - container.removeObject(object.getName()); - }); - } - - folderColors.remove(folderToDelete, this._isGlobal); - folderToDelete.getParent().removeFolderChild(folderToDelete); - forceUpdateList(); - - onObjectModified(false); - }); - } - - copy(): void {} - cut(): void {} - - paste(): void { - if (!Clipboard.has(OBJECT_CLIPBOARD_KIND)) return; - - const clipboardContent = Clipboard.get(OBJECT_CLIPBOARD_KIND); - const serializedObject = SafeExtractor.extractObjectProperty( - clipboardContent, - 'object' - ); - const objectName = SafeExtractor.extractStringProperty( - clipboardContent, - 'name' - ); - const objectType = SafeExtractor.extractStringProperty( - clipboardContent, - 'type' - ); - - if (!objectName || !objectType || !serializedObject) return; - - const { - project, - globalObjectsContainer, - objectsContainer, - onObjectPasted, - expandFolders, - onObjectModified, - onObjectCreated, - } = this.props; - - const isTheFirstOfItsTypeInProject = !gd.UsedObjectTypeFinder.scanProject( - project, - objectType - ); - - const newObjectWithContext = addSerializedObjectToObjectsContainer({ - project, - globalObjectsContainer, - objectsContainer, - objectName, - positionObjectFolderOrObjectWithContext: { - objectFolderOrObject: this.objectFolder, - global: this._isGlobal, - }, - objectType, - serializedObject, - addInsideFolder: true, - }); - - onObjectCreated( - [newObjectWithContext.object], - isTheFirstOfItsTypeInProject - ); - - onObjectModified(false); - if (onObjectPasted) onObjectPasted(newObjectWithContext.object); - - expandFolders([ - { objectFolderOrObject: this.objectFolder, global: this._isGlobal }, - ]); - } - - duplicate(): void {} - - getRightButton(i18n: I18nType): any { - return null; - } -} \ No newline at end of file diff --git a/daInviare/TreeViewRow.js b/daInviare/TreeViewRow.js deleted file mode 100644 index 04ad8f3e0bbe..000000000000 --- a/daInviare/TreeViewRow.js +++ /dev/null @@ -1,636 +0,0 @@ -// @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 ChevronArrowBottom from '../CustomSvgIcons/ChevronArrowBottom'; -import ChevronArrowRight from '../CustomSvgIcons/ChevronArrowRight'; -import Folder from '../CustomSvgIcons/Folder'; -import ListIcon from '../ListIcon'; -import useForceUpdate from '../../Utils/UseForceUpdate'; -import classes from './TreeView.module.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 { useDragDropManager } from 'react-dnd'; -import { dataObjectToProps } from '../../Utils/HTMLDataset'; -import { type DraggedItem } from '../DragAndDrop/DragSourceAndDropTarget'; -import classNames from 'classnames'; -import { TreeViewRightPrimaryButton } from './TreeViewRightPrimaryButton'; - -// $FlowFixMe[missing-local-annot] -const stopPropagation = e => e.stopPropagation(); - -const DELAY_BEFORE_OPENING_FOLDER_ON_DRAG_HOVER = 800; -const DELAY_BEFORE_OPENING_CONTEXT_MENU_ON_MOBILE = 1000; -export const TREE_VIEW_ROW_HEIGHT = 32; -const COLLAPSABLE_LINE_SIDE_DROP_ZONE_HEIGHT = 6; - -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(initialValue); - const inputRef = React.useRef(null); - - /** - * When mounting the component, focus and select content. - * We use setTimeout to ensure this runs after any deferred focus restoration - * from MUI Modal (used by the context menu on web), which runs in a useEffect - * cleanup. Without this, MUI's focus restoration steals focus from the input - * right after it mounts (introduced in React 18). - */ - React.useEffect(() => { - const id = setTimeout(() => { - const input = inputRef.current; - if (input) { - // We focus and select the text here, and not with autoFocus on the input, - // to avoid issues with focus restoration from MUI Modal (used by the context menu on web) - input.focus(); - input.select(); - } - }, 0); - return () => clearTimeout(id); - }, []); - - /** - * 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 ( -
- { - setValue(e.currentTarget.value); - }} - onClick={stopPropagation} - onDoubleClick={stopPropagation} - onContextMenu={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); - } - }} - /> -
- ); -}; - -// $FlowFixMe[missing-local-annot] -const memoized = memoizeOne((initialValue, callback) => callback()); - -type Props = {| - index: number, - style: any, - data: ItemData, - /** Used by react-window. */ - isScrolling?: boolean, -|}; - -// $FlowFixMe[missing-local-annot] -const TreeViewRow = (props: Props) => { - const { data, index, style } = props; - const { - flattenedData, - onOpen, - onClick, - onSelect, - onBlurField, - onEndRenaming, - renamedItemId, - onContextMenu, - canDrop, - onDrop, - onEditItem, - isMobile, - DragSourceAndDropTarget, - getItemHtmlId, - forceDefaultDraggingPreview, - shouldSelectUponContextMenuOpening, - } = data; - const node = flattenedData[index]; - const left = node.depth * 16; - const forceUpdate = useForceUpdate(); - const isStayingOverRef = React.useRef(false); - const openWhenOverTimeoutId = React.useRef(null); - const [whereToDrop, setWhereToDrop] = React.useState< - 'before' | 'after' | 'inside' - >('before'); - const containerRef = React.useRef(null); - const dragDropManager = useDragDropManager(); - const openContextMenu = React.useCallback( - // $FlowFixMe[missing-local-annot] - ({ clientX, clientY }) => { - // When the context menu opens it intercepts subsequent touch events, - // so the drag backend never receives touchend/touchcancel and the drag - // stays active indefinitely. End it explicitly before opening the menu. - if (dragDropManager.getMonitor().isDragging()) { - dragDropManager.getActions().endDrag(); - } - onContextMenu({ - index: index, - item: node.item, - x: clientX, - y: clientY, - }); - }, - [dragDropManager, onContextMenu, index, node.item] - ); - - const { contextMenuProps: longTouchForContextMenuProps } = useLongTouch( - openContextMenu, - { - delay: DELAY_BEFORE_OPENING_CONTEXT_MENU_ON_MOBILE, - } - ); - - const onClickItem = React.useCallback( - // $FlowFixMe[missing-local-annot] - event => { - if (!node || node.item.isPlaceholder) return; - if (node.item.isRoot) { - onOpen(node); - return; - } - onSelect({ node, exclusive: !(event.metaKey || event.ctrlKey) }); - onClick(node); - }, - [onClick, onSelect, node, onOpen] - ); - - const onDoubleClickItem = React.useCallback( - // $FlowFixMe[missing-local-annot] - e => { - if (!node || !node.hasChildren || node.disableCollapse) return; - onOpen(node); - }, - [node, onOpen] - ); - - const selectAndOpenContextMenu = React.useCallback( - (event: MouseEvent) => { - if (!node.item.isRoot) onClickItem(event); - openContextMenu(event); - }, - [node, onClickItem, openContextMenu] - ); - - const setIsStayingOver = React.useCallback( - (active: boolean, canDrop: boolean) => { - // Do not open folder if cannot drop into it. It's implied that if it - // cannot be dropped in the folder, it cannot be dropped in any subfolders - // of the folder. - if (!canDrop && active) return; - if (active !== isStayingOverRef.current) { - isStayingOverRef.current = active; - forceUpdate(); - } - }, - [forceUpdate] - ); - - /** - * Effect that opens the node if the user is dragging another node and stays - * over the node. - */ - React.useEffect( - () => { - if ( - isStayingOverRef.current && - !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; - }; - } - }, - // We want to have isStayingOverRef.current as dependency. - // eslint-disable-next-line react-hooks/exhaustive-deps - [onOpen, node, forceUpdate, isStayingOverRef.current] - ); - - const endRenaming = React.useCallback( - (newValue: string) => { - onEndRenaming(node.item, newValue); - }, - [onEndRenaming, node.item] - ); - - const getContainerYPosition = React.useCallback(() => { - if (containerRef.current) { - return containerRef.current.getBoundingClientRect().top; - } - }, []); - - const displayAsFolder = node.canHaveChildren; - - // Create an empty pixel image once to override the default drag preview of all items. - const emptyImage = new Image(); - emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; - - return ( -
- { - if (!node.selected) onSelect({ node, exclusive: !node.selected }); - - if (forceDefaultDraggingPreview) { - return {}; - } - - // We return the item name and thumbnail to be used by the - // drag preview if this is not a folder. - // We can't use the item itself because it's not serializable. - if (typeof node.name === 'string' && !displayAsFolder) { - const draggedItem: DraggedItem = { - name: node.name, - thumbnail: node.thumbnailSrc || undefined, - is3D: - // $FlowFixMe[incompatible-type] - // $FlowFixMe[prop-missing] - !!node.item.content && - !!node.item.content.is3D && - // $FlowFixMe[incompatible-type] - node.item.content.is3D(), - }; - return draggedItem; - } - - // Otherwise, we let the default drag preview be displayed. - return {}; - }} - canDrag={() => - // Prevent dragging of root folder or placeholder. - !node.item.isRoot && - !node.item.isPlaceholder && - // 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, whereToDrop) : () => 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 <= - COLLAPSABLE_LINE_SIDE_DROP_ZONE_HEIGHT - ? 'before' - : y - containerYPosition <= - TREE_VIEW_ROW_HEIGHT + - COLLAPSABLE_LINE_SIDE_DROP_ZONE_HEIGHT - ? '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 <= - COLLAPSABLE_LINE_SIDE_DROP_ZONE_HEIGHT - ? 'before' - : 'inside' - ); - } - } else { - setWhereToDrop( - y - containerYPosition <= TREE_VIEW_ROW_HEIGHT / 2 - ? 'before' - : 'after' - ); - } - } - }} - > - {({ - connectDragSource, - connectDropTarget, - connectDragPreview, - isOver, - canDrop, - }) => { - setIsStayingOver(isOver, canDrop); - - const isRenaming = renamedItemId === node.id; - - let itemRow = ( -
- {displayAsFolder ? ( - <> - { - e.stopPropagation(); - onOpen(node); - }} - disabled={node.disableCollapse} - > - {node.collapsed ? ( - - ) : ( - - )} - - {node.thumbnailSrc === 'NO_ICON' ? ( - node.dataset && node.dataset.folderColor ? ( - - - - - ) : null - ) : node.thumbnailSrc && node.thumbnailSrc !== 'FOLDER' ? ( -
- -
- ) : ( - !node.item.isRoot && ( - - ) - )} - - ) : node.thumbnailSrc ? ( -
- -
- ) : null} - {renamedItemId === node.id && typeof node.name === 'string' ? ( - - ) : ( - - {node.name} - - )} -
- ); - - // If this is an object, connect the drag preview with an empty image - // to override the default drag preview. - if (typeof node.name === 'string' && !displayAsFolder) { - connectDragPreview(emptyImage); - } else { - // If not (folder for instance), just use the item row as drag preview. - itemRow = connectDragPreview(itemRow); - } - - const rightButtons = Array.isArray(node.rightButton) - ? node.rightButton - : node.rightButton - ? [node.rightButton] - : []; - - const shouldDisplayMenu = - !node.shouldHideMenuIcon && - !isMobile && - (!node.item.isRoot || node.shouldHideMenuIcon !== null) && - !node.item.isPlaceholder; - - const dragSource = connectDragSource( -
- {isOver && whereToDrop === 'before' && ( - - )} -
onEditItem(node.item) : undefined - } - onContextMenu={ - isRenaming - ? undefined - : shouldSelectUponContextMenuOpening - ? selectAndOpenContextMenu - : openContextMenu - } - {...(isRenaming ? {} : longTouchForContextMenuProps)} - > - {itemRow} - {(node.rightComponent || - rightButtons.length > 0 || - shouldDisplayMenu) && ( -
- {node.rightComponent} - {rightButtons.map(rightButton => - rightButton.primary ? ( - { - e.stopPropagation(); - if (rightButton.click) { - rightButton.click(); - } - }} - label={ - rightButton.showPrimaryLabel - ? rightButton.label - : null - } - disabled={ - rightButton.enabled === undefined - ? false - : !rightButton.enabled - } - icon={rightButton.icon} - /> - ) : ( - { - e.stopPropagation(); - if (rightButton.click) { - rightButton.click(); - } - }} - tooltip={rightButton.label} - disabled={ - rightButton.enabled === undefined - ? false - : !rightButton.enabled - } - > - {rightButton.icon} - - ) - )} - {shouldDisplayMenu && ( - { - e.stopPropagation(); - onContextMenu({ - item: node.item, - index, - x: e.clientX, - y: e.clientY, - }); - }} - > - - - )} -
- )} -
- {isOver && whereToDrop === 'after' && ( - - )} -
- ); - - const shouldDisplayDropIndicator = - isOver && - whereToDrop === 'inside' && - displayAsFolder && - !node.item.isRoot; - const dropIndicatorClassName = shouldDisplayDropIndicator - ? canDrop - ? classes.withCanDropInsideIndicator - : classes.withCannotDropInsideIndicator - : null; - - const dropTarget = connectDropTarget( -
- {dragSource} -
- ); - - return ( -
0, - })} - > - {dropTarget} -
- ); - }} -
-
- ); -}; - -// $FlowFixMe[incompatible-type] - memo does not support having a generic in the props. -// $FlowFixMe[missing-type-arg] -export default (React.memo( - TreeViewRow, - areEqual -): React.ComponentType); diff --git a/daInviare/index.js b/daInviare/index.js deleted file mode 100644 index 6ed7b68d7dff..000000000000 --- a/daInviare/index.js +++ /dev/null @@ -1,1934 +0,0 @@ -// @flow -import { Trans } from '@lingui/macro'; -import { I18n } from '@lingui/react'; -import { type I18n as I18nType } from '@lingui/core'; -import { t } from '@lingui/macro'; - -import * as React from 'react'; -import { AutoSizer } from 'react-virtualized'; -import Background from '../UI/Background'; -import CompactSearchBar from '../UI/CompactSearchBar'; -import NewObjectDialog from '../AssetStore/NewObjectDialog'; -import AssetSwappingDialog from '../AssetStore/AssetSwappingDialog'; -import newNameGenerator from '../Utils/NewNameGenerator'; -import Window from '../Utils/Window'; -import { showWarningBox } from '../UI/Messages/MessageBox'; -import { type ObjectEditorTab } from '../ObjectEditor/ObjectEditorDialog'; -import type { ObjectWithContext } from '../ObjectsList/EnumerateObjects'; -import TreeView, { - type TreeViewInterface, - type MenuButton, -} from '../UI/TreeView'; -import { type MenuItemTemplate } from '../UI/Menu/Menu.flow'; -import { type UnsavedChanges } from '../MainFrame/UnsavedChangesContext'; -import { type HotReloadPreviewButtonProps } from '../HotReload/HotReloadPreviewButton'; -import useForceUpdate from '../Utils/UseForceUpdate'; -import { type ResourceManagementProps } from '../ResourcesList/ResourceSource'; -import { Column } from '../UI/Grid'; -import Add from '../UI/CustomSvgIcons/Add'; -import InAppTutorialContext from '../InAppTutorial/InAppTutorialContext'; -import { - getFoldersAscendanceWithoutRootFolder, - type ObjectFolderOrObjectWithContext, -} from './EnumerateObjectFolderOrObject'; -import { mapFor } from '../Utils/MapFor'; -import { LineStackLayout } from '../UI/Layout'; -import KeyboardShortcuts from '../UI/KeyboardShortcuts'; -import Link from '../UI/Link'; -import { getHelpLink } from '../Utils/HelpLink'; -import useAlertDialog from '../UI/Alert/useAlertDialog'; -import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer'; -import ErrorBoundary from '../UI/ErrorBoundary'; -import { getInsertionParentAndPositionFromSelection } from '../Utils/ObjectFolders'; -import { - ObjectTreeViewItemContent, - getObjectTreeViewItemId, - type ObjectTreeViewItemProps, -} from './ObjectTreeViewItemContent'; -import { - ObjectFolderTreeViewItemContent, - getObjectFolderTreeViewItemId, - expandAllSubfolders, - folderColors, - type ObjectFolderTreeViewItemProps, -} from './ObjectFolderTreeViewItemContent'; -import { ProjectScopedContainersAccessor } from '../InstructionOrExpression/EventsScope'; -import { type HTMLDataset } from '../Utils/HTMLDataset'; -import type { MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow'; -import type { EventsScope } from '../InstructionOrExpression/EventsScope'; -import { type InstallAssetOutput } from '../AssetStore/InstallAsset'; -import { exceptionallyGuardAgainstDeadObject } from '../Utils/IsNullPtr'; - -const gd: libGDevelop = global.gd; - -const sceneObjectsRootFolderId = 'scene-objects'; -const globalObjectsRootFolderId = 'global-objects'; -const globalObjectsEmptyPlaceholderId = 'global-empty-placeholder'; -const sceneObjectsEmptyPlaceholderId = 'scene-empty-placeholder'; - -const globalObjectsWikiLink = getHelpLink( - '/interface/scene-editor/global-objects/', - ':~:text=Global%20objects%20are%20objects%20which,are%20usable%20by%20all%20Scenes' -); - -const styles = { - listContainer: { - flex: 1, - display: 'flex', - flexDirection: 'column', - }, - autoSizerContainer: { flex: 1 }, - autoSizer: { width: '100%' }, - colorPickerCard: { - position: 'fixed', - zIndex: 1000, - background: '#1e1e1e', - borderRadius: 12, - border: '0.5px solid rgba(255,255,255,0.1)', - width: 300, - overflow: 'hidden', - boxShadow: '0 8px 32px rgba(0,0,0,0.6)', - userSelect: 'none', - }, - colorPickerHeader: { - padding: '14px 16px 12px', - borderBottom: '0.5px solid rgba(255,255,255,0.08)', - display: 'flex', - alignItems: 'center', - gap: 10, - cursor: 'grab', - }, - colorPickerBody: { - padding: 16, - }, - colorPickerGrid: { - display: 'grid', - gridTemplateColumns: 'repeat(7, 1fr)', - gap: 6, - marginBottom: 14, - }, - colorPickerHexRow: { - display: 'flex', - alignItems: 'center', - gap: 8, - marginBottom: 14, - background: 'rgba(255,255,255,0.05)', - border: '0.5px solid rgba(255,255,255,0.1)', - borderRadius: 8, - padding: '7px 10px', - }, - colorPickerHexDot: { - width: 22, - height: 22, - borderRadius: 4, - flexShrink: 0, - }, - colorPickerHexText: { - fontSize: 13, - color: 'rgba(255,255,255,0.45)', - fontFamily: 'monospace', - }, - colorPickerCustomBtn: { - marginLeft: 'auto', - display: 'flex', - alignItems: 'center', - gap: 5, - padding: '4px 10px', - borderRadius: 6, - border: '0.5px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.07)', - color: 'rgba(255,255,255,0.6)', - fontSize: 12, - cursor: 'pointer', - }, - colorPickerActions: { - display: 'flex', - gap: 8, - }, - colorPickerBtnConfirm: { - flex: 1, - padding: '8px', - borderRadius: 8, - border: 'none', - background: '#6d28d9', - color: 'white', - fontSize: 13, - fontWeight: 500, - cursor: 'pointer', - }, - colorPickerBtnReset: { - padding: '8px 14px', - borderRadius: 8, - border: '0.5px solid rgba(255,255,255,0.15)', - background: 'transparent', - color: 'rgba(255,255,255,0.5)', - fontSize: 20, - cursor: 'pointer', - lineHeight: 1, - }, - colorPickerBtnCancel: { - padding: '8px 14px', - borderRadius: 8, - border: '0.5px solid rgba(255,255,255,0.15)', - background: 'transparent', - color: 'rgba(255,255,255,0.5)', - fontSize: 13, - cursor: 'pointer', - }, -}; - -export const getLabelsForObjectsAndGroupsLists = ( - scope: EventsScope -): {| - localScopeObjectsTitle: MessageDescriptor, - higherScopeObjectsTitle: MessageDescriptor | null, - localScopeGroupsTitle: MessageDescriptor | null, - higherScopeGroupsTitle: MessageDescriptor | null, -|} => { - if (scope.layout) { - return { - localScopeObjectsTitle: t`Scene Objects`, - higherScopeObjectsTitle: t`Global Objects`, - localScopeGroupsTitle: t`Scene Groups`, - higherScopeGroupsTitle: t`Global Groups`, - }; - } else if (scope.eventsBasedObject) { - return { - localScopeObjectsTitle: t`Object's children`, - higherScopeObjectsTitle: null, - localScopeGroupsTitle: t`Object's groups`, - higherScopeGroupsTitle: null, - }; - } - - throw new Error('Scope not recognized.'); -}; - -export const getTreeViewItemIdFromObjectFolderOrObject = ( - objectFolderOrObject: gdObjectFolderOrObject -): string => { - if (objectFolderOrObject.isFolder()) { - return getObjectFolderTreeViewItemId(objectFolderOrObject); - } - const object = exceptionallyGuardAgainstDeadObject( - objectFolderOrObject.getObject() - ); - if (!object) return `deleted-${objectFolderOrObject.ptr}`; - return getObjectTreeViewItemId(object); -}; - -export interface TreeViewItemContent { - getName(): string | React.Node; - getId(): string; - getHtmlId(index: number): ?string; - getDataSet(): ?HTMLDataset; - getThumbnail(): ?string; - onClick(): void; - buildMenuTemplate(i18n: I18nType, index: number): Array; - getRightButton(i18n: I18nType): ?MenuButton; - renderRightComponent(i18n: I18nType): ?React.Node; - rename(newName: string): void; - edit(): void; - delete(): void; - copy(): void; - paste(): void; - cut(): void; - duplicate(): void; - getIndex(): number; - isDescendantOf(treeViewItemContent: TreeViewItemContent): boolean; - isSibling(treeViewItemContent: TreeViewItemContent): boolean; - isGlobal(): boolean; - is3D(): boolean; - getObjectFolderOrObject(): gdObjectFolderOrObject | null; -} - -interface TreeViewItem { - isRoot?: boolean; - isPlaceholder?: boolean; - +content: TreeViewItemContent; - getChildren(i18n: I18nType): ?Array; -} - -class LeafTreeViewItem implements TreeViewItem { - content: TreeViewItemContent; - - constructor(content: TreeViewItemContent) { - this.content = content; - } - - getChildren(i18n: I18nType): ?Array { - return null; - } -} - -// $FlowFixMe[incompatible-type] -class PlaceHolderTreeViewItem implements TreeViewItem { - isPlaceholder = true; - content: TreeViewItemContent; - - constructor(id: string, label: string | React.Node) { - this.content = new LabelTreeViewItemContent(id, label); - } - - getChildren(i18n: I18nType): ?Array { - return null; - } -} - -const createTreeViewItem = ({ - objectFolderOrObject, - isGlobal, - objectFolderTreeViewItemProps, - objectTreeViewItemProps, -}: {| - objectFolderOrObject: gdObjectFolderOrObject, - isGlobal: boolean, - objectFolderTreeViewItemProps: ObjectFolderTreeViewItemProps, - objectTreeViewItemProps: ObjectTreeViewItemProps, -|}): TreeViewItem => { - if (objectFolderOrObject.isFolder()) { - return new ObjectFolderTreeViewItem({ - objectFolderOrObject: objectFolderOrObject, - global: isGlobal, - isRoot: false, - objectFolderTreeViewItemProps, - objectTreeViewItemProps, - content: new ObjectFolderTreeViewItemContent( - objectFolderOrObject, - isGlobal, - objectFolderTreeViewItemProps - ), - }); - } else { - return new LeafTreeViewItem( - new ObjectTreeViewItemContent( - objectFolderOrObject, - isGlobal, - objectTreeViewItemProps - ) - ); - } -}; - -// $FlowFixMe[incompatible-type] -class ObjectFolderTreeViewItem implements TreeViewItem { - isRoot: boolean; - global: boolean; - isPlaceholder = false; - content: TreeViewItemContent; - objectFolderOrObject: gdObjectFolderOrObject; - placeholder: ?PlaceHolderTreeViewItem; - objectFolderTreeViewItemProps: ObjectFolderTreeViewItemProps; - objectTreeViewItemProps: ObjectTreeViewItemProps; - - constructor({ - objectFolderOrObject, - global, - isRoot, - content, - placeholder, - objectFolderTreeViewItemProps, - objectTreeViewItemProps, - }: {| - objectFolderOrObject: gdObjectFolderOrObject, - global: boolean, - isRoot: boolean, - content: TreeViewItemContent, - placeholder?: PlaceHolderTreeViewItem, - objectFolderTreeViewItemProps: ObjectFolderTreeViewItemProps, - objectTreeViewItemProps: ObjectTreeViewItemProps, - |}) { - this.isRoot = isRoot; - this.global = global; - this.content = content; - this.objectFolderOrObject = objectFolderOrObject; - this.placeholder = placeholder; - this.objectFolderTreeViewItemProps = objectFolderTreeViewItemProps; - this.objectTreeViewItemProps = objectTreeViewItemProps; - } - - getChildren(i18n: I18nType): ?Array { - if (!exceptionallyGuardAgainstDeadObject(this.objectFolderOrObject)) - return this.placeholder ? [this.placeholder] : []; - - if (this.objectFolderOrObject.getChildrenCount() === 0) { - return this.placeholder ? [this.placeholder] : []; - } - return mapFor(0, this.objectFolderOrObject.getChildrenCount(), i => { - const child = this.objectFolderOrObject.getChildAt(i); - return createTreeViewItem({ - objectFolderOrObject: child, - isGlobal: this.global, - objectFolderTreeViewItemProps: this.objectFolderTreeViewItemProps, - objectTreeViewItemProps: this.objectTreeViewItemProps, - }); - }); - } -} - -class LabelTreeViewItemContent implements TreeViewItemContent { - id: string; - label: string | React.Node; - dataSet: { [string]: string }; - buildMenuTemplateFunction: ( - i18n: I18nType, - index: number - ) => Array; - rightButton: ?MenuButton; - - constructor( - id: string, - label: string | React.Node, - rightButton?: MenuButton | null, - buildMenuTemplateFunction?: () => Array - ) { - this.id = id; - this.label = label; - this.buildMenuTemplateFunction = (i18n: I18nType, index: number) => - // $FlowFixMe[incompatible-type] - [ - rightButton - ? { - id: rightButton.id, - label: i18n._(rightButton.label), - click: rightButton.click, - enabled: rightButton.enabled, - } - : null, - ...(buildMenuTemplateFunction ? buildMenuTemplateFunction() : []), - ].filter(Boolean); - this.rightButton = rightButton; - } - - getName(): string | React.Node { - return this.label; - } - - getId(): string { - return this.id; - } - - getRightButton(i18n: I18nType): ?MenuButton { - return this.rightButton; - } - - getHtmlId(index: number): ?string { - return this.id; - } - - getDataSet(): ?HTMLDataset { - return {}; - } - - getThumbnail(): ?string { - return null; - } - - onClick(): void {} - - // $FlowFixMe[missing-local-annot] - buildMenuTemplate(i18n: I18nType, index: number) { - return this.buildMenuTemplateFunction(i18n, index); - } - - renderRightComponent(i18n: I18nType): ?React.Node { - return null; - } - - rename(newName: string): void {} - - edit(): void {} - - delete(): void {} - - copy(): void {} - - paste(): void {} - - cut(): void {} - - duplicate(): void {} - - getIndex(): number { - return 0; - } - - isDescendantOf(treeViewItemContent: TreeViewItemContent): boolean { - return false; - } - - isSibling(treeViewItemContent: TreeViewItemContent): boolean { - return false; - } - - isGlobal(): boolean { - return false; - } - - is3D(): boolean { - return false; - } - - getObjectFolderOrObject(): gdObjectFolderOrObject | null { - return null; - } -} - -const getTreeViewItemName = (item: TreeViewItem) => item.content.getName(); -const getTreeViewItemId = (item: TreeViewItem) => item.content.getId(); -const getTreeViewItemHtmlId = (item: TreeViewItem, index: number) => - item.content.getHtmlId(index); -const getTreeViewItemChildren = (i18n: I18nType) => (item: TreeViewItem) => - item.getChildren(i18n); -const getTreeViewItemThumbnail = (item: TreeViewItem) => - item.content.getThumbnail(); -const getTreeViewItemDataSet = (item: TreeViewItem) => - item.content.getDataSet(); -const buildMenuTemplate = (i18n: I18nType) => ( - item: TreeViewItem, - index: number -) => item.content.buildMenuTemplate(i18n, index); -const renderTreeViewItemRightComponent = (i18n: I18nType) => ( - item: TreeViewItem -) => item.content.renderRightComponent(i18n); -const renameItem = (item: TreeViewItem, newName: string) => { - item.content.rename(newName); -}; -const onClickItem = (item: TreeViewItem) => { - item.content.onClick(); -}; -const editItem = (item: TreeViewItem) => { - item.content.edit(); -}; -const deleteItem = (item: TreeViewItem) => { - item.content.delete(); -}; -const duplicateItem = (item: TreeViewItem) => { - item.content.duplicate(); -}; -const getTreeViewItemRightButton = (i18n: I18nType) => (item: TreeViewItem) => - item.content.getRightButton(i18n); - -export const objectWithContextReactDndType = 'GD_OBJECT_WITH_CONTEXT'; - -const objectTypeToDefaultName = { - Sprite: 'NewSprite', - 'TiledSpriteObject::TiledSprite': 'NewTiledSprite', - 'ParticleSystem::ParticleEmitter': 'NewParticlesEmitter', - 'PanelSpriteObject::PanelSprite': 'NewPanelSprite', - 'PrimitiveDrawing::Drawer': 'NewShapePainter', - 'TextObject::Text': 'NewText', - 'BBText::BBText': 'NewBBText', - 'BitmapText::BitmapTextObject': 'NewBitmapText', - 'TextEntryObject::TextEntry': 'NewTextEntry', - 'TileMap::SimpleTileMap': 'NewTileMap', - 'TileMap::TileMap': 'NewExternalTileMap', - 'TileMap::CollisionMask': 'NewExternalTileMapMask', - 'MyDummyExtension::DummyObject': 'NewDummyObject', - 'Lighting::LightObject': 'NewLight', - 'TextInput::TextInputObject': 'NewTextInput', - 'Scene3D::Model3DObject': 'New3DModel', - 'Scene3D::Cube3DObject': 'New3DBox', - 'SpineObject::SpineObject': 'NewSpine', - 'Video::VideoObject': 'NewVideo', -}; - -export type ObjectsListInterface = {| - forceUpdateList: () => void, - openNewObjectDialog: () => void, - closeNewObjectDialog: () => void, -|}; - -type Props = {| - project: gdProject, - layout: ?gdLayout, - eventsFunctionsExtension: gdEventsFunctionsExtension | null, - eventsBasedObject: gdEventsBasedObject | null, - initialInstances?: gdInitialInstancesContainer, - projectScopedContainersAccessor: ProjectScopedContainersAccessor, - globalObjectsContainer: gdObjectsContainer | null, - objectsContainer: gdObjectsContainer, - - onSelectAllInstancesOfObjectInLayout?: string => void, - resourceManagementProps: ResourceManagementProps, - onDeleteObjects: ( - objectWithContext: ObjectWithContext[], - cb: (boolean) => void - ) => void, - onRenameObjectFolderOrObjectWithContextFinish: ( - objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext, - newName: string, - cb: (boolean) => void - ) => void, - selectedObjectFolderOrObjectsWithContext: Array, - - beforeSetAsGlobalObject?: (objectName: string) => boolean, - canSetAsGlobalObject?: boolean, - onSetAsGlobalObject: (object: gdObject) => void, - - onEditObject: (object: gdObject, initialTab: ?ObjectEditorTab) => void, - onOpenEventBasedObjectEditor: ( - extensionName: string, - eventsBasedObjectName: string - ) => void, - onOpenEventBasedObjectVariantEditor: ( - extensionName: string, - eventsBasedObjectName: string, - variantName: string - ) => void, - onExportAssets: () => void, - onImportAssets: () => void, - onObjectCreated: ( - objects: Array, - isTheFirstOfItsTypeInProject: boolean - ) => void, - onObjectEdited: ( - objectWithContext: ObjectWithContext, - hasResourceChanged: boolean - ) => void, - onObjectFolderOrObjectWithContextSelected: ( - ?ObjectFolderOrObjectWithContext - ) => void, - onObjectPasted?: gdObject => void, - getValidatedObjectOrGroupName: (newName: string, global: boolean) => string, - onAddObjectInstance: (objectName: string) => void, - onWillInstallExtension: (extensionNames: Array) => void, - onExtensionInstalled: (extensionNames: Array) => void, - - getThumbnail: ( - project: gdProject, - objectConfiguration: gdObjectConfiguration - ) => string, - unsavedChanges?: ?UnsavedChanges, - hotReloadPreviewButtonProps: HotReloadPreviewButtonProps, - isListLocked: boolean, -|}; - -// ─── Componente ColorPickerDialog ──────────────────────────────────────────── - -const PRESET_COLORS = [ - '#ef4444', '#f97316', '#eab308', '#22c55e', - '#3b82f6', '#a855f7', '#ec4899', '#14b8a6', - '#6366f1', '#f43f5e', '#84cc16', '#06b6d4', - '#f59e0b', '#78716c', -]; - -type ColorPickerDialogProps = {| - objectFolder: gdObjectFolderOrObject, - isGlobal: boolean, - onClose: () => void, - onForceUpdate: () => void, -|}; - -const ColorPickerDialog = ({ - objectFolder, - isGlobal, - onClose, - onForceUpdate, -}: ColorPickerDialogProps) => { - const currentColor = folderColors.get(objectFolder, isGlobal) || '#a855f7'; - const [selectedColor, setSelectedColor] = React.useState(currentColor); - const nativeInputRef = React.useRef(null); - - const [pos, setPos] = React.useState({ - x: window.innerWidth / 2 - 150, - y: window.innerHeight / 2 - 160, - }); - const dragging = React.useRef(false); - const dragOffset = React.useRef({ x: 0, y: 0 }); - - const onMouseDown = (e) => { - dragging.current = true; - dragOffset.current = { x: e.clientX - pos.x, y: e.clientY - pos.y }; - e.preventDefault(); - }; - - React.useEffect(() => { - const onMouseMove = (e) => { - if (!dragging.current) return; - setPos({ x: e.clientX - dragOffset.current.x, y: e.clientY - dragOffset.current.y }); - }; - const onMouseUp = () => { dragging.current = false; }; - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - return () => { - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('mouseup', onMouseUp); - }; - }, []); - - const handleConfirm = () => { - folderColors.set(objectFolder, isGlobal, selectedColor); - onForceUpdate(); - onClose(); - }; - - const handleReset = () => { - // Reset to default grey instead of removing, so the SVG folder icon is kept - folderColors.set(objectFolder, isGlobal, '#6b7280'); - onForceUpdate(); - onClose(); - }; - - const handleCustomPick = () => { - if (nativeInputRef.current) nativeInputRef.current.click(); - }; - - const folderName = objectFolder.getFolderName(); - - return ( -
e.stopPropagation()} - > -
- - - - - Folder color - {folderName} -
- -
-
- {PRESET_COLORS.map(color => ( -
setSelectedColor(color)} - /> - ))} -
- -
-
- {selectedColor} - - setSelectedColor(e.target.value)} - style={{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }} - /> -
- -
- - - -
-
-
- ); -}; -// ───────────────────────────────────────────────────────────────────────────── - -const ObjectsList = React.forwardRef( - ( - { - project, - layout, - eventsFunctionsExtension, - eventsBasedObject, - initialInstances, - projectScopedContainersAccessor, - globalObjectsContainer, - objectsContainer, - resourceManagementProps, - onSelectAllInstancesOfObjectInLayout, - onDeleteObjects, - onRenameObjectFolderOrObjectWithContextFinish, - selectedObjectFolderOrObjectsWithContext, - - beforeSetAsGlobalObject, - canSetAsGlobalObject, - onSetAsGlobalObject, - - onEditObject, - onOpenEventBasedObjectEditor, - onOpenEventBasedObjectVariantEditor, - onExportAssets, - onImportAssets, - onObjectCreated, - onObjectEdited, - onObjectFolderOrObjectWithContextSelected, - onObjectPasted, - getValidatedObjectOrGroupName, - onAddObjectInstance, - onWillInstallExtension, - onExtensionInstalled, - - getThumbnail, - unsavedChanges, - hotReloadPreviewButtonProps, - isListLocked, - }: Props, - ref - ) => { - const { currentlyRunningInAppTutorial } = React.useContext( - InAppTutorialContext - ); - const [searchText, setSearchText] = React.useState(''); - const { showDeleteConfirmation } = useAlertDialog(); - const treeViewRef = React.useRef>(null); - const forceUpdate = useForceUpdate(); - const { isMobile } = useResponsiveWindowSize(); - - const forceUpdateList = React.useCallback( - () => { - forceUpdate(); - if (treeViewRef.current) treeViewRef.current.forceUpdateList(); - }, - [forceUpdate] - ); - - const [newObjectDialogOpen, setNewObjectDialogOpen] = React.useState<{ - from: ObjectFolderOrObjectWithContext | null, - } | null>(null); - - // ── STATO COLOR PICKER ────────────────────────────────────────────────── - const [colorPickerOpen, setColorPickerOpen] = React.useState<{ - objectFolder: gdObjectFolderOrObject, - isGlobal: boolean, - } | null>(null); - // ──────────────────────────────────────────────────────────────────────── - - React.useImperativeHandle(ref, () => ({ - forceUpdateList: () => { - forceUpdate(); - if (treeViewRef.current) treeViewRef.current.forceUpdateList(); - }, - openNewObjectDialog: () => { - setNewObjectDialogOpen({ from: null }); - }, - closeNewObjectDialog: () => { - setNewObjectDialogOpen(null); - }, - })); - - const [ - objectAssetSwappingDialogOpen, - setObjectAssetSwappingDialogOpen, - ] = React.useState<{ objectWithContext: ObjectWithContext } | null>(null); - - const keyboardShortcutsRef = React.useRef( - new KeyboardShortcuts({ - shortcutCallbacks: {}, - }) - ); - - const scrollToItem = React.useCallback((itemId: string) => { - if (treeViewRef.current) { - treeViewRef.current.scrollToItemFromId(itemId); - } - }, []); - - const addObject = React.useCallback( - (objectType: string) => { - const defaultName = project.hasEventsBasedObject(objectType) - ? 'New' + - (project.getEventsBasedObject(objectType).getDefaultName() || - project.getEventsBasedObject(objectType).getName()) - : // $FlowFixMe[invalid-computed-prop] - objectTypeToDefaultName[objectType] || 'NewObject'; - const name = newNameGenerator( - defaultName, - name => - objectsContainer.hasObjectNamed(name) || - (!!globalObjectsContainer && - globalObjectsContainer.hasObjectNamed(name)) - ); - - const isTheFirstOfItsTypeInProject = !gd.UsedObjectTypeFinder.scanProject( - project, - objectType - ); - - let object; - let objectFolderOrObjectWithContext; - if ( - newObjectDialogOpen && - newObjectDialogOpen.from && - !newObjectDialogOpen.from.global - ) { - const selectedItem = newObjectDialogOpen.from.objectFolderOrObject; - const { - folder: parentFolder, - position, - } = getInsertionParentAndPositionFromSelection(selectedItem); - - object = objectsContainer.insertNewObjectInFolder( - project, - objectType, - name, - parentFolder, - position - ); - objectFolderOrObjectWithContext = { - objectFolderOrObject: parentFolder.getObjectChild(name), - global: false, - }; - - if (treeViewRef.current) { - treeViewRef.current.openItems([ - getObjectFolderTreeViewItemId(parentFolder), - ]); - } - } else { - object = objectsContainer.insertNewObject( - project, - objectType, - name, - objectsContainer.getObjectsCount() - ); - objectFolderOrObjectWithContext = { - objectFolderOrObject: objectsContainer - .getRootFolder() - .getObjectChild(name), - global: false, - }; - } - - if (treeViewRef.current) - treeViewRef.current.openItems([sceneObjectsRootFolderId]); - - setTimeout(() => { - scrollToItem(getObjectTreeViewItemId(object)); - }, 100); - - setNewObjectDialogOpen(null); - // $FlowFixMe[constant-condition] - if (onEditObject) { - onEditObject(object); - onObjectFolderOrObjectWithContextSelected( - objectFolderOrObjectWithContext - ); - } - onObjectCreated([object], isTheFirstOfItsTypeInProject); - }, - [ - project, - newObjectDialogOpen, - onEditObject, - objectsContainer, - globalObjectsContainer, - scrollToItem, - onObjectCreated, - onObjectFolderOrObjectWithContextSelected, - ] - ); - - const onObjectsAddedFromAssets = React.useCallback( - ({ - createdObjects: objects, - isTheFirstOfItsTypeInProject, - }: InstallAssetOutput) => { - if (objects.length === 0) return; - - onObjectCreated(objects, isTheFirstOfItsTypeInProject); - - const lastObject = objects[objects.length - 1]; - - if (newObjectDialogOpen && newObjectDialogOpen.from) { - const { - objectFolderOrObject: selectedObjectFolderOrObject, - } = newObjectDialogOpen.from; - if (treeViewRef.current) { - treeViewRef.current.openItems( - getFoldersAscendanceWithoutRootFolder( - selectedObjectFolderOrObject - ).map(folder => getObjectFolderTreeViewItemId(folder)) - ); - } - } else { - if (treeViewRef.current) { - treeViewRef.current.openItems([sceneObjectsRootFolderId]); - } - } - setTimeout(() => { - scrollToItem(getObjectTreeViewItemId(lastObject)); - }, 100); - }, - [onObjectCreated, scrollToItem, newObjectDialogOpen] - ); - - const swapObjectAsset = React.useCallback( - (objectWithContext: ObjectWithContext) => { - setObjectAssetSwappingDialogOpen({ objectWithContext }); - }, - [] - ); - - const onAddNewObject = React.useCallback( - (item: ObjectFolderOrObjectWithContext | null) => { - setNewObjectDialogOpen({ from: item }); - }, - [] - ); - - // ── CALLBACK OPEN COLOR PICKER ────────────────────────────────────────── - const openColorPicker = React.useCallback( - (objectFolder: gdObjectFolderOrObject, isGlobal: boolean) => { - setColorPickerOpen({ objectFolder, isGlobal }); - }, - [] - ); - // ──────────────────────────────────────────────────────────────────────── - - const onObjectModified = React.useCallback( - (shouldForceUpdateList: boolean) => { - if (unsavedChanges) unsavedChanges.triggerUnsavedChanges(); - - if (shouldForceUpdateList) forceUpdateList(); - else forceUpdate(); - }, - [forceUpdate, forceUpdateList, unsavedChanges] - ); - - const selectObjectFolderOrObjectWithContext = React.useCallback( - (objectFolderOrObjectWithContext: ?ObjectFolderOrObjectWithContext) => { - onObjectFolderOrObjectWithContextSelected( - objectFolderOrObjectWithContext - ); - }, - [onObjectFolderOrObjectWithContextSelected] - ); - - const editName = React.useCallback( - (itemId: string) => { - const treeView = treeViewRef.current; - if (treeView) { - if (isMobile) { - treeView.scrollToItemFromId(itemId, 'start'); - } - treeView.renameItemFromId(itemId); - } - }, - [isMobile] - ); - - const getClosestVisibleParentId = ( - objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext - ): ?string => { - const treeView = treeViewRef.current; - if (!treeView) return null; - const { objectFolderOrObject } = objectFolderOrObjectWithContext; - const topToBottomAscendanceId = getFoldersAscendanceWithoutRootFolder( - objectFolderOrObject - ) - .reverse() - .map(parent => getObjectFolderTreeViewItemId(objectFolderOrObject)); - const topToBottomAscendanceOpenness = treeView.areItemsOpenFromId( - topToBottomAscendanceId - ); - const firstClosedFolderIndex = topToBottomAscendanceOpenness.indexOf( - false - ); - if (firstClosedFolderIndex === -1) { - return getTreeViewItemIdFromObjectFolderOrObject(objectFolderOrObject); - } - // $FlowFixMe[incompatible-type] - return topToBottomAscendanceId[firstClosedFolderIndex]; - }; - - const setAsGlobalObject = React.useCallback( - ({ - i18n, - objectFolderOrObject, - index, - folder, - }: { - i18n: I18nType, - objectFolderOrObject: gdObjectFolderOrObject, - index?: number, - folder?: gdObjectFolderOrObject, - }) => { - if (!globalObjectsContainer) { - return; - } - const destinationFolder = - folder && folder.isFolder() - ? folder - : globalObjectsContainer.getRootFolder(); - if (objectFolderOrObject.isFolder()) return; - const object = objectFolderOrObject.getObject(); - - const objectName: string = object.getName(); - if (!objectsContainer.hasObjectNamed(objectName)) return; - - if (globalObjectsContainer.hasObjectNamed(objectName)) { - showWarningBox( - i18n._( - t`A global object with this name already exists. Please change the object name before setting it as a global object` - ), - { delayToNextTick: true } - ); - return; - } - - if (beforeSetAsGlobalObject && !beforeSetAsGlobalObject(objectName)) { - return; - } - - const answer = Window.showConfirmDialog( - i18n._( - t`Global elements help manage objects across multiple scenes and are recommended for frequently used objects. This action cannot be undone. - - Do you want to set this as global object?` - ) - ); - if (!answer) return; - - objectsContainer.moveObjectFolderOrObjectToAnotherContainerInFolder( - objectFolderOrObject, - globalObjectsContainer, - destinationFolder, - typeof index === 'number' - ? index - : globalObjectsContainer.getObjectsCount() - ); - gd.WholeProjectRefactorer.updateBehaviorsSharedData(project); - onObjectModified(true); - onSetAsGlobalObject(object); - - const newObjectFolderOrObjectWithContext = { - objectFolderOrObject, - global: true, - }; - selectObjectFolderOrObjectWithContext( - newObjectFolderOrObjectWithContext - ); - - setTimeout(() => { - scrollToItem(getObjectTreeViewItemId(object)); - }, 100); - }, - [ - project, - globalObjectsContainer, - objectsContainer, - beforeSetAsGlobalObject, - onSetAsGlobalObject, - onObjectModified, - selectObjectFolderOrObjectWithContext, - scrollToItem, - ] - ); - - const addFolder = React.useCallback( - (items: Array) => { - let newObjectFolderOrObjectWithContext; - if (items.length === 1) { - const { - objectFolderOrObject: selectedObjectFolderOrObject, - global, - } = items[0]; - if (selectedObjectFolderOrObject.isFolder()) { - const newFolder = selectedObjectFolderOrObject.insertNewFolder( - 'NewFolder', - 0 - ); - newObjectFolderOrObjectWithContext = { - objectFolderOrObject: newFolder, - global, - }; - // Assign default grey color to new folder - folderColors.set(newFolder, global, '#6b7280'); - if (treeViewRef.current) { - treeViewRef.current.openItems([ - getObjectFolderTreeViewItemId(items[0].objectFolderOrObject), - ]); - } - } else { - const parentFolder = selectedObjectFolderOrObject.getParent(); - const newFolder = parentFolder.insertNewFolder( - 'NewFolder', - parentFolder.getChildPosition(selectedObjectFolderOrObject) + 1 - ); - newObjectFolderOrObjectWithContext = { - objectFolderOrObject: newFolder, - global, - }; - // Assign default grey color to new folder - folderColors.set(newFolder, global, '#6b7280'); - } - } else { - const rootFolder = objectsContainer.getRootFolder(); - const newFolder = rootFolder.insertNewFolder('NewFolder', 0); - newObjectFolderOrObjectWithContext = { - objectFolderOrObject: newFolder, - global: false, - }; - // Assign default grey color to new folder - folderColors.set(newFolder, false, '#6b7280'); - } - selectObjectFolderOrObjectWithContext( - newObjectFolderOrObjectWithContext - ); - const itemsToOpen = getFoldersAscendanceWithoutRootFolder( - newObjectFolderOrObjectWithContext.objectFolderOrObject - ).map(folder => getObjectFolderTreeViewItemId(folder)); - itemsToOpen.push( - newObjectFolderOrObjectWithContext.global - ? globalObjectsRootFolderId - : sceneObjectsRootFolderId - ); - if (treeViewRef.current) treeViewRef.current.openItems(itemsToOpen); - - editName( - getObjectFolderTreeViewItemId( - newObjectFolderOrObjectWithContext.objectFolderOrObject - ) - ); - forceUpdateList(); - }, - [ - forceUpdateList, - objectsContainer, - selectObjectFolderOrObjectWithContext, - editName, - ] - ); - - const onMovedObjectFolderOrObjectToAnotherFolderInSameContainer = React.useCallback( - (objectFolderOrObjectWithContext: ObjectFolderOrObjectWithContext) => { - const treeView = treeViewRef.current; - if (treeView) { - const closestVisibleParentId = getClosestVisibleParentId( - objectFolderOrObjectWithContext - ); - if (closestVisibleParentId) { - treeView.animateItemFromId(closestVisibleParentId); - } - } - onObjectModified(true); - }, - [onObjectModified] - ); - - const expandFolders = React.useCallback( - ( - objectFolderOrObjectWithContexts: Array - ) => { - if (treeViewRef.current) { - treeViewRef.current.openItems( - objectFolderOrObjectWithContexts.map( - objectFolderOrObjectWithContext => - getObjectFolderTreeViewItemId( - objectFolderOrObjectWithContext.objectFolderOrObject - ) - ) - ); - } - }, - [] - ); - - const objectTreeViewItemProps = React.useMemo( - () => ({ - project, - globalObjectsContainer, - objectsContainer, - onObjectPasted, - onSelectAllInstancesOfObjectInLayout, - editName, - onEditObject, - onDeleteObjects, - onAddObjectInstance, - initialInstances, - onOpenEventBasedObjectEditor, - onOpenEventBasedObjectVariantEditor, - getValidatedObjectOrGroupName, - onRenameObjectFolderOrObjectWithContextFinish, - onObjectModified, - onObjectCreated, - swapObjectAsset, - onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, - canSetAsGlobalObject, - setAsGlobalObject, - getThumbnail, - showDeleteConfirmation, - selectObjectFolderOrObjectWithContext, - addFolder, - forceUpdateList, - forceUpdate, - isListLocked, - }), - [ - project, - globalObjectsContainer, - objectsContainer, - onObjectPasted, - onSelectAllInstancesOfObjectInLayout, - editName, - onEditObject, - onDeleteObjects, - onAddObjectInstance, - initialInstances, - onOpenEventBasedObjectEditor, - onOpenEventBasedObjectVariantEditor, - getValidatedObjectOrGroupName, - onRenameObjectFolderOrObjectWithContextFinish, - onObjectModified, - onObjectCreated, - swapObjectAsset, - onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, - canSetAsGlobalObject, - setAsGlobalObject, - getThumbnail, - showDeleteConfirmation, - selectObjectFolderOrObjectWithContext, - addFolder, - forceUpdateList, - forceUpdate, - isListLocked, - ] - ); - - const objectFolderTreeViewItemProps = React.useMemo( - () => ({ - project, - globalObjectsContainer, - objectsContainer, - onObjectPasted, - onObjectModified, - onObjectCreated, - editName, - expandFolders, - addFolder, - onAddNewObject, - onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, - onRenameObjectFolderOrObjectWithContextFinish, - onDeleteObjects, - selectObjectFolderOrObjectWithContext, - showDeleteConfirmation, - forceUpdateList, - forceUpdate, - isListLocked, - openColorPicker, // <-- AGGIUNTO - }), - [ - project, - globalObjectsContainer, - objectsContainer, - onObjectPasted, - onObjectModified, - onObjectCreated, - editName, - expandFolders, - addFolder, - onAddNewObject, - onMovedObjectFolderOrObjectToAnotherFolderInSameContainer, - onRenameObjectFolderOrObjectWithContextFinish, - onDeleteObjects, - selectObjectFolderOrObjectWithContext, - showDeleteConfirmation, - forceUpdateList, - forceUpdate, - isListLocked, - openColorPicker, // <-- AGGIUNTO - ] - ); - - const globalObjectsRootFolder = globalObjectsContainer - ? globalObjectsContainer.getRootFolder() - : null; - const objectsRootFolder = objectsContainer.getRootFolder(); - const labels = React.useMemo( - () => - getLabelsForObjectsAndGroupsLists( - projectScopedContainersAccessor.getScope() - ), - [projectScopedContainersAccessor] - ); - - const isEntirelyEmpty = - objectsContainer.getObjectsCount() === 0 && - (!globalObjectsContainer || - globalObjectsContainer.getObjectsCount() === 0); - - const getTreeViewData = React.useCallback( - (i18n: I18nType): Array => { - const treeViewItems = [ - globalObjectsRootFolder && - new ObjectFolderTreeViewItem({ - objectFolderOrObject: globalObjectsRootFolder, - global: true, - isRoot: true, - content: new LabelTreeViewItemContent( - globalObjectsRootFolderId, - i18n._(labels.higherScopeObjectsTitle), - null, - () => [ - { - label: i18n._(t`Add a folder`), - click: () => - addFolder([ - { - objectFolderOrObject: globalObjectsRootFolder, - global: true, - }, - ]), - }, - { type: 'separator' }, - { - label: i18n._(t`Expand all sub folders`), - click: () => - expandAllSubfolders( - globalObjectsRootFolder, - true, - expandFolders - ), - }, - ] - ), - placeholder: new PlaceHolderTreeViewItem( - globalObjectsEmptyPlaceholderId, - ( - - There is no{' '} - - Window.openExternalURL(globalObjectsWikiLink) - } - > - global object - {' '} - yet. - - ) - ), - objectTreeViewItemProps, - objectFolderTreeViewItemProps, - }), - new ObjectFolderTreeViewItem({ - objectFolderOrObject: objectsRootFolder, - global: false, - isRoot: true, - content: new LabelTreeViewItemContent( - sceneObjectsRootFolderId, - i18n._(labels.localScopeObjectsTitle), - { - primary: true, - showPrimaryLabel: isEntirelyEmpty, - icon: , - label: t`Add object`, - click: () => { - onAddNewObject(selectedObjectFolderOrObjectsWithContext[0]); - }, - id: 'add-new-object-button', - enabled: !isListLocked, - }, - () => [ - { - label: i18n._(t`Add a folder`), - click: () => - addFolder([ - { - objectFolderOrObject: objectsRootFolder, - global: false, - }, - ]), - enabled: !isListLocked, - }, - { type: 'separator' }, - { - label: i18n._(t`Expand all sub folders`), - click: () => - expandAllSubfolders( - objectsRootFolder, - false, - expandFolders - ), - }, - { type: 'separator' }, - { - label: i18n._(t`Export as assets`), - click: () => onExportAssets(), - }, - { - label: i18n._(t`Import assets`), - click: () => onImportAssets(), - }, - ] - ), - placeholder: new PlaceHolderTreeViewItem( - sceneObjectsEmptyPlaceholderId, - i18n._(t`Start by adding a new object.`) - ), - objectTreeViewItemProps, - objectFolderTreeViewItemProps, - }), - ].filter(Boolean); - // $FlowFixMe[incompatible-type] - return treeViewItems; - }, - [ - globalObjectsRootFolder, - labels.higherScopeObjectsTitle, - labels.localScopeObjectsTitle, - objectTreeViewItemProps, - objectFolderTreeViewItemProps, - objectsRootFolder, - isEntirelyEmpty, - isListLocked, - addFolder, - expandFolders, - onAddNewObject, - selectedObjectFolderOrObjectsWithContext, - onExportAssets, - onImportAssets, - ] - ); - - const selectedItems = React.useMemo( - () => { - return selectedObjectFolderOrObjectsWithContext.map( - ({ objectFolderOrObject, global }) => { - return createTreeViewItem({ - objectFolderOrObject, - isGlobal: global, - objectFolderTreeViewItemProps, - objectTreeViewItemProps, - }); - } - ); - }, - [ - selectedObjectFolderOrObjectsWithContext, - objectFolderTreeViewItemProps, - objectTreeViewItemProps, - ] - ); - - React.useEffect( - () => { - if (keyboardShortcutsRef.current) { - keyboardShortcutsRef.current.setShortcutCallback('onDelete', () => { - if (!isListLocked) { - deleteItem(selectedItems[0]); - } - }); - keyboardShortcutsRef.current.setShortcutCallback( - 'onDuplicate', - () => { - if (!isListLocked) { - duplicateItem(selectedItems[0]); - } - } - ); - keyboardShortcutsRef.current.setShortcutCallback('onRename', () => { - if (!isListLocked) { - editName(selectedItems[0].content.getId()); - } - }); - } - }, - [ - selectedObjectFolderOrObjectsWithContext, - editName, - selectedItems, - isListLocked, - ] - ); - - const canMoveSelectionTo = React.useCallback( - (destinationItem: TreeViewItem) => { - if (destinationItem.isRoot) return false; - if (destinationItem.isPlaceholder) { - if ( - destinationItem.content.getId() === - globalObjectsEmptyPlaceholderId && - selectedItems.length === 1 && - !selectedItems[0].content.isGlobal() - ) { - const objectFolderOrObject = selectedItems[0].content.getObjectFolderOrObject(); - return !!objectFolderOrObject && !objectFolderOrObject.isFolder(); - } - return false; - } - if ( - selectedItems.every( - selectedItem => - selectedItem.content.isGlobal() === - destinationItem.content.isGlobal() - ) - ) { - if ( - selectedItems[0] && - destinationItem.content.isDescendantOf(selectedItems[0].content) - ) { - return false; - } - return true; - } else if ( - selectedItems.length === 1 && - selectedItems.every( - selectedObject => selectedObject.content.isGlobal() === false - ) && - destinationItem.content.isGlobal() - ) { - const objectFolderOrObject = selectedItems[0].content.getObjectFolderOrObject(); - return !!objectFolderOrObject && !objectFolderOrObject.isFolder(); - } - - return false; - }, - [selectedItems] - ); - - const moveSelectionTo = React.useCallback( - ( - i18n: I18nType, - destinationItem: TreeViewItem, - where: 'before' | 'inside' | 'after' - ) => { - if (destinationItem.isRoot || selectedItems.length !== 1) { - return; - } - const selectedItem = selectedItems[0]; - const selectedObjectFolderOrObject = selectedItem.content.getObjectFolderOrObject(); - - if ( - !selectedObjectFolderOrObject || - destinationItem.content.getId() === selectedItem.content.getId() - ) { - return; - } - - if (destinationItem.isPlaceholder) { - if ( - destinationItem.content.getId() === - globalObjectsEmptyPlaceholderId && - selectedItems.length === 1 && - !selectedItem.content.isGlobal() - ) { - setAsGlobalObject({ - i18n, - objectFolderOrObject: selectedObjectFolderOrObject, - }); - } - return; - } - - const destinationObjectFolderOrObject = destinationItem.content.getObjectFolderOrObject(); - if (!destinationObjectFolderOrObject) { - return; - } - if ( - selectedItem.content.isGlobal() === false && - destinationItem.content.isGlobal() - ) { - let parent, index; - if ( - where === 'inside' && - destinationObjectFolderOrObject.isFolder() - ) { - parent = destinationObjectFolderOrObject; - index = 0; - } else { - parent = destinationObjectFolderOrObject.getParent(); - index = - destinationItem.content.getIndex() + (where === 'after' ? 1 : 0); - } - setAsGlobalObject({ - i18n, - objectFolderOrObject: selectedObjectFolderOrObject, - folder: parent, - index, - }); - return; - } - - if ( - selectedItem.content.isGlobal() === destinationItem.content.isGlobal() - ) { - let parent; - if ( - where === 'inside' && - destinationObjectFolderOrObject.isFolder() - ) { - parent = destinationObjectFolderOrObject; - } else { - parent = destinationObjectFolderOrObject.getParent(); - } - const selectedObjectFolderOrObjectParent = selectedObjectFolderOrObject.getParent(); - if (parent === selectedObjectFolderOrObjectParent) { - const fromIndex = selectedItem.content.getIndex(); - let toIndex = destinationItem.content.getIndex(); - if (toIndex > fromIndex) toIndex -= 1; - if (where === 'after') toIndex += 1; - selectedObjectFolderOrObjectParent.moveChild(fromIndex, toIndex); - } else { - if (destinationItem.content.isDescendantOf(selectedItem.content)) { - return; - } - const position = - where === 'inside' - ? 0 - : destinationItem.content.getIndex() + - (where === 'after' ? 1 : 0); - selectedObjectFolderOrObjectParent.moveObjectFolderOrObjectToAnotherFolder( - selectedObjectFolderOrObject, - parent, - position - ); - const treeView = treeViewRef.current; - if (treeView) { - const closestVisibleParentId = getClosestVisibleParentId({ - objectFolderOrObject: parent, - global: destinationItem.content.isGlobal(), - }); - if (closestVisibleParentId) { - treeView.animateItemFromId(closestVisibleParentId); - } - } - } - } else { - return; - } - onObjectModified(true); - }, - [onObjectModified, selectedItems, setAsGlobalObject] - ); - - const onCollapseItem = React.useCallback( - (item: TreeViewItem) => { - if (!selectedItems || selectedItems.length !== 1) return; - const selectedItem = selectedItems[0]; - if (!selectedItem) return; - if (selectedItem.content.isDescendantOf(item.content)) { - selectObjectFolderOrObjectWithContext(null); - } - }, - [selectObjectFolderOrObjectWithContext, selectedItems] - ); - - const listKey = project.ptr + ';' + objectsContainer.ptr; - const initiallyOpenedNodeIds = [ - globalObjectsRootFolder && globalObjectsRootFolder.getChildrenCount() > 0 - ? globalObjectsRootFolderId - : null, - sceneObjectsRootFolderId, - ].filter(Boolean); - - const arrowKeyNavigationProps = React.useMemo( - () => ({ - onGetItemInside: (item: TreeViewItem): ?TreeViewItem => { - if (item.isPlaceholder || item.isRoot) return null; - const objectFolderOrObject = item.content.getObjectFolderOrObject(); - if (!objectFolderOrObject) return null; - if (!objectFolderOrObject.isFolder()) return null; - else { - if (objectFolderOrObject.getChildrenCount() === 0) return null; - return createTreeViewItem({ - objectFolderOrObject: objectFolderOrObject.getChildAt(0), - isGlobal: item.content.isGlobal(), - objectFolderTreeViewItemProps, - objectTreeViewItemProps, - }); - } - }, - onGetItemOutside: (item: TreeViewItem): ?TreeViewItem => { - if (item.isPlaceholder || item.isRoot) return null; - const objectFolderOrObject = item.content.getObjectFolderOrObject(); - if (!objectFolderOrObject) return null; - const parent = objectFolderOrObject.getParent(); - if (parent.isRootFolder()) return null; - return createTreeViewItem({ - objectFolderOrObject: parent, - isGlobal: item.content.isGlobal(), - objectFolderTreeViewItemProps, - objectTreeViewItemProps, - }); - }, - }), - [objectFolderTreeViewItemProps, objectTreeViewItemProps] - ); - - return ( - - - - - - -
- - {({ i18n }) => ( -
- - {({ height }) => ( - // $FlowFixMe[incompatible-type] - // $FlowFixMe[incompatible-exact] - { - if (!items) { - selectObjectFolderOrObjectWithContext(null); - return; - } - const itemContentToSelect = items[0].content; - const objectFolderOrObjectToSelect = itemContentToSelect.getObjectFolderOrObject(); - if (objectFolderOrObjectToSelect) { - selectObjectFolderOrObjectWithContext({ - objectFolderOrObject: objectFolderOrObjectToSelect, - global: itemContentToSelect.isGlobal(), - }); - } else { - selectObjectFolderOrObjectWithContext(null); - } - }} - onRenameItem={renameItem} - buildMenuTemplate={buildMenuTemplate(i18n)} - onMoveSelectionToItem={(destinationItem, where) => - moveSelectionTo(i18n, destinationItem, where) - } - canMoveSelectionToItem={canMoveSelectionTo} - reactDndType={objectWithContextReactDndType} - initiallyOpenedNodeIds={initiallyOpenedNodeIds} - arrowKeyNavigationProps={arrowKeyNavigationProps} - shouldSelectUponContextMenuOpening - getItemRightButton={getTreeViewItemRightButton(i18n)} - renderRightComponent={renderTreeViewItemRightComponent( - i18n - )} - /> - )} - -
- )} -
-
- - {/* Dialog nuovi oggetti */} - {newObjectDialogOpen && ( - setNewObjectDialogOpen(null)} - onCreateNewObject={addObject} - onObjectsAddedFromAssets={onObjectsAddedFromAssets} - project={project} - layout={layout} - eventsFunctionsExtension={eventsFunctionsExtension} - eventsBasedObject={eventsBasedObject} - objectsContainer={objectsContainer} - resourceManagementProps={resourceManagementProps} - targetObjectFolderOrObjectWithContext={newObjectDialogOpen.from} - onWillInstallExtension={onWillInstallExtension} - onExtensionInstalled={onExtensionInstalled} - /> - )} - - {/* Dialog swap asset */} - {objectAssetSwappingDialogOpen && ( - { - setObjectAssetSwappingDialogOpen(null); - if (swappingDone) - onObjectEdited( - objectAssetSwappingDialogOpen.objectWithContext, - true - ); - }} - project={project} - layout={layout} - eventsBasedObject={eventsBasedObject} - objectsContainer={objectsContainer} - object={objectAssetSwappingDialogOpen.objectWithContext.object} - resourceManagementProps={resourceManagementProps} - onWillInstallExtension={onWillInstallExtension} - onExtensionInstalled={onExtensionInstalled} - /> - )} - - {/* ── COLOR PICKER DIALOG ── */} - {colorPickerOpen && ( - setColorPickerOpen(null)} - onForceUpdate={forceUpdateList} - /> - )} - {/* ───────────────────────── */} -
- ); - } -); - -const arePropsEqual = (prevProps: Props, nextProps: Props): boolean => - prevProps.selectedObjectFolderOrObjectsWithContext === - nextProps.selectedObjectFolderOrObjectsWithContext && - prevProps.project === nextProps.project && - prevProps.globalObjectsContainer === nextProps.globalObjectsContainer && - prevProps.objectsContainer === nextProps.objectsContainer; - -// $FlowFixMe[incompatible-type] -const MemoizedObjectsList = React.memo( - // $FlowFixMe[incompatible-type] - // $FlowFixMe[incompatible-exact] - ObjectsList, - arePropsEqual -); - -const ObjectsListWithErrorBoundary: React.ComponentType<{ - ...Props, - +ref?: React.RefSetter, -}> = React.forwardRef((props, ref) => ( - Objects list} - scope="scene-editor-objects-list" - > - {/* $FlowFixMe[incompatible-type] */} - - -)); - -export default ObjectsListWithErrorBoundary; diff --git a/newIDE/app/src/ObjectsList/ObjectFolderTreeViewItemContent.js b/newIDE/app/src/ObjectsList/ObjectFolderTreeViewItemContent.js index cd72ee40d37a..412f6ed24c8c 100644 --- a/newIDE/app/src/ObjectsList/ObjectFolderTreeViewItemContent.js +++ b/newIDE/app/src/ObjectsList/ObjectFolderTreeViewItemContent.js @@ -1,7 +1,6 @@ // @flow import { type I18n as I18nType } from '@lingui/core'; import { t } from '@lingui/macro'; - import * as React from 'react'; import Clipboard from '../Utils/Clipboard'; import { SafeExtractor } from '../Utils/SafeExtractor'; @@ -24,6 +23,70 @@ import { exceptionallyGuardAgainstDeadObject } from '../Utils/IsNullPtr'; const gd: libGDevelop = global.gd; +// Builds a stable path key like "Root/Enemies/Boss" by walking up the folder tree. +// This key survives page reloads, unlike objectFolder.ptr which is a memory address. +const getFolderStableKey = ( + objectFolder: gdObjectFolderOrObject, + isGlobal: boolean +): string => { + const parts: Array = []; + let current = objectFolder; + try { + while (current && !current.isRootFolder()) { + parts.unshift(current.getFolderName()); + current = current.getParent(); + } + } catch (e) { + // fallback: if walking fails, use the folder name only + parts.unshift(objectFolder.getFolderName()); + } + const scope = isGlobal ? 'global' : 'scene'; + return scope + '/' + parts.join('/'); +}; + +export const folderColors = { + get(objectFolder: gdObjectFolderOrObject, isGlobal: boolean): string | null { + try { + const key = getFolderStableKey(objectFolder, isGlobal); + const saved = localStorage.getItem('gdevelop_custom_folder_colors'); + const colors: { [string]: string } = saved ? JSON.parse(saved) : {}; + return colors[key] || null; + } catch (e) { + return null; + } + }, + set(objectFolder: gdObjectFolderOrObject, isGlobal: boolean, color: string) { + try { + const key = getFolderStableKey(objectFolder, isGlobal); + const saved = localStorage.getItem('gdevelop_custom_folder_colors'); + const colors: { [string]: string } = saved ? JSON.parse(saved) : {}; + colors[key] = color; + localStorage.setItem( + 'gdevelop_custom_folder_colors', + JSON.stringify(colors) + ); + } catch (e) { + console.error('Error saving folder color:', e); + } + }, + remove(objectFolder: gdObjectFolderOrObject, isGlobal: boolean) { + try { + const key = getFolderStableKey(objectFolder, isGlobal); + const saved = localStorage.getItem('gdevelop_custom_folder_colors'); + if (saved) { + const colors: { [string]: string } = JSON.parse(saved); + delete colors[key]; + localStorage.setItem( + 'gdevelop_custom_folder_colors', + JSON.stringify(colors) + ); + } + } catch (e) { + console.error('Error removing folder color:', e); + } + }, +}; + export const expandAllSubfolders = ( objectFolder: gdObjectFolderOrObject, isGlobal: boolean, @@ -81,14 +144,15 @@ export type ObjectFolderTreeViewItemProps = {| forceUpdateList: () => void, forceUpdate: () => void, isListLocked: boolean, + openColorPicker: ( + objectFolder: gdObjectFolderOrObject, + isGlobal: boolean + ) => void, |}; export const getObjectFolderTreeViewItemId = ( objectFolder: gdObjectFolderOrObject ): string => { - // Use the ptr as id since two folders can have the same name. - // If using folder name, this would need for methods when renaming - // the folder to keep it open. return `object-folder-${objectFolder.ptr}`; }; @@ -139,14 +203,18 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { return false; } - getName(): string | React.Node { + _getFolderName(): string { + if (!exceptionallyGuardAgainstDeadObject(this.objectFolder)) return ''; + return this.objectFolder.getFolderName(); + } + + getName(): string { if (!exceptionallyGuardAgainstDeadObject(this.objectFolder)) return ''; return this.objectFolder.getFolderName(); } getId(): string { - // getObjectFolderTreeViewItemId only uses .ptr, so it's safe even if dead. - return getObjectFolderTreeViewItemId(this.objectFolder); + return getObjectFolderTreeViewItemId(this.objectFolder) || ''; } getHtmlId(index: number): ?string { @@ -155,13 +223,18 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { getDataSet(): ?HTMLDataset { if (!exceptionallyGuardAgainstDeadObject(this.objectFolder)) return null; + const color = folderColors.get(this.objectFolder, this._isGlobal); return { folderName: this.objectFolder.getFolderName(), global: this._isGlobal.toString(), + ...(color ? { folderColor: color } : {}), }; } - getThumbnail(): ?string { + const color = folderColors.get(this.objectFolder, this._isGlobal); + if (color) { + return 'NO_ICON'; + } return 'FOLDER'; } @@ -169,16 +242,21 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { rename(newName: string): void { const safeNewName = newName.replaceAll('/', '-'); - if (this.getName() === safeNewName) { + if (this._getFolderName() === safeNewName) { return; } - + // Save the current color before renaming (the key will change with the new name) + const currentColor = folderColors.get(this.objectFolder, this._isGlobal); this.props.onRenameObjectFolderOrObjectWithContextFinish( { objectFolderOrObject: this.objectFolder, global: this._isGlobal }, safeNewName, doRename => { if (!doRename) return; - + // After rename, the folder path key has changed: remove old key and save with new key + if (currentColor) { + folderColors.remove(this.objectFolder, this._isGlobal); + folderColors.set(this.objectFolder, this._isGlobal, currentColor); + } this.props.onObjectModified(false); } ); @@ -186,7 +264,11 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { edit(): void {} - _getPasteLabel( + _openColorPicker(): void { + this.props.openColorPicker(this.objectFolder, this._isGlobal); + } + + getPasteLabel( i18n: I18nType, { isGlobalObject, @@ -220,9 +302,11 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { const container = this._isGlobal ? globalObjectsContainer : objectsContainer; + if (!container) { return []; } + const folderAndPathsInContainer = enumerateFoldersInContainer(container); folderAndPathsInContainer.unshift({ path: i18n._(t`Root folder`), @@ -234,9 +318,10 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { !folderAndPath.folder.isADescendantOf(this.objectFolder) && folderAndPath.folder !== this.objectFolder ); + return [ { - label: this._getPasteLabel(i18n, { + label: this.getPasteLabel(i18n, { isGlobalObject: this._isGlobal, isFolder: true, }), @@ -249,6 +334,11 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { accelerator: 'F2', enabled: !isListLocked, }, + { + label: i18n._(t`Change folder color`), + click: () => this._openColorPicker(), + enabled: !isListLocked, + }, { label: i18n._(t`Delete`), click: () => this.delete(), @@ -281,7 +371,6 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { }); }, })), - { type: 'separator' }, { label: i18n._(t`Create new folder...`), @@ -350,9 +439,10 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { } = this.props; const objectsToDelete = enumerateObjectsInFolder(this.objectFolder); + if (objectsToDelete.length === 0) { - // Folder is empty or contains only empty folders. selectObjectFolderOrObjectWithContext(null); + folderColors.remove(this.objectFolder, this._isGlobal); this.objectFolder.getParent().removeFolderChild(this.objectFolder); forceUpdateList(); return; @@ -360,6 +450,7 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { let message: MessageDescriptor; let title: MessageDescriptor; + if (objectsToDelete.length === 1) { message = t`Are you sure you want to remove this folder and with it the object ${objectsToDelete[0].getName()}? This can't be undone.`; title = t`Remove folder and object`; @@ -378,17 +469,9 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { global: this._isGlobal, })); - // TODO: Change selectedObjectFolderOrObjectWithContext so that it's easy - // to remove an item using keyboard only and to navigate with the arrow - // keys right after deleting it. selectObjectFolderOrObjectWithContext(null); const folderToDelete = this.objectFolder; - // It's important to call onDeleteObjects, because the parent might - // have to do some refactoring/clean up work before the object is deleted - // (typically, the SceneEditor will remove instances referring to the object, - // leading to the removal of their renderer - which can keep a reference to - // the object). onDeleteObjects(objectsWithContext, doRemove => { if (!doRemove) return; const container = this._isGlobal @@ -400,6 +483,7 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { }); } + folderColors.remove(folderToDelete, this._isGlobal); folderToDelete.getParent().removeFolderChild(folderToDelete); forceUpdateList(); @@ -408,7 +492,6 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { } copy(): void {} - cut(): void {} paste(): void { @@ -427,6 +510,7 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { clipboardContent, 'type' ); + if (!objectName || !objectType || !serializedObject) return; const { @@ -465,6 +549,7 @@ export class ObjectFolderTreeViewItemContent implements TreeViewItemContent { onObjectModified(false); if (onObjectPasted) onObjectPasted(newObjectWithContext.object); + expandFolders([ { objectFolderOrObject: this.objectFolder, global: this._isGlobal }, ]); diff --git a/newIDE/app/src/ObjectsList/index.js b/newIDE/app/src/ObjectsList/index.js index 6f22aa5b438e..3b7eff5b358f 100644 --- a/newIDE/app/src/ObjectsList/index.js +++ b/newIDE/app/src/ObjectsList/index.js @@ -49,6 +49,7 @@ import { ObjectFolderTreeViewItemContent, getObjectFolderTreeViewItemId, expandAllSubfolders, + folderColors, type ObjectFolderTreeViewItemProps, } from './ObjectFolderTreeViewItemContent'; import { ProjectScopedContainersAccessor } from '../InstructionOrExpression/EventsScope'; @@ -78,6 +79,102 @@ const styles = { }, autoSizerContainer: { flex: 1 }, autoSizer: { width: '100%' }, + colorPickerCard: { + position: 'fixed', + zIndex: 1000, + background: '#1e1e1e', + borderRadius: 12, + border: '0.5px solid rgba(255,255,255,0.1)', + width: 300, + overflow: 'hidden', + boxShadow: '0 8px 32px rgba(0,0,0,0.6)', + userSelect: 'none', + }, + colorPickerHeader: { + padding: '14px 16px 12px', + borderBottom: '0.5px solid rgba(255,255,255,0.08)', + display: 'flex', + alignItems: 'center', + gap: 10, + cursor: 'grab', + }, + colorPickerBody: { + padding: 16, + }, + colorPickerGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(7, 1fr)', + gap: 6, + marginBottom: 14, + }, + colorPickerHexRow: { + display: 'flex', + alignItems: 'center', + gap: 8, + marginBottom: 14, + background: 'rgba(255,255,255,0.05)', + border: '0.5px solid rgba(255,255,255,0.1)', + borderRadius: 8, + padding: '7px 10px', + }, + colorPickerHexDot: { + width: 22, + height: 22, + borderRadius: 4, + flexShrink: 0, + }, + colorPickerHexText: { + fontSize: 13, + color: 'rgba(255,255,255,0.45)', + fontFamily: 'monospace', + }, + colorPickerCustomBtn: { + marginLeft: 'auto', + display: 'flex', + alignItems: 'center', + gap: 5, + padding: '4px 10px', + borderRadius: 6, + border: '0.5px solid rgba(255,255,255,0.2)', + background: 'rgba(255,255,255,0.07)', + color: 'rgba(255,255,255,0.6)', + fontSize: 12, + cursor: 'pointer', + }, + colorPickerActions: { + display: 'flex', + gap: 8, + }, + colorPickerBtnConfirm: { + flex: 1, + padding: '8px', + borderRadius: 8, + border: 'none', + background: '#6d28d9', + color: 'white', + fontSize: 13, + fontWeight: 500, + cursor: 'pointer', + }, + colorPickerBtnReset: { + padding: '8px 14px', + borderRadius: 8, + border: '0.5px solid rgba(255,255,255,0.15)', + background: 'transparent', + color: 'rgba(255,255,255,0.5)', + fontSize: 20, + cursor: 'pointer', + lineHeight: 1, + }, + colorPickerBtnCancel: { + padding: '8px 14px', + borderRadius: 8, + border: '0.5px solid rgba(255,255,255,0.15)', + background: 'transparent', + color: 'rgba(255,255,255,0.5)', + fontSize: 13, + cursor: 'pointer', + }, }; export const getLabelsForObjectsAndGroupsLists = ( @@ -98,7 +195,7 @@ export const getLabelsForObjectsAndGroupsLists = ( } else if (scope.eventsBasedObject) { return { localScopeObjectsTitle: t`Object's children`, - higherScopeObjectsTitle: null, // Global objects not accessible from custom object. + higherScopeObjectsTitle: null, localScopeGroupsTitle: t`Object's groups`, higherScopeGroupsTitle: null, }; @@ -448,22 +545,7 @@ type Props = {| eventsFunctionsExtension: gdEventsFunctionsExtension | null, eventsBasedObject: gdEventsBasedObject | null, initialInstances?: gdInitialInstancesContainer, - /** The objects retrieved from ProjectScopedContainers must never be kept in a - * state as they may be temporary copies. - * It also contains "fake" objects like "Object" for the parent of custom objects. - * It's useful to check if an object name is taken, but not to edit ObjectsContainer. - * Also see `ProjectScopedContainers::MakeNewProjectScopedContainersForEventsBasedObject`. - * Search for "ProjectScopedContainers wrongly containing temporary objects containers or objects" - * in the codebase. - */ projectScopedContainersAccessor: ProjectScopedContainersAccessor, - - // These 2 containers always contains the "real" objects. - // TODO: they should be replaced by projectScopedContainersAccessor, but we can't use this - // as `ProjectScopedContainers` may return temporary objects that can't be edited or have references - // to them kept. - // Search for "ProjectScopedContainers wrongly containing temporary objects containers or objects" - // in the codebase. globalObjectsContainer: gdObjectsContainer | null, objectsContainer: gdObjectsContainer, @@ -522,6 +604,205 @@ type Props = {| isListLocked: boolean, |}; +// ─── Componente ColorPickerDialog ──────────────────────────────────────────── + +const PRESET_COLORS = [ + '#ef4444', + '#f97316', + '#eab308', + '#22c55e', + '#3b82f6', + '#a855f7', + '#ec4899', + '#14b8a6', + '#6366f1', + '#f43f5e', + '#84cc16', + '#06b6d4', + '#f59e0b', + '#78716c', +]; + +type ColorPickerDialogProps = {| + objectFolder: gdObjectFolderOrObject, + isGlobal: boolean, + onClose: () => void, + onForceUpdate: () => void, +|}; + +const ColorPickerDialog = ({ + objectFolder, + isGlobal, + onClose, + onForceUpdate, +}: ColorPickerDialogProps) => { + const currentColor = folderColors.get(objectFolder, isGlobal) || '#a855f7'; + const [selectedColor, setSelectedColor] = React.useState(currentColor); + const nativeInputRef = React.useRef(null); + + const [pos, setPos] = React.useState({ + x: window.innerWidth / 2 - 150, + y: window.innerHeight / 2 - 160, + }); + const dragging = React.useRef(false); + const dragOffset = React.useRef({ x: 0, y: 0 }); + + const onMouseDown = (e: MouseEvent) => { + dragging.current = true; + dragOffset.current = { x: e.clientX - pos.x, y: e.clientY - pos.y }; + e.preventDefault(); + }; + + React.useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + setPos({ + x: e.clientX - dragOffset.current.x, + y: e.clientY - dragOffset.current.y, + }); + }; + const onMouseUp = () => { + dragging.current = false; + }; + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + }, []); + + const handleConfirm = () => { + folderColors.set(objectFolder, isGlobal, selectedColor); + onForceUpdate(); + onClose(); + }; + + const handleReset = () => { + // Reset to default grey instead of removing, so the SVG folder icon is kept + folderColors.set(objectFolder, isGlobal, '#6b7280'); + onForceUpdate(); + onClose(); + }; + + const handleCustomPick = () => { + if (nativeInputRef.current) nativeInputRef.current.click(); + }; + + const folderName = objectFolder.getFolderName(); + + return ( +
e.stopPropagation()} + > +
+ + + + + + Folder color + + + {folderName} + +
+ +
+
+ {PRESET_COLORS.map(color => ( +
setSelectedColor(color)} + /> + ))} +
+ +
+
+ {selectedColor} + + setSelectedColor(e.target.value)} + style={{ + position: 'absolute', + opacity: 0, + width: 0, + height: 0, + pointerEvents: 'none', + }} + /> +
+ +
+ + + +
+
+
+ ); +}; +// ───────────────────────────────────────────────────────────────────────────── + const ObjectsList = React.forwardRef( ( { @@ -585,6 +866,13 @@ const ObjectsList = React.forwardRef( from: ObjectFolderOrObjectWithContext | null, } | null>(null); + // ── STATO COLOR PICKER ────────────────────────────────────────────────── + const [colorPickerOpen, setColorPickerOpen] = React.useState<{ + objectFolder: gdObjectFolderOrObject, + isGlobal: boolean, + } | null>(null); + // ──────────────────────────────────────────────────────────────────────── + React.useImperativeHandle(ref, () => ({ forceUpdateList: () => { forceUpdate(); @@ -603,10 +891,6 @@ const ObjectsList = React.forwardRef( setObjectAssetSwappingDialogOpen, ] = React.useState<{ objectWithContext: ObjectWithContext } | null>(null); - // Initialize keyboard shortcuts as empty. - // onDelete, onDuplicate and onRename callbacks are set in an effect because it applies - // to the selected item (that is a props). As it is stored in a ref, the keyboard shortcut - // instance does not update with selectedObjectFolderOrObjectsWithContext changes. const keyboardShortcutsRef = React.useRef( new KeyboardShortcuts({ shortcutCallbacks: {}, @@ -645,7 +929,6 @@ const ObjectsList = React.forwardRef( if ( newObjectDialogOpen && newObjectDialogOpen.from && - // If a scene objectFolderOrObject is selected, insert new object next to or inside it. !newObjectDialogOpen.from.global ) { const selectedItem = newObjectDialogOpen.from.objectFolderOrObject; @@ -689,16 +972,11 @@ const ObjectsList = React.forwardRef( if (treeViewRef.current) treeViewRef.current.openItems([sceneObjectsRootFolderId]); - // Scroll to the new object. - // Ideally, we'd wait for the list to be updated to scroll, but - // to simplify the code, we just wait a few ms for a new render - // to be done. setTimeout(() => { scrollToItem(getObjectTreeViewItemId(object)); - }, 100); // A few ms is enough for a new render to be done. + }, 100); setNewObjectDialogOpen(null); - // TODO Should it be called later? // $FlowFixMe[constant-condition] if (onEditObject) { onEditObject(object); @@ -729,9 +1007,6 @@ const ObjectsList = React.forwardRef( onObjectCreated(objects, isTheFirstOfItsTypeInProject); - // Here, the last object in the array might not be the last object - // in the tree view, given the fact that assets are added in parallel - // See (AssetPackInstallDialog.onInstallAssets). const lastObject = objects[objects.length - 1]; if (newObjectDialogOpen && newObjectDialogOpen.from) { @@ -750,13 +1025,9 @@ const ObjectsList = React.forwardRef( treeViewRef.current.openItems([sceneObjectsRootFolderId]); } } - // Scroll to the new object. - // Ideally, we'd wait for the list to be updated to scroll, but - // to simplify the code, we just wait a few ms for a new render - // to be done. setTimeout(() => { scrollToItem(getObjectTreeViewItemId(lastObject)); - }, 100); // A few ms is enough for a new render to be done. + }, 100); }, [onObjectCreated, scrollToItem, newObjectDialogOpen] ); @@ -775,6 +1046,15 @@ const ObjectsList = React.forwardRef( [] ); + // ── CALLBACK OPEN COLOR PICKER ────────────────────────────────────────── + const openColorPicker = React.useCallback( + (objectFolder: gdObjectFolderOrObject, isGlobal: boolean) => { + setColorPickerOpen({ objectFolder, isGlobal }); + }, + [] + ); + // ──────────────────────────────────────────────────────────────────────── + const onObjectModified = React.useCallback( (shouldForceUpdateList: boolean) => { if (unsavedChanges) unsavedChanges.triggerUnsavedChanges(); @@ -799,8 +1079,6 @@ const ObjectsList = React.forwardRef( const treeView = treeViewRef.current; if (treeView) { if (isMobile) { - // Position item at top of the screen to make sure it will be visible - // once the keyboard is open. treeView.scrollToItemFromId(itemId, 'start'); } treeView.renameItemFromId(itemId); @@ -827,10 +1105,9 @@ const ObjectsList = React.forwardRef( false ); if (firstClosedFolderIndex === -1) { - // If all parents are open, return the objectFolderOrObject given as input. return getTreeViewItemIdFromObjectFolderOrObject(objectFolderOrObject); } - // $FlowFixMe[incompatible-type] - We are confident this TreeView item is in fact a ObjectFolderOrObjectWithContext + // $FlowFixMe[incompatible-type] return topToBottomAscendanceId[firstClosedFolderIndex]; }; @@ -882,9 +1159,6 @@ const ObjectsList = React.forwardRef( ); if (!answer) return; - // It's safe to call moveObjectFolderOrObjectToAnotherContainerInFolder because - // it does not invalidate the references to the object in memory - so other editors - // like InstancesRenderer can continue to work. objectsContainer.moveObjectFolderOrObjectToAnotherContainerInFolder( objectFolderOrObject, globalObjectsContainer, @@ -905,13 +1179,9 @@ const ObjectsList = React.forwardRef( newObjectFolderOrObjectWithContext ); - // Scroll to the moved object. - // Ideally, we'd wait for the list to be updated to scroll, but - // to simplify the code, we just wait a few ms for a new render - // to be done. setTimeout(() => { scrollToItem(getObjectTreeViewItemId(object)); - }, 100); // A few ms is enough for a new render to be done. + }, 100); }, [ project, @@ -942,6 +1212,8 @@ const ObjectsList = React.forwardRef( objectFolderOrObject: newFolder, global, }; + // Assign default grey color to new folder + folderColors.set(newFolder, global, '#6b7280'); if (treeViewRef.current) { treeViewRef.current.openItems([ getObjectFolderTreeViewItemId(items[0].objectFolderOrObject), @@ -957,6 +1229,8 @@ const ObjectsList = React.forwardRef( objectFolderOrObject: newFolder, global, }; + // Assign default grey color to new folder + folderColors.set(newFolder, global, '#6b7280'); } } else { const rootFolder = objectsContainer.getRootFolder(); @@ -965,6 +1239,8 @@ const ObjectsList = React.forwardRef( objectFolderOrObject: newFolder, global: false, }; + // Assign default grey color to new folder + folderColors.set(newFolder, false, '#6b7280'); } selectObjectFolderOrObjectWithContext( newObjectFolderOrObjectWithContext @@ -1109,6 +1385,7 @@ const ObjectsList = React.forwardRef( forceUpdateList, forceUpdate, isListLocked, + openColorPicker, // <-- AGGIUNTO }), [ project, @@ -1129,6 +1406,7 @@ const ObjectsList = React.forwardRef( forceUpdateList, forceUpdate, isListLocked, + openColorPicker, // <-- AGGIUNTO ] ); @@ -1345,14 +1623,11 @@ const ObjectsList = React.forwardRef( selectedItems.length === 1 && !selectedItems[0].content.isGlobal() ) { - // In that case, the user is drag n dropping a scene object on the - // empty placeholder of the global objects section. const objectFolderOrObject = selectedItems[0].content.getObjectFolderOrObject(); return !!objectFolderOrObject && !objectFolderOrObject.isFolder(); } return false; } - // Check if at least one element in the selection can be moved. if ( selectedItems.every( selectedItem => @@ -1446,7 +1721,6 @@ const ObjectsList = React.forwardRef( return; } - // At this point, the move is done from within the same container. if ( selectedItem.content.isGlobal() === destinationItem.content.isGlobal() ) { @@ -1499,10 +1773,6 @@ const ObjectsList = React.forwardRef( [onObjectModified, selectedItems, setAsGlobalObject] ); - /** - * Unselect item if one of the parent is collapsed (folded) so that the item - * does not stay selected and not visible to the user. - */ const onCollapseItem = React.useCallback( (item: TreeViewItem) => { if (!selectedItems || selectedItems.length !== 1) return; @@ -1515,9 +1785,6 @@ const ObjectsList = React.forwardRef( [selectObjectFolderOrObjectWithContext, selectedItems] ); - // Force List component to be mounted again if project or objectsContainer - // has been changed. Avoid accessing to invalid objects that could - // crash the app. const listKey = project.ptr + ';' + objectsContainer.ptr; const initiallyOpenedNodeIds = [ globalObjectsRootFolder && globalObjectsRootFolder.getChildrenCount() > 0 @@ -1587,11 +1854,7 @@ const ObjectsList = React.forwardRef( ( )}
+ + {/* Dialog nuovi oggetti */} {newObjectDialogOpen && ( setNewObjectDialogOpen(null)} @@ -1659,6 +1924,8 @@ const ObjectsList = React.forwardRef( onExtensionInstalled={onExtensionInstalled} /> )} + + {/* Dialog swap asset */} {objectAssetSwappingDialogOpen && ( { @@ -1679,18 +1946,23 @@ const ObjectsList = React.forwardRef( onExtensionInstalled={onExtensionInstalled} /> )} + + {/* ── COLOR PICKER DIALOG ── */} + {colorPickerOpen && ( + setColorPickerOpen(null)} + onForceUpdate={forceUpdateList} + /> + )} + {/* ───────────────────────── */} ); } ); const arePropsEqual = (prevProps: Props, nextProps: Props): boolean => - // The component is costly to render, so avoid any re-rendering as much - // as possible. - // We make the assumption that no changes to objects list is made outside - // from the component. - // If a change is made, the component won't notice it: you have to manually - // call forceUpdate. prevProps.selectedObjectFolderOrObjectsWithContext === nextProps.selectedObjectFolderOrObjectsWithContext && prevProps.project === nextProps.project && diff --git a/newIDE/app/src/UI/TreeView/TreeViewRow.js b/newIDE/app/src/UI/TreeView/TreeViewRow.js index 9be47a172b6a..04ad8f3e0bbe 100644 --- a/newIDE/app/src/UI/TreeView/TreeViewRow.js +++ b/newIDE/app/src/UI/TreeView/TreeViewRow.js @@ -404,7 +404,25 @@ const TreeViewRow = (props: Props) => { /> )} - {node.thumbnailSrc && node.thumbnailSrc !== 'FOLDER' ? ( + {node.thumbnailSrc === 'NO_ICON' ? ( + node.dataset && node.dataset.folderColor ? ( + + + + + ) : null + ) : node.thumbnailSrc && node.thumbnailSrc !== 'FOLDER' ? (