Files
immich-folio/lib/zoom.ts
2026-10-02 18:14:23 +02:00

235 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Zoom geometry for the lightbox (#467). Pure, so it can be tested without a
* DOM; the gesture wiring lives in hooks/useZoom.ts.
*
* The model: the photo is laid out at its "fit" size, as the lightbox always
* shows it, and zooming applies `translate(x, y) scale(scale)` to that box
* with the transform origin at its centre. `scale: 1` is fit. Every
* coordinate is in CSS pixels relative to the viewport.
*
* The top of the range is 1:1 — one image pixel per *device* pixel, so the
* CSS size at 1:1 is the image width divided by devicePixelRatio. That is the
* view a sharpness check needs. Going further only interpolates pixels: it
* shows nothing the file does not hold and makes good focus look soft, so the
* range is clamped to [fit, 1:1] rather than [fit, 2 × 1:1].
*/
/** The current transform of the fit box. */
export interface ZoomView {
scale: number;
/** Translation of the box centre, CSS px. */
x: number;
y: number;
}
/** Where the photo sits at fit: the centre of its box and its size, CSS px. */
export interface FitBox {
cx: number;
cy: number;
width: number;
height: number;
}
export interface Viewport {
width: number;
height: number;
}
export interface Point {
x: number;
y: number;
}
/**
* The largest image offered for zoom on a touch device (coarse pointer), in
* pixels. Showing a photo at 1:1 means decoding all of it: 50 MP is 200 MB of
* RGBA, about what phones and tablets cope with alongside the page. Larger
* files (a 60 MP Leica Q3 frame, 240 MB decoded) get no zoom control there,
* rather than one that crashes the tab. Mouse and trackpad devices are not
* limited.
*/
export const TOUCH_ZOOM_MAX_PIXELS = 50_000_000;
/** Whether a photo of this pixel size may be zoomed on this kind of device. */
export function zoomFitsDevice(
width: number | undefined,
height: number | undefined,
coarsePointer: boolean,
): boolean {
if (!width || !height) return false;
return !coarsePointer || width * height <= TOUCH_ZOOM_MAX_PIXELS;
}
/** The untouched view. */
export const FIT_VIEW: ZoomView = Object.freeze({ scale: 1, x: 0, y: 0 });
/**
* Below this the view counts as fit. Pinching back almost to fit should land
* on fit, not leave the photo a pixel off and swipe navigation switched off.
*/
const FIT_EPSILON = 0.02;
export function isZoomed(view: ZoomView): boolean {
return view.scale > 1 + FIT_EPSILON;
}
/**
* The scale (relative to fit) at which one image pixel covers one device
* pixel. Below 1 when the photo is already shown at more than 1:1 — a small
* file on a large screen — which leaves nothing to zoom into.
*/
export function oneToOneScale(naturalWidth: number, fitWidth: number, dpr: number): number {
if (!(naturalWidth > 0) || !(fitWidth > 0)) return 1;
const ratio = dpr > 0 && Number.isFinite(dpr) ? dpr : 1;
return naturalWidth / ratio / fitWidth;
}
/** The top of the zoom range: 1:1, and never below fit. */
export function maxZoomScale(naturalWidth: number, fitWidth: number, dpr: number): number {
return Math.max(1, oneToOneScale(naturalWidth, fitWidth, dpr));
}
/** Whether zooming would show anything the fit view does not. */
export function canZoom(maxScale: number): boolean {
return maxScale > 1 + FIT_EPSILON;
}
export function clampScale(scale: number, maxScale: number): number {
if (!Number.isFinite(scale)) return 1;
return Math.min(Math.max(scale, 1), Math.max(1, maxScale));
}
/**
* The allowed range for one axis of the translation.
*
* A box larger than the viewport may move until its edge meets the viewport
* edge, never further — no empty band appears beside the photo. A box smaller
* than the viewport may move anywhere that keeps it fully on screen; that
* range always contains 0, so a photo that is barely zoomed stays where it was
* instead of jumping to the centre.
*/
function axisRange(centre: number, half: number, extent: number): [number, number] {
const a = half - centre;
const b = extent - centre - half;
return [Math.min(a, b), Math.max(a, b)];
}
/** Keep the photo covering the viewport (or fully on it), at a clamped scale. */
export function clampView(
view: ZoomView,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
const scale = clampScale(view.scale, maxScale);
if (scale <= 1) return { ...FIT_VIEW };
const [minX, maxX] = axisRange(fit.cx, (fit.width * scale) / 2, viewport.width);
const [minY, maxY] = axisRange(fit.cy, (fit.height * scale) / 2, viewport.height);
return {
scale,
x: Math.min(Math.max(view.x, minX), maxX),
y: Math.min(Math.max(view.y, minY), maxY),
};
}
/**
* Change the scale while the photo point under `point` stays under it — the
* cursor for a wheel or a double-click, the midpoint of two fingers for a
* pinch. Clamping can still move it at the edges, which is the price of never
* showing an empty band.
*/
export function zoomAround(
view: ZoomView,
nextScale: number,
point: Point,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
const scale = clampScale(nextScale, maxScale);
// The photo point under `point`, as an offset from the box centre at scale 1.
const u = (point.x - fit.cx - view.x) / view.scale;
const v = (point.y - fit.cy - view.y) / view.scale;
return clampView(
{ scale, x: point.x - fit.cx - u * scale, y: point.y - fit.cy - v * scale },
fit,
viewport,
maxScale,
);
}
/**
* The pinch step: the photo point that was under the fingers' midpoint when
* the pinch began follows the midpoint, at the start scale times how far the
* fingers spread. Computed from the start of the gesture rather than
* incrementally, so rounding cannot drift the photo away under the fingers.
*/
export function pinchView(
start: ZoomView,
startMid: Point,
startDistance: number,
mid: Point,
distance: number,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
const factor = startDistance > 0 ? distance / startDistance : 1;
const scale = clampScale(start.scale * factor, maxScale);
const u = (startMid.x - fit.cx - start.x) / start.scale;
const v = (startMid.y - fit.cy - start.y) / start.scale;
return clampView(
{ scale, x: mid.x - fit.cx - u * scale, y: mid.y - fit.cy - v * scale },
fit,
viewport,
maxScale,
);
}
/** Move the photo by a pointer delta, clamped. */
export function panBy(
view: ZoomView,
dx: number,
dy: number,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
return clampView({ ...view, x: view.x + dx, y: view.y + dy }, fit, viewport, maxScale);
}
/** Double-click / double-tap: fit ↔ 1:1, around the point. */
export function toggleView(
view: ZoomView,
point: Point,
fit: FitBox,
viewport: Viewport,
maxScale: number,
): ZoomView {
if (isZoomed(view)) return { ...FIT_VIEW };
return zoomAround(view, maxScale, point, fit, viewport, maxScale);
}
/**
* The `+` / `-` keys and the wheel: multiply the scale, around the centre of
* the viewport unless a point is given. A step that lands within reach of fit
* snaps to it.
*/
export function stepView(
view: ZoomView,
factor: number,
fit: FitBox,
viewport: Viewport,
maxScale: number,
point?: Point,
): ZoomView {
const at = point ?? { x: viewport.width / 2, y: viewport.height / 2 };
const next = zoomAround(view, view.scale * factor, at, fit, viewport, maxScale);
return isZoomed(next) ? next : { ...FIT_VIEW };
}
/** The CSS transform for a view. */
export function viewTransform(view: ZoomView): string {
return `translate3d(${view.x}px, ${view.y}px, 0) scale(${view.scale})`;
}