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

204 lines
7.3 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.
import { describe, it, expect } from 'vitest';
import {
FIT_VIEW,
canZoom,
clampView,
isZoomed,
maxZoomScale,
oneToOneScale,
panBy,
pinchView,
stepView,
toggleView,
TOUCH_ZOOM_MAX_PIXELS,
zoomFitsDevice,
zoomAround,
type FitBox,
type ZoomView,
} from '../zoom';
/**
* The lightbox zoom geometry (#467). A 1000×600 fit box centred in a
* 1200×800 viewport, showing a 6000×3600 photo: 1:1 is six times the fit size
* on a 1× screen, three times on a 2× one.
*/
const viewport = { width: 1200, height: 800 };
const fit: FitBox = { cx: 600, cy: 400, width: 1000, height: 600 };
const NATURAL = 6000;
/** Where a photo point (offset from the box centre at fit) lands on screen. */
const screenOf = (view: ZoomView, u: number, v: number) => ({
x: fit.cx + view.x + u * view.scale,
y: fit.cy + view.y + v * view.scale,
});
describe('1:1 scale', () => {
it('is one image pixel per device pixel', () => {
expect(oneToOneScale(NATURAL, 1000, 1)).toBe(6);
expect(oneToOneScale(NATURAL, 1000, 2)).toBe(3);
expect(oneToOneScale(NATURAL, 1000, 3)).toBe(2);
});
it('accounts for a fractional ratio', () => {
expect(oneToOneScale(NATURAL, 1000, 1.5)).toBeCloseTo(4);
});
it('treats a missing or nonsense ratio as 1', () => {
expect(oneToOneScale(NATURAL, 1000, 0)).toBe(6);
expect(oneToOneScale(NATURAL, 1000, NaN)).toBe(6);
});
it('never goes below fit', () => {
// A 1500px photo on a 3× screen is already shown beyond 1:1 at fit.
expect(oneToOneScale(1500, 1000, 3)).toBe(0.5);
expect(maxZoomScale(1500, 1000, 3)).toBe(1);
expect(canZoom(maxZoomScale(1500, 1000, 3))).toBe(false);
expect(canZoom(maxZoomScale(NATURAL, 1000, 2))).toBe(true);
});
it('is 1 when a size is unknown', () => {
expect(oneToOneScale(0, 1000, 1)).toBe(1);
expect(oneToOneScale(NATURAL, 0, 1)).toBe(1);
});
});
describe('clamping', () => {
const max = 6;
it('keeps the scale between fit and 1:1', () => {
expect(clampView({ scale: 10, x: 0, y: 0 }, fit, viewport, max).scale).toBe(6);
expect(clampView({ scale: 0.5, x: 30, y: 30 }, fit, viewport, max)).toEqual(FIT_VIEW);
});
it('never shows an empty band beside a photo larger than the viewport', () => {
// At 6× the box is 6000×3600: its left edge may go no further right than
// the viewport's left edge, and so on.
const v = clampView({ scale: 6, x: 99999, y: -99999 }, fit, viewport, max);
expect(fit.cx + v.x - (fit.width * 6) / 2).toBeCloseTo(0);
expect(fit.cy + v.y + (fit.height * 6) / 2).toBeCloseTo(viewport.height);
});
it('keeps a photo smaller than the viewport on screen, where it was', () => {
// 1.1× is 1100 wide in a 1200 viewport: it may move 50px either way.
const centred = clampView({ scale: 1.1, x: 0, y: 0 }, fit, viewport, max);
expect(centred.x).toBe(0);
const pushed = clampView({ scale: 1.1, x: 500, y: 0 }, fit, viewport, max);
expect(pushed.x).toBeCloseTo(50);
});
it('pans by a delta, within the same bounds', () => {
const start = { scale: 3, x: 0, y: 0 };
expect(panBy(start, 100, -50, fit, viewport, max)).toEqual({ scale: 3, x: 100, y: -50 });
// 3000 wide in 1200: at most 900 either side of centre.
expect(panBy(start, 5000, 0, fit, viewport, max).x).toBeCloseTo(900);
});
});
describe('zooming around a point', () => {
const max = 6;
it('keeps the photo point under the cursor where it was', () => {
const point = { x: 700, y: 450 };
// Photo point under the cursor at fit:
const u = point.x - fit.cx;
const v = point.y - fit.cy;
const next = zoomAround(FIT_VIEW, 3, point, fit, viewport, max);
expect(next.scale).toBe(3);
const after = screenOf(next, u, v);
expect(after.x).toBeCloseTo(point.x);
expect(after.y).toBeCloseTo(point.y);
});
it('keeps it fixed from an already zoomed, panned view too', () => {
const start = { scale: 2, x: -150, y: 80 };
const point = { x: 400, y: 300 };
const u = (point.x - fit.cx - start.x) / start.scale;
const v = (point.y - fit.cy - start.y) / start.scale;
const next = zoomAround(start, 4, point, fit, viewport, max);
const after = screenOf(next, u, v);
expect(after.x).toBeCloseTo(point.x);
expect(after.y).toBeCloseTo(point.y);
});
it('gives way to the clamp at the photo edge', () => {
// A point at the very corner cannot stay put without exposing the
// background, so the photo edge wins.
const next = zoomAround(FIT_VIEW, 6, { x: 100, y: 100 }, fit, viewport, max);
expect(fit.cx + next.x - (fit.width * 6) / 2).toBeCloseTo(0);
});
it('double-click goes to 1:1 at the point, and back to fit', () => {
const point = { x: 650, y: 420 };
const zoomed = toggleView(FIT_VIEW, point, fit, viewport, max);
expect(zoomed.scale).toBe(max);
expect(isZoomed(zoomed)).toBe(true);
const after = screenOf(zoomed, point.x - fit.cx, point.y - fit.cy);
expect(after.x).toBeCloseTo(point.x);
expect(toggleView(zoomed, point, fit, viewport, max)).toEqual(FIT_VIEW);
});
it('steps by a factor around the viewport centre, and snaps back to fit', () => {
const once = stepView(FIT_VIEW, 2, fit, viewport, max);
expect(once.scale).toBe(2);
expect(stepView(stepView(once, 2, fit, viewport, max), 2, fit, viewport, max).scale).toBe(6);
expect(stepView(once, 0.5, fit, viewport, max)).toEqual(FIT_VIEW);
expect(stepView(once, 0.51, fit, viewport, max)).toEqual(FIT_VIEW);
});
});
describe('pinch', () => {
const max = 6;
it('scales by how far the fingers spread, around their midpoint', () => {
const mid = { x: 640, y: 380 };
const next = pinchView(FIT_VIEW, mid, 100, mid, 250, fit, viewport, max);
expect(next.scale).toBeCloseTo(2.5);
const after = screenOf(next, mid.x - fit.cx, mid.y - fit.cy);
expect(after.x).toBeCloseTo(mid.x);
expect(after.y).toBeCloseTo(mid.y);
});
it('follows the midpoint as the fingers move', () => {
const startMid = { x: 600, y: 400 };
const mid = { x: 660, y: 430 };
const next = pinchView({ scale: 3, x: 0, y: 0 }, startMid, 100, mid, 100, fit, viewport, max);
expect(next).toEqual({ scale: 3, x: 60, y: 30 });
});
it('pinching back past fit lands on fit', () => {
const mid = { x: 600, y: 400 };
expect(pinchView({ scale: 2, x: 40, y: 0 }, mid, 200, mid, 50, fit, viewport, max)).toEqual(
FIT_VIEW,
);
});
it('cannot pinch beyond 1:1', () => {
const mid = { x: 600, y: 400 };
expect(pinchView(FIT_VIEW, mid, 10, mid, 1000, fit, viewport, max).scale).toBe(max);
});
});
describe('zoomFitsDevice (review of #830)', () => {
it('limits touch devices to 50 MP', () => {
expect(TOUCH_ZOOM_MAX_PIXELS).toBe(50_000_000);
// Leica Q3, 60 MP: not on a phone, fine with a mouse.
expect(zoomFitsDevice(9520, 6336, true)).toBe(false);
expect(zoomFitsDevice(9520, 6336, false)).toBe(true);
// iPhone, 12 MP; and exactly at the limit.
expect(zoomFitsDevice(4032, 3024, true)).toBe(true);
expect(zoomFitsDevice(10000, 5000, true)).toBe(true);
});
it('needs a size', () => {
expect(zoomFitsDevice(undefined, 100, false)).toBe(false);
});
});
describe('isZoomed', () => {
it('counts a hair above fit as fit', () => {
expect(isZoomed(FIT_VIEW)).toBe(false);
expect(isZoomed({ scale: 1.01, x: 0, y: 0 })).toBe(false);
expect(isZoomed({ scale: 1.5, x: 0, y: 0 })).toBe(true);
});
});