DevToolbarTrigger.tsx258 lines · main
1'use client'
2
3import Image from 'next/image'
4import { useCallback, useEffect, useRef, useState } from 'react'
5import type { CSSProperties, PointerEvent } from 'react'
6import { Button, cn } from 'ui'
7
8import { useDevToolbar } from './DevToolbarContext'
9
10// Duplicated for tree-shaking — bundler must see literal process.env reference.
11// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbar.tsx, feature-flags.tsx
12const env = process.env.NEXT_PUBLIC_ENVIRONMENT
13const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
14const POSITION_STORAGE_KEY = 'dev-telemetry-toolbar-position'
15const DRAG_THRESHOLD = 4
16const MARGIN = 24
17const BUTTON_SIZE = 40 // h-10 w-10
18
19// Spring easing: slight overshoot then settle
20const SNAP_TRANSITION =
21 'top 380ms cubic-bezier(0.34, 1.56, 0.64, 1), left 380ms cubic-bezier(0.34, 1.56, 0.64, 1)'
22
23type SnapPosition =
24 | 'top-left'
25 | 'top-center'
26 | 'top-right'
27 | 'middle-left'
28 | 'middle-center'
29 | 'middle-right'
30 | 'bottom-left'
31 | 'bottom-center'
32 | 'bottom-right'
33
34// All positions expressed as pixel top+left so transitions interpolate cleanly
35function getSnapCoords(
36 position: SnapPosition,
37 vw: number,
38 vh: number
39): { top: number; left: number } {
40 const [row, col] = position.split('-')
41 const top =
42 row === 'top'
43 ? MARGIN
44 : row === 'bottom'
45 ? vh - MARGIN - BUTTON_SIZE
46 : Math.round(vh / 2 - BUTTON_SIZE / 2)
47 const left =
48 col === 'left'
49 ? MARGIN
50 : col === 'right'
51 ? vw - MARGIN - BUTTON_SIZE
52 : Math.round(vw / 2 - BUTTON_SIZE / 2)
53 return { top, left }
54}
55
56function getNearestSnapPosition(cx: number, cy: number): SnapPosition {
57 const vw = window.innerWidth
58 const vh = window.innerHeight
59 const row = cy < vh / 3 ? 'top' : cy > (2 * vh) / 3 ? 'bottom' : 'middle'
60 const col = cx < vw / 3 ? 'left' : cx > (2 * vw) / 3 ? 'right' : 'center'
61 return `${row}-${col}` as SnapPosition
62}
63
64function readStoredPosition(): SnapPosition {
65 if (typeof window === 'undefined') return 'bottom-right'
66 return (localStorage.getItem(POSITION_STORAGE_KEY) as SnapPosition) ?? 'bottom-right'
67}
68
69export function DevToolbarTrigger() {
70 const { isEnabled, isOpen, setIsOpen, events } = useDevToolbar()
71 const [snapPosition, setSnapPosition] = useState<SnapPosition>('bottom-right')
72 const [hasHydrated, setHasHydrated] = useState(false)
73 const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null)
74 // Holds the last drag pixel position for one RAF to prime the CSS transition
75 const [releasedAt, setReleasedAt] = useState<{ x: number; y: number } | null>(null)
76 const [viewport, setViewport] = useState(() => ({
77 w: typeof window !== 'undefined' ? window.innerWidth : 1920,
78 h: typeof window !== 'undefined' ? window.innerHeight : 1080,
79 }))
80
81 const dragRef = useRef<{
82 startPointerX: number
83 startPointerY: number
84 startButtonX: number
85 startButtonY: number
86 hasDragged: boolean
87 } | null>(null)
88 const wasDraggingRef = useRef(false)
89
90 // Restore persisted position after mount to avoid SSR hydration mismatch.
91 // hasHydrated is set via RAF so the correct position is painted before transitions are enabled,
92 // preventing the spring animation from firing on initial load.
93 useEffect(() => {
94 const stored = readStoredPosition()
95 if (stored !== 'bottom-right') setSnapPosition(stored)
96 const id = requestAnimationFrame(() => setHasHydrated(true))
97 return () => cancelAnimationFrame(id)
98 }, [])
99
100 // Keep snap coords accurate on resize
101 useEffect(() => {
102 const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight })
103 window.addEventListener('resize', onResize)
104 return () => window.removeEventListener('resize', onResize)
105 }, [])
106
107 // Two-phase snap: hold last drag position for one frame (primes the transition),
108 // then clear it so the spring fires from that position to the snap target
109 useEffect(() => {
110 if (releasedAt === null) return
111 const id = requestAnimationFrame(() => setReleasedAt(null))
112 return () => cancelAnimationFrame(id)
113 }, [releasedAt])
114
115 const handlePointerDown = useCallback((e: PointerEvent<HTMLButtonElement>) => {
116 const rect = e.currentTarget.getBoundingClientRect()
117 dragRef.current = {
118 startPointerX: e.clientX,
119 startPointerY: e.clientY,
120 startButtonX: rect.left,
121 startButtonY: rect.top,
122 hasDragged: false,
123 }
124 wasDraggingRef.current = false
125 e.currentTarget.setPointerCapture(e.pointerId)
126 }, [])
127
128 const handlePointerMove = useCallback((e: PointerEvent<HTMLButtonElement>) => {
129 if (!dragRef.current) return
130 const dx = e.clientX - dragRef.current.startPointerX
131 const dy = e.clientY - dragRef.current.startPointerY
132 if (!dragRef.current.hasDragged && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
133 dragRef.current.hasDragged = true
134 setDragPos({
135 x: dragRef.current.startButtonX + dx,
136 y: dragRef.current.startButtonY + dy,
137 })
138 }, [])
139
140 const handlePointerUp = useCallback((e: PointerEvent<HTMLButtonElement>) => {
141 if (!dragRef.current) return
142 const { hasDragged } = dragRef.current
143 dragRef.current = null
144 wasDraggingRef.current = hasDragged
145 if (!hasDragged) return
146 const rect = e.currentTarget.getBoundingClientRect()
147 const cx = rect.left + rect.width / 2
148 const cy = rect.top + rect.height / 2
149 const newPosition = getNearestSnapPosition(cx, cy)
150 setSnapPosition(newPosition)
151 localStorage.setItem(POSITION_STORAGE_KEY, newPosition)
152 // Phase 1: park at last drag position with transition primed
153 setReleasedAt({ x: rect.left, y: rect.top })
154 setDragPos(null)
155 }, [])
156
157 const handlePointerCancel = useCallback(() => {
158 dragRef.current = null
159 wasDraggingRef.current = false
160 setDragPos(null)
161 setReleasedAt(null)
162 }, [])
163
164 if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
165
166 const eventCount = events.length
167 const isDragging = dragPos !== null
168 const snapCoords = getSnapCoords(snapPosition, viewport.w, viewport.h)
169 const FULL_TRANSITION = `${SNAP_TRANSITION}, opacity 200ms ease`
170
171 const containerStyle: CSSProperties =
172 dragPos !== null
173 ? {
174 position: 'fixed',
175 zIndex: 50,
176 left: dragPos.x,
177 top: dragPos.y,
178 transition: 'none',
179 opacity: 1,
180 }
181 : releasedAt !== null
182 ? // Phase 1: same pixel position as drag end, transition now defined
183 {
184 position: 'fixed',
185 zIndex: 50,
186 left: releasedAt.x,
187 top: releasedAt.y,
188 transition: FULL_TRANSITION,
189 opacity: isOpen ? 0 : 1,
190 pointerEvents: isOpen ? 'none' : undefined,
191 }
192 : // Phase 2: spring fires from releasedAt → snapCoords
193 {
194 position: 'fixed',
195 zIndex: 50,
196 ...snapCoords,
197 transition: hasHydrated ? FULL_TRANSITION : 'none',
198 opacity: isOpen ? 0 : 1,
199 pointerEvents: isOpen ? 'none' : undefined,
200 }
201
202 const handleClick = () => {
203 if (wasDraggingRef.current) {
204 wasDraggingRef.current = false
205 return
206 }
207 setIsOpen(true)
208 }
209
210 return (
211 <div style={containerStyle}>
212 <Button
213 type="text"
214 className={cn(
215 'relative rounded-full h-10 w-10 p-0',
216 'bg-surface-100 border border-overlay shadow-md',
217 'text-foreground-light hover:text-foreground hover:bg-surface-200',
218 'focus-visible:outline-0 focus-visible:outline-transparent focus-visible:outline-offset-0',
219 'select-none touch-none',
220 isDragging ? 'cursor-grabbing' : 'cursor-pointer'
221 )}
222 aria-label="Open dev toolbar"
223 onClick={handleClick}
224 onPointerDown={handlePointerDown}
225 onPointerMove={handlePointerMove}
226 onPointerUp={handlePointerUp}
227 onPointerCancel={handlePointerCancel}
228 title="Dev Toolbar"
229 >
230 <Image
231 src="/img/logo-pixel-small-light.png"
232 alt="Dev Toolbar"
233 width={16}
234 height={16}
235 style={{
236 filter:
237 'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)',
238 }}
239 aria-hidden="true"
240 className="pointer-events-none"
241 />
242 {eventCount > 0 && (
243 <span
244 className={cn(
245 'absolute -top-1 -right-1',
246 'h-4 min-w-4 px-0.5',
247 'inline-flex items-center justify-center',
248 'rounded-full bg-destructive text-foreground',
249 'text-[10px] font-medium leading-none'
250 )}
251 >
252 {eventCount > 99 ? '99+' : eventCount}
253 </span>
254 )}
255 </Button>
256 </div>
257 )
258}