Created
October 26, 2023 19:22
-
-
Save nitedani/7dcc7041daf48750e2f1849ac6a7e9ac to your computer and use it in GitHub Desktop.
Discord-like floater window
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { motion, useSpring } from "framer-motion"; | |
import { | |
MouseEventHandler, | |
ReactEventHandler, | |
useCallback, | |
useEffect, | |
useRef, | |
useState, | |
} from "react"; | |
function convertRemToPixels(rem) { | |
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize); | |
} | |
export const Floater = ({ | |
onDoubleClick, | |
children, | |
width = 300, | |
height, | |
deadzone = 5, | |
}: { | |
onDoubleClick?: () => void; | |
children: React.ReactNode; | |
width?: number; | |
height?: number; | |
deadzone?: number; | |
}) => { | |
const [, rerender] = useState({}); | |
const motionRef = useRef<HTMLDivElement>(null); | |
const [mounted, setMounted] = useState(false); | |
const opacity = useSpring(0, { duration: 300 }); | |
const margin = convertRemToPixels(1.5); | |
const xy = useRef({ | |
x: 0, | |
y: 0, | |
}); | |
const snapToCorner = useCallback(() => { | |
if (!motionRef.current) return; | |
const appRect = document.body.getBoundingClientRect(); | |
const pipRect = motionRef.current.getBoundingClientRect(); | |
const pipMiddleX = pipRect.width / 2; | |
const pipMiddleY = pipRect.height / 2; | |
if (xy.current.x + pipMiddleX > appRect.width / 2) { | |
xy.current.x = appRect.width - pipRect.width - margin; | |
} else { | |
xy.current.x = margin; | |
} | |
if (xy.current.y + pipMiddleY > appRect.height / 2) { | |
xy.current.y = appRect.height - pipRect.height - margin; | |
} else { | |
xy.current.y = margin; | |
} | |
rerender({}); | |
}, [margin]); | |
const onResize: ReactEventHandler<HTMLDivElement> = (e) => { | |
const appRect = document.body.getBoundingClientRect(); | |
const pipRect = e.currentTarget.getBoundingClientRect(); | |
if (!pipRect.width || !pipRect.height) { | |
return; | |
} | |
// bottom right corner | |
xy.current.x = appRect.width - pipRect.width - margin; | |
xy.current.y = appRect.height - pipRect.height - margin; | |
setMounted(true); | |
opacity.set(1); | |
}; | |
useEffect(() => { | |
const listener = (e) => { | |
snapToCorner(); | |
}; | |
window.addEventListener("resize", listener); | |
window.addEventListener("orientationchange", listener); | |
window.addEventListener("blur", listener); | |
return () => { | |
window.removeEventListener("resize", listener); | |
window.removeEventListener("orientationchange", listener); | |
window.removeEventListener("blur", listener); | |
}; | |
}, []); | |
const onMouseDown: MouseEventHandler<HTMLDivElement> = (e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
const clickedPos = { x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY }; | |
const listener = () => { | |
snapToCorner(); | |
window.removeEventListener("mouseup", listener); | |
window.removeEventListener("mousemove", listener2); | |
}; | |
window.addEventListener("mouseup", listener); | |
const listener2 = (e: MouseEvent) => { | |
if ( | |
Math.abs(clickedPos.x - e.offsetX) < deadzone && | |
Math.abs(clickedPos.y - e.offsetY) < deadzone | |
) { | |
return; | |
} | |
let correctedX = e.x - clickedPos.x; | |
let correctedY = e.y - clickedPos.y; | |
const appRect = document.body.getBoundingClientRect(); | |
const pipRect = motionRef.current!.getBoundingClientRect(); | |
if (correctedX < 0) { | |
correctedX = 0; | |
} | |
if (correctedX > appRect.width - pipRect.width) { | |
correctedX = appRect.width - pipRect.width; | |
} | |
if (correctedY < 0) { | |
correctedY = 0; | |
} | |
if (correctedY > appRect.height - pipRect.height) { | |
correctedY = appRect.height - pipRect.height; | |
} | |
xy.current = { x: correctedX, y: correctedY }; | |
rerender({}); | |
}; | |
window.addEventListener("mousemove", listener2); | |
}; | |
const pip = ( | |
<div | |
ref={motionRef} | |
onResize={onResize} | |
style={{ | |
width, | |
}} | |
> | |
{children} | |
</div> | |
); | |
// calculate initial size and position in a hidden div to avoid flicker | |
if (!mounted) { | |
return ( | |
<div | |
style={{ | |
position: "fixed", | |
opacity: 0, | |
}} | |
> | |
{pip} | |
</div> | |
); | |
} | |
return ( | |
<motion.div | |
role="group" | |
onMouseDown={onMouseDown} | |
onDoubleClick={onDoubleClick} | |
animate={{ x: xy.current.x, y: xy.current.y }} | |
style={{ | |
opacity, | |
position: "absolute", | |
width: "300px", | |
zIndex: 100, | |
boxShadow: "var(--chakra-shadows-lg)", | |
borderRadius: "var(--chakra-radii-xl)", | |
overflow: "hidden", | |
cursor: "grab", | |
}} | |
transition={{ | |
type: "spring", | |
damping: 20, | |
stiffness: 250, | |
restDelta: 0.001, | |
}} | |
initial={{ | |
x: xy.current.x, | |
y: xy.current.y, | |
}} | |
> | |
{pip} | |
</motion.div> | |
); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment