perfect-postcode/frontend/src/hooks/usePaneResize.ts
Andras Schmelczer 349a6c1d53
Some checks failed
CI / Python (lint + test) (push) Failing after 3m38s
CI / Rust (lint + test) (push) Failing after 3m32s
CI / Frontend (lint + typecheck) (push) Failing after 4m12s
Build and publish Docker image / build-and-push (push) Failing after 4m48s
Fun changes
2026-04-04 22:59:44 +01:00

126 lines
4.1 KiB
TypeScript

import { useState, useCallback, useRef, useLayoutEffect } from 'react';
interface PaneResizeHandlers {
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: () => void;
}
export function usePaneResize(
initialSize: number,
minSize: number,
maxSize: number,
side: 'left' | 'right' | 'top' | 'bottom'
): [number, PaneResizeHandlers, React.RefCallback<HTMLElement>] {
const [size, setSize] = useState(initialSize);
const draggingRef = useRef(false);
const liveSizeRef = useRef(initialSize);
const targetRef = useRef<HTMLElement | null>(null);
const containerOffsetRef = useRef(0);
const containerSizeRef = useRef(0);
const rafRef = useRef<number | null>(null);
const isVertical = side === 'top' || side === 'bottom';
const styleProp = isVertical ? 'height' : 'width';
const targetCallbackRef = useCallback(
(el: HTMLElement | null) => {
targetRef.current = el;
if (el) {
el.style[styleProp] = `${liveSizeRef.current}px`;
}
},
[styleProp]
);
// Keep DOM in sync when React state commits (e.g. on pointerUp).
// This ensures the ref-managed element always reflects the latest size
// without relying on React-controlled style props.
useLayoutEffect(() => {
if (targetRef.current) {
targetRef.current.style[styleProp] = `${size}px`;
}
}, [size, styleProp]);
const computeSize = useCallback(
(e: React.PointerEvent): number => {
if (isVertical) {
const total = containerSizeRef.current || window.innerHeight;
const resolvedMax = maxSize <= 1 ? total * maxSize : maxSize;
const pos = e.clientY - containerOffsetRef.current;
return side === 'top'
? Math.min(resolvedMax, Math.max(minSize, pos))
: Math.min(resolvedMax, Math.max(minSize, total - pos));
} else {
const resolvedMax = maxSize <= 1 ? window.innerWidth * maxSize : maxSize;
return side === 'left'
? Math.min(resolvedMax, Math.max(minSize, e.clientX))
: Math.min(resolvedMax, Math.max(minSize, window.innerWidth - e.clientX));
}
},
[side, isVertical, minSize, maxSize]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true;
if (isVertical) {
const container = (e.currentTarget as HTMLElement).parentElement;
if (container) {
const rect = container.getBoundingClientRect();
containerOffsetRef.current = rect.top;
containerSizeRef.current = rect.height;
}
}
},
[isVertical]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingRef.current) return;
liveSizeRef.current = computeSize(e);
if (targetRef.current) {
// Batch DOM updates to once per animation frame — on mobile, pointermove
// can fire multiple times per frame, and each direct style.height write
// forces a synchronous reflow that desynchronises MapLibre and deck.gl.
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
if (targetRef.current) {
targetRef.current.style[styleProp] = `${liveSizeRef.current}px`;
}
});
}
} else {
setSize(liveSizeRef.current);
}
},
[computeSize, styleProp]
);
const handlePointerUp = useCallback(() => {
draggingRef.current = false;
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
// Apply final size synchronously so the commit is immediate
if (targetRef.current) {
targetRef.current.style[styleProp] = `${liveSizeRef.current}px`;
}
setSize(liveSizeRef.current);
}, [styleProp]);
return [
size,
{
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
},
targetCallbackRef,
];
}