From db32f5d4a704d901a8d484d05435a57856595d2c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 10 Mar 2026 10:04:30 +0100 Subject: [PATCH] Add responsive image srcset support Let browsers pick optimal image resolution based on viewport and device pixel ratio, improving quality on retina/4K displays while avoiding wasted bandwidth on mobile devices. Backgrounds get medium/large/ultra variants. Inline images use a tiered approach based on content element width with sizes hints matching the layout breakpoints. Small elements ( { }); }); + describe('srcset', () => { + beforeEach(() => features.enable('frontend', ['image_srcset'])); + afterEach(() => features.enabledFeatureNames = []); + + function renderInlineImage({contentElementWidth = 0, ...seedOptions} = {}) { + const result = renderInContentElement( + , + {seed: seedOptions} + ); + result.simulateScrollPosition('near viewport'); + return result; + } + + it('uses medium and large srcset for default width', () => { + const {getByRole} = renderInlineImage({ + imageFileUrlTemplates: { + medium: ':id_partition/medium/image.jpg', + large: ':id_partition/large/image.jpg' + }, + imageFiles: [{permaId: 100, id: 1, width: 4000, height: 3000}] + }); + + expect(getByRole('img')).toHaveAttribute('srcset', + '000/000/001/medium/image.jpg 1024w, 000/000/001/large/image.jpg 1920w'); + expect(getByRole('img')).toHaveAttribute('sizes', + '(min-width: 950px) 950px, 100vw'); + }); + + it('uses medium, large and ultra srcset for full width', () => { + const {getByRole} = renderInlineImage({ + contentElementWidth: 3, + imageFileUrlTemplates: { + medium: ':id_partition/medium/image.jpg', + large: ':id_partition/large/image.jpg', + ultra: ':id_partition/ultra/image.jpg' + }, + imageFiles: [{permaId: 100, id: 1, width: 4000, height: 3000}] + }); + + expect(getByRole('img')).toHaveAttribute('srcset', + '000/000/001/medium/image.jpg 1024w, ' + + '000/000/001/large/image.jpg 1920w, ' + + '000/000/001/ultra/image.jpg 3840w'); + expect(getByRole('img')).toHaveAttribute('sizes', '100vw'); + }); + + it('uses medium, large and ultra srcset with 1200px sizes for xl width', () => { + const {getByRole} = renderInlineImage({ + contentElementWidth: 2, + imageFileUrlTemplates: { + medium: ':id_partition/medium/image.jpg', + large: ':id_partition/large/image.jpg', + ultra: ':id_partition/ultra/image.jpg' + }, + imageFiles: [{permaId: 100, id: 1, width: 4000, height: 3000}] + }); + + expect(getByRole('img')).toHaveAttribute('srcset', + '000/000/001/medium/image.jpg 1024w, ' + + '000/000/001/large/image.jpg 1920w, ' + + '000/000/001/ultra/image.jpg 3840w'); + expect(getByRole('img')).toHaveAttribute('sizes', + '(min-width: 950px) 1200px, 100vw'); + }); + + it('uses computed width descriptors for portrait images', () => { + const {getByRole} = renderInlineImage({ + imageFileUrlTemplates: { + medium: ':id_partition/medium/image.jpg', + large: ':id_partition/large/image.jpg' + }, + imageFiles: [{permaId: 100, id: 1, width: 2160, height: 3840}] + }); + + expect(getByRole('img')).toHaveAttribute('srcset', + '000/000/001/medium/image.jpg 576w, 000/000/001/large/image.jpg 1080w'); + }); + + it('uses plain medium variant for small widths', () => { + const {getByRole} = renderInlineImage({ + contentElementWidth: -1, + imageFileUrlTemplates: { + medium: ':id_partition/medium/image.jpg', + large: ':id_partition/large/image.jpg' + }, + imageFiles: [{permaId: 100, id: 1, width: 200, height: 100}] + }); + + expect(getByRole('img')).not.toHaveAttribute('srcset'); + expect(getByRole('img')).toHaveAttribute('src', + '000/000/001/medium/image.jpg'); + }); + + it('falls back to original behavior when feature is disabled', () => { + features.enabledFeatureNames = []; + + const {getByRole} = renderInlineImage({ + imageFileUrlTemplates: { + medium: ':id_partition/medium/image.jpg', + large: ':id_partition/large/image.jpg' + }, + imageFiles: [{permaId: 100, id: 1, width: 200, height: 100}] + }); + + expect(getByRole('img')).not.toHaveAttribute('srcset'); + expect(getByRole('img')).toHaveAttribute('src', + '000/000/001/medium/image.jpg'); + }); + }); + describe('basic functionality', () => { it('renders with FitViewport and ContentElementBox', () => { const {getContentElement} = renderContentElement({ diff --git a/entry_types/scrolled/package/spec/entryState/useFile-spec.js b/entry_types/scrolled/package/spec/entryState/useFile-spec.js index 43350019cf..a750927652 100644 --- a/entry_types/scrolled/package/spec/entryState/useFile-spec.js +++ b/entry_types/scrolled/package/spec/entryState/useFile-spec.js @@ -211,6 +211,140 @@ describe('useFile', () => { }); }); + it('includes variantWidths for image files', () => { + const {result} = renderHookInEntry( + () => useFile({collectionName: 'imageFiles', permaId: 1}), + { + seed: { + fileUrlTemplates: { + imageFiles: { + medium: '/image_files/:id_partition/medium/:basename.:processed_extension', + large: '/image_files/:id_partition/large/:basename.:processed_extension', + } + }, + fileModelTypes: { + imageFiles: 'Pageflow::ImageFile' + }, + imageFiles: [ + { + id: 100, + permaId: 1, + basename: 'image', + extension: 'jpg', + processedExtension: 'webp', + width: 4000, + height: 3000 + } + ] + } + } + ); + + expect(result.current.variantWidths).toEqual([ + ['1024w', 'medium'], + ['1920w', 'large'] + ]); + }); + + it('computes variantWidths based on actual image dimensions for portrait images', () => { + const {result} = renderHookInEntry( + () => useFile({collectionName: 'imageFiles', permaId: 1}), + { + seed: { + fileUrlTemplates: { + imageFiles: { + medium: '/image_files/:id_partition/medium/:basename.:processed_extension', + large: '/image_files/:id_partition/large/:basename.:processed_extension', + } + }, + fileModelTypes: { + imageFiles: 'Pageflow::ImageFile' + }, + imageFiles: [ + { + id: 100, + permaId: 1, + basename: 'image', + extension: 'jpg', + processedExtension: 'webp', + width: 2160, + height: 3840 + } + ] + } + } + ); + + expect(result.current.variantWidths).toEqual([ + ['576w', 'medium'], + ['1080w', 'large'] + ]); + }); + + it('deduplicates variantWidths when variants produce same width', () => { + const {result} = renderHookInEntry( + () => useFile({collectionName: 'imageFiles', permaId: 1}), + { + seed: { + fileUrlTemplates: { + imageFiles: { + medium: '/image_files/:id_partition/medium/:basename.:processed_extension', + large: '/image_files/:id_partition/large/:basename.:processed_extension', + ultra: '/image_files/:id_partition/ultra/:basename.:processed_extension', + } + }, + fileModelTypes: { + imageFiles: 'Pageflow::ImageFile' + }, + imageFiles: [ + { + id: 100, + permaId: 1, + basename: 'image', + extension: 'jpg', + processedExtension: 'webp', + width: 1920, + height: 1080 + } + ] + } + } + ); + + expect(result.current.variantWidths).toEqual([ + ['1024w', 'medium'], + ['1920w', 'large'] + ]); + }); + + it('does not include variantWidths for video files', () => { + const {result} = renderHookInEntry( + () => useFile({collectionName: 'videoFiles', permaId: 1}), + { + seed: { + fileUrlTemplates: { + videoFiles: { + high: '/video_files/:id_partition/high.mp4', + }, + }, + fileModelTypes: { + videoFiles: 'Pageflow::VideoFile' + }, + videoFiles: [ + { + id: 100, + permaId: 1, + basename: 'video', + variants: ['high'], + } + ] + } + } + ); + + expect(result.current.variantWidths).toBeUndefined(); + }); + it('falls back to file name for display name from watched collection', () => { const {result} = renderHookInEntry( () => useFile({collectionName: 'imageFiles', permaId: 1}), diff --git a/entry_types/scrolled/package/spec/frontend/Image-spec.js b/entry_types/scrolled/package/spec/frontend/Image-spec.js index 5429a40d93..f92aa17d2e 100644 --- a/entry_types/scrolled/package/spec/frontend/Image-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Image-spec.js @@ -359,6 +359,175 @@ describe('Image', () => { expect(getByRole('img').hasAttribute('alt')).toBe(true); }); + it('renders srcset with width descriptors for array variant', () => { + const {getByRole} = renderInEntry( + () => , + { + seed: { + imageFileUrlTemplates: { + large: ':id_partition/large/image.jpg', + ultra: ':id_partition/ultra/image.jpg' + }, + imageFiles: [ + {id: 1, permaId: 100, width: 4000, height: 3000} + ] + } + } + ); + + expect(getByRole('img')).toHaveAttribute('src', '000/000/001/large/image.jpg'); + expect(getByRole('img')).toHaveAttribute('srcset', + '000/000/001/large/image.jpg 1920w, 000/000/001/ultra/image.jpg 3840w'); + expect(getByRole('img')).toHaveAttribute('sizes', '100vw'); + }); + + it('passes custom sizes prop through', () => { + const {getByRole} = renderInEntry( + () => , + { + seed: { + imageFileUrlTemplates: { + large: ':id_partition/large/image.jpg', + ultra: ':id_partition/ultra/image.jpg' + }, + imageFiles: [ + {id: 1, permaId: 100, width: 4000, height: 3000} + ] + } + } + ); + + expect(getByRole('img')).toHaveAttribute('sizes', '(min-width: 960px) 50vw, 100vw'); + }); + + it('treats single-element array variant like string variant', () => { + const {getByRole} = renderInEntry( + () => , + { + seed: { + imageFileUrlTemplates: { + large: ':id_partition/large/image.jpg' + }, + imageFiles: [ + {id: 1, permaId: 100} + ] + } + } + ); + + expect(getByRole('img')).toHaveAttribute('src', '000/000/001/large/image.jpg'); + expect(getByRole('img')).not.toHaveAttribute('srcset'); + }); + + it('skips srcset for SVG when preferSvg is true', () => { + const {getByRole} = renderInEntry( + () => , + { + seed: { + imageFileUrlTemplates: { + original: ':id_partition/original/:basename.:extension', + large: ':id_partition/large/image.jpg', + ultra: ':id_partition/ultra/image.jpg' + }, + imageFiles: [ + {id: 1, permaId: 100, basename: 'image', extension: 'svg'} + ] + } + } + ); + + expect(getByRole('img')).toHaveAttribute('src', '000/000/001/original/image.svg'); + expect(getByRole('img')).not.toHaveAttribute('srcset'); + }); + + it('does not render srcset for string variant', () => { + const {getByRole} = renderInEntry( + () => , + { + seed: { + imageFileUrlTemplates: { + large: ':id_partition/large/image.jpg' + }, + imageFiles: [ + {id: 1, permaId: 100} + ] + } + } + ); + + expect(getByRole('img')).not.toHaveAttribute('srcset'); + }); + + it('skips srcset when variants compute to same width', () => { + const {getByRole} = renderInEntry( + () => , + { + seed: { + imageFileUrlTemplates: { + large: ':id_partition/large/image.jpg', + ultra: ':id_partition/ultra/image.jpg' + }, + imageFiles: [ + {id: 1, permaId: 100, width: 1920, height: 1080} + ] + } + } + ); + + expect(getByRole('img')).toHaveAttribute('src', '000/000/001/large/image.jpg'); + expect(getByRole('img')).not.toHaveAttribute('srcset'); + }); + + it('uses computed width descriptors for portrait images', () => { + const {getByRole} = renderInEntry( + () => , + { + seed: { + imageFileUrlTemplates: { + medium: ':id_partition/medium/image.jpg', + large: ':id_partition/large/image.jpg' + }, + imageFiles: [ + {id: 1, permaId: 100, width: 2160, height: 3840} + ] + } + } + ); + + expect(getByRole('img')).toHaveAttribute('srcset', + '000/000/001/medium/image.jpg 576w, 000/000/001/large/image.jpg 1080w'); + }); + + it('uses correct width descriptors for medium and large array variant', () => { + const {getByRole} = renderInEntry( + () => , + { + seed: { + imageFileUrlTemplates: { + medium: ':id_partition/medium/image.jpg', + large: ':id_partition/large/image.jpg' + }, + imageFiles: [ + {id: 1, permaId: 100} + ] + } + } + ); + + expect(getByRole('img')).toHaveAttribute('srcset', + '000/000/001/medium/image.jpg 1024w, 000/000/001/large/image.jpg 1920w'); + }); + it('supports width and height attributes', () => { const {getByRole} = renderInEntry( () => @@ -120,6 +120,31 @@ function ImageWithCaption({ ); } +function imageVariantAndSizes(contentElementWidth) { + if (!features.isEnabled('image_srcset')) { + return { + variant: contentElementWidth === contentElementWidths.full ? 'large' : 'medium' + }; + } + + if (contentElementWidth >= contentElementWidths.xl) { + return { + variant: ['medium', 'large', 'ultra'], + sizes: contentElementWidth === contentElementWidths.full ? + '100vw' : '(min-width: 950px) 1200px, 100vw' + }; + } + + if (contentElementWidth >= contentElementWidths.md) { + return { + variant: ['medium', 'large'], + sizes: '(min-width: 950px) 950px, 100vw' + }; + } + + return {variant: 'medium'}; +} + function processImageModifiers(imageModifiers) { const cropValue = getModiferValue(imageModifiers, 'crop'); const isCircleCrop = cropValue === 'circle'; diff --git a/entry_types/scrolled/package/src/entryState/extendFile.js b/entry_types/scrolled/package/src/entryState/extendFile.js index c439bd4499..52427ea879 100644 --- a/entry_types/scrolled/package/src/entryState/extendFile.js +++ b/entry_types/scrolled/package/src/entryState/extendFile.js @@ -1,44 +1,34 @@ export function extendFile(collectionName, file, config) { - return addModelType( - collectionName, - expandUrls( - collectionName, - file, - config.fileUrlTemplates - ), - config.fileModelTypes - ); -} - -function addModelType(collectionName, file, modelTypes) { if (!file) { return null; } - if (!modelTypes[collectionName]) { - throw new Error(`Could not find model type for collection name ${collectionName}`); - } + const variants = file.variants ? + ['original', ...file.variants] : + Object.keys(config.fileUrlTemplates[collectionName] || {}); return { ...file, - modelType: modelTypes[collectionName] + modelType: resolveModelType(collectionName, config.fileModelTypes), + urls: buildUrls(collectionName, file, variants, config.fileUrlTemplates), + variantWidths: computeVariantWidths(collectionName, file, variants) }; } -function expandUrls(collectionName, file, urlTemplates) { - if (!file) { - return null; +function resolveModelType(collectionName, modelTypes) { + if (!modelTypes[collectionName]) { + throw new Error(`Could not find model type for collection name ${collectionName}`); } + return modelTypes[collectionName]; +} + +function buildUrls(collectionName, file, variants, urlTemplates) { if (!urlTemplates[collectionName]) { throw new Error(`No file url templates found for ${collectionName}`); } - const variants = file.variants ? - ['original', ...file.variants] : - Object.keys(urlTemplates[collectionName]); - - const urls = variants.reduce((result, variant) => { + return variants.reduce((result, variant) => { const url = getFileUrl(collectionName, file, variant, @@ -50,11 +40,6 @@ function expandUrls(collectionName, file, urlTemplates) { return result; }, {}); - - return { - urls, - ...file - }; } function getFileUrl(collectionName, file, quality, urlTemplates) { @@ -91,3 +76,42 @@ function hlsQualities(file) { .filter(quality => file.variants.includes(quality)) .join(','); } + +const variantGeometries = { + imageFiles: {medium: 1024, large: 1920, ultra: 3840} +}; + +function computeVariantWidths(collectionName, file, variants) { + const geometries = variantGeometries[collectionName]; + + if (!geometries) { + return undefined; + } + + const widthToVariant = {}; + + variants.forEach(variant => { + const geometrySize = geometries[variant]; + + if (geometrySize) { + const key = variantWidth(file, geometrySize) + 'w'; + + if (!widthToVariant[key]) { + widthToVariant[key] = variant; + } + } + }); + + return Object.entries(widthToVariant); +} + +function variantWidth(file, geometrySize) { + const {width, height} = file; + + if (!width || !height) { + return geometrySize; + } + + const scale = Math.min(geometrySize / width, geometrySize / height, 1); + return Math.round(width * scale); +} diff --git a/entry_types/scrolled/package/src/entryState/useFile.js b/entry_types/scrolled/package/src/entryState/useFile.js index 17c29d8c55..5423b1130f 100644 --- a/entry_types/scrolled/package/src/entryState/useFile.js +++ b/entry_types/scrolled/package/src/entryState/useFile.js @@ -1,3 +1,5 @@ +import {useMemo} from 'react'; + import {useEntryStateCollectionItem, useEntryStateConfig} from './EntryStateProvider'; import {extendFile} from './extendFile'; @@ -26,10 +28,10 @@ import {extendFile} from './extendFile'; */ export function useFile({collectionName, permaId}) { const file = useEntryStateCollectionItem(collectionName, permaId); + const config = useEntryStateConfig(); - return extendFile( - collectionName, - file, - useEntryStateConfig() + return useMemo( + () => extendFile(collectionName, file, config), + [collectionName, file, config] ); } diff --git a/entry_types/scrolled/package/src/frontend/Image.js b/entry_types/scrolled/package/src/frontend/Image.js index 5c4a6eeb89..e78526d6b6 100644 --- a/entry_types/scrolled/package/src/frontend/Image.js +++ b/entry_types/scrolled/package/src/frontend/Image.js @@ -9,7 +9,10 @@ import {ImageStructuredData} from './ImageStructuredData'; * * @param {Object} props * @param {Object} props.imageFile - Image file obtained via `useFile`. - * @param {string} [props.variant] - Paperclip style to use. Defaults to large. + * @param {string|string[]} [props.variant] - Paperclip style to use. Defaults to + * large. Pass an array (e.g. `['large', 'ultra']`) to emit a `srcset` with + * width descriptors. The first entry is used as `src` fallback. + * @param {string} [props.sizes] - Sizes attribute for srcset. Defaults to `"100vw"`. * @param {boolean} [props.load] - Whether to load the image. Can be used for lazy loading. * @param {boolean} [props.structuredData] - Whether to render a JSON+LD script tag. * @param {boolean} [props.preferSvg] - Use original if image is SVG. @@ -36,7 +39,9 @@ function renderImageTag(props, imageFile) { return ( {imageFile.configuration.alt variant.includes(v) && imageFile.urls[v]) + .map(([w, v]) => `${imageFile.urls[v]} ${w}`); + + if (entries.length <= 1) return undefined; + + return entries.join(', '); +} + +function imageSizes(imageFile, props) { + if (imageSrcSet(imageFile, props)) { + return props.sizes || '100vw'; + } + + return undefined; +} + function imageUrl(imageFile, {variant, preferSvg}) { if (variant === 'ultra' && !imageFile.urls.ultra) { variant = 'large'; diff --git a/entry_types/scrolled/package/src/frontend/v1/Backdrop/BackgroundImage.js b/entry_types/scrolled/package/src/frontend/v1/Backdrop/BackgroundImage.js index 230dfa563b..3ae1baa781 100644 --- a/entry_types/scrolled/package/src/frontend/v1/Backdrop/BackgroundImage.js +++ b/entry_types/scrolled/package/src/frontend/v1/Backdrop/BackgroundImage.js @@ -4,6 +4,7 @@ import {Image} from '../../Image'; import {MotifArea} from '../MotifArea'; import {useSectionLifecycle} from '../../useSectionLifecycle'; import {Effects} from './Effects'; +import {features} from 'pageflow/frontend'; export function BackgroundImage({image, onMotifAreaUpdate, containerDimension}) { const {shouldLoad} = useSectionLifecycle(); @@ -14,7 +15,9 @@ export function BackgroundImage({image, onMotifAreaUpdate, containerDimension}) + preferSvg={true} + variant={features.isEnabled('image_srcset') ? + ['medium', 'large', 'ultra'] : 'large'} />