Skip to content

Commit

Permalink
feat(common): Add automatic srcset generation to ngOptimizedImage (#4…
Browse files Browse the repository at this point in the history
…7547)

Add a feature to automatically generate the srcset attribute for images using the NgOptimizedImage directive. Uses the 'sizes' attribute to determine the appropriate srcset to generate.

PR Close #47547
  • Loading branch information
atcastle authored and thePunderWoman committed Oct 10, 2022
1 parent ed11a13 commit 4fde292
Show file tree
Hide file tree
Showing 6 changed files with 381 additions and 34 deletions.
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

### Handling `srcset` attributes

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.
*
* Learn more about the responsive image configuration in [the NgOptimizedImage
* guide](guide/image-directive).
* @publicApi
* @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
* @developerPreview
*/
export const IMAGE_CONFIG = new InjectionToken<ImageConfig>(
'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;

/**
* 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') {
// 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

0 comments on commit 4fde292

Please sign in to comment.