DevToolbarTrigger.tsx258 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import Image from 'next/image' |
| 4 | import { useCallback, useEffect, useRef, useState } from 'react' |
| 5 | import type { CSSProperties, PointerEvent } from 'react' |
| 6 | import { Button, cn } from 'ui' |
| 7 | |
| 8 | import { 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 |
| 12 | const env = process.env.NEXT_PUBLIC_ENVIRONMENT |
| 13 | const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging' |
| 14 | const POSITION_STORAGE_KEY = 'dev-telemetry-toolbar-position' |
| 15 | const DRAG_THRESHOLD = 4 |
| 16 | const MARGIN = 24 |
| 17 | const BUTTON_SIZE = 40 // h-10 w-10 |
| 18 | |
| 19 | // Spring easing: slight overshoot then settle |
| 20 | const 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 | |
| 23 | type 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 |
| 35 | function 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 | |
| 56 | function 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 | |
| 64 | function readStoredPosition(): SnapPosition { |
| 65 | if (typeof window === 'undefined') return 'bottom-right' |
| 66 | return (localStorage.getItem(POSITION_STORAGE_KEY) as SnapPosition) ?? 'bottom-right' |
| 67 | } |
| 68 | |
| 69 | export 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 | } |