From 9edce856ff59802b727e2ae610d3783efea006eb Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 6 Sep 2026 23:07:04 +0200 Subject: [PATCH] fix(gallery): honor canvas settings in the Premium lightbox --- .../components/common/AuthenticatedImage.tsx | 24 ++- .../AuthenticatedImage.canvasRelease.test.tsx | 18 +- .../GalleryPremiumLayout.canvas.test.tsx | 181 ++++++++++++++++++ .../__tests__/canvasLightboxOnly.test.ts | 15 +- .../gallery/layouts/GalleryPremiumLayout.tsx | 27 ++- .../gallery/layouts/PremiumLightboxImage.tsx | 50 +++++ 6 files changed, 299 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/gallery/__tests__/GalleryPremiumLayout.canvas.test.tsx create mode 100644 frontend/src/components/gallery/layouts/PremiumLightboxImage.tsx diff --git a/frontend/src/components/common/AuthenticatedImage.tsx b/frontend/src/components/common/AuthenticatedImage.tsx index 0db0b7ad..af12a4ba 100644 --- a/frontend/src/components/common/AuthenticatedImage.tsx +++ b/frontend/src/components/common/AuthenticatedImage.tsx @@ -17,7 +17,8 @@ interface AuthenticatedImageProps extends Omit 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 = ({ const [isLoading, setIsLoading] = useState(true); const [canvasReady, setCanvasReady] = useState(false); const [canvasFailed, setCanvasFailed] = useState(false); - const canvasRef = useRef(null); + const canvasRef = useRef(null); const imageRef = useRef(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 = ({ 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 = ({ imageRef.current = null; img.removeAttribute('src'); } - onLoad?.(); + onLoad?.(dimensions); }; img.onerror = (e) => { @@ -455,7 +466,7 @@ export const AuthenticatedImage: React.FC = ({ if (useCanvasRendering && !canvasFailed) { return ( = ({ ); } - return {alt}; + return {alt} onLoad?.({ + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + })} {...props} />; }; diff --git a/frontend/src/components/common/__tests__/AuthenticatedImage.canvasRelease.test.tsx b/frontend/src/components/common/__tests__/AuthenticatedImage.canvasRelease.test.tsx index 52b21f0f..70a8c944 100644 --- a/frontend/src/components/common/__tests__/AuthenticatedImage.canvasRelease.test.tsx +++ b/frontend/src/components/common/__tests__/AuthenticatedImage.canvasRelease.test.tsx @@ -11,7 +11,7 @@ * ever released. A decoded 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( - + ); 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( + + ); + 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 }); + }); }); diff --git a/frontend/src/components/gallery/__tests__/GalleryPremiumLayout.canvas.test.tsx b/frontend/src/components/gallery/__tests__/GalleryPremiumLayout.canvas.test.tsx new file mode 100644 index 00000000..bff60208 --- /dev/null +++ b/frontend/src/components/gallery/__tests__/GalleryPremiumLayout.canvas.test.tsx @@ -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) => + {renderer.photo({}, { photo, width: 300, height: 200 })} + )}, +})); + +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> = {}) { + return render(); +} + +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' }); + }); +}); diff --git a/frontend/src/components/gallery/__tests__/canvasLightboxOnly.test.ts b/frontend/src/components/gallery/__tests__/canvasLightboxOnly.test.ts index d8a8c45e..6c6417db 100644 --- a/frontend/src/components/gallery/__tests__/canvasLightboxOnly.test.ts +++ b/frontend/src/components/gallery/__tests__/canvasLightboxOnly.test.ts @@ -8,7 +8,7 @@ * so the tiles render 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 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([]); }); diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index 0a2dd95a..59517ca8 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -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 = ({ allowDownloads = true, downloadChoices, onPickResolution, + protectionLevel = 'standard', useEnhancedProtection = false, + useCanvasRendering = false, feedbackEnabled = false, feedbackOptions, heroPhotoOverride, @@ -217,6 +220,17 @@ export const GalleryPremiumLayout: React.FC = ({ 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>({}); + 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(null); const [likedPhotoIds, setLikedPhotoIds] = useState>(new Set()); // Seed from server is_liked on first non-empty payload (#590 follow-up). @@ -324,14 +338,15 @@ export const GalleryPremiumLayout: React.FC = ({ // 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 = ({ // 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 = ({ 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, }} diff --git a/frontend/src/components/gallery/layouts/PremiumLightboxImage.tsx b/frontend/src/components/gallery/layouts/PremiumLightboxImage.tsx new file mode 100644 index 00000000..ac87585e --- /dev/null +++ b/frontend/src/components/gallery/layouts/PremiumLightboxImage.tsx @@ -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 ; +} + +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 ( + + ); +}