mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 00:03:42 +08:00
268 lines
7.5 KiB
TypeScript
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,
|
|
};
|
|
}
|