'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { profiledKey } from '@/lib/utils/profile-storage'; import { clampFloatingButtonPosition, getDefaultFloatingButtonPosition, getFloatingButtonRatios, getPositionFromFloatingButtonRatios, type FloatingAnchor, type FloatingButtonPosition, type FloatingButtonRatios, type FloatingButtonViewport, } from '@/lib/utils/floating-button-position'; interface StoredFloatingPosition extends FloatingButtonRatios { /** Version 2 ratios are measured within the draggable area, not the viewport. */ version?: 2; } const CURRENT_STORAGE_VERSION = 2; 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, }; 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 customRatiosRef = useRef(null); const pointerUpHandlerRef = useRef<(event: PointerEvent) => void>(() => undefined); const suppressClickRef = useRef(false); const clampPosition = useCallback((x: number, y: number, width: number, height: number) => ({ ...clampFloatingButtonPosition( { x, y }, { width, height }, buttonSize, margin, ), }), [buttonSize, margin]); const getDefaultPosition = useCallback((width: number, height: number) => { return getDefaultFloatingButtonPosition( { width, height }, defaultAnchor, defaultYRatio, buttonSize, margin, ); }, [buttonSize, defaultAnchor, defaultYRatio, margin]); const persistPosition = useCallback((nextPosition: FloatingButtonPosition) => { if (typeof window === 'undefined') return; const viewport: FloatingButtonViewport = { width: window.innerWidth, height: window.innerHeight, }; const ratios = getFloatingButtonRatios(nextPosition, viewport, buttonSize, margin); const payload: StoredFloatingPosition = { version: CURRENT_STORAGE_VERSION, ...ratios, }; localStorage.setItem(profiledKey(storageKey), JSON.stringify(payload)); }, [buttonSize, margin, storageKey]); useEffect(() => { if (typeof window === 'undefined') return; const loadPosition = () => { const width = window.innerWidth; const height = window.innerHeight; const fallbackPosition = getDefaultPosition(width, height); customRatiosRef.current = null; try { const raw = localStorage.getItem(profiledKey(storageKey)); if (!raw) { positionRef.current = fallbackPosition; setPosition(fallbackPosition); return; } const parsed = JSON.parse(raw) as Partial & { version?: number }; if ( typeof parsed.xRatio !== 'number' || !Number.isFinite(parsed.xRatio) || typeof parsed.yRatio !== 'number' || !Number.isFinite(parsed.yRatio) || (parsed.version !== undefined && parsed.version !== CURRENT_STORAGE_VERSION) ) { positionRef.current = fallbackPosition; setPosition(fallbackPosition); return; } const viewport = { width, height }; const nextPosition = parsed.version === CURRENT_STORAGE_VERSION ? getPositionFromFloatingButtonRatios( { xRatio: parsed.xRatio, yRatio: parsed.yRatio }, viewport, buttonSize, margin, ) : clampPosition(parsed.xRatio * width, parsed.yRatio * height, width, height); customRatiosRef.current = getFloatingButtonRatios(nextPosition, viewport, buttonSize, margin); positionRef.current = nextPosition; setPosition(nextPosition); } catch { positionRef.current = fallbackPosition; setPosition(fallbackPosition); } }; loadPosition(); const handleResize = () => { const width = window.innerWidth; const height = window.innerHeight; const nextPosition = customRatiosRef.current ? getPositionFromFloatingButtonRatios( customRatiosRef.current, { width, height }, buttonSize, margin, ) : getDefaultPosition(width, height); positionRef.current = nextPosition; setPosition(nextPosition); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [buttonSize, clampPosition, getDefaultPosition, margin, 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 ); customRatiosRef.current = getFloatingButtonRatios( nextPosition, { width: window.innerWidth, height: window.innerHeight }, buttonSize, margin, ); positionRef.current = nextPosition; setPosition(nextPosition); }, [buttonSize, clampPosition, margin]); 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', pointerUpHandlerRef.current); window.removeEventListener('pointercancel', pointerUpHandlerRef.current); }, [finishDrag, handlePointerMove]); useEffect(() => { pointerUpHandlerRef.current = handlePointerUp; }, [handlePointerUp]); 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, }; }