import { css, cx } from "@emotion/css"; import React, { useEffect, useRef, useState } from "react"; export interface DraggableButtonProps extends React.ButtonHTMLAttributes {} interface Pos { x: number; y: number; } interface Velocity extends Pos { timestamp: number; } const relativePos = (pos: Pos, container: DOMRect) => { return { x: pos.x - container.left, y: pos.y - container.top, }; }; export const DraggableButton = React.forwardRef< HTMLButtonElement, DraggableButtonProps >(({ children, ...props }, ref) => { const [position, setPosition] = useState({ x: 0, y: 0 }); const [rotation, setRotation] = useState(0); const baseRotation = useRef(0); const [isDragging, setIsDragging] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [isInitialized, setIsInitialized] = useState(false); 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 animationFrame = useRef(); // Capture initial rotation on mount useEffect(() => { const el = myRef.current; if (!el || isInitialized) return; // Extract initial rotation from transform style const transform = window.getComputedStyle(el).transform; const matrix = new DOMMatrix(transform); baseRotation.current = Math.atan2(matrix.b, matrix.a) * (180 / Math.PI); 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; // Set position and clear any bottom/right values el.style.position = "absolute"; el.style.top = `${top}px`; el.style.left = `${left}px`; el.style.bottom = "unset"; el.style.right = "unset"; 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.transform = `rotateZ(${rotation}deg)`; 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, }; } return { x: e.clientX, y: e.clientY, }; }; const handleStart = (e: React.MouseEvent | React.TouchEvent) => { if (!myRef.current || !containerRef.current) return; if ("touches" in e) { e.preventDefault(); } // Cancel any ongoing momentum animation if (animationFrame.current) { cancelAnimationFrame(animationFrame.current); } setIsDragging(true); 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, }); }; 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; // Add to current rotation based on movement return rotation + positionDelta * ROTATION_FACTOR; }; const handleMove = (e: MouseEvent | TouchEvent) => { if (!isDragging || !containerRef.current) return; if ("touches" in e) { e.preventDefault(); } const eventPos = getEventPos(e); const relative = relativePos(eventPos, containerRef.current); const newPosition = { x: relative.x - dragOffset.x, y: relative.y - dragOffset.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, timestamp: now, }; lastVelocity.current = newVelocity; lastPosition.current = newPosition; // Update rotation based on velocity setRotation(calculateRotation(newVelocity)); } setPosition(newPosition); }; const handleEnd = () => { setIsDragging(false); // Remove the rotation reset - let chaos reign! // setRotation(baseRotation.current); // Start momentum animation if ( Math.abs(lastVelocity.current.x) > 0.1 || Math.abs(lastVelocity.current.y) > 0.1 ) { animationFrame.current = requestAnimationFrame(applyMomentum); } }; 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, 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 => ({ x: prev.x + newVelocity.x, y: prev.y + newVelocity.y, })); lastVelocity.current = newVelocity; animationFrame.current = requestAnimationFrame(applyMomentum); }; useEffect(() => { if (isDragging) { window.addEventListener("mousemove", handleMove); window.addEventListener("mouseup", handleEnd); window.addEventListener("touchmove", handleMove, { passive: false }); window.addEventListener("touchend", handleEnd); window.addEventListener("touchcancel", handleEnd); } return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleEnd); window.removeEventListener("touchmove", handleMove); window.removeEventListener("touchend", handleEnd); window.removeEventListener("touchcancel", handleEnd); }; }, [isDragging, dragOffset]); return ( ); });