diff --git a/.changeset/trigger-edge-dock.md b/.changeset/trigger-edge-dock.md new file mode 100644 index 000000000..35bf114a6 --- /dev/null +++ b/.changeset/trigger-edge-dock.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': minor +--- + +Implement the hot corner feature for the trigger edge dock. diff --git a/docs/configuration.md b/docs/configuration.md index bfbf04baf..54f9d0576 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,7 +35,7 @@ The `config` object is mainly focused around user interaction with the devtools { position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle-left' | 'middle-right' } ``` -- `triggerMode` - How the trigger is placed. `'floating'` (the default) lets you drag the trigger anywhere on screen, and throw it: it glides with momentum and springs back off the edges. `'fixed'` anchors it to `position`. The floating spot is persisted in local storage. +- `triggerMode` - How the trigger is placed. `'floating'` (the default) lets you drag the trigger anywhere on screen, and throw it: it glides with momentum and springs back off the edges. Drag it off any screen edge to hide it behind a slim arrow tab (click the tab to bring it back), and hold it still over a glowing corner for two seconds to drop it there without pinning. `'fixed'` anchors it to `position`. The floating spot is persisted in local storage. ```ts { triggerMode: 'fixed' | 'floating' } diff --git a/packages/devtools/src/components/tanstack-trigger-mark.tsx b/packages/devtools/src/components/tanstack-trigger-mark.tsx index 8f19e4846..401472c8a 100644 --- a/packages/devtools/src/components/tanstack-trigger-mark.tsx +++ b/packages/devtools/src/components/tanstack-trigger-mark.tsx @@ -7,6 +7,9 @@ const RAINBOW_STOP_0 = '#FF5F5F' // semantic-color-exempt: trigger-rainbow-mark const RAINBOW_STOP_1 = '#FFA05C' // semantic-color-exempt: trigger-rainbow-mark const RAINBOW_STOP_2 = '#FFF27C' // semantic-color-exempt: trigger-rainbow-mark const RAINBOW_STOP_3 = '#74DCFF' // semantic-color-exempt: trigger-rainbow-mark +// Exposed as a single opaque CSS value so the edge tab button (a plain DOM +// element, not an SVG) can paint the same rainbow as its background. +export const TRIGGER_MARK_GRADIENT = `linear-gradient(to bottom, ${RAINBOW_STOP_0} 0%, ${RAINBOW_STOP_1} 34.4449%, ${RAINBOW_STOP_2} 73.3354%, ${RAINBOW_STOP_3} 100%)` // semantic-color-exempt: trigger-rainbow-mark /** * The default trigger mark: the rainbow palm from the TanStack dark favicon, diff --git a/packages/devtools/src/components/trigger.test.tsx b/packages/devtools/src/components/trigger.test.tsx index fc7093ea7..8aa620de8 100644 --- a/packages/devtools/src/components/trigger.test.tsx +++ b/packages/devtools/src/components/trigger.test.tsx @@ -1,8 +1,18 @@ -import { render } from '@solidjs/testing-library' +import { fireEvent, render } from '@solidjs/testing-library' import { createSignal } from 'solid-js' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DevtoolsProvider } from '../context/devtools-context' -import { Trigger, clamp, stepAxis } from './trigger' +import { TANSTACK_DEVTOOLS_SETTINGS } from '../utils/storage' +import { + Trigger, + clamp, + cornerAt, + cornerCoords, + directionCorner, + offScreenEdge, + quadrantCorner, + stepAxis, +} from './trigger' import type { TanStackDevtoolsConfig } from '../context/devtools-context' const renderTrigger = (config?: Partial) => { @@ -29,8 +39,6 @@ describe('Trigger', () => { expect(button).toBeInTheDocument() expect(button?.tagName).toBe('BUTTON') - // buttonStyle() is a clsx of mainCloseBtn + position + animation goober - // classes, so the rendered class attribute must contain several classes. const classList = button?.getAttribute('class')?.split(/\s+/) ?? [] expect(classList.length).toBeGreaterThanOrEqual(3) }) @@ -64,21 +72,17 @@ describe('throw physics', () => { 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) @@ -99,3 +103,515 @@ describe('throw physics', () => { expect(pos).toBeLessThanOrEqual(500) }) }) + +describe('hot corners', () => { + const bounds = { minX: 8, minY: 8, maxX: 508, maxY: 308 } + + it('goes hot only when both axes are near a wall', () => { + expect(cornerAt({ x: 10, y: 10 }, bounds, 72)).toBe('top-left') + expect(cornerAt({ x: 500, y: 300 }, bounds, 72)).toBe('bottom-right') + expect(cornerAt({ x: 10, y: 300 }, bounds, 72)).toBe('bottom-left') + expect(cornerAt({ x: 500, y: 10 }, bounds, 72)).toBe('top-right') + expect(cornerAt({ x: 10, y: 160 }, bounds, 72)).toBeNull() + expect(cornerAt({ x: 250, y: 10 }, bounds, 72)).toBeNull() + expect(cornerAt({ x: 250, y: 160 }, bounds, 72)).toBeNull() + }) + + it('treats the snap distance as inclusive', () => { + expect(cornerAt({ x: 80, y: 80 }, bounds, 72)).toBe('top-left') + expect(cornerAt({ x: 81, y: 81 }, bounds, 72)).toBeNull() + }) + + it('anchors a pinned corner to the padded bounds', () => { + expect(cornerCoords('top-left', bounds)).toEqual({ x: 8, y: 8 }) + expect(cornerCoords('top-right', bounds)).toEqual({ x: 508, y: 8 }) + expect(cornerCoords('bottom-left', bounds)).toEqual({ x: 8, y: 308 }) + expect(cornerCoords('bottom-right', bounds)).toEqual({ x: 508, y: 308 }) + }) + + it('keeps a pin on screen when the window is smaller than the trigger', () => { + const degenerate = { minX: 8, minY: 8, maxX: -20, maxY: -20 } + expect(cornerCoords('bottom-right', degenerate)).toEqual({ x: 8, y: 8 }) + }) +}) + +describe('magnetic direction', () => { + it('reads a corner from the direction of a nudge alone', () => { + expect(directionCorner(-40, -40, null)).toBe('top-left') + expect(directionCorner(40, -40, null)).toBe('top-right') + expect(directionCorner(-40, 40, null)).toBe('bottom-left') + expect(directionCorner(40, 40, null)).toBe('bottom-right') + }) + + it('keeps the fallback when neither axis leaves the deadzone', () => { + expect(directionCorner(4, -4, 'bottom-left')).toBe('bottom-left') + expect(directionCorner(0, 0, null)).toBeNull() + }) + + it('ignores sideways wander on a long drag along one axis', () => { + expect(directionCorner(12, -200, 'bottom-left')).toBe('top-left') + expect(directionCorner(-12, -200, 'bottom-right')).toBe('top-right') + expect(directionCorner(200, 12, 'top-right')).toBe('top-right') + expect(directionCorner(200, -12, 'bottom-left')).toBe('bottom-right') + }) + + it('still reads a diagonal once the minor axis is deliberate', () => { + expect(directionCorner(120, -200, 'bottom-left')).toBe('top-right') + expect(directionCorner(-120, -200, 'bottom-right')).toBe('top-left') + }) + + it('falls back to the quadrant the trigger sits in', () => { + const size = { width: 56, height: 56 } + const viewport = { width: 1024, height: 768 } + expect(quadrantCorner({ x: 8, y: 8 }, size, viewport)).toBe('top-left') + expect(quadrantCorner({ x: 960, y: 8 }, size, viewport)).toBe('top-right') + expect(quadrantCorner({ x: 8, y: 704 }, size, viewport)).toBe('bottom-left') + expect(quadrantCorner({ x: 960, y: 704 }, size, viewport)).toBe( + 'bottom-right', + ) + }) +}) + +describe('off screen edge', () => { + const size = { width: 56, height: 56 } + const viewport = { width: 1024, height: 768 } + + it('is null while the trigger centre stays on screen', () => { + expect(offScreenEdge({ x: 8, y: 8 }, size, viewport)).toBeNull() + expect(offScreenEdge({ x: 960, y: 704 }, size, viewport)).toBeNull() + expect(offScreenEdge({ x: -20, y: 300 }, size, viewport)).toBeNull() + expect(offScreenEdge({ x: 990, y: 300 }, size, viewport)).toBeNull() + }) + + it('reports the edge the centre crosses', () => { + expect(offScreenEdge({ x: -28, y: 300 }, size, viewport)).toBe('left') + expect(offScreenEdge({ x: 996, y: 300 }, size, viewport)).toBe('right') + expect(offScreenEdge({ x: 300, y: -28 }, size, viewport)).toBe('top') + expect(offScreenEdge({ x: 300, y: 740 }, size, viewport)).toBe('bottom') + }) + + it('prefers a side edge when the centre leaves through a corner', () => { + expect(offScreenEdge({ x: -40, y: -40 }, size, viewport)).toBe('left') + expect(offScreenEdge({ x: 2000, y: 2000 }, size, viewport)).toBe('right') + }) +}) + +describe('dragging a floating trigger into a corner', () => { + beforeEach(() => { + localStorage.clear() + Element.prototype.setPointerCapture = vi.fn() + Element.prototype.releasePointerCapture = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + }) + + const drag = (el: Element, type: string, x: number, y: number) => + el.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + pointerId: 1, + clientX: x, + clientY: y, + }), + ) + + const storedSettings = () => + JSON.parse(localStorage.getItem(TANSTACK_DEVTOOLS_SETTINGS) ?? '{}') + + it('shows the hot corner mark only while over a corner, then pins there', () => { + const { container, getByLabelText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + const mark = () => container.querySelector('[data-tsd-hot-corner]') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 600, 400) + expect(mark()).toBeNull() + + drag(button, 'pointermove', 1016, 760) + expect(mark()).not.toBeNull() + + expect(mark()?.getAttribute('data-tsd-hot-corner')).toBe('bottom-right') + + drag(button, 'pointerup', 1016, 760) + expect(mark()).toBeNull() + expect(storedSettings().triggerCorner).toBe('bottom-right') + expect(storedSettings().triggerCoords).toEqual({ x: 1016, y: 760 }) + }) + + it('drops the pin when the trigger is dragged back out of the corner', () => { + const { getByLabelText } = renderTrigger({ triggerMode: 'floating' }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 1016, 760) + drag(button, 'pointerup', 1016, 760) + expect(storedSettings().triggerCorner).toBe('bottom-right') + + drag(button, 'pointerdown', 1016, 760) + drag(button, 'pointermove', 516, 400) + drag(button, 'pointerup', 516, 400) + expect(storedSettings().triggerCorner).toBeUndefined() + }) +}) + +describe('the drag tooltip', () => { + beforeEach(() => { + localStorage.clear() + Element.prototype.setPointerCapture = vi.fn() + Element.prototype.releasePointerCapture = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + }) + + const drag = (el: Element, type: string, x: number, y: number) => + el.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + pointerId: 1, + clientX: x, + clientY: y, + }), + ) + + it('stays up when the pointer outruns the button mid-drag', () => { + const { getByLabelText, queryByText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 400, 400) + expect(queryByText(/Drag/)).not.toBeNull() + + fireEvent.mouseLeave(button) + drag(button, 'pointermove', 700, 300) + expect(queryByText(/Drag/)).not.toBeNull() + + drag(button, 'pointerup', 700, 300) + expect(queryByText(/Drag/)).toBeNull() + }) + + it('gets out of the way while the drag is still going', () => { + vi.useFakeTimers() + try { + const { getByLabelText, queryByText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 400, 400) + expect(queryByText(/Drag/)).not.toBeNull() + + vi.advanceTimersByTime(1500) + expect(queryByText(/Drag/)).toBeNull() + + drag(button, 'pointermove', 500, 400) + expect(queryByText(/Drag/)).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('still hides on hover-out when no drag is in flight', () => { + const { getByLabelText, queryByText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 400, 400) + drag(button, 'pointerup', 400, 400) + drag(button, 'pointerdown', 400, 400) + expect(queryByText(/Drag/)).not.toBeNull() + + drag(button, 'pointercancel', 400, 400) + fireEvent.mouseLeave(button) + expect(queryByText(/Drag/)).toBeNull() + }) +}) + +describe('escape during a trigger drag', () => { + beforeEach(() => { + localStorage.clear() + Element.prototype.setPointerCapture = vi.fn() + Element.prototype.releasePointerCapture = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + }) + + const drag = (el: Element, type: string, x: number, y: number) => + el.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + pointerId: 1, + clientX: x, + clientY: y, + }), + ) + + const escape = () => + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ) + + const storedSettings = () => + JSON.parse(localStorage.getItem(TANSTACK_DEVTOOLS_SETTINGS) ?? '{}') + + it('puts the trigger back where it was picked up and drops the hot corner', () => { + const { container, getByLabelText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 1016, 760) + expect(container.querySelector('[data-tsd-hot-corner]')).not.toBeNull() + + escape() + expect(container.querySelector('[data-tsd-hot-corner]')).toBeNull() + expect(storedSettings().triggerCoords).toEqual({ x: 0, y: 0 }) + expect(storedSettings().triggerCorner).toBeUndefined() + + drag(button, 'pointerup', 1016, 760) + expect(storedSettings().triggerCoords).toEqual({ x: 0, y: 0 }) + expect(storedSettings().triggerCorner).toBeUndefined() + }) + + it('restores the pin the drag started from', () => { + const { getByLabelText } = renderTrigger({ triggerMode: 'floating' }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 1016, 760) + drag(button, 'pointerup', 1016, 760) + expect(storedSettings().triggerCorner).toBe('bottom-right') + + drag(button, 'pointerdown', 1016, 760) + drag(button, 'pointermove', 516, 400) + escape() + expect(storedSettings().triggerCorner).toBe('bottom-right') + expect(storedSettings().triggerCoords).toEqual({ x: 1016, y: 760 }) + }) + + it('leaves a settled trigger alone', () => { + const { getByLabelText } = renderTrigger({ triggerMode: 'floating' }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 1016, 760) + drag(button, 'pointerup', 1016, 760) + + escape() + expect(storedSettings().triggerCoords).toEqual({ x: 1016, y: 760 }) + expect(storedSettings().triggerCorner).toBe('bottom-right') + }) +}) + +describe('holding a drag on a hot corner', () => { + beforeEach(() => { + localStorage.clear() + Element.prototype.setPointerCapture = vi.fn() + Element.prototype.releasePointerCapture = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const drag = (el: Element, type: string, x: number, y: number) => + el.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + pointerId: 1, + clientX: x, + clientY: y, + }), + ) + + const storedSettings = () => + JSON.parse(localStorage.getItem(TANSTACK_DEVTOOLS_SETTINGS) ?? '{}') + + it('deactivates the hot corner after holding still for 2s', () => { + const { container, getByLabelText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + const mark = () => container.querySelector('[data-tsd-hot-corner]') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 1016, 760) + expect(mark()).not.toBeNull() + + vi.advanceTimersByTime(1999) + expect(mark()).not.toBeNull() + vi.advanceTimersByTime(1) + expect(mark()).toBeNull() + + drag(button, 'pointermove', 500, 400) + drag(button, 'pointerup', 500, 400) + expect(storedSettings().triggerCorner).toBeUndefined() + }) + + it('stays snoozed inside the corner, and re-arms after leaving it', () => { + const { container, getByLabelText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + const mark = () => container.querySelector('[data-tsd-hot-corner]') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 1016, 760) + vi.advanceTimersByTime(2000) + expect(mark()).toBeNull() + + drag(button, 'pointermove', 1010, 750) + expect(mark()).toBeNull() + + drag(button, 'pointermove', 500, 400) + drag(button, 'pointermove', 1016, 760) + expect(mark()).not.toBeNull() + + drag(button, 'pointermove', 500, 400) + drag(button, 'pointerup', 500, 400) + }) + + it('restarts the countdown whenever the pointer moves', () => { + const { container, getByLabelText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + const mark = () => container.querySelector('[data-tsd-hot-corner]') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 1016, 760) + vi.advanceTimersByTime(1500) + drag(button, 'pointermove', 1020, 750) + vi.advanceTimersByTime(1500) + expect(mark()).not.toBeNull() + vi.advanceTimersByTime(500) + expect(mark()).toBeNull() + + drag(button, 'pointermove', 500, 400) + drag(button, 'pointerup', 500, 400) + }) + + it('still pins when released before the hold elapses', () => { + const { getByLabelText } = renderTrigger({ triggerMode: 'floating' }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 1016, 760) + vi.advanceTimersByTime(1500) + drag(button, 'pointerup', 1016, 760) + expect(storedSettings().triggerCorner).toBe('bottom-right') + }) + + it('lets a release pushed into a snoozed corner hide the trigger', () => { + const { getByLabelText, queryByLabelText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 2000, 2000) + vi.advanceTimersByTime(2000) + drag(button, 'pointerup', 2000, 2000) + + expect(storedSettings().triggerCorner).toBeUndefined() + expect(storedSettings().triggerEdge).toBe('right') + expect(queryByLabelText('Open TanStack Devtools')).not.toBeInTheDocument() + expect( + getByLabelText('Show TanStack Devtools trigger'), + ).toBeInTheDocument() + }) +}) + +describe('dragging the trigger off screen', () => { + beforeEach(() => { + localStorage.clear() + Element.prototype.setPointerCapture = vi.fn() + Element.prototype.releasePointerCapture = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + }) + + const drag = (el: Element, type: string, x: number, y: number) => + el.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + pointerId: 1, + clientX: x, + clientY: y, + }), + ) + + const storedSettings = () => + JSON.parse(localStorage.getItem(TANSTACK_DEVTOOLS_SETTINGS) ?? '{}') + + it('hides the trigger behind an arrow tab that brings it back', () => { + const { getByLabelText, queryByLabelText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 5000, 400) + + expect( + getByLabelText('Show TanStack Devtools trigger'), + ).toBeInTheDocument() + + drag(button, 'pointerup', 5000, 400) + + expect(queryByLabelText('Open TanStack Devtools')).not.toBeInTheDocument() + expect(storedSettings().triggerEdge).toBe('right') + expect(storedSettings().triggerCorner).toBeUndefined() + + fireEvent.click(getByLabelText('Show TanStack Devtools trigger')) + + expect(storedSettings().triggerEdge).toBeUndefined() + expect(getByLabelText('Open TanStack Devtools')).toBeInTheDocument() + expect( + queryByLabelText('Show TanStack Devtools trigger'), + ).not.toBeInTheDocument() + }) + + it('docks to the edge even when the release is also corner-hot', () => { + const { container, getByLabelText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 5000, 0) + + expect(container.querySelector('[data-tsd-hot-corner]')).toBeNull() + expect( + getByLabelText('Show TanStack Devtools trigger'), + ).toBeInTheDocument() + + drag(button, 'pointerup', 5000, 0) + + expect(storedSettings().triggerEdge).toBe('right') + expect(storedSettings().triggerCorner).toBeUndefined() + }) + + it('keeps the trigger when the release never crosses the edge', () => { + const { getByLabelText, queryByLabelText } = renderTrigger({ + triggerMode: 'floating', + }) + const button = getByLabelText('Open TanStack Devtools') + + drag(button, 'pointerdown', 0, 0) + drag(button, 'pointermove', 5000, 400) + drag(button, 'pointermove', 500, 400) + drag(button, 'pointerup', 500, 400) + + expect(getByLabelText('Open TanStack Devtools')).toBeInTheDocument() + expect(queryByLabelText('Show TanStack Devtools trigger')).toBeNull() + expect(storedSettings().triggerEdge).toBeUndefined() + }) +}) diff --git a/packages/devtools/src/components/trigger.tsx b/packages/devtools/src/components/trigger.tsx index 1c96417ee..4afbc924f 100644 --- a/packages/devtools/src/components/trigger.tsx +++ b/packages/devtools/src/components/trigger.tsx @@ -9,8 +9,19 @@ import { import clsx from 'clsx' import { createDevtoolsSettings } from '../context/use-devtools-context' import { createStyles } from '../styles/use-styles' +import { + HOT_CORNER_HOLD_MS, + HOT_CORNER_SNAP, + TRIGGER_EDGE_TAB_LENGTH, + TRIGGER_EDGE_TAB_PAD, + TRIGGER_TOOLTIP_MS, +} from '../utils/constants' import { TanStackTriggerMark } from './tanstack-trigger-mark' -import type { TriggerCoords } from '../context/devtools-store' +import type { + TriggerCoords, + TriggerCorner, + TriggerEdge, +} from '../context/devtools-store' import type { Accessor } from 'solid-js' // --- Throw physics (pure, unit-tested in trigger.test.tsx) --- @@ -18,11 +29,136 @@ 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 DIRECTION_DEADZONE = 8 // px below which a nudge has no directional intent +const DIRECTION_AXIS_RATIO = 0.5 // an axis only reads as intent at half the dominant one const PADDING_RATIO = 0.5 // matches size[2] = --tsrd-font-size * 0.5 +type Bounds = { minX: number; minY: number; maxX: number; maxY: number } + export const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) +export const cornerAt = ( + { x, y }: TriggerCoords, + b: Bounds, + snap = HOT_CORNER_SNAP, +): TriggerCorner | null => { + const vertical = + y - b.minY <= snap ? 'top' : b.maxY - y <= snap ? 'bottom' : null + const horizontal = + x - b.minX <= snap ? 'left' : b.maxX - x <= snap ? 'right' : null + return vertical && horizontal ? `${vertical}-${horizontal}` : null +} + +export const cornerCoords = ( + corner: TriggerCorner, + b: Bounds, +): TriggerCoords => ({ + x: clamp(corner.endsWith('left') ? b.minX : b.maxX, b.minX, b.maxX), + y: clamp(corner.startsWith('top') ? b.minY : b.maxY, b.minY, b.maxY), +}) + +/** + * Magnetic-mode corner: which corner a nudge (dx, dy) points toward, from + * wherever the trigger currently sits. A tiny move is enough — the corner is + * read from the direction alone, not from distance to any actual corner. + * + * Intent is judged per axis relative to the dominant one, not against a flat + * pixel threshold: a long drag "up" always carries some sideways wander, and + * absolute thresholds read that wander as a deliberate horizontal move. An + * axis that falls short keeps whichever side of `fallback` it already had. + */ +export const directionCorner = ( + dx: number, + dy: number, + fallback: TriggerCorner | null, + deadzone = DIRECTION_DEADZONE, +): TriggerCorner | null => { + const ax = Math.abs(dx) + const ay = Math.abs(dy) + if (ax <= deadzone && ay <= deadzone) return fallback + const vertical = + ay > deadzone && ay >= ax * DIRECTION_AXIS_RATIO + ? dy < 0 + ? 'top' + : 'bottom' + : (fallback?.startsWith('top') ?? false) + ? 'top' + : 'bottom' + const horizontal = + ax > deadzone && ax >= ay * DIRECTION_AXIS_RATIO + ? dx < 0 + ? 'left' + : 'right' + : (fallback?.endsWith('left') ?? false) + ? 'left' + : 'right' + return `${vertical}-${horizontal}` +} + +/** + * The corner of the viewport quadrant the trigger sits in — the magnetic + * fallback for an unpinned trigger, so an axis with no intent holds the side + * it is already on instead of always reading as right/bottom. + */ +export const quadrantCorner = ( + { x, y }: TriggerCoords, + size: { width: number; height: number }, + viewport: { width: number; height: number }, +): TriggerCorner => { + const vertical = + y + size.height / 2 < viewport.height / 2 ? 'top' : 'bottom' + const horizontal = x + size.width / 2 < viewport.width / 2 ? 'left' : 'right' + return `${vertical}-${horizontal}` +} + +/** + * Which viewport edge the trigger's centre has crossed, if any. Horizontal + * wins at a corner so the tab docks to a side edge rather than the top/bottom + * strip a diagonal fling happened to reach last. + */ +export const offScreenEdge = ( + { x, y }: TriggerCoords, + size: { width: number; height: number }, + viewport: { width: number; height: number }, +): TriggerEdge | null => { + const cx = x + size.width / 2 + const cy = y + size.height / 2 + if (cx <= 0) return 'left' + if (cx >= viewport.width) return 'right' + if (cy <= 0) return 'top' + if (cy >= viewport.height) return 'bottom' + return null +} + +// Base chevron points right; rotate to aim back into the screen from the edge. +const CHEVRON_ROTATION: Record = { + left: 0, + top: 90, + right: 180, + bottom: 270, +} + +const EdgeTabChevron = (props: { edge: TriggerEdge }) => ( + +) + /** * Advance one axis by its velocity for a single frame, bouncing off the * [min, max] walls with damping. Returns the new position and velocity. @@ -55,9 +191,25 @@ export const Trigger = (props: { const [coords, setCoords] = createSignal( settings().triggerCoords ?? null, ) + const [pinnedCorner, setPinnedCorner] = createSignal( + settings().triggerCorner ?? null, + ) + const [hotCorner, setHotCorner] = createSignal(null) + const [dockedEdge, setDockedEdge] = createSignal( + settings().triggerEdge ?? null, + ) + const [hoverEdge, setHoverEdge] = createSignal(null) + const [tooltipVisible, setTooltipVisible] = createSignal(false) + const [magneticMode, setMagneticMode] = createSignal(false) + const [shiftMagnetic, setShiftMagnetic] = createSignal(false) const styles = createStyles() const isFloating = createMemo(() => settings().triggerMode === 'floating') + const docked = createMemo(() => isFloating() && dockedEdge() !== null) + const shownEdge = createMemo(() => + isFloating() ? (dockedEdge() ?? hoverEdge()) : null, + ) + const magneticActive = createMemo(() => magneticMode() || shiftMagnetic()) const buttonStyle = createMemo(() => { return clsx( @@ -70,6 +222,7 @@ export const Trigger = (props: { !settings().customTrigger && styles().mainCloseBtnDefault, styles().mainCloseBtnAnimation(props.isOpen(), settings().hideUntilHover), isFloating() && styles().mainCloseBtnFloating, + isFloating() && magneticActive() && styles().mainCloseBtnMagnetic, ) }) @@ -81,7 +234,7 @@ export const Trigger = (props: { return (Number.isFinite(fontSize) ? fontSize : 16) * PADDING_RATIO } - const bounds = (el: HTMLElement) => { + const bounds = (el: HTMLElement): Bounds => { const pad = edgePadding(el) const rect = el.getBoundingClientRect() return { @@ -105,6 +258,12 @@ export const Trigger = (props: { let vx = 0 let vy = 0 let raf: number | undefined + let activePointer: number | undefined + let startPinnedCorner: TriggerCorner | null = null + let holdTimer: ReturnType | undefined + let snoozedCorner: TriggerCorner | null = null + let tooltipShowTimer: ReturnType | undefined + let tooltipHideTimer: ReturnType | undefined const cancelThrow = () => { if (raf !== undefined) { @@ -113,10 +272,159 @@ export const Trigger = (props: { } } - const persist = () => setSettings({ triggerCoords: coords() ?? undefined }) + const hideTooltip = () => { + clearTimeout(tooltipShowTimer) + clearTimeout(tooltipHideTimer) + tooltipShowTimer = undefined + tooltipHideTimer = undefined + setTooltipVisible(false) + } + + // Pointer capture keeps a drag alive after the pointer outruns the button, + // and an edge preview drops the button out of hit-testing entirely — both + // fire a hover-out that must not pull the tooltip out from under the drag. + const hideTooltipUnlessDragging = () => { + if (!dragging) hideTooltip() + } + + // The hint reads once and then only blocks the view of the drag it explains. + const showTooltip = () => { + hideTooltip() + setTooltipVisible(true) + tooltipHideTimer = setTimeout( + () => setTooltipVisible(false), + TRIGGER_TOOLTIP_MS, + ) + } + + const scheduleTooltip = () => { + hideTooltip() + tooltipShowTimer = setTimeout(showTooltip, 400) + } + + const cancelHold = () => { + if (holdTimer !== undefined) { + clearTimeout(holdTimer) + holdTimer = undefined + } + } + + const armHoldTimer = () => { + cancelHold() + holdTimer = setTimeout(() => { + holdTimer = undefined + snoozedCorner = hotCorner() + setHotCorner(null) + }, HOT_CORNER_HOLD_MS) + } + + const persist = () => + setSettings({ + triggerCoords: coords() ?? undefined, + triggerCorner: pinnedCorner() ?? undefined, + triggerEdge: dockedEdge() ?? undefined, + }) + + const hideToEdge = (edge: TriggerEdge) => { + setPinnedCorner(null) + setHotCorner(null) + setHoverEdge(null) + setDockedEdge(edge) + persist() + } + + const restoreFromEdge = () => { + setDockedEdge(null) + persist() + } + + const edgeOffScreen = (c: TriggerCoords, el: HTMLElement) => { + const rect = el.getBoundingClientRect() + return offScreenEdge( + c, + { width: rect.width, height: rect.height }, + { width: window.innerWidth, height: window.innerHeight }, + ) + } + + const edgeTabStyle = (edge: TriggerEdge) => { + const vertical = edge === 'left' || edge === 'right' + const current = coords() + const viewport = vertical ? window.innerHeight : window.innerWidth + const max = Math.max( + TRIGGER_EDGE_TAB_PAD, + viewport - TRIGGER_EDGE_TAB_LENGTH - TRIGGER_EDGE_TAB_PAD, + ) + const along = (vertical ? current?.y : current?.x) ?? max / 2 + const pos = clamp(along, TRIGGER_EDGE_TAB_PAD, max) + return vertical ? { top: `${pos}px` } : { left: `${pos}px` } + } + + // Where an axis with no directional intent falls back to: the corner the + // drag started pinned at, or failing that the quadrant it started in. + const magneticFallback = (el: HTMLElement) => + startPinnedCorner ?? + quadrantCorner( + { x: startPosX, y: startPosY }, + el.getBoundingClientRect(), + { width: window.innerWidth, height: window.innerHeight }, + ) + + const pinTo = (corner: TriggerCorner, el: HTMLElement) => { + setPinnedCorner(corner) + setHotCorner(null) + setCoords(cornerCoords(corner, bounds(el))) + persist() + } + + const releaseCapture = () => { + const el = buttonRef() + if (activePointer !== undefined && el?.hasPointerCapture(activePointer)) + el.releasePointerCapture(activePointer) + activePointer = undefined + } + + /** + * Escape abandons the gesture: a drag goes back to where it was picked up + * (pin and all), a throw stops where it is rather than rewinding a flight + * the user has already watched. Returns whether there was anything to undo. + */ + const cancelGesture = () => { + if (dragging) { + dragging = false + vx = 0 + vy = 0 + cancelHold() + hideTooltip() + snoozedCorner = null + setHoverEdge(null) + setShiftMagnetic(false) + releaseCapture() + setHotCorner(null) + setPinnedCorner(startPinnedCorner) + setCoords({ x: startPosX, y: startPosY }) + persist() + return true + } + if (raf !== undefined) { + cancelThrow() + setShiftMagnetic(false) + setHotCorner(null) + persist() + return true + } + return false + } const startThrow = () => { cancelThrow() + // A throw keeps the corner its launch pointed at: friction shrinks the + // velocity every frame, so re-reading direction from it would end the + // throw on whatever rounding noise outlived the real motion. + const launched = buttonRef() + const thrownCorner = launched + ? directionCorner(vx, vy, magneticFallback(launched), 0) + : null const tick = () => { const el = buttonRef() const current = coords() @@ -129,12 +437,17 @@ export const Trigger = (props: { const ny = stepAxis(current.y, vy, b.minY, b.maxY) vx = nx.vel vy = ny.vel - setCoords({ x: nx.pos, y: ny.pos }) + const next = { x: nx.pos, y: ny.pos } + setCoords(next) + setHotCorner(magneticActive() ? thrownCorner : cornerAt(next, b)) if (Math.hypot(vx, vy) > MIN_SPEED) { raf = requestAnimationFrame(tick) } else { raf = undefined - persist() + setShiftMagnetic(false) + const corner = hotCorner() + if (corner) pinTo(corner, el) + else persist() } } raf = requestAnimationFrame(tick) @@ -146,8 +459,17 @@ export const Trigger = (props: { const current = coords() if (!el || !current) return cancelThrow() + cancelHold() + showTooltip() + snoozedCorner = null + setHoverEdge(null) + startPinnedCorner = pinnedCorner() + setPinnedCorner(null) + setHotCorner(null) dragging = true moved = false + setShiftMagnetic(e.shiftKey) + activePointer = e.pointerId el.setPointerCapture(e.pointerId) startX = e.clientX startY = e.clientY @@ -164,16 +486,41 @@ export const Trigger = (props: { const onPointerMove = (e: PointerEvent) => { if (!dragging) return e.preventDefault() + setShiftMagnetic(e.shiftKey) 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), - }) + const rect = el.getBoundingClientRect() + const next = { + x: clamp( + startPosX + dx, + -rect.width / 2, + window.innerWidth - rect.width / 2, + ), + y: clamp( + startPosY + dy, + -rect.height / 2, + window.innerHeight - rect.height / 2, + ), + } + setCoords(next) + const edge = moved ? edgeOffScreen(next, el) : null + setHoverEdge(edge) + // An edge preview and a hot corner would both claim the release, and only + // the preview is on screen to say so — so past the edge, no corner. + const corner = + moved && !edge + ? magneticActive() + ? directionCorner(dx, dy, magneticFallback(el)) + : cornerAt(next, bounds(el)) + : null + if (corner !== snoozedCorner) snoozedCorner = null + const hot = snoozedCorner ? null : corner + setHotCorner(hot) + cancelHold() + if (hot) armHoldTimer() // Velocity in px per ~16ms frame, so it plugs straight into stepAxis. const dt = e.timeStamp - lastT if (dt > 0) { @@ -188,18 +535,48 @@ export const Trigger = (props: { const endDrag = (e: PointerEvent, canThrow: boolean) => { if (!dragging) return dragging = false + cancelHold() + hideTooltip() + snoozedCorner = null + setHoverEdge(null) const el = buttonRef() - if (el?.hasPointerCapture(e.pointerId)) - el.releasePointerCapture(e.pointerId) + releaseCapture() // 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 } + // Crossing an edge wins: that is the arrow tab the preview promised, and a + // corner cannot be hot out there. A corner in turn wins over a throw — the + // mark promised it would stick. + const current = coords() + if (el && current && moved) { + const edge = edgeOffScreen(current, el) + if (edge) { + hideToEdge(edge) + return + } + } + const corner = hotCorner() + if (corner && el) { + pinTo(corner, el) + return + } + setHotCorner(null) + if (el && current) { + // Released dangling past the padded bounds without crossing an edge: + // slide back onto the screen before settling or throwing. + const b = bounds(el) + setCoords({ + x: clamp(current.x, b.minX, b.maxX), + y: clamp(current.y, b.minY, b.maxY), + }) + } if (canThrow && moved && Math.hypot(vx, vy) > MIN_SPEED) { startThrow() } else { + setShiftMagnetic(false) persist() } } @@ -217,15 +594,22 @@ export const Trigger = (props: { 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. + // On going floating (or coming back from an edge dock): 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 — or the off-screen spot it was hidden at — must not load + // off-screen). Reads/writes coords untracked so this only runs on + // mode/ref/dock changes. createEffect(() => { - if (!isFloating()) return + if (!isFloating() || dockedEdge()) return const el = buttonRef() if (!el) return untrack(() => { + const corner = pinnedCorner() + if (corner) { + setCoords(cornerCoords(corner, bounds(el))) + return + } const current = coords() if (!current) { const rect = el.getBoundingClientRect() @@ -240,25 +624,48 @@ export const Trigger = (props: { }) }) + createEffect(() => { + if (!isFloating()) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && cancelGesture()) event.stopPropagation() + if (event.altKey && event.key.toLowerCase() === 'm') { + setMagneticMode((v) => !v) + event.stopPropagation() + } + } + window.addEventListener('keydown', onKeyDown) + onCleanup(() => window.removeEventListener('keydown', onKeyDown)) + }) + // Keep the trigger on screen when the window is resized. createEffect(() => { if (!isFloating()) return const onResize = () => { + if (dockedEdge()) return 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), - }) + const corner = pinnedCorner() + setCoords( + corner + ? cornerCoords(corner, b) + : { + 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) + onCleanup(() => { + cancelThrow() + cancelHold() + hideTooltip() + }) createEffect(() => { const triggerComponent = settings().customTrigger @@ -272,36 +679,85 @@ export const Trigger = (props: { return ( - + )} + + + + {(corner) => ( +