510 lines
17 KiB
TypeScript
510 lines
17 KiB
TypeScript
'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 {
|
||
CLICK_ZOOM_SCALE,
|
||
FIT_VIEW,
|
||
canZoom,
|
||
clampView,
|
||
isZoomed,
|
||
maxZoomScale,
|
||
onFitBox,
|
||
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;
|
||
/** A mouse press held longer than this was not a click. */
|
||
const CLICK_MS = 500;
|
||
/** 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],
|
||
);
|
||
|
||
/**
|
||
* 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],
|
||
);
|
||
|
||
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' };
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
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);
|
||
};
|
||
}, [enabled, photoKey, surface, measure, apply, toggle, click]);
|
||
|
||
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,
|
||
};
|
||
}
|