import React, { memo, useCallback, useContext, useEffect, useState } from 'react' import { useDrop } from 'react-dnd' import Hotkeys from 'react-hot-keys' import { useTranslation } from 'react-i18next' import AutoSizer from 'react-virtualized-auto-sizer' import { areEqual, FixedSizeList } from 'react-window' import { Object3D } from 'three' import { AllFileTypes } from '@xrengine/engine/src/assets/constants/fileTypes' import { Engine } from '@xrengine/engine/src/ecs/classes/Engine' import { Entity } from '@xrengine/engine/src/ecs/classes/Entity' import { getComponent, getOptionalComponent, hasComponent } from '@xrengine/engine/src/ecs/functions/ComponentFunctions' import { EntityTreeComponent, getEntityNodeArrayFromEntities, traverseEntityNode } from '@xrengine/engine/src/ecs/functions/EntityTree' import { GroupComponent } from '@xrengine/engine/src/scene/components/GroupComponent' import { ModelComponent } from '@xrengine/engine/src/scene/components/ModelComponent' import { NameComponent } from '@xrengine/engine/src/scene/components/NameComponent' import { dispatchAction } from '@xrengine/hyperflux' import { Checkbox } from '@mui/material' import MenuItem from '@mui/material/MenuItem' import { PopoverPosition } from '@mui/material/Popover' import { EditorCameraComponent } from '../../classes/EditorCameraComponent' import { ItemTypes, SupportedFileTypes } from '../../constants/AssetTypes' import { addMediaNode } from '../../functions/addMediaNode' import { EditorControlFunctions } from '../../functions/EditorControlFunctions' import { isAncestor } from '../../functions/getDetachedObjectsRoots' import { cmdOrCtrlString } from '../../functions/utils' import { EditorAction, useEditorState } from '../../services/EditorServices' import { accessSelectionState, useSelectionState } from '../../services/SelectionServices' import useUpload from '../assets/useUpload' import { addPrefabElement } from '../element/ElementList' import { ContextMenu } from '../layout/ContextMenu' import { updateProperties } from '../properties/Util' import { AppContext } from '../Search/context' import Search from '../Search/Search' import { HeirarchyTreeCollapsedNodeType, HeirarchyTreeNodeType, heirarchyTreeWalker } from './HeirarchyTreeWalker' import { getNodeElId, HierarchyTreeNode, HierarchyTreeNodeData, HierarchyTreeNodeProps, RenameNodeData } from './HierarchyTreeNode' import styles from './styles.module.scss' /** * uploadOption initializing object containing Properties multiple, accepts. * * @type {Object} */ const uploadOptions = { multiple: true, accepts: AllFileTypes } /** * getNodeKey function used to get object id at given index. * * @param {number} index [index of the node to get object id] * @param {object} data * @return {string} */ function getNodeKey(index: number, data: HierarchyTreeNodeData) { return index //data.nodes[index].entityNode ? data.nodes[index].entityNode.entity : data.nodes[index].toString() } function traverseWithDepth(obj3d: Object3D, depth: number, cb: Function) { cb(obj3d, depth) for (const obj of obj3d.children) { traverseWithDepth(obj, depth + 1, cb) } } function getModelNodesFromTreeWalker( inputNodes: HeirarchyTreeNodeType[], collapsedNodes: HeirarchyTreeCollapsedNodeType, showObject3Ds: boolean ): HeirarchyTreeNodeType[] { const outputNodes = [] as HeirarchyTreeNodeType[] const selected = new Set( accessSelectionState().value.selectedEntities.filter((ent) => typeof ent === 'string') as string[] ) for (const node of inputNodes) { outputNodes.push(node) const isCollapsed = collapsedNodes[node.entityNode] if (showObject3Ds && hasComponent(node.entityNode as Entity, ModelComponent)) { const group = getOptionalComponent(node.entityNode as Entity, GroupComponent) if (!group?.length) continue node.isLeaf = false if (isCollapsed) continue let childIndex = node.childIndex for (const obj3d of group) traverseWithDepth(obj3d, node.depth, (obj, depth) => { if (group.includes(obj)) return outputNodes.push({ depth, obj3d: obj, entityNode: null!, childIndex: childIndex++, lastChild: false, isLeaf: true, //!obj.children.length, // todo, store collapsed state on obj3d isCollapsed: node.isCollapsed, selected: selected.has(obj.uuid), active: false }) }) } } return outputNodes } /** * HierarchyPanel function component provides view for hierarchy tree. * * @constructor */ export default function HierarchyPanel({ setSearchElement, setSearchHierarchy }: { setSearchElement: (_: string) => void setSearchHierarchy: (_: string) => void }) { const { t } = useTranslation() const [contextSelectedItem, setContextSelectedItem] = React.useState(undefined) const [anchorPosition, setAnchorPosition] = React.useState(undefined) const [anchorEl, setAnchorEl] = React.useState(null) const open = Boolean(anchorEl) const onUpload = useUpload(uploadOptions) const selectionState = useSelectionState() const [renamingNode, setRenamingNode] = useState(null) const [collapsedNodes, setCollapsedNodes] = useState({}) const [nodes, setNodes] = useState([]) const nodeSearch: HeirarchyTreeNodeType[] = [] const [selectedNode, _setSelectedNode] = useState(null) const editorState = useEditorState() const { searchHierarchy } = useContext(AppContext) const showObject3DInHierarchy = useEditorState().showObject3DInHierarchy const MemoTreeNode = memo( (props: HierarchyTreeNodeProps) => , areEqual ) if (searchHierarchy.length > 0) { const condition = new RegExp(searchHierarchy.toLowerCase()) nodes.forEach((node) => { if ( (node.entityNode && condition.test(getComponent(node.entityNode as Entity, NameComponent)?.toLowerCase() ?? '')) || (node.obj3d && condition.test(node.obj3d.name?.toLowerCase() ?? '')) ) nodeSearch.push(node) }) } const updateNodeHierarchy = useCallback( (world = Engine.instance.currentWorld) => { setNodes( getModelNodesFromTreeWalker( Array.from(heirarchyTreeWalker(world.sceneEntity, selectionState.selectedEntities.value, collapsedNodes)), collapsedNodes, showObject3DInHierarchy.value ) ) }, [collapsedNodes] ) useEffect(updateNodeHierarchy, [collapsedNodes]) useEffect(updateNodeHierarchy, [ showObject3DInHierarchy, selectionState.selectedEntities, selectionState.sceneGraphChangeCounter ]) const setSelectedNode = (selection) => !editorState.lockPropertiesPanel.value && _setSelectedNode(selection) /* Expand & Collapse Functions */ const expandNode = useCallback( (node: HeirarchyTreeNodeType) => { if (node.obj3d) return // todo setCollapsedNodes({ ...collapsedNodes, [node.entityNode]: false }) }, [collapsedNodes] ) const collapseNode = useCallback( (node: HeirarchyTreeNodeType) => { if (node.obj3d) return // todo setCollapsedNodes({ ...collapsedNodes, [node.entityNode]: true }) }, [collapsedNodes] ) const expandChildren = useCallback( (node: HeirarchyTreeNodeType) => { handleClose() if (node.obj3d) return // todo traverseEntityNode(node.entityNode as Entity, (child) => (collapsedNodes[child] = false)) setCollapsedNodes({ ...collapsedNodes }) }, [collapsedNodes] ) const collapseChildren = useCallback( (node: HeirarchyTreeNodeType) => { handleClose() if (node.obj3d) return // todo traverseEntityNode(node.entityNode as Entity, (child) => (collapsedNodes[child] = true)) setCollapsedNodes({ ...collapsedNodes }) }, [collapsedNodes] ) /* Expand & Collapse Functions */ const onObjectChanged = useCallback( (propertyName) => { if (propertyName === 'name' || !propertyName) updateNodeHierarchy() }, [collapsedNodes] ) useEffect(() => { onObjectChanged(selectionState.propertyName.value) }, [selectionState.objectChangeCounter]) /* Event handlers */ const onMouseDown = useCallback((e: MouseEvent, node: HeirarchyTreeNodeType) => { if (e.detail === 1) { if (e.shiftKey) { EditorControlFunctions.toggleSelection([node.entityNode ?? node.obj3d!.uuid]) setSelectedNode(null) } else if (!node.selected) { EditorControlFunctions.replaceSelection([node.entityNode ?? node.obj3d!.uuid]) setSelectedNode(node) } } }, []) const onContextMenu = (event: React.MouseEvent, item: HeirarchyTreeNodeType) => { event.preventDefault() event.stopPropagation() setContextSelectedItem(item) setAnchorEl(event.currentTarget) setAnchorPosition({ left: event.clientX + 2, top: event.clientY - 6 }) } const handleClose = () => { setContextSelectedItem(undefined) setAnchorEl(null) setAnchorPosition(undefined) } const onClick = useCallback((e: MouseEvent, node: HeirarchyTreeNodeType) => { if (node.obj3d) return // todo if (e.detail === 2) { const cameraComponent = getComponent(Engine.instance.currentWorld.cameraEntity, EditorCameraComponent) cameraComponent.focusedObjects = [node.entityNode] cameraComponent.refocus = true } }, []) const onToggle = useCallback( (_, node: HeirarchyTreeNodeType) => { if (node.obj3d) return // todo if (collapsedNodes[node.entityNode as Entity]) expandNode(node) else collapseNode(node) }, [collapsedNodes, expandNode, collapseNode] ) const onKeyDown = useCallback( (e: KeyboardEvent, node: HeirarchyTreeNodeType) => { const nodeIndex = nodes.indexOf(node) const entityTree = getComponent(node.entityNode as Entity, EntityTreeComponent) switch (e.key) { case 'ArrowDown': e.preventDefault() const nextNode = nodeIndex !== -1 && nodes[nodeIndex + 1] if (!nextNode) return if (e.shiftKey) { EditorControlFunctions.addToSelection([nextNode.entityNode ?? nextNode.obj3d!.uuid]) } const nextNodeEl = document.getElementById(getNodeElId(nextNode)) if (nextNodeEl) nextNodeEl.focus() break case 'ArrowUp': e.preventDefault() const prevNode = nodeIndex !== -1 && nodes[nodeIndex - 1] if (!prevNode) return if (e.shiftKey) { EditorControlFunctions.addToSelection([prevNode.entityNode ?? prevNode.obj3d!.uuid]) } const prevNodeEl = document.getElementById(getNodeElId(prevNode)) if (prevNodeEl) prevNodeEl.focus() break case 'ArrowLeft': if (entityTree && (!entityTree.children || entityTree.children.length === 0)) return if (node.obj3d && (!node.obj3d.children || node.obj3d.children.length === 0)) return if (e.shiftKey) collapseChildren(node) else collapseNode(node) break case 'ArrowRight': if (entityTree && (!entityTree.children || entityTree.children.length === 0)) return if (node.obj3d && (!node.obj3d.children || node.obj3d.children.length === 0)) return if (e.shiftKey) expandChildren(node) else expandNode(node) break case 'Enter': if (e.shiftKey) { EditorControlFunctions.toggleSelection([node.entityNode ?? node.obj3d!.uuid]) setSelectedNode(null) } else { EditorControlFunctions.replaceSelection([node.entityNode ?? node.obj3d!.uuid]) setSelectedNode(node) } break case 'Delete': case 'Backspace': if (selectedNode && !renamingNode) onDeleteNode(selectedNode!) break } }, [nodes, expandNode, collapseNode, expandChildren, collapseChildren, renamingNode, selectedNode] ) const onDeleteNode = useCallback((node: HeirarchyTreeNodeType) => { handleClose() let objs = node.selected ? getEntityNodeArrayFromEntities(selectionState.selectedEntities.value) : [node.entityNode ?? node.obj3d!.uuid] EditorControlFunctions.removeObject(objs) }, []) const onDuplicateNode = useCallback((node: HeirarchyTreeNodeType) => { handleClose() let objs = node.selected ? getEntityNodeArrayFromEntities(selectionState.selectedEntities.value) : [node.entityNode ?? node.obj3d!.uuid] EditorControlFunctions.duplicateObject(objs) }, []) const onGroupNodes = useCallback((node: HeirarchyTreeNodeType) => { handleClose() const objs = node.selected ? getEntityNodeArrayFromEntities(selectionState.selectedEntities.value) : [node.entityNode ?? node.obj3d!.uuid] EditorControlFunctions.groupObjects(objs, [], []) }, []) /* Event handlers */ /* Rename functions */ const onRenameNode = useCallback((node: HeirarchyTreeNodeType) => { handleClose() if (node.entityNode) { const entity = node.entityNode as Entity setRenamingNode({ entity, name: getComponent(entity, NameComponent) }) } else { // todo } }, []) const onChangeName = useCallback( (node: HeirarchyTreeNodeType, name: string) => setRenamingNode({ entity: node.entityNode as Entity, name }), [] ) const onRenameSubmit = useCallback((node: HeirarchyTreeNodeType, name: string) => { if (name) { if (!node.obj3d) updateProperties(NameComponent, name, [node.entityNode]) const groups = getOptionalComponent(node.entityNode as Entity, GroupComponent) if (groups) for (const obj of groups) if (obj) obj.name = name } setRenamingNode(null) }, []) /* Rename functions */ const [, treeContainerDropTarget] = useDrop({ accept: [ItemTypes.Node, ItemTypes.File, ItemTypes.Prefab, ...SupportedFileTypes], drop(item: any, monitor) { if (monitor.didDrop()) return // check if item contains files if (item.files) { const dndItem: any = monitor.getItem() const entries = Array.from(dndItem.items).map((item: any) => item.webkitGetAsEntry()) //uploading files then adding to editor media onUpload(entries).then((assets) => { if (!assets) return for (const asset of assets) addMediaNode(asset) }) return } if (item.url) { addMediaNode(item.url) return } if (item.type === ItemTypes.Prefab) { addPrefabElement(item) return } EditorControlFunctions.reparentObject(Array.isArray(item.value) ? item.value : [item.value]) }, canDrop(item: any, monitor) { if (!monitor.isOver({ shallow: true })) return false // check if item is of node type if (item.type === ItemTypes.Node) { const world = Engine.instance.currentWorld return !(item.multiple ? item.value.some((otherObject) => isAncestor(otherObject, world.sceneEntity)) : isAncestor(item.value, world.sceneEntity)) } return true } }) const HierarchyList = //useCallback( ({ height, width }) => ( 0 ? nodeSearch.length : nodes.length} itemData={{ renamingNode, nodes: nodeSearch?.length > 0 ? nodeSearch : nodes, onKeyDown, onChangeName, onRenameSubmit, onMouseDown, onClick, onToggle, onUpload }} itemKey={getNodeKey} outerRef={treeContainerDropTarget} innerElementType="ul" > {MemoTreeNode} ) //, //[editorState.advancedMode, editorState.projectLoaded, collapsedNodes, showObject3DInHierarchy, selectedNode, updateNodeHierarchy] //) return ( <>
{editorState.advancedMode.value && (
{t('editor:hierarchy.lbl-explode')} dispatchAction(EditorAction.showObject3DInHierarchy({ showObject3DInHierarchy: value })) } />
)}
{Engine.instance.currentWorld.scene && (
{HierarchyList}
)}
onRenameNode(contextSelectedItem!)}>{t('editor:hierarchy.lbl-rename')} { e.preventDefault() e.stopPropagation() selectedNode && onDuplicateNode(selectedNode!) }} > onDuplicateNode(contextSelectedItem!)}> {t('editor:hierarchy.lbl-duplicate')}
{cmdOrCtrlString + ' + d'}
{ e.preventDefault() e.stopPropagation() selectedNode && onGroupNodes(selectedNode!!) }} > onGroupNodes(contextSelectedItem!)}> {t('editor:hierarchy.lbl-group')}
{cmdOrCtrlString + ' + g'}
onDeleteNode(contextSelectedItem!)}>{t('editor:hierarchy.lbl-delete')} expandChildren(contextSelectedItem!)}>{t('editor:hierarchy.lbl-expandAll')} collapseChildren(contextSelectedItem!)}> {t('editor:hierarchy.lbl-collapseAll')}
) }