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}` : ``;