From 519bea61dc7c53bcb97c44a14a0a084fb49547b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Krakowski?= <48633090+bart-krakowski@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:55:59 +0200 Subject: [PATCH] feat: hot corners --- .changeset/trigger-edge-dock.md | 5 + docs/configuration.md | 2 +- .../src/components/tanstack-trigger-mark.tsx | 8 +- .../devtools/src/components/trigger.test.tsx | 401 ++++++++++++++++- packages/devtools/src/components/trigger.tsx | 410 +++++++++++++++--- .../devtools/src/context/devtools-store.ts | 26 +- packages/devtools/src/styles/use-styles.ts | 105 ++++- packages/devtools/src/utils/constants.ts | 17 + 8 files changed, 907 insertions(+), 67 deletions(-) create mode 100644 .changeset/trigger-edge-dock.md 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..62bc0a957 100644 --- a/packages/devtools/src/components/tanstack-trigger-mark.tsx +++ b/packages/devtools/src/components/tanstack-trigger-mark.tsx @@ -3,10 +3,10 @@ import { createUniqueId } from 'solid-js' // The rainbow favicon paints its own colours so it stays readable on any host // page. These literals are the official dark-favicon stops, not theme tokens. const PALM_INK = '#171717' // semantic-color-exempt: trigger-rainbow-mark -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 +export const RAINBOW_STOP_0 = '#FF5F5F' // semantic-color-exempt: trigger-rainbow-mark +export const RAINBOW_STOP_1 = '#FFA05C' // semantic-color-exempt: trigger-rainbow-mark +export const RAINBOW_STOP_2 = '#FFF27C' // semantic-color-exempt: trigger-rainbow-mark +export const RAINBOW_STOP_3 = '#74DCFF' // 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..cc9124033 100644 --- a/packages/devtools/src/components/trigger.test.tsx +++ b/packages/devtools/src/components/trigger.test.tsx @@ -1,8 +1,16 @@ -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, + offScreenEdge, + stepAxis, +} from './trigger' import type { TanStackDevtoolsConfig } from '../context/devtools-context' const renderTrigger = (config?: Partial) => { @@ -29,8 +37,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 +70,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 +101,384 @@ 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('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('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', 2000, 2000) + drag(button, 'pointerup', 2000, 2000) + 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', 2000, 2000) + drag(button, 'pointerup', 2000, 2000) + + 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('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..1617185ed 100644 --- a/packages/devtools/src/components/trigger.tsx +++ b/packages/devtools/src/components/trigger.tsx @@ -9,8 +9,18 @@ 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, +} 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) --- @@ -20,9 +30,78 @@ 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 +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), +}) + +/** + * 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 +134,21 @@ 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 styles = createStyles() const isFloating = createMemo(() => settings().triggerMode === 'floating') + const docked = createMemo(() => isFloating() && dockedEdge() !== null) + const shownEdge = createMemo(() => + isFloating() ? (dockedEdge() ?? hoverEdge()) : null, + ) const buttonStyle = createMemo(() => { return clsx( @@ -81,7 +172,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 +196,10 @@ 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 const cancelThrow = () => { if (raf !== undefined) { @@ -113,7 +208,106 @@ export const Trigger = (props: { } } - const persist = () => setSettings({ triggerCoords: coords() ?? undefined }) + 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` } + } + + 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() + snoozedCorner = null + setHoverEdge(null) + releaseCapture() + setHotCorner(null) + setPinnedCorner(startPinnedCorner) + setCoords({ x: startPosX, y: startPosY }) + persist() + return true + } + if (raf !== undefined) { + cancelThrow() + setHotCorner(null) + persist() + return true + } + return false + } const startThrow = () => { cancelThrow() @@ -129,12 +323,16 @@ 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(cornerAt(next, b)) if (Math.hypot(vx, vy) > MIN_SPEED) { raf = requestAnimationFrame(tick) } else { raf = undefined - persist() + const corner = hotCorner() + if (corner) pinTo(corner, el) + else persist() } } raf = requestAnimationFrame(tick) @@ -146,8 +344,15 @@ export const Trigger = (props: { const current = coords() if (!el || !current) return cancelThrow() + cancelHold() + snoozedCorner = null + setHoverEdge(null) + startPinnedCorner = pinnedCorner() + setPinnedCorner(null) + setHotCorner(null) dragging = true moved = false + activePointer = e.pointerId el.setPointerCapture(e.pointerId) startX = e.clientX startY = e.clientY @@ -169,11 +374,27 @@ export const Trigger = (props: { 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) + setHoverEdge(moved ? edgeOffScreen(next, el) : null) + const corner = moved ? 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,15 +409,40 @@ export const Trigger = (props: { const endDrag = (e: PointerEvent, canThrow: boolean) => { if (!dragging) return dragging = false + cancelHold() + 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 } + // A corner wins over both hiding and throwing: the mark promised it would + // stick there. To hide at a corner, hold to snooze the corner first. + const corner = hotCorner() + if (corner && el) { + pinTo(corner, el) + return + } + setHotCorner(null) + const current = coords() + if (el && current) { + const edge = edgeOffScreen(current, el) + if (moved && edge) { + hideToEdge(edge) + return + } + // 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 { @@ -217,15 +463,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 +493,43 @@ export const Trigger = (props: { }) }) + createEffect(() => { + if (!isFloating()) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && cancelGesture()) 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() + }) createEffect(() => { const triggerComponent = settings().customTrigger @@ -272,36 +543,73 @@ export const Trigger = (props: { return ( - + )} + + + + {(corner) => ( +