'use client'; /** * Gesture wiring for the lightbox zoom (#467). The geometry is in lib/zoom.ts; * this hook measures the page, listens to the pointer, wheel and Safari * gesture events on the photo, and keeps the view. * * Pointer events throughout, never native drag: AssetProtection cancels * `dragstart` on every image when image dragging is disabled, which would * leave a drag-based pan dead on exactly the sites that care most about their * photos. * * The full-resolution file is asked for the first time the view leaves fit — * never before, and never for a neighbour. Until it arrives the preview is * shown scaled up, so a gesture answers at once; the file then takes its * place in the same box under the same transform, so nothing moves. */ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; import { FIT_VIEW, canZoom, clampView, isZoomed, maxZoomScale, panBy, pinchView, stepView, toggleView, viewTransform, zoomAround, type FitBox, type Point, type Viewport, type ZoomView, } from '@/lib/zoom'; export type ZoomStatus = 'idle' | 'loading' | 'loaded' | 'failed'; interface UseZoomOptions { /** Identifies the photo on screen; a new one resets the zoom. */ photoKey: string; /** * Width in pixels of the full-resolution image, upright. Undefined when the * photo cannot be zoomed, which switches every gesture off. */ naturalWidth?: number; /** The photo at fit size: measured for the geometry, and where gestures land. */ imageRef: RefObject; /** * The element that receives the gestures (the photo's container). An * element rather than a ref: the lightbox renders nothing until it has * mounted, so the listeners have to be attached when the element appears, * not when the hook first runs. */ surface: HTMLElement | null; } /** Two taps closer than this in time and space are a double-tap. */ const DOUBLE_TAP_MS = 300; const DOUBLE_TAP_PX = 30; /** A pointer that moved further than this was a drag, not a tap. */ const TAP_SLOP_PX = 10; /** The `+` / `-` step. */ const KEY_STEP = 2; /** Wheel delta per e-fold of zoom; see onWheel. */ const WHEEL_DIVISOR = 300; type Gesture = | { kind: 'none' } | { kind: 'pan'; pointerId: number; start: Point; startView: ZoomView } | { kind: 'pinch'; startView: ZoomView; startMid: Point; startDistance: number }; function prefersReducedMotion(): boolean { return ( typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ); } export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOptions) { const enabled = !!naturalWidth && naturalWidth > 0; const [view, setViewState] = useState(FIT_VIEW); const [animate, setAnimate] = useState(false); const [status, setStatus] = useState('idle'); // A new photo starts at fit, with nothing requested. Adjusted while // rendering rather than in an effect, so the new photo never renders once // with the old one's transform. const [shownKey, setShownKey] = useState(photoKey); if (shownKey !== photoKey) { setShownKey(photoKey); setViewState(FIT_VIEW); setAnimate(false); setStatus('idle'); } // The handlers below are attached once per photo and read the latest values // from refs, so a pan does not re-subscribe on every frame. const viewRef = useRef(FIT_VIEW); const naturalRef = useRef(naturalWidth ?? 0); const statusRef = useRef('idle'); useEffect(() => { viewRef.current = view; statusRef.current = status; }); useEffect(() => { naturalRef.current = naturalWidth ?? 0; }, [photoKey, naturalWidth]); /** The fit box, the viewport and the 1:1 scale, measured now. */ const measure = useCallback((): { fit: FitBox; viewport: Viewport; maxScale: number; } | null => { const img = imageRef.current; const parent = img?.parentElement; if (!img || !parent || !img.offsetWidth || !img.offsetHeight) return null; // Layout sizes, not getBoundingClientRect() of the image: that includes // the zoom transform. The container is never transformed. const rect = parent.getBoundingClientRect(); const fit = { width: img.offsetWidth, height: img.offsetHeight, cx: rect.left + img.offsetLeft + img.offsetWidth / 2, cy: rect.top + img.offsetTop + img.offsetHeight / 2, }; const viewport = { width: window.innerWidth, height: window.innerHeight }; const maxScale = maxZoomScale(naturalRef.current, fit.width, window.devicePixelRatio || 1); return { fit, viewport, maxScale }; }, [imageRef]); /** Show a view. `animated` for discrete steps; gestures follow the finger. */ const apply = useCallback((next: ZoomView, animated: boolean) => { viewRef.current = next; setAnimate(animated && !prefersReducedMotion()); setViewState(next); if (isZoomed(next) && statusRef.current === 'idle') { statusRef.current = 'loading'; setStatus('loading'); } }, []); const usable = useCallback(() => { if (!enabled || statusRef.current === 'failed') return null; const m = measure(); return m && canZoom(m.maxScale) ? m : null; }, [enabled, measure]); /** Fit ↔ 1:1 around a point; around the photo's centre without one. */ const toggle = useCallback( (point?: Point) => { const m = usable(); if (!m) { if (isZoomed(viewRef.current)) apply(FIT_VIEW, true); return; } const at = point ?? { x: m.fit.cx, y: m.fit.cy }; apply(toggleView(viewRef.current, at, m.fit, m.viewport, m.maxScale), true); }, [apply, usable], ); const step = useCallback( (factor: number) => { const m = usable(); if (!m) return; apply(stepView(viewRef.current, factor, m.fit, m.viewport, m.maxScale), true); }, [apply, usable], ); const zoomIn = useCallback(() => step(KEY_STEP), [step]); const zoomOut = useCallback(() => step(1 / KEY_STEP), [step]); const reset = useCallback(() => apply(FIT_VIEW, true), [apply]); /** The full-resolution file arrived: correct 1:1 if the estimate was off. */ const onFullLoad = useCallback( (img: HTMLImageElement) => { const before = measure(); if (img.naturalWidth > 0) naturalRef.current = img.naturalWidth; statusRef.current = 'loaded'; setStatus('loaded'); const after = measure(); if (!before || !after || before.maxScale === after.maxScale) return; const current = viewRef.current; // Someone who asked for 1:1 still gets 1:1 against the real size. const atMax = Math.abs(current.scale - before.maxScale) < 1e-3; const next = atMax ? zoomAround( current, after.maxScale, { x: after.viewport.width / 2, y: after.viewport.height / 2 }, after.fit, after.viewport, after.maxScale, ) : clampView(current, after.fit, after.viewport, after.maxScale); apply(next, false); }, [apply, measure], ); /** No full-resolution file (Immich has none, or the request failed). */ const onFullError = useCallback(() => { statusRef.current = 'failed'; setStatus('failed'); apply(FIT_VIEW, false); }, [apply]); // A resized window changes the fit box under the view; starting over at fit // is simpler and less surprising than re-deriving the pan. useEffect(() => { if (!enabled) return; const onResize = () => { if (isZoomed(viewRef.current)) apply(FIT_VIEW, false); }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [enabled, apply]); // ── Gestures ────────────────────────────────────────────────────────────── useEffect(() => { if (!enabled || !surface) return; const pointers = new Map(); const downs = new Map(); let gesture: Gesture = { kind: 'none' }; /** Two fingers were down at some point of the current touch sequence. */ let multi = false; let lastTap: { t: number; x: number; y: number } | null = null; let lastPointerType = ''; const midAndDistance = () => { const [a, b] = [...pointers.values()]; return { mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, distance: Math.hypot(a.x - b.x, a.y - b.y), }; }; const startPan = (pointerId: number, at: Point) => { gesture = { kind: 'pan', pointerId, start: at, startView: viewRef.current }; }; const onPointerDown = (e: PointerEvent) => { lastPointerType = e.pointerType; if (e.pointerType === 'mouse' && e.button !== 0) return; if (statusRef.current === 'failed') return; const at = { x: e.clientX, y: e.clientY }; pointers.set(e.pointerId, at); downs.set(e.pointerId, { t: e.timeStamp, ...at, moved: false }); try { surface.setPointerCapture(e.pointerId); } catch { // Synthetic events (tests) have no active pointer to capture. } if (pointers.size === 2) { multi = true; const { mid, distance } = midAndDistance(); gesture = { kind: 'pinch', startView: viewRef.current, startMid: mid, startDistance: distance, }; } else if (pointers.size === 1 && isZoomed(viewRef.current)) { startPan(e.pointerId, at); // No text selection or image drag behind a mouse pan. if (e.pointerType === 'mouse') e.preventDefault(); } }; const onPointerMove = (e: PointerEvent) => { if (!pointers.has(e.pointerId)) return; const at = { x: e.clientX, y: e.clientY }; pointers.set(e.pointerId, at); const down = downs.get(e.pointerId); if (down && Math.hypot(at.x - down.x, at.y - down.y) > TAP_SLOP_PX) down.moved = true; if (gesture.kind === 'pinch' && pointers.size >= 2) { const m = measure(); if (!m || !canZoom(m.maxScale)) return; const { mid, distance } = midAndDistance(); const g = gesture; apply( pinchView( g.startView, g.startMid, g.startDistance, mid, distance, m.fit, m.viewport, m.maxScale, ), false, ); } else if (gesture.kind === 'pan' && gesture.pointerId === e.pointerId) { const m = measure(); if (!m) return; const g = gesture; apply( panBy(g.startView, at.x - g.start.x, at.y - g.start.y, m.fit, m.viewport, m.maxScale), false, ); } }; const onPointerEnd = (e: PointerEvent) => { if (!pointers.has(e.pointerId)) return; const down = downs.get(e.pointerId); pointers.delete(e.pointerId); downs.delete(e.pointerId); if (gesture.kind === 'pinch') { if (pointers.size === 1 && isZoomed(viewRef.current)) { const [[id, at]] = [...pointers.entries()]; startPan(id, at); } else if (pointers.size < 2) { gesture = { kind: 'none' }; } // A pinch let go just above fit lands on fit. if (pointers.size === 0 && !isZoomed(viewRef.current)) apply(FIT_VIEW, false); } else if (gesture.kind === 'pan' && pointers.size === 0) { gesture = { kind: 'none' }; } const wasTap = e.type === 'pointerup' && e.pointerType !== 'mouse' && !multi && down && !down.moved && e.timeStamp - down.t < DOUBLE_TAP_MS; if (pointers.size === 0) multi = false; if (!wasTap) return; // Double-tap: a second tap close to the first, soon after it. if ( lastTap && e.timeStamp - lastTap.t < DOUBLE_TAP_MS && Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) < DOUBLE_TAP_PX ) { lastTap = null; toggle({ x: e.clientX, y: e.clientY }); } else { lastTap = { t: e.timeStamp, x: e.clientX, y: e.clientY }; } }; // Mouse only: touch has its own double-tap above, and some browsers also // synthesise a dblclick from it, which would toggle straight back. const onDoubleClick = (e: MouseEvent) => { if (lastPointerType && lastPointerType !== 'mouse') return; e.preventDefault(); toggle({ x: e.clientX, y: e.clientY }); }; // Ctrl+wheel is how Chrome, Firefox and Edge report a trackpad pinch (and // what a mouse user presses to zoom); a plain wheel pans once zoomed. const onWheel = (e: WheelEvent) => { if (statusRef.current === 'failed') return; const perLine = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1; if (e.ctrlKey) { const m = measure(); if (!m || !canZoom(m.maxScale)) return; e.preventDefault(); // A trackpad pinch sends many small deltas, a mouse wheel one large // one per notch (100 or more): about 1.4× a notch, never more than // 2× per event, so a single notch cannot jump straight to 1:1. const factor = Math.min(2, Math.max(0.5, Math.exp((-e.deltaY * perLine) / WHEEL_DIVISOR))); const current = viewRef.current; const next = zoomAround( current, current.scale * factor, { x: e.clientX, y: e.clientY }, m.fit, m.viewport, m.maxScale, ); apply(isZoomed(next) ? next : FIT_VIEW, false); } else if (isZoomed(viewRef.current)) { const m = measure(); if (!m) return; e.preventDefault(); apply( panBy( viewRef.current, -e.deltaX * perLine, -e.deltaY * perLine, m.fit, m.viewport, m.maxScale, ), false, ); } }; // Safari reports a trackpad pinch as gesture events, not as ctrl+wheel. // On iOS the same events accompany a two-finger touch, which the pointer // handlers already follow — so they are only used with no pointer down. let gestureStart: ZoomView | null = null; type SafariGesture = Event & { scale: number; clientX: number; clientY: number }; const onGestureStart = (e: Event) => { if (pointers.size > 0 || statusRef.current === 'failed') return; e.preventDefault(); gestureStart = viewRef.current; }; const onGestureChange = (e: Event) => { if (!gestureStart || pointers.size > 0) return; e.preventDefault(); const g = e as SafariGesture; const m = measure(); if (!m || !canZoom(m.maxScale)) return; const next = zoomAround( viewRef.current, gestureStart.scale * g.scale, { x: g.clientX, y: g.clientY }, m.fit, m.viewport, m.maxScale, ); apply(isZoomed(next) ? next : FIT_VIEW, false); }; const onGestureEnd = () => { gestureStart = null; }; surface.addEventListener('pointerdown', onPointerDown); surface.addEventListener('pointermove', onPointerMove); surface.addEventListener('pointerup', onPointerEnd); surface.addEventListener('pointercancel', onPointerEnd); surface.addEventListener('dblclick', onDoubleClick); surface.addEventListener('wheel', onWheel, { passive: false }); surface.addEventListener('gesturestart', onGestureStart); surface.addEventListener('gesturechange', onGestureChange); surface.addEventListener('gestureend', onGestureEnd); return () => { surface.removeEventListener('pointerdown', onPointerDown); surface.removeEventListener('pointermove', onPointerMove); surface.removeEventListener('pointerup', onPointerEnd); surface.removeEventListener('pointercancel', onPointerEnd); surface.removeEventListener('dblclick', onDoubleClick); surface.removeEventListener('wheel', onWheel); surface.removeEventListener('gesturestart', onGestureStart); surface.removeEventListener('gesturechange', onGestureChange); surface.removeEventListener('gestureend', onGestureEnd); }; }, [enabled, photoKey, surface, measure, apply, toggle]); const zoomed = isZoomed(view); /** The full-resolution image is in the page (requested, loading or shown). */ const requested = status !== 'idle'; return { /** Gestures do something for this photo. */ enabled: enabled && status !== 'failed', zoomed, status, requested, /** * Inline style for both images. Absent until zoom has been used on this * photo, so the stylesheet's own entry animation is left alone. */ imageStyle: zoomed || requested ? { transform: viewTransform(view), transition: animate ? 'transform 220ms cubic-bezier(0.2, 0.7, 0.3, 1)' : 'none', } : undefined, toggle, zoomIn, zoomOut, reset, onFullLoad, onFullError, }; }