Files
KVideo/lib/hooks/useFloatingButtonPosition.ts
T

268 lines
7.5 KiB
TypeScript

'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<FloatingButtonPosition | null>(null);
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
const positionRef = useRef<FloatingButtonPosition | null>(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<StoredFloatingPosition>;
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<HTMLElement>) => {
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<HTMLElement>) => {
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,
};
}