UNPKG

@angular/common

Version:

Angular - commonly needed directives and services

975 lines 146 kB
/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ import { booleanAttribute, Directive, ElementRef, inject, Injector, Input, NgZone, numberAttribute, PLATFORM_ID, Renderer2, ɵformatRuntimeError as formatRuntimeError, ɵIMAGE_CONFIG as IMAGE_CONFIG, ɵIMAGE_CONFIG_DEFAULTS as IMAGE_CONFIG_DEFAULTS, ɵperformanceMarkFeature as performanceMarkFeature, ɵRuntimeError as RuntimeError, ɵunwrapSafeValue as unwrapSafeValue, ChangeDetectorRef, } from '@angular/core'; import { isPlatformServer } from '../../platform_id'; import { imgDirectiveDetails } from './error_helper'; import { cloudinaryLoaderInfo } from './image_loaders/cloudinary_loader'; import { IMAGE_LOADER, noopImageLoader, } from './image_loaders/image_loader'; import { imageKitLoaderInfo } from './image_loaders/imagekit_loader'; import { imgixLoaderInfo } from './image_loaders/imgix_loader'; import { netlifyLoaderInfo } from './image_loaders/netlify_loader'; import { LCPImageObserver } from './lcp_image_observer'; import { PreconnectLinkChecker } from './preconnect_link_checker'; import { PreloadLinkCreator } from './preload-link-creator'; import * as i0 from "@angular/core"; /** * When a Base64-encoded image is passed as an input to the `NgOptimizedImage` directive, * an error is thrown. The image content (as a string) might be very long, thus making * it hard to read an error message if the entire string is included. This const defines * the number of characters that should be included into the error message. The rest * of the content is truncated. */ const BASE64_IMG_MAX_LENGTH_IN_ERROR = 50; /** * RegExpr to determine whether a src in a srcset is using width descriptors. * Should match something like: "100w, 200w". */ const VALID_WIDTH_DESCRIPTOR_SRCSET = /^((\s*\d+w\s*(,|$)){1,})$/; /** * RegExpr to determine whether a src in a srcset is using density descriptors. * Should match something like: "1x, 2x, 50x". Also supports decimals like "1.5x, 1.50x". */ const VALID_DENSITY_DESCRIPTOR_SRCSET = /^((\s*\d+(\.\d+)?x\s*(,|$)){1,})$/; /** * Srcset values with a density descriptor higher than this value will actively * throw an error. Such densities are not permitted as they cause image sizes * to be unreasonably large and slow down LCP. */ export const ABSOLUTE_SRCSET_DENSITY_CAP = 3; /** * Used only in error message text to communicate best practices, as we will * only throw based on the slightly more conservative ABSOLUTE_SRCSET_DENSITY_CAP. */ 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. */ const ASPECT_RATIO_TOLERANCE = 0.1; /** * Used to determine whether the image has been requested at an overly * large size compared to the actual rendered image size (after taking * into account a typical device pixel ratio). In pixels. */ const OVERSIZED_IMAGE_TOLERANCE = 1000; /** * Used to limit automatic srcset generation of very large sources for * fixed-size images. In pixels. */ const FIXED_SRCSET_WIDTH_LIMIT = 1920; const FIXED_SRCSET_HEIGHT_LIMIT = 1080; /** * Default blur radius of the CSS filter used on placeholder images, in pixels */ export const PLACEHOLDER_BLUR_AMOUNT = 15; /** * Used to warn or error when the user provides an overly large dataURL for the placeholder * attribute. * Character count of Base64 images is 1 character per byte, and base64 encoding is approximately * 33% larger than base images, so 4000 characters is around 3KB on disk and 10000 characters is * around 7.7KB. Experimentally, 4000 characters is about 20x20px in PNG or medium-quality JPEG * format, and 10,000 is around 50x50px, but there's quite a bit of variation depending on how the * image is saved. */ export const DATA_URL_WARN_LIMIT = 4000; export const DATA_URL_ERROR_LIMIT = 10000; /** Info about built-in loaders we can test for. */ export const BUILT_IN_LOADERS = [ imgixLoaderInfo, imageKitLoaderInfo, cloudinaryLoaderInfo, netlifyLoaderInfo, ]; /** * Directive that improves image loading performance by enforcing best practices. * * `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 * - Automatically generating a preconnect link tag in the document head * * 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 * * @usageNotes * The `NgOptimizedImage` directive is marked as [standalone](guide/standalone-components) and can * be imported directly. * * Follow the steps below to enable and use the directive: * 1. Import it into the necessary NgModule or a standalone Component. * 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 `ngSrc`. * Using a `ngSrc` allows the directive to control when the `src` gets set, which triggers an image * download. * * Step 1: import the `NgOptimizedImage` directive. * * ```typescript * import { NgOptimizedImage } from '@angular/common'; * * // Include it into the necessary NgModule * @NgModule({ * imports: [NgOptimizedImage], * }) * class AppModule {} * * // ... or a standalone Component * @Component({ * standalone: true * imports: [NgOptimizedImage], * }) * class MyStandaloneComponent {} * ``` * * Step 2: configure a loader. * * To use the **default loader**: no additional code changes are necessary. The URL returned by the * generic loader will always match the value of "src". In other words, this loader applies no * transformations to the resource URL and the value of the `ngSrc` attribute will be used as is. * * To use an existing loader for a **third-party image service**: add the provider factory for your * chosen service to the `providers` array. In the example below, the Imgix loader is used: * * ```typescript * import {provideImgixLoader} from '@angular/common'; * * // Call the function and add the result to the `providers` array: * providers: [ * provideImgixLoader("https://my.base.url/"), * ], * ``` * * The `NgOptimizedImage` directive provides the following functions: * - `provideCloudflareLoader` * - `provideCloudinaryLoader` * - `provideImageKitLoader` * - `provideImgixLoader` * * If you use a different image provider, you can create a custom loader function as described * below. * * To use a **custom loader**: provide your loader function as a value for the `IMAGE_LOADER` DI * token. * * ```typescript * import {IMAGE_LOADER, ImageLoaderConfig} from '@angular/common'; * * // Configure the loader using the `IMAGE_LOADER` token. * providers: [ * { * provide: IMAGE_LOADER, * useValue: (config: ImageLoaderConfig) => { * return `https://example.com/${config.src}-${config.width}.jpg}`; * } * }, * ], * ``` * * Step 3: update `<img>` tags in templates to use `ngSrc` instead of `src`. * * ``` * <img ngSrc="logo.png" width="200" height="100"> * ``` * * @publicApi */ export class NgOptimizedImage { constructor() { this.imageLoader = inject(IMAGE_LOADER); this.config = processConfig(inject(IMAGE_CONFIG)); this.renderer = inject(Renderer2); this.imgElement = inject(ElementRef).nativeElement; this.injector = inject(Injector); this.isServer = isPlatformServer(inject(PLATFORM_ID)); this.preloadLinkCreator = inject(PreloadLinkCreator); // a LCP image observer - should be injected only in the dev mode this.lcpObserver = ngDevMode ? this.injector.get(LCPImageObserver) : 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). */ this._renderedSrc = null; /** * Indicates whether this image should have a high priority. */ this.priority = false; /** * Disables automatic srcset generation for this image. */ this.disableOptimizedSrcset = false; /** * Sets the image to "fill mode", which eliminates the height/width requirement and adds * styles such that the image fills its containing element. */ this.fill = false; } /** @nodoc */ ngOnInit() { performanceMarkFeature('NgOptimizedImage'); if (ngDevMode) { const ngZone = this.injector.get(NgZone); assertNonEmptyInput(this, 'ngSrc', this.ngSrc); assertValidNgSrcset(this, this.ngSrcset); assertNoConflictingSrc(this); if (this.ngSrcset) { assertNoConflictingSrcset(this); } assertNotBase64Image(this); assertNotBlobUrl(this); if (this.fill) { assertEmptyWidthAndHeight(this); // This leaves the Angular zone to avoid triggering unnecessary change detection cycles when // `load` tasks are invoked on images. ngZone.runOutsideAngular(() => assertNonZeroRenderedHeight(this, this.imgElement, this.renderer)); } else { assertNonEmptyWidthAndHeight(this); if (this.height !== undefined) { assertGreaterThanZero(this, this.height, 'height'); } if (this.width !== undefined) { assertGreaterThanZero(this, this.width, 'width'); } // Only check for distorted images when not in fill mode, where // images may be intentionally stretched, cropped or letterboxed. ngZone.runOutsideAngular(() => assertNoImageDistortion(this, this.imgElement, this.renderer)); } assertValidLoadingInput(this); if (!this.ngSrcset) { assertNoComplexSizes(this); } assertValidPlaceholder(this, this.imageLoader); assertNotMissingBuiltInLoader(this.ngSrc, this.imageLoader); assertNoNgSrcsetWithoutLoader(this, this.imageLoader); assertNoLoaderParamsWithoutLoader(this, this.imageLoader); if (this.lcpObserver !== null) { const ngZone = this.injector.get(NgZone); ngZone.runOutsideAngular(() => { this.lcpObserver.registerImage(this.getRewrittenSrc(), this.ngSrc, this.priority); }); } if (this.priority) { const checker = this.injector.get(PreconnectLinkChecker); checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc); } } if (this.placeholder) { this.removePlaceholderOnLoad(this.imgElement); } this.setHostAttributes(); } setHostAttributes() { // Must set width/height explicitly in case they are bound (in which case they will // only be reflected and not found by the browser) if (this.fill) { this.sizes ||= '100vw'; } else { this.setHostAttribute('width', this.width.toString()); this.setHostAttribute('height', this.height.toString()); } this.setHostAttribute('loading', this.getLoadingBehavior()); this.setHostAttribute('fetchpriority', this.getFetchPriority()); // The `data-ng-img` attribute flags an image as using the directive, to allow // for analysis of the directive's performance. this.setHostAttribute('ng-img', 'true'); // The `src` and `srcset` attributes should be set last since other attributes // could affect the image's loading behavior. const rewrittenSrcset = this.updateSrcAndSrcset(); if (this.sizes) { this.setHostAttribute('sizes', this.sizes); } if (this.isServer && this.priority) { this.preloadLinkCreator.createPreloadLinkTag(this.renderer, this.getRewrittenSrc(), rewrittenSrcset, this.sizes); } } /** @nodoc */ ngOnChanges(changes) { if (ngDevMode) { assertNoPostInitInputChange(this, changes, [ 'ngSrcset', 'width', 'height', 'priority', 'fill', 'loading', 'sizes', 'loaderParams', 'disableOptimizedSrcset', ]); } if (changes['ngSrc'] && !changes['ngSrc'].isFirstChange()) { const oldSrc = this._renderedSrc; this.updateSrcAndSrcset(true); const newSrc = this._renderedSrc; if (this.lcpObserver !== null && oldSrc && newSrc && oldSrc !== newSrc) { const ngZone = this.injector.get(NgZone); ngZone.runOutsideAngular(() => { this.lcpObserver?.updateImage(oldSrc, newSrc); }); } } } callImageLoader(configWithoutCustomParams) { let augmentedConfig = configWithoutCustomParams; if (this.loaderParams) { augmentedConfig.loaderParams = this.loaderParams; } return this.imageLoader(augmentedConfig); } getLoadingBehavior() { if (!this.priority && this.loading !== undefined) { return this.loading; } return this.priority ? 'eager' : 'lazy'; } getFetchPriority() { return this.priority ? 'high' : 'auto'; } getRewrittenSrc() { // 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._renderedSrc) { const imgConfig = { src: this.ngSrc }; // Cache calculated image src to reuse it later in the code. this._renderedSrc = this.callImageLoader(imgConfig); } return this._renderedSrc; } getRewrittenSrcset() { const widthSrcSet = VALID_WIDTH_DESCRIPTOR_SRCSET.test(this.ngSrcset); const finalSrcs = this.ngSrcset .split(',') .filter((src) => src !== '') .map((srcStr) => { srcStr = srcStr.trim(); const width = widthSrcSet ? parseFloat(srcStr) : parseFloat(srcStr) * this.width; return `${this.callImageLoader({ src: this.ngSrc, width })} ${srcStr}`; }); return finalSrcs.join(', '); } getAutomaticSrcset() { if (this.sizes) { return this.getResponsiveSrcset(); } else { return this.getFixedSrcset(); } } getResponsiveSrcset() { 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.callImageLoader({ src: this.ngSrc, width: bp })} ${bp}w`); return finalSrcs.join(', '); } updateSrcAndSrcset(forceSrcRecalc = false) { if (forceSrcRecalc) { // Reset cached value, so that the followup `getRewrittenSrc()` call // will recalculate it and update the cache. this._renderedSrc = null; } const rewrittenSrc = this.getRewrittenSrc(); this.setHostAttribute('src', rewrittenSrc); let rewrittenSrcset = undefined; if (this.ngSrcset) { rewrittenSrcset = this.getRewrittenSrcset(); } else if (this.shouldGenerateAutomaticSrcset()) { rewrittenSrcset = this.getAutomaticSrcset(); } if (rewrittenSrcset) { this.setHostAttribute('srcset', rewrittenSrcset); } return rewrittenSrcset; } getFixedSrcset() { const finalSrcs = DENSITY_SRCSET_MULTIPLIERS.map((multiplier) => `${this.callImageLoader({ src: this.ngSrc, width: this.width * multiplier, })} ${multiplier}x`); return finalSrcs.join(', '); } shouldGenerateAutomaticSrcset() { let oversizedImage = false; if (!this.sizes) { oversizedImage = this.width > FIXED_SRCSET_WIDTH_LIMIT || this.height > FIXED_SRCSET_HEIGHT_LIMIT; } return (!this.disableOptimizedSrcset && !this.srcset && this.imageLoader !== noopImageLoader && !oversizedImage); } /** * Returns an image url formatted for use with the CSS background-image property. Expects one of: * * A base64 encoded image, which is wrapped and passed through. * * A boolean. If true, calls the image loader to generate a small placeholder url. */ generatePlaceholder(placeholderInput) { const { placeholderResolution } = this.config; if (placeholderInput === true) { return `url(${this.callImageLoader({ src: this.ngSrc, width: placeholderResolution, isPlaceholder: true, })})`; } else if (typeof placeholderInput === 'string' && placeholderInput.startsWith('data:')) { return `url(${placeholderInput})`; } return null; } /** * Determines if blur should be applied, based on an optional boolean * property `blur` within the optional configuration object `placeholderConfig`. */ shouldBlurPlaceholder(placeholderConfig) { if (!placeholderConfig || !placeholderConfig.hasOwnProperty('blur')) { return true; } return Boolean(placeholderConfig.blur); } removePlaceholderOnLoad(img) { const callback = () => { const changeDetectorRef = this.injector.get(ChangeDetectorRef); removeLoadListenerFn(); removeErrorListenerFn(); this.placeholder = false; changeDetectorRef.markForCheck(); }; const removeLoadListenerFn = this.renderer.listen(img, 'load', callback); const removeErrorListenerFn = this.renderer.listen(img, 'error', callback); } /** @nodoc */ ngOnDestroy() { if (ngDevMode) { if (!this.priority && this._renderedSrc !== null && this.lcpObserver !== null) { this.lcpObserver.unregisterImage(this._renderedSrc); } } } setHostAttribute(name, value) { this.renderer.setAttribute(this.imgElement, name, value); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: NgOptimizedImage, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "17.3.9", type: NgOptimizedImage, isStandalone: true, selector: "img[ngSrc]", inputs: { ngSrc: ["ngSrc", "ngSrc", unwrapSafeUrl], ngSrcset: "ngSrcset", sizes: "sizes", width: ["width", "width", numberAttribute], height: ["height", "height", numberAttribute], loading: "loading", priority: ["priority", "priority", booleanAttribute], loaderParams: "loaderParams", disableOptimizedSrcset: ["disableOptimizedSrcset", "disableOptimizedSrcset", booleanAttribute], fill: ["fill", "fill", booleanAttribute], placeholder: ["placeholder", "placeholder", booleanOrDataUrlAttribute], placeholderConfig: "placeholderConfig", src: "src", srcset: "srcset" }, host: { properties: { "style.position": "fill ? \"absolute\" : null", "style.width": "fill ? \"100%\" : null", "style.height": "fill ? \"100%\" : null", "style.inset": "fill ? \"0\" : null", "style.background-size": "placeholder ? \"cover\" : null", "style.background-position": "placeholder ? \"50% 50%\" : null", "style.background-repeat": "placeholder ? \"no-repeat\" : null", "style.background-image": "placeholder ? generatePlaceholder(placeholder) : null", "style.filter": "placeholder && shouldBlurPlaceholder(placeholderConfig) ? \"blur(15px)\" : null" } }, usesOnChanges: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImport: i0, type: NgOptimizedImage, decorators: [{ type: Directive, args: [{ standalone: true, selector: 'img[ngSrc]', host: { '[style.position]': 'fill ? "absolute" : null', '[style.width]': 'fill ? "100%" : null', '[style.height]': 'fill ? "100%" : null', '[style.inset]': 'fill ? "0" : null', '[style.background-size]': 'placeholder ? "cover" : null', '[style.background-position]': 'placeholder ? "50% 50%" : null', '[style.background-repeat]': 'placeholder ? "no-repeat" : null', '[style.background-image]': 'placeholder ? generatePlaceholder(placeholder) : null', '[style.filter]': `placeholder && shouldBlurPlaceholder(placeholderConfig) ? "blur(${PLACEHOLDER_BLUR_AMOUNT}px)" : null`, }, }] }], propDecorators: { ngSrc: [{ type: Input, args: [{ required: true, transform: unwrapSafeUrl }] }], ngSrcset: [{ type: Input }], sizes: [{ type: Input }], width: [{ type: Input, args: [{ transform: numberAttribute }] }], height: [{ type: Input, args: [{ transform: numberAttribute }] }], loading: [{ type: Input }], priority: [{ type: Input, args: [{ transform: booleanAttribute }] }], loaderParams: [{ type: Input }], disableOptimizedSrcset: [{ type: Input, args: [{ transform: booleanAttribute }] }], fill: [{ type: Input, args: [{ transform: booleanAttribute }] }], placeholder: [{ type: Input, args: [{ transform: booleanOrDataUrlAttribute }] }], placeholderConfig: [{ type: Input }], src: [{ type: Input }], srcset: [{ type: Input }] } }); /***** Helpers *****/ /** * Sorts provided config breakpoints and uses defaults. */ function processConfig(config) { let sortedBreakpoints = {}; if (config.breakpoints) { sortedBreakpoints.breakpoints = config.breakpoints.sort((a, b) => a - b); } return Object.assign({}, IMAGE_CONFIG_DEFAULTS, config, sortedBreakpoints); } /***** Assert functions *****/ /** * Verifies that there is no `src` set on a host element. */ function assertNoConflictingSrc(dir) { if (dir.src) { throw new RuntimeError(2950 /* RuntimeErrorCode.UNEXPECTED_SRC_ATTR */, `${imgDirectiveDetails(dir.ngSrc)} both \`src\` and \`ngSrc\` have been set. ` + `Supplying both of these attributes breaks lazy loading. ` + `The NgOptimizedImage directive sets \`src\` itself based on the value of \`ngSrc\`. ` + `To fix this, please remove the \`src\` attribute.`); } } /** * Verifies that there is no `srcset` set on a host element. */ function assertNoConflictingSrcset(dir) { if (dir.srcset) { throw new RuntimeError(2951 /* RuntimeErrorCode.UNEXPECTED_SRCSET_ATTR */, `${imgDirectiveDetails(dir.ngSrc)} both \`srcset\` and \`ngSrcset\` have been set. ` + `Supplying both of these attributes breaks lazy loading. ` + `The NgOptimizedImage directive sets \`srcset\` itself based on the value of ` + `\`ngSrcset\`. To fix this, please remove the \`srcset\` attribute.`); } } /** * Verifies that the `ngSrc` is not a Base64-encoded image. */ function assertNotBase64Image(dir) { let ngSrc = dir.ngSrc.trim(); if (ngSrc.startsWith('data:')) { if (ngSrc.length > BASE64_IMG_MAX_LENGTH_IN_ERROR) { ngSrc = ngSrc.substring(0, BASE64_IMG_MAX_LENGTH_IN_ERROR) + '...'; } throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc, false)} \`ngSrc\` is a Base64-encoded string ` + `(${ngSrc}). NgOptimizedImage does not support Base64-encoded strings. ` + `To fix this, disable the NgOptimizedImage directive for this element ` + `by removing \`ngSrc\` and using a standard \`src\` attribute instead.`); } } /** * Verifies that the 'sizes' only includes responsive values. */ function assertNoComplexSizes(dir) { let sizes = dir.sizes; if (sizes?.match(/((\)|,)\s|^)\d+px/)) { throw new RuntimeError(2952 /* 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.`); } } function assertValidPlaceholder(dir, imageLoader) { assertNoPlaceholderConfigWithoutPlaceholder(dir); assertNoRelativePlaceholderWithoutLoader(dir, imageLoader); assertNoOversizedDataUrl(dir); } /** * Verifies that placeholderConfig isn't being used without placeholder */ function assertNoPlaceholderConfigWithoutPlaceholder(dir) { if (dir.placeholderConfig && !dir.placeholder) { throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc, false)} \`placeholderConfig\` options were provided for an ` + `image that does not use the \`placeholder\` attribute, and will have no effect.`); } } /** * Warns if a relative URL placeholder is specified, but no loader is present to provide the small * image. */ function assertNoRelativePlaceholderWithoutLoader(dir, imageLoader) { if (dir.placeholder === true && imageLoader === noopImageLoader) { throw new RuntimeError(2963 /* RuntimeErrorCode.MISSING_NECESSARY_LOADER */, `${imgDirectiveDetails(dir.ngSrc)} the \`placeholder\` attribute is set to true but ` + `no image loader is configured (i.e. the default one is being used), ` + `which would result in the same image being used for the primary image and its placeholder. ` + `To fix this, provide a loader or remove the \`placeholder\` attribute from the image.`); } } /** * Warns or throws an error if an oversized dataURL placeholder is provided. */ function assertNoOversizedDataUrl(dir) { if (dir.placeholder && typeof dir.placeholder === 'string' && dir.placeholder.startsWith('data:')) { if (dir.placeholder.length > DATA_URL_ERROR_LIMIT) { throw new RuntimeError(2965 /* RuntimeErrorCode.OVERSIZED_PLACEHOLDER */, `${imgDirectiveDetails(dir.ngSrc)} the \`placeholder\` attribute is set to a data URL which is longer ` + `than ${DATA_URL_ERROR_LIMIT} characters. This is strongly discouraged, as large inline placeholders ` + `directly increase the bundle size of Angular and hurt page load performance. To fix this, generate ` + `a smaller data URL placeholder.`); } if (dir.placeholder.length > DATA_URL_WARN_LIMIT) { console.warn(formatRuntimeError(2965 /* RuntimeErrorCode.OVERSIZED_PLACEHOLDER */, `${imgDirectiveDetails(dir.ngSrc)} the \`placeholder\` attribute is set to a data URL which is longer ` + `than ${DATA_URL_WARN_LIMIT} characters. This is discouraged, as large inline placeholders ` + `directly increase the bundle size of Angular and hurt page load performance. For better loading performance, ` + `generate a smaller data URL placeholder.`)); } } } /** * Verifies that the `ngSrc` is not a Blob URL. */ function assertNotBlobUrl(dir) { const ngSrc = dir.ngSrc.trim(); if (ngSrc.startsWith('blob:')) { throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} \`ngSrc\` was set to a blob URL (${ngSrc}). ` + `Blob URLs are not supported by the NgOptimizedImage directive. ` + `To fix this, disable the NgOptimizedImage directive for this element ` + `by removing \`ngSrc\` and using a regular \`src\` attribute instead.`); } } /** * Verifies that the input is set to a non-empty string. */ function assertNonEmptyInput(dir, name, value) { const isString = typeof value === 'string'; const isEmptyString = isString && value.trim() === ''; if (!isString || isEmptyString) { throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} \`${name}\` has an invalid value ` + `(\`${value}\`). To fix this, change the value to a non-empty string.`); } } /** * Verifies that the `ngSrcset` is in a valid format, e.g. "100w, 200w" or "1x, 2x". */ export function assertValidNgSrcset(dir, value) { if (value == null) return; assertNonEmptyInput(dir, 'ngSrcset', value); const stringVal = value; const isValidWidthDescriptor = VALID_WIDTH_DESCRIPTOR_SRCSET.test(stringVal); const isValidDensityDescriptor = VALID_DENSITY_DESCRIPTOR_SRCSET.test(stringVal); if (isValidDensityDescriptor) { assertUnderDensityCap(dir, stringVal); } const isValidSrcset = isValidWidthDescriptor || isValidDensityDescriptor; if (!isValidSrcset) { throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} \`ngSrcset\` has an invalid value (\`${value}\`). ` + `To fix this, supply \`ngSrcset\` using a comma-separated list of one or more width ` + `descriptors (e.g. "100w, 200w") or density descriptors (e.g. "1x, 2x").`); } } function assertUnderDensityCap(dir, value) { const underDensityCap = value .split(',') .every((num) => num === '' || parseFloat(num) <= ABSOLUTE_SRCSET_DENSITY_CAP); if (!underDensityCap) { throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} the \`ngSrcset\` contains an unsupported image density:` + `\`${value}\`. NgOptimizedImage generally recommends a max image density of ` + `${RECOMMENDED_SRCSET_DENSITY_CAP}x but supports image densities up to ` + `${ABSOLUTE_SRCSET_DENSITY_CAP}x. The human eye cannot distinguish between image densities ` + `greater than ${RECOMMENDED_SRCSET_DENSITY_CAP}x - which makes them unnecessary for ` + `most use cases. Images that will be pinch-zoomed are typically the primary use case for ` + `${ABSOLUTE_SRCSET_DENSITY_CAP}x images. Please remove the high density descriptor and try again.`); } } /** * Creates a `RuntimeError` instance to represent a situation when an input is set after * the directive has initialized. */ function postInitInputChangeError(dir, inputName) { let reason; if (inputName === 'width' || inputName === 'height') { reason = `Changing \`${inputName}\` may result in different attribute value ` + `applied to the underlying image element and cause layout shifts on a page.`; } else { reason = `Changing the \`${inputName}\` would have no effect on the underlying ` + `image element, because the resource loading has already occurred.`; } return new RuntimeError(2953 /* RuntimeErrorCode.UNEXPECTED_INPUT_CHANGE */, `${imgDirectiveDetails(dir.ngSrc)} \`${inputName}\` was updated after initialization. ` + `The NgOptimizedImage directive will not react to this input change. ${reason} ` + `To fix this, either switch \`${inputName}\` to a static value ` + `or wrap the image element in an *ngIf that is gated on the necessary value.`); } /** * Verify that none of the listed inputs has changed. */ function assertNoPostInitInputChange(dir, changes, inputs) { inputs.forEach((input) => { const isUpdated = changes.hasOwnProperty(input); if (isUpdated && !changes[input].isFirstChange()) { if (input === 'ngSrc') { // When the `ngSrc` input changes, we detect that only in the // `ngOnChanges` hook, thus the `ngSrc` is already set. We use // `ngSrc` in the error message, so we use a previous value, but // not the updated one in it. dir = { ngSrc: changes[input].previousValue }; } throw postInitInputChangeError(dir, input); } }); } /** * Verifies that a specified input is a number greater than 0. */ function assertGreaterThanZero(dir, inputValue, inputName) { const validNumber = typeof inputValue === 'number' && inputValue > 0; const validString = typeof inputValue === 'string' && /^\d+$/.test(inputValue.trim()) && parseInt(inputValue) > 0; if (!validNumber && !validString) { throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} \`${inputName}\` has an invalid value. ` + `To fix this, provide \`${inputName}\` as a number greater than 0.`); } } /** * 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, img, renderer) { const removeLoadListenerFn = renderer.listen(img, 'load', () => { removeLoadListenerFn(); removeErrorListenerFn(); const computedStyle = window.getComputedStyle(img); let renderedWidth = parseFloat(computedStyle.getPropertyValue('width')); let renderedHeight = parseFloat(computedStyle.getPropertyValue('height')); const boxSizing = computedStyle.getPropertyValue('box-sizing'); if (boxSizing === 'border-box') { const paddingTop = computedStyle.getPropertyValue('padding-top'); const paddingRight = computedStyle.getPropertyValue('padding-right'); const paddingBottom = computedStyle.getPropertyValue('padding-bottom'); const paddingLeft = computedStyle.getPropertyValue('padding-left'); renderedWidth -= parseFloat(paddingRight) + parseFloat(paddingLeft); renderedHeight -= parseFloat(paddingTop) + parseFloat(paddingBottom); } const renderedAspectRatio = renderedWidth / renderedHeight; const nonZeroRenderedDimensions = renderedWidth !== 0 && renderedHeight !== 0; const intrinsicWidth = img.naturalWidth; const intrinsicHeight = img.naturalHeight; const intrinsicAspectRatio = intrinsicWidth / intrinsicHeight; const suppliedWidth = dir.width; const suppliedHeight = dir.height; const suppliedAspectRatio = suppliedWidth / suppliedHeight; // Tolerance is used to account for the impact of subpixel rendering. // Due to subpixel rendering, the rendered, intrinsic, and supplied // aspect ratios of a correctly configured image may not exactly match. // For example, a `width=4030 height=3020` image might have a rendered // size of "1062w, 796.48h". (An aspect ratio of 1.334... vs. 1.333...) const inaccurateDimensions = Math.abs(suppliedAspectRatio - intrinsicAspectRatio) > ASPECT_RATIO_TOLERANCE; const stylingDistortion = nonZeroRenderedDimensions && Math.abs(intrinsicAspectRatio - renderedAspectRatio) > ASPECT_RATIO_TOLERANCE; if (inaccurateDimensions) { console.warn(formatRuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} the aspect ratio of the image does not match ` + `the aspect ratio indicated by the width and height attributes. ` + `\nIntrinsic image size: ${intrinsicWidth}w x ${intrinsicHeight}h ` + `(aspect-ratio: ${round(intrinsicAspectRatio)}). \nSupplied width and height attributes: ` + `${suppliedWidth}w x ${suppliedHeight}h (aspect-ratio: ${round(suppliedAspectRatio)}). ` + `\nTo fix this, update the width and height attributes.`)); } else if (stylingDistortion) { console.warn(formatRuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} the aspect ratio of the rendered image ` + `does not match the image's intrinsic aspect ratio. ` + `\nIntrinsic image size: ${intrinsicWidth}w x ${intrinsicHeight}h ` + `(aspect-ratio: ${round(intrinsicAspectRatio)}). \nRendered image size: ` + `${renderedWidth}w x ${renderedHeight}h (aspect-ratio: ` + `${round(renderedAspectRatio)}). \nThis issue can occur if "width" and "height" ` + `attributes are added to an image without updating the corresponding ` + `image styling. To fix this, adjust image styling. In most cases, ` + `adding "height: auto" or "width: auto" to the image styling will fix ` + `this issue.`)); } else if (!dir.ngSrcset && nonZeroRenderedDimensions) { // If `ngSrcset` hasn't been set, sanity check the intrinsic size. const recommendedWidth = RECOMMENDED_SRCSET_DENSITY_CAP * renderedWidth; const recommendedHeight = RECOMMENDED_SRCSET_DENSITY_CAP * renderedHeight; const oversizedWidth = intrinsicWidth - recommendedWidth >= OVERSIZED_IMAGE_TOLERANCE; const oversizedHeight = intrinsicHeight - recommendedHeight >= OVERSIZED_IMAGE_TOLERANCE; if (oversizedWidth || oversizedHeight) { console.warn(formatRuntimeError(2960 /* RuntimeErrorCode.OVERSIZED_IMAGE */, `${imgDirectiveDetails(dir.ngSrc)} the intrinsic image is significantly ` + `larger than necessary. ` + `\nRendered image size: ${renderedWidth}w x ${renderedHeight}h. ` + `\nIntrinsic image size: ${intrinsicWidth}w x ${intrinsicHeight}h. ` + `\nRecommended intrinsic image size: ${recommendedWidth}w x ${recommendedHeight}h. ` + `\nNote: Recommended intrinsic image size is calculated assuming a maximum DPR of ` + `${RECOMMENDED_SRCSET_DENSITY_CAP}. To improve loading time, resize the image ` + `or consider using the "ngSrcset" and "sizes" attributes.`)); } } }); // We only listen to the `error` event to remove the `load` event listener because it will not be // fired if the image fails to load. This is done to prevent memory leaks in development mode // because image elements aren't garbage-collected properly. It happens because zone.js stores the // event listener directly on the element and closures capture `dir`. const removeErrorListenerFn = renderer.listen(img, 'error', () => { removeLoadListenerFn(); removeErrorListenerFn(); }); } /** * Verifies that a specified input is set. */ function assertNonEmptyWidthAndHeight(dir) { let missingAttributes = []; if (dir.width === undefined) missingAttributes.push('width'); if (dir.height === undefined) missingAttributes.push('height'); if (missingAttributes.length > 0) { throw new RuntimeError(2954 /* RuntimeErrorCode.REQUIRED_INPUT_MISSING */, `${imgDirectiveDetails(dir.ngSrc)} these required attributes ` + `are missing: ${missingAttributes.map((attr) => `"${attr}"`).join(', ')}. ` + `Including "width" and "height" attributes will prevent image-related layout shifts. ` + `To fix this, include "width" and "height" attributes on the image tag or turn on ` + `"fill" mode with the \`fill\` attribute.`); } } /** * Verifies that width and height are not set. Used in fill mode, where those attributes don't make * sense. */ function assertEmptyWidthAndHeight(dir) { if (dir.width || dir.height) { throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} the attributes \`height\` and/or \`width\` are present ` + `along with the \`fill\` attribute. Because \`fill\` mode causes an image to fill its containing ` + `element, the size attributes have no effect and should be removed.`); } } /** * Verifies that the rendered image has a nonzero height. If the image is in fill mode, provides * guidance that this can be caused by the containing element's CSS position property. */ function assertNonZeroRenderedHeight(dir, img, renderer) { const removeLoadListenerFn = renderer.listen(img, 'load', () => { removeLoadListenerFn(); removeErrorListenerFn(); const renderedHeight = img.clientHeight; if (dir.fill && renderedHeight === 0) { console.warn(formatRuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} the height of the fill-mode image is zero. ` + `This is likely because the containing element does not have the CSS 'position' ` + `property set to one of the following: "relative", "fixed", or "absolute". ` + `To fix this problem, make sure the container element has the CSS 'position' ` + `property defined and the height of the element is not zero.`)); } }); // See comments in the `assertNoImageDistortion`. const removeErrorListenerFn = renderer.listen(img, 'error', () => { removeLoadListenerFn(); removeErrorListenerFn(); }); } /** * Verifies that the `loading` attribute is set to a valid input & * is not used on priority images. */ function assertValidLoadingInput(dir) { if (dir.loading && dir.priority) { throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} the \`loading\` attribute ` + `was used on an image that was marked "priority". ` + `Setting \`loading\` on priority images is not allowed ` + `because these images will always be eagerly loaded. ` + `To fix this, remove the “loading” attribute from the priority image.`); } const validInputs = ['auto', 'eager', 'lazy']; if (typeof dir.loading === 'string' && !validInputs.includes(dir.loading)) { throw new RuntimeError(2952 /* RuntimeErrorCode.INVALID_INPUT */, `${imgDirectiveDetails(dir.ngSrc)} the \`loading\` attribute ` + `has an invalid value (\`${dir.loading}\`). ` + `To fix this, provide a valid value ("lazy", "eager", or "auto").`); } } /** * Warns if NOT using a loader (falling back to the generic loader) and * the image appears to be hosted on one of the image CDNs for which * we do have a built-in image loader. Suggests switching to the * built-in loader. * * @param ngSrc Value of the ngSrc attribute * @param imageLoader ImageLoader provided */ function assertNotMissingBuiltInLoader(ngSrc, imageLoader) { if (imageLoader === noopImageLoader) { let builtInLoaderName = ''; for (const loader of BUILT_IN_LOADERS) { if (loader.testUrl(ngSrc)) { builtInLoaderName = loader.name; break; } } if (builtInLoaderName) { console.warn(formatRuntimeError(2962 /* RuntimeErrorCode.MISSING_BUILTIN_LOADER */, `NgOptimizedImage: It looks like your images may be hosted on the ` + `${builtInLoaderName} CDN, but your app is not using Angular's ` + `built-in loader for that CDN. We recommend switching to use ` + `the built-in by calling \`provide${builtInLoaderName}Loader()\` ` + `in your \`providers\` and passing it your instance's base URL. ` + `If you don't want to use the built-in loader, define a custom ` + `loader function using IMAGE_LOADER to silence this warning.`)); } } } /** * Warns if ngSrcset is present and no loader is configured (i.e. the default one is being used). */ function assertNoNgSrcsetWithoutLoader(dir, imageLoader) { if (dir.ngSrcset && imageLoader === noopImageLoader) { console.warn(formatRuntimeError(2963 /* RuntimeErrorCode.MISSING_NECESSARY_LOADER */, `${imgDirectiveDetails(dir.ngSrc)} the \`ngSrcset\` attribute is present but ` + `no image loader is configured (i.e. the default one is being used), ` + `which would result in the same image being used for all configured sizes. ` + `To fix this, provide a loader or remove the \`ngSrcset\` attribute from the image.`)); } } /** * Warns if loaderParams is present and no loader is configured (i.e. the default one is being * used). */ function assertNoLoaderParamsWithoutLoader(dir, imageLoader) { if (dir.loaderParams && imageLoader === noopImageLoader) { console.warn(formatRuntimeError(2963 /* RuntimeErrorCode.MISSING_NECESSARY_LOADER */, `${imgDirectiveDetails(dir.ngSrc)} the \`loaderParams\` attribute is present but ` + `no image loader is configured (i.e. the default one is being used), ` + `which means that the loaderParams data will not be consumed and will not affect the URL. ` + `To fix this, provide a custom loader or remove the \`loaderParams\` attribute from the image.`)); } } function round(input) { return Number.isInteger(input) ? input : input.toFixed(2); } // Transform function to handle SafeValue input for ngSrc. This doesn't do any sanitization, // as that is not needed for img.src and img.srcset. This transform is purely for compatibility. function unwrapSafeUrl(value) { if (typeof value === 'string') { return value; } return unwrapSafeValue(value); } // Transform function to handle inputs which may be booleans, strings, or string representations // of boolean values. Used for the placeholder attribute. export function booleanOrDataUrlAttribute(value) { if (typeof value === 'string' && value.startsWith(`data:`)) { return value; } return booleanAttribute(value); } //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibmdfb3B0aW1pemVkX2ltYWdlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vcGFja2FnZXMvY29tbW9uL3NyYy9kaXJlY3RpdmVzL25nX29wdGltaXplZF9pbWFnZS9uZ19vcHRpbWl6ZWRfaW1hZ2UudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7Ozs7OztHQU1HO0FBRUgsT0FBTyxFQUNMLGdCQUFnQixFQUNoQixTQUFTLEVBQ1QsVUFBVSxFQUNWLE1BQU0sRUFDTixRQUFRLEVBQ1IsS0FBSyxFQUNMLE1BQU0sRUFDTixlQUFlLEVBSWYsV0FBVyxFQUNYLFNBQVMsRUFFVCxtQkFBbUIsSUFBSSxrQkFBa0IsRUFDekMsYUFBYSxJQUFJLFlBQVksRUFDN0Isc0JBQXNCLElBQUkscUJBQXFCLEVBRS9DLHVCQUF1QixJQUFJLHNCQUFzQixFQUNqRCxhQUFhLElBQUksWUFBWSxFQUU3QixnQkFBZ0IsSUFBSSxlQUFlLEVBQ25DLGlCQUFpQixHQUNsQixNQUFNLGVBQWUsQ0FBQztBQUd2QixPQUFPLEVBQUMsZ0JBQWdCLEVBQUMsTUFBTSxtQkFBbUIsQ0FBQztBQUVuRCxPQUFPLEVBQUMsbUJBQW1CLEVBQUMsTUFBTSxnQkFBZ0IsQ0FBQztBQUNuRCxPQUFPLEVBQUMsb0JBQW9CLEVBQUMsTUFBTSxtQ0FBbUMsQ0FBQztBQUN2RSxPQUFPLEVBQ0wsWUFBWSxFQUdaLGVBQWUsR0FDaEIsTUFBTSw4QkFBOEIsQ0FBQztBQUN0QyxPQUFPLEVBQUMsa0JBQWtCLEVBQUMsTUFBTSxpQ0FBaUMsQ0FBQztBQUNuRSxPQUFPLEVBQUMsZUFBZSxFQUFDLE1BQU0sOEJBQThCLENBQUM7QUFDN0QsT0FBTyxFQUFDLGlCQUFpQixFQUFDLE1BQU0sZ0NBQWdDLENBQUM7QUFDakUsT0FBTyxFQUFDLGdCQUFnQixFQUFDLE1BQU0sc0JBQXNCLENBQUM7QUFDdEQsT0FBTyxFQUFDLHFCQUFxQixFQUFDLE1BQU0sMkJBQTJCLENBQUM7QUFDaEUsT0FBTyxFQUFDLGtCQUFrQixFQUFDLE1BQU0sd0JBQXdCLENBQUM7O0FBRTFEOzs7Ozs7R0FNRztBQUNILE1BQU0sOEJBQThCLEdBQUcsRUFBRSxDQUFDO0FBRTFDOzs7R0FHRztBQUNILE1BQU0sNkJBQTZCLEdBQUcsMkJBQTJCLENBQUM7QUFFbEU7OztHQUdHO0FBQ0gsTUFBTSwrQkFBK0IsR0FBRyxtQ0FBbUMsQ0FBQztBQUU1RTs7OztHQUlHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sMkJBQTJCLEdBQUcsQ0FBQyxDQUFDO0FBRTdDOzs7R0FHRztBQUNILE1BQU0sQ0FBQyxNQUFNLDhCQUE4QixHQUFHLENBQUMsQ0FBQztBQUVoRDs7R0FFRztBQUNILE1BQU0sMEJBQTBCLEdBQUcsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUM7QUFFMUM7O0dBRUc7QUFDSCxNQUFNLDBCQUEwQixHQUFHLEdBQUcsQ0FBQztBQUN2Qzs7R0FFRztBQUNILE1BQU0sc0JBQXNCLEdBQUcsR0FBRyxDQUFDO0FBRW5DOzs7O0dBSUc7QUFDSCxNQUFNLHlCQUF5QixHQUFHLElBQUksQ0FBQztBQUV2Qzs7O0dBR0c7QUFDSCxNQUFNLHdCQUF3QixHQUFHLElBQUksQ0FBQztBQUN0QyxNQUFNLHlCQUF5QixHQUFHLElBQUksQ0FBQztBQ