Last active
April 23, 2026 14:43
-
-
Save senadir/8186d8323a7aa9bac412bc32b524de30 to your computer and use it in GitHub Desktop.
This file contains hidden or 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
| // 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