Files
cinny/src/app/hooks/usePan.ts
T
jaredandClaude Opus 4.8 836e4a6679 fix(mobile): full-screen media viewers + touch-pan for zoomed images (M3)
Mobile-audit batch 3. All changes mobile-gated (@media <=750px) so desktop is
unchanged.

- ModalWide: fill the phone screen (100vw/100vh, no radius) at <=750px instead
  of floating as an 85vw card. This also full-screens the file/PDF viewer and
  the avatar-crop editor on mobile (they share ModalWide) — intended.
- UserHero avatar viewer: new mobile-only ModalMobileFull class (no desktop
  effect) so it goes edge-to-edge on phones like the timeline lightbox.
- usePan: add touch support (single-finger drag, cleaned up on
  touchend/cancel/unmount) alongside the unchanged mouse path, so a zoomed image
  can be panned on a phone. Wired into ImageViewer and the MediaGallery lightbox.

Two review passes: mouse path byte-for-byte unchanged; desktop provably
unaffected; touch is gated to zoom!=1 so a non-zoomed image never hijacks
swipe/scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:24:26 -04:00

123 lines
3.9 KiB
TypeScript

import { MouseEventHandler, TouchEventHandler, useEffect, useRef, useState } from 'react';
export type Pan = {
translateX: number;
translateY: number;
};
const INITIAL_PAN = {
translateX: 0,
translateY: 0,
};
export const usePan = (active: boolean) => {
const [pan, setPan] = useState<Pan>(INITIAL_PAN);
const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>(
active ? 'grab' : 'initial',
);
// Track the exact handler references that were passed to addEventListener so
// we can remove them even if the component re-renders or unmounts mid-drag.
const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
const touchAttachedRef = useRef<{
move: (e: TouchEvent) => void;
end: (e: TouchEvent) => void;
} | null>(null);
useEffect(() => {
setCursor(active ? 'grab' : 'initial');
}, [active]);
const handleMouseDown: MouseEventHandler<HTMLElement> = (evt) => {
if (!active) return;
evt.preventDefault();
setCursor('grabbing');
const handleMouseMove = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setPan((p) => ({
translateX: p.translateX + e.movementX,
translateY: p.translateY + e.movementY,
}));
};
const handleMouseUp = (e: MouseEvent) => {
e.preventDefault();
setCursor('grab');
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
attachedRef.current = null;
};
attachedRef.current = { move: handleMouseMove, up: handleMouseUp };
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// Touch equivalent so a zoomed image can be dragged on a phone. Single-finger
// only (ignore multi-touch / pinch); touch events carry no movementX/Y, so we
// derive the delta from the previous touch position.
const handleTouchStart: TouchEventHandler<HTMLElement> = (evt) => {
if (!active || evt.touches.length !== 1) return;
setCursor('grabbing');
let lastX = evt.touches[0].clientX;
let lastY = evt.touches[0].clientY;
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
e.preventDefault();
const t = e.touches[0];
const dx = t.clientX - lastX;
const dy = t.clientY - lastY;
lastX = t.clientX;
lastY = t.clientY;
setPan((p) => ({ translateX: p.translateX + dx, translateY: p.translateY + dy }));
};
const handleTouchEnd = () => {
setCursor('grab');
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
touchAttachedRef.current = null;
};
touchAttachedRef.current = { move: handleTouchMove, end: handleTouchEnd };
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
};
useEffect(() => {
if (!active) setPan(INITIAL_PAN);
}, [active]);
// Remove listeners if the component unmounts while a drag is in progress.
useEffect(
() => () => {
if (attachedRef.current) {
document.removeEventListener('mousemove', attachedRef.current.move);
document.removeEventListener('mouseup', attachedRef.current.up);
attachedRef.current = null;
}
if (touchAttachedRef.current) {
document.removeEventListener('touchmove', touchAttachedRef.current.move);
document.removeEventListener('touchend', touchAttachedRef.current.end);
document.removeEventListener('touchcancel', touchAttachedRef.current.end);
touchAttachedRef.current = null;
}
},
[],
);
return {
pan,
cursor,
onMouseDown: handleMouseDown,
onTouchStart: handleTouchStart,
};
};