Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multi entity transform + gizmos refactor #1037

Merged
merged 6 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface ModalState {
cb?: () => void
}

const getLabel = (sdk: SdkContextValue, entity: Entity) => {
export const getLabel = (sdk: SdkContextValue, entity: Entity) => {
const nameComponent = sdk.components.Name.getOrNull(entity)
switch (entity) {
case ROOT:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Entity } from '@dcl/ecs'
import { useEffect, useMemo, useState } from 'react'

import { withSdk } from '../../hoc/withSdk'
import { useChange } from '../../hooks/sdk/useChange'
import { useSelectedEntity } from '../../hooks/sdk/useSelectedEntity'
import { useEntitiesWith } from '../../hooks/sdk/useEntitiesWith'
import { useAppSelector } from '../../redux/hooks'
import { getHiddenComponents } from '../../redux/ui'
import { EDITOR_ENTITIES } from '../../lib/sdk/tree'

import { GltfInspector } from './GltfInspector'
import { ActionInspector } from './ActionInspector'
Expand Down Expand Up @@ -32,8 +34,42 @@ import { SmartItemBasicView } from './SmartItemBasicView'

import './EntityInspector.css'

export const EntityInspector = withSdk(({ sdk }) => {
const entity = useSelectedEntity()
export function EntityInspector() {
const selectedEntities = useEntitiesWith((components) => components.Selection)
const ownedEntities = useMemo(
() => selectedEntities.filter((entity) => !EDITOR_ENTITIES.includes(entity)),
[selectedEntities]
)
const entity = useMemo(() => (selectedEntities.length > 0 ? selectedEntities[0] : null), [selectedEntities])

if (ownedEntities.length > 1) {
return <MultiEntityInspector entities={ownedEntities} />
}

return <SingleEntityInspector entity={entity} />
}

const MultiEntityInspector = withSdk<{ entities: Entity[] }>(({ sdk, entities }) => {
const hiddenComponents = useAppSelector(getHiddenComponents)
const inspectors = useMemo(
() => [{ name: sdk.components.Transform.componentName, component: TransformInspector }],
[sdk]
)

return (
<div className="EntityInspector">
<div className="EntityHeader">
<div className="title">{entities.length} entities selected</div>
</div>
{inspectors.map(
({ name, component: Inspector }, index) =>
!hiddenComponents[name] && <Inspector key={`${index}-${entities.join(',')}`} entities={entities} />
)}
</div>
)
})

const SingleEntityInspector = withSdk<{ entity: Entity | null }>(({ sdk, entity }) => {
const hiddenComponents = useAppSelector(getHiddenComponents)
const [isBasicViewEnabled, setIsBasicViewEnabled] = useState(false)

Expand Down Expand Up @@ -123,20 +159,20 @@ export const EntityInspector = withSdk(({ sdk }) => {
)

return (
<div className="EntityInspector" key={entity}>
<div className="EntityInspector">
{entity !== null ? (
<>
<EntityHeader entity={entity} />
{inspectors.map(
({ name, component: Inspector }, index) =>
!hiddenComponents[name] && <Inspector key={index} entity={entity} />
!hiddenComponents[name] && <Inspector key={`${index}-${entity}`} entities={[entity]} />
)}
{isBasicViewEnabled ? (
<SmartItemBasicView entity={entity} />
) : (
advancedInspectorComponents.map(
({ name, component: Inspector }, index) =>
!hiddenComponents[name] && <Inspector key={index} entity={entity} />
!hiddenComponents[name] && <Inspector key={`${index}-${entity}`} entity={entity} />
)
)}
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect } from 'react'

import { isValidNumericInput, useComponentInput } from '../../../hooks/sdk/useComponentInput'
import { isValidNumericInput, useComponentInput, useMultiComponentInput } from '../../../hooks/sdk/useComponentInput'
import { useHasComponent } from '../../../hooks/sdk/useHasComponent'
import { withSdk } from '../../../hoc/withSdk'

Expand All @@ -13,14 +13,15 @@ import { Link, Props as LinkProps } from './Link'

import './TransformInspector.css'

export default withSdk<Props>(({ sdk, entity }) => {
export default withSdk<Props>(({ sdk, entities }) => {
const { Transform, TransformConfig } = sdk.components
const entity = entities.find((entity) => Transform.has(entity)) || entities[0]

const hasTransform = useHasComponent(entity, Transform)
const transform = Transform.getOrNull(entity) ?? undefined
const config = TransformConfig.getOrNull(entity) ?? undefined
const { getInputProps } = useComponentInput(
entity,
const { getInputProps } = useMultiComponentInput(
entities,
Transform,
fromTransform,
toTransform(transform, config),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Entity } from '@dcl/ecs'

export interface Props {
entity: Entity
entities: Entity[]
}

export type TransformInput = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const TextField = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
<input
className="input"
ref={ref}
type={type}
type={inputValue === '--' ? 'text' : type}
value={inputValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
Expand Down
183 changes: 177 additions & 6 deletions packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { InputHTMLAttributes, useCallback, useEffect, useRef, useState } from 'react'
import { Entity } from '@dcl/ecs'
import { InputHTMLAttributes, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CrdtMessageType, Entity } from '@dcl/ecs'
import { recursiveCheck as hasDiff } from 'jest-matcher-deep-close-to/lib/recursiveCheck'
import { getValue, NestedKey, setValue } from '../../lib/logic/get-set-value'
import { Component } from '../../lib/sdk/components'
import { useComponentValue } from './useComponentValue'
import { getComponentValue, isLastWriteWinComponent, useComponentValue } from './useComponentValue'
import { useSdk } from './useSdk'
import { useChange } from './useChange'

type Input = {
[key: string]: boolean | string | string[] | any[] | Record<string, boolean | string | string[] | any[] | Input>
Expand All @@ -15,6 +18,9 @@ export function isValidNumericInput(input: Input[keyof Input]): boolean {
if (typeof input === 'boolean') {
return !!input
}
if (typeof input === 'number') {
return !isNaN(input)
}
return input.length > 0 && !isNaN(Number(input))
}

Expand Down Expand Up @@ -70,10 +76,8 @@ export const useComponentInput = <ComponentValueType extends object, InputType e
if (skipSyncRef.current) return
if (validate(input)) {
const newComponentValue = { ...componentValue, ...fromInputToComponentValue(input) }
if (isEqual(newComponentValue)) return

if (isEqual(newComponentValue)) {
return
}
setComponentValue(newComponentValue)
}
}, [input])
Expand Down Expand Up @@ -115,3 +119,170 @@ export const useComponentInput = <ComponentValueType extends object, InputType e

return { getInputProps: getProps, isValid }
}

// Helper function to recursively merge values
const mergeValues = (values: any[]): any => {
// Base case - if any value is not an object, compare directly
if (!values.every((val) => val && typeof val === 'object')) {
return values.every((val) => val === values[0]) ? values[0] : '--'
}

// Get all keys from all objects
const allKeys = [...new Set(values.flatMap(Object.keys))]

// Create result object
const result: any = {}

// For each key, recursively merge values
for (const key of allKeys) {
const valuesForKey = values.map((obj) => obj[key])
result[key] = mergeValues(valuesForKey)
}

return result
}

const mergeComponentValues = <ComponentValueType extends object, InputType extends Input>(
values: ComponentValueType[],
fromComponentValueToInput: (componentValue: ComponentValueType) => InputType
): InputType => {
// Transform all component values to input format
const inputs = values.map(fromComponentValueToInput)

// Get first input as reference
const firstInput = inputs[0]

// Create result object with same shape as first input
const result = {} as InputType

// For each key in first input
for (const key in firstInput) {
const valuesForKey = inputs.map((input) => input[key])
result[key] = mergeValues(valuesForKey)
}

return result
}

const getEntityAndComponentValue = <ComponentValueType extends object>(
entities: Entity[],
component: Component<ComponentValueType>
): [Entity, ComponentValueType][] => {
return entities.map((entity) => [entity, getComponentValue(entity, component) as ComponentValueType])
}

export const useMultiComponentInput = <ComponentValueType extends object, InputType extends Input>(
entities: Entity[],
component: Component<ComponentValueType>,
fromComponentValueToInput: (componentValue: ComponentValueType) => InputType,
fromInputToComponentValue: (input: InputType) => ComponentValueType,
validateInput: (input: InputType) => boolean = () => true
) => {
// If there's only one entity, use the single entity version just to be safe for now
if (entities.length === 1) {
return useComponentInput(
entities[0],
component,
fromComponentValueToInput,
fromInputToComponentValue,
validateInput
)
}
const sdk = useSdk()

// Get initial merged value from all entities
const initialEntityValues = getEntityAndComponentValue(entities, component)
const initialMergedValue = useMemo(
() =>
mergeComponentValues(
initialEntityValues.map(([_, component]) => component),
fromComponentValueToInput
),
[] // only compute on mount
)

const [value, setMergeValue] = useState(initialMergedValue)
const [isValid, setIsValid] = useState(true)
const [isFocused, setIsFocused] = useState(false)

// Handle input updates
const handleUpdate = useCallback(
(path: NestedKey<InputType>, getter: (event: React.ChangeEvent<HTMLInputElement>) => any = (e) => e.target.value) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
if (!value) return

const newValue = setValue(value, path, getter(event))
if (!hasDiff(value, newValue, 2)) return

// Only update if component is last-write-win and SDK exists
if (!isLastWriteWinComponent(component) || !sdk) {
setMergeValue(newValue)
return
}

// Validate and update all entities
const entityUpdates = getEntityAndComponentValue(entities, component).map(([entity, componentValue]) => {
const updatedInput = setValue(fromComponentValueToInput(componentValue as any), path, getter(event))
const newComponentValue = fromInputToComponentValue(updatedInput)
return {
entity,
value: newComponentValue,
isValid: validateInput(updatedInput)
}
})

const allUpdatesValid = entityUpdates.every(({ isValid }) => isValid)

if (allUpdatesValid) {
entityUpdates.forEach(({ entity, value }) => {
sdk.operations.updateValue(component, entity, value)
})
void sdk.operations.dispatch()
}

setMergeValue(newValue)
setIsValid(allUpdatesValid)
},
[value, sdk, component, entities, fromInputToComponentValue, fromComponentValueToInput, validateInput]
)

// Sync with engine changes
useChange(
(event) => {
const isRelevantUpdate =
entities.includes(event.entity) &&
component.componentId === event.component?.componentId &&
event.value &&
event.operation === CrdtMessageType.PUT_COMPONENT

if (!isRelevantUpdate) return

const updatedEntityValues = getEntityAndComponentValue(entities, component)
const newMergedValue = mergeComponentValues(
updatedEntityValues.map(([_, component]) => component),
fromComponentValueToInput
)

if (!hasDiff(value, newMergedValue, 2) || isFocused) return

setMergeValue(newMergedValue)
},
[entities, component, fromComponentValueToInput, value, isFocused]
)

// Input props getter
const getInputProps = useCallback(
(
path: NestedKey<InputType>,
getter?: (event: React.ChangeEvent<HTMLInputElement>) => any
): Pick<InputHTMLAttributes<HTMLElement>, 'value' | 'onChange' | 'onFocus' | 'onBlur'> => ({
value: (getValue(value, path) || '').toString(),
onChange: handleUpdate(path, getter),
onFocus: () => setIsFocused(true),
onBlur: () => setIsFocused(false)
}),
[value, handleUpdate]
)

return { getInputProps, isValid }
}
9 changes: 2 additions & 7 deletions packages/@dcl/inspector/src/hooks/sdk/useComponentValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,7 @@ export const useComponentValue = <ComponentValueType>(entity: Entity, component:

// sync state -> engine
useEffect(() => {
if (value === null) return
const isEqualValue = !recursiveCheck(getComponentValue(entity, component), value, 2)

if (isEqualValue) {
return
}
if (value === null || isComponentEqual(value)) return
if (isLastWriteWinComponent(component) && sdk) {
sdk.operations.updateValue(component, entity, value!)
void sdk.operations.dispatch()
Expand All @@ -48,7 +43,7 @@ export const useComponentValue = <ComponentValueType>(entity: Entity, component:
(event) => {
if (entity === event.entity && component.componentId === event.component?.componentId && !!event.value) {
if (event.operation === CrdtMessageType.PUT_COMPONENT) {
// TODO: This setValue is generating a isEqual comparission.
// TODO: This setValue is generating an isEqual comparission in previous effect.
// Maybe we have to use two pure functions instead of an effect.
// Same happens with the input & componentValue.
setValue(event.value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,14 @@ export const setGizmoManager = (entity: EcsEntity, value: { gizmo: number }) =>

toggleSelection(entity, true)

const selectedEntities = Array.from(context.engine.getEntitiesWith(context.editorComponents.Selection))
const types = context.gizmos.getGizmoTypes()
const type = types[value?.gizmo || 0]
context.gizmos.setGizmoType(type)

if (selectedEntities.length === 1) {
context.gizmos.setEntity(entity)
} else if (selectedEntities.length > 1) {
context.gizmos.repositionGizmoOnCentroid()
}
context.gizmos.addEntity(entity)
}

export const unsetGizmoManager = (entity: EcsEntity) => {
const context = entity.context.deref()!
const selectedEntities = Array.from(context.engine.getEntitiesWith(context.editorComponents.Selection))
const currentEntity = context.gizmos.getEntity()

toggleSelection(entity, false)

if (currentEntity?.entityId === entity.entityId || selectedEntities.length === 0) {
context.gizmos.unsetEntity()
} else {
context.gizmos.repositionGizmoOnCentroid()
}
context.gizmos.removeEntity(entity)
}
Loading
Loading