Skip to content

Commit d54d2fa

Browse files
authored
feat: range select in entity tree & multiple drag & drop behaviour (#1154)
* small tweaks * add range select for Tree component * add multiple drag & drop behaviour
1 parent e40b0c4 commit d54d2fa

6 files changed

Lines changed: 191 additions & 31 deletions

File tree

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

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useMemo } from 'react'
1+
import React, { useCallback, useMemo, useState } from 'react'
22
import { Entity } from '@dcl/ecs'
33

44
import { CAMERA, PLAYER, ROOT } from '../../lib/sdk/tree'
@@ -76,6 +76,7 @@ const Hierarchy: React.FC = () => {
7676
getId,
7777
getChildren,
7878
getLabel,
79+
getSelectedItems,
7980
isOpen,
8081
isHidden,
8182
canRename,
@@ -86,6 +87,7 @@ const Hierarchy: React.FC = () => {
8687
centerViewOnEntity
8788
} = useTree()
8889
const selectedEntities = useEntitiesWith((components) => components.Selection)
90+
const [lastSelectedItem, setLastSelectedItem] = useState<Entity | undefined>(undefined)
8991

9092
const isSelected = useCallback(
9193
(entity: Entity) => {
@@ -94,19 +96,74 @@ const Hierarchy: React.FC = () => {
9496
[selectedEntities]
9597
)
9698

99+
const getAllVisibleEntities = useCallback(() => {
100+
const entities: Entity[] = []
101+
102+
const traverse = (entity: Entity) => {
103+
if (!isHidden(entity)) {
104+
entities.push(entity)
105+
if (isOpen(entity)) {
106+
getChildren(entity).forEach((child) => traverse(child))
107+
}
108+
}
109+
}
110+
111+
traverse(ROOT)
112+
113+
return entities
114+
}, [getChildren, isOpen, isHidden])
115+
116+
const handleRangeSelection = useCallback(
117+
(fromEntity: Entity, toEntity: Entity) => {
118+
const allEntities = getAllVisibleEntities()
119+
const fromIndex = allEntities.findIndex((e) => getId(e) === getId(fromEntity))
120+
const toIndex = allEntities.findIndex((e) => getId(e) === getId(toEntity))
121+
122+
const startIndex = Math.min(fromIndex, toIndex)
123+
const endIndex = Math.max(fromIndex, toIndex)
124+
125+
allEntities.forEach((entity, index) => {
126+
if (index >= startIndex && index <= endIndex) {
127+
void select(entity, index > startIndex) // first item replaces selection, others add to selection
128+
}
129+
})
130+
},
131+
[getAllVisibleEntities, getId, select]
132+
)
133+
134+
const handleSelect = useCallback(
135+
(entity: Entity, clickType?: 'single' | 'ctrl' | 'shift') => {
136+
if (clickType === 'shift' && lastSelectedItem) {
137+
handleRangeSelection(lastSelectedItem, entity)
138+
} else {
139+
const isMultipleSelection = clickType === 'ctrl' || clickType === 'shift'
140+
void select(entity, isMultipleSelection)
141+
}
142+
},
143+
[select, lastSelectedItem, handleRangeSelection]
144+
)
145+
146+
const handleLastSelectedChange = useCallback(
147+
(entity: Entity) => {
148+
if (entity !== lastSelectedItem) setLastSelectedItem(entity)
149+
},
150+
[lastSelectedItem]
151+
)
152+
97153
const props = {
98154
getExtraContextMenu: ContextMenu,
99155
onAddChild: addChild,
100156
onDrop: setParent,
101157
onRemove: remove,
102158
onRename: rename,
103-
onSelect: select,
159+
onSelect: handleSelect,
104160
onDoubleSelect: centerViewOnEntity,
105161
onSetOpen: setOpen,
106162
onDuplicate: duplicate,
107163
getId: getId,
108164
getChildren: getChildren,
109165
getLabel: getLabel,
166+
getSelectedItems: getSelectedItems,
110167
getIcon: (val: Entity) => <HierarchyIcon value={val} />,
111168
isOpen: isOpen,
112169
isSelected: isSelected,
@@ -115,7 +172,8 @@ const Hierarchy: React.FC = () => {
115172
canRemove: canRemove,
116173
canDuplicate: canDuplicate,
117174
canDrag: canDrag,
118-
canReorder: canReorder
175+
canReorder: canReorder,
176+
onLastSelectedChange: handleLastSelectedChange
119177
}
120178

121179
return (

packages/@dcl/inspector/src/components/ProjectAssetExplorer/ProjectView.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,6 @@ function ProjectView({ folders, thumbnails }: Props) {
175175
onCancel={() => setSearch('')}
176176
/>
177177
<FilesTree
178-
tree={tree}
179178
className="editor-assets-tree"
180179
value={ROOT}
181180
onAddChild={noop}

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

Lines changed: 91 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Props<T> = {
2323
getChildren: (value: T) => T[]
2424
getIcon?: (value: T) => JSX.Element
2525
getLabel: (value: T) => string | JSX.Element
26+
getSelectedItems?: () => T[]
2627
isOpen: (value: T) => boolean
2728
isSelected: (value: T) => boolean
2829
isHidden: (value: T) => boolean
@@ -33,7 +34,7 @@ type Props<T> = {
3334
canDrag?: (value: T) => boolean
3435
canReorder?: (source: T, target: T, type: DropType) => boolean
3536
onSetOpen: (value: T, isOpen: boolean) => void
36-
onSelect: (value: T, multiple?: boolean) => void
37+
onSelect: (value: T, clickType?: 'single' | 'ctrl' | 'shift') => void
3738
onDoubleSelect?: (value: T) => void
3839
onDrop: (source: T, target: T, dropType: DropType) => void
3940
onRename: (value: T, label: string) => void
@@ -42,13 +43,13 @@ type Props<T> = {
4243
onDuplicate: (value: T) => void
4344
getDragContext?: () => unknown
4445
dndType?: string
45-
tree?: unknown
46+
onLastSelectedChange?: (value: T) => void
4647
}
4748

4849
type EmptyString = ''
4950

50-
const getDefaultLevel = () => 1
51-
const getLevelStyles = (level: number) => ({ paddingLeft: `${(level - 1) * 10}px` })
51+
const getDefaultLevel = () => 0
52+
const getLevelStyles = (level: number) => ({ paddingLeft: `${level * 10}px` })
5253
const getExpandStyles = (active: boolean) => ({ height: active ? 'auto' : '0', overflow: 'hidden', display: 'block' })
5354
const getEditModeStyles = (active: boolean) => ({ display: active ? 'none' : '' })
5455

@@ -64,6 +65,7 @@ export function Tree<T>() {
6465
getId,
6566
getChildren,
6667
getLabel,
68+
getSelectedItems,
6769
isOpen,
6870
isSelected,
6971
onSelect,
@@ -82,7 +84,8 @@ export function Tree<T>() {
8284
onDoubleSelect,
8385
onSetOpen,
8486
getDragContext = () => ({}),
85-
dndType = 'tree'
87+
dndType = 'tree',
88+
onLastSelectedChange
8689
} = props
8790
const ref = useRef<HTMLDivElement>(null)
8891
const id = getId(value)
@@ -110,33 +113,65 @@ export function Tree<T>() {
110113
[getId, getChildren]
111114
)
112115

113-
const [, drag] = useDrag(
114-
() => ({
115-
type: dndType,
116-
canDrag: enableDrag,
117-
item: { value, context: getDragContext() }
118-
}),
119-
[value]
116+
const canDropMultiple = useCallback(
117+
(target: T, sources: T[]): boolean => {
118+
if (sources.some((source) => getId(target) === getId(source))) return false
119+
if (sources.some((source) => isDescendantOf(target, source))) return false
120+
return getChildren(target).every(($) => canDropMultiple($, sources))
121+
},
122+
[getId, getChildren]
123+
)
124+
125+
const isDescendantOf = useCallback(
126+
(ancestor: T, descendant: T): boolean => {
127+
const children = getChildren(ancestor)
128+
if (children.some((child) => getId(child) === getId(descendant))) return true
129+
return children.some((child) => isDescendantOf(child, descendant))
130+
},
131+
[getId, getChildren]
120132
)
121133

122134
const [{ isHover }, drop] = useDrop(
123135
() => ({
124136
accept: dndType,
125-
drop: ({ value: item }: { value: T }, monitor) => {
137+
drop: (item: { items: T[]; context: unknown }, monitor) => {
126138
const dropTypeValue = dropType || dropTypeRef.current
127-
if (monitor.didDrop() || !canDrop(item, value) || !dropTypeValue) return
128-
onDrop(item, value, dropTypeValue)
139+
if (monitor.didDrop() || !dropTypeValue) return
140+
141+
const { items } = item
142+
const isMultipleDrag = items.length > 1
143+
144+
if (isMultipleDrag) {
145+
if (!canDropMultiple(value, items)) return
146+
items.forEach((sourceItem) => onDrop(sourceItem, value, dropTypeValue))
147+
} else {
148+
const sourceItem = items[0]
149+
if (!canDrop(sourceItem, value)) return
150+
onDrop(sourceItem, value, dropTypeValue)
151+
}
129152
},
130-
hover: ({ value: item }, monitor) => {
131-
if (!ref.current || item === value) {
153+
hover: (item: { items: T[]; context: unknown }, monitor) => {
154+
if (!ref.current) {
155+
dropTypeRef.current = ''
156+
return setDropType('')
157+
}
158+
159+
const { items } = item
160+
161+
// check if hovering over one of the dragged items
162+
if (items.some((sourceItem) => getId(sourceItem) === getId(value))) {
132163
dropTypeRef.current = ''
133164
return setDropType('')
134165
}
135166

136167
const coords = monitor.getClientOffset() as XYCoord
137168
const rect = ref.current.getBoundingClientRect()
138169
const dropType = calculateDropType(coords.y, rect)
139-
const enableReorder = canReorder ? canReorder(item, value, dropType) : true
170+
171+
const enableReorder = canReorder
172+
? items.every((sourceItem) => canReorder(sourceItem, value, dropType))
173+
: true
174+
140175
const newDropTypeValue = enableReorder ? dropType : ''
141176

142177
setDropType(newDropTypeValue)
@@ -146,19 +181,26 @@ export function Tree<T>() {
146181
isHover: monitor.isOver({ shallow: true })
147182
})
148183
}),
149-
[value, dropType, onDrop, canDrop]
184+
[value, dropType, onDrop, canDrop, canDropMultiple, canReorder, getId]
150185
)
151186

152187
const quitEditMode = () => setEditMode(false)
153188
const quitInsertMode = () => setInsertMode(false)
154189

155190
const handleSelect = (event: React.MouseEvent) => {
156-
if (event.type === ClickType.CONTEXT_MENU && event.ctrlKey) {
157-
onSelect(value, true)
158-
} else if (event.type === ClickType.CLICK) {
159-
onSelect(value, event.shiftKey)
160-
if (event.detail > 1 && onDoubleSelect) onDoubleSelect(value)
191+
const isMac = /Mac|iPhone|iPod|iPad/.test(navigator.userAgent)
192+
const isCtrlClick =
193+
(isMac ? event.type === ClickType.CONTEXT_MENU : event.type === ClickType.CLICK) && event.ctrlKey
194+
const isShiftClick = event.type === ClickType.CLICK && event.shiftKey
195+
const isDoubleClick = event.type === ClickType.CLICK && event.detail > 1 && onDoubleSelect
196+
const clickType = isCtrlClick ? 'ctrl' : isShiftClick ? 'shift' : 'single'
197+
198+
if (clickType === 'single' && onLastSelectedChange) {
199+
onLastSelectedChange(value)
161200
}
201+
202+
onSelect(value, clickType)
203+
if (isDoubleClick) onDoubleSelect(value)
162204
}
163205

164206
const handleOpen = (_: React.MouseEvent) => {
@@ -186,6 +228,29 @@ export function Tree<T>() {
186228
}
187229

188230
const sdk = useSdk()
231+
232+
const [, drag] = useDrag(
233+
() => ({
234+
type: dndType,
235+
canDrag: enableDrag,
236+
item: () => {
237+
const selectedItems = getSelectedItems ? getSelectedItems() : []
238+
// if this item is selected and there are multiple selections, drag all selected items
239+
if (selectedItems.length > 1 && selectedItems.some((item) => getId(item) === getId(value))) {
240+
return {
241+
items: selectedItems,
242+
context: getDragContext()
243+
}
244+
}
245+
return {
246+
items: [value],
247+
context: getDragContext()
248+
}
249+
}
250+
}),
251+
[value, getSelectedItems, getId]
252+
)
253+
189254
const handleRemove = () => {
190255
if (isEntity && sdk) {
191256
const selectedEntities = sdk.operations.getSelectedEntities()
@@ -252,13 +317,13 @@ export function Tree<T>() {
252317
<div ref={ref} style={getEditModeStyles(editMode)} className="item-area">
253318
<DisclosureWidget enabled={enableOpen} isOpen={open} onOpen={handleOpen} />
254319
<div onClick={handleSelect} onContextMenu={handleSelect} className="selectable-area">
255-
{props.getIcon ? props.getIcon(value) : <></>}
320+
{props.getIcon && props.getIcon(value)}
256321
<div>{label || id}</div>
257322
{isEntity && <ActionArea entity={value as Entity} />}
258323
</div>
259324
</div>
260325
{editMode && typeof label === 'string' && (
261-
<EditInput value={label || ''} onCancel={quitEditMode} onSubmit={onChangeEditValue} />
326+
<EditInput value={label} onCancel={quitEditMode} onSubmit={onChangeEditValue} />
262327
)}
263328
</div>
264329
<TreeChildren {...props} />

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ export const useTree = () => {
100100
[sdk, handleUpdate, tree]
101101
)
102102

103+
const getSelectedItems = useCallback((): Entity[] => {
104+
if (!sdk) return []
105+
return sdk.operations.getSelectedEntities()
106+
}, [sdk])
107+
103108
const setParent = useCallback(
104109
async (source: Entity, target: Entity, type: DropType) => {
105110
if (source === ROOT || !sdk) return
@@ -222,6 +227,7 @@ export const useTree = () => {
222227
canDuplicate,
223228
canDrag,
224229
canReorder,
225-
centerViewOnEntity
230+
centerViewOnEntity,
231+
getSelectedItems
226232
}
227233
}

packages/@dcl/inspector/test/e2e/Hierarchy.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,24 @@ describe('Hierarchy', () => {
119119
await expect(Hierarchy.exists(parent)).resolves.toBe(false)
120120
await expect(Hierarchy.exists(child)).resolves.toBe(false)
121121
}, 100_000)
122+
123+
test('drag and drop multiple selected entities', async () => {
124+
await Hierarchy.addChild(ROOT, 'multi-parent')
125+
await Hierarchy.addChild(ROOT, 'multi-child-1')
126+
await Hierarchy.addChild(ROOT, 'multi-child-2')
127+
await Hierarchy.addChild(ROOT, 'multi-child-3')
128+
129+
const parent = await Hierarchy.getId('multi-parent')
130+
const child1 = await Hierarchy.getId('multi-child-1')
131+
const child2 = await Hierarchy.getId('multi-child-2')
132+
const child3 = await Hierarchy.getId('multi-child-3')
133+
134+
await Hierarchy.selectMultiple([child1, child2, child3])
135+
136+
await Hierarchy.setParent(child1, parent) // this should move all selected children
137+
138+
await expect(Hierarchy.isAncestor(child1, parent)).resolves.toBe(true)
139+
await expect(Hierarchy.isAncestor(child2, parent)).resolves.toBe(true)
140+
await expect(Hierarchy.isAncestor(child3, parent)).resolves.toBe(true)
141+
}, 100_000)
122142
})

packages/@dcl/inspector/test/e2e/pageObjects/Hierarchy.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,18 @@ class HierarchyPageObject {
134134
}
135135
}
136136

137+
async selectMultiple(entityIds: number[]) {
138+
const firstItem = await this.getItem(entityIds[0], this.getItemSelectorById)
139+
await firstItem.click()
140+
141+
for (let i = 1; i < entityIds.length; i++) {
142+
const item = await this.getItem(entityIds[i], this.getItemSelectorById)
143+
await page.keyboard.down('Control')
144+
await item.click()
145+
await page.keyboard.up('Control')
146+
}
147+
}
148+
137149
async addComponent(entityId: number, componentName: string) {
138150
const item = await this.getItem(entityId, this.getItemSelectorById)
139151
await item.click({ button: 'right' })

0 commit comments

Comments
 (0)