From 3a870fadd0a9998df1ed244f8816d43582ee0487 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Jan 2026 10:01:59 -0500 Subject: [PATCH 1/4] feat(ui): Add drag functionality to keyless prompt --- .../devPrompts/KeylessPrompt/index.tsx | 23 +- .../KeylessPrompt/use-drag-to-corner.ts | 415 ++++++++++++++++++ 2 files changed, 432 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx index 77be3ce743f..fb0100f3861 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useState } from 'react'; import { Portal } from '../../../elements/Portal'; import { MosaicThemeProvider, useMosaicTheme } from '../../../mosaic/theme-provider'; import { handleDashboardUrlParsing } from '../shared'; +import { useDragToCorner } from './use-drag-to-corner'; import { useRevalidateEnvironment } from './use-revalidate-environment'; type KeylessPromptProps = { @@ -36,8 +37,8 @@ function KeylessPromptInternal(props: KeylessPromptProps) { const [isOpen, setIsOpen] = useState(isSignedIn || isLocked); const [hasMounted, setHasMounted] = useState(false); const id = React.useId(); - const containerRef = React.useRef(null); const theme = useMosaicTheme(); + const { isDragging, cornerStyle, containerRef, onPointerDown, preventClick, isInitialized } = useDragToCorner(); React.useEffect(() => { setHasMounted(true); @@ -116,6 +117,11 @@ function KeylessPromptInternal(props: KeylessPromptProps) {
{ + if (preventClick) { + return; + } if (!isLocked) { setIsOpen(prev => !prev); } diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts new file mode 100644 index 00000000000..3dd5ae3a0e0 --- /dev/null +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts @@ -0,0 +1,415 @@ +import type { PointerEventHandler } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +const STORAGE_KEY = 'clerk-keyless-prompt-corner'; +const CORNER_OFFSET = '1.25rem'; +const CORNER_OFFSET_PX = 20; // 1.25rem ≈ 20px +const DRAG_THRESHOLD = 5; +const VELOCITY_SAMPLE_INTERVAL_MS = 10; +const VELOCITY_HISTORY_SIZE = 5; +const INERTIA_DECELERATION_RATE = 0.999; + +interface Point { + x: number; + y: number; +} + +interface Velocity { + position: Point; + timestamp: number; +} + +interface CornerTranslation { + corner: Corner; + translation: Point; +} + +interface UseDragToCornerResult { + corner: Corner; + isDragging: boolean; + cornerStyle: React.CSSProperties; + containerRef: React.RefObject; + onPointerDown: PointerEventHandler; + preventClick: boolean; + isInitialized: boolean; +} + +const getCornerFromPosition = (x: number, y: number): Corner => { + const centerX = window.innerWidth / 2; + const centerY = window.innerHeight / 2; + + const isLeft = x < centerX; + const isTop = y < centerY; + + if (isTop && isLeft) { + return 'top-left'; + } + if (isTop && !isLeft) { + return 'top-right'; + } + if (!isTop && isLeft) { + return 'bottom-left'; + } + return 'bottom-right'; +}; + +const getCornerStyles = (corner: Corner): React.CSSProperties => { + switch (corner) { + case 'top-left': + return { top: CORNER_OFFSET, left: CORNER_OFFSET }; + case 'top-right': + return { top: CORNER_OFFSET, right: CORNER_OFFSET }; + case 'bottom-left': + return { bottom: CORNER_OFFSET, left: CORNER_OFFSET }; + case 'bottom-right': + return { bottom: CORNER_OFFSET, right: CORNER_OFFSET }; + } +}; + +const loadCornerPreference = (): Corner => { + if (typeof window === 'undefined') { + return 'bottom-right'; + } + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(stored)) { + return stored as Corner; + } + } catch { + // Ignore localStorage errors + } + return 'bottom-right'; +}; + +const saveCornerPreference = (corner: Corner): void => { + if (typeof window === 'undefined') { + return; + } + try { + localStorage.setItem(STORAGE_KEY, corner); + } catch { + // Ignore localStorage errors + } +}; + +const project = (initialVelocity: number): number => { + return ((initialVelocity / 1000) * INERTIA_DECELERATION_RATE) / (1 - INERTIA_DECELERATION_RATE); +}; + +const calculateVelocity = (history: Velocity[]): Point => { + if (history.length < 2) { + return { x: 0, y: 0 }; + } + + const oldestPoint = history[0]; + const latestPoint = history[history.length - 1]; + const timeDelta = latestPoint.timestamp - oldestPoint.timestamp; + + if (timeDelta === 0) { + return { x: 0, y: 0 }; + } + + // Calculate pixels per millisecond + const velocityX = (latestPoint.position.x - oldestPoint.position.x) / timeDelta; + const velocityY = (latestPoint.position.y - oldestPoint.position.y) / timeDelta; + + // Convert to pixels per second for more intuitive values + return { x: velocityX * 1000, y: velocityY * 1000 }; +}; + +export const useDragToCorner = (): UseDragToCornerResult => { + // Initialize with deterministic server-safe value to avoid SSR/hydration mismatch + const [corner, setCorner] = useState('bottom-right'); + const [isDragging, setIsDragging] = useState(false); + const [preventClick, setPreventClick] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + const pendingCornerUpdate = useRef(null); + + // Defer localStorage read to client-side only after mount + useEffect(() => { + if (typeof window === 'undefined') { + setIsInitialized(true); + return; + } + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(stored)) { + const storedCorner = stored as Corner; + // Set corner before making visible to prevent flash + setCorner(storedCorner); + } + } catch { + // Ignore localStorage errors + } finally { + // Mark as initialized after reading localStorage (or if it fails) + setIsInitialized(true); + } + }, []); + + const containerRef = useRef(null); + const machine = useRef<{ state: 'idle' | 'press' | 'animating' } | { state: 'drag'; pointerId: number }>({ + state: 'idle', + }); + + const cleanup = useRef<(() => void) | null>(null); + const origin = useRef({ x: 0, y: 0 }); + const translation = useRef({ x: 0, y: 0 }); + const lastTimestamp = useRef(0); + const velocities = useRef([]); + + const set = useCallback((position: Point) => { + if (containerRef.current) { + translation.current = position; + containerRef.current.style.translate = `${position.x}px ${position.y}px`; + } + }, []); + + const getCorners = useCallback((): Record => { + const container = containerRef.current; + if (!container) { + return { + 'top-left': { x: 0, y: 0 }, + 'top-right': { x: 0, y: 0 }, + 'bottom-left': { x: 0, y: 0 }, + 'bottom-right': { x: 0, y: 0 }, + }; + } + + const offset = CORNER_OFFSET_PX; + const triggerWidth = container.offsetWidth || 0; + const triggerHeight = container.offsetHeight || 0; + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + + const getAbsolutePosition = (corner: Corner): Point => { + const isRight = corner.includes('right'); + const isBottom = corner.includes('bottom'); + + const x = isRight ? window.innerWidth - scrollbarWidth - offset - triggerWidth : offset; + const y = isBottom ? window.innerHeight - offset - triggerHeight : offset; + + return { x, y }; + }; + + const basePosition = getAbsolutePosition(corner); + + const rel = (pos: Point): Point => { + return { x: pos.x - basePosition.x, y: pos.y - basePosition.y }; + }; + + return { + 'top-left': rel(getAbsolutePosition('top-left')), + 'top-right': rel(getAbsolutePosition('top-right')), + 'bottom-left': rel(getAbsolutePosition('bottom-left')), + 'bottom-right': rel(getAbsolutePosition('bottom-right')), + }; + }, [corner]); + + const animate = useCallback( + (cornerTranslation: CornerTranslation) => { + const el = containerRef.current; + if (!el) { + return; + } + + const handleAnimationEnd = (e: TransitionEvent) => { + if (e.propertyName === 'translate') { + machine.current = { state: 'animating' }; + + // Mark that we're waiting for corner update, then update corner state + // The useLayoutEffect will reset translate once cornerStyle has been applied + pendingCornerUpdate.current = cornerTranslation.corner; + setCorner(cornerTranslation.corner); + saveCornerPreference(cornerTranslation.corner); + + el.removeEventListener('transitionend', handleAnimationEnd); + } + }; + + el.style.transition = 'translate 300ms cubic-bezier(0.2, 0, 0.2, 1)'; + el.addEventListener('transitionend', handleAnimationEnd); + set(cornerTranslation.translation); + }, + [set], + ); + + const cancel = useCallback(() => { + if (machine.current.state === 'drag') { + containerRef.current?.releasePointerCapture(machine.current.pointerId); + } + machine.current = machine.current.state === 'drag' ? { state: 'animating' } : { state: 'idle' }; + + if (cleanup.current !== null) { + cleanup.current(); + cleanup.current = null; + } + + velocities.current = []; + setIsDragging(false); + containerRef.current?.classList.remove('dev-tools-grabbing'); + document.body.style.removeProperty('user-select'); + document.body.style.removeProperty('-webkit-user-select'); + }, []); + + // Reset translate after corner state has updated and cornerStyle has been applied + useLayoutEffect(() => { + if (pendingCornerUpdate.current !== null && pendingCornerUpdate.current === corner) { + const el = containerRef.current; + if (el && machine.current.state === 'animating') { + translation.current = { x: 0, y: 0 }; + el.style.transition = ''; + el.style.translate = '0px 0px'; + machine.current = { state: 'idle' }; + setPreventClick(false); + pendingCornerUpdate.current = null; + } + } + }, [corner]); + + useLayoutEffect(() => { + return () => { + cancel(); + }; + }, [cancel]); + + const handlePointerDown: PointerEventHandler = useCallback( + e => { + const target = e.target as HTMLElement; + if (target.tagName === 'A' || target.closest('a')) { + return; + } + + if (e.button !== 0) { + return; // ignore right click + } + + const container = containerRef.current; + if (!container) { + return; + } + + origin.current = { x: e.clientX, y: e.clientY }; + machine.current = { state: 'press' }; + velocities.current = []; + translation.current = { x: 0, y: 0 }; + lastTimestamp.current = Date.now(); + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (machine.current.state === 'press') { + const dx = moveEvent.clientX - origin.current.x; + const dy = moveEvent.clientY - origin.current.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance >= DRAG_THRESHOLD) { + machine.current = { state: 'drag', pointerId: moveEvent.pointerId }; + container.setPointerCapture(moveEvent.pointerId); + container.classList.add('dev-tools-grabbing'); + document.body.style.userSelect = 'none'; + document.body.style.webkitUserSelect = 'none'; + setIsDragging(true); + } else { + return; + } + } + + if (machine.current.state !== 'drag') { + return; + } + + const currentPosition = { x: moveEvent.clientX, y: moveEvent.clientY }; + const dx = currentPosition.x - origin.current.x; + const dy = currentPosition.y - origin.current.y; + + origin.current = currentPosition; + + const newTranslation = { + x: translation.current.x + dx, + y: translation.current.y + dy, + }; + + set(newTranslation); + + // Keep a history of recent positions for velocity calculation + const now = Date.now(); + const shouldAddToHistory = now - lastTimestamp.current >= VELOCITY_SAMPLE_INTERVAL_MS; + + if (shouldAddToHistory) { + velocities.current = [ + ...velocities.current.slice(-VELOCITY_HISTORY_SIZE + 1), + { position: currentPosition, timestamp: now }, + ]; + lastTimestamp.current = now; + } + }; + + const handlePointerUp = () => { + const wasDragging = machine.current.state === 'drag'; + const velocity = calculateVelocity(velocities.current); + cancel(); + + if (wasDragging) { + const container = containerRef.current; + if (!container) { + return; + } + + const rect = container.getBoundingClientRect(); + const currentAbsoluteX = rect.left; + const currentAbsoluteY = rect.top; + + // Project final position with inertia + const projectedX = currentAbsoluteX + project(velocity.x); + const projectedY = currentAbsoluteY + project(velocity.y); + + // Determine target corner based on projected position + const newCorner = getCornerFromPosition(projectedX, projectedY); + + // Get all corner translations relative to current corner + const allCorners = getCorners(); + + // The translation to animate to is the difference between the new corner's position + // and the current translation + const targetTranslation = allCorners[newCorner]; + + setPreventClick(true); + animate({ corner: newCorner, translation: targetTranslation }); + } + }; + + const handleClick = (clickEvent: MouseEvent) => { + if (machine.current.state === 'animating') { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + machine.current = { state: 'idle' }; + container.removeEventListener('click', handleClick); + } + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp, { once: true }); + container.addEventListener('click', handleClick); + + if (cleanup.current !== null) { + cleanup.current(); + } + + cleanup.current = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + container.removeEventListener('click', handleClick); + }; + }, + [cancel, set, animate, getCorners], + ); + + return { + corner, + isDragging, + cornerStyle: getCornerStyles(corner), + containerRef, + onPointerDown: handlePointerDown, + preventClick, + isInitialized, + }; +}; From eed45a8bb75491478a230ca6a4c67b4d39809a3c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Jan 2026 10:43:10 -0500 Subject: [PATCH 2/4] fix --- .../KeylessPrompt/use-drag-to-corner.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts index 3dd5ae3a0e0..a3ec465ece1 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts @@ -238,6 +238,7 @@ export const useDragToCorner = (): UseDragToCornerResult => { if (machine.current.state === 'drag') { containerRef.current?.releasePointerCapture(machine.current.pointerId); } + const wasDragging = machine.current.state === 'drag'; machine.current = machine.current.state === 'drag' ? { state: 'animating' } : { state: 'idle' }; if (cleanup.current !== null) { @@ -250,6 +251,14 @@ export const useDragToCorner = (): UseDragToCornerResult => { containerRef.current?.classList.remove('dev-tools-grabbing'); document.body.style.removeProperty('user-select'); document.body.style.removeProperty('-webkit-user-select'); + + // If we weren't dragging, reset any translation that might have been applied + if (!wasDragging && containerRef.current) { + translation.current = { x: 0, y: 0 }; + containerRef.current.style.translate = '0px 0px'; + containerRef.current.style.transition = ''; + setPreventClick(false); + } }, []); // Reset translate after corner state has updated and cornerStyle has been applied @@ -345,10 +354,11 @@ export const useDragToCorner = (): UseDragToCornerResult => { const handlePointerUp = () => { const wasDragging = machine.current.state === 'drag'; - const velocity = calculateVelocity(velocities.current); - cancel(); if (wasDragging) { + const velocity = calculateVelocity(velocities.current); + cancel(); + const container = containerRef.current; if (!container) { return; @@ -374,15 +384,25 @@ export const useDragToCorner = (): UseDragToCornerResult => { setPreventClick(true); animate({ corner: newCorner, translation: targetTranslation }); + } else { + // If we weren't dragging, just cancel to clean up state + // Make sure preventClick is false so button clicks work + cancel(); + // Double-check preventClick is reset (in case cancel() didn't handle it) + setPreventClick(false); } }; const handleClick = (clickEvent: MouseEvent) => { - if (machine.current.state === 'animating') { + // Only prevent clicks if we're animating to a new corner + // Never prevent clicks on buttons or links - let them handle their own clicks + const target = clickEvent.target as HTMLElement; + const isButton = target.tagName === 'BUTTON' || target.closest('button'); + const isLink = target.tagName === 'A' || target.closest('a'); + + if (machine.current.state === 'animating' && !isButton && !isLink) { clickEvent.preventDefault(); clickEvent.stopPropagation(); - machine.current = { state: 'idle' }; - container.removeEventListener('click', handleClick); } }; From 4a4927c7b075208157a1bb1eba656725a660f8fb Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Jan 2026 11:15:13 -0500 Subject: [PATCH 3/4] wip --- .../devPrompts/KeylessPrompt/index.tsx | 4 +- .../KeylessPrompt/use-drag-to-corner.ts | 256 +++++++++--------- 2 files changed, 128 insertions(+), 132 deletions(-) diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx index fb0100f3861..308e53b2294 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx @@ -117,7 +117,7 @@ function KeylessPromptInternal(props: KeylessPromptProps) {
{ - const centerX = window.innerWidth / 2; - const centerY = window.innerHeight / 2; - - const isLeft = x < centerX; - const isTop = y < centerY; - - if (isTop && isLeft) { - return 'top-left'; - } - if (isTop && !isLeft) { - return 'top-right'; - } - if (!isTop && isLeft) { - return 'bottom-left'; - } - return 'bottom-right'; -}; +function getCornerFromPosition(x: number, y: number): Corner { + const vertical = y < window.innerHeight / 2 ? 'top' : 'bottom'; + const horizontal = x < window.innerWidth / 2 ? 'left' : 'right'; + return `${vertical}-${horizontal}` as Corner; +} -const getCornerStyles = (corner: Corner): React.CSSProperties => { +function getCornerStyles(corner: Corner): React.CSSProperties { switch (corner) { case 'top-left': return { top: CORNER_OFFSET, left: CORNER_OFFSET }; @@ -66,24 +58,11 @@ const getCornerStyles = (corner: Corner): React.CSSProperties => { case 'bottom-right': return { bottom: CORNER_OFFSET, right: CORNER_OFFSET }; } -}; +} -const loadCornerPreference = (): Corner => { - if (typeof window === 'undefined') { - return 'bottom-right'; - } - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored && ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(stored)) { - return stored as Corner; - } - } catch { - // Ignore localStorage errors - } - return 'bottom-right'; -}; +const VALID_CORNERS: Corner[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; -const saveCornerPreference = (corner: Corner): void => { +function saveCornerPreference(corner: Corner): void { if (typeof window === 'undefined') { return; } @@ -92,34 +71,32 @@ const saveCornerPreference = (corner: Corner): void => { } catch { // Ignore localStorage errors } -}; +} -const project = (initialVelocity: number): number => { +function project(initialVelocity: number): number { return ((initialVelocity / 1000) * INERTIA_DECELERATION_RATE) / (1 - INERTIA_DECELERATION_RATE); -}; +} -const calculateVelocity = (history: Velocity[]): Point => { +function calculateVelocity(history: Velocity[]): Point { if (history.length < 2) { - return { x: 0, y: 0 }; + return ZERO_POINT; } - const oldestPoint = history[0]; - const latestPoint = history[history.length - 1]; - const timeDelta = latestPoint.timestamp - oldestPoint.timestamp; + const oldest = history[0]; + const latest = history[history.length - 1]; + const timeDelta = latest.timestamp - oldest.timestamp; if (timeDelta === 0) { - return { x: 0, y: 0 }; + return ZERO_POINT; } - // Calculate pixels per millisecond - const velocityX = (latestPoint.position.x - oldestPoint.position.x) / timeDelta; - const velocityY = (latestPoint.position.y - oldestPoint.position.y) / timeDelta; - - // Convert to pixels per second for more intuitive values - return { x: velocityX * 1000, y: velocityY * 1000 }; -}; + return { + x: ((latest.position.x - oldest.position.x) / timeDelta) * 1000, + y: ((latest.position.y - oldest.position.y) / timeDelta) * 1000, + }; +} -export const useDragToCorner = (): UseDragToCornerResult => { +export function useDragToCorner(): UseDragToCornerResult { // Initialize with deterministic server-safe value to avoid SSR/hydration mismatch const [corner, setCorner] = useState('bottom-right'); const [isDragging, setIsDragging] = useState(false); @@ -135,15 +112,12 @@ export const useDragToCorner = (): UseDragToCornerResult => { } try { const stored = localStorage.getItem(STORAGE_KEY); - if (stored && ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(stored)) { - const storedCorner = stored as Corner; - // Set corner before making visible to prevent flash - setCorner(storedCorner); + if (stored && VALID_CORNERS.includes(stored as Corner)) { + setCorner(stored as Corner); } } catch { // Ignore localStorage errors } finally { - // Mark as initialized after reading localStorage (or if it fails) setIsInitialized(true); } }, []); @@ -159,50 +133,50 @@ export const useDragToCorner = (): UseDragToCornerResult => { const lastTimestamp = useRef(0); const velocities = useRef([]); - const set = useCallback((position: Point) => { - if (containerRef.current) { - translation.current = position; - containerRef.current.style.translate = `${position.x}px ${position.y}px`; + const setTranslation = useCallback((position: Point) => { + if (!containerRef.current) { + return; } + translation.current = position; + containerRef.current.style.transform = `translate3d(${position.x}px, ${position.y}px, 0)`; }, []); const getCorners = useCallback((): Record => { const container = containerRef.current; if (!container) { return { - 'top-left': { x: 0, y: 0 }, - 'top-right': { x: 0, y: 0 }, - 'bottom-left': { x: 0, y: 0 }, - 'bottom-right': { x: 0, y: 0 }, + 'top-left': ZERO_POINT, + 'top-right': ZERO_POINT, + 'bottom-left': ZERO_POINT, + 'bottom-right': ZERO_POINT, }; } - const offset = CORNER_OFFSET_PX; const triggerWidth = container.offsetWidth || 0; const triggerHeight = container.offsetHeight || 0; const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - const getAbsolutePosition = (corner: Corner): Point => { - const isRight = corner.includes('right'); - const isBottom = corner.includes('bottom'); - - const x = isRight ? window.innerWidth - scrollbarWidth - offset - triggerWidth : offset; - const y = isBottom ? window.innerHeight - offset - triggerHeight : offset; - - return { x, y }; - }; + function getAbsolutePosition(c: Corner): Point { + const isRight = c.includes('right'); + const isBottom = c.includes('bottom'); + return { + x: isRight ? window.innerWidth - scrollbarWidth - CORNER_OFFSET_PX - triggerWidth : CORNER_OFFSET_PX, + y: isBottom ? window.innerHeight - CORNER_OFFSET_PX - triggerHeight : CORNER_OFFSET_PX, + }; + } - const basePosition = getAbsolutePosition(corner); + const base = getAbsolutePosition(corner); - const rel = (pos: Point): Point => { - return { x: pos.x - basePosition.x, y: pos.y - basePosition.y }; - }; + function toRelative(c: Corner): Point { + const pos = getAbsolutePosition(c); + return { x: pos.x - base.x, y: pos.y - base.y }; + } return { - 'top-left': rel(getAbsolutePosition('top-left')), - 'top-right': rel(getAbsolutePosition('top-right')), - 'bottom-left': rel(getAbsolutePosition('bottom-left')), - 'bottom-right': rel(getAbsolutePosition('bottom-right')), + 'top-left': toRelative('top-left'), + 'top-right': toRelative('top-right'), + 'bottom-left': toRelative('bottom-left'), + 'bottom-right': toRelative('bottom-right'), }; }, [corner]); @@ -214,7 +188,7 @@ export const useDragToCorner = (): UseDragToCornerResult => { } const handleAnimationEnd = (e: TransitionEvent) => { - if (e.propertyName === 'translate') { + if (e.propertyName === 'transform') { machine.current = { state: 'animating' }; // Mark that we're waiting for corner update, then update corner state @@ -222,26 +196,57 @@ export const useDragToCorner = (): UseDragToCornerResult => { pendingCornerUpdate.current = cornerTranslation.corner; setCorner(cornerTranslation.corner); saveCornerPreference(cornerTranslation.corner); - - el.removeEventListener('transitionend', handleAnimationEnd); } }; - el.style.transition = 'translate 300ms cubic-bezier(0.2, 0, 0.2, 1)'; - el.addEventListener('transitionend', handleAnimationEnd); - set(cornerTranslation.translation); + el.style.transition = SPRING_TRANSITION; + el.addEventListener('transitionend', handleAnimationEnd, { once: true }); + setTranslation(cornerTranslation.translation); }, - [set], + [setTranslation], ); + const resetTranslation = useCallback(() => { + const el = containerRef.current; + if (!el) { + return; + } + + const hasTranslation = translation.current.x !== 0 || translation.current.y !== 0; + + if (hasTranslation) { + el.style.transition = SPRING_TRANSITION; + el.addEventListener( + 'transitionend', + () => { + translation.current = ZERO_POINT; + el.style.transition = ''; + el.style.transform = ZERO_TRANSFORM; + setPreventClick(false); + }, + { once: true }, + ); + el.style.transform = ZERO_TRANSFORM; + } else { + translation.current = ZERO_POINT; + el.style.transform = ZERO_TRANSFORM; + el.style.transition = ''; + setPreventClick(false); + } + }, []); + const cancel = useCallback(() => { - if (machine.current.state === 'drag') { + const currentState = machine.current.state; + const wasDragging = currentState === 'drag'; + + if (currentState === 'drag') { containerRef.current?.releasePointerCapture(machine.current.pointerId); + machine.current = { state: 'animating' }; + } else { + machine.current = { state: 'idle' }; } - const wasDragging = machine.current.state === 'drag'; - machine.current = machine.current.state === 'drag' ? { state: 'animating' } : { state: 'idle' }; - if (cleanup.current !== null) { + if (cleanup.current) { cleanup.current(); cleanup.current = null; } @@ -252,23 +257,19 @@ export const useDragToCorner = (): UseDragToCornerResult => { document.body.style.removeProperty('user-select'); document.body.style.removeProperty('-webkit-user-select'); - // If we weren't dragging, reset any translation that might have been applied - if (!wasDragging && containerRef.current) { - translation.current = { x: 0, y: 0 }; - containerRef.current.style.translate = '0px 0px'; - containerRef.current.style.transition = ''; - setPreventClick(false); + if (!wasDragging) { + resetTranslation(); } - }, []); + }, [resetTranslation]); // Reset translate after corner state has updated and cornerStyle has been applied useLayoutEffect(() => { - if (pendingCornerUpdate.current !== null && pendingCornerUpdate.current === corner) { + if (pendingCornerUpdate.current === corner) { const el = containerRef.current; if (el && machine.current.state === 'animating') { - translation.current = { x: 0, y: 0 }; + translation.current = ZERO_POINT; el.style.transition = ''; - el.style.translate = '0px 0px'; + el.style.transform = ZERO_TRANSFORM; machine.current = { state: 'idle' }; setPreventClick(false); pendingCornerUpdate.current = null; @@ -301,7 +302,7 @@ export const useDragToCorner = (): UseDragToCornerResult => { origin.current = { x: e.clientX, y: e.clientY }; machine.current = { state: 'press' }; velocities.current = []; - translation.current = { x: 0, y: 0 }; + translation.current = ZERO_POINT; lastTimestamp.current = Date.now(); const handlePointerMove = (moveEvent: PointerEvent) => { @@ -310,16 +311,20 @@ export const useDragToCorner = (): UseDragToCornerResult => { const dy = moveEvent.clientY - origin.current.y; const distance = Math.sqrt(dx * dx + dy * dy); - if (distance >= DRAG_THRESHOLD) { - machine.current = { state: 'drag', pointerId: moveEvent.pointerId }; - container.setPointerCapture(moveEvent.pointerId); - container.classList.add('dev-tools-grabbing'); - document.body.style.userSelect = 'none'; - document.body.style.webkitUserSelect = 'none'; - setIsDragging(true); - } else { + if (distance < DRAG_THRESHOLD) { return; } + + machine.current = { state: 'drag', pointerId: moveEvent.pointerId }; + try { + container.setPointerCapture(moveEvent.pointerId); + } catch { + // Pointer capture may fail on some browsers/devices - drag still works without it + } + container.classList.add('dev-tools-grabbing'); + document.body.style.userSelect = 'none'; + document.body.style.webkitUserSelect = 'none'; + setIsDragging(true); } if (machine.current.state !== 'drag') { @@ -332,18 +337,13 @@ export const useDragToCorner = (): UseDragToCornerResult => { origin.current = currentPosition; - const newTranslation = { + setTranslation({ x: translation.current.x + dx, y: translation.current.y + dy, - }; - - set(newTranslation); + }); - // Keep a history of recent positions for velocity calculation const now = Date.now(); - const shouldAddToHistory = now - lastTimestamp.current >= VELOCITY_SAMPLE_INTERVAL_MS; - - if (shouldAddToHistory) { + if (now - lastTimestamp.current >= VELOCITY_SAMPLE_INTERVAL_MS) { velocities.current = [ ...velocities.current.slice(-VELOCITY_HISTORY_SIZE + 1), { position: currentPosition, timestamp: now }, @@ -385,17 +385,11 @@ export const useDragToCorner = (): UseDragToCornerResult => { setPreventClick(true); animate({ corner: newCorner, translation: targetTranslation }); } else { - // If we weren't dragging, just cancel to clean up state - // Make sure preventClick is false so button clicks work cancel(); - // Double-check preventClick is reset (in case cancel() didn't handle it) - setPreventClick(false); } }; const handleClick = (clickEvent: MouseEvent) => { - // Only prevent clicks if we're animating to a new corner - // Never prevent clicks on buttons or links - let them handle their own clicks const target = clickEvent.target as HTMLElement; const isButton = target.tagName === 'BUTTON' || target.closest('button'); const isLink = target.tagName === 'A' || target.closest('a'); @@ -408,19 +402,21 @@ export const useDragToCorner = (): UseDragToCornerResult => { window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp, { once: true }); + window.addEventListener('pointercancel', cancel, { once: true }); container.addEventListener('click', handleClick); - if (cleanup.current !== null) { + if (cleanup.current) { cleanup.current(); } cleanup.current = () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', cancel); container.removeEventListener('click', handleClick); }; }, - [cancel, set, animate, getCorners], + [cancel, setTranslation, animate, getCorners], ); return { @@ -432,4 +428,4 @@ export const useDragToCorner = (): UseDragToCornerResult => { preventClick, isInitialized, }; -}; +} From 42189d9e4020bbdc7490ec98fd0fbee7e2767d19 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Jan 2026 11:18:53 -0500 Subject: [PATCH 4/4] wip --- .../KeylessPrompt/use-drag-to-corner.ts | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts index dc8b20991c8..4061e0d89d2 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts @@ -41,10 +41,21 @@ interface UseDragToCornerResult { isInitialized: boolean; } -function getCornerFromPosition(x: number, y: number): Corner { - const vertical = y < window.innerHeight / 2 ? 'top' : 'bottom'; - const horizontal = x < window.innerWidth / 2 ? 'left' : 'right'; - return `${vertical}-${horizontal}` as Corner; +function getNearestCorner(projectedTranslation: Point, corners: Record): Corner { + let nearestCorner: Corner = 'bottom-right'; + let minDistance = Infinity; + + for (const [corner, translation] of Object.entries(corners)) { + const dx = projectedTranslation.x - translation.x; + const dy = projectedTranslation.y - translation.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance < minDistance) { + minDistance = distance; + nearestCorner = corner as Corner; + } + } + + return nearestCorner; } function getCornerStyles(corner: Corner): React.CSSProperties { @@ -190,9 +201,6 @@ export function useDragToCorner(): UseDragToCornerResult { const handleAnimationEnd = (e: TransitionEvent) => { if (e.propertyName === 'transform') { machine.current = { state: 'animating' }; - - // Mark that we're waiting for corner update, then update corner state - // The useLayoutEffect will reset translate once cornerStyle has been applied pendingCornerUpdate.current = cornerTranslation.corner; setCorner(cornerTranslation.corner); saveCornerPreference(cornerTranslation.corner); @@ -213,23 +221,20 @@ export function useDragToCorner(): UseDragToCornerResult { } const hasTranslation = translation.current.x !== 0 || translation.current.y !== 0; + translation.current = ZERO_POINT; + el.style.transform = ZERO_TRANSFORM; if (hasTranslation) { el.style.transition = SPRING_TRANSITION; el.addEventListener( 'transitionend', () => { - translation.current = ZERO_POINT; el.style.transition = ''; - el.style.transform = ZERO_TRANSFORM; setPreventClick(false); }, { once: true }, ); - el.style.transform = ZERO_TRANSFORM; } else { - translation.current = ZERO_POINT; - el.style.transform = ZERO_TRANSFORM; el.style.transition = ''; setPreventClick(false); } @@ -239,7 +244,7 @@ export function useDragToCorner(): UseDragToCornerResult { const currentState = machine.current.state; const wasDragging = currentState === 'drag'; - if (currentState === 'drag') { + if (machine.current.state === 'drag') { containerRef.current?.releasePointerCapture(machine.current.pointerId); machine.current = { state: 'animating' }; } else { @@ -262,7 +267,6 @@ export function useDragToCorner(): UseDragToCornerResult { } }, [resetTranslation]); - // Reset translate after corner state has updated and cornerStyle has been applied useLayoutEffect(() => { if (pendingCornerUpdate.current === corner) { const el = containerRef.current; @@ -291,7 +295,7 @@ export function useDragToCorner(): UseDragToCornerResult { } if (e.button !== 0) { - return; // ignore right click + return; } const container = containerRef.current; @@ -319,7 +323,7 @@ export function useDragToCorner(): UseDragToCornerResult { try { container.setPointerCapture(moveEvent.pointerId); } catch { - // Pointer capture may fail on some browsers/devices - drag still works without it + // Pointer capture may fail - drag still works without it } container.classList.add('dev-tools-grabbing'); document.body.style.userSelect = 'none'; @@ -364,22 +368,13 @@ export function useDragToCorner(): UseDragToCornerResult { return; } - const rect = container.getBoundingClientRect(); - const currentAbsoluteX = rect.left; - const currentAbsoluteY = rect.top; - - // Project final position with inertia - const projectedX = currentAbsoluteX + project(velocity.x); - const projectedY = currentAbsoluteY + project(velocity.y); + const projectedTranslation = { + x: translation.current.x + project(velocity.x), + y: translation.current.y + project(velocity.y), + }; - // Determine target corner based on projected position - const newCorner = getCornerFromPosition(projectedX, projectedY); - - // Get all corner translations relative to current corner const allCorners = getCorners(); - - // The translation to animate to is the difference between the new corner's position - // and the current translation + const newCorner = getNearestCorner(projectedTranslation, allCorners); const targetTranslation = allCorners[newCorner]; setPreventClick(true);