Skip to content

Commit dc61e1e

Browse files
fix(devtools): keep the floating trigger in place on window resize (#529)
* fix(devtools): keep the floating trigger in place on window resize Store the floating trigger spot as a percent of the free space, not as pixels. On every resize the trigger is placed again from that percent, so it stays in its corner and cannot move off-screen. Drag and throw still work in pixels. * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 507c3f9 commit dc61e1e

4 files changed

Lines changed: 86 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/devtools': patch
3+
---
4+
5+
Keep the floating trigger at the same relative spot when the window size changes. The trigger now stores its spot as a percent of the free space, so it stays in its corner and never moves off-screen.

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

Lines changed: 23 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, clamp, stepAxis } from './trigger'
5+
import { Trigger, clamp, stepAxis, toPercent, toPixels } from './trigger'
66
import type { TanStackDevtoolsConfig } from '../context/devtools-context'
77

88
const renderTrigger = (config?: Partial<TanStackDevtoolsConfig>) => {
@@ -99,3 +99,25 @@ describe('throw physics', () => {
9999
expect(pos).toBeLessThanOrEqual(500)
100100
})
101101
})
102+
103+
describe('trigger percent position', () => {
104+
it('maps the same percent to the same relative spot at any window size', () => {
105+
// Free space 8..936 in a 1000px window: 100% is the far wall.
106+
expect(toPixels(100, 8, 936)).toBe(936)
107+
expect(toPixels(0, 8, 936)).toBe(8)
108+
expect(toPixels(50, 8, 736)).toBe(372)
109+
})
110+
111+
it('round-trips pixels through a percent', () => {
112+
expect(toPixels(toPercent(500, 8, 936), 8, 936)).toBeCloseTo(500)
113+
})
114+
115+
it('keeps the trigger inside the walls', () => {
116+
expect(toPercent(2000, 8, 936)).toBe(100)
117+
expect(toPercent(-50, 8, 936)).toBe(0)
118+
expect(toPixels(250, 8, 936)).toBe(936)
119+
// A window smaller than the trigger pins it to the start wall.
120+
expect(toPercent(10, 8, 4)).toBe(0)
121+
expect(toPixels(100, 8, 4)).toBe(8)
122+
})
123+
})

packages/devtools/src/components/trigger.tsx

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,27 @@ export const stepAxis = (
4545
return { pos: p, vel: v }
4646
}
4747

48+
/**
49+
* Convert one axis between pixels and a percent (0-100) of the free space
50+
* between the two walls. The stored spot is a percent, so it maps back into
51+
* view at any window size.
52+
*/
53+
export const toPercent = (pos: number, min: number, max: number) =>
54+
max > min ? clamp(((pos - min) / (max - min)) * 100, 0, 100) : 0
55+
56+
export const toPixels = (percent: number, min: number, max: number) =>
57+
min + (Math.max(max, min) - min) * (clamp(percent, 0, 100) / 100)
58+
4859
export const Trigger = (props: {
4960
isOpen: Accessor<boolean>
5061
setIsOpen: (isOpen: boolean) => void
5162
}) => {
5263
const { settings, setSettings } = createDevtoolsSettings()
5364
const [containerRef, setContainerRef] = createSignal<HTMLElement>()
5465
const [buttonRef, setButtonRef] = createSignal<HTMLButtonElement>()
55-
const [coords, setCoords] = createSignal<TriggerCoords | null>(
56-
settings().triggerCoords ?? null,
57-
)
66+
// On-screen pixels, used by drag and throw. The stored `triggerCoords` is a
67+
// percent of the free space, see `persist` and `placeFromSettings`.
68+
const [coords, setCoords] = createSignal<TriggerCoords | null>(null)
5869
const styles = createStyles()
5970

6071
const isFloating = createMemo(() => settings().triggerMode === 'floating')
@@ -83,12 +94,13 @@ export const Trigger = (props: {
8394

8495
const bounds = (el: HTMLElement) => {
8596
const pad = edgePadding(el)
86-
const rect = el.getBoundingClientRect()
97+
// offsetWidth/Height ignore the hover scale, so bounds do not change
98+
// while the pointer is over the trigger.
8799
return {
88100
minX: pad,
89101
minY: pad,
90-
maxX: window.innerWidth - rect.width - pad,
91-
maxY: window.innerHeight - rect.height - pad,
102+
maxX: window.innerWidth - el.offsetWidth - pad,
103+
maxY: window.innerHeight - el.offsetHeight - pad,
92104
}
93105
}
94106

@@ -113,7 +125,18 @@ export const Trigger = (props: {
113125
}
114126
}
115127

116-
const persist = () => setSettings({ triggerCoords: coords() ?? undefined })
128+
const persist = () => {
129+
const el = buttonRef()
130+
const current = coords()
131+
if (!el || !current) return
132+
const b = bounds(el)
133+
setSettings({
134+
triggerCoords: {
135+
x: toPercent(current.x, b.minX, b.maxX),
136+
y: toPercent(current.y, b.minY, b.maxY),
137+
},
138+
})
139+
}
117140

118141
const startThrow = () => {
119142
cancelThrow()
@@ -217,42 +240,41 @@ export const Trigger = (props: {
217240
props.setIsOpen(!props.isOpen())
218241
}
219242

220-
// On going floating: seed coords from the button's current (fixed) position
221-
// if there's no stored spot, otherwise clamp the restored spot into view
222-
// (a saved position from a larger window must not load off-screen).
223-
// Reads/writes coords untracked so this only runs on mode/ref changes.
243+
// Place the trigger from its stored percent, or seed the percent from the
244+
// button's current (fixed) position when there is no stored spot. Runs on
245+
// going floating and on every window resize, so the trigger keeps the same
246+
// relative spot and never ends up off-screen.
247+
let seededSpot: TriggerCoords | undefined
248+
const placeFromSettings = (el: HTMLElement) => {
249+
const b = bounds(el)
250+
let spot = settings().triggerCoords ?? seededSpot
251+
if (!spot) {
252+
const rect = el.getBoundingClientRect()
253+
spot = seededSpot = {
254+
x: toPercent(rect.left, b.minX, b.maxX),
255+
y: toPercent(rect.top, b.minY, b.maxY),
256+
}
257+
}
258+
setCoords({
259+
x: toPixels(spot.x, b.minX, b.maxX),
260+
y: toPixels(spot.y, b.minY, b.maxY),
261+
})
262+
}
263+
264+
// Reads settings untracked so this only runs on mode/ref changes.
224265
createEffect(() => {
225266
if (!isFloating()) return
226267
const el = buttonRef()
227268
if (!el) return
228-
untrack(() => {
229-
const current = coords()
230-
if (!current) {
231-
const rect = el.getBoundingClientRect()
232-
setCoords({ x: rect.left, y: rect.top })
233-
return
234-
}
235-
const b = bounds(el)
236-
setCoords({
237-
x: clamp(current.x, b.minX, b.maxX),
238-
y: clamp(current.y, b.minY, b.maxY),
239-
})
240-
})
269+
untrack(() => placeFromSettings(el))
241270
})
242271

243-
// Keep the trigger on screen when the window is resized.
244272
createEffect(() => {
245273
if (!isFloating()) return
246274
const onResize = () => {
247275
const el = buttonRef()
248-
const current = coords()
249-
if (!el || !current) return
250-
const b = bounds(el)
251-
setCoords({
252-
x: clamp(current.x, b.minX, b.maxX),
253-
y: clamp(current.y, b.minY, b.maxY),
254-
})
255-
persist()
276+
// A throw in flight owns the position; it persists when it settles.
277+
if (el && !dragging && raf === undefined) placeFromSettings(el)
256278
}
257279
window.addEventListener('resize', onResize)
258280
onCleanup(() => window.removeEventListener('resize', onResize))

packages/devtools/src/context/devtools-store.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ export type DevtoolsStore = {
5656
*/
5757
triggerMode: TriggerMode
5858
/**
59-
* The persisted top-left coordinates (in px) of the floating trigger.
60-
* Only used when `triggerMode` is "floating".
59+
* The persisted spot of the floating trigger, as a percent (0-100) of the
60+
* free space from the left and top edges. `{ x: 100, y: 100 }` is the
61+
* bottom-right corner. Only used when `triggerMode` is "floating".
6162
* @default undefined
6263
*/
6364
triggerCoords?: TriggerCoords

0 commit comments

Comments
 (0)