Skip to content

Commit 7c2ddce

Browse files
committed
feat: drag and resize with react-springs
1 parent 7cca54d commit 7c2ddce

File tree

3 files changed

+300
-0
lines changed

3 files changed

+300
-0
lines changed
+220
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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 isNil from 'lodash-es/isNil.js';
9+
import type { ReactNode, RefObject } from 'react';
10+
import { useCallback, useMemo, useRef } from 'react';
11+
12+
export interface Grid4Props {
13+
/**
14+
* The dimension for the grid.
15+
*/
16+
dimensions: {
17+
/**
18+
* The max height of the grid in pixels.
19+
*/
20+
height: number;
21+
/**
22+
* The max width of the grid in pixels.
23+
*/
24+
width: number;
25+
};
26+
}
27+
28+
// We need to invoke our pointer event handlers from different event listeners
29+
// that each have their own unique interface. Rather than force cast the events
30+
// to the desired interface, which may introduce a bug later, we'll create
31+
// a simplified interface that can be used from any event listener.
32+
export interface GridPointerEvent {
33+
clientX: number;
34+
clientY: number;
35+
}
36+
37+
const GridItem4: React.FC<Grid4Props> = (props: Grid4Props): ReactNode => {
38+
const { dimensions: gridDimensions } = props;
39+
40+
const [{ x, y, width, height }, api] = useSpring(() => ({
41+
x: 0,
42+
y: 0,
43+
width: 100,
44+
height: 100,
45+
}));
46+
47+
const draggableRef = useRef<HTMLDivElement>(null);
48+
const resizableRef = useRef<HTMLDivElement>(null);
49+
50+
const isEventTarget = useCallback(
51+
(
52+
eventOrTarget: Event | EventTarget | null | undefined,
53+
ref: RefObject<HTMLElement>
54+
) => {
55+
if (isNil(eventOrTarget)) {
56+
return false;
57+
}
58+
if ('target' in eventOrTarget) {
59+
return eventOrTarget.target === ref.current;
60+
}
61+
return eventOrTarget === ref.current;
62+
},
63+
[]
64+
);
65+
66+
const isDragging = useCallback(
67+
(eventOrTarget: Event | EventTarget | null | undefined): boolean => {
68+
return isEventTarget(eventOrTarget, draggableRef);
69+
},
70+
[isEventTarget]
71+
);
72+
73+
const isResizing = useCallback(
74+
(eventOrTarget: Event | EventTarget | null | undefined): boolean => {
75+
return isEventTarget(eventOrTarget, resizableRef);
76+
},
77+
[isEventTarget]
78+
);
79+
80+
const dragHandler: Handler<'drag', EventTypes['drag']> = useCallback(
81+
/**
82+
* Callback to invoke when a gesture event ends.
83+
* For example, when the user stops dragging or resizing.
84+
*/
85+
(state) => {
86+
// The cumulative displacements the pointer has moved relative to
87+
// the last vector returned by the `from` drag option function.
88+
const [dx, dy] = state.offset;
89+
90+
if (isResizing(state.event)) {
91+
// When resizing, the values are the new width and height dimensions.
92+
api.set({
93+
width: dx,
94+
height: dy,
95+
});
96+
} else if (isDragging(state.event)) {
97+
// When dragging, the values are the new x and y coordinates.
98+
api.set({
99+
x: dx,
100+
y: dy,
101+
});
102+
}
103+
},
104+
[api, isResizing, isDragging]
105+
);
106+
107+
const dragOptions: UserDragConfig = useMemo(() => {
108+
return {
109+
/**
110+
* When a gesture event begins, specify the reference vector
111+
* from which to calculate the distance the pointer moves.
112+
*/
113+
from: (state) => {
114+
if (isResizing(state.target)) {
115+
return [width.get(), height.get()];
116+
}
117+
return [x.get(), y.get()];
118+
},
119+
/**
120+
* When a gesture event begins, specify the where the pointer can move.
121+
* The element will not be dragged or resized outside of these bounds.
122+
*/
123+
bounds: (state) => {
124+
const containerWidth = gridDimensions.width;
125+
const containerHeight = gridDimensions.height;
126+
if (isResizing(state?.event)) {
127+
return {
128+
top: 50, // min height
129+
left: 50, // min width
130+
right: containerWidth - x.get(),
131+
bottom: containerHeight - y.get(),
132+
};
133+
}
134+
return {
135+
top: 0,
136+
left: 0,
137+
right: containerWidth - width.get(),
138+
bottom: containerHeight - height.get(),
139+
};
140+
},
141+
};
142+
}, [x, y, width, height, gridDimensions, isResizing]);
143+
144+
const bind = useDrag(dragHandler, dragOptions);
145+
146+
return (
147+
<animated.div
148+
style={{
149+
position: 'absolute',
150+
x,
151+
y,
152+
width,
153+
height,
154+
backgroundColor: 'brown',
155+
overflow: 'hidden',
156+
}}
157+
{...bind()}
158+
>
159+
<div
160+
ref={draggableRef}
161+
style={{
162+
width: '100%',
163+
height: '20px',
164+
cursor: 'move',
165+
backgroundColor: 'red',
166+
textAlign: 'center',
167+
}}
168+
>
169+
Drag Handle
170+
</div>
171+
172+
<div
173+
style={{
174+
width: '100%',
175+
height: '100%',
176+
overflowY: 'auto',
177+
overflowX: 'hidden',
178+
}}
179+
>
180+
This quick brown fox jumped over the fence.
181+
</div>
182+
183+
<div
184+
ref={resizableRef}
185+
style={{
186+
position: 'absolute',
187+
bottom: -4,
188+
right: -4,
189+
width: 10,
190+
height: 10,
191+
cursor: 'nwse-resize',
192+
backgroundColor: '#0097df',
193+
borderRadius: 4,
194+
}}
195+
></div>
196+
</animated.div>
197+
);
198+
};
199+
200+
GridItem4.displayName = 'GridItem4';
201+
202+
export const Grid4: React.FC<Grid4Props> = (props: Grid4Props): ReactNode => {
203+
const gridDimensions = props.dimensions;
204+
205+
return (
206+
<div
207+
style={{
208+
position: 'relative',
209+
height: gridDimensions.height,
210+
width: gridDimensions.width,
211+
overflow: 'hidden',
212+
}}
213+
>
214+
<GridItem4 dimensions={gridDimensions} />
215+
<GridItem4 dimensions={gridDimensions} />
216+
</div>
217+
);
218+
};
219+
220+
Grid4.displayName = 'Grid4';

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,11 @@
7171
"@emotion/cache": "^11.11.0",
7272
"@emotion/css": "^11.11.2",
7373
"@emotion/react": "^11.11.3",
74+
"@react-spring/web": "^9.7.3",
7475
"@sentry/electron": "^4.18.0",
7576
"@sentry/nextjs": "^7.102.1",
7677
"@sentry/node": "^7.102.1",
78+
"@use-gesture/react": "^10.3.0",
7779
"dotenv": "^16.4.5",
7880
"dotenv-flow": "^4.1.0",
7981
"electron-extension-installer": "^1.2.0",

yarn.lock

+78
Original file line numberDiff line numberDiff line change
@@ -2736,6 +2736,64 @@ __metadata:
27362736
languageName: node
27372737
linkType: hard
27382738

2739+
"@react-spring/animated@npm:~9.7.3":
2740+
version: 9.7.3
2741+
resolution: "@react-spring/animated@npm:9.7.3"
2742+
dependencies:
2743+
"@react-spring/shared": "npm:~9.7.3"
2744+
"@react-spring/types": "npm:~9.7.3"
2745+
peerDependencies:
2746+
react: ^16.8.0 || ^17.0.0 || ^18.0.0
2747+
checksum: 10c0/5151da4fa7da010bb2edbee05871aa7a4aea8763fe617389d17605810aa0dd817374205e5fb3930b650f4a7f25fcdf23205fdfb7365686ff75888bdfd0b39839
2748+
languageName: node
2749+
linkType: hard
2750+
2751+
"@react-spring/core@npm:~9.7.3":
2752+
version: 9.7.3
2753+
resolution: "@react-spring/core@npm:9.7.3"
2754+
dependencies:
2755+
"@react-spring/animated": "npm:~9.7.3"
2756+
"@react-spring/shared": "npm:~9.7.3"
2757+
"@react-spring/types": "npm:~9.7.3"
2758+
peerDependencies:
2759+
react: ^16.8.0 || ^17.0.0 || ^18.0.0
2760+
checksum: 10c0/e28c05de8435bf2eaf8481f8acdf093909d4be9881a9a854c51dfac7c2d5562088d0fb2ce04e2f07e1b3bf621d8da3ab57bf6fedb4fdc954e3aa263bc1e393af
2761+
languageName: node
2762+
linkType: hard
2763+
2764+
"@react-spring/shared@npm:~9.7.3":
2765+
version: 9.7.3
2766+
resolution: "@react-spring/shared@npm:9.7.3"
2767+
dependencies:
2768+
"@react-spring/types": "npm:~9.7.3"
2769+
peerDependencies:
2770+
react: ^16.8.0 || ^17.0.0 || ^18.0.0
2771+
checksum: 10c0/afb03ed28ccf62efa4012e531c3659999bb364d1e0eb0fb729b962f9bd21a0b772a2d98e862062c9c32c06edf72327afcc45984d9eb22fdd961706b6ddf6950d
2772+
languageName: node
2773+
linkType: hard
2774+
2775+
"@react-spring/types@npm:~9.7.3":
2776+
version: 9.7.3
2777+
resolution: "@react-spring/types@npm:9.7.3"
2778+
checksum: 10c0/d645044f3cc9ceb7c4f6c4d061aaf6660018568a1553d05638f56b3328c5f91597ee4118334abe22fc8f07f5ee02f054340170c1d52e11b3faea22888b5170d4
2779+
languageName: node
2780+
linkType: hard
2781+
2782+
"@react-spring/web@npm:^9.7.3":
2783+
version: 9.7.3
2784+
resolution: "@react-spring/web@npm:9.7.3"
2785+
dependencies:
2786+
"@react-spring/animated": "npm:~9.7.3"
2787+
"@react-spring/core": "npm:~9.7.3"
2788+
"@react-spring/shared": "npm:~9.7.3"
2789+
"@react-spring/types": "npm:~9.7.3"
2790+
peerDependencies:
2791+
react: ^16.8.0 || ^17.0.0 || ^18.0.0
2792+
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
2793+
checksum: 10c0/a5b4847a2921a29a3e8ce569f4951abeb268b6e8eb230f8c49d98709216b2b3a23ba1e58628c51c9eaa0bd20d83f9d7b8f8f03df98215e933de4c66c39e17fe1
2794+
languageName: node
2795+
linkType: hard
2796+
27392797
"@rollup/plugin-commonjs@npm:24.0.0":
27402798
version: 24.0.0
27412799
resolution: "@rollup/plugin-commonjs@npm:24.0.0"
@@ -4174,6 +4232,24 @@ __metadata:
41744232
languageName: node
41754233
linkType: hard
41764234

4235+
"@use-gesture/core@npm:10.3.0":
4236+
version: 10.3.0
4237+
resolution: "@use-gesture/core@npm:10.3.0"
4238+
checksum: 10c0/7a92017fcc0a483043b2f202acd2b2e89ee3b81183a1537e5efd321cc18e36101c49c805d7932b5485c2fc3baa511eae759677c81cb77f149fa047616e78cfbd
4239+
languageName: node
4240+
linkType: hard
4241+
4242+
"@use-gesture/react@npm:^10.3.0":
4243+
version: 10.3.0
4244+
resolution: "@use-gesture/react@npm:10.3.0"
4245+
dependencies:
4246+
"@use-gesture/core": "npm:10.3.0"
4247+
peerDependencies:
4248+
react: ">= 16.8.0"
4249+
checksum: 10c0/82e7a0149f05301b0363de0c5a29112adbf8e2740ba1a0e06756ee63fe44ab094dd54fe64df3bd8ec163ebad53a38f1e6156c0b454756bb2e3aee02a4444fcf1
4250+
languageName: node
4251+
linkType: hard
4252+
41774253
"@vitest/coverage-v8@npm:^1.3.1":
41784254
version: 1.3.1
41794255
resolution: "@vitest/coverage-v8@npm:1.3.1"
@@ -11343,6 +11419,7 @@ __metadata:
1134311419
"@emotion/react": "npm:^11.11.3"
1134411420
"@faker-js/faker": "npm:^8.4.1"
1134511421
"@next/eslint-plugin-next": "npm:^14.1.0"
11422+
"@react-spring/web": "npm:^9.7.3"
1134611423
"@semantic-release/changelog": "npm:^6.0.3"
1134711424
"@semantic-release/commit-analyzer": "npm:^11.1.0"
1134811425
"@semantic-release/git": "npm:^10.0.1"
@@ -11364,6 +11441,7 @@ __metadata:
1136411441
"@types/uuid": "npm:^9.0.8"
1136511442
"@typescript-eslint/eslint-plugin": "npm:^7.0.2"
1136611443
"@typescript-eslint/parser": "npm:^7.0.2"
11444+
"@use-gesture/react": "npm:^10.3.0"
1136711445
"@vitest/coverage-v8": "npm:^1.3.1"
1136811446
babel-loader: "npm:^9.1.3"
1136911447
concurrently: "npm:^8.2.2"

0 commit comments

Comments
 (0)