Files
readest/apps/readest-app/src/app/reader/hooks/useReviewPanelDrag.ts
T

125 lines
4.0 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
type Position = {
x: number;
y: number;
};
type UseReviewPanelDragOptions = {
enabled: boolean;
panelRef: RefObject<HTMLElement | null>;
margin: number;
defaultWidthRatio: number;
topInset: number;
};
const pointerTargetIsInteractive = (target: EventTarget | null) =>
target instanceof HTMLElement &&
Boolean(target.closest('button, input, textarea, select, a, .drag-bar'));
export const useReviewPanelDrag = ({
enabled,
panelRef,
margin,
defaultWidthRatio,
topInset,
}: UseReviewPanelDragOptions) => {
const initialPosition = () => ({
x:
typeof globalThis.window === 'undefined'
? margin
: Math.max(
margin,
globalThis.window.innerWidth -
Math.round(globalThis.window.innerWidth * defaultWidthRatio) -
margin,
),
y: typeof globalThis.window === 'undefined' ? margin : topInset + margin,
});
const panelPositionRef = useRef<Position>(initialPosition());
const [panelPosition, setPanelPosition] = useState(panelPositionRef.current);
const clampPanelPosition = useCallback(
(next: Position) => {
const panel = panelRef.current;
const width = panel?.offsetWidth || Math.round(window.innerWidth * defaultWidthRatio);
const height = panel?.offsetHeight || Math.round(window.innerHeight * 0.82);
return {
x: Math.min(Math.max(margin, next.x), Math.max(margin, window.innerWidth - width - margin)),
y: Math.min(
Math.max(margin, next.y),
Math.max(margin, window.innerHeight - height - margin),
),
};
},
[defaultWidthRatio, margin, panelRef],
);
const updatePanelPosition = useCallback(
(next: Position) => {
const clamped = clampPanelPosition(next);
panelPositionRef.current = clamped;
setPanelPosition(clamped);
},
[clampPanelPosition],
);
const moveToDefaultPosition = useCallback(() => {
if (!enabled) return;
const panel = panelRef.current;
const width = panel?.offsetWidth || Math.round(window.innerWidth * defaultWidthRatio);
updatePanelPosition({
x: window.innerWidth - width - margin,
y: topInset + margin,
});
}, [defaultWidthRatio, enabled, margin, panelRef, topInset, updatePanelPosition]);
const handlePanelDragStart = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!enabled || pointerTargetIsInteractive(event.target)) return;
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const startPosition = panelPositionRef.current;
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'move';
const handleMove = (moveEvent: PointerEvent) => {
updatePanelPosition({
x: startPosition.x + moveEvent.clientX - startX,
y: startPosition.y + moveEvent.clientY - startY,
});
};
const handleEnd = () => {
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleEnd);
window.removeEventListener('pointercancel', handleEnd);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleEnd);
window.addEventListener('pointercancel', handleEnd);
},
[enabled, updatePanelPosition],
);
useEffect(() => {
if (!enabled) return;
const handleResize = () => updatePanelPosition(panelPositionRef.current);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [enabled, updatePanelPosition]);
return {
panelPosition,
panelPositionRef,
updatePanelPosition,
moveToDefaultPosition,
handlePanelDragStart,
};
};