From 9270546c9dbb66b94f92176ecdc676aaecddd86d Mon Sep 17 00:00:00 2001
From: Ralksta <16941050+ralksta@users.noreply.github.com>
Date: Sun, 4 Oct 2026 09:49:35 +0200
Subject: [PATCH] release: v0.20.1
---
CHANGELOG.md | 26 +++++++++++
README.md | 15 ++++---
app/install/InstallWizard.tsx | 4 +-
components/__tests__/LightboxZoom.test.tsx | 42 +++++++++++++++---
docs/gallery-config.md | 26 ++++++++---
hooks/useZoom.ts | 51 ++++++++++++++++------
lib/__tests__/zoom.test.ts | 23 +++++++++-
lib/i18n/locales/de.ts | 2 +-
lib/i18n/locales/en.ts | 2 +-
lib/i18n/locales/es.ts | 2 +-
lib/i18n/locales/fr.ts | 2 +-
lib/i18n/locales/it.ts | 2 +-
lib/i18n/locales/nl.ts | 2 +-
lib/immich.ts | 7 ++-
lib/zoom.ts | 24 ++++++++--
package-lock.json | 4 +-
package.json | 2 +-
17 files changed, 189 insertions(+), 47 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55d000f..eaa639c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
+## [0.20.1] — 2026-10-04
+
+### Changed
+
+- **A single click zooms the lightbox photo in by 1.5×**
+ ([#835](https://github.com/ralksta/immich-folio/pull/835)). The first
+ version of zoom needed a double-click and went straight to 1:1, which was
+ more than visitors wanted when they only wanted a closer look. A click now
+ zooms to 1.5× around the cursor and a second click returns to fit. A press
+ that moves more than 10 px or is held longer than half a second is a pan, so
+ dragging a zoomed photo no longer drops out of the zoom. Ctrl and wheel,
+ pinch, touch double-tap, the magnifier button (still 1:1) and `+`, `-`, `0`
+ and `Esc` work as before.
+
+### Fixed
+
+- **Photo zoom and originals download need the `asset.download` permission**
+ on the Immich API key, which the setup guide never said. Zoom into a JPEG or
+ AVIF, the single download and both ZIPs stream `/assets/:id/original`, and
+ Immich answers `403` to a key that only has `asset.read` and `asset.view`.
+ The README permission table (which also credited `asset.read` with
+ originals), the photo zoom and originals download sections of the gallery
+ guide and the setup wizard hint now name it, and the log says so when a
+ `403` hits an original. Nothing changes for a site that uses neither
+ feature. Reported by a user who had to widen their key after enabling zoom.
+
## [0.20.0] — 2026-10-02
### Security
diff --git a/README.md b/README.md
index 06d2413..18afd88 100644
--- a/README.md
+++ b/README.md
@@ -237,11 +237,14 @@ PROOFING_WEBHOOK_URL=https://… # notify on submitted client proofing s
Create a dedicated API key in Immich under **Account Settings → API Keys**. Immich Folio only needs **read access** — it never modifies your library.
-| Permission | Required | Used for |
-| ------------ | -------- | -------------------------------------------------------------------- |
-| `album.read` | ✅ Yes | List and fetch album metadata & photo lists |
-| `asset.read` | ✅ Yes | Fetch asset metadata, EXIF data, thumbnails, previews, and originals |
-| `asset.view` | ✅ Yes | Stream image/video files (thumbnail, preview, video playback) |
+| Permission | Required | Used for |
+| ---------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `album.read` | ✅ Yes | List and fetch album metadata & photo lists |
+| `asset.read` | ✅ Yes | Fetch asset metadata, EXIF data and search results |
+| `asset.view` | ✅ Yes | Stream image/video files (thumbnail, preview, video playback) |
+| `asset.download` | Only for zoom and downloads | Stream originals: [photo zoom](docs/gallery-config.md#photo-zoom) on JPEG/AVIF and [originals download](docs/gallery-config.md#originals-download) |
+
+> **Zoom and downloads need `asset.download`.** Without it Immich answers `403` for the original file, so the zoom button reports that full resolution is unavailable and downloads fail. Leave it off if you use neither feature.
> **No write permissions needed.** `album.create`, `asset.upload`, `asset.delete`, etc. can all be left **off**.
@@ -347,7 +350,7 @@ own password, separate from any album passwords, and writes straight to
v0.20.0 — zoom, five new presets and photos as you edited them:
-- **1:1 zoom in the lightbox** — double-click, pinch or press `+` to see a photo at full resolution and judge its sharpness; off by default, switched on for the site, a page or a single album ([guide](docs/gallery-config.md#photo-zoom))
+- **1:1 zoom in the lightbox** — click, pinch, Ctrl + scroll or press `+` to see a photo at full resolution and judge its sharpness; off by default, switched on for the site, a page or a single album ([guide](docs/gallery-config.md#photo-zoom))
- **Five new theme presets** — Kunsthalle (a grey gallery wall with numbered wall labels), Ma, Cyanotype, Salon and Birch, each in dark and light; the passepartout mat of Studio, Classic and Noir is finally visible too
- **Edits made in Immich show up** — crops and rotations from Immich's editor appear in the grid, the lightbox, share images and downloads
- **No GPS in downloads** — original downloads and ZIPs keep camera data and colour profile but lose the coordinates and sub-city place names ([advisory](https://github.com/ralksta/immich-folio/security/advisories/GHSA-g4xf-4285-4cv4))
diff --git a/app/install/InstallWizard.tsx b/app/install/InstallWizard.tsx
index c8a1a7f..f9ed629 100644
--- a/app/install/InstallWizard.tsx
+++ b/app/install/InstallWizard.tsx
@@ -238,7 +238,9 @@ export function InstallWizard({ initialApiUrl, setupToken }: Props) {
disabled={testing}
/>
- Create one in Immich under Admin Settings → API Keys.
+ Create one in Immich under Account Settings → API Keys. It needs album.read,
+ asset.read and asset.view, plus asset.download if you want photo zoom or
+ original downloads.
diff --git a/components/__tests__/LightboxZoom.test.tsx b/components/__tests__/LightboxZoom.test.tsx
index 60d7148..05c64d8 100644
--- a/components/__tests__/LightboxZoom.test.tsx
+++ b/components/__tests__/LightboxZoom.test.tsx
@@ -7,7 +7,7 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
/**
* The lightbox zoom (#467), wired up: the control, the keys, Esc order,
- * double-click, swipe while zoomed, and that the full-resolution file is
+ * mouse click and pan, swipe while zoomed, and that the full-resolution file is
* requested only when the visitor zooms.
*
* jsdom does no layout, so the fit box is stubbed: every image is laid out at
@@ -229,14 +229,44 @@ describe('keys', () => {
});
describe('gestures', () => {
- it('double-click goes to 1:1 around the cursor, and back', () => {
+ /** A mouse press at a point, released `moveBy` px further on. */
+ const press = (surface: HTMLElement, x: number, y: number, moveBy = 0) => {
+ const at = { pointerId: 1, pointerType: 'mouse', button: 0, clientY: y };
+ fireEvent.pointerDown(surface, { ...at, clientX: x });
+ if (moveBy) fireEvent.pointerMove(surface, { ...at, clientX: x + moveBy });
+ fireEvent.pointerUp(surface, { ...at, clientX: x + moveBy });
+ };
+
+ it('a click on the photo zooms in by half around the cursor, and back', () => {
+ render();
+ const surface = preview().parentElement!;
+ press(surface, 600, 300);
+ // Fit box centre (500, 300); the cursor 100px right of it stays put at
+ // 1.5×, and the top edge is clamped to the 768px jsdom viewport.
+ expect(preview().style.transform).toBe('translate3d(-50px, 18px, 0) scale(1.5)');
+ press(surface, 600, 300);
+ expect(preview().style.transform).toContain('scale(1)');
+ });
+
+ it('a drag pans the zoomed photo instead of leaving the zoom', () => {
+ render();
+ const surface = preview().parentElement!;
+ press(surface, 600, 300);
+ press(surface, 600, 300, 100);
+ expect(preview().style.transform).toBe('translate3d(50px, 18px, 0) scale(1.5)');
+ });
+
+ it('a click beside the photo does not zoom', () => {
+ render();
+ press(preview().parentElement!, 1010, 300);
+ expect(preview().style.transform).toBe('');
+ });
+
+ it('the dblclick event itself no longer zooms', () => {
render();
const surface = preview().parentElement!;
fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 });
- // Fit box centre (500, 300); the cursor 100px right of it stays put at 6×.
- expect(preview().style.transform).toBe('translate3d(-500px, 0px, 0) scale(6)');
- fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 });
- expect(preview().style.transform).toContain('scale(1)');
+ expect(preview().style.transform).toBe('');
});
it('no swipe navigation while zoomed', () => {
diff --git a/docs/gallery-config.md b/docs/gallery-config.md
index 7bdc282..d21fae3 100644
--- a/docs/gallery-config.md
+++ b/docs/gallery-config.md
@@ -262,6 +262,9 @@ albums:
download: true
```
+The API key needs the `asset.download` permission in Immich, since originals
+are streamed from `/assets/:id/original`.
+
Once enabled, visitors get a **Download album** link in the album header (every
original as a ZIP), and — where [client proofing](#client-proofing) is on — a
**Download selected (.zip)** action in the export dialog that zips just the
@@ -762,13 +765,15 @@ In the admin panel the switch is under **Settings › General › Portfolio
Features**, and the page and album drawers in the page builder have an
_Inherit / On / Off_ choice.
-**Using it.** Double-click (or double-tap) zooms to 1:1 around the point
-clicked and back to fit; so does the magnifier button in the lightbox bar.
-Pinch, or Ctrl + scroll wheel / trackpad pinch, zooms continuously between fit
-and 1:1 — never further, since enlarging past 1:1 only interpolates pixels and
-makes good focus look soft. Drag (or one finger) pans; `+` / `-` step, `0` and
-`Esc` go back to fit (a second `Esc` closes the viewer). Swiping to the next
-photo is off while zoomed, and changing photos resets the zoom.
+**Using it.** A click on the photo zooms in by half (1.5× the fit size)
+around the point clicked, and a second click goes back to fit. Double-tap on a
+touch screen, or the magnifier button in the lightbox bar, zooms straight to
+1:1 and back. Pinch, or Ctrl + scroll wheel / trackpad pinch, zooms
+continuously between fit and 1:1 — never further, since enlarging past 1:1
+only interpolates pixels and makes good focus look soft. Drag (or one finger)
+pans; `+` / `-` step, `0` and `Esc` go back to fit (a second `Esc` closes the
+viewer). Swiping to the next photo is off while zoomed, and changing photos
+resets the zoom.
**What is shown.** The full-resolution file is requested only when a visitor
zooms — never on opening the viewer, and never for the neighbouring photos.
@@ -788,6 +793,13 @@ GPS coordinates and place names below city level are overwritten in place;
camera data and colour profile stay. A file whose metadata cannot be read is
refused rather than sent.
+Zoom into a JPEG or AVIF streams the original from Immich, so the API key
+needs the `asset.download` permission (_Account Settings › API Keys_). With
+only `asset.read` and `asset.view`, Immich answers `403` and the lightbox says
+full resolution is unavailable; the Folio log shows
+`Failed to stream … 403`. The same permission covers
+[originals download](#originals-download).
+
Formats a browser cannot display need Immich's **full-size preview**
(_Administration › Settings › Image Settings › Full-size image_, JPEG format).
Immich generates it only while that setting is on, so turn it on and run the
diff --git a/hooks/useZoom.ts b/hooks/useZoom.ts
index 68eaa4b..4c4cbad 100644
--- a/hooks/useZoom.ts
+++ b/hooks/useZoom.ts
@@ -18,11 +18,13 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import {
+ CLICK_ZOOM_SCALE,
FIT_VIEW,
canZoom,
clampView,
isZoomed,
maxZoomScale,
+ onFitBox,
panBy,
pinchView,
stepView,
@@ -61,6 +63,8 @@ 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. */
@@ -163,6 +167,26 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
[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();
@@ -231,7 +255,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
/** 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()];
@@ -246,7 +269,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
};
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 };
@@ -329,6 +351,19 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
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' &&
@@ -352,14 +387,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
}
};
- // 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) => {
@@ -435,7 +462,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
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);
@@ -445,13 +471,12 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
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]);
+ }, [enabled, photoKey, surface, measure, apply, toggle, click]);
const zoomed = isZoomed(view);
/** The full-resolution image is in the page (requested, loading or shown). */
diff --git a/lib/__tests__/zoom.test.ts b/lib/__tests__/zoom.test.ts
index 340a84c..a3133e2 100644
--- a/lib/__tests__/zoom.test.ts
+++ b/lib/__tests__/zoom.test.ts
@@ -1,11 +1,13 @@
import { describe, it, expect } from 'vitest';
import {
+ CLICK_ZOOM_SCALE,
FIT_VIEW,
canZoom,
clampView,
isZoomed,
maxZoomScale,
oneToOneScale,
+ onFitBox,
panBy,
pinchView,
stepView,
@@ -127,7 +129,26 @@ describe('zooming around a point', () => {
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', () => {
+ it('toggles to a given scale at the point, clamped to 1:1', () => {
+ const point = { x: 650, y: 420 };
+ const closer = toggleView(FIT_VIEW, point, fit, viewport, max, CLICK_ZOOM_SCALE);
+ expect(closer.scale).toBe(1.5);
+ const after = screenOf(closer, point.x - fit.cx, point.y - fit.cy);
+ expect(after.x).toBeCloseTo(point.x);
+ expect(after.y).toBeCloseTo(point.y);
+ expect(toggleView(closer, point, fit, viewport, max, CLICK_ZOOM_SCALE)).toEqual(FIT_VIEW);
+ // A photo with less to give than the click asks for stops at 1:1.
+ expect(toggleView(FIT_VIEW, point, fit, viewport, 1.2, CLICK_ZOOM_SCALE).scale).toBe(1.2);
+ });
+
+ it('knows the photo from the band beside it', () => {
+ expect(onFitBox({ x: fit.cx, y: fit.cy }, fit)).toBe(true);
+ expect(onFitBox({ x: fit.cx + fit.width / 2, y: fit.cy }, fit)).toBe(true);
+ expect(onFitBox({ x: fit.cx + fit.width / 2 + 1, y: fit.cy }, fit)).toBe(false);
+ expect(onFitBox({ x: fit.cx, y: fit.cy - fit.height / 2 - 1 }, fit)).toBe(false);
+ });
+
+ it('double-tap and the button go 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);
diff --git a/lib/i18n/locales/de.ts b/lib/i18n/locales/de.ts
index 326998d..1268e4b 100644
--- a/lib/i18n/locales/de.ts
+++ b/lib/i18n/locales/de.ts
@@ -187,7 +187,7 @@ export const de: Dictionary = {
shortcutList: 'Diese Liste',
shortcutClose: 'Ansicht schließen',
zoomIn: 'Auf volle Auflösung zoomen',
- zoomInTitle: 'Auf volle Auflösung zoomen (+ oder Doppelklick)',
+ zoomInTitle: 'Auf volle Auflösung zoomen (+)',
zoomOut: 'Foto an den Bildschirm anpassen',
zoomOutTitle: 'Einpassen (0 oder Esc)',
zoomLoading: 'Volle Auflösung wird geladen …',
diff --git a/lib/i18n/locales/en.ts b/lib/i18n/locales/en.ts
index d281b6f..09a265d 100644
--- a/lib/i18n/locales/en.ts
+++ b/lib/i18n/locales/en.ts
@@ -191,7 +191,7 @@ export const en = {
shortcutClose: 'Close the viewer',
/** The zoom button (#467): to 1:1 from fit, and back. */
zoomIn: 'Zoom to full resolution',
- zoomInTitle: 'Zoom to full resolution (+ or double-click)',
+ zoomInTitle: 'Zoom to full resolution (+)',
zoomOut: 'Fit the photo to the screen',
zoomOutTitle: 'Fit to screen (0 or Esc)',
zoomLoading: 'Loading full resolution…',
diff --git a/lib/i18n/locales/es.ts b/lib/i18n/locales/es.ts
index 44ba15d..cec61dc 100644
--- a/lib/i18n/locales/es.ts
+++ b/lib/i18n/locales/es.ts
@@ -187,7 +187,7 @@ export const es: Dictionary = {
shortcutList: 'Esta lista',
shortcutClose: 'Cerrar el visor',
zoomIn: 'Ampliar a resolución completa',
- zoomInTitle: 'Ampliar a resolución completa (+ o doble clic)',
+ zoomInTitle: 'Ampliar a resolución completa (+)',
zoomOut: 'Ajustar la foto a la pantalla',
zoomOutTitle: 'Ajustar a la pantalla (0 o Esc)',
zoomLoading: 'Cargando la resolución completa…',
diff --git a/lib/i18n/locales/fr.ts b/lib/i18n/locales/fr.ts
index 49cb6ce..2761a36 100644
--- a/lib/i18n/locales/fr.ts
+++ b/lib/i18n/locales/fr.ts
@@ -190,7 +190,7 @@ export const fr: Dictionary = {
shortcutList: 'Cette liste',
shortcutClose: 'Fermer la visionneuse',
zoomIn: 'Zoomer en pleine résolution',
- zoomInTitle: 'Zoomer en pleine résolution (+ ou double-clic)',
+ zoomInTitle: 'Zoomer en pleine résolution (+)',
zoomOut: 'Ajuster la photo à l’écran',
zoomOutTitle: 'Ajuster à l’écran (0 ou Échap)',
zoomLoading: 'Chargement de la pleine résolution…',
diff --git a/lib/i18n/locales/it.ts b/lib/i18n/locales/it.ts
index 70093f4..b6c81a1 100644
--- a/lib/i18n/locales/it.ts
+++ b/lib/i18n/locales/it.ts
@@ -187,7 +187,7 @@ export const it: Dictionary = {
shortcutList: 'Questo elenco',
shortcutClose: 'Chiudi il visualizzatore',
zoomIn: 'Ingrandisci alla risoluzione piena',
- zoomInTitle: 'Ingrandisci alla risoluzione piena (+ o doppio clic)',
+ zoomInTitle: 'Ingrandisci alla risoluzione piena (+)',
zoomOut: 'Adatta la foto allo schermo',
zoomOutTitle: 'Adatta allo schermo (0 o Esc)',
zoomLoading: 'Caricamento della risoluzione piena…',
diff --git a/lib/i18n/locales/nl.ts b/lib/i18n/locales/nl.ts
index 530c19b..94e17b4 100644
--- a/lib/i18n/locales/nl.ts
+++ b/lib/i18n/locales/nl.ts
@@ -188,7 +188,7 @@ export const nl: Dictionary = {
shortcutList: 'Deze lijst',
shortcutClose: 'De viewer sluiten',
zoomIn: 'Inzoomen op volledige resolutie',
- zoomInTitle: 'Inzoomen op volledige resolutie (+ of dubbelklik)',
+ zoomInTitle: 'Inzoomen op volledige resolutie (+)',
zoomOut: 'Foto aan het scherm aanpassen',
zoomOutTitle: 'Aan scherm aanpassen (0 of Esc)',
zoomLoading: 'Volledige resolutie laden…',
diff --git a/lib/immich.ts b/lib/immich.ts
index 877777a..edf01af 100644
--- a/lib/immich.ts
+++ b/lib/immich.ts
@@ -375,7 +375,12 @@ class ImmichClient {
}
if (!res.ok) {
- console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
+ console.error(
+ `[Immich] Failed to stream ${assetId}: ${res.status}` +
+ (res.status === 403 && endpoint.includes('/original')
+ ? ' (the API key probably lacks the asset.download permission)'
+ : ''),
+ );
// Neither branch below reads the body — an unconsumed one keeps its
// socket out of undici's pool until GC finalises it (#635).
await res.body?.cancel();
diff --git a/lib/zoom.ts b/lib/zoom.ts
index 88acc93..41e108c 100644
--- a/lib/zoom.ts
+++ b/lib/zoom.ts
@@ -134,7 +134,7 @@ export function clampView(
/**
* 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
+ * cursor for a wheel or a 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.
*/
@@ -198,16 +198,34 @@ export function panBy(
return clampView({ ...view, x: view.x + dx, y: view.y + dy }, fit, viewport, maxScale);
}
-/** Double-click / double-tap: fit ↔ 1:1, around the point. */
+/**
+ * How far a mouse click zooms: half again the fit size, a closer look rather
+ * than the jump to 1:1, which on a large file is several times the screen.
+ * Ctrl + wheel goes the rest of the way.
+ */
+export const CLICK_ZOOM_SCALE = 1.5;
+
+/**
+ * Fit ↔ zoomed, around the point: to `target` (clamped to 1:1), or to 1:1
+ * itself without one — the double-tap and the magnifier button.
+ */
export function toggleView(
view: ZoomView,
point: Point,
fit: FitBox,
viewport: Viewport,
maxScale: number,
+ target: number = maxScale,
): ZoomView {
if (isZoomed(view)) return { ...FIT_VIEW };
- return zoomAround(view, maxScale, point, fit, viewport, maxScale);
+ return zoomAround(view, target, point, fit, viewport, maxScale);
+}
+
+/** Whether a point lies on the photo at fit — not on the band beside it. */
+export function onFitBox(point: Point, fit: FitBox): boolean {
+ return (
+ Math.abs(point.x - fit.cx) <= fit.width / 2 && Math.abs(point.y - fit.cy) <= fit.height / 2
+ );
}
/**
diff --git a/package-lock.json b/package-lock.json
index 578ef2c..7a7e33e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "immich-folio",
- "version": "0.20.0",
+ "version": "0.20.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-folio",
- "version": "0.20.0",
+ "version": "0.20.1",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/package.json b/package.json
index 145135c..9312c79 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "immich-folio",
- "version": "0.20.0",
+ "version": "0.20.1",
"license": "MIT",
"scripts": {
"dev": "next dev",