269 lines
7.2 KiB

import { css, cx } from "@emotion/css";
import React, { useEffect, useRef, useState } from "react";
export interface DraggableButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
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<HTMLButtonElement | null>(null);
const containerRef = useRef<DOMRect | null>(null);
const lastVelocity = useRef<Velocity>({ x: 0, y: 0, timestamp: 0 });
const lastPosition = useRef<Pos>({ x: 0, y: 0 });
const animationFrame = useRef<number>();
// 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 (
<button
ref={el => {
if (!el) return;
// Start with absolute positioning
el.style.position = "absolute";
myRef.current = el;
// Store initial container bounds
const parent = el.parentElement;
if (parent) {
containerRef.current = parent.getBoundingClientRect();
}
if (ref)
if (typeof ref === "function") ref(el);
else ref.current = el;
}}
onMouseDown={handleStart}
onTouchStart={handleStart}
{...props}
className={cx(
props.className,
css`
cursor: ${isDragging ? "grabbing" : "grab"};
touch-action: none; /* Prevent scrolling while dragging on touch devices */
.dynamic-gradient {
cursor: inherit;
}
`,
)}>
{children}
</button>
);
});