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(common): add automatic srcset feature to ngOptimizedImage #47547

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
63 changes: 59 additions & 4 deletions aio/content/guide/image-directive.md
Expand Up @@ -89,24 +89,65 @@ You can typically fix this by adding `height: auto` or `width: auto` to your ima

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably want to add a line about requesting correctly sized images to the list of "benefits" at the top of the docs (e.g. line 14)

### Handling `srcset` attributes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
### Handling `srcset` attributes
### Requesting images at the right size


If your `<img>` tag defines a `srcset` attribute, replace it with `ngSrcset`.
Defining a [`srcset` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset) ensures that the browser requests an image at the right size for your user's viewport, so it doesn't waste time downloading an image that's too large. 'NgOptimizedImage' generates an appropriate `srcset` for the image, based on the presence and value of the [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes) on the image tag.

#### Fixed-size images

If your image should be "fixed" in size (i.e. the same size across devices, except for [pixel density](https://web.dev/codelab-density-descriptors/)), there is no need to set a `sizes` attribute. A `srcset` can be generated automatically from the image's width and height attributes with no further input required.

Example srcset generated: `<img ... srcset="image-400w.jpg 1x, image-800w.jpg 2x">`

#### Responsive images

If your image should be responsive (i.e. grow and shrink according to viewport size), then you will need to define a [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes) to generate the `srcset`.

If you haven't used `sizes` before, a good place to start is to set it based on viewport width. For example, if your CSS causes the image to fill 100% of viewport width, set `sizes` to `100vw` and the browser will select the image in the `srcset` that is closest to the viewport width (after accounting for pixel density). If your image is only likely to take up half the screen (ex: in a sidebar), set `sizes` to `50vw` to ensure the browser selects a smaller image. And so on.

If you find that the above does not cover your desired image behavior, see the documentation on [advanced sizes values](#advanced-sizes-values).

By default, the responsive breakpoints are:

`[16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840]`

If you would like to customize these breakpoints, you can do so using the `IMAGE_CONFIG` provider:

<code-example format="typescript" language="typescript">
providers: [
{
provide: IMAGE_CONFIG,
useValue: {
breakpoints: [16, 48, 96, 128, 384, 640, 750, 828, 1080, 1200, 1920]
}
},
],
</code-example>

If you would like to manually define a `srcset` attribute, you can provide your own directly, or use the `ngSrcset` attribute:

<code-example format="html" language="html">

&lt;img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w"&gt;

</code-example>

If the `ngSrcset` attribute is present, `NgOptimizedImage` generates and sets the [`srcset` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset) using the configured image loader. Do not include image file names in `ngSrcset` - the directive infers this information from `ngSrc`. The directive supports both width descriptors (e.g. `100w`) and density descriptors (e.g. `1x`) are supported.

You can also use `ngSrcset` with the standard image [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes).
If the `ngSrcset` attribute is present, `NgOptimizedImage` generates and sets the `srcset` using the configured image loader. Do not include image file names in `ngSrcset` - the directive infers this information from `ngSrc`. The directive supports both width descriptors (e.g. `100w`) and density descriptors (e.g. `1x`) are supported.

<code-example format="html" language="html">

&lt;img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w" sizes="50vw"&gt;

</code-example>

### Disabling automatic srcset generation

To disable srcset generation for a single image, you can add the `disableOptimizedSrcset` attribute on the image:

<code-example format="html" language="html">

&lt;img ngSrc="about.jpg" disableOptimizedSrcset&gt;

</code-example>

### Disabling image lazy loading

By default, `NgOptimizedImage` sets `loading=lazy` for all images that are not marked `priority`. You can disable this behavior for non-priority images by setting the `loading` attribute. This attribute accepts values: `eager`, `auto`, and `lazy`. [See the documentation for the standard image `loading` attribute for details](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/loading#value).
Expand All @@ -117,6 +158,20 @@ By default, `NgOptimizedImage` sets `loading=lazy` for all images that are not m

</code-example>

### Advanced 'sizes' values

You may want to have images displayed at varying widths on differently-sized screens. A common example of this pattern is a grid- or column-based layout that renders a single column on mobile devices, and two columns on larger devices. You can capture this behavior in the `sizes` attribute, using a "media query" syntax, such as the following:

<code-example format="html" language="html">

&lt;img ngSrc="cat.jpg" width="400" height="200" sizes="(max-width: 768px) 100vw, 50vw"&gt;

</code-example>

The `sizes` attribute in the above example says "I expect this image to be 100 percent of the screen width on devices under 768px wide. Otherwise, I expect it to be 50 percent of the screen width.

For additional information about the `sizes` attribute, see [web.dev](https://web.dev/learn/design/responsive-images/#sizes) or [mdn](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes).

<!-- links -->

<!-- external links -->
Expand Down
6 changes: 5 additions & 1 deletion goldens/public-api/common/index.md
Expand Up @@ -546,6 +546,9 @@ export abstract class NgLocalization {

// @public
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
set disableOptimizedSrcset(value: string | boolean | undefined);
// (undocumented)
get disableOptimizedSrcset(): boolean;
set height(value: string | number | undefined);
// (undocumented)
get height(): number | undefined;
Expand All @@ -563,11 +566,12 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
get priority(): boolean;
// @deprecated
set rawSrc(value: string);
sizes?: string;
set width(value: string | number | undefined);
// (undocumented)
get width(): number | undefined;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc],img[rawSrc]", never, { "rawSrc": "rawSrc"; "ngSrc": "ngSrc"; "ngSrcset": "ngSrcset"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "src": "src"; "srcset": "srcset"; }, {}, never, never, true, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc],img[rawSrc]", never, { "rawSrc": "rawSrc"; "ngSrc": "ngSrc"; "ngSrcset": "ngSrcset"; "sizes": "sizes"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "disableOptimizedSrcset": "disableOptimizedSrcset"; "src": "src"; "srcset": "srcset"; }, {}, never, never, true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<NgOptimizedImage, never>;
}
Expand Down
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, ElementRef, inject, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {Directive, ElementRef, inject, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';

import {RuntimeErrorCode} from '../../errors';

Expand Down Expand Up @@ -49,6 +49,15 @@ export const ABSOLUTE_SRCSET_DENSITY_CAP = 3;
*/
export const RECOMMENDED_SRCSET_DENSITY_CAP = 2;

/**
* Used in generating automatic density-based srcsets
*/
const DENSITY_SRCSET_MULTIPLIERS = [1, 2];

/**
* Used to determine which breakpoints to use on full-width images
*/
const VIEWPORT_BREAKPOINT_CUTOFF = 640;
/**
* Used to determine whether two aspect ratios are similar in value.
*/
Expand All @@ -61,6 +70,34 @@ const ASPECT_RATIO_TOLERANCE = .1;
*/
const OVERSIZED_IMAGE_TOLERANCE = 1000;

/**
* A configuration object for the NgOptimizedImage directive. Contains:
* - breakpoints: An array of integer breakpoints used to generate
* srcsets for responsive images.
*
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
* Learn more about the responsive image configuration in [the NgOptimizedImage
* guide](guide/image-directive).
* @publicApi
atcastle marked this conversation as resolved.
Show resolved Hide resolved
* @developerPreview
*/
export type ImageConfig = {
breakpoints?: number[]
};

const defaultConfig: ImageConfig = {
breakpoints: [16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840],
};

/**
* Injection token that configures the image optimized image functionality.
*
* @see `NgOptimizedImage`
* @publicApi
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
atcastle marked this conversation as resolved.
Show resolved Hide resolved
* @developerPreview
*/
export const IMAGE_CONFIG = new InjectionToken<ImageConfig>(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: do we want people to provide different breakpoint values in the different parts of the application? Or is the intention that one set of breakpoints applies to the entire application?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the main use case for doing this is to match a configuration on your CDN. Since it's possible to have different CDNs for different sections of an application (one for product images, another for static images, for instance) I think it makes sense to be able to set this differently for different parts.

'ImageConfig', {providedIn: 'root', factory: () => defaultConfig});

/**
* Directive that improves image loading performance by enforcing best practices.
*
Expand All @@ -72,6 +109,7 @@ const OVERSIZED_IMAGE_TOLERANCE = 1000;
*
* In addition, the directive:
* - Generates appropriate asset URLs if a corresponding `ImageLoader` function is provided
* - Automatically generates a srcset
* - Requires that `width` and `height` are set
* - Warns if `width` or `height` have been set incorrectly
* - Warns if the image will be visually distorted when rendered
Expand Down Expand Up @@ -165,6 +203,7 @@ const OVERSIZED_IMAGE_TOLERANCE = 1000;
})
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
private imageLoader = inject(IMAGE_LOADER);
private config: ImageConfig = processConfig(inject(IMAGE_CONFIG));
private renderer = inject(Renderer2);
private imgElement: HTMLImageElement = inject(ElementRef).nativeElement;
private injector = inject(Injector);
Expand Down Expand Up @@ -223,6 +262,12 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
*/
@Input() ngSrcset!: string;

/**
* The base `sizes` attribute passed through to the `<img>` element.
* Providing sizes causes the image to create an automatic responsive srcset.
*/
@Input() sizes?: string;
atcastle marked this conversation as resolved.
Show resolved Hide resolved

/**
* The intrinsic width of the image in pixels.
*/
Expand Down Expand Up @@ -269,6 +314,18 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
}
private _priority = false;

/**
* Disables automatic srcset generation for this image.
*/
@Input()
set disableOptimizedSrcset(value: string|boolean|undefined) {
this._disableOptimizedSrcset = inputToBoolean(value);
}
get disableOptimizedSrcset(): boolean {
return this._disableOptimizedSrcset;
}
private _disableOptimizedSrcset = false;

/**
* Value of the `src` attribute if set on the host `<img>` element.
* This input is exclusively read to assert that `src` is not set in conflict
Expand All @@ -290,12 +347,17 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
assertNonEmptyInput(this, 'ngSrc', this.ngSrc);
assertValidNgSrcset(this, this.ngSrcset);
assertNoConflictingSrc(this);
assertNoConflictingSrcset(this);
if (this.ngSrcset) {
assertNoConflictingSrcset(this);
}
assertNotBase64Image(this);
assertNotBlobUrl(this);
assertNonEmptyWidthAndHeight(this);
assertValidLoadingInput(this);
assertNoImageDistortion(this, this.imgElement, this.renderer);
if (!this.ngSrcset) {
assertNoComplexSizes(this);
}
if (this.priority) {
const checker = this.injector.get(PreconnectLinkChecker);
checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc);
Expand Down Expand Up @@ -325,8 +387,13 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
// The `src` and `srcset` attributes should be set last since other attributes
// could affect the image's loading behavior.
this.setHostAttribute('src', this.getRewrittenSrc());
if (this.sizes) {
this.setHostAttribute('sizes', this.sizes);
}
if (this.ngSrcset) {
this.setHostAttribute('srcset', this.getRewrittenSrcset());
} else if (!this._disableOptimizedSrcset && !this.srcset) {
this.setHostAttribute('srcset', this.getAutomaticSrcset());
}
}

Expand Down Expand Up @@ -370,6 +437,36 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
return finalSrcs.join(', ');
}

private getAutomaticSrcset(): string {
if (this.sizes) {
return this.getResponsiveSrcset();
} else {
return this.getFixedSrcset();
}
}

private getResponsiveSrcset(): string {
const {breakpoints} = this.config;

let filteredBreakpoints = breakpoints!;
if (this.sizes?.trim() === '100vw') {
atcastle marked this conversation as resolved.
Show resolved Hide resolved
// Since this is a full-screen-width image, our srcset only needs to include
// breakpoints with full viewport widths.
filteredBreakpoints = breakpoints!.filter(bp => bp >= VIEWPORT_BREAKPOINT_CUTOFF);
}

const finalSrcs =
filteredBreakpoints.map(bp => `${this.imageLoader({src: this.ngSrc, width: bp})} ${bp}w`);
return finalSrcs.join(', ');
}

private getFixedSrcset(): string {
const finalSrcs = DENSITY_SRCSET_MULTIPLIERS.map(
multiplier => `${this.imageLoader({src: this.ngSrc, width: this.width! * multiplier})} ${
multiplier}x`);
return finalSrcs.join(', ');
}

ngOnDestroy() {
if (ngDevMode) {
if (!this.priority && this._renderedSrc !== null && this.lcpObserver !== null) {
Expand Down Expand Up @@ -399,6 +496,16 @@ function inputToBoolean(value: unknown): boolean {
return value != null && `${value}` !== 'false';
}

/**
* Sorts provided config breakpoints and uses defaults.
*/
function processConfig(config: ImageConfig): ImageConfig {
let sortedBreakpoints: {breakpoints?: number[]} = {};
if (config.breakpoints) {
sortedBreakpoints.breakpoints = config.breakpoints.sort((a, b) => a - b);
}
return Object.assign({}, defaultConfig, config, sortedBreakpoints);
}

/***** Assert functions *****/

Expand Down Expand Up @@ -448,6 +555,21 @@ function assertNotBase64Image(dir: NgOptimizedImage) {
}
}

/**
* Verifies that the 'sizes' only includes responsive values.
*/
function assertNoComplexSizes(dir: NgOptimizedImage) {
let sizes = dir.sizes;
if (sizes?.match(/((\)|,)\s|^)\d+px/)) {
throw new RuntimeError(
RuntimeErrorCode.INVALID_INPUT,
`${imgDirectiveDetails(dir.ngSrc, false)} \`sizes\` was set to a string including ` +
`pixel values. For automatic \`srcset\` generation, \`sizes\` must only include responsive ` +
`values, such as \`sizes="50vw"\` or \`sizes="(min-width: 768px) 50vw, 100vw"\`. ` +
`To fix this, modify the \`sizes\` attribute, or provide your own \`ngSrcset\` value directly.`);
}
}

/**
* Verifies that the `ngSrc` is not a Blob URL.
*/
Expand Down