Browse Source

fix: attempt to fix problems with DraggableButton

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

254
src/components/DraggableButton.tsx

@ -1,5 +1,6 @@
import { css, cx } from "@emotion/css"; import { css, cx } from "@emotion/css";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { composeRefs } from "../util";
const isOutsideViewport = (el: HTMLElement) => { const isOutsideViewport = (el: HTMLElement) => {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
@ -20,6 +21,9 @@ export interface DraggableButtonProps
interface Pos { interface Pos {
x: number; x: number;
y: number; y: number;
rot: number;
touchOffsetX?: number;
touchOffsetY?: number;
} }
interface Velocity extends Pos { 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< export const DraggableButton = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
DraggableButtonProps DraggableButtonProps
>(({ children, onOutsideViewport, ...props }, ref) => { >(({ children, onOutsideViewport, ...props }, ref) => {
const [position, setPosition] = useState({ x: 0, y: 0 }); const [transform, setTransform] = useState<Pos>({ x: 0, y: 0, rot: 0 });
const [rotation, setRotation] = useState(0);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState<Pos | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const myRef = useRef<HTMLButtonElement | null>(null); const myRef = useRef<HTMLButtonElement | null>(null);
const containerRef = useRef<DOMRect | null>(null); const containerRef = useRef<DOMRect | null>(null);
const lastVelocity = useRef<Velocity>({ x: 0, y: 0, timestamp: 0 }); const lastVelocity = useRef<Velocity>({ x: 0, y: 0, rot: 0, timestamp: 0 });
const lastPosition = useRef<Pos>({ x: 0, y: 0 }); const lastPosition = useRef<Pos>({ x: 0, y: 0, rot: 0 });
const animationFrame = useRef<number>(); const animationFrame = useRef<number>();
const [isOutside, setIsOutside] = useState(false); const [isOutside, setIsOutside] = useState(false);
useEffect(() => { useEffect(() => {
if (isOutside) onOutsideViewport?.(); 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 const updateContainerRef = () => {
el.style.position = "absolute"; if (myRef.current?.parentElement) {
el.style.transition = "none"; containerRef.current =
myRef.current.parentElement.getBoundingClientRect();
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,
};
} }
return {
x: e.clientX,
y: e.clientY,
};
}; };
const handleStart = (e: React.MouseEvent | React.TouchEvent) => { const handleStart = (e: MouseEvent | TouchEvent) => {
if (!myRef.current || !containerRef.current) return; if (!myRef.current) return;
if ("touches" in e) e.preventDefault();
if ("touches" in e) { updateContainerRef();
e.preventDefault(); 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 eventPos = getEventPos(e);
const relative = relativePos(eventPos, containerRef.current); setDragStart({
x: eventPos.x,
// Initialize last position with current position y: eventPos.y,
lastPosition.current = position; rot: transform.rot,
lastVelocity.current = { x: 0, y: 0, timestamp: performance.now() };
setDragOffset({
x: relative.x - position.x,
y: relative.y - position.y,
}); });
}; 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 setIsDragging(true);
return rotation + positionDelta * ROTATION_FACTOR;
}; };
const handleMove = (e: MouseEvent | TouchEvent) => { 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) { const currentPos = getEventPos(e);
e.preventDefault();
}
const eventPos = getEventPos(e); // Calculate movement since last frame
const relative = relativePos(eventPos, containerRef.current); const frameDelta = {
const newPosition = { x: currentPos.x - lastPosition.current.x,
x: relative.x - dragOffset.x, y: currentPos.y - lastPosition.current.y,
y: relative.y - dragOffset.y,
}; };
// Calculate velocity
const now = performance.now(); const now = performance.now();
const elapsed = now - lastVelocity.current.timestamp; const elapsed = now - lastVelocity.current.timestamp;
if (elapsed > 0) { if (elapsed > 0) {
const newVelocity = { lastVelocity.current = {
x: ((newPosition.x - lastPosition.current.x) / elapsed) * 16, x: (frameDelta.x / elapsed) * 16,
y: ((newPosition.y - lastPosition.current.y) / elapsed) * 16, y: (frameDelta.y / elapsed) * 16,
rot: 0,
timestamp: now, 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 = () => { const handleEnd = () => {
setIsDragging(false); setIsDragging(false);
// Start momentum animation
if ( if (
Math.abs(lastVelocity.current.x) > 0.1 || Math.abs(lastVelocity.current.x) > 0.1 ||
Math.abs(lastVelocity.current.y) > 0.1 Math.abs(lastVelocity.current.y) > 0.1
) )
animationFrame.current = requestAnimationFrame(applyMomentum); animationFrame.current = requestAnimationFrame(applyMomentum);
if (isOutsideViewport(myRef.current!)) setIsOutside(true); if (myRef.current && isOutsideViewport(myRef.current)) setIsOutside(true);
}; };
const applyMomentum = () => { const applyMomentum = () => {
const now = performance.now(); const now = performance.now();
const elapsed = now - lastVelocity.current.timestamp; 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 decay = Math.pow(0.7, elapsed / 16);
const newVelocity = { const newVelocity = {
x: lastVelocity.current.x * decay, x: lastVelocity.current.x * decay,
y: lastVelocity.current.y * decay, y: lastVelocity.current.y * decay,
rot: lastVelocity.current.rot * decay,
timestamp: now, timestamp: now,
}; };
// Stop animation when velocity is very low
if (Math.abs(newVelocity.x) < 0.01 && Math.abs(newVelocity.y) < 0.01) { if (Math.abs(newVelocity.x) < 0.01 && Math.abs(newVelocity.y) < 0.01) {
cancelAnimationFrame(animationFrame.current!); cancelAnimationFrame(animationFrame.current!);
return; return;
} }
setPosition(prev => ({ setTransform(prev => ({
x: prev.x + newVelocity.x, x: prev.x + newVelocity.x,
y: prev.y + newVelocity.y, y: prev.y + newVelocity.y,
rot: prev.rot,
})); }));
lastVelocity.current = newVelocity; lastVelocity.current = newVelocity;
@ -228,50 +176,66 @@ export const DraggableButton = React.forwardRef<
}; };
useEffect(() => { 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) { if (isDragging) {
window.addEventListener("mousemove", handleMove); window.addEventListener("mousemove", handleMove, { passive: false });
window.addEventListener("mouseup", handleEnd);
window.addEventListener("touchmove", handleMove, { passive: false }); window.addEventListener("touchmove", handleMove, { passive: false });
window.addEventListener("mouseup", handleEnd);
window.addEventListener("touchend", handleEnd); window.addEventListener("touchend", handleEnd);
window.addEventListener("touchcancel", handleEnd); window.addEventListener("touchcancel", handleEnd);
} }
return () => { return () => {
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
}
el.removeEventListener("mousedown", handleStart);
el.removeEventListener("touchstart", handleStart);
window.removeEventListener("mousemove", handleMove); window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleEnd);
window.removeEventListener("touchmove", handleMove); window.removeEventListener("touchmove", handleMove);
window.removeEventListener("mouseup", handleEnd);
window.removeEventListener("touchend", handleEnd); window.removeEventListener("touchend", handleEnd);
window.removeEventListener("touchcancel", 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 ( return (
<button <button
ref={el => { ref={composeRefs(myRef, ref)}
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} {...props}
className={cx( className={cx(
props.className, props.className,
css` css`
position: absolute;
transition: none;
cursor: ${isDragging ? "grabbing" : "grab"}; cursor: ${isDragging ? "grabbing" : "grab"};
touch-action: none; /* Prevent scrolling while dragging on touch devices */ touch-action: none;
will-change: transform;
.dynamic-gradient { .dynamic-gradient {
cursor: inherit; cursor: inherit;
} }

Loading…
Cancel
Save