'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { profiledKey } from '@/lib/utils/profile-storage'; type FloatingAnchor = 'left' | 'right'; interface FloatingButtonPosition { x: number; y: number; } interface StoredFloatingPosition { xRatio: number; yRatio: number; } interface UseFloatingButtonPositionOptions { storageKey: string; defaultAnchor: FloatingAnchor; defaultYRatio?: number; buttonSize?: number; margin?: number; } interface DragState { active: boolean; dragging: boolean; pointerId: number | null; startClientX: number; startClientY: number; offsetX: number; offsetY: number; } const DRAG_THRESHOLD = 8; const INITIAL_DRAG_STATE: DragState = { active: false, dragging: false, pointerId: null, startClientX: 0, startClientY: 0, offsetX: 0, offsetY: 0, }; function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } export function useFloatingButtonPosition({ storageKey, defaultAnchor, defaultYRatio = 0.5, buttonSize = 56, margin = 16, }: UseFloatingButtonPositionOptions) { const [position, setPosition] = useState(null); const dragStateRef = useRef(INITIAL_DRAG_STATE); const positionRef = useRef(null); const suppressClickRef = useRef(false); const clampPosition = useCallback((x: number, y: number, width: number, height: number) => ({ x: clamp(x, margin, Math.max(margin, width - buttonSize - margin)), y: clamp(y, margin, Math.max(margin, height - buttonSize - margin)), }), [buttonSize, margin]); const getDefaultPosition = useCallback((width: number, height: number) => { const x = defaultAnchor === 'left' ? margin : Math.max(margin, width - buttonSize - margin); const centeredY = height * defaultYRatio - buttonSize / 2; return clampPosition(x, centeredY, width, height); }, [buttonSize, clampPosition, defaultAnchor, defaultYRatio, margin]); const persistPosition = useCallback((nextPosition: FloatingButtonPosition) => { if (typeof window === 'undefined') return; const payload: StoredFloatingPosition = { xRatio: nextPosition.x / window.innerWidth, yRatio: nextPosition.y / window.innerHeight, }; localStorage.setItem(profiledKey(storageKey), JSON.stringify(payload)); }, [storageKey]); useEffect(() => { if (typeof window === 'undefined') return; const loadPosition = () => { const width = window.innerWidth; const height = window.innerHeight; const fallbackPosition = getDefaultPosition(width, height); try { const raw = localStorage.getItem(profiledKey(storageKey)); if (!raw) { positionRef.current = fallbackPosition; setPosition(fallbackPosition); return; } const parsed = JSON.parse(raw) as Partial; if (typeof parsed.xRatio !== 'number' || typeof parsed.yRatio !== 'number') { positionRef.current = fallbackPosition; setPosition(fallbackPosition); return; } const nextPosition = clampPosition( parsed.xRatio * width, parsed.yRatio * height, width, height ); positionRef.current = nextPosition; setPosition(nextPosition); } catch { positionRef.current = fallbackPosition; setPosition(fallbackPosition); } }; loadPosition(); const handleResize = () => { const width = window.innerWidth; const height = window.innerHeight; const fallbackPosition = getDefaultPosition(width, height); const basePosition = positionRef.current || fallbackPosition; const nextPosition = clampPosition(basePosition.x, basePosition.y, width, height); positionRef.current = nextPosition; setPosition(nextPosition); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [clampPosition, getDefaultPosition, storageKey]); const finishDrag = useCallback(() => { const dragState = dragStateRef.current; const didDrag = dragState.dragging; if (didDrag && positionRef.current) { persistPosition(positionRef.current); } suppressClickRef.current = didDrag; dragStateRef.current = INITIAL_DRAG_STATE; }, [persistPosition]); const handlePointerMove = useCallback((event: PointerEvent) => { const dragState = dragStateRef.current; if (!dragState.active || dragState.pointerId !== event.pointerId) { return; } const movedX = Math.abs(event.clientX - dragState.startClientX); const movedY = Math.abs(event.clientY - dragState.startClientY); if (!dragState.dragging && (movedX > DRAG_THRESHOLD || movedY > DRAG_THRESHOLD)) { dragState.dragging = true; } if (!dragState.dragging) { return; } event.preventDefault(); const nextPosition = clampPosition( event.clientX - dragState.offsetX, event.clientY - dragState.offsetY, window.innerWidth, window.innerHeight ); positionRef.current = nextPosition; setPosition(nextPosition); }, [clampPosition]); const handlePointerUp = useCallback((event: PointerEvent) => { const dragState = dragStateRef.current; if (!dragState.active || dragState.pointerId !== event.pointerId) { return; } finishDrag(); window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }, [finishDrag, handlePointerMove]); useEffect(() => { return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; }, [handlePointerMove, handlePointerUp]); const onPointerDown = useCallback((event: React.PointerEvent) => { if (event.button !== 0) return; const target = event.currentTarget; const rect = target.getBoundingClientRect(); dragStateRef.current = { active: true, dragging: false, pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top, }; window.addEventListener('pointermove', handlePointerMove, { passive: false }); window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); }, [handlePointerMove, handlePointerUp]); const consumeSyntheticClick = useCallback((event: React.MouseEvent) => { if (!suppressClickRef.current) return false; suppressClickRef.current = false; event.preventDefault(); event.stopPropagation(); return true; }, []); const floatingStyle = position ? { left: `${position.x}px`, top: `${position.y}px`, right: 'auto', bottom: 'auto', transform: 'none', } : defaultAnchor === 'left' ? { left: `${margin}px`, top: '50%', right: 'auto', bottom: 'auto', transform: 'translateY(-50%)', } : { right: `${margin}px`, top: '50%', left: 'auto', bottom: 'auto', transform: 'translateY(-50%)', }; return { floatingStyle, onPointerDown, consumeSyntheticClick, }; }