Skip to content

Commit 6bee559

Browse files
committed
feat(devtools): add floating/draggable trigger with throw physics
Add a `triggerMode: 'fixed' | 'floating'` setting. In floating mode the trigger can be dragged anywhere with the left mouse button; releasing with velocity throws it (inertia + wall bounce). Position is clamped to a padded on-screen area (matching the fixed trigger's edge offset) and persisted to local storage, re-clamping on window resize so it can never load off-screen.
1 parent 92f69d0 commit 6bee559

7 files changed

Lines changed: 384 additions & 24 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@tanstack/devtools': minor
3+
---
4+
5+
Add a floating trigger mode. Set `triggerMode: 'floating'` (or choose it under
6+
Settings → Trigger Mode) to drag the devtools trigger anywhere on screen with
7+
the left mouse button. Releasing a drag with velocity throws it — it glides with
8+
momentum and springs back off the screen edges. The trigger is always kept
9+
within a padded, on-screen area (it can never end up off-screen) and its
10+
position is persisted to local storage.

docs/configuration.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,18 @@ The `config` object is mainly focused around user interaction with the devtools
2929
{ hideUntilHover: boolean }
3030
```
3131

32-
- `position` - The position of the TanStack devtools trigger
32+
- `position` - The position of the TanStack devtools trigger (used when `triggerMode` is `'fixed'`)
3333

3434
```ts
3535
{ position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle-left' | 'middle-right' }
3636
```
3737

38+
- `triggerMode` - How the trigger is placed. `'fixed'` anchors it to `position`; `'floating'` lets you drag the trigger anywhere on screen (and throw it — it glides with momentum and springs back off the edges). The floating spot is persisted in local storage.
39+
40+
```ts
41+
{ triggerMode: 'fixed' | 'floating' }
42+
```
43+
3844
- `panelLocation` - The location of the devtools panel
3945

4046
```ts

packages/devtools/src/components/trigger.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { render } from '@solidjs/testing-library'
22
import { createSignal } from 'solid-js'
33
import { beforeEach, describe, expect, it } from 'vitest'
44
import { DevtoolsProvider } from '../context/devtools-context'
5-
import { Trigger } from './trigger'
5+
import { Trigger, clamp, stepAxis } from './trigger'
66
import type { TanStackDevtoolsConfig } from '../context/devtools-context'
77

88
const renderTrigger = (config?: Partial<TanStackDevtoolsConfig>) => {
@@ -37,4 +37,52 @@ describe('Trigger', () => {
3737

3838
expect(queryByLabelText('Open TanStack Devtools')).not.toBeInTheDocument()
3939
})
40+
41+
it('renders the floating trigger without a fixed position class', () => {
42+
const { queryByLabelText } = renderTrigger({ triggerMode: 'floating' })
43+
44+
const button = queryByLabelText('Open TanStack Devtools')
45+
expect(button).toBeInTheDocument()
46+
})
47+
})
48+
49+
describe('throw physics', () => {
50+
it('clamps values into range', () => {
51+
expect(clamp(5, 0, 10)).toBe(5)
52+
expect(clamp(-5, 0, 10)).toBe(0)
53+
expect(clamp(50, 0, 10)).toBe(10)
54+
// Degenerate range (window smaller than trigger + padding): stays at min.
55+
expect(clamp(5, 10, 0)).toBe(10)
56+
})
57+
58+
it('advances position by velocity while inside the walls', () => {
59+
const { pos, vel } = stepAxis(100, 10, 0, 500)
60+
expect(pos).toBe(110)
61+
expect(vel).toBeCloseTo(9.5) // 10 * FRICTION(0.95)
62+
})
63+
64+
it('bounces and damps velocity at a wall', () => {
65+
const hitMax = stepAxis(495, 20, 0, 500)
66+
expect(hitMax.pos).toBe(500)
67+
expect(hitMax.vel).toBeLessThan(0) // reversed
68+
// 20 * 0.95 = 19, reversed & damped by RESTITUTION(0.5) => -9.5
69+
expect(hitMax.vel).toBeCloseTo(-9.5)
70+
71+
const hitMin = stepAxis(5, -20, 0, 500)
72+
expect(hitMin.pos).toBe(0)
73+
expect(hitMin.vel).toBeGreaterThan(0)
74+
})
75+
76+
it('a throw decays to a stop (loop terminates within bounds)', () => {
77+
let pos = 250
78+
let vel = 40
79+
let frames = 0
80+
while (Math.abs(vel) > 0.1 && frames < 10000) {
81+
;({ pos, vel } = stepAxis(pos, vel, 0, 500))
82+
frames++
83+
}
84+
expect(frames).toBeLessThan(10000)
85+
expect(pos).toBeGreaterThanOrEqual(0)
86+
expect(pos).toBeLessThanOrEqual(500)
87+
})
4088
})

packages/devtools/src/components/trigger.tsx

Lines changed: 257 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,262 @@
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'
29
import clsx from 'clsx'
310
import { createDevtoolsSettings } from '../context/use-devtools-context'
411
import { createStyles } from '../styles/use-styles'
512
import TanStackLogo from './tanstack-logo.png'
13+
import type { TriggerCoords } from '../context/devtools-store'
614
import 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+
848
export 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

Comments
 (0)