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 ) ) el . releasePointerCapture ( e . pointerId )
191+ // If the pointer sat still before release, the last flick velocity is
192+ // stale — don't launch a throw the user didn't actually make.
193+ if ( e . timeStamp - lastT > 50 ) {
194+ vx = 0
195+ vy = 0
196+ }
197+ if ( canThrow && moved && Math . hypot ( vx , vy ) > MIN_SPEED ) {
198+ startThrow ( )
199+ } else {
200+ persist ( )
201+ }
202+ }
203+
204+ const onPointerUp = ( e : PointerEvent ) => endDrag ( e , true )
205+ // A cancelled pointer (OS gesture, context menu) just drops in place.
206+ const onPointerCancel = ( e : PointerEvent ) => endDrag ( e , false )
207+
208+ const onClick = ( ) => {
209+ // A drag release also fires a click — swallow it so dragging never toggles.
210+ if ( moved ) {
211+ moved = false
212+ return
213+ }
214+ props . setIsOpen ( ! props . isOpen ( ) )
215+ }
216+
217+ // On going floating: seed coords from the button's current (fixed) position
218+ // if there's no stored spot, otherwise clamp the restored spot into view
219+ // (a saved position from a larger window must not load off-screen).
220+ // Reads/writes coords untracked so this only runs on mode/ref changes.
221+ createEffect ( ( ) => {
222+ if ( ! isFloating ( ) ) return
223+ const el = buttonRef ( )
224+ if ( ! el ) return
225+ untrack ( ( ) => {
226+ const current = coords ( )
227+ if ( ! current ) {
228+ const rect = el . getBoundingClientRect ( )
229+ setCoords ( { x : rect . left , y : rect . top } )
230+ return
231+ }
232+ const b = bounds ( el )
233+ setCoords ( {
234+ x : clamp ( current . x , b . minX , b . maxX ) ,
235+ y : clamp ( current . y , b . minY , b . maxY ) ,
236+ } )
237+ } )
21238 } )
22239
240+ // Keep the trigger on screen when the window is resized.
241+ createEffect ( ( ) => {
242+ if ( ! isFloating ( ) ) return
243+ const onResize = ( ) => {
244+ const el = buttonRef ( )
245+ const current = coords ( )
246+ if ( ! el || ! current ) return
247+ const b = bounds ( el )
248+ setCoords ( {
249+ x : clamp ( current . x , b . minX , b . maxX ) ,
250+ y : clamp ( current . y , b . minY , b . maxY ) ,
251+ } )
252+ persist ( )
253+ }
254+ window . addEventListener ( 'resize' , onResize )
255+ onCleanup ( ( ) => window . removeEventListener ( 'resize' , onResize ) )
256+ } )
257+
258+ onCleanup ( cancelThrow )
259+
23260 createEffect ( ( ) => {
24261 const triggerComponent = settings ( ) . customTrigger
25262 const el = containerRef ( )
@@ -33,10 +270,26 @@ export const Trigger = (props: {
33270 return (
34271 < Show when = { ! settings ( ) . triggerHidden } >
35272 < button
273+ ref = { setButtonRef }
36274 type = "button"
37275 aria-label = "Open TanStack Devtools"
38276 class = { buttonStyle ( ) }
39- onClick = { ( ) => props . setIsOpen ( ! props . isOpen ( ) ) }
277+ style = {
278+ isFloating ( ) && coords ( )
279+ ? {
280+ left : `${ coords ( ) ! . x } px` ,
281+ top : `${ coords ( ) ! . y } px` ,
282+ right : 'auto' ,
283+ bottom : 'auto' ,
284+ transform : 'none' ,
285+ }
286+ : undefined
287+ }
288+ onClick = { onClick }
289+ onPointerDown = { onPointerDown }
290+ onPointerMove = { onPointerMove }
291+ onPointerUp = { onPointerUp }
292+ onPointerCancel = { onPointerCancel }
40293 >
41294 < Show
42295 when = { settings ( ) . customTrigger }
0 commit comments