mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(common): Add automatic srcset generation to ngOptimizedImage (#47547)
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
This commit is contained in:
committed by
Jessica Janiuk
parent
ed11a13c3c
commit
4fde292bb5
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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.
|
||||
*
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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 *****/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user