mirror of https://github.com/mkrhere/pw2
269 lines
7.2 KiB
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>
|
|
);
|
|
});
|
|
|