DevToolbarTrigger.tsx 8.6 KB

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