Browse Source

fix: attempt to fix problems with DraggableButton

master
Muthu Kumar 1 month ago
parent
commit
52fa434f59
Failed to extract signature
  1. 250
      src/components/DraggableButton.tsx

250
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<Pos>({ 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<Pos | null>(null);
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 lastVelocity = useRef<Velocity>({ x: 0, y: 0, rot: 0, timestamp: 0 });
const lastPosition = useRef<Pos>({ x: 0, y: 0, rot: 0 });
const animationFrame = useRef<number>();
const [isOutside, setIsOutside] = useState(false);
useEffect(() => {
if (isOutside) onOutsideViewport?.();
}, [isOutside !== true]);
}, [isOutside, onOutsideViewport]);
// 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;
// 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();
}
updateContainerRef();
if (!containerRef.current) return;
// Cancel any ongoing momentum animation
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
}
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,
});
};
lastPosition.current = eventPos;
lastVelocity.current = { x: 0, y: 0, rot: 0, timestamp: performance.now() };
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;
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]);
return (
<button
ref={el => {
useEffect(() => {
const el = myRef.current;
if (!el) return;
// Start with absolute positioning
el.style.position = "absolute";
myRef.current = el;
// Apply transform
el.style.transform = `translate(${transform.x}px, ${transform.y}px) rotate(${transform.rot}deg)`;
// Store initial container bounds
const parent = el.parentElement;
if (parent) {
containerRef.current = parent.getBoundingClientRect();
}
if (!isDragging && isOutsideViewport(el)) setIsOutside(true);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsDragging(false);
};
if (ref)
if (typeof ref === "function") ref(el);
else ref.current = el;
}}
onMouseDown={handleStart}
onTouchStart={handleStart}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [transform, isDragging]);
return (
<button
ref={composeRefs(myRef, ref)}
{...props}
className={cx(
props.className,
css`
position: absolute;
transition: none;
cursor: ${isDragging ? "grabbing" : "grab"};
touch-action: none; /* Prevent scrolling while dragging on touch devices */
touch-action: none;
will-change: transform;
.dynamic-gradient {
cursor: inherit;
}

Loading…
Cancel
Save