1- import { Show , createEffect , createMemo , createSignal } from 'solid-js'
1+ import {
2+ Show ,
3+ createEffect ,
4+ createMemo ,
5+ createSignal ,
6+ onCleanup ,
7+ untrack ,
8+ } from 'solid-js'
29import clsx from 'clsx'
310import { createDevtoolsSettings } from '../context/use-devtools-context'
411import { createStyles } from '../styles/use-styles'
512import TanStackLogo from './tanstack-logo.png'
13+ import type { TriggerCoords } from '../context/devtools-store'
614import type { Accessor } from 'solid-js'
715
16+ // --- Throw physics (pure, unit-tested in trigger.test.tsx) ---
17+ const FRICTION = 0.95 // velocity retained each frame
18+ const RESTITUTION = 0.5 // velocity retained after a wall bounce
19+ const MIN_SPEED = 0.1 // px/frame below which the throw stops
20+ const DRAG_THRESHOLD = 4 // px of movement before a press counts as a drag
21+ const PADDING_RATIO = 0.5 // matches size[2] = --tsrd-font-size * 0.5
22+
23+ export const clamp = ( value : number , min : number , max : number ) =>
24+ Math . max ( min , Math . min ( max , value ) )
25+
26+ /**
27+ * Advance one axis by its velocity for a single frame, bouncing off the
28+ * [min, max] walls with damping. Returns the new position and velocity.
29+ */
30+ export const stepAxis = (
31+ pos : number ,
32+ vel : number ,
33+ min : number ,
34+ max : number ,
35+ ) : { pos : number ; vel : number } => {
36+ let p = pos + vel
37+ let v = vel * FRICTION
38+ if ( p <= min ) {
39+ p = min
40+ v = - v * RESTITUTION
41+ } else if ( p >= max ) {
42+ p = max
43+ v = - v * RESTITUTION
44+ }
45+ return { pos : p , vel : v }
46+ }
47+
848export const Trigger = ( props : {
949 isOpen : Accessor < boolean >
1050 setIsOpen : ( isOpen : boolean ) => void
1151} ) => {
12- const { settings } = createDevtoolsSettings ( )
52+ const { settings, setSettings } = createDevtoolsSettings ( )
1353 const [ containerRef , setContainerRef ] = createSignal < HTMLElement > ( )
54+ const [ buttonRef , setButtonRef ] = createSignal < HTMLButtonElement > ( )
55+ const [ coords , setCoords ] = createSignal < TriggerCoords | null > (
56+ settings ( ) . triggerCoords ?? null ,
57+ )
1458 const styles = createStyles ( )
59+
60+ const isFloating = createMemo ( ( ) => settings ( ) . triggerMode === 'floating' )
61+
1562 const buttonStyle = createMemo ( ( ) => {
1663 return clsx (
1764 styles ( ) . mainCloseBtn ,
18- styles ( ) . mainCloseBtnPosition ( settings ( ) . position ) ,
65+ // Keep the fixed-position class until floating coords are seeded, so the
66+ // seed reads the trigger's real on-screen spot (not the unpositioned
67+ // static-flow position) and the hand-off to inline left/top is seamless.
68+ ( ! isFloating ( ) || ! coords ( ) ) &&
69+ styles ( ) . mainCloseBtnPosition ( settings ( ) . position ) ,
1970 styles ( ) . mainCloseBtnAnimation ( props . isOpen ( ) , settings ( ) . hideUntilHover ) ,
71+ isFloating ( ) && styles ( ) . mainCloseBtnFloating ,
72+ )
73+ } )
74+
75+ // Padding away from the edges, matching the fixed trigger's offset (size[2]).
76+ const edgePadding = ( el : HTMLElement ) => {
77+ const fontSize = parseFloat (
78+ getComputedStyle ( el ) . getPropertyValue ( '--tsrd-font-size' ) ,
2079 )
80+ return ( Number . isFinite ( fontSize ) ? fontSize : 16 ) * PADDING_RATIO
81+ }
82+
83+ const bounds = ( el : HTMLElement ) => {
84+ const pad = edgePadding ( el )
85+ const rect = el . getBoundingClientRect ( )
86+ return {
87+ minX : pad ,
88+ minY : pad ,
89+ maxX : window . innerWidth - rect . width - pad ,
90+ maxY : window . innerHeight - rect . height - pad ,
91+ }
92+ }
93+
94+ // --- drag state (non-reactive; only `coords` drives rendering) ---
95+ let dragging = false
96+ let moved = false
97+ let startX = 0
98+ let startY = 0
99+ let startPosX = 0
100+ let startPosY = 0
101+ let lastX = 0
102+ let lastY = 0
103+ let lastT = 0
104+ let vx = 0
105+ let vy = 0
106+ let raf : number | undefined
107+
108+ const cancelThrow = ( ) => {
109+ if ( raf !== undefined ) {
110+ cancelAnimationFrame ( raf )
111+ raf = undefined
112+ }
113+ }
114+
115+ const persist = ( ) => setSettings ( { triggerCoords : coords ( ) ?? undefined } )
116+
117+ const startThrow = ( ) => {
118+ cancelThrow ( )
119+ const tick = ( ) => {
120+ const el = buttonRef ( )
121+ const current = coords ( )
122+ if ( ! el || ! current ) {
123+ raf = undefined
124+ return
125+ }
126+ const b = bounds ( el )
127+ const nx = stepAxis ( current . x , vx , b . minX , b . maxX )
128+ const ny = stepAxis ( current . y , vy , b . minY , b . maxY )
129+ vx = nx . vel
130+ vy = ny . vel
131+ setCoords ( { x : nx . pos , y : ny . pos } )
132+ if ( Math . hypot ( vx , vy ) > MIN_SPEED ) {
133+ raf = requestAnimationFrame ( tick )
134+ } else {
135+ raf = undefined
136+ persist ( )
137+ }
138+ }
139+ raf = requestAnimationFrame ( tick )
140+ }
141+
142+ const onPointerDown = ( e : PointerEvent ) => {
143+ if ( ! isFloating ( ) || e . button !== 0 ) return
144+ const el = buttonRef ( )
145+ const current = coords ( )
146+ if ( ! el || ! current ) return
147+ cancelThrow ( )
148+ dragging = true
149+ moved = false
150+ el . setPointerCapture ( e . pointerId )
151+ startX = e . clientX
152+ startY = e . clientY
153+ startPosX = current . x
154+ startPosY = current . y
155+ lastX = e . clientX
156+ lastY = e . clientY
157+ lastT = e . timeStamp
158+ vx = 0
159+ vy = 0
160+ e . preventDefault ( )
161+ }
162+
163+ const onPointerMove = ( e : PointerEvent ) => {
164+ if ( ! dragging ) return
165+ const el = buttonRef ( )
166+ if ( ! el ) return
167+ const dx = e . clientX - startX
168+ const dy = e . clientY - startY
169+ if ( Math . hypot ( dx , dy ) > DRAG_THRESHOLD ) moved = true
170+ const b = bounds ( el )
171+ setCoords ( {
172+ x : clamp ( startPosX + dx , b . minX , b . maxX ) ,
173+ y : clamp ( startPosY + dy , b . minY , b . maxY ) ,
174+ } )
175+ // Velocity in px per ~16ms frame, so it plugs straight into stepAxis.
176+ const dt = e . timeStamp - lastT
177+ if ( dt > 0 ) {
178+ vx = ( ( e . clientX - lastX ) / dt ) * 16
179+ vy = ( ( e . clientY - lastY ) / dt ) * 16
180+ }
181+ lastX = e . clientX
182+ lastY = e . clientY
183+ lastT = e . timeStamp
184+ }
185+
186+ const endDrag = ( e : PointerEvent , canThrow : boolean ) => {
187+ if ( ! dragging ) return
188+ dragging = false
189+ const el = buttonRef ( )
190+ if ( el ?. hasPointerCapture ( e . pointerId ) )
191+ el . releasePointerCapture ( e . pointerId )
192+ // If the pointer sat still before release, the last flick velocity is
193+ // stale — don't launch a throw the user didn't actually make.
194+ if ( e . timeStamp - lastT > 50 ) {
195+ vx = 0
196+ vy = 0
197+ }
198+ if ( canThrow && moved && Math . hypot ( vx , vy ) > MIN_SPEED ) {
199+ startThrow ( )
200+ } else {
201+ persist ( )
202+ }
203+ }
204+
205+ const onPointerUp = ( e : PointerEvent ) => endDrag ( e , true )
206+ // A cancelled pointer (OS gesture, context menu) just drops in place.
207+ const onPointerCancel = ( e : PointerEvent ) => endDrag ( e , false )
208+
209+ const onClick = ( ) => {
210+ // A drag release also fires a click — swallow it so dragging never toggles.
211+ if ( moved ) {
212+ moved = false
213+ return
214+ }
215+ props . setIsOpen ( ! props . isOpen ( ) )
216+ }
217+
218+ // On going floating: seed coords from the button's current (fixed) position
219+ // if there's no stored spot, otherwise clamp the restored spot into view
220+ // (a saved position from a larger window must not load off-screen).
221+ // Reads/writes coords untracked so this only runs on mode/ref changes.
222+ createEffect ( ( ) => {
223+ if ( ! isFloating ( ) ) return
224+ const el = buttonRef ( )
225+ if ( ! el ) return
226+ untrack ( ( ) => {
227+ const current = coords ( )
228+ if ( ! current ) {
229+ const rect = el . getBoundingClientRect ( )
230+ setCoords ( { x : rect . left , y : rect . top } )
231+ return
232+ }
233+ const b = bounds ( el )
234+ setCoords ( {
235+ x : clamp ( current . x , b . minX , b . maxX ) ,
236+ y : clamp ( current . y , b . minY , b . maxY ) ,
237+ } )
238+ } )
21239 } )
22240
241+ // Keep the trigger on screen when the window is resized.
242+ createEffect ( ( ) => {
243+ if ( ! isFloating ( ) ) return
244+ const onResize = ( ) => {
245+ const el = buttonRef ( )
246+ const current = coords ( )
247+ if ( ! el || ! current ) return
248+ const b = bounds ( el )
249+ setCoords ( {
250+ x : clamp ( current . x , b . minX , b . maxX ) ,
251+ y : clamp ( current . y , b . minY , b . maxY ) ,
252+ } )
253+ persist ( )
254+ }
255+ window . addEventListener ( 'resize' , onResize )
256+ onCleanup ( ( ) => window . removeEventListener ( 'resize' , onResize ) )
257+ } )
258+
259+ onCleanup ( cancelThrow )
260+
23261 createEffect ( ( ) => {
24262 const triggerComponent = settings ( ) . customTrigger
25263 const el = containerRef ( )
@@ -33,10 +271,26 @@ export const Trigger = (props: {
33271 return (
34272 < Show when = { ! settings ( ) . triggerHidden } >
35273 < button
274+ ref = { setButtonRef }
36275 type = "button"
37276 aria-label = "Open TanStack Devtools"
38277 class = { buttonStyle ( ) }
39- onClick = { ( ) => props . setIsOpen ( ! props . isOpen ( ) ) }
278+ style = {
279+ isFloating ( ) && coords ( )
280+ ? {
281+ left : `${ coords ( ) ! . x } px` ,
282+ top : `${ coords ( ) ! . y } px` ,
283+ right : 'auto' ,
284+ bottom : 'auto' ,
285+ transform : 'none' ,
286+ }
287+ : undefined
288+ }
289+ onClick = { onClick }
290+ onPointerDown = { onPointerDown }
291+ onPointerMove = { onPointerMove }
292+ onPointerUp = { onPointerUp }
293+ onPointerCancel = { onPointerCancel }
40294 >
41295 < Show
42296 when = { settings ( ) . customTrigger }
0 commit comments