Skip to content

Commit 65e9f24

Browse files
[dev-overlay] Make badge draggable (vercel#78716)
1 parent f62a135 commit 65e9f24

File tree

5 files changed

+321
-33
lines changed

5 files changed

+321
-33
lines changed

packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.tsx

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
2-
import type { OverlayState } from '../../../../shared'
2+
import { STORAGE_KEY_POSITION, type OverlayState } from '../../../../shared'
33

44
import { useState, useEffect, useRef, createContext, useContext } from 'react'
55
import { Toast } from '../../toast'
@@ -21,6 +21,7 @@ import {
2121
getInitialPosition,
2222
type DevToolsScale,
2323
} from './dev-tools-info/preferences'
24+
import { Draggable } from './draggable'
2425

2526
// TODO: add E2E tests to cover different scenarios
2627

@@ -83,6 +84,8 @@ const OVERLAYS = {
8384

8485
export type Overlays = (typeof OVERLAYS)[keyof typeof OVERLAYS]
8586

87+
const INDICATOR_PADDING = 20
88+
8689
function DevToolsPopover({
8790
routerType,
8891
disabled,
@@ -258,31 +261,38 @@ function DevToolsPopover({
258261
'--animate-out-timing-function': MENU_CURVE,
259262
boxShadow: 'none',
260263
zIndex: 2147483647,
261-
// Reset the toast component's default positions.
262-
bottom: 'initial',
263-
left: 'initial',
264-
[vertical]: '20px',
265-
[horizontal]: '20px',
264+
[vertical]: `${INDICATOR_PADDING}px`,
265+
[horizontal]: `${INDICATOR_PADDING}px`,
266266
} as CSSProperties
267267
}
268268
>
269-
{/* Trigger */}
270-
<NextLogo
271-
ref={triggerRef}
272-
aria-haspopup="menu"
273-
aria-expanded={isMenuOpen}
274-
aria-controls="nextjs-dev-tools-menu"
275-
aria-label={`${isMenuOpen ? 'Close' : 'Open'} Next.js Dev Tools`}
276-
data-nextjs-dev-tools-button
277-
disabled={disabled}
278-
issueCount={issueCount}
279-
onTriggerClick={onTriggerClick}
280-
toggleErrorOverlay={toggleErrorOverlay}
281-
isDevBuilding={useIsDevBuilding()}
282-
isDevRendering={useIsDevRendering()}
283-
isBuildError={isBuildError}
284-
scale={scale}
285-
/>
269+
<Draggable
270+
padding={INDICATOR_PADDING}
271+
onDragStart={() => setOpen(null)}
272+
position={position}
273+
setPosition={(p) => {
274+
localStorage.setItem(STORAGE_KEY_POSITION, p)
275+
setPosition(p)
276+
}}
277+
>
278+
{/* Trigger */}
279+
<NextLogo
280+
ref={triggerRef}
281+
aria-haspopup="menu"
282+
aria-expanded={isMenuOpen}
283+
aria-controls="nextjs-dev-tools-menu"
284+
aria-label={`${isMenuOpen ? 'Close' : 'Open'} Next.js Dev Tools`}
285+
data-nextjs-dev-tools-button
286+
disabled={disabled}
287+
issueCount={issueCount}
288+
onTriggerClick={onTriggerClick}
289+
toggleErrorOverlay={toggleErrorOverlay}
290+
isDevBuilding={useIsDevBuilding()}
291+
isDevRendering={useIsDevRendering()}
292+
isBuildError={isBuildError}
293+
scale={scale}
294+
/>
295+
</Draggable>
286296

287297
{/* Route Info */}
288298
<RouteInfo
@@ -612,4 +622,12 @@ export const DEV_TOOLS_INDICATOR_STYLES = `
612622
line-height: var(--size-16);
613623
}
614624
}
625+
626+
.dev-tools-grabbing {
627+
cursor: grabbing;
628+
629+
> * {
630+
pointer-events: none;
631+
}
632+
}
615633
`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { useRef } from 'react'
2+
3+
interface Point {
4+
x: number
5+
y: number
6+
}
7+
8+
type Corners = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
9+
10+
interface Corner {
11+
corner: Corners
12+
translation: Point
13+
}
14+
15+
export function Draggable({
16+
children,
17+
padding,
18+
position: currentCorner,
19+
setPosition: setCurrentCorner,
20+
onDragStart,
21+
}: {
22+
children: React.ReactElement
23+
position: Corners
24+
padding: number
25+
setPosition: (position: Corners) => void
26+
onDragStart?: () => void
27+
}) {
28+
const { ref, animate, ...drag } = useDrag({
29+
threshold: 5,
30+
onDragStart,
31+
onDragEnd,
32+
onAnimationEnd,
33+
})
34+
35+
function onDragEnd(translation: Point, velocity: Point) {
36+
const projectedPosition = {
37+
x: translation.x + project(velocity.x),
38+
y: translation.y + project(velocity.y),
39+
}
40+
const nearestCorner = getNearestCorner(projectedPosition)
41+
animate(nearestCorner)
42+
}
43+
44+
function onAnimationEnd({ corner }: Corner) {
45+
// Unset drag translation
46+
setTimeout(() => {
47+
ref.current?.style.removeProperty('translate')
48+
setCurrentCorner(corner)
49+
})
50+
}
51+
52+
function getNearestCorner({ x, y }: Point): Corner {
53+
const allCorners = getCorners()
54+
const distances = Object.entries(allCorners).map(([key, translation]) => {
55+
const distance = Math.sqrt(
56+
(x - translation.x) ** 2 + (y - translation.y) ** 2
57+
)
58+
return { key, distance }
59+
})
60+
const min = Math.min(...distances.map((d) => d.distance))
61+
const nearest = distances.find((d) => d.distance === min)
62+
if (!nearest) {
63+
// Safety fallback
64+
return { corner: currentCorner, translation: allCorners[currentCorner] }
65+
}
66+
return {
67+
translation: allCorners[nearest.key as Corners],
68+
corner: nearest.key as Corners,
69+
}
70+
}
71+
72+
function getCorners(): Record<Corners, Point> {
73+
const offset = padding * 2
74+
const triggerWidth = ref.current?.offsetWidth || 0
75+
const triggerHeight = ref.current?.offsetHeight || 0
76+
77+
function getAbsolutePosition(corner: Corners) {
78+
const isRight = corner.includes('right')
79+
const isBottom = corner.includes('bottom')
80+
81+
return {
82+
x: isRight ? window.innerWidth - offset - triggerWidth : 0,
83+
y: isBottom ? window.innerHeight - offset - triggerHeight : 0,
84+
}
85+
}
86+
87+
const basePosition = getAbsolutePosition(currentCorner)
88+
89+
// Calculate all corner positions relative to the current corner
90+
return {
91+
'top-left': {
92+
x: 0 - basePosition.x,
93+
y: 0 - basePosition.y,
94+
},
95+
'top-right': {
96+
x: window.innerWidth - offset - triggerWidth - basePosition.x,
97+
y: 0 - basePosition.y,
98+
},
99+
'bottom-left': {
100+
x: 0 - basePosition.x,
101+
y: window.innerHeight - offset - triggerHeight - basePosition.y,
102+
},
103+
'bottom-right': {
104+
x: window.innerWidth - offset - triggerWidth - basePosition.x,
105+
y: window.innerHeight - offset - triggerHeight - basePosition.y,
106+
},
107+
}
108+
}
109+
110+
return (
111+
<div ref={ref} {...drag} style={{ touchAction: 'none' }}>
112+
{children}
113+
</div>
114+
)
115+
}
116+
117+
interface UseDragOptions {
118+
onDragStart?: () => void
119+
onDrag?: (translation: Point) => void
120+
onDragEnd?: (translation: Point, velocity: Point) => void
121+
onAnimationEnd?: (corner: Corner) => void
122+
threshold: number // Minimum movement before drag starts
123+
}
124+
125+
interface Velocity {
126+
position: Point
127+
timestamp: number
128+
}
129+
130+
export function useDrag(options: UseDragOptions) {
131+
const ref = useRef<HTMLDivElement>(null)
132+
const state = useRef<'idle' | 'press' | 'drag' | 'drag-end'>('idle')
133+
134+
const origin = useRef<Point>({ x: 0, y: 0 })
135+
const translation = useRef<Point>({ x: 0, y: 0 })
136+
const lastTimestamp = useRef(0)
137+
const velocities = useRef<Velocity[]>([])
138+
139+
function set(position: Point) {
140+
if (ref.current) {
141+
translation.current = position
142+
ref.current.style.translate = `${position.x}px ${position.y}px`
143+
}
144+
}
145+
146+
function animate(corner: Corner) {
147+
const el = ref.current
148+
if (el === null) return
149+
150+
function listener(e: TransitionEvent) {
151+
if (e.propertyName === 'translate') {
152+
options.onAnimationEnd?.(corner)
153+
translation.current = { x: 0, y: 0 }
154+
el!.style.transition = ''
155+
el!.removeEventListener('transitionend', listener)
156+
}
157+
}
158+
159+
// Generated from https://www.easing.dev/spring
160+
el.style.transition = 'translate 491.22ms var(--timing-bounce)'
161+
el.addEventListener('transitionend', listener)
162+
set(corner.translation)
163+
}
164+
165+
function onClick(e: MouseEvent) {
166+
if (state.current === 'drag-end') {
167+
e.preventDefault()
168+
e.stopPropagation()
169+
state.current = 'idle'
170+
ref.current?.removeEventListener('click', onClick)
171+
}
172+
}
173+
174+
function onPointerDown(e: React.PointerEvent) {
175+
origin.current = { x: e.clientX, y: e.clientY }
176+
state.current = 'press'
177+
window.addEventListener('pointermove', onPointerMove)
178+
window.addEventListener('pointerup', onPointerUp)
179+
ref.current?.addEventListener('click', onClick)
180+
}
181+
182+
function onPointerMove(e: PointerEvent) {
183+
if (state.current === 'press') {
184+
const dx = e.clientX - origin.current.x
185+
const dy = e.clientY - origin.current.y
186+
const distance = Math.sqrt(dx * dx + dy * dy)
187+
188+
if (distance >= options.threshold) {
189+
state.current = 'drag'
190+
ref.current?.setPointerCapture(e.pointerId)
191+
ref.current?.classList.add('dev-tools-grabbing')
192+
options.onDragStart?.()
193+
}
194+
}
195+
196+
if (state.current !== 'drag') return
197+
198+
const currentPosition = { x: e.clientX, y: e.clientY }
199+
200+
const dx = currentPosition.x - origin.current.x
201+
const dy = currentPosition.y - origin.current.y
202+
origin.current = currentPosition
203+
204+
const newTranslation = {
205+
x: translation.current.x + dx,
206+
y: translation.current.y + dy,
207+
}
208+
209+
set(newTranslation)
210+
211+
// Keep a history of recent positions for velocity calculation
212+
// Only store points that are at least 10ms apart to avoid too many samples
213+
const now = Date.now()
214+
const shouldAddToHistory = now - lastTimestamp.current >= 10
215+
if (shouldAddToHistory) {
216+
velocities.current = [
217+
...velocities.current.slice(-5),
218+
{ position: currentPosition, timestamp: now },
219+
]
220+
}
221+
222+
lastTimestamp.current = now
223+
options.onDrag?.(translation.current)
224+
}
225+
226+
function onPointerUp(e: PointerEvent) {
227+
state.current = state.current === 'drag' ? 'drag-end' : 'idle'
228+
229+
window.removeEventListener('pointermove', onPointerMove)
230+
window.removeEventListener('pointerup', onPointerUp)
231+
232+
const velocity = calculateVelocity(velocities.current)
233+
velocities.current = []
234+
235+
ref.current?.classList.remove('dev-tools-grabbing')
236+
ref.current?.releasePointerCapture(e.pointerId)
237+
options.onDragEnd?.(translation.current, velocity)
238+
}
239+
240+
return {
241+
ref,
242+
onPointerDown,
243+
animate,
244+
}
245+
}
246+
247+
function calculateVelocity(
248+
history: Array<{ position: Point; timestamp: number }>
249+
): Point {
250+
if (history.length < 2) {
251+
return { x: 0, y: 0 }
252+
}
253+
254+
const oldestPoint = history[0]
255+
const latestPoint = history[history.length - 1]
256+
257+
const timeDelta = latestPoint.timestamp - oldestPoint.timestamp
258+
259+
if (timeDelta === 0) {
260+
return { x: 0, y: 0 }
261+
}
262+
263+
// Calculate pixels per millisecond
264+
const velocityX =
265+
(latestPoint.position.x - oldestPoint.position.x) / timeDelta
266+
const velocityY =
267+
(latestPoint.position.y - oldestPoint.position.y) / timeDelta
268+
269+
// Convert to pixels per second for more intuitive values
270+
return {
271+
x: velocityX * 1000,
272+
y: velocityY * 1000,
273+
}
274+
}
275+
276+
function project(initialVelocity: number, decelerationRate = 0.999) {
277+
return ((initialVelocity / 1000) * decelerationRate) / (1 - decelerationRate)
278+
}

packages/next/src/client/components/react-dev-overlay/ui/components/fader/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,4 @@ export const FADER_STYLES = `
5656
mask-image: linear-gradient(to bottom, var(--color-bg) var(--stop), transparent);
5757
}
5858
}
59-
6059
`

0 commit comments

Comments
 (0)