fix(gallery): honor canvas settings in the Premium lightbox

This commit is contained in:
Paul Nothaft
2026-09-06 23:07:04 +02:00
parent c75839d3ea
commit 9edce856ff
6 changed files with 299 additions and 16 deletions
@@ -17,7 +17,8 @@ interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImage
/** Fired when the canvas branch blocks a context-menu attempt. The only
* protection callback this component actually implements (#1297). */
onProtectionViolation?: (violationType: string) => void;
onLoad?: () => void;
/** Dimensions of the loaded rendition, which can differ from the original. */
onLoad?: (dimensions: { width: number; height: number }) => void;
/**
* Priority in the shared fetch queue (#1287). NOT the native `fetchPriority`
* DOM attribute, which stays available on this component and takes
@@ -112,8 +113,17 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
const [isLoading, setIsLoading] = useState(true);
const [canvasReady, setCanvasReady] = useState(false);
const [canvasFailed, setCanvasFailed] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
// Release the backing store immediately when a lightbox slide stops being
// a canvas, including when the carousel retains its detached DOM node.
const setCanvasRef = useCallback((canvas: HTMLCanvasElement | null) => {
if (canvasRef.current && canvasRef.current !== canvas) {
canvasRef.current.width = 0;
canvasRef.current.height = 0;
}
canvasRef.current = canvas;
}, []);
// Retry state (#1287). The nonce is a dependency of the fetch effect, so
// bumping it is the retry; the counter is per src, so a new image gets a
// fresh budget without an extra effect run to reset it.
@@ -378,6 +388,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = () => {
imageRef.current = img;
const dimensions = { width: img.naturalWidth, height: img.naturalHeight };
const drawn = drawToCanvas();
// Once drawImage has copied the pixels into the canvas the source
// decode is dead weight, so drop it here rather than at unmount. The
@@ -398,7 +409,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
imageRef.current = null;
img.removeAttribute('src');
}
onLoad?.();
onLoad?.(dimensions);
};
img.onerror = (e) => {
@@ -455,7 +466,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
if (useCanvasRendering && !canvasFailed) {
return (
<canvas
ref={canvasRef}
ref={setCanvasRef}
className={props.className}
style={{
...props.style,
@@ -480,5 +491,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
);
}
return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
return <img src={imageSrc} alt={alt} onLoad={(event) => onLoad?.({
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
})} {...props} />;
};
@@ -11,7 +11,7 @@
* ever released. A decoded <img> in the document is evictable under memory
* pressure; one held by a ref is not.
*/
import { render, waitFor } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../../utils/galleryAuthStorage', () => ({
@@ -75,9 +75,10 @@ describe('AuthenticatedImage canvas mode', () => {
const getContext = vi
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D);
const onLoad = vi.fn();
render(
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering onLoad={onLoad} />
);
await waitFor(() => expect(created.length).toBeGreaterThan(0));
@@ -86,6 +87,7 @@ describe('AuthenticatedImage canvas mode', () => {
await waitFor(() => expect(ctx.drawImage).toHaveBeenCalled());
// Still mounted, still the same src — and already released.
await waitFor(() => expect(img.getAttribute('src')).toBeNull());
expect(onLoad).toHaveBeenCalledWith({ width: 10, height: 10 });
getContext.mockRestore();
});
@@ -132,4 +134,16 @@ describe('AuthenticatedImage canvas mode', () => {
expect(first.getAttribute('src')).toBeNull();
});
it('reports the decoded dimensions in ordinary image mode too', async () => {
const onLoad = vi.fn();
const { container } = render(
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" onLoad={onLoad} />
);
await waitFor(() => expect(container.querySelector('img')).not.toBeNull());
const img = container.querySelector('img')!;
Object.defineProperties(img, { naturalWidth: { value: 320 }, naturalHeight: { value: 240 } });
fireEvent.load(img);
expect(onLoad).toHaveBeenCalledWith({ width: 320, height: 240 });
});
});
@@ -0,0 +1,181 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { GalleryPremiumLayout } from '../layouts/GalleryPremiumLayout';
import { galleryService } from '../../../services/gallery.service';
import type { Photo } from '../../../types';
const { download } = vi.hoisted(() => ({ download: vi.fn() }));
vi.mock('react-i18next', async () => ({
...await vi.importActual('react-i18next'),
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('../../../hooks/useGallery', () => ({ useDownloadPhoto: () => ({ mutate: download }) }));
vi.mock('../../../contexts/GuestIdentityContext', () => ({ useGuestIdentityOptional: () => null }));
vi.mock('../../../hooks/useInputMode', () => ({ useInputMode: () => 'mouse' }));
vi.mock('react-intersection-observer', () => ({ useInView: () => ({ ref: vi.fn(), inView: true }) }));
vi.mock('../../../services/gallery.service', () => ({ galleryService: { trackPhotoView: vi.fn() } }));
vi.mock('../../common', async () => ({
...await vi.importActual('../../common'),
PoweredBy: () => null,
}));
// Only stub the masonry geometry. Both YARL (including Zoom/Thumbnails) and
// AuthenticatedImage are real, so opening a tile exercises the integration.
vi.mock('react-photo-album', () => ({
MasonryPhotoAlbum: ({ photos, render: renderer }: any) => <>{photos.map((photo: any) =>
<React.Fragment key={photo.key}>{renderer.photo({}, { photo, width: 300, height: 200 })}</React.Fragment>
)}</>,
}));
const photos = [1, 2, 3].map((id) => ({
id, filename: `photo-${id}.jpg`, original_filename: `original-${id}.jpg`,
url: `/api/gallery/demo/photo/${id}`,
preview_url: `/api/gallery/demo/preview/${id}`,
thumbnail_url: `/api/gallery/demo/thumbnail/${id}`,
width: 6000, height: 4000, type: 'individual', size: 1,
uploaded_at: '2026-01-01T00:00:00Z',
})) as Photo[];
const decoded: HTMLImageElement[] = [];
const drawImage = vi.fn();
const revokeObjectURL = vi.fn();
const fetchImage = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
decoded.length = 0;
sessionStorage.setItem('gallery_token_demo', 'gallery-test-token');
fetchImage.mockResolvedValue({ ok: true, blob: async () => new Blob(['image']) });
vi.stubGlobal('fetch', fetchImage);
let sequence = 0;
vi.stubGlobal('URL', class extends URL {
static createObjectURL = () => `blob:premium-${++sequence}`;
static revokeObjectURL = revokeObjectURL;
});
const RealImage = Image;
vi.stubGlobal('Image', class extends RealImage {
constructor() {
super();
decoded.push(this);
Object.defineProperties(this, {
complete: { value: true },
naturalWidth: { value: 1600 },
naturalHeight: { value: 1000 },
});
setTimeout(() => this.onload?.(new Event('load')), 0);
}
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(960);
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(640);
});
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function mount(props: Partial<React.ComponentProps<typeof GalleryPremiumLayout>> = {}) {
return render(<GalleryPremiumLayout
photos={photos} slug="demo" onPhotoClick={vi.fn()} onDownload={vi.fn()}
allowDownloads={false} {...props}
/>);
}
async function openPhoto() {
fireEvent.click(screen.getByTestId('photo-card-1'));
return screen.findByRole('dialog');
}
async function readyCanvas(dialog: HTMLElement, id = 1) {
await waitFor(() => {
const canvas = dialog.querySelector(`canvas[aria-label="photo-${id}.jpg"]`);
expect(canvas).toHaveStyle({ opacity: '1' });
});
return dialog.querySelector(`canvas[aria-label="photo-${id}.jpg"]`) as HTMLCanvasElement;
}
describe('Premium lightbox canvas rendering (#1325)', () => {
it.each([
{ useCanvasRendering: true, protectionLevel: 'standard' as const },
{ useCanvasRendering: false, protectionLevel: 'maximum' as const },
])('uses canvas only for the active photo: %j', async (options) => {
mount(options);
expect(document.querySelector('canvas')).toBeNull();
const dialog = await openPhoto();
const canvas = await readyCanvas(dialog);
expect(canvas.width).toBe(1600);
expect(document.querySelectorAll('canvas')).toHaveLength(1);
expect(dialog.querySelectorAll('.yarl__slide img').length).toBeGreaterThan(0);
expect(dialog.querySelector('.yarl__thumbnails_container canvas')).toBeNull();
expect(fetchImage).toHaveBeenCalledWith(expect.stringContaining('/preview/1'), expect.objectContaining({
credentials: 'include', headers: { Authorization: 'Bearer gallery-test-token' },
}));
expect(galleryService.trackPhotoView).toHaveBeenCalledWith('demo', 1);
});
it.each(['basic', 'standard', 'enhanced'] as const)('uses an image with %s protection and canvas off', async (protectionLevel) => {
mount({ protectionLevel, useCanvasRendering: false });
const dialog = await openPhoto();
expect(dialog.querySelector('canvas')).toBeNull();
expect(dialog.querySelector('.yarl__slide_current img')).not.toBeNull();
});
it('releases canvases and blob URLs on navigation and close', async () => {
mount({ useCanvasRendering: true });
const dialog = await openPhoto();
const first = await readyCanvas(dialog);
const firstBlob = decoded[0].src; // The detached source has already been released.
expect(firstBlob).toBe('');
const revokedBefore = revokeObjectURL.mock.calls.length;
fireEvent.click(within(dialog).getByRole('button', { name: 'Next' }));
const second = await readyCanvas(dialog, 2);
expect(first.width).toBe(0);
expect(first.height).toBe(0);
expect(document.querySelectorAll('canvas')).toHaveLength(1);
expect(revokeObjectURL.mock.calls.length).toBeGreaterThan(revokedBefore);
expect(galleryService.trackPhotoView).toHaveBeenLastCalledWith('demo', 2);
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(second.width).toBe(0);
expect(second.height).toBe(0);
expect(document.querySelector('canvas')).toBeNull();
});
it('keeps zoom bounded by the loaded preview without decoding again', async () => {
mount({ useCanvasRendering: true });
const dialog = await openPhoto();
const canvas = await readyCanvas(dialog);
const decodeCount = decoded.length;
fireEvent.click(within(dialog).getByRole('button', { name: 'Zoom in' }));
// 1600px preview / (960px viewport - 2 * 16px carousel padding).
await waitFor(() => expect(canvas.parentElement?.style.transform).toContain('scale(1.72414)'));
expect(decoded).toHaveLength(decodeCount);
expect(within(dialog).getByRole('button', { name: 'Zoom in' })).toBeDisabled();
});
it('loads the thumbnail fallback with gallery authentication', async () => {
fetchImage.mockImplementation(async (url: string) => url.includes('/preview/1')
? { ok: false, status: 404, statusText: 'Not Found' }
: { ok: true, blob: async () => new Blob(['image']) });
mount({ useCanvasRendering: true });
const dialog = await openPhoto();
await readyCanvas(dialog);
expect(fetchImage).toHaveBeenLastCalledWith(expect.stringContaining('/api/gallery/demo/thumbnail/1'), expect.objectContaining({
headers: { Authorization: 'Bearer gallery-test-token' },
}));
});
it('preserves captions and downloads the current photo after navigation', async () => {
mount({ useCanvasRendering: true, allowDownloads: true, showOriginalFilename: true });
const dialog = await openPhoto();
await readyCanvas(dialog);
fireEvent.click(within(dialog).getByRole('button', { name: 'Next' }));
await readyCanvas(dialog, 2);
expect(within(dialog).getByText('original-2.jpg')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Download' }));
expect(download).toHaveBeenCalledWith({ slug: 'demo', photoId: 2, filename: 'photo-2.jpg' });
});
});
@@ -8,7 +8,7 @@
* so the tiles render <img> whatever the protection level says, and the
* lightbox keeps the per-event toggle and the `maximum` implication.
*
* Source-level pin: nothing under components/gallery except PhotoLightbox
* Source-level pin: nothing under components/gallery except the lightbox renderers
* may hand `useCanvasRendering` to AuthenticatedImage.
*/
import fs from 'fs';
@@ -27,6 +27,7 @@ function walk(dir: string): string[] {
describe('canvas rendering stays in the lightbox', () => {
const files = walk(root);
const lightbox = path.join(root, 'PhotoLightbox.tsx');
const lightboxRenderers = [lightbox, path.join(root, 'layouts/PremiumLightboxImage.tsx')];
/** The JSX props of every <AuthenticatedImage> in a file, plus every
* `imageProps={{ ... }}` object a layout hands to PhotoCard to spread in. */
@@ -35,16 +36,18 @@ describe('canvas rendering stays in the lightbox', () => {
...source.split('imageProps={{').slice(1).map((chunk) => chunk.split('}}')[0]),
];
it('only PhotoLightbox passes useCanvasRendering to AuthenticatedImage', () => {
const offenders = files.filter((file) => file !== lightbox
it('only lightbox renderers pass useCanvasRendering to AuthenticatedImage', () => {
const offenders = files.filter((file) => !lightboxRenderers.includes(file)
&& imageProps(fs.readFileSync(file, 'utf8')).some((props) => props.includes('useCanvasRendering')));
expect(offenders.map((f) => path.relative(root, f))).toEqual([]);
// The pin has teeth: the lightbox itself is caught by the same probe.
expect(imageProps(fs.readFileSync(lightbox, 'utf8')).some((props) => props.includes('useCanvasRendering'))).toBe(true);
for (const renderer of lightboxRenderers) {
expect(imageProps(fs.readFileSync(renderer, 'utf8')).some((props) => props.includes('useCanvasRendering'))).toBe(true);
}
});
it('only PhotoLightbox turns canvas on for protection level maximum', () => {
const offenders = files.filter((file) => file !== lightbox
it('only lightbox renderers turn canvas on for protection level maximum', () => {
const offenders = files.filter((file) => !lightboxRenderers.includes(file)
&& /useCanvasRendering[^\n]*protectionLevel === 'maximum'/.test(fs.readFileSync(file, 'utf8')));
expect(offenders.map((f) => path.relative(root, f))).toEqual([]);
});
@@ -33,6 +33,7 @@ import { toast } from 'react-toastify';
import './GalleryPremiumLayout.css';
import { lightboxImageUrl } from '../imageTiers';
import { renderPremiumLightboxImage } from './PremiumLightboxImage';
interface PhotoCardProps {
photo: Photo;
@@ -203,7 +204,9 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
allowDownloads = true,
downloadChoices,
onPickResolution,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
@@ -217,6 +220,17 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const { t } = useTranslation();
const downloadPhotoMutation = useDownloadPhoto();
const [lightboxIndex, setLightboxIndex] = useState(-1);
// The delivered preview can be smaller than the original. Keep Zoom's
// pixel limit/aspect ratio tied to the loaded rendition, as its default
// image renderer does internally.
const [imageDimensions, setImageDimensions] = useState<Record<string, { width: number; height: number }>>({});
const handleLightboxImageLoad = useCallback((src: string, dimensions: { width: number; height: number }) => {
setImageDimensions((previous) => (
previous[src]?.width === dimensions.width && previous[src]?.height === dimensions.height
? previous
: { ...previous, [src]: dimensions }
));
}, []);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
// Seed from server is_liked on first non-empty payload (#590 follow-up).
@@ -324,14 +338,15 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
// nothing and Download would silently do nothing (#1166 review).
photoId: photo.id,
alt: photo.filename,
width: photo.width || 1200,
height: photo.height || 800,
width: imageDimensions[lightboxImageUrl(photo)]?.width || photo.width || 1200,
height: imageDimensions[lightboxImageUrl(photo)]?.height || photo.height || 800,
thumbnail: photo.thumbnail_url || undefined,
download: allowDownloads ? photo.url : undefined,
title: showOriginalFilename
? (photo.original_filename || photo.filename)
: undefined,
}));
}, [filteredPhotos, allowDownloads, showOriginalFilename]);
}, [filteredPhotos, allowDownloads, showOriginalFilename, imageDimensions]);
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
@@ -655,6 +670,9 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
// slide change — same semantics as PhotoLightbox's beacon.
on={{
view: ({ index }) => {
// Keep the controlled index in sync when loaded dimensions update
// the slides array; otherwise YARL jumps back to the opening photo.
setLightboxIndex(index);
const photo = filteredPhotos[index];
if (photo) galleryService.trackPhotoView(slug, photo.id);
},
@@ -672,6 +690,9 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
thumbnail: { border: 'none' }
}}
render={{
slide: (props) => renderPremiumLightboxImage({
...props, slug, useCanvasRendering, protectionLevel, onImageLoad: handleLightboxImageLoad,
}),
buttonPrev: slides.length <= 1 ? () => null : undefined,
buttonNext: slides.length <= 1 ? () => null : undefined,
}}
@@ -0,0 +1,50 @@
import { useCallback } from 'react';
import type { RenderSlideProps, SlideImage } from 'yet-another-react-lightbox';
import { isImageSlide } from 'yet-another-react-lightbox';
import { AuthenticatedImage } from '../../common/AuthenticatedImage';
interface PremiumLightboxImageProps extends RenderSlideProps {
slug: string;
useCanvasRendering: boolean;
protectionLevel: 'basic' | 'standard' | 'enhanced' | 'maximum';
onImageLoad: (src: string, dimensions: { width: number; height: number }) => void;
}
/** Return undefined for YARL's ordinary image renderer, including all neighbours.
* Keeping the slide's image type lets its Zoom plugin own gestures and transforms. */
export function renderPremiumLightboxImage({
slide, offset, slug, useCanvasRendering, protectionLevel, onImageLoad,
}: PremiumLightboxImageProps) {
if (!isImageSlide(slide) || offset !== 0 || !(useCanvasRendering || protectionLevel === 'maximum')) {
return undefined;
}
return <PremiumLightboxImage key={slide.src} slide={slide} slug={slug} onImageLoad={onImageLoad} />;
}
function PremiumLightboxImage({ slide, slug, onImageLoad }: {
slide: SlideImage;
slug: string;
onImageLoad: PremiumLightboxImageProps['onImageLoad'];
}) {
// Stable across zoom/parent renders: AuthenticatedImage's decode effect
// depends on this callback, so an inline function would decode again.
const handleLoad = useCallback((dimensions: { width: number; height: number }) => {
onImageLoad(slide.src, dimensions);
}, [slide.src, onImageLoad]);
return (
<AuthenticatedImage
src={slide.src}
fallbackSrc={slide.thumbnail}
alt={slide.alt || ''}
slug={slug}
isGallery
queuePriority="high"
useCanvasRendering
className="yarl__slide_image"
onLoad={handleLoad}
draggable={false}
/>
);
}