Skip to content

Commit

Permalink
feat: add responsive image component (#12381)
Browse files Browse the repository at this point in the history
* feat: add experimental responsive images config option

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update config types

* Move config into `images`

* Move jsdocs

* wip: responsive image component

* Improve srcset logic

* Improve fixture

* Lock

* Update styling

* Fix style prop handling

* Update test (there's an extra style for images now)

* Safely access the src props

* Remove unused export

* Handle priority images

* Consolidate styles

* Update tests

* Comment

* Refactor srcset

* Avoid dupes of original image

* Calculate missing dimensions

* Bugfixes

* Add tests

* Fix test

* Correct order

* Lint

* Fix fspath

* Update test

* Fix test

* Conditional component per flag

* Fix class concatenation

* Remove logger

* Rename helper

* Add comments

* Format

* Fix markdoc tests

* Add test for style tag

---------

Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
ascorbic and sarah11918 authored Nov 11, 2024
1 parent ffe063c commit e4a9c35
Show file tree
Hide file tree
Showing 21 changed files with 837 additions and 84 deletions.
58 changes: 56 additions & 2 deletions packages/astro/components/Image.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
import { type LocalImageProps, type RemoteImageProps, getImage } from 'astro:assets';
import { type LocalImageProps, type RemoteImageProps, getImage, imageConfig } from 'astro:assets';
import { AstroError, AstroErrorData } from '../dist/core/errors/index.js';
import type { HTMLAttributes } from '../types';
Expand Down Expand Up @@ -33,6 +33,60 @@ if (image.srcSet.values.length > 0) {
if (import.meta.env.DEV) {
additionalAttributes['data-image-component'] = 'true';
}
const { experimentalResponsiveImages } = imageConfig;
const layoutClassMap = {
fixed: 'aim-fi',
responsive: 'aim-re',
};
const cssFitValues = ['fill', 'contain', 'cover', 'scale-down'];
const objectFit = props.fit ?? imageConfig.experimentalObjectFit ?? 'cover';
const objectPosition = props.position ?? imageConfig.experimentalObjectPosition ?? 'center';
// The style prop can't be spread when using define:vars, so we need to extract it here
// @see https://github.com/withastro/compiler/issues/1050
const { style = '', class: className, ...attrs } = { ...additionalAttributes, ...image.attributes };
---

<img src={image.src} {...additionalAttributes} {...image.attributes} />
{
experimentalResponsiveImages ? (
<img
src={image.src}
{...attrs}
{style}
class={`${layoutClassMap[props.layout ?? imageConfig.experimentalLayout] ?? ''} aim ${className ?? ''}`.trim()}
/>
) : (
<img src={image.src} {...additionalAttributes} {...image.attributes} class={className} />
)
}

<style
define:vars={experimentalResponsiveImages && {
w: image.attributes.width ?? props.width ?? image.options.width,
h: image.attributes.height ?? props.height ?? image.options.height,
fit: cssFitValues.includes(objectFit) && objectFit,
pos: objectPosition,
}}
>
/* Shared by all Astro images */
.aim {
width: 100%;
height: auto;
object-fit: var(--fit);
object-position: var(--pos);
aspect-ratio: var(--w) / var(--h);
}
/* Styles for responsive layout */
.aim-re {
max-width: calc(var(--w) * 1px);
max-height: calc(var(--h) * 1px);
}
/* Styles for fixed layout */
.aim-fi {
width: calc(var(--w) * 1px);
height: calc(var(--h) * 1px);
}
</style>
98 changes: 85 additions & 13 deletions packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import {
} from './types.js';
import { isESMImportedImage, isRemoteImage, resolveSrc } from './utils/imageKind.js';
import { inferRemoteSize } from './utils/remoteProbe.js';
import {
DEFAULT_RESOLUTIONS,
getSizesAttribute,
getWidths,
LIMITED_RESOLUTIONS,
} from './layout.js';

export async function getConfiguredImageService(): Promise<ImageService> {
if (!globalThis?.astroAsset?.imageService) {
Expand All @@ -32,9 +38,13 @@ export async function getConfiguredImageService(): Promise<ImageService> {
return globalThis.astroAsset.imageService;
}

type ImageConfig = AstroConfig['image'] & {
experimentalResponsiveImages: boolean;
};

export async function getImage(
options: UnresolvedImageTransform,
imageConfig: AstroConfig['image'],
imageConfig: ImageConfig,
): Promise<GetImageResult> {
if (!options || typeof options !== 'object') {
throw new AstroError({
Expand Down Expand Up @@ -65,6 +75,10 @@ export async function getImage(
src: await resolveSrc(options.src),
};

let originalWidth: number | undefined;
let originalHeight: number | undefined;
let originalFormat: string | undefined;

// Infer size for remote images if inferSize is true
if (
options.inferSize &&
Expand All @@ -74,6 +88,9 @@ export async function getImage(
const result = await inferRemoteSize(resolvedOptions.src); // Directly probe the image URL
resolvedOptions.width ??= result.width;
resolvedOptions.height ??= result.height;
originalWidth = result.width;
originalHeight = result.height;
originalFormat = result.format;
delete resolvedOptions.inferSize; // Delete so it doesn't end up in the attributes
}

Expand All @@ -88,8 +105,49 @@ export async function getImage(
(resolvedOptions.src.clone ?? resolvedOptions.src)
: resolvedOptions.src;

if (isESMImportedImage(clonedSrc)) {
originalWidth = clonedSrc.width;
originalHeight = clonedSrc.height;
originalFormat = clonedSrc.format;
}

if (originalWidth && originalHeight) {
// Calculate any missing dimensions from the aspect ratio, if available
const aspectRatio = originalWidth / originalHeight;
if (resolvedOptions.height && !resolvedOptions.width) {
resolvedOptions.width = Math.round(resolvedOptions.height * aspectRatio);
} else if (resolvedOptions.width && !resolvedOptions.height) {
resolvedOptions.height = Math.round(resolvedOptions.width / aspectRatio);
} else if (!resolvedOptions.width && !resolvedOptions.height) {
resolvedOptions.width = originalWidth;
resolvedOptions.height = originalHeight;
}
}
resolvedOptions.src = clonedSrc;

const layout = options.layout ?? imageConfig.experimentalLayout;

if (imageConfig.experimentalResponsiveImages && layout) {
resolvedOptions.widths ||= getWidths({
width: resolvedOptions.width,
layout,
originalWidth,
breakpoints: isLocalService(service) ? LIMITED_RESOLUTIONS : DEFAULT_RESOLUTIONS,
});
resolvedOptions.sizes ||= getSizesAttribute({ width: resolvedOptions.width, layout });

if (resolvedOptions.priority) {
resolvedOptions.loading ??= 'eager';
resolvedOptions.decoding ??= 'sync';
resolvedOptions.fetchpriority ??= 'high';
} else {
resolvedOptions.loading ??= 'lazy';
resolvedOptions.decoding ??= 'async';
resolvedOptions.fetchpriority ??= 'auto';
}
delete resolvedOptions.priority;
}

const validatedOptions = service.validateOptions
? await service.validateOptions(resolvedOptions, imageConfig)
: resolvedOptions;
Expand All @@ -100,13 +158,23 @@ export async function getImage(
: [];

let imageURL = await service.getURL(validatedOptions, imageConfig);

const matchesOriginal = (transform: ImageTransform) =>
transform.width === originalWidth &&
transform.height === originalHeight &&
transform.format === originalFormat;

let srcSets: SrcSetValue[] = await Promise.all(
srcSetTransforms.map(async (srcSet) => ({
transform: srcSet.transform,
url: await service.getURL(srcSet.transform, imageConfig),
descriptor: srcSet.descriptor,
attributes: srcSet.attributes,
})),
srcSetTransforms.map(async (srcSet) => {
return {
transform: srcSet.transform,
url: matchesOriginal(srcSet.transform)
? imageURL
: await service.getURL(srcSet.transform, imageConfig),
descriptor: srcSet.descriptor,
attributes: srcSet.attributes,
};
}),
);

if (
Expand All @@ -120,12 +188,16 @@ export async function getImage(
propsToHash,
originalFilePath,
);
srcSets = srcSetTransforms.map((srcSet) => ({
transform: srcSet.transform,
url: globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash, originalFilePath),
descriptor: srcSet.descriptor,
attributes: srcSet.attributes,
}));
srcSets = srcSetTransforms.map((srcSet) => {
return {
transform: srcSet.transform,
url: matchesOriginal(srcSet.transform)
? imageURL
: globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash, originalFilePath),
descriptor: srcSet.descriptor,
attributes: srcSet.attributes,
};
});
}

return {
Expand Down
118 changes: 118 additions & 0 deletions packages/astro/src/assets/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { ImageLayout } from '../types/public/index.js';

// Common screen widths. These will be filtered according to the image size and layout
export const DEFAULT_RESOLUTIONS = [
640, // older and lower-end phones
750, // iPhone 6-8
828, // iPhone XR/11
960, // older horizontal phones
1080, // iPhone 6-8 Plus
1280, // 720p
1668, // Various iPads
1920, // 1080p
2048, // QXGA
2560, // WQXGA
3200, // QHD+
3840, // 4K
4480, // 4.5K
5120, // 5K
6016, // 6K
];

// A more limited set of screen widths, for statically generated images
export const LIMITED_RESOLUTIONS = [
640, // older and lower-end phones
750, // iPhone 6-8
828, // iPhone XR/11
1080, // iPhone 6-8 Plus
1280, // 720p
1668, // Various iPads
2048, // QXGA
2560, // WQXGA
];

/**
* Gets the breakpoints for an image, based on the layout and width
*
* The rules are as follows:
*
* - For full-width layout we return all breakpoints smaller than the original image width
* - For fixed layout we return 1x and 2x the requested width, unless the original image is smaller than that.
* - For responsive layout we return all breakpoints smaller than 2x the requested width, unless the original image is smaller than that.
*/
export const getWidths = ({
width,
layout,
breakpoints = DEFAULT_RESOLUTIONS,
originalWidth,
}: {
width?: number;
layout: ImageLayout;
breakpoints?: Array<number>;
originalWidth?: number;
}): Array<number> => {
const smallerThanOriginal = (w: number) => !originalWidth || w <= originalWidth;

// For full-width layout we return all breakpoints smaller than the original image width
if (layout === 'full-width') {
return breakpoints.filter(smallerThanOriginal);
}
// For other layouts we need a width to generate breakpoints. If no width is provided, we return an empty array
if (!width) {
return [];
}
const doubleWidth = width * 2;
// For fixed layout we want to return the 1x and 2x widths. We only do this if the original image is large enough to do this though.
const maxSize = originalWidth ? Math.min(doubleWidth, originalWidth) : doubleWidth;
if (layout === 'fixed') {
return originalWidth && width > originalWidth ? [originalWidth] : [width, maxSize];
}

// For responsive layout we want to return all breakpoints smaller than 2x requested width.
if (layout === 'responsive') {
return (
[
// Always include the image at 1x and 2x the specified width
width,
doubleWidth,
...breakpoints,
]
// Filter out any resolutions that are larger than the double-resolution image or source image
.filter((w) => w <= maxSize)
// Sort the resolutions in ascending order
.sort((a, b) => a - b)
);
}

return [];
};

/**
* Gets the `sizes` attribute for an image, based on the layout and width
*/
export const getSizesAttribute = ({
width,
layout,
}: { width?: number; layout?: ImageLayout }): string | undefined => {
if (!width || !layout) {
return undefined;
}
switch (layout) {
// If screen is wider than the max size then image width is the max size,
// otherwise it's the width of the screen
case `responsive`:
return `(min-width: ${width}px) ${width}px, 100vw`;

// Image is always the same width, whatever the size of the screen
case `fixed`:
return `${width}px`;

// Image is always the width of the screen
case `full-width`:
return `100vw`;

case 'none':
default:
return undefined;
}
};
Loading

0 comments on commit e4a9c35

Please sign in to comment.