Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add responsive image component #12381

Merged
merged 39 commits into from
Nov 11, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
91f84b0
feat: add experimental responsive images config option
ascorbic Nov 5, 2024
ff31c40
Apply suggestions from code review
ascorbic Nov 5, 2024
11e94b4
Update config types
ascorbic Nov 5, 2024
3babb85
Move config into `images`
ascorbic Nov 5, 2024
1e3d9fa
Move jsdocs
ascorbic Nov 5, 2024
42d2aa2
wip: responsive image component
ascorbic Nov 5, 2024
59e89ed
Improve srcset logic
ascorbic Nov 5, 2024
bcd401f
Improve fixture
ascorbic Nov 5, 2024
4ef817f
Lock
ascorbic Nov 6, 2024
f4641d5
Merge branch 'responsive-images' into resp-img-component
ascorbic Nov 6, 2024
bb6ec15
Update styling
ascorbic Nov 6, 2024
e5d652b
Fix style prop handling
ascorbic Nov 6, 2024
95f9d9d
Update test (there's an extra style for images now)
ascorbic Nov 6, 2024
9e3b761
Safely access the src props
ascorbic Nov 6, 2024
074252e
Remove unused export
ascorbic Nov 6, 2024
e254168
Handle priority images
ascorbic Nov 6, 2024
2121c59
Consolidate styles
ascorbic Nov 6, 2024
8752a7c
Update tests
ascorbic Nov 6, 2024
02bf554
Comment
ascorbic Nov 7, 2024
5ddf701
Refactor srcset
ascorbic Nov 7, 2024
5c3a130
Avoid dupes of original image
ascorbic Nov 7, 2024
c73c0f1
Calculate missing dimensions
ascorbic Nov 7, 2024
b639d09
Bugfixes
ascorbic Nov 8, 2024
cd99df6
Add tests
ascorbic Nov 8, 2024
7a184f0
Merge branch 'responsive-images' into resp-img-component
ascorbic Nov 8, 2024
e698d71
Fix test
ascorbic Nov 8, 2024
fcfb64c
Correct order
ascorbic Nov 8, 2024
323f240
Lint
ascorbic Nov 8, 2024
a72b325
Fix fspath
ascorbic Nov 8, 2024
f067dfe
Update test
ascorbic Nov 11, 2024
efc9fc7
Fix test
ascorbic Nov 11, 2024
e0a7c5f
Conditional component per flag
ascorbic Nov 11, 2024
2a174d2
Fix class concatenation
ascorbic Nov 11, 2024
2e8a4e2
Remove logger
ascorbic Nov 11, 2024
8af0315
Rename helper
ascorbic Nov 11, 2024
ee0c499
Add comments
ascorbic Nov 11, 2024
9487ef8
Format
ascorbic Nov 11, 2024
71ba896
Fix markdoc tests
ascorbic Nov 11, 2024
e538c34
Add test for style tag
ascorbic Nov 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 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,58 @@ 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} />
<img
src={image.src}
{...attrs}
{style}
class:list={[
experimentalResponsiveImages && layoutClassMap[props.layout ?? imageConfig.experimentalLayout],
experimentalResponsiveImages && 'aim',
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>
93 changes: 80 additions & 13 deletions packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from './types.js';
import { isESMImportedImage, isRemoteImage, resolveSrc } from './utils/imageKind.js';
import { inferRemoteSize } from './utils/remoteProbe.js';
import { DEFAULT_RESOLUTIONS, getSizes, getWidths, LIMITED_RESOLUTIONS } from './layout.js';

export async function getConfiguredImageService(): Promise<ImageService> {
if (!globalThis?.astroAsset?.imageService) {
Expand All @@ -32,9 +33,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 +70,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 +83,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 +100,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;
ematipico marked this conversation as resolved.
Show resolved Hide resolved

if (imageConfig.experimentalResponsiveImages && layout) {
resolvedOptions.widths ||= getWidths({
width: resolvedOptions.width,
layout,
originalWidth,
breakpoints: isLocalService(service) ? LIMITED_RESOLUTIONS : DEFAULT_RESOLUTIONS,
});
resolvedOptions.sizes ||= getSizes({ 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 +153,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 +183,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
108 changes: 108 additions & 0 deletions packages/astro/src/assets/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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 = [
ematipico marked this conversation as resolved.
Show resolved Hide resolved
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
*/
ematipico marked this conversation as resolved.
Show resolved Hide resolved
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;
if (layout === 'full-width') {
return breakpoints.filter(smallerThanOriginal);
}
if (!width) {
return [];
}
const doubleWidth = width * 2;
const maxSize = originalWidth ? Math.min(doubleWidth, originalWidth) : doubleWidth;
if (layout === 'fixed') {
// If the image is larger than the original, only include the original width
// Otherwise, include the image width and the double-resolution width, unless the double-resolution width is larger than the original
return originalWidth && width > originalWidth ? [originalWidth] : [width, maxSize];
}
if (layout === 'responsive') {
return (
[
// Always include the image at 1x and 2x the specified width
width,
doubleWidth,
...breakpoints,
]
// Sort the resolutions in ascending order
.sort((a, b) => a - b)
// Filter out any resolutions that are larger than the double-resolution image or source image
.filter((w) => w <= maxSize)
ematipico marked this conversation as resolved.
Show resolved Hide resolved
);
}

return [];
};

/**
* Gets the `sizes` attribute for an image, based on the layout and width
*/
export const getSizes = ({
ematipico marked this conversation as resolved.
Show resolved Hide resolved
width,
layout,
}: { width?: number; layout?: ImageLayout }): string | undefined => {
ematipico marked this conversation as resolved.
Show resolved Hide resolved
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
Loading