diff --git a/.changeset/floating-draggable-trigger.md b/.changeset/floating-draggable-trigger.md new file mode 100644 index 00000000..dd692608 --- /dev/null +++ b/.changeset/floating-draggable-trigger.md @@ -0,0 +1,10 @@ +--- +'@tanstack/devtools': minor +--- + +Add a floating trigger mode. Set `triggerMode: 'floating'` (or choose it under +Settings → Trigger Mode) to drag the devtools trigger anywhere on screen with +the left mouse button. Releasing a drag with velocity throws it — it glides with +momentum and springs back off the screen edges. The trigger is always kept +within a padded, on-screen area (it can never end up off-screen) and its +position is persisted to local storage. diff --git a/docs/configuration.md b/docs/configuration.md index 1e742efe..8f8c7b6d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,12 +29,18 @@ The `config` object is mainly focused around user interaction with the devtools { hideUntilHover: boolean } ``` -- `position` - The position of the TanStack devtools trigger +- `position` - The position of the TanStack devtools trigger (used when `triggerMode` is `'fixed'`) ```ts { position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle-left' | 'middle-right' } ``` +- `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. + +```ts +{ triggerMode: 'fixed' | 'floating' } +``` + - `panelLocation` - The location of the devtools panel ```ts diff --git a/packages/devtools/src/components/trigger.test.tsx b/packages/devtools/src/components/trigger.test.tsx index d58a64fb..34c6eb76 100644 --- a/packages/devtools/src/components/trigger.test.tsx +++ b/packages/devtools/src/components/trigger.test.tsx @@ -2,7 +2,7 @@ import { render } from '@solidjs/testing-library' import { createSignal } from 'solid-js' import { beforeEach, describe, expect, it } from 'vitest' import { DevtoolsProvider } from '../context/devtools-context' -import { Trigger } from './trigger' +import { Trigger, clamp, stepAxis } from './trigger' import type { TanStackDevtoolsConfig } from '../context/devtools-context' const renderTrigger = (config?: Partial) => { @@ -37,4 +37,52 @@ describe('Trigger', () => { expect(queryByLabelText('Open TanStack Devtools')).not.toBeInTheDocument() }) + + it('renders the floating trigger without a fixed position class', () => { + const { queryByLabelText } = renderTrigger({ triggerMode: 'floating' }) + + const button = queryByLabelText('Open TanStack Devtools') + expect(button).toBeInTheDocument() + }) +}) + +describe('throw physics', () => { + it('clamps values into range', () => { + expect(clamp(5, 0, 10)).toBe(5) + expect(clamp(-5, 0, 10)).toBe(0) + expect(clamp(50, 0, 10)).toBe(10) + // Degenerate range (window smaller than trigger + padding): stays at min. + expect(clamp(5, 10, 0)).toBe(10) + }) + + it('advances position by velocity while inside the walls', () => { + const { pos, vel } = stepAxis(100, 10, 0, 500) + expect(pos).toBe(110) + expect(vel).toBeCloseTo(9.5) // 10 * FRICTION(0.95) + }) + + it('bounces and damps velocity at a wall', () => { + const hitMax = stepAxis(495, 20, 0, 500) + expect(hitMax.pos).toBe(500) + expect(hitMax.vel).toBeLessThan(0) // reversed + // 20 * 0.95 = 19, reversed & damped by RESTITUTION(0.5) => -9.5 + expect(hitMax.vel).toBeCloseTo(-9.5) + + const hitMin = stepAxis(5, -20, 0, 500) + expect(hitMin.pos).toBe(0) + expect(hitMin.vel).toBeGreaterThan(0) + }) + + it('a throw decays to a stop (loop terminates within bounds)', () => { + let pos = 250 + let vel = 40 + let frames = 0 + while (Math.abs(vel) > 0.1 && frames < 10000) { + ;({ pos, vel } = stepAxis(pos, vel, 0, 500)) + frames++ + } + expect(frames).toBeLessThan(10000) + expect(pos).toBeGreaterThanOrEqual(0) + expect(pos).toBeLessThanOrEqual(500) + }) }) diff --git a/packages/devtools/src/components/trigger.tsx b/packages/devtools/src/components/trigger.tsx index 44870631..df7e0763 100644 --- a/packages/devtools/src/components/trigger.tsx +++ b/packages/devtools/src/components/trigger.tsx @@ -1,25 +1,263 @@ -import { Show, createEffect, createMemo, createSignal } from 'solid-js' +import { + Show, + createEffect, + createMemo, + createSignal, + onCleanup, + untrack, +} from 'solid-js' import clsx from 'clsx' import { createDevtoolsSettings } from '../context/use-devtools-context' import { createStyles } from '../styles/use-styles' import TanStackLogo from './tanstack-logo.png' +import type { TriggerCoords } from '../context/devtools-store' import type { Accessor } from 'solid-js' +// --- Throw physics (pure, unit-tested in trigger.test.tsx) --- +const FRICTION = 0.95 // velocity retained each frame +const RESTITUTION = 0.5 // velocity retained after a wall bounce +const MIN_SPEED = 0.1 // px/frame below which the throw stops +const DRAG_THRESHOLD = 4 // px of movement before a press counts as a drag +const PADDING_RATIO = 0.5 // matches size[2] = --tsrd-font-size * 0.5 + +export const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)) + +/** + * Advance one axis by its velocity for a single frame, bouncing off the + * [min, max] walls with damping. Returns the new position and velocity. + */ +export const stepAxis = ( + pos: number, + vel: number, + min: number, + max: number, +): { pos: number; vel: number } => { + let p = pos + vel + let v = vel * FRICTION + if (p <= min) { + p = min + v = -v * RESTITUTION + } else if (p >= max) { + p = max + v = -v * RESTITUTION + } + return { pos: p, vel: v } +} + export const Trigger = (props: { isOpen: Accessor setIsOpen: (isOpen: boolean) => void }) => { - const { settings } = createDevtoolsSettings() + const { settings, setSettings } = createDevtoolsSettings() const [containerRef, setContainerRef] = createSignal() + const [buttonRef, setButtonRef] = createSignal() + const [coords, setCoords] = createSignal( + settings().triggerCoords ?? null, + ) const styles = createStyles() + + const isFloating = createMemo(() => settings().triggerMode === 'floating') + const buttonStyle = createMemo(() => { return clsx( styles().mainCloseBtn, - styles().mainCloseBtnPosition(settings().position), + // Keep the fixed-position class until floating coords are seeded, so the + // seed reads the trigger's real on-screen spot (not the unpositioned + // static-flow position) and the hand-off to inline left/top is seamless. + (!isFloating() || !coords()) && + styles().mainCloseBtnPosition(settings().position), styles().mainCloseBtnAnimation(props.isOpen(), settings().hideUntilHover), + isFloating() && styles().mainCloseBtnFloating, + ) + }) + + // Padding away from the edges, matching the fixed trigger's offset (size[2]). + const edgePadding = (el: HTMLElement) => { + const fontSize = parseFloat( + getComputedStyle(el).getPropertyValue('--tsrd-font-size'), ) + return (Number.isFinite(fontSize) ? fontSize : 16) * PADDING_RATIO + } + + const bounds = (el: HTMLElement) => { + const pad = edgePadding(el) + const rect = el.getBoundingClientRect() + return { + minX: pad, + minY: pad, + maxX: window.innerWidth - rect.width - pad, + maxY: window.innerHeight - rect.height - pad, + } + } + + // --- drag state (non-reactive; only `coords` drives rendering) --- + let dragging = false + let moved = false + let startX = 0 + let startY = 0 + let startPosX = 0 + let startPosY = 0 + let lastX = 0 + let lastY = 0 + let lastT = 0 + let vx = 0 + let vy = 0 + let raf: number | undefined + + const cancelThrow = () => { + if (raf !== undefined) { + cancelAnimationFrame(raf) + raf = undefined + } + } + + const persist = () => setSettings({ triggerCoords: coords() ?? undefined }) + + const startThrow = () => { + cancelThrow() + const tick = () => { + const el = buttonRef() + const current = coords() + if (!el || !current) { + raf = undefined + return + } + const b = bounds(el) + const nx = stepAxis(current.x, vx, b.minX, b.maxX) + const ny = stepAxis(current.y, vy, b.minY, b.maxY) + vx = nx.vel + vy = ny.vel + setCoords({ x: nx.pos, y: ny.pos }) + if (Math.hypot(vx, vy) > MIN_SPEED) { + raf = requestAnimationFrame(tick) + } else { + raf = undefined + persist() + } + } + raf = requestAnimationFrame(tick) + } + + const onPointerDown = (e: PointerEvent) => { + if (!isFloating() || e.button !== 0) return + const el = buttonRef() + const current = coords() + if (!el || !current) return + cancelThrow() + dragging = true + moved = false + el.setPointerCapture(e.pointerId) + startX = e.clientX + startY = e.clientY + startPosX = current.x + startPosY = current.y + lastX = e.clientX + lastY = e.clientY + lastT = e.timeStamp + vx = 0 + vy = 0 + e.preventDefault() + } + + const onPointerMove = (e: PointerEvent) => { + if (!dragging) return + const el = buttonRef() + if (!el) return + const dx = e.clientX - startX + const dy = e.clientY - startY + if (Math.hypot(dx, dy) > DRAG_THRESHOLD) moved = true + const b = bounds(el) + setCoords({ + x: clamp(startPosX + dx, b.minX, b.maxX), + y: clamp(startPosY + dy, b.minY, b.maxY), + }) + // Velocity in px per ~16ms frame, so it plugs straight into stepAxis. + const dt = e.timeStamp - lastT + if (dt > 0) { + vx = ((e.clientX - lastX) / dt) * 16 + vy = ((e.clientY - lastY) / dt) * 16 + } + lastX = e.clientX + lastY = e.clientY + lastT = e.timeStamp + } + + const endDrag = (e: PointerEvent, canThrow: boolean) => { + if (!dragging) return + dragging = false + const el = buttonRef() + if (el?.hasPointerCapture(e.pointerId)) + el.releasePointerCapture(e.pointerId) + // If the pointer sat still before release, the last flick velocity is + // stale — don't launch a throw the user didn't actually make. + if (e.timeStamp - lastT > 50) { + vx = 0 + vy = 0 + } + if (canThrow && moved && Math.hypot(vx, vy) > MIN_SPEED) { + startThrow() + } else { + persist() + } + } + + const onPointerUp = (e: PointerEvent) => endDrag(e, true) + // A cancelled pointer (OS gesture, context menu) just drops in place. + const onPointerCancel = (e: PointerEvent) => endDrag(e, false) + + const onClick = () => { + // A drag release also fires a click — swallow it so dragging never toggles. + if (moved) { + moved = false + return + } + props.setIsOpen(!props.isOpen()) + } + + // On going floating: seed coords from the button's current (fixed) position + // if there's no stored spot, otherwise clamp the restored spot into view + // (a saved position from a larger window must not load off-screen). + // Reads/writes coords untracked so this only runs on mode/ref changes. + createEffect(() => { + if (!isFloating()) return + const el = buttonRef() + if (!el) return + untrack(() => { + const current = coords() + if (!current) { + const rect = el.getBoundingClientRect() + setCoords({ x: rect.left, y: rect.top }) + return + } + const b = bounds(el) + setCoords({ + x: clamp(current.x, b.minX, b.maxX), + y: clamp(current.y, b.minY, b.maxY), + }) + }) }) + // Keep the trigger on screen when the window is resized. + createEffect(() => { + if (!isFloating()) return + const onResize = () => { + const el = buttonRef() + const current = coords() + if (!el || !current) return + const b = bounds(el) + setCoords({ + x: clamp(current.x, b.minX, b.maxX), + y: clamp(current.y, b.minY, b.maxY), + }) + persist() + } + window.addEventListener('resize', onResize) + onCleanup(() => window.removeEventListener('resize', onResize)) + }) + + onCleanup(cancelThrow) + createEffect(() => { const triggerComponent = settings().customTrigger const el = containerRef() @@ -33,10 +271,26 @@ export const Trigger = (props: { return (