Merge pull request #1328 from PicPeak/fix/1325-premium-canvas
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
|
/** Fired when the canvas branch blocks a context-menu attempt. The only
|
||||||
* protection callback this component actually implements (#1297). */
|
* protection callback this component actually implements (#1297). */
|
||||||
onProtectionViolation?: (violationType: string) => void;
|
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`
|
* Priority in the shared fetch queue (#1287). NOT the native `fetchPriority`
|
||||||
* DOM attribute, which stays available on this component and takes
|
* 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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [canvasReady, setCanvasReady] = useState(false);
|
const [canvasReady, setCanvasReady] = useState(false);
|
||||||
const [canvasFailed, setCanvasFailed] = useState(false);
|
const [canvasFailed, setCanvasFailed] = useState(false);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const imageRef = useRef<HTMLImageElement | 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
|
// 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
|
// 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.
|
// fresh budget without an extra effect run to reset it.
|
||||||
@@ -378,6 +388,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imageRef.current = img;
|
imageRef.current = img;
|
||||||
|
const dimensions = { width: img.naturalWidth, height: img.naturalHeight };
|
||||||
const drawn = drawToCanvas();
|
const drawn = drawToCanvas();
|
||||||
// Once drawImage has copied the pixels into the canvas the source
|
// Once drawImage has copied the pixels into the canvas the source
|
||||||
// decode is dead weight, so drop it here rather than at unmount. The
|
// 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;
|
imageRef.current = null;
|
||||||
img.removeAttribute('src');
|
img.removeAttribute('src');
|
||||||
}
|
}
|
||||||
onLoad?.();
|
onLoad?.(dimensions);
|
||||||
};
|
};
|
||||||
|
|
||||||
img.onerror = (e) => {
|
img.onerror = (e) => {
|
||||||
@@ -455,7 +466,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
if (useCanvasRendering && !canvasFailed) {
|
if (useCanvasRendering && !canvasFailed) {
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={setCanvasRef}
|
||||||
className={props.className}
|
className={props.className}
|
||||||
style={{
|
style={{
|
||||||
...props.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
|
* ever released. A decoded <img> in the document is evictable under memory
|
||||||
* pressure; one held by a ref is not.
|
* 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';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
vi.mock('../../../utils/galleryAuthStorage', () => ({
|
vi.mock('../../../utils/galleryAuthStorage', () => ({
|
||||||
@@ -75,9 +75,10 @@ describe('AuthenticatedImage canvas mode', () => {
|
|||||||
const getContext = vi
|
const getContext = vi
|
||||||
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||||
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D);
|
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D);
|
||||||
|
const onLoad = vi.fn();
|
||||||
|
|
||||||
render(
|
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));
|
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||||
@@ -86,6 +87,7 @@ describe('AuthenticatedImage canvas mode', () => {
|
|||||||
await waitFor(() => expect(ctx.drawImage).toHaveBeenCalled());
|
await waitFor(() => expect(ctx.drawImage).toHaveBeenCalled());
|
||||||
// Still mounted, still the same src — and already released.
|
// Still mounted, still the same src — and already released.
|
||||||
await waitFor(() => expect(img.getAttribute('src')).toBeNull());
|
await waitFor(() => expect(img.getAttribute('src')).toBeNull());
|
||||||
|
expect(onLoad).toHaveBeenCalledWith({ width: 10, height: 10 });
|
||||||
|
|
||||||
getContext.mockRestore();
|
getContext.mockRestore();
|
||||||
});
|
});
|
||||||
@@ -132,4 +134,16 @@ describe('AuthenticatedImage canvas mode', () => {
|
|||||||
|
|
||||||
expect(first.getAttribute('src')).toBeNull();
|
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
|
* so the tiles render <img> whatever the protection level says, and the
|
||||||
* lightbox keeps the per-event toggle and the `maximum` implication.
|
* 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.
|
* may hand `useCanvasRendering` to AuthenticatedImage.
|
||||||
*/
|
*/
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -27,6 +27,7 @@ function walk(dir: string): string[] {
|
|||||||
describe('canvas rendering stays in the lightbox', () => {
|
describe('canvas rendering stays in the lightbox', () => {
|
||||||
const files = walk(root);
|
const files = walk(root);
|
||||||
const lightbox = path.join(root, 'PhotoLightbox.tsx');
|
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
|
/** The JSX props of every <AuthenticatedImage> in a file, plus every
|
||||||
* `imageProps={{ ... }}` object a layout hands to PhotoCard to spread in. */
|
* `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]),
|
...source.split('imageProps={{').slice(1).map((chunk) => chunk.split('}}')[0]),
|
||||||
];
|
];
|
||||||
|
|
||||||
it('only PhotoLightbox passes useCanvasRendering to AuthenticatedImage', () => {
|
it('only lightbox renderers pass useCanvasRendering to AuthenticatedImage', () => {
|
||||||
const offenders = files.filter((file) => file !== lightbox
|
const offenders = files.filter((file) => !lightboxRenderers.includes(file)
|
||||||
&& imageProps(fs.readFileSync(file, 'utf8')).some((props) => props.includes('useCanvasRendering')));
|
&& imageProps(fs.readFileSync(file, 'utf8')).some((props) => props.includes('useCanvasRendering')));
|
||||||
expect(offenders.map((f) => path.relative(root, f))).toEqual([]);
|
expect(offenders.map((f) => path.relative(root, f))).toEqual([]);
|
||||||
// The pin has teeth: the lightbox itself is caught by the same probe.
|
// 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', () => {
|
it('only lightbox renderers turn canvas on for protection level maximum', () => {
|
||||||
const offenders = files.filter((file) => file !== lightbox
|
const offenders = files.filter((file) => !lightboxRenderers.includes(file)
|
||||||
&& /useCanvasRendering[^\n]*protectionLevel === 'maximum'/.test(fs.readFileSync(file, 'utf8')));
|
&& /useCanvasRendering[^\n]*protectionLevel === 'maximum'/.test(fs.readFileSync(file, 'utf8')));
|
||||||
expect(offenders.map((f) => path.relative(root, f))).toEqual([]);
|
expect(offenders.map((f) => path.relative(root, f))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { toast } from 'react-toastify';
|
|||||||
|
|
||||||
import './GalleryPremiumLayout.css';
|
import './GalleryPremiumLayout.css';
|
||||||
import { lightboxImageUrl } from '../imageTiers';
|
import { lightboxImageUrl } from '../imageTiers';
|
||||||
|
import { renderPremiumLightboxImage } from './PremiumLightboxImage';
|
||||||
|
|
||||||
interface PhotoCardProps {
|
interface PhotoCardProps {
|
||||||
photo: Photo;
|
photo: Photo;
|
||||||
@@ -203,7 +204,9 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
downloadChoices,
|
downloadChoices,
|
||||||
onPickResolution,
|
onPickResolution,
|
||||||
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
|
useCanvasRendering = false,
|
||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
feedbackOptions,
|
feedbackOptions,
|
||||||
heroPhotoOverride,
|
heroPhotoOverride,
|
||||||
@@ -217,6 +220,17 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const downloadPhotoMutation = useDownloadPhoto();
|
const downloadPhotoMutation = useDownloadPhoto();
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(-1);
|
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 [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||||
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
|
||||||
// Seed from server is_liked on first non-empty payload (#590 follow-up).
|
// 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).
|
// nothing and Download would silently do nothing (#1166 review).
|
||||||
photoId: photo.id,
|
photoId: photo.id,
|
||||||
alt: photo.filename,
|
alt: photo.filename,
|
||||||
width: photo.width || 1200,
|
width: imageDimensions[lightboxImageUrl(photo)]?.width || photo.width || 1200,
|
||||||
height: photo.height || 800,
|
height: imageDimensions[lightboxImageUrl(photo)]?.height || photo.height || 800,
|
||||||
|
thumbnail: photo.thumbnail_url || undefined,
|
||||||
download: allowDownloads ? photo.url : undefined,
|
download: allowDownloads ? photo.url : undefined,
|
||||||
title: showOriginalFilename
|
title: showOriginalFilename
|
||||||
? (photo.original_filename || photo.filename)
|
? (photo.original_filename || photo.filename)
|
||||||
: undefined,
|
: undefined,
|
||||||
}));
|
}));
|
||||||
}, [filteredPhotos, allowDownloads, showOriginalFilename]);
|
}, [filteredPhotos, allowDownloads, showOriginalFilename, imageDimensions]);
|
||||||
|
|
||||||
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
|
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -655,6 +670,9 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
// slide change — same semantics as PhotoLightbox's beacon.
|
// slide change — same semantics as PhotoLightbox's beacon.
|
||||||
on={{
|
on={{
|
||||||
view: ({ index }) => {
|
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];
|
const photo = filteredPhotos[index];
|
||||||
if (photo) galleryService.trackPhotoView(slug, photo.id);
|
if (photo) galleryService.trackPhotoView(slug, photo.id);
|
||||||
},
|
},
|
||||||
@@ -672,6 +690,9 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
thumbnail: { border: 'none' }
|
thumbnail: { border: 'none' }
|
||||||
}}
|
}}
|
||||||
render={{
|
render={{
|
||||||
|
slide: (props) => renderPremiumLightboxImage({
|
||||||
|
...props, slug, useCanvasRendering, protectionLevel, onImageLoad: handleLightboxImageLoad,
|
||||||
|
}),
|
||||||
buttonPrev: slides.length <= 1 ? () => null : undefined,
|
buttonPrev: slides.length <= 1 ? () => null : undefined,
|
||||||
buttonNext: 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