mirror of https://github.com/mkrhere/pw2
3 changed files with 232 additions and 6 deletions
@ -0,0 +1,185 @@ |
|||||
|
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; |
||||
|
} |
||||
|
|
||||
|
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 [isDragging, setIsDragging] = useState(false); |
||||
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); |
||||
|
const myRef = useRef<HTMLButtonElement | null>(null); |
||||
|
const containerRef = useRef<DOMRect | null>(null); |
||||
|
const [isInitialized, setIsInitialized] = useState(false); |
||||
|
|
||||
|
// Convert initial position and set up element on mount
|
||||
|
useEffect(() => { |
||||
|
const el = myRef.current; |
||||
|
if (!el || isInitialized) return; |
||||
|
|
||||
|
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"; |
||||
|
|
||||
|
setPosition({ x: left, y: top }); |
||||
|
setIsInitialized(true); |
||||
|
}, []); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const el = myRef.current; |
||||
|
if (!el || !isInitialized) return; |
||||
|
|
||||
|
containerRef.current = el.parentElement?.getBoundingClientRect() ?? null; |
||||
|
|
||||
|
el.style.transition = "none"; |
||||
|
el.style.left = `${position.x}px`; |
||||
|
el.style.top = `${position.y}px`; |
||||
|
|
||||
|
const handleKeyDown = (e: KeyboardEvent) => { |
||||
|
if (e.key === "Escape") setIsDragging(false); |
||||
|
}; |
||||
|
|
||||
|
window.addEventListener("keydown", handleKeyDown); |
||||
|
return () => window.removeEventListener("keydown", handleKeyDown); |
||||
|
}, [position, isInitialized]); |
||||
|
|
||||
|
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; |
||||
|
|
||||
|
// Prevent scrolling on touch devices
|
||||
|
if ("touches" in e) { |
||||
|
e.preventDefault(); |
||||
|
} |
||||
|
|
||||
|
setIsDragging(true); |
||||
|
|
||||
|
const eventPos = getEventPos(e); |
||||
|
const relative = relativePos(eventPos, containerRef.current); |
||||
|
|
||||
|
setDragOffset({ |
||||
|
x: relative.x - position.x, |
||||
|
y: relative.y - position.y, |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const handleMove = (e: MouseEvent | TouchEvent) => { |
||||
|
if (!isDragging || !containerRef.current) return; |
||||
|
|
||||
|
// Prevent scrolling on touch devices
|
||||
|
if ("touches" in e) { |
||||
|
e.preventDefault(); |
||||
|
} |
||||
|
|
||||
|
const eventPos = getEventPos(e); |
||||
|
const relative = relativePos(eventPos, containerRef.current); |
||||
|
|
||||
|
setPosition({ |
||||
|
x: relative.x - dragOffset.x, |
||||
|
y: relative.y - dragOffset.y, |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const handleEnd = () => { |
||||
|
setIsDragging(false); |
||||
|
}; |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (isDragging) { |
||||
|
// Mouse events
|
||||
|
window.addEventListener("mousemove", handleMove); |
||||
|
window.addEventListener("mouseup", handleEnd); |
||||
|
|
||||
|
// Touch events
|
||||
|
window.addEventListener("touchmove", handleMove, { passive: false }); |
||||
|
window.addEventListener("touchend", handleEnd); |
||||
|
window.addEventListener("touchcancel", handleEnd); |
||||
|
} |
||||
|
|
||||
|
return () => { |
||||
|
// Mouse events
|
||||
|
window.removeEventListener("mousemove", handleMove); |
||||
|
window.removeEventListener("mouseup", handleEnd); |
||||
|
|
||||
|
// Touch events
|
||||
|
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> |
||||
|
); |
||||
|
}); |
Loading…
Reference in new issue