mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
refactor(common): various NgOptimizedImage directive updates (#47082)
This commit contains a set of small changes based on the feedback received in https://github.com/angular/angular/pull/47082. PR Close #47082
This commit is contained in:
committed by
Pawel Kozlowski
parent
aa254244f4
commit
213f57cb3d
@@ -11,15 +11,15 @@ import {ɵRuntimeError as RuntimeError} from '@angular/core';
|
||||
import {RuntimeErrorCode} from '../../errors';
|
||||
|
||||
/**
|
||||
* Asserts whether an ngDevMode is enabled and throws an error if it's not the case.
|
||||
* This assert can be used to make sure that there is no dev-mode code invoked in
|
||||
* Asserts that the application is in development mode. Throws an error if the application is in
|
||||
* production mode. This assert can be used to make sure that there is no dev-mode code invoked in
|
||||
* the prod mode accidentally.
|
||||
*/
|
||||
export function assertDevMode(check: string) {
|
||||
export function assertDevMode(checkName: string) {
|
||||
if (!ngDevMode) {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE,
|
||||
`Unexpected invocation of the ${check} in the prod mode. ` +
|
||||
`Unexpected invocation of the ${checkName} in the prod mode. ` +
|
||||
`Please make sure that the prod mode is enabled for production builds.`);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -9,23 +9,26 @@
|
||||
import {createImageLoader, ImageLoaderConfig} from './image_loader';
|
||||
|
||||
/**
|
||||
* Function that generates a built-in ImageLoader for Cloudflare Image Resizing
|
||||
* Function that generates an ImageLoader for Cloudflare Image Resizing
|
||||
* and turns it into an Angular provider. Note: Cloudflare has multiple image
|
||||
* products - this provider is specifically for Cloudflare Image Resizing;
|
||||
* it will not work with Cloudflare Images or Cloudflare Polish.
|
||||
*
|
||||
* @param path Your domain name
|
||||
* e.g. https://mysite.com
|
||||
* @param path Your domain name, e.g. https://mysite.com
|
||||
* @param options An object with extra configuration:
|
||||
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
|
||||
* should verify that there is a corresponding `<link rel="preconnect">`
|
||||
* present in the document's `<head>`.
|
||||
* @returns Provider that provides an ImageLoader function
|
||||
*
|
||||
* @publicApi
|
||||
* @developerPreview
|
||||
*/
|
||||
export const provideCloudflareLoader = createImageLoader(
|
||||
createCloudflareURL,
|
||||
createCloudflareUrl,
|
||||
ngDevMode ? ['https://<ZONE>/cdn-cgi/image/<OPTIONS>/<SOURCE-IMAGE>'] : undefined);
|
||||
|
||||
function createCloudflareURL(path: string, config: ImageLoaderConfig) {
|
||||
function createCloudflareUrl(path: string, config: ImageLoaderConfig) {
|
||||
let params = `format=auto`;
|
||||
if (config.width) {
|
||||
params += `,width=${config.width}`;
|
||||
|
||||
+4
-5
@@ -9,15 +9,14 @@
|
||||
import {createImageLoader, ImageLoaderConfig} from './image_loader';
|
||||
|
||||
/**
|
||||
* Function that generates a built-in ImageLoader for Cloudinary
|
||||
* and turns it into an Angular provider.
|
||||
* Function that generates an ImageLoader for Cloudinary and turns it into an Angular provider.
|
||||
*
|
||||
* @param path Base URL of your Cloudinary images
|
||||
* This URL should match one of the following formats:
|
||||
* https://res.cloudinary.com/mysite
|
||||
* https://mysite.cloudinary.com
|
||||
* https://subdomain.mysite.com
|
||||
* @param options An object that allows to provide extra configuration:
|
||||
* @param options An object with extra configuration:
|
||||
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
|
||||
* should verify that there is a corresponding `<link rel="preconnect">`
|
||||
* present in the document's `<head>`.
|
||||
@@ -27,7 +26,7 @@ import {createImageLoader, ImageLoaderConfig} from './image_loader';
|
||||
* @developerPreview
|
||||
*/
|
||||
export const provideCloudinaryLoader = createImageLoader(
|
||||
createCloudinaryURL,
|
||||
createCloudinaryUrl,
|
||||
ngDevMode ?
|
||||
[
|
||||
'https://res.cloudinary.com/mysite', 'https://mysite.cloudinary.com',
|
||||
@@ -35,7 +34,7 @@ export const provideCloudinaryLoader = createImageLoader(
|
||||
] :
|
||||
undefined);
|
||||
|
||||
function createCloudinaryURL(path: string, config: ImageLoaderConfig) {
|
||||
function createCloudinaryUrl(path: string, config: ImageLoaderConfig) {
|
||||
// Example of a Cloudinary image URL:
|
||||
// https://res.cloudinary.com/mysite/image/upload/c_scale,f_auto,q_auto,w_600/marketing/tile-topics-m.png
|
||||
let params = `f_auto,q_auto`; // sets image format and quality to "auto"
|
||||
|
||||
@@ -19,9 +19,13 @@ import {isAbsoluteUrl, isValidPath, normalizePath, normalizeSrc} from '../url';
|
||||
* @developerPreview
|
||||
*/
|
||||
export interface ImageLoaderConfig {
|
||||
// Name of the image to be added to the image request URL
|
||||
/**
|
||||
* Image file name to be added to the image request URL.
|
||||
*/
|
||||
src: string;
|
||||
// Width of the requested image (to be used when generating srcset)
|
||||
/**
|
||||
* Width of the requested image (to be used when generating srcset).
|
||||
*/
|
||||
width?: number;
|
||||
}
|
||||
|
||||
@@ -40,8 +44,8 @@ export type ImageLoader = (config: ImageLoaderConfig) => string;
|
||||
const noopImageLoader = (config: ImageLoaderConfig) => config.src;
|
||||
|
||||
/**
|
||||
* Special token that allows to configure a function that will be used to produce an image URL based
|
||||
* on the specified input.
|
||||
* Injection token that configures the function that produces image URLs based on the specified
|
||||
* input.
|
||||
*
|
||||
* @publicApi
|
||||
* @developerPreview
|
||||
@@ -62,6 +66,11 @@ export function createImageLoader(
|
||||
|
||||
const loaderFn = (config: ImageLoaderConfig) => {
|
||||
if (ngDevMode && isAbsoluteUrl(config.src)) {
|
||||
// Image loader functions expect an image file name (e.g. `my-image.png`)
|
||||
// or a relative path + a file name (e.g. `/a/b/c/my-image.png`) as an input,
|
||||
// so the final absolute URL can be constructed.
|
||||
// When an absolute URL is provided instead - the loader can not
|
||||
// build a final URL, thus the error is thrown to indicate that.
|
||||
throwUnexpectedAbsoluteUrlError(path, config.src);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
import {createImageLoader, ImageLoaderConfig} from './image_loader';
|
||||
|
||||
/**
|
||||
* Function that generates a built-in ImageLoader for ImageKit
|
||||
* and turns it into an Angular provider.
|
||||
* Function that generates an ImageLoader for ImageKit and turns it into an Angular provider.
|
||||
*
|
||||
* @param path Base URL of your ImageKit images
|
||||
* This URL should match one of the following formats:
|
||||
* https://ik.imagekit.io/myaccount
|
||||
* https://subdomain.mysite.com
|
||||
* @param options An object that allows to provide extra configuration:
|
||||
* @param options An object with extra configuration:
|
||||
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
|
||||
* should verify that there is a corresponding `<link rel="preconnect">`
|
||||
* present in the document's `<head>`.
|
||||
@@ -26,10 +25,10 @@ import {createImageLoader, ImageLoaderConfig} from './image_loader';
|
||||
* @developerPreview
|
||||
*/
|
||||
export const provideImageKitLoader = createImageLoader(
|
||||
createImagekitURL,
|
||||
createImagekitUrl,
|
||||
ngDevMode ? ['https://ik.imagekit.io/mysite', 'https://subdomain.mysite.com'] : undefined);
|
||||
|
||||
export function createImagekitURL(path: string, config: ImageLoaderConfig) {
|
||||
export function createImagekitUrl(path: string, config: ImageLoaderConfig) {
|
||||
// Example of an ImageKit image URL:
|
||||
// https://ik.imagekit.io/demo/tr:w-300,h-300/medium_cafe_B1iTdD0C.jpg
|
||||
let params = `tr:q-auto`; // applies the "auto quality" transformation
|
||||
|
||||
@@ -9,12 +9,11 @@
|
||||
import {createImageLoader, ImageLoaderConfig} from './image_loader';
|
||||
|
||||
/**
|
||||
* Function that generates a built-in ImageLoader for Imgix and turns it
|
||||
* into an Angular provider.
|
||||
* Function that generates an ImageLoader for Imgix and turns it into an Angular provider.
|
||||
*
|
||||
* @param path path to the desired Imgix origin,
|
||||
* e.g. https://somepath.imgix.net or https://images.mysite.com
|
||||
* @param options An object that allows to provide extra configuration:
|
||||
* @param options An object with extra configuration:
|
||||
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
|
||||
* should verify that there is a corresponding `<link rel="preconnect">`
|
||||
* present in the document's `<head>`.
|
||||
@@ -24,9 +23,9 @@ import {createImageLoader, ImageLoaderConfig} from './image_loader';
|
||||
* @developerPreview
|
||||
*/
|
||||
export const provideImgixLoader =
|
||||
createImageLoader(createImgixURL, ngDevMode ? ['https://somepath.imgix.net/'] : undefined);
|
||||
createImageLoader(createImgixUrl, ngDevMode ? ['https://somepath.imgix.net/'] : undefined);
|
||||
|
||||
function createImgixURL(path: string, config: ImageLoaderConfig) {
|
||||
function createImgixUrl(path: string, config: ImageLoaderConfig) {
|
||||
const url = new URL(`${path}/${config.src}`);
|
||||
// This setting ensures the smallest allowable format is set.
|
||||
url.searchParams.set('auto', 'format');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {Inject, Injectable, OnDestroy, ɵformatRuntimeError as formatRuntimeError} from '@angular/core';
|
||||
import {inject, Injectable, OnDestroy, ɵformatRuntimeError as formatRuntimeError} from '@angular/core';
|
||||
|
||||
import {DOCUMENT} from '../../dom_tokens';
|
||||
import {RuntimeErrorCode} from '../../errors';
|
||||
@@ -16,11 +16,11 @@ import {imgDirectiveDetails} from './error_helper';
|
||||
import {getUrl} from './url';
|
||||
|
||||
/**
|
||||
* Contains the logic to detect whether an image with the `NgOptimizedImage` directive
|
||||
* is treated as an LCP element. If so, verifies that the image is marked as a priority,
|
||||
* using the `priority` attribute.
|
||||
* Observer that detects whether an image with `NgOptimizedImage`
|
||||
* is treated as a Largest Contentful Paint (LCP) element. If so,
|
||||
* asserts that the image has the `priority` attribute.
|
||||
*
|
||||
* Note: this is a dev-mode only class, which should not appear in prod bundles,
|
||||
* Note: this is a dev-mode only class and it does not appear in prod bundles,
|
||||
* thus there is no `ngDevMode` use in the code.
|
||||
*
|
||||
* Based on https://web.dev/lcp/#measure-lcp-in-javascript.
|
||||
@@ -35,22 +35,24 @@ export class LCPImageObserver implements OnDestroy {
|
||||
private window: Window|null = null;
|
||||
private observer: PerformanceObserver|null = null;
|
||||
|
||||
constructor(@Inject(DOCUMENT) doc: Document) {
|
||||
constructor() {
|
||||
assertDevMode('LCP checker');
|
||||
const win = doc.defaultView;
|
||||
const win = inject(DOCUMENT).defaultView;
|
||||
if (typeof win !== 'undefined' && typeof PerformanceObserver !== 'undefined') {
|
||||
this.window = win;
|
||||
this.observer = this.initPerformanceObserver();
|
||||
}
|
||||
}
|
||||
|
||||
// Inits PerformanceObserver and subscribes to LCP events.
|
||||
// Based on https://web.dev/lcp/#measure-lcp-in-javascript
|
||||
/**
|
||||
* Inits PerformanceObserver and subscribes to LCP events.
|
||||
* Based on https://web.dev/lcp/#measure-lcp-in-javascript
|
||||
*/
|
||||
private initPerformanceObserver(): PerformanceObserver {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
if (entries.length === 0) return;
|
||||
// Note: we use the latest entry produced by the `PerformanceObserver` as the best
|
||||
// We use the latest entry produced by the `PerformanceObserver` as the best
|
||||
// signal on which element is actually an LCP one. As an example, the first image to load on
|
||||
// a page, by virtue of being the only thing on the page so far, is often a LCP candidate
|
||||
// and gets reported by PerformanceObserver, but isn't necessarily the LCP element.
|
||||
@@ -64,13 +66,7 @@ export class LCPImageObserver implements OnDestroy {
|
||||
const imgRawSrc = this.images.get(imgSrc);
|
||||
if (imgRawSrc && !this.alreadyWarned.has(imgSrc)) {
|
||||
this.alreadyWarned.add(imgSrc);
|
||||
const directiveDetails = imgDirectiveDetails(imgRawSrc);
|
||||
console.warn(formatRuntimeError(
|
||||
RuntimeErrorCode.LCP_IMG_MISSING_PRIORITY,
|
||||
`${directiveDetails} this image is the Largest Contentful Paint (LCP) ` +
|
||||
`element but was not marked "priority". This image should be marked ` +
|
||||
`"priority" in order to prioritize its loading. ` +
|
||||
`To fix this, add the "priority" attribute.`));
|
||||
logMissingPriorityWarning(imgSrc);
|
||||
}
|
||||
});
|
||||
observer.observe({type: 'largest-contentful-paint', buffered: true});
|
||||
@@ -94,3 +90,13 @@ export class LCPImageObserver implements OnDestroy {
|
||||
this.alreadyWarned.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function logMissingPriorityWarning(rawSrc: string) {
|
||||
const directiveDetails = imgDirectiveDetails(rawSrc);
|
||||
console.warn(formatRuntimeError(
|
||||
RuntimeErrorCode.LCP_IMG_MISSING_PRIORITY,
|
||||
`${directiveDetails} this image is the Largest Contentful Paint (LCP) ` +
|
||||
`element but was not marked "priority". This image should be marked ` +
|
||||
`"priority" in order to prioritize its loading. ` +
|
||||
`To fix this, add the "priority" attribute.`));
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {Directive, ElementRef, Inject, Injector, Input, NgModule, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
|
||||
import {Directive, ElementRef, inject, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
|
||||
|
||||
import {RuntimeErrorCode} from '../../errors';
|
||||
|
||||
import {imgDirectiveDetails} from './error_helper';
|
||||
import {IMAGE_LOADER, ImageLoader} from './image_loaders/image_loader';
|
||||
import {IMAGE_LOADER} from './image_loaders/image_loader';
|
||||
import {LCPImageObserver} from './lcp_image_observer';
|
||||
import {PreconnectLinkChecker} from './preconnect_link_checker';
|
||||
|
||||
@@ -55,15 +55,16 @@ export const RECOMMENDED_SRCSET_DENSITY_CAP = 2;
|
||||
const ASPECT_RATIO_TOLERANCE = .1;
|
||||
|
||||
/**
|
||||
* The directive that helps to improve image loading performance by following best practices.
|
||||
* Directive that improves image loading performance by enforcing best practices.
|
||||
*
|
||||
* The `NgOptimizedImage` ensures that the loading of the LCP image is prioritized by:
|
||||
* `NgOptimizedImage` ensures that the loading of the Largest Contentful Paint (LCP) image is
|
||||
* prioritized by:
|
||||
* - Automatically setting the `fetchpriority` attribute on the `<img>` tag
|
||||
* - Lazy loading non-priority images by default
|
||||
* - Asserting that there is a corresponding preconnect link tag in the document head
|
||||
*
|
||||
* In addition, the directive:
|
||||
* - Generates appropriate asset URLs (if a corresponding loader function is provided)
|
||||
* - Generates appropriate asset URLs if a corresponding `ImageLoader` function is provided
|
||||
* - 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
|
||||
@@ -74,8 +75,10 @@ const ASPECT_RATIO_TOLERANCE = .1;
|
||||
*
|
||||
* Follow the steps below to enable and use the directive:
|
||||
* 1. Import it into the necessary NgModule or a standalone Component.
|
||||
* 2. Configure a loader that you want to use.
|
||||
* 2. Optionally provide an `ImageLoader` if you use an image hosting service.
|
||||
* 3. Update the necessary `<img>` tags in templates and replace `src` attributes with `rawSrc`.
|
||||
* Using a `rawSrc` allows the directive to control when the `src` gets set, which triggers an image
|
||||
* download.
|
||||
*
|
||||
* Step 1: import the `NgOptimizedImage` directive.
|
||||
*
|
||||
@@ -140,7 +143,7 @@ const ASPECT_RATIO_TOLERANCE = .1;
|
||||
* ],
|
||||
* ```
|
||||
*
|
||||
* Step 3: update `<img>` tags in templates to use `rawSrc` instead of `rawSrc`.
|
||||
* Step 3: update `<img>` tags in templates to use `rawSrc` instead of `src`.
|
||||
*
|
||||
* ```
|
||||
* <img rawSrc="logo.png" width="200" height="100">
|
||||
@@ -154,20 +157,18 @@ const ASPECT_RATIO_TOLERANCE = .1;
|
||||
selector: 'img[rawSrc]',
|
||||
})
|
||||
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
|
||||
constructor(
|
||||
@Inject(IMAGE_LOADER) private imageLoader: ImageLoader, private renderer: Renderer2,
|
||||
private imgElement: ElementRef, private injector: Injector) {}
|
||||
private imageLoader = inject(IMAGE_LOADER);
|
||||
private renderer = inject(Renderer2);
|
||||
private imgElement: HTMLImageElement = inject(ElementRef).nativeElement;
|
||||
private injector = inject(Injector);
|
||||
|
||||
// Private fields to keep normalized input values.
|
||||
private _width?: number;
|
||||
private _height?: number;
|
||||
private _priority = false;
|
||||
|
||||
// Calculate the rewritten `src` once and store it.
|
||||
// This is needed to avoid repetitive calculations and make sure the directive cleanup in the
|
||||
// `ngOnDestroy` does not rely on the `IMAGE_LOADER` logic (which in turn can rely on some other
|
||||
// instance that might be already destroyed).
|
||||
private _rewrittenSrc: string|null = null;
|
||||
/**
|
||||
* Calculate the rewritten `src` once and store it.
|
||||
* This is needed to avoid repetitive calculations and make sure the directive cleanup in the
|
||||
* `ngOnDestroy` does not rely on the `IMAGE_LOADER` logic (which in turn can rely on some other
|
||||
* instance that might be already destroyed).
|
||||
*/
|
||||
private _renderedSrc: string|null = null;
|
||||
|
||||
/**
|
||||
* Name of the source image.
|
||||
@@ -190,34 +191,36 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() rawSrcset!: string;
|
||||
|
||||
/**
|
||||
* The intrinsic width of the image in px.
|
||||
* The intrinsic width of the image in pixels.
|
||||
*/
|
||||
@Input()
|
||||
set width(value: string|number|undefined) {
|
||||
ngDevMode && assertGreaterThanZeroNumber(this, value, 'width');
|
||||
ngDevMode && assertGreaterThanZero(this, value, 'width');
|
||||
this._width = inputToInteger(value);
|
||||
}
|
||||
get width(): number|undefined {
|
||||
return this._width;
|
||||
}
|
||||
private _width?: number;
|
||||
|
||||
/**
|
||||
* The intrinsic height of the image in px.
|
||||
* The intrinsic height of the image in pixels.
|
||||
*/
|
||||
@Input()
|
||||
set height(value: string|number|undefined) {
|
||||
ngDevMode && assertGreaterThanZeroNumber(this, value, 'height');
|
||||
ngDevMode && assertGreaterThanZero(this, value, 'height');
|
||||
this._height = inputToInteger(value);
|
||||
}
|
||||
get height(): number|undefined {
|
||||
return this._height;
|
||||
}
|
||||
private _height?: number;
|
||||
|
||||
/**
|
||||
* The desired loading behavior (lazy, eager, or auto).
|
||||
* The primary use case for this input is opting-out non-priority images
|
||||
* from lazy loading by marking them loading='eager' or loading='auto'.
|
||||
* This input should not be used with priority images.
|
||||
*
|
||||
* Setting images as loading='eager' or loading='auto' marks them
|
||||
* as non-priority images. Avoid changing this input for priority images.
|
||||
*/
|
||||
@Input() loading?: string;
|
||||
|
||||
@@ -231,16 +234,22 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
|
||||
get priority(): boolean {
|
||||
return this._priority;
|
||||
}
|
||||
private _priority = false;
|
||||
|
||||
/**
|
||||
* Get a value of the `src` and `srcset` if they're set on a host `<img>` element.
|
||||
* These inputs are needed to verify that there are no conflicting sources provided
|
||||
* at the same time (e.g. `src` and `rawSrc` together or `srcset` and `rawSrcset`,
|
||||
* thus causing an ambiguity on which src to use) and that images
|
||||
* don't start to load until a lazy loading strategy is set.
|
||||
* 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
|
||||
* with `rawSrc` and that images don't start to load until a lazy loading strategy is set.
|
||||
* @internal
|
||||
*/
|
||||
@Input() src?: string;
|
||||
|
||||
/**
|
||||
* Value of the `srcset` attribute if set on the host `<img>` element.
|
||||
* This input is exclusively read to assert that `srcset` is not set in conflict
|
||||
* with `rawSrcset` and that images don't start to load until a lazy loading strategy is set.
|
||||
* @internal
|
||||
*/
|
||||
@Input() srcset?: string;
|
||||
|
||||
ngOnInit() {
|
||||
@@ -250,7 +259,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
|
||||
assertNoConflictingSrc(this);
|
||||
assertNoConflictingSrcset(this);
|
||||
assertNotBase64Image(this);
|
||||
assertNotBlobURL(this);
|
||||
assertNotBlobUrl(this);
|
||||
assertNonEmptyWidthAndHeight(this);
|
||||
assertValidLoadingInput(this);
|
||||
assertNoImageDistortion(this, this.imgElement, this.renderer);
|
||||
@@ -304,12 +313,12 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
|
||||
// ImageLoaderConfig supports setting a width property. However, we're not setting width here
|
||||
// because if the developer uses rendered width instead of intrinsic width in the HTML width
|
||||
// attribute, the image requested may be too small for 2x+ screens.
|
||||
if (!this._rewrittenSrc) {
|
||||
if (!this._renderedSrc) {
|
||||
const imgConfig = {src: this.rawSrc};
|
||||
// Cache calculated image src to reuse it later in the code.
|
||||
this._rewrittenSrc = this.imageLoader(imgConfig);
|
||||
this._renderedSrc = this.imageLoader(imgConfig);
|
||||
}
|
||||
return this._rewrittenSrc;
|
||||
return this._renderedSrc;
|
||||
}
|
||||
|
||||
private getRewrittenSrcset(): string {
|
||||
@@ -324,27 +333,31 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
ngOnDestroy() {
|
||||
if (ngDevMode) {
|
||||
if (!this.priority && this._rewrittenSrc !== null) {
|
||||
if (!this.priority && this._renderedSrc !== null) {
|
||||
withLCPImageObserver(
|
||||
this.injector,
|
||||
(observer: LCPImageObserver) => observer.unregisterImage(this._rewrittenSrc!));
|
||||
(observer: LCPImageObserver) => observer.unregisterImage(this._renderedSrc!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setHostAttribute(name: string, value: string): void {
|
||||
this.renderer.setAttribute(this.imgElement.nativeElement, name, value);
|
||||
this.renderer.setAttribute(this.imgElement, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/***** Helpers *****/
|
||||
|
||||
// Convert input value to integer.
|
||||
/**
|
||||
* Convert input value to integer.
|
||||
*/
|
||||
function inputToInteger(value: string|number|undefined): number|undefined {
|
||||
return typeof value === 'string' ? parseInt(value, 10) : value;
|
||||
}
|
||||
|
||||
// Convert input value to boolean.
|
||||
/**
|
||||
* Convert input value to boolean.
|
||||
*/
|
||||
function inputToBoolean(value: unknown): boolean {
|
||||
return value != null && `${value}` !== 'false';
|
||||
}
|
||||
@@ -376,31 +389,37 @@ function withLCPImageObserver(
|
||||
|
||||
/***** Assert functions *****/
|
||||
|
||||
// Verifies that there is no `src` set on a host element.
|
||||
/**
|
||||
* Verifies that there is no `src` set on a host element.
|
||||
*/
|
||||
function assertNoConflictingSrc(dir: NgOptimizedImage) {
|
||||
if (dir.src) {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.UNEXPECTED_SRC_ATTR,
|
||||
`${imgDirectiveDetails(dir.rawSrc)} both \`src\` and \`rawSrc\` have been set. ` +
|
||||
`Supplying both of these attributes is not necessary and will break lazy loading. ` +
|
||||
`The NgOptimizedImage directive will set \`src\` itself based on the value of \`rawSrc\`. ` +
|
||||
`Supplying both of these attributes breaks lazy loading. ` +
|
||||
`The NgOptimizedImage directive sets \`src\` itself based on the value of \`rawSrc\`. ` +
|
||||
`To fix this, please remove the \`src\` attribute.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that there is no `srcset` set on a host element.
|
||||
/**
|
||||
* Verifies that there is no `srcset` set on a host element.
|
||||
*/
|
||||
function assertNoConflictingSrcset(dir: NgOptimizedImage) {
|
||||
if (dir.srcset) {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.UNEXPECTED_SRCSET_ATTR,
|
||||
`${imgDirectiveDetails(dir.rawSrc)} both \`srcset\` and \`rawSrcset\` have been set. ` +
|
||||
`Supplying both of these attributes is not necessary and will break lazy loading. ` +
|
||||
`The NgOptimizedImage directive will set \`srcset\` itself based on the value of ` +
|
||||
`Supplying both of these attributes breaks lazy loading. ` +
|
||||
`The NgOptimizedImage directive sets \`srcset\` itself based on the value of ` +
|
||||
`\`rawSrcset\`. To fix this, please remove the \`srcset\` attribute.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that the `rawSrc` is not a Base64-encoded image.
|
||||
/**
|
||||
* Verifies that the `rawSrc` is not a Base64-encoded image.
|
||||
*/
|
||||
function assertNotBase64Image(dir: NgOptimizedImage) {
|
||||
let rawSrc = dir.rawSrc.trim();
|
||||
if (rawSrc.startsWith('data:')) {
|
||||
@@ -416,8 +435,10 @@ function assertNotBase64Image(dir: NgOptimizedImage) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that the `rawSrc` is not a Blob URL.
|
||||
function assertNotBlobURL(dir: NgOptimizedImage) {
|
||||
/**
|
||||
* Verifies that the `rawSrc` is not a Blob URL.
|
||||
*/
|
||||
function assertNotBlobUrl(dir: NgOptimizedImage) {
|
||||
const rawSrc = dir.rawSrc.trim();
|
||||
if (rawSrc.startsWith('blob:')) {
|
||||
throw new RuntimeError(
|
||||
@@ -429,7 +450,9 @@ function assertNotBlobURL(dir: NgOptimizedImage) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that the input is set to a non-empty string.
|
||||
/**
|
||||
* Verifies that the input is set to a non-empty string.
|
||||
*/
|
||||
function assertNonEmptyInput(dir: NgOptimizedImage, name: string, value: unknown) {
|
||||
const isString = typeof value === 'string';
|
||||
const isEmptyString = isString && value.trim() === '';
|
||||
@@ -441,7 +464,9 @@ function assertNonEmptyInput(dir: NgOptimizedImage, name: string, value: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that the `rawSrcset` is in a valid format, e.g. "100w, 200w" or "1x, 2x"
|
||||
/**
|
||||
* Verifies that the `rawSrcset` is in a valid format, e.g. "100w, 200w" or "1x, 2x".
|
||||
*/
|
||||
export function assertValidRawSrcset(dir: NgOptimizedImage, value: unknown) {
|
||||
if (value == null) return;
|
||||
assertNonEmptyInput(dir, 'rawSrcset', value);
|
||||
@@ -481,8 +506,10 @@ function assertUnderDensityCap(dir: NgOptimizedImage, value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a `RuntimeError` instance to represent a situation when an input is set after
|
||||
// the directive has initialized.
|
||||
/**
|
||||
* Creates a `RuntimeError` instance to represent a situation when an input is set after
|
||||
* the directive has initialized.
|
||||
*/
|
||||
function postInitInputChangeError(dir: NgOptimizedImage, inputName: string): {} {
|
||||
return new RuntimeError(
|
||||
RuntimeErrorCode.UNEXPECTED_INPUT_CHANGE,
|
||||
@@ -492,7 +519,9 @@ function postInitInputChangeError(dir: NgOptimizedImage, inputName: string): {}
|
||||
`in an *ngIf that is gated on the necessary value.`);
|
||||
}
|
||||
|
||||
// Verify that none of the listed inputs has changed.
|
||||
/**
|
||||
* Verify that none of the listed inputs has changed.
|
||||
*/
|
||||
function assertNoPostInitInputChange(
|
||||
dir: NgOptimizedImage, changes: SimpleChanges, inputs: string[]) {
|
||||
inputs.forEach(input => {
|
||||
@@ -510,9 +539,10 @@ function assertNoPostInitInputChange(
|
||||
});
|
||||
}
|
||||
|
||||
// Verifies that a specified input is a number greater than 0.
|
||||
function assertGreaterThanZeroNumber(
|
||||
dir: NgOptimizedImage, inputValue: unknown, inputName: string) {
|
||||
/**
|
||||
* Verifies that a specified input is a number greater than 0.
|
||||
*/
|
||||
function assertGreaterThanZero(dir: NgOptimizedImage, inputValue: unknown, inputName: string) {
|
||||
const validNumber = typeof inputValue === 'number' && inputValue > 0;
|
||||
const validString =
|
||||
typeof inputValue === 'string' && /^\d+$/.test(inputValue.trim()) && parseInt(inputValue) > 0;
|
||||
@@ -525,21 +555,25 @@ function assertGreaterThanZeroNumber(
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that the rendered image is not visually distorted. Effectively this is checking:
|
||||
// - Whether the "width" and "height" attributes reflect the actual dimensions of the image.
|
||||
// - Whether image styling is "correct" (see below for a longer explanation).
|
||||
/**
|
||||
* Verifies that the rendered image is not visually distorted. Effectively this is checking:
|
||||
* - Whether the "width" and "height" attributes reflect the actual dimensions of the image.
|
||||
* - Whether image styling is "correct" (see below for a longer explanation).
|
||||
*/
|
||||
function assertNoImageDistortion(
|
||||
dir: NgOptimizedImage, element: ElementRef<any>, renderer: Renderer2) {
|
||||
const img = element.nativeElement;
|
||||
dir: NgOptimizedImage, img: HTMLImageElement, renderer: Renderer2) {
|
||||
const removeListenerFn = renderer.listen(img, 'load', () => {
|
||||
removeListenerFn();
|
||||
const renderedWidth = parseFloat(img.clientWidth);
|
||||
const renderedHeight = parseFloat(img.clientHeight);
|
||||
// TODO: `clientWidth`, `clientHeight`, `naturalWidth` and `naturalHeight`
|
||||
// are typed as number, but we run `parseFloat` (which accepts strings only).
|
||||
// Verify whether `parseFloat` is needed in the cases below.
|
||||
const renderedWidth = parseFloat(img.clientWidth as any);
|
||||
const renderedHeight = parseFloat(img.clientHeight as any);
|
||||
const renderedAspectRatio = renderedWidth / renderedHeight;
|
||||
const nonZeroRenderedDimensions = renderedWidth !== 0 && renderedHeight !== 0;
|
||||
|
||||
const intrinsicWidth = parseFloat(img.naturalWidth);
|
||||
const intrinsicHeight = parseFloat(img.naturalHeight);
|
||||
const intrinsicWidth = parseFloat(img.naturalWidth as any);
|
||||
const intrinsicHeight = parseFloat(img.naturalHeight as any);
|
||||
const intrinsicAspectRatio = intrinsicWidth / intrinsicHeight;
|
||||
|
||||
const suppliedWidth = dir.width!;
|
||||
@@ -583,7 +617,9 @@ function assertNoImageDistortion(
|
||||
});
|
||||
}
|
||||
|
||||
// Verifies that a specified input is set.
|
||||
/**
|
||||
* Verifies that a specified input is set.
|
||||
*/
|
||||
function assertNonEmptyWidthAndHeight(dir: NgOptimizedImage) {
|
||||
let missingAttributes = [];
|
||||
if (dir.width === undefined) missingAttributes.push('width');
|
||||
@@ -598,8 +634,10 @@ function assertNonEmptyWidthAndHeight(dir: NgOptimizedImage) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that the `loading` attribute is set to a valid input &
|
||||
// is not used on priority images.
|
||||
/**
|
||||
* Verifies that the `loading` attribute is set to a valid input &
|
||||
* is not used on priority images.
|
||||
*/
|
||||
function assertValidLoadingInput(dir: NgOptimizedImage) {
|
||||
if (dir.loading && dir.priority) {
|
||||
throw new RuntimeError(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {Inject, Injectable, InjectionToken, Optional, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
|
||||
import {inject, Injectable, InjectionToken, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
|
||||
|
||||
import {DOCUMENT} from '../../dom_tokens';
|
||||
import {RuntimeErrorCode} from '../../errors';
|
||||
@@ -49,25 +49,28 @@ export const PRECONNECT_CHECK_BLOCKLIST =
|
||||
*/
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class PreconnectLinkChecker {
|
||||
// Set of <link rel="preconnect"> tags found on this page.
|
||||
// The `null` value indicates that there was no DOM query operation performed.
|
||||
/**
|
||||
* Set of <link rel="preconnect"> tags found on this page.
|
||||
* The `null` value indicates that there was no DOM query operation performed.
|
||||
*/
|
||||
private preconnectLinks: Set<string>|null = null;
|
||||
|
||||
// Keep track of all already seen origin URLs to avoid repeating the same check.
|
||||
/*
|
||||
* Keep track of all already seen origin URLs to avoid repeating the same check.
|
||||
*/
|
||||
private alreadySeen = new Set<string>();
|
||||
|
||||
private window: Window|null = null;
|
||||
|
||||
private blocklist = new Set<string>(INTERNAL_PRECONNECT_CHECK_BLOCKLIST);
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private doc: Document,
|
||||
@Optional() @Inject(PRECONNECT_CHECK_BLOCKLIST) blocklist: Array<string|string[]>|null) {
|
||||
constructor() {
|
||||
assertDevMode('preconnect link checker');
|
||||
const win = doc.defaultView;
|
||||
const win = inject(DOCUMENT).defaultView;
|
||||
if (typeof win !== 'undefined') {
|
||||
this.window = win;
|
||||
}
|
||||
const blocklist = inject(PRECONNECT_CHECK_BLOCKLIST, {optional: true});
|
||||
if (blocklist) {
|
||||
this.populateBlocklist(blocklist);
|
||||
}
|
||||
@@ -124,8 +127,7 @@ export class PreconnectLinkChecker {
|
||||
private queryPreconnectLinks(): Set<string> {
|
||||
const preconnectUrls = new Set<string>();
|
||||
const selector = 'link[rel=preconnect]';
|
||||
const links =
|
||||
Array.from(document.querySelectorAll('link[rel=preconnect]')) as HTMLLinkElement[];
|
||||
const links: HTMLLinkElement[] = Array.from(document.querySelectorAll(selector));
|
||||
for (let link of links) {
|
||||
const url = getUrl(link.href, this.window!);
|
||||
preconnectUrls.add(url.origin);
|
||||
@@ -139,8 +141,10 @@ export class PreconnectLinkChecker {
|
||||
}
|
||||
}
|
||||
|
||||
// Invokes a callback for each element in the array. Also invokes a callback
|
||||
// recursively for each nested array.
|
||||
/**
|
||||
* Invokes a callback for each element in the array. Also invokes a callback
|
||||
* recursively for each nested array.
|
||||
*/
|
||||
function deepForEach<T>(input: (T|any[])[], fn: (value: T) => void): void {
|
||||
for (let value of input) {
|
||||
Array.isArray(value) ? deepForEach(value, fn) : fn(value);
|
||||
|
||||
Reference in New Issue
Block a user