Skip to content

Instantly share code, notes, and snippets.

@senadir
Last active April 23, 2026 14:43
Show Gist options
  • Select an option

  • Save senadir/8186d8323a7aa9bac412bc32b524de30 to your computer and use it in GitHub Desktop.

Select an option

Save senadir/8186d8323a7aa9bac412bc32b524de30 to your computer and use it in GitHub Desktop.
// Archived: hand-rolled pointer-event drag/resize/snap for the floating AI panel.
// Replaced by `../use-floating-panel.ts`, which uses Motion's built-in drag
// (dragConstraints, dragElastic, onDragEnd snap). Kept for reference in case
// we hit a regression and need the old behaviour back.
import { useCallback, useEffect, useRef, useState } from "react"
import { animate, useMotionValue, useTransform } from "motion/react"
import {
INTERACTIVE_SELECTOR,
PANEL_MARGIN,
PANEL_MIN_H,
PANEL_MIN_W,
RESIZE_CURSORS,
RESIZE_ORIGINS,
getDefaultPanelState,
loadPanelState,
savePanelState,
type PanelState,
type ResizeDir,
} from "../panel-state"
const EDGE_SNAP = 80
const THROW_VEL = 300
const SNAP_SPRING = {
type: "spring" as const,
stiffness: 320,
damping: 28,
}
const clamp = (v: number, min: number, max: number) =>
Math.max(min, Math.min(v, max))
function clampToViewport(state: PanelState, vw: number, vh: number): PanelState {
const width = clamp(state.width, PANEL_MIN_W, vw - PANEL_MARGIN * 2)
const height = clamp(state.height, PANEL_MIN_H, vh - PANEL_MARGIN * 2)
const x = clamp(
state.x,
PANEL_MARGIN,
Math.max(PANEL_MARGIN, vw - width - PANEL_MARGIN)
)
const y = clamp(
state.y,
PANEL_MARGIN,
Math.max(PANEL_MARGIN, vh - height - PANEL_MARGIN)
)
return { x, y, width, height }
}
// pos is distance from the anchor edge (right/bottom). Throw toward the anchor
// (positive velocity in screen coords) or release near the anchor → pin to PANEL_MARGIN.
// Throw away from anchor / release near the opposite edge → pin to the far margin.
function snapAxis(pos: number, vel: number, size: number, viewport: number) {
const max = viewport - size - PANEL_MARGIN
const predicted = pos - vel * 0.15
const anchorDist = predicted
const oppositeDist = viewport - (predicted + size)
let target = predicted
if (Math.abs(vel) > THROW_VEL) {
target = vel > 0 ? PANEL_MARGIN : max
} else if (Math.min(anchorDist, oppositeDist) < EDGE_SNAP) {
target = anchorDist < oppositeDist ? PANEL_MARGIN : max
}
return clamp(target, PANEL_MARGIN, max)
}
type Cleanup = () => void
function bindGesture(
onMove: (e: PointerEvent) => void,
onUp: () => void
): Cleanup {
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
return () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
}
function resetBodyStyle() {
document.body.style.cursor = ""
document.body.style.userSelect = ""
}
export function useFloatingPanel(open: boolean) {
const [initial] = useState<PanelState>(() => {
const raw = loadPanelState() ?? getDefaultPanelState()
if (typeof window === "undefined") return raw
return clampToViewport(raw, window.innerWidth, window.innerHeight)
})
const x = useMotionValue(initial.x)
const y = useMotionValue(initial.y)
const width = useMotionValue(initial.width)
const height = useMotionValue(initial.height)
const translateX = useTransform(x, (v) => -v)
const translateY = useTransform(y, (v) => -v)
const [isDragging, setIsDragging] = useState(false)
const [transformOrigin, setTransformOrigin] = useState("top right")
const gestureCleanup = useRef<Cleanup | null>(null)
const persist = useCallback(() => {
savePanelState({
x: x.get(),
y: y.get(),
width: width.get(),
height: height.get(),
})
}, [x, y, width, height])
useEffect(() => {
if (!open) return
return () => {
gestureCleanup.current?.()
gestureCleanup.current = null
resetBodyStyle()
}
}, [open])
useEffect(() => {
if (!open) return
const onResize = () => {
const clamped = clampToViewport(
{
x: x.get(),
y: y.get(),
width: width.get(),
height: height.get(),
},
window.innerWidth,
window.innerHeight
)
let changed = false
if (clamped.width !== width.get()) {
width.set(clamped.width)
changed = true
}
if (clamped.height !== height.get()) {
height.set(clamped.height)
changed = true
}
if (clamped.x !== x.get()) {
x.set(clamped.x)
changed = true
}
if (clamped.y !== y.get()) {
y.set(clamped.y)
changed = true
}
if (changed) persist()
}
window.addEventListener("resize", onResize)
return () => window.removeEventListener("resize", onResize)
}, [open, x, y, width, height, persist])
const startDrag = useCallback(
(e: React.PointerEvent<HTMLElement>) => {
if ((e.target as HTMLElement).closest(INTERACTIVE_SELECTOR)) return
e.preventDefault()
gestureCleanup.current?.()
setIsDragging(true)
const startPX = e.clientX
const startPY = e.clientY
const startX = x.get()
const startY = y.get()
let lastTime = performance.now()
let lastX = startPX
let lastY = startPY
let vx = 0
let vy = 0
const onMove = (ev: PointerEvent) => {
x.set(startX - (ev.clientX - startPX))
y.set(startY - (ev.clientY - startPY))
const now = performance.now()
const dt = now - lastTime
if (dt > 0) {
vx = ((ev.clientX - lastX) / dt) * 1000
vy = ((ev.clientY - lastY) / dt) * 1000
}
lastTime = now
lastX = ev.clientX
lastY = ev.clientY
}
const onUp = () => {
gestureCleanup.current?.()
gestureCleanup.current = null
setIsDragging(false)
const targetX = snapAxis(x.get(), vx, width.get(), window.innerWidth)
const targetY = snapAxis(y.get(), vy, height.get(), window.innerHeight)
Promise.all([
animate(x, targetX, SNAP_SPRING),
animate(y, targetY, SNAP_SPRING),
]).then(persist)
}
gestureCleanup.current = bindGesture(onMove, onUp)
},
[x, y, width, height, persist]
)
const keyboardResize = useCallback(
(direction: ResizeDir, dx: number, dy: number) => {
const growE = direction.includes("e")
const growW = direction.includes("w")
const growS = direction.includes("s")
const growN = direction.includes("n")
if (growE || growW) {
const curW = width.get()
const curX = x.get()
const delta = growW ? -dx : dx
if (growE) {
const newW = clamp(
curW + delta,
PANEL_MIN_W,
curX + curW - PANEL_MARGIN
)
width.set(newW)
x.set(curX - (newW - curW))
} else {
width.set(
clamp(
curW + delta,
PANEL_MIN_W,
window.innerWidth - curX - PANEL_MARGIN
)
)
}
}
if (growS || growN) {
const curH = height.get()
const curY = y.get()
const delta = growN ? -dy : dy
if (growS) {
const newH = clamp(
curH + delta,
PANEL_MIN_H,
curY + curH - PANEL_MARGIN
)
height.set(newH)
y.set(curY - (newH - curH))
} else {
height.set(
clamp(
curH + delta,
PANEL_MIN_H,
window.innerHeight - curY - PANEL_MARGIN
)
)
}
}
persist()
},
[x, y, width, height, persist]
)
const startResize = useCallback(
(direction: ResizeDir, e: React.PointerEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
gestureCleanup.current?.()
setTransformOrigin(RESIZE_ORIGINS[direction])
document.body.style.cursor = RESIZE_CURSORS[direction]
document.body.style.userSelect = "none"
const startPX = e.clientX
const startPY = e.clientY
const startW = width.get()
const startH = height.get()
const startX = x.get()
const startY = y.get()
// Anchored at bottom-right: dragging the E or S edge changes x/y *and* size
// because those edges sit on the anchored side. Dragging W or N is size-only.
const growE = direction.includes("e")
const growW = direction.includes("w")
const growS = direction.includes("s")
const growN = direction.includes("n")
const onMove = (ev: PointerEvent) => {
const dx = ev.clientX - startPX
const dy = ev.clientY - startPY
if (growE) {
const newW = clamp(
startW + dx,
PANEL_MIN_W,
startX + startW - PANEL_MARGIN
)
width.set(newW)
x.set(startX - (newW - startW))
} else if (growW) {
width.set(
clamp(
startW - dx,
PANEL_MIN_W,
window.innerWidth - startX - PANEL_MARGIN
)
)
}
if (growS) {
const newH = clamp(
startH + dy,
PANEL_MIN_H,
startY + startH - PANEL_MARGIN
)
height.set(newH)
y.set(startY - (newH - startH))
} else if (growN) {
height.set(
clamp(
startH - dy,
PANEL_MIN_H,
window.innerHeight - startY - PANEL_MARGIN
)
)
}
}
const onUp = () => {
gestureCleanup.current?.()
gestureCleanup.current = null
resetBodyStyle()
setTransformOrigin("top right")
persist()
}
gestureCleanup.current = bindGesture(onMove, onUp)
},
[x, y, width, height, persist]
)
return {
translateX,
translateY,
width,
height,
transformOrigin,
isDragging,
startDrag,
startResize,
keyboardResize,
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment