release: v0.20.1
This commit is contained in:
26
CHANGELOG.md
26
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
|
||||
|
||||
@@ -238,10 +238,13 @@ 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.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))
|
||||
|
||||
@@ -238,7 +238,9 @@ export function InstallWizard({ initialApiUrl, setupToken }: Props) {
|
||||
disabled={testing}
|
||||
/>
|
||||
<p className="admin-hint">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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(<Harness assets={[ZOOMABLE]} />);
|
||||
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(<Harness assets={[ZOOMABLE]} />);
|
||||
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(<Harness assets={[ZOOMABLE]} />);
|
||||
press(preview().parentElement!, 1010, 300);
|
||||
expect(preview().style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('the dblclick event itself no longer zooms', () => {
|
||||
render(<Harness assets={[ZOOMABLE]} />);
|
||||
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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 …',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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();
|
||||
|
||||
24
lib/zoom.ts
24
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-folio",
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.1",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user