Spaces:
Running
Running
import { useRef, useState, useEffect, useCallback, type ReactNode } from "react"; | |
import { LAYOUT } from "../constants"; | |
interface DraggableContainerProps { | |
children: ReactNode; | |
initialPosition: "bottom-left" | "bottom-right" | { x: number; y: number }; | |
className?: string; | |
onDimensionsReady?: (dimensions: { width: number; height: number }) => void; | |
} | |
interface Position { | |
x: number; | |
y: number; | |
} | |
interface Dimensions { | |
width: number; | |
height: number; | |
} | |
const clampPosition = (position: Position, maxX: number, maxY: number): Position => ({ | |
x: Math.max(0, Math.min(position.x, maxX)), | |
y: Math.max(0, Math.min(position.y, maxY)), | |
}); | |
const getBasePosition = (position: "bottom-left" | "bottom-right", dimensions: Dimensions): Position => { | |
const { width, height } = dimensions; | |
switch (position) { | |
case "bottom-left": | |
return { | |
x: LAYOUT.MARGINS.DEFAULT, | |
y: window.innerHeight - height - LAYOUT.MARGINS.BOTTOM, | |
}; | |
case "bottom-right": | |
return { | |
x: window.innerWidth - width - LAYOUT.MARGINS.DEFAULT, | |
y: window.innerHeight - height - LAYOUT.MARGINS.BOTTOM, | |
}; | |
} | |
}; | |
export default function DraggableContainer({ | |
children, | |
initialPosition, | |
className = "", | |
onDimensionsReady, | |
}: DraggableContainerProps) { | |
const [position, setPosition] = useState<Position>({ x: 0, y: 0 }); | |
const [isDragging, setIsDragging] = useState(false); | |
const [hasBeenDragged, setHasBeenDragged] = useState(false); | |
const [dragOffset, setDragOffset] = useState<Position>({ x: 0, y: 0 }); | |
const [relativeOffset, setRelativeOffset] = useState<Position>({ | |
x: 0, | |
y: 0, | |
}); | |
const [dimensions, setDimensions] = useState<Dimensions>({ | |
width: 0, | |
height: 0, | |
}); | |
const [isInitialized, setIsInitialized] = useState(false); | |
const containerRef = useRef<HTMLDivElement>(null); | |
const calculatePosition = useCallback((): Position => { | |
if (!containerRef.current || dimensions.width === 0) return { x: 0, y: 0 }; | |
if (typeof initialPosition === "object") { | |
return initialPosition; | |
} | |
const basePosition = getBasePosition(initialPosition, dimensions); | |
return hasBeenDragged | |
? { | |
x: basePosition.x + relativeOffset.x, | |
y: basePosition.y + relativeOffset.y, | |
} | |
: basePosition; | |
}, [dimensions, initialPosition, hasBeenDragged, relativeOffset]); | |
const updateDimensions = useCallback( | |
(newDimensions: Dimensions) => { | |
setDimensions(newDimensions); | |
if (onDimensionsReady && !hasBeenDragged) { | |
onDimensionsReady(newDimensions); | |
} | |
}, | |
[onDimensionsReady, hasBeenDragged], | |
); | |
const constrainToViewport = useCallback((pos: Position) => { | |
if (!containerRef.current) return pos; | |
const maxX = window.innerWidth - containerRef.current.offsetWidth; | |
const maxY = window.innerHeight - containerRef.current.offsetHeight; | |
return clampPosition(pos, maxX, maxY); | |
}, []); | |
useEffect(() => { | |
if (!isInitialized && dimensions.width > 0 && !hasBeenDragged) { | |
const newPosition = calculatePosition(); | |
setPosition(constrainToViewport(newPosition)); | |
setIsInitialized(true); | |
} | |
}, [isInitialized, dimensions.width, hasBeenDragged, calculatePosition, constrainToViewport]); | |
useEffect(() => { | |
if (!containerRef.current) return; | |
const resizeObserver = new ResizeObserver(() => { | |
if (!containerRef.current) return; | |
const rect = containerRef.current.getBoundingClientRect(); | |
const newDimensions = { width: rect.width, height: rect.height }; | |
updateDimensions(newDimensions); | |
setPosition((prev) => constrainToViewport(prev)); | |
}); | |
resizeObserver.observe(containerRef.current); | |
return () => resizeObserver.disconnect(); | |
}, [updateDimensions, constrainToViewport]); | |
useEffect(() => { | |
const handleResize = () => { | |
const newPosition = calculatePosition(); | |
setPosition(constrainToViewport(newPosition)); | |
}; | |
window.addEventListener("resize", handleResize); | |
return () => window.removeEventListener("resize", handleResize); | |
}, [calculatePosition, constrainToViewport]); | |
useEffect(() => { | |
if (!isDragging) return; | |
const handleMouseMove = (e: MouseEvent) => { | |
const newPosition = { | |
x: e.clientX - dragOffset.x, | |
y: e.clientY - dragOffset.y, | |
}; | |
setPosition(constrainToViewport(newPosition)); | |
}; | |
const handleMouseUp = () => { | |
setIsDragging(false); | |
setHasBeenDragged(true); | |
if (containerRef.current && typeof initialPosition !== "object") { | |
const basePosition = getBasePosition(initialPosition, dimensions); | |
setRelativeOffset({ | |
x: position.x - basePosition.x, | |
y: position.y - basePosition.y, | |
}); | |
} | |
}; | |
document.addEventListener("mousemove", handleMouseMove); | |
document.addEventListener("mouseup", handleMouseUp); | |
return () => { | |
document.removeEventListener("mousemove", handleMouseMove); | |
document.removeEventListener("mouseup", handleMouseUp); | |
}; | |
}, [isDragging, dragOffset, position, initialPosition, dimensions, constrainToViewport]); | |
const handleMouseDown = (e: React.MouseEvent) => { | |
if (!containerRef.current) return; | |
const rect = containerRef.current.getBoundingClientRect(); | |
setDragOffset({ | |
x: e.clientX - rect.left, | |
y: e.clientY - rect.top, | |
}); | |
setIsDragging(true); | |
}; | |
return ( | |
<div | |
ref={containerRef} | |
className={`fixed z-50 ${className} ${isDragging ? "cursor-grabbing" : "cursor-grab"}`} | |
style={{ | |
left: position.x, | |
top: position.y, | |
transform: isDragging ? "scale(1.02)" : "scale(1)", | |
transition: isDragging ? "none" : "transform 0.2s ease", | |
opacity: isInitialized ? 1 : 0, | |
}} | |
onMouseDown={handleMouseDown} | |
> | |
{children} | |
</div> | |
); | |
} | |