Files
immich-folio/hooks/useZoom.ts

510 lines
17 KiB
TypeScript
Raw Normal View History

2026-10-02 18:14:23 +02:00
'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 {
2026-10-04 09:49:35 +02:00
CLICK_ZOOM_SCALE,
2026-10-02 18:14:23 +02:00
FIT_VIEW,
canZoom,
clampView,
isZoomed,
maxZoomScale,
2026-10-04 09:49:35 +02:00
onFitBox,
2026-10-02 18:14:23 +02:00
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<HTMLImageElement | null>;
/**
* 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;
2026-10-04 09:49:35 +02:00
/** A mouse press held longer than this was not a click. */
const CLICK_MS = 500;
2026-10-02 18:14:23 +02:00
/** 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<ZoomView>(FIT_VIEW);
const [animate, setAnimate] = useState(false);
const [status, setStatus] = useState<ZoomStatus>('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<ZoomView>(FIT_VIEW);
const naturalRef = useRef(naturalWidth ?? 0);
const statusRef = useRef<ZoomStatus>('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],
);
2026-10-04 09:49:35 +02:00
/**
* A mouse click: on the photo at fit, a closer look around the cursor;
* anywhere while zoomed, back to fit. A click beside the photo does nothing.
*/
const click = useCallback(
(point: Point) => {
if (isZoomed(viewRef.current)) {
apply(FIT_VIEW, true);
return;
}
const m = usable();
if (!m || !onFitBox(point, m.fit)) return;
apply(
toggleView(viewRef.current, point, m.fit, m.viewport, m.maxScale, CLICK_ZOOM_SCALE),
true,
);
},
[apply, usable],
);
2026-10-02 18:14:23 +02:00
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<number, Point>();
const downs = new Map<number, { t: number; x: number; y: number; moved: boolean }>();
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;
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) => {
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' };
}
2026-10-04 09:49:35 +02:00
// A mouse press that neither moved nor lingered is a click; one that
// moved was a pan and leaves the view where the pan put it.
const wasClick =
e.type === 'pointerup' &&
e.pointerType === 'mouse' &&
down &&
!down.moved &&
e.timeStamp - down.t < CLICK_MS;
if (wasClick) {
click({ x: e.clientX, y: e.clientY });
return;
}
2026-10-02 18:14:23 +02:00
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 };
}
};
// 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('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('wheel', onWheel);
surface.removeEventListener('gesturestart', onGestureStart);
surface.removeEventListener('gesturechange', onGestureChange);
surface.removeEventListener('gestureend', onGestureEnd);
};
2026-10-04 09:49:35 +02:00
}, [enabled, photoKey, surface, measure, apply, toggle, click]);
2026-10-02 18:14:23 +02:00
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,
};
}