Skip to content

Commit c4f93a1

Browse files
lethemanhrezk2ll
authored andcommitted
feat: implement virtualized grid list drag and drop ✨
1 parent 8744345 commit c4f93a1

File tree

9 files changed

+345
-3
lines changed

9 files changed

+345
-3
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import cx from 'classnames'
2+
import React, { useEffect } from 'react'
3+
import { useDrag, useDrop } from 'react-dnd'
4+
import { getEmptyImage } from 'react-dnd-html5-backend'
5+
6+
const GridItem = ({ item, context, renderItem, className }) => {
7+
const {
8+
selectedItems = [],
9+
itemsInDropProcess = [],
10+
setItemsInDropProcess = () => {},
11+
dragProps
12+
} = context || {}
13+
14+
const {
15+
onDrop,
16+
canDrop: canDropProps,
17+
canDrag: canDragProps,
18+
dragId
19+
} = dragProps
20+
21+
const isSelected = context?.isSelectedItem?.(item)
22+
const isDisabled = itemsInDropProcess.includes(item._id)
23+
24+
const [{ isDragging }, dragRef, dragPreview] = useDrag(
25+
() => ({
26+
type: dragId,
27+
isDragging: monitor => {
28+
if (selectedItems.length > 0) {
29+
return selectedItems.some(sel => sel._id === item._id)
30+
}
31+
return item._id === monitor.getItem().draggedItems?.[0]._id
32+
},
33+
item: {
34+
draggedItems: selectedItems.length > 0 ? selectedItems : [item]
35+
},
36+
canDrag: () => {
37+
const defaultCanDrag = canDragProps?.(item) ?? true
38+
if (selectedItems.length > 0) {
39+
return defaultCanDrag && isSelected
40+
}
41+
return defaultCanDrag
42+
},
43+
collect: monitor => ({
44+
isDragging: monitor.isDragging()
45+
})
46+
}),
47+
[item, selectedItems]
48+
)
49+
50+
const [{ isOver }, dropRef] = useDrop(
51+
() => ({
52+
accept: dragId,
53+
canDrop: () => (canDropProps ? canDropProps(item) : true),
54+
drop: async draggedItem => {
55+
setItemsInDropProcess(
56+
draggedItem.draggedItems.map(dragged => dragged._id)
57+
)
58+
await onDrop(draggedItem.draggedItems, item, selectedItems)
59+
setItemsInDropProcess([])
60+
},
61+
collect: monitor => ({
62+
isOver: monitor.isOver()
63+
})
64+
}),
65+
[item._id, selectedItems]
66+
)
67+
68+
useEffect(() => {
69+
dragPreview(getEmptyImage(), { captureDraggingState: true })
70+
}, [dragPreview])
71+
72+
return (
73+
<div
74+
ref={node => dragRef(dropRef(node))}
75+
className={cx(
76+
className,
77+
isDragging ? 'virtualized u-o-50' : 'virtualized'
78+
)}
79+
style={{ opacity: isDisabled ? 0.5 : 1 }}
80+
>
81+
{renderItem(item, { isDragging, isOver, isDisabled })}
82+
</div>
83+
)
84+
}
85+
86+
export default GridItem
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React, { forwardRef, useState, useMemo } from 'react'
2+
3+
import GridItem from './GridItem'
4+
import VirtualizedGridList from '../'
5+
import CustomDragLayer from '../../../utils/Dnd/CustomDrag/CustomDragLayer'
6+
import DnDConfigWrapper from '../../../utils/Dnd/DnDConfigWrapper'
7+
8+
const VirtualizedGridListDnd = ({
9+
dragProps,
10+
context,
11+
itemRenderer,
12+
children,
13+
componentProps = {
14+
List: {},
15+
Item: {}
16+
},
17+
components,
18+
...props
19+
}) => {
20+
const [itemsInDropProcess, setItemsInDropProcess] = useState([])
21+
22+
const _context = useMemo(
23+
() => ({
24+
...context,
25+
dragProps,
26+
itemRenderer,
27+
itemsInDropProcess,
28+
setItemsInDropProcess,
29+
items: props.items
30+
}),
31+
[context, dragProps, itemRenderer, itemsInDropProcess, props.items]
32+
)
33+
34+
return (
35+
<>
36+
<CustomDragLayer dragId={dragProps.dragId} />
37+
<VirtualizedGridList
38+
components={{
39+
Scroller: forwardRef(({ ...scrollerProps }, ref) => (
40+
<DnDConfigWrapper ref={ref}>
41+
<div {...scrollerProps} ref={ref} />
42+
</DnDConfigWrapper>
43+
)),
44+
Item: ({ context, children, ...props }) => {
45+
const item = context?.items?.[props['data-index']]
46+
return (
47+
<GridItem
48+
item={item}
49+
context={context}
50+
renderItem={() => <>{children}</>}
51+
{...componentProps.Item}
52+
/>
53+
)
54+
},
55+
...components
56+
}}
57+
context={_context}
58+
itemRenderer={itemRenderer}
59+
{...props}
60+
>
61+
{children}
62+
</VirtualizedGridList>
63+
</>
64+
)
65+
}
66+
67+
export default VirtualizedGridListDnd

react/GridList/Virtualized/index.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import React, { forwardRef } from 'react'
22
import { VirtuosoGrid } from 'react-virtuoso'
33

44
const VirtualizedGridList = forwardRef(
5-
({ items = [], itemRenderer, components, ...props }, ref) => {
5+
({ items = [], itemRenderer, components, context, ...props }, ref) => {
66
return (
77
<VirtuosoGrid
88
ref={ref}
99
components={components}
10+
context={context}
1011
style={{ height: '100%' }}
1112
totalCount={items.length}
1213
itemContent={index => itemRenderer(items[index])}

react/Table/Virtualized/Dnd/index.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useState, useMemo } from 'react'
22

3-
import CustomDragLayer from './CustomDrag/CustomDragLayer'
43
import virtuosoComponentsDnd from './virtuosoComponents'
4+
import CustomDragLayer from '../../../utils/Dnd/CustomDrag/CustomDragLayer'
55
import VirtualizedTable from '../index'
66
import virtuosoComponents from '../virtuosoComponents'
77

react/Table/Virtualized/Dnd/virtuosoComponents.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React, { forwardRef } from 'react'
22

3-
import DnDConfigWrapper from './DnDConfigWrapper'
43
import TableRowDnD from './TableRow'
54
import TableContainer from '../../../TableContainer'
5+
import DnDConfigWrapper from '../../../utils/Dnd/DnDConfigWrapper'
66
import virtuosoComponents from '../virtuosoComponents'
77

88
/**
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React from 'react'
2+
import { useDragLayer } from 'react-dnd'
3+
4+
import DragPreviewWrapper from './DragPreviewWrapper'
5+
6+
const layerStyles = {
7+
position: 'fixed',
8+
pointerEvents: 'none',
9+
zIndex: 100,
10+
left: 0,
11+
top: 0,
12+
width: '100%',
13+
height: '100%'
14+
}
15+
16+
// Example find in the official documentation
17+
// https://react-dnd.github.io/react-dnd/examples/drag-around/custom-drag-layer
18+
export const CustomDragLayer = ({ dragId }) => {
19+
const { itemType, isDragging, item, initialOffset, currentOffset } =
20+
useDragLayer(monitor => ({
21+
item: monitor.getItem(),
22+
itemType: monitor.getItemType(),
23+
initialOffset: monitor.getInitialSourceClientOffset(),
24+
currentOffset: monitor.getSourceClientOffset(),
25+
isDragging: monitor.isDragging()
26+
}))
27+
28+
if (!isDragging) {
29+
return null
30+
}
31+
32+
return (
33+
<div style={layerStyles}>
34+
<DragPreviewWrapper
35+
item={item}
36+
itemType={itemType}
37+
dragId={dragId}
38+
initialOffset={initialOffset}
39+
currentOffset={currentOffset}
40+
/>
41+
</div>
42+
)
43+
}
44+
45+
export default CustomDragLayer
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from 'react'
2+
3+
import Badge from '../../../Badge'
4+
import Paper from '../../../Paper'
5+
import Typography from '../../../Typography'
6+
import { makeStyles } from '../../../styles'
7+
8+
const useStyles = makeStyles({
9+
root: {
10+
width: 'fit-content'
11+
}
12+
})
13+
14+
const DragPreview = ({ fileName, selectedCount }) => {
15+
const classes = useStyles()
16+
17+
return (
18+
<>
19+
{selectedCount > 1 ? (
20+
<Badge
21+
badgeContent={selectedCount}
22+
size="large"
23+
color="primary"
24+
anchorOrigin={{
25+
vertical: 'top',
26+
horizontal: 'right'
27+
}}
28+
overlap="rectangular"
29+
>
30+
<Paper classes={classes} className="u-p-half u-maw-5">
31+
<Typography>{fileName}</Typography>
32+
</Paper>
33+
</Badge>
34+
) : (
35+
<Paper classes={classes} className="u-p-half u-maw-5">
36+
<Typography>{fileName}</Typography>
37+
</Paper>
38+
)}
39+
</>
40+
)
41+
}
42+
43+
export default React.memo(DragPreview)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React, { useEffect, useState } from 'react'
2+
3+
import DragPreview from './DragPreview'
4+
5+
const makeStyles = ({ x, y }) => {
6+
if (!x || !y) {
7+
return { display: 'none' }
8+
}
9+
10+
const transform = `translate(${x}px, ${y}px)`
11+
12+
return {
13+
transform,
14+
WebkitTransform: transform
15+
}
16+
}
17+
18+
const DragPreviewWrapper = ({
19+
item,
20+
itemType,
21+
dragId,
22+
initialOffset,
23+
currentOffset
24+
}) => {
25+
const [mousePosition, setMousePosition] = useState({ x: null, y: null })
26+
27+
useEffect(() => {
28+
const handleMouseMove = e => {
29+
setMousePosition({ x: e.clientX, y: e.clientY })
30+
}
31+
32+
window.addEventListener('dragover', handleMouseMove)
33+
return () => {
34+
window.removeEventListener('dragover', handleMouseMove)
35+
}
36+
}, [])
37+
38+
if (!initialOffset || !currentOffset || itemType !== dragId) {
39+
return null
40+
}
41+
42+
return (
43+
<div style={makeStyles(mousePosition)}>
44+
<DragPreview
45+
fileName={item.draggedItems[0].name}
46+
selectedCount={item.draggedItems.length}
47+
/>
48+
</div>
49+
)
50+
}
51+
52+
export default DragPreviewWrapper

react/utils/Dnd/DnDConfigWrapper.jsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { forwardRef, useEffect, useState } from 'react'
2+
import { useDragDropManager } from 'react-dnd'
3+
4+
const DnDConfigWrapper = forwardRef(({ children }, ref) => {
5+
const dragDropManager = useDragDropManager()
6+
const monitor = dragDropManager.getMonitor()
7+
const [isDragging, setIsDragging] = useState(false)
8+
9+
useEffect(() => {
10+
const unsubscribe = monitor.subscribeToStateChange(() => {
11+
setIsDragging(monitor.isDragging())
12+
})
13+
return () => unsubscribe()
14+
}, [monitor])
15+
16+
useEffect(() => {
17+
if (!isDragging) return
18+
19+
const scrollThreshold = 100
20+
const scrollMaxSpeed = 75
21+
22+
const intervalId = setInterval(() => {
23+
const offset = monitor.getClientOffset()
24+
const container = ref.current
25+
if (!offset || !container) return
26+
27+
const { top, bottom } = container.getBoundingClientRect()
28+
const distanceToTop = offset.y - top
29+
const distanceToBottom = bottom - offset.y
30+
31+
if (distanceToTop < scrollThreshold) {
32+
const speed = scrollMaxSpeed * (1 - distanceToTop / scrollThreshold)
33+
container.scrollBy(0, -speed)
34+
} else if (distanceToBottom < scrollThreshold) {
35+
const speed = scrollMaxSpeed * (1 - distanceToBottom / scrollThreshold)
36+
container.scrollBy(0, speed)
37+
}
38+
}, 16) // ~60fps
39+
40+
return () => clearInterval(intervalId)
41+
}, [isDragging, monitor, ref])
42+
43+
return children
44+
})
45+
46+
DnDConfigWrapper.displayName = 'DnDConfigWrapper'
47+
48+
export default DnDConfigWrapper

0 commit comments

Comments
 (0)