Skip to content

Commit 54fc604

Browse files
committed
feat: draggable and resizable grid item component
1 parent 7c2ddce commit 54fc604

File tree

1 file changed

+258
-0
lines changed

1 file changed

+258
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Inspired by react-crop-video project by BiteSize Academy.
2+
// https://github.com/alexkrkn/react-crop-video/
3+
// https://www.youtube.com/watch?v=vDxZLN6FVqY
4+
5+
import { animated, useSpring } from '@react-spring/web';
6+
import type { EventTypes, Handler, UserDragConfig } from '@use-gesture/react';
7+
import { useDrag } from '@use-gesture/react';
8+
import get from 'lodash-es/get';
9+
import isNil from 'lodash-es/isNil';
10+
import type { MouseEvent, ReactNode, RefObject } from 'react';
11+
import { useCallback, useMemo, useRef } from 'react';
12+
import { useLogger } from '../../hooks/logger.jsx';
13+
14+
export interface DraggableItemProps {
15+
/**
16+
* The dimension for the grid where the item may be dragged and resized.
17+
*/
18+
boundary: {
19+
/**
20+
* The max height of the grid in pixels.
21+
*/
22+
height: number;
23+
/**
24+
* The max width of the grid in pixels.
25+
*/
26+
width: number;
27+
};
28+
/**
29+
* The unique identifier for the grid item.
30+
*/
31+
itemId: string;
32+
/**
33+
* Text to display in the title bar of the grid item.
34+
* Note the prop `title` is reserved and refers to titling a DOM element,
35+
* not for passing data to child components. So using a more specific name.
36+
*/
37+
titleBarText: string;
38+
/**
39+
* Handler when the user clicks the close button in the title bar.
40+
* Passes the `itemId` of the grid item being closed.
41+
*/
42+
onClose?: (itemId: string) => void;
43+
/**
44+
* Is this the focused grid item?
45+
* When yes then it will be positioned above the other grid items.
46+
*/
47+
isFocused?: boolean;
48+
/**
49+
* When the grid item receives focus then notify the parent component.
50+
* The parent component has responsibility for managing the `isFocused`
51+
* property for all of the grid items to reflect the change.
52+
*/
53+
onFocus?: (itemId: string) => void;
54+
/**
55+
* This property contains any children nested within the grid item
56+
* when you're constructing the grid layout.
57+
* You must nest it within the root element of the grid item.
58+
*/
59+
children?: ReactNode;
60+
}
61+
62+
export const DraggableItem: React.FC<DraggableItemProps> = (
63+
props: DraggableItemProps
64+
): ReactNode => {
65+
const { boundary, itemId, titleBarText } = props;
66+
const { isFocused, onFocus, onClose, children } = props;
67+
68+
const logger = useLogger('draggable-item');
69+
70+
// Handle when the user clicks the close button in the title bar.
71+
const onCloseClick = useCallback(
72+
(evt: MouseEvent<HTMLElement>) => {
73+
evt.preventDefault();
74+
if (onClose) {
75+
onClose(itemId);
76+
}
77+
},
78+
[onClose, itemId]
79+
);
80+
81+
const [{ x, y, width, height }, api] = useSpring(() => ({
82+
x: 0,
83+
y: 0,
84+
width: 100,
85+
height: 100,
86+
}));
87+
88+
const dragHandleRef = useRef<HTMLDivElement>(null);
89+
const resizeHandleRef = useRef<HTMLDivElement>(null);
90+
91+
/**
92+
* Is the event target the same element as the ref?
93+
*/
94+
const isEventTarget = useCallback(
95+
(
96+
eventOrTarget: Event | EventTarget | null | undefined,
97+
ref: RefObject<HTMLElement>
98+
) => {
99+
if (isNil(eventOrTarget)) {
100+
return false;
101+
}
102+
103+
if (eventOrTarget === ref.current) {
104+
return true;
105+
}
106+
107+
if (get(eventOrTarget, 'target') === ref.current) {
108+
return true;
109+
}
110+
111+
if (get(eventOrTarget, 'currentTarget') === ref.current) {
112+
return true;
113+
}
114+
115+
return false;
116+
},
117+
[]
118+
);
119+
120+
/**
121+
* Did the user click and drag the drag handle?
122+
*/
123+
const isDragging = useCallback(
124+
(eventOrTarget: Event | EventTarget | null | undefined): boolean => {
125+
return isEventTarget(eventOrTarget, dragHandleRef);
126+
},
127+
[isEventTarget]
128+
);
129+
130+
/**
131+
* Did the user click and drag the resize handle?
132+
*/
133+
const isResizing = useCallback(
134+
(eventOrTarget: Event | EventTarget | null | undefined): boolean => {
135+
return isEventTarget(eventOrTarget, resizeHandleRef);
136+
},
137+
[isEventTarget]
138+
);
139+
140+
const dragHandler: Handler<'drag', EventTypes['drag']> = useCallback(
141+
/**
142+
* Callback to invoke when a gesture event ends.
143+
* For example, when the user stops dragging or resizing.
144+
*/
145+
(state) => {
146+
// The vector for where the pointer has moved to relative to
147+
// the last vector returned by the `from` drag option function.
148+
// When resizing, the values are the new width and height dimensions.
149+
// When dragging, the values are the new x and y coordinates.
150+
const [dx, dy] = state.offset;
151+
152+
if (isResizing(state.event)) {
153+
api.set({ width: dx, height: dy });
154+
}
155+
156+
if (isDragging(state.event)) {
157+
api.set({ x: dx, y: dy });
158+
}
159+
160+
// On the start of a gesture, ensure the grid item is visible
161+
// above all other grid items that may be overlapping it.
162+
if (state.active) {
163+
if (onFocus) {
164+
onFocus(itemId);
165+
}
166+
}
167+
},
168+
[itemId, api, isResizing, isDragging, onFocus]
169+
);
170+
171+
const dragOptions: UserDragConfig = useMemo(() => {
172+
return {
173+
/**
174+
* When a gesture event begins, specify the reference vector
175+
* from which to calculate the distance the pointer moves.
176+
*/
177+
from: (state) => {
178+
if (isResizing(state.target)) {
179+
return [width.get(), height.get()];
180+
}
181+
return [x.get(), y.get()];
182+
},
183+
/**
184+
* When a gesture event begins, specify the where the pointer can move.
185+
* The element will not be dragged or resized outside of these bounds.
186+
*/
187+
bounds: (state) => {
188+
const containerWidth = boundary.width;
189+
const containerHeight = boundary.height;
190+
if (isResizing(state?.event)) {
191+
return {
192+
top: 50, // min height
193+
left: 50, // min width
194+
right: containerWidth - x.get(),
195+
bottom: containerHeight - y.get(),
196+
};
197+
}
198+
return {
199+
top: 0,
200+
left: 0,
201+
right: containerWidth - width.get(),
202+
bottom: containerHeight - height.get(),
203+
};
204+
},
205+
};
206+
}, [x, y, width, height, boundary, isResizing]);
207+
208+
const bind = useDrag(dragHandler, dragOptions);
209+
210+
return (
211+
<animated.div
212+
style={{
213+
position: 'absolute',
214+
x,
215+
y,
216+
width,
217+
height,
218+
backgroundColor: 'brown',
219+
overflow: 'hidden',
220+
touchAction: 'none',
221+
zIndex: isFocused ? 999 : 888,
222+
}}
223+
{...bind()}
224+
>
225+
<div
226+
ref={dragHandleRef}
227+
style={{
228+
position: 'absolute',
229+
width: '100%',
230+
height: '20px',
231+
cursor: 'move',
232+
backgroundColor: 'red',
233+
textAlign: 'center',
234+
}}
235+
>
236+
Drag Handle
237+
</div>
238+
239+
{children}
240+
241+
<div
242+
ref={resizeHandleRef}
243+
style={{
244+
position: 'absolute',
245+
bottom: -4,
246+
right: -4,
247+
width: 10,
248+
height: 10,
249+
cursor: 'nwse-resize',
250+
backgroundColor: '#0097df',
251+
borderRadius: 4,
252+
}}
253+
></div>
254+
</animated.div>
255+
);
256+
};
257+
258+
DraggableItem.displayName = 'DraggableItem';

0 commit comments

Comments
 (0)