Skip to content

Commit cf41aed

Browse files
authored
Add multi entity transform + gizmos refactor (#1037)
* set gizmo free movement as default * split to SingleEntityInspector & MultipleEntityInspector * magic stuff for multiple entity Transform * magic stuff for multiple entity Transform #2 * add '--' for inputs * refactor gizmo manager
1 parent 2c002ca commit cf41aed

File tree

12 files changed

+338
-192
lines changed

12 files changed

+338
-192
lines changed

packages/@dcl/inspector/src/components/EntityInspector/EntityHeader/EntityHeader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ interface ModalState {
3232
cb?: () => void
3333
}
3434

35-
const getLabel = (sdk: SdkContextValue, entity: Entity) => {
35+
export const getLabel = (sdk: SdkContextValue, entity: Entity) => {
3636
const nameComponent = sdk.components.Name.getOrNull(entity)
3737
switch (entity) {
3838
case ROOT:

packages/@dcl/inspector/src/components/EntityInspector/EntityInspector.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { Entity } from '@dcl/ecs'
12
import { useEffect, useMemo, useState } from 'react'
23

34
import { withSdk } from '../../hoc/withSdk'
45
import { useChange } from '../../hooks/sdk/useChange'
5-
import { useSelectedEntity } from '../../hooks/sdk/useSelectedEntity'
6+
import { useEntitiesWith } from '../../hooks/sdk/useEntitiesWith'
67
import { useAppSelector } from '../../redux/hooks'
78
import { getHiddenComponents } from '../../redux/ui'
9+
import { EDITOR_ENTITIES } from '../../lib/sdk/tree'
810

911
import { GltfInspector } from './GltfInspector'
1012
import { ActionInspector } from './ActionInspector'
@@ -32,8 +34,42 @@ import { SmartItemBasicView } from './SmartItemBasicView'
3234

3335
import './EntityInspector.css'
3436

35-
export const EntityInspector = withSdk(({ sdk }) => {
36-
const entity = useSelectedEntity()
37+
export function EntityInspector() {
38+
const selectedEntities = useEntitiesWith((components) => components.Selection)
39+
const ownedEntities = useMemo(
40+
() => selectedEntities.filter((entity) => !EDITOR_ENTITIES.includes(entity)),
41+
[selectedEntities]
42+
)
43+
const entity = useMemo(() => (selectedEntities.length > 0 ? selectedEntities[0] : null), [selectedEntities])
44+
45+
if (ownedEntities.length > 1) {
46+
return <MultiEntityInspector entities={ownedEntities} />
47+
}
48+
49+
return <SingleEntityInspector entity={entity} />
50+
}
51+
52+
const MultiEntityInspector = withSdk<{ entities: Entity[] }>(({ sdk, entities }) => {
53+
const hiddenComponents = useAppSelector(getHiddenComponents)
54+
const inspectors = useMemo(
55+
() => [{ name: sdk.components.Transform.componentName, component: TransformInspector }],
56+
[sdk]
57+
)
58+
59+
return (
60+
<div className="EntityInspector">
61+
<div className="EntityHeader">
62+
<div className="title">{entities.length} entities selected</div>
63+
</div>
64+
{inspectors.map(
65+
({ name, component: Inspector }, index) =>
66+
!hiddenComponents[name] && <Inspector key={`${index}-${entities.join(',')}`} entities={entities} />
67+
)}
68+
</div>
69+
)
70+
})
71+
72+
const SingleEntityInspector = withSdk<{ entity: Entity | null }>(({ sdk, entity }) => {
3773
const hiddenComponents = useAppSelector(getHiddenComponents)
3874
const [isBasicViewEnabled, setIsBasicViewEnabled] = useState(false)
3975

@@ -123,20 +159,20 @@ export const EntityInspector = withSdk(({ sdk }) => {
123159
)
124160

125161
return (
126-
<div className="EntityInspector" key={entity}>
162+
<div className="EntityInspector">
127163
{entity !== null ? (
128164
<>
129165
<EntityHeader entity={entity} />
130166
{inspectors.map(
131167
({ name, component: Inspector }, index) =>
132-
!hiddenComponents[name] && <Inspector key={index} entity={entity} />
168+
!hiddenComponents[name] && <Inspector key={`${index}-${entity}`} entities={[entity]} />
133169
)}
134170
{isBasicViewEnabled ? (
135171
<SmartItemBasicView entity={entity} />
136172
) : (
137173
advancedInspectorComponents.map(
138174
({ name, component: Inspector }, index) =>
139-
!hiddenComponents[name] && <Inspector key={index} entity={entity} />
175+
!hiddenComponents[name] && <Inspector key={`${index}-${entity}`} entity={entity} />
140176
)
141177
)}
142178
</>

packages/@dcl/inspector/src/components/EntityInspector/TransformInspector/TransformInspector.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react'
22

3-
import { isValidNumericInput, useComponentInput } from '../../../hooks/sdk/useComponentInput'
3+
import { isValidNumericInput, useComponentInput, useMultiComponentInput } from '../../../hooks/sdk/useComponentInput'
44
import { useHasComponent } from '../../../hooks/sdk/useHasComponent'
55
import { withSdk } from '../../../hoc/withSdk'
66

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

1414
import './TransformInspector.css'
1515

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

1920
const hasTransform = useHasComponent(entity, Transform)
2021
const transform = Transform.getOrNull(entity) ?? undefined
2122
const config = TransformConfig.getOrNull(entity) ?? undefined
22-
const { getInputProps } = useComponentInput(
23-
entity,
23+
const { getInputProps } = useMultiComponentInput(
24+
entities,
2425
Transform,
2526
fromTransform,
2627
toTransform(transform, config),

packages/@dcl/inspector/src/components/EntityInspector/TransformInspector/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Entity } from '@dcl/ecs'
22

33
export interface Props {
4-
entity: Entity
4+
entities: Entity[]
55
}
66

77
export type TransformInput = {

packages/@dcl/inspector/src/components/ui/TextField/TextField.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ const TextField = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
123123
<input
124124
className="input"
125125
ref={ref}
126-
type={type}
126+
type={inputValue === '--' ? 'text' : type}
127127
value={inputValue}
128128
onChange={handleInputChange}
129129
onFocus={handleInputFocus}

packages/@dcl/inspector/src/hooks/sdk/useComponentInput.ts

Lines changed: 177 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { InputHTMLAttributes, useCallback, useEffect, useRef, useState } from 'react'
2-
import { Entity } from '@dcl/ecs'
1+
import { InputHTMLAttributes, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+
import { CrdtMessageType, Entity } from '@dcl/ecs'
3+
import { recursiveCheck as hasDiff } from 'jest-matcher-deep-close-to/lib/recursiveCheck'
34
import { getValue, NestedKey, setValue } from '../../lib/logic/get-set-value'
45
import { Component } from '../../lib/sdk/components'
5-
import { useComponentValue } from './useComponentValue'
6+
import { getComponentValue, isLastWriteWinComponent, useComponentValue } from './useComponentValue'
7+
import { useSdk } from './useSdk'
8+
import { useChange } from './useChange'
69

710
type Input = {
811
[key: string]: boolean | string | string[] | any[] | Record<string, boolean | string | string[] | any[] | Input>
@@ -15,6 +18,9 @@ export function isValidNumericInput(input: Input[keyof Input]): boolean {
1518
if (typeof input === 'boolean') {
1619
return !!input
1720
}
21+
if (typeof input === 'number') {
22+
return !isNaN(input)
23+
}
1824
return input.length > 0 && !isNaN(Number(input))
1925
}
2026

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

74-
if (isEqual(newComponentValue)) {
75-
return
76-
}
7781
setComponentValue(newComponentValue)
7882
}
7983
}, [input])
@@ -115,3 +119,170 @@ export const useComponentInput = <ComponentValueType extends object, InputType e
115119

116120
return { getInputProps: getProps, isValid }
117121
}
122+
123+
// Helper function to recursively merge values
124+
const mergeValues = (values: any[]): any => {
125+
// Base case - if any value is not an object, compare directly
126+
if (!values.every((val) => val && typeof val === 'object')) {
127+
return values.every((val) => val === values[0]) ? values[0] : '--'
128+
}
129+
130+
// Get all keys from all objects
131+
const allKeys = [...new Set(values.flatMap(Object.keys))]
132+
133+
// Create result object
134+
const result: any = {}
135+
136+
// For each key, recursively merge values
137+
for (const key of allKeys) {
138+
const valuesForKey = values.map((obj) => obj[key])
139+
result[key] = mergeValues(valuesForKey)
140+
}
141+
142+
return result
143+
}
144+
145+
const mergeComponentValues = <ComponentValueType extends object, InputType extends Input>(
146+
values: ComponentValueType[],
147+
fromComponentValueToInput: (componentValue: ComponentValueType) => InputType
148+
): InputType => {
149+
// Transform all component values to input format
150+
const inputs = values.map(fromComponentValueToInput)
151+
152+
// Get first input as reference
153+
const firstInput = inputs[0]
154+
155+
// Create result object with same shape as first input
156+
const result = {} as InputType
157+
158+
// For each key in first input
159+
for (const key in firstInput) {
160+
const valuesForKey = inputs.map((input) => input[key])
161+
result[key] = mergeValues(valuesForKey)
162+
}
163+
164+
return result
165+
}
166+
167+
const getEntityAndComponentValue = <ComponentValueType extends object>(
168+
entities: Entity[],
169+
component: Component<ComponentValueType>
170+
): [Entity, ComponentValueType][] => {
171+
return entities.map((entity) => [entity, getComponentValue(entity, component) as ComponentValueType])
172+
}
173+
174+
export const useMultiComponentInput = <ComponentValueType extends object, InputType extends Input>(
175+
entities: Entity[],
176+
component: Component<ComponentValueType>,
177+
fromComponentValueToInput: (componentValue: ComponentValueType) => InputType,
178+
fromInputToComponentValue: (input: InputType) => ComponentValueType,
179+
validateInput: (input: InputType) => boolean = () => true
180+
) => {
181+
// If there's only one entity, use the single entity version just to be safe for now
182+
if (entities.length === 1) {
183+
return useComponentInput(
184+
entities[0],
185+
component,
186+
fromComponentValueToInput,
187+
fromInputToComponentValue,
188+
validateInput
189+
)
190+
}
191+
const sdk = useSdk()
192+
193+
// Get initial merged value from all entities
194+
const initialEntityValues = getEntityAndComponentValue(entities, component)
195+
const initialMergedValue = useMemo(
196+
() =>
197+
mergeComponentValues(
198+
initialEntityValues.map(([_, component]) => component),
199+
fromComponentValueToInput
200+
),
201+
[] // only compute on mount
202+
)
203+
204+
const [value, setMergeValue] = useState(initialMergedValue)
205+
const [isValid, setIsValid] = useState(true)
206+
const [isFocused, setIsFocused] = useState(false)
207+
208+
// Handle input updates
209+
const handleUpdate = useCallback(
210+
(path: NestedKey<InputType>, getter: (event: React.ChangeEvent<HTMLInputElement>) => any = (e) => e.target.value) =>
211+
(event: React.ChangeEvent<HTMLInputElement>) => {
212+
if (!value) return
213+
214+
const newValue = setValue(value, path, getter(event))
215+
if (!hasDiff(value, newValue, 2)) return
216+
217+
// Only update if component is last-write-win and SDK exists
218+
if (!isLastWriteWinComponent(component) || !sdk) {
219+
setMergeValue(newValue)
220+
return
221+
}
222+
223+
// Validate and update all entities
224+
const entityUpdates = getEntityAndComponentValue(entities, component).map(([entity, componentValue]) => {
225+
const updatedInput = setValue(fromComponentValueToInput(componentValue as any), path, getter(event))
226+
const newComponentValue = fromInputToComponentValue(updatedInput)
227+
return {
228+
entity,
229+
value: newComponentValue,
230+
isValid: validateInput(updatedInput)
231+
}
232+
})
233+
234+
const allUpdatesValid = entityUpdates.every(({ isValid }) => isValid)
235+
236+
if (allUpdatesValid) {
237+
entityUpdates.forEach(({ entity, value }) => {
238+
sdk.operations.updateValue(component, entity, value)
239+
})
240+
void sdk.operations.dispatch()
241+
}
242+
243+
setMergeValue(newValue)
244+
setIsValid(allUpdatesValid)
245+
},
246+
[value, sdk, component, entities, fromInputToComponentValue, fromComponentValueToInput, validateInput]
247+
)
248+
249+
// Sync with engine changes
250+
useChange(
251+
(event) => {
252+
const isRelevantUpdate =
253+
entities.includes(event.entity) &&
254+
component.componentId === event.component?.componentId &&
255+
event.value &&
256+
event.operation === CrdtMessageType.PUT_COMPONENT
257+
258+
if (!isRelevantUpdate) return
259+
260+
const updatedEntityValues = getEntityAndComponentValue(entities, component)
261+
const newMergedValue = mergeComponentValues(
262+
updatedEntityValues.map(([_, component]) => component),
263+
fromComponentValueToInput
264+
)
265+
266+
if (!hasDiff(value, newMergedValue, 2) || isFocused) return
267+
268+
setMergeValue(newMergedValue)
269+
},
270+
[entities, component, fromComponentValueToInput, value, isFocused]
271+
)
272+
273+
// Input props getter
274+
const getInputProps = useCallback(
275+
(
276+
path: NestedKey<InputType>,
277+
getter?: (event: React.ChangeEvent<HTMLInputElement>) => any
278+
): Pick<InputHTMLAttributes<HTMLElement>, 'value' | 'onChange' | 'onFocus' | 'onBlur'> => ({
279+
value: (getValue(value, path) || '').toString(),
280+
onChange: handleUpdate(path, getter),
281+
onFocus: () => setIsFocused(true),
282+
onBlur: () => setIsFocused(false)
283+
}),
284+
[value, handleUpdate]
285+
)
286+
287+
return { getInputProps, isValid }
288+
}

packages/@dcl/inspector/src/hooks/sdk/useComponentValue.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,7 @@ export const useComponentValue = <ComponentValueType>(entity: Entity, component:
2828

2929
// sync state -> engine
3030
useEffect(() => {
31-
if (value === null) return
32-
const isEqualValue = !recursiveCheck(getComponentValue(entity, component), value, 2)
33-
34-
if (isEqualValue) {
35-
return
36-
}
31+
if (value === null || isComponentEqual(value)) return
3732
if (isLastWriteWinComponent(component) && sdk) {
3833
sdk.operations.updateValue(component, entity, value!)
3934
void sdk.operations.dispatch()
@@ -48,7 +43,7 @@ export const useComponentValue = <ComponentValueType>(entity: Entity, component:
4843
(event) => {
4944
if (entity === event.entity && component.componentId === event.component?.componentId && !!event.value) {
5045
if (event.operation === CrdtMessageType.PUT_COMPONENT) {
51-
// TODO: This setValue is generating a isEqual comparission.
46+
// TODO: This setValue is generating an isEqual comparission in previous effect.
5247
// Maybe we have to use two pure functions instead of an effect.
5348
// Same happens with the input & componentValue.
5449
setValue(event.value)

packages/@dcl/inspector/src/lib/babylon/decentraland/editorComponents/selection.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,28 +53,14 @@ export const setGizmoManager = (entity: EcsEntity, value: { gizmo: number }) =>
5353

5454
toggleSelection(entity, true)
5555

56-
const selectedEntities = Array.from(context.engine.getEntitiesWith(context.editorComponents.Selection))
5756
const types = context.gizmos.getGizmoTypes()
5857
const type = types[value?.gizmo || 0]
5958
context.gizmos.setGizmoType(type)
60-
61-
if (selectedEntities.length === 1) {
62-
context.gizmos.setEntity(entity)
63-
} else if (selectedEntities.length > 1) {
64-
context.gizmos.repositionGizmoOnCentroid()
65-
}
59+
context.gizmos.addEntity(entity)
6660
}
6761

6862
export const unsetGizmoManager = (entity: EcsEntity) => {
6963
const context = entity.context.deref()!
70-
const selectedEntities = Array.from(context.engine.getEntitiesWith(context.editorComponents.Selection))
71-
const currentEntity = context.gizmos.getEntity()
72-
7364
toggleSelection(entity, false)
74-
75-
if (currentEntity?.entityId === entity.entityId || selectedEntities.length === 0) {
76-
context.gizmos.unsetEntity()
77-
} else {
78-
context.gizmos.repositionGizmoOnCentroid()
79-
}
65+
context.gizmos.removeEntity(entity)
8066
}

0 commit comments

Comments
 (0)