|
|
@ -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]); |
|
|
|
|
|
|
|
// 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; |
|
|
|
}, [isOutside, onOutsideViewport]); |
|
|
|
|
|
|
|
// 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(); |
|
|
|
} |
|
|
|
|
|
|
|
// Cancel any ongoing momentum animation
|
|
|
|
if (animationFrame.current) { |
|
|
|
cancelAnimationFrame(animationFrame.current); |
|
|
|
} |
|
|
|
updateContainerRef(); |
|
|
|
if (!containerRef.current) return; |
|
|
|
|
|
|
|
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, |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
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; |
|
|
|
lastPosition.current = eventPos; |
|
|
|
lastVelocity.current = { x: 0, y: 0, rot: 0, timestamp: performance.now() }; |
|
|
|
|
|
|
|
// 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]); |
|
|
|
|
|
|
|
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 ( |
|
|
|
<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} |
|
|
|
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; |
|
|
|
} |
|
|
|