diff --git a/aio/content/guide/image-directive.md b/aio/content/guide/image-directive.md index c33bbaaf969..60bc285db37 100644 --- a/aio/content/guide/image-directive.md +++ b/aio/content/guide/image-directive.md @@ -276,9 +276,41 @@ providers: [ ], -A loader function for the `NgOptimizedImage` directive takes an object with the `ImageLoaderConfig` type (from `@angular/common`) as its argument and returns the absolute URL of the image asset. The `ImageLoaderConfig` object contains the `src` and `width` properties. +A loader function for the `NgOptimizedImage` directive takes an object with the `ImageLoaderConfig` type (from `@angular/common`) as its argument and returns the absolute URL of the image asset. The `ImageLoaderConfig` object contains the `src` property, and optional `width` and `loaderParams` properties. -Note: a custom loader must support requesting images at various widths in order for `ngSrcset` to work properly. +Note: even though the `width` property may not always be present, a custom loader must use it to support requesting images at various widths in order for `ngSrcset` to work properly. + +### The `loaderParams` Property + +There is an additional attribute supported by the `NgOptimizedImage` directive, called `loaderParams`, which is specifically designed to support the use of custom loaders. The `loaderParams` attribute take an object with any properties as a value, and does not do anything on its own. The data in `loaderParams` is added to the `ImageLoaderConfig` object passed to your custom loader, and can be used to control the behavior of the loader. + +A common use for `loaderParams` is controlling advanced image CDN features. + +### Example custom loader + +The following shows an example of a custom loader function. This example function concatenates `src` and `width`, and uses `loaderParams` to control a custom CDN feature for rounded corners: + + +const myCustomLoader = (config: ImageLoaderConfig) => { + let url = `https://example.com/images/${config.src}?`; + let queryParams = []; + if (config.width) { + queryParams.push(`w=${config.width}`); + } + if (config.loaderParams?.roundedCorners) { + queryParams.push('mask=corners&corner-radius=5'); + } + return url + queryParams.join('&'); +}; + + +Note that in the above example, we've invented the 'roundedCorners' property name to control a feature of our custom loader. We could then use this feature when creating an image, as follows: + + + +<img ngSrc="profile.jpg" width="300" height="300" [loaderParams]="{roundedCorners: true}"> + + diff --git a/goldens/public-api/common/errors.md b/goldens/public-api/common/errors.md index f34aa7d1f34..3d1d367af08 100644 --- a/goldens/public-api/common/errors.md +++ b/goldens/public-api/common/errors.md @@ -17,9 +17,9 @@ export const enum RuntimeErrorCode { // (undocumented) MISSING_BUILTIN_LOADER = 2962, // (undocumented) - NG_FOR_MISSING_DIFFER = -2200, + MISSING_NECESSARY_LOADER = 2963, // (undocumented) - NGSRCSET_WITHOUT_LOADER = 2963, + NG_FOR_MISSING_DIFFER = -2200, // (undocumented) OVERSIZED_IMAGE = 2960, // (undocumented) diff --git a/goldens/public-api/common/index.md b/goldens/public-api/common/index.md index a88d0a8cd0e..ee32492d6aa 100644 --- a/goldens/public-api/common/index.md +++ b/goldens/public-api/common/index.md @@ -327,6 +327,9 @@ export type ImageLoader = (config: ImageLoaderConfig) => string; // @public export interface ImageLoaderConfig { + loaderParams?: { + [key: string]: any; + }; src: string; width?: number; } @@ -605,6 +608,9 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { set height(value: string | number | undefined); // (undocumented) get height(): number | undefined; + loaderParams?: { + [key: string]: any; + }; loading?: 'lazy' | 'eager' | 'auto'; // (undocumented) ngOnChanges(changes: SimpleChanges): void; @@ -622,7 +628,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { // (undocumented) get width(): number | undefined; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts index a73282ef316..ca247f27473 100644 --- a/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts @@ -27,6 +27,10 @@ export interface ImageLoaderConfig { * Width of the requested image (to be used when generating srcset). */ width?: number; + /** + * Additional user-provided parameters for use by the ImageLoader. + */ + loaderParams?: {[key: string]: any;}; } /** diff --git a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts index 78fd7ab23bb..8f8b2c53c59 100644 --- a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts +++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts @@ -13,7 +13,7 @@ import {isPlatformServer} from '../../platform_id'; import {imgDirectiveDetails} from './error_helper'; import {cloudinaryLoaderInfo} from './image_loaders/cloudinary_loader'; -import {IMAGE_LOADER, ImageLoader, noopImageLoader} from './image_loaders/image_loader'; +import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, noopImageLoader} from './image_loaders/image_loader'; import {imageKitLoaderInfo} from './image_loaders/imagekit_loader'; import {imgixLoaderInfo} from './image_loaders/imgix_loader'; import {LCPImageObserver} from './lcp_image_observer'; @@ -317,6 +317,11 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { } private _priority = false; + /** + * Data to pass through to custom loaders. + */ + @Input() loaderParams?: {[key: string]: any}; + /** * Disables automatic srcset generation for this image. */ @@ -386,6 +391,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { } assertNotMissingBuiltInLoader(this.ngSrc, this.imageLoader); assertNoNgSrcsetWithoutLoader(this, this.imageLoader); + assertNoLoaderParamsWithoutLoader(this, this.imageLoader); if (this.priority) { const checker = this.injector.get(PreconnectLinkChecker); checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc); @@ -462,11 +468,21 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { 'fill', 'loading', 'sizes', + 'loaderParams', 'disableOptimizedSrcset', ]); } } + private callImageLoader(configWithoutCustomParams: Omit): + string { + let augmentedConfig: ImageLoaderConfig = configWithoutCustomParams; + if (this.loaderParams) { + augmentedConfig.loaderParams = this.loaderParams; + } + return this.imageLoader(augmentedConfig); + } + private getLoadingBehavior(): string { if (!this.priority && this.loading !== undefined) { return this.loading; @@ -485,7 +501,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { if (!this._renderedSrc) { const imgConfig = {src: this.ngSrc}; // Cache calculated image src to reuse it later in the code. - this._renderedSrc = this.imageLoader(imgConfig); + this._renderedSrc = this.callImageLoader(imgConfig); } return this._renderedSrc; } @@ -495,7 +511,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { const finalSrcs = this.ngSrcset.split(',').filter(src => src !== '').map(srcStr => { srcStr = srcStr.trim(); const width = widthSrcSet ? parseFloat(srcStr) : parseFloat(srcStr) * this.width!; - return `${this.imageLoader({src: this.ngSrc, width})} ${srcStr}`; + return `${this.callImageLoader({src: this.ngSrc, width})} ${srcStr}`; }); return finalSrcs.join(', '); } @@ -518,15 +534,16 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { filteredBreakpoints = breakpoints!.filter(bp => bp >= VIEWPORT_BREAKPOINT_CUTOFF); } - const finalSrcs = - filteredBreakpoints.map(bp => `${this.imageLoader({src: this.ngSrc, width: bp})} ${bp}w`); + const finalSrcs = filteredBreakpoints.map( + bp => `${this.callImageLoader({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`); + const finalSrcs = DENSITY_SRCSET_MULTIPLIERS.map(multiplier => `${this.callImageLoader({ + src: this.ngSrc, + width: this.width! * multiplier + })} ${multiplier}x`); return finalSrcs.join(', '); } @@ -961,10 +978,25 @@ function assertNotMissingBuiltInLoader(ngSrc: string, imageLoader: ImageLoader) function assertNoNgSrcsetWithoutLoader(dir: NgOptimizedImage, imageLoader: ImageLoader) { if (dir.ngSrcset && imageLoader === noopImageLoader) { console.warn(formatRuntimeError( - RuntimeErrorCode.NGSRCSET_WITHOUT_LOADER, + 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: NgOptimizedImage, imageLoader: ImageLoader) { + if (dir.loaderParams && imageLoader === noopImageLoader) { + console.warn(formatRuntimeError( + 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.`)); + } +} diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts index a886c284f2d..ed4bd0a66ec 100644 --- a/packages/common/src/errors.ts +++ b/packages/common/src/errors.ts @@ -33,5 +33,5 @@ export const enum RuntimeErrorCode { OVERSIZED_IMAGE = 2960, TOO_MANY_PRELOADED_IMAGES = 2961, MISSING_BUILTIN_LOADER = 2962, - NGSRCSET_WITHOUT_LOADER = 2963, + MISSING_NECESSARY_LOADER = 2963, } diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts index 5b21e14d6fa..7865f706b61 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -642,14 +642,9 @@ describe('Image directive', () => { }); const inputs = [ - ['ngSrc', 'new-img.png'], - ['width', 10], - ['height', 20], - ['priority', true], - ['fill', true], - ['loading', true], - ['sizes', '90vw'], - ['disableOptimizedSrcset', true], + ['ngSrc', 'new-img.png'], ['width', 10], ['height', 20], ['priority', true], ['fill', true], + ['loading', true], ['sizes', '90vw'], ['disableOptimizedSrcset', true], + ['loaderParams', '{foo: "test1"}'] ]; inputs.forEach(([inputName, value]) => { it(`should throw if the \`${inputName}\` input changed after directive initialized the input`, @@ -665,6 +660,7 @@ describe('Image directive', () => { [loading]="loading" [sizes]="sizes" [disableOptimizedSrcset]="disableOptimizedSrcset" + [loaderParams]="loaderParams" >` }) class TestComponent { @@ -676,6 +672,7 @@ describe('Image directive', () => { loading = false; sizes = '100vw'; disableOptimizedSrcset = false; + loaderParams = {bar: 'test2'}; } setupTestingModule({component: TestComponent}); @@ -1121,6 +1118,26 @@ describe('Image directive', () => { }); describe('loaders', () => { + const imageLoaderWithData = (config: ImageLoaderConfig) => { + let paramsString = ''; + if (config.loaderParams) { + paramsString = + Object.entries(config.loaderParams).map(entry => `${entry[0]}=${entry[1]}`).join('&'); + } + let queryString = `${config.width ? 'w=' + config.width + '&' : ''}${paramsString}`; + return `${config.src}?${queryString}`; + }; + + // Test complex loaderParams schema with nesting: + // loaderParams = { + // transforms1: {example1: "foo"}, + // transforms2: {example2: "bar"} + // } + const nestedImageLoader = (config: ImageLoaderConfig) => { + return `${config.src}/${config.loaderParams?.transforms1.example1}/${ + config.loaderParams?.transforms2.example2}`; + }; + it('should set `src` to match `ngSrc` if image loader is not provided', () => { setupTestingModule(); @@ -1194,13 +1211,36 @@ describe('Image directive', () => { expect(consoleWarnSpy.calls.count()).toBe(1); expect(consoleWarnSpy.calls.argsFor(0)[0]) .toBe( - 'NG02963: The NgOptimizedImage directive (activated on an element ' + + `NG0${ + RuntimeErrorCode + .MISSING_NECESSARY_LOADER}: The NgOptimizedImage directive (activated on an element ` + 'with the `ngSrc="img.png"`) has detected that 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.'); }); + it('should warn if there is no image loader but `loaderParams` is present', () => { + setUpModuleNoLoader(); + + const template = + ``; + const fixture = createTestComponent(template); + const consoleWarnSpy = spyOn(console, 'warn'); + fixture.detectChanges(); + + expect(consoleWarnSpy.calls.count()).toBe(1); + expect(consoleWarnSpy.calls.argsFor(0)[0]) + .toBe( + `NG0${ + RuntimeErrorCode + .MISSING_NECESSARY_LOADER}: The NgOptimizedImage directive (activated on an element ` + + 'with the `ngSrc="img.png"`) has detected that 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.'); + }); + it('should set `src` using the image loader provided via the `IMAGE_LOADER` token to compose src URL', () => { const imageLoader = (config: ImageLoaderConfig) => `${IMG_BASE_URL}/${config.src}`; @@ -1235,6 +1275,80 @@ describe('Image directive', () => { expect(imgs[0].src.trim()).toBe(`${IMG_BASE_URL}/img.png?rewritten=true`); }); + it('should pass data payload from loaderParams to custom image loaders', () => { + setupTestingModule({imageLoader: imageLoaderWithData}); + const template = ` + + `; + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const imgs = nativeElement.querySelectorAll('img')!; + expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png?testProp1=testValue1&testProp2=testValue2`); + }); + + it('should pass nested data payloads from loaderParams to custom image loaders', () => { + @Component({ + selector: 'test-cmp', + template: `` + }) + class TestComponent { + ngSrc = `${IMG_BASE_URL}/img.png`; + width = 300; + height = 300; + params = {transforms1: {example1: 'foo'}, transforms2: {example2: 'bar'}}; + } + setupTestingModule({imageLoader: nestedImageLoader, component: TestComponent}); + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const imgs = nativeElement.querySelectorAll('img')!; + expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png/foo/bar`); + }); + + it('should pass data payload from loaderParams to loader when generating srcsets', () => { + setupTestingModule({imageLoader: imageLoaderWithData}); + const template = ` + + `; + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const imgs = nativeElement.querySelectorAll('img')!; + expect(imgs[0].srcset) + .toBe(`${IMG_BASE_URL}/img.png?w=150&testProp1=testValue1&testProp2=testValue2 1x, ${ + IMG_BASE_URL}/img.png?w=300&testProp1=testValue1&testProp2=testValue2 2x`); + }); + + it('should pass data payload from loaderParams to loader when generating responsive srcsets', + () => { + setupTestingModule({imageLoader: imageLoaderWithData}); + const template = ` + + `; + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const imgs = nativeElement.querySelectorAll('img')!; + expect(imgs[0].srcset) + .toBe(`${IMG_BASE_URL}/img.png?w=640&testProp1=testValue1&testProp2=testValue2 640w, ${ + IMG_BASE_URL}/img.png?w=750&testProp1=testValue1&testProp2=testValue2 750w, ${ + IMG_BASE_URL}/img.png?w=828&testProp1=testValue1&testProp2=testValue2 828w, ${ + IMG_BASE_URL}/img.png?w=1080&testProp1=testValue1&testProp2=testValue2 1080w, ${ + IMG_BASE_URL}/img.png?w=1200&testProp1=testValue1&testProp2=testValue2 1200w, ${ + IMG_BASE_URL}/img.png?w=1920&testProp1=testValue1&testProp2=testValue2 1920w, ${ + IMG_BASE_URL}/img.png?w=2048&testProp1=testValue1&testProp2=testValue2 2048w, ${ + IMG_BASE_URL}/img.png?w=3840&testProp1=testValue1&testProp2=testValue2 3840w`); + }); + it('should set `src` to an image URL that does not include a default width parameter', () => { const imageLoader = (config: ImageLoaderConfig) => { const widthStr = config.width ? `?w=${config.width}` : ``;