fix(gallery): honor canvas settings in the Premium lightbox
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user