From 52fa434f593342d3355769d632b5f5f697e8960d Mon Sep 17 00:00:00 2001 From: Muthu Kumar Date: Wed, 9 Apr 2025 20:37:54 +0530 Subject: [PATCH] fix: attempt to fix problems with DraggableButton --- src/components/DraggableButton.tsx | 254 ++++++++++++++++--------------------- 1 file changed, 109 insertions(+), 145 deletions(-) diff --git a/src/components/DraggableButton.tsx b/src/components/DraggableButton.tsx index a3e0b4c..6b357ed 100644 --- a/src/components/DraggableButton.tsx +++ b/src/components/DraggableButton.tsx @@ -1,5 +1,6 @@ import { css, cx } from "@emotion/css"; import React, { useEffect, useRef, useState } from "react"; +import { composeRefs } from "../util"; const isOutsideViewport = (el: HTMLElement) => { const rect = el.getBoundingClientRect(); @@ -20,6 +21,9 @@ export interface DraggableButtonProps interface Pos { x: number; y: number; + rot: number; + touchOffsetX?: number; + touchOffsetY?: number; } interface Velocity extends Pos { @@ -33,194 +37,138 @@ const relativePos = (pos: Pos, container: DOMRect) => { }; }; +const getEventPos = (e: MouseEvent | TouchEvent): Pos => { + if ("touches" in e) + return { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + rot: 0, + }; + + return { + x: e.clientX, + y: e.clientY, + rot: 0, + }; +}; + export const DraggableButton = React.forwardRef< HTMLButtonElement, DraggableButtonProps >(({ children, onOutsideViewport, ...props }, ref) => { - const [position, setPosition] = useState({ x: 0, y: 0 }); - const [rotation, setRotation] = useState(0); + const [transform, setTransform] = useState({ x: 0, y: 0, rot: 0 }); const [isDragging, setIsDragging] = useState(false); - const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const [isInitialized, setIsInitialized] = useState(false); + const [dragStart, setDragStart] = useState(null); const myRef = useRef(null); const containerRef = useRef(null); - const lastVelocity = useRef({ x: 0, y: 0, timestamp: 0 }); - const lastPosition = useRef({ x: 0, y: 0 }); + const lastVelocity = useRef({ x: 0, y: 0, rot: 0, timestamp: 0 }); + const lastPosition = useRef({ x: 0, y: 0, rot: 0 }); const animationFrame = useRef(); - const [isOutside, setIsOutside] = useState(false); useEffect(() => { if (isOutside) onOutsideViewport?.(); - }, [isOutside !== true]); - - // Capture initial rotation on mount - useEffect(() => { - const el = myRef.current; - if (!el || isInitialized) return; - - // Extract initial rotation from transform style - const rotate = window.getComputedStyle(el).rotate; - setRotation(parseFloat(rotate)); - - const rect = el.getBoundingClientRect(); - const parentRect = el.parentElement?.getBoundingClientRect() ?? rect; - - // Convert current position to top/left - const top = rect.top - parentRect.top; - const left = rect.left - parentRect.left; + }, [isOutside, onOutsideViewport]); - // Set position and clear any bottom/right values - el.style.position = "absolute"; - el.style.transition = "none"; - - setPosition({ x: left, y: top }); - setIsInitialized(true); - }, []); - - useEffect(() => { - const el = myRef.current; - if (!el || !isInitialized) return; - - containerRef.current = el.parentElement?.getBoundingClientRect() ?? null; - - // Remove the transition property entirely - el.style.left = `${position.x}px`; - el.style.top = `${position.y}px`; - el.style.rotate = `${rotation}deg`; - - if (!isDragging && myRef.current && isOutsideViewport(myRef.current!)) - setIsOutside(true); - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") setIsDragging(false); - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [position, isInitialized, rotation, isDragging]); - - const getEventPos = ( - e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent, - ): Pos => { - if ("touches" in e) { - return { - x: e.touches[0].clientX, - y: e.touches[0].clientY, - }; + const updateContainerRef = () => { + if (myRef.current?.parentElement) { + containerRef.current = + myRef.current.parentElement.getBoundingClientRect(); } - return { - x: e.clientX, - y: e.clientY, - }; }; - const handleStart = (e: React.MouseEvent | React.TouchEvent) => { - if (!myRef.current || !containerRef.current) return; + const handleStart = (e: MouseEvent | TouchEvent) => { + if (!myRef.current) return; + if ("touches" in e) e.preventDefault(); - if ("touches" in e) { - e.preventDefault(); - } - - // Cancel any ongoing momentum animation - if (animationFrame.current) { - cancelAnimationFrame(animationFrame.current); - } + updateContainerRef(); + if (!containerRef.current) return; - setIsDragging(true); + if (animationFrame.current) cancelAnimationFrame(animationFrame.current); const eventPos = getEventPos(e); - const relative = relativePos(eventPos, containerRef.current); - - // Initialize last position with current position - lastPosition.current = position; - lastVelocity.current = { x: 0, y: 0, timestamp: performance.now() }; - - setDragOffset({ - x: relative.x - position.x, - y: relative.y - position.y, + setDragStart({ + x: eventPos.x, + y: eventPos.y, + rot: transform.rot, }); - }; - - const calculateRotation = (velocity: Velocity) => { - // Use a simpler rotation calculation based on position change - const ROTATION_FACTOR = 0.2; // Adjust this to make rotation more or less sensitive - - // Use the actual position change for rotation - const positionDelta = lastPosition.current.x - position.x; + lastPosition.current = eventPos; + lastVelocity.current = { x: 0, y: 0, rot: 0, timestamp: performance.now() }; - // Add to current rotation based on movement - return rotation + positionDelta * ROTATION_FACTOR; + setIsDragging(true); }; const handleMove = (e: MouseEvent | TouchEvent) => { - if (!isDragging || !containerRef.current) return; + if (!isDragging || !dragStart || !containerRef.current) return; + if ("touches" in e) e.preventDefault(); - if ("touches" in e) { - e.preventDefault(); - } + const currentPos = getEventPos(e); - const eventPos = getEventPos(e); - const relative = relativePos(eventPos, containerRef.current); - const newPosition = { - x: relative.x - dragOffset.x, - y: relative.y - dragOffset.y, + // Calculate movement since last frame + const frameDelta = { + x: currentPos.x - lastPosition.current.x, + y: currentPos.y - lastPosition.current.y, }; - // Calculate velocity const now = performance.now(); const elapsed = now - lastVelocity.current.timestamp; + if (elapsed > 0) { - const newVelocity = { - x: ((newPosition.x - lastPosition.current.x) / elapsed) * 16, - y: ((newPosition.y - lastPosition.current.y) / elapsed) * 16, + lastVelocity.current = { + x: (frameDelta.x / elapsed) * 16, + y: (frameDelta.y / elapsed) * 16, + rot: 0, timestamp: now, }; - lastVelocity.current = newVelocity; - lastPosition.current = newPosition; - - // Update rotation based on velocity - setRotation(calculateRotation(newVelocity)); } - setPosition(newPosition); + // Calculate rotation based on horizontal movement + const ROTATION_FACTOR = 0.2; + const rotationDelta = frameDelta.x * ROTATION_FACTOR; + + lastPosition.current = currentPos; + + // Update transform based on frame delta + setTransform(prev => ({ + x: prev.x + frameDelta.x, + y: prev.y + frameDelta.y, + rot: prev.rot + rotationDelta, + })); }; const handleEnd = () => { setIsDragging(false); - // Start momentum animation if ( Math.abs(lastVelocity.current.x) > 0.1 || Math.abs(lastVelocity.current.y) > 0.1 ) animationFrame.current = requestAnimationFrame(applyMomentum); - if (isOutsideViewport(myRef.current!)) setIsOutside(true); + if (myRef.current && isOutsideViewport(myRef.current)) setIsOutside(true); }; const applyMomentum = () => { const now = performance.now(); const elapsed = now - lastVelocity.current.timestamp; - - // Apply decay factor (0.95 = more momentum, 0.8 = less momentum) const decay = Math.pow(0.7, elapsed / 16); const newVelocity = { x: lastVelocity.current.x * decay, y: lastVelocity.current.y * decay, + rot: lastVelocity.current.rot * decay, timestamp: now, }; - // Stop animation when velocity is very low if (Math.abs(newVelocity.x) < 0.01 && Math.abs(newVelocity.y) < 0.01) { cancelAnimationFrame(animationFrame.current!); return; } - setPosition(prev => ({ + setTransform(prev => ({ x: prev.x + newVelocity.x, y: prev.y + newVelocity.y, + rot: prev.rot, })); lastVelocity.current = newVelocity; @@ -228,50 +176,66 @@ export const DraggableButton = React.forwardRef< }; useEffect(() => { + if (!myRef.current) return; + const el = myRef.current; + + // Always listen for drag start + el.addEventListener("mousedown", handleStart, { passive: false }); + el.addEventListener("touchstart", handleStart, { passive: false }); + + // Only add move/end handlers when dragging if (isDragging) { - window.addEventListener("mousemove", handleMove); - window.addEventListener("mouseup", handleEnd); + window.addEventListener("mousemove", handleMove, { passive: false }); window.addEventListener("touchmove", handleMove, { passive: false }); + window.addEventListener("mouseup", handleEnd); window.addEventListener("touchend", handleEnd); window.addEventListener("touchcancel", handleEnd); } return () => { + if (animationFrame.current) { + cancelAnimationFrame(animationFrame.current); + } + + el.removeEventListener("mousedown", handleStart); + el.removeEventListener("touchstart", handleStart); window.removeEventListener("mousemove", handleMove); - window.removeEventListener("mouseup", handleEnd); window.removeEventListener("touchmove", handleMove); + window.removeEventListener("mouseup", handleEnd); window.removeEventListener("touchend", handleEnd); window.removeEventListener("touchcancel", handleEnd); }; - }, [isDragging, dragOffset]); + }, [isDragging]); + + useEffect(() => { + const el = myRef.current; + if (!el) return; + + // Apply transform + el.style.transform = `translate(${transform.x}px, ${transform.y}px) rotate(${transform.rot}deg)`; + + if (!isDragging && isOutsideViewport(el)) setIsOutside(true); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsDragging(false); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [transform, isDragging]); return (