From 86e77a5d559eddb285e74cc34c0db73de5645022 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir <43554145+AndrewKushnir@users.noreply.github.com> Date: Fri, 15 Apr 2022 16:58:40 -0700 Subject: [PATCH] feat(common): add Image directive skeleton (#45627) (#47082) This commit adds Image directive skeleton as well as a set of basic tests. PR Close #47082 --- goldens/public-api/common/errors.md | 6 +- packages/common/src/directives/ng_image.ts | 158 ++++++++++++++++++ packages/common/src/errors.ts | 6 + .../common/test/directives/ng_image_spec.ts | 102 +++++++++++ 4 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 packages/common/src/directives/ng_image.ts create mode 100644 packages/common/test/directives/ng_image_spec.ts diff --git a/goldens/public-api/common/errors.md b/goldens/public-api/common/errors.md index 40852d52f5f43..ba2928599a0d6 100644 --- a/goldens/public-api/common/errors.md +++ b/goldens/public-api/common/errors.md @@ -6,12 +6,16 @@ // @public export const enum RuntimeErrorCode { + // (undocumented) + INVALID_INPUT = 2951, // (undocumented) INVALID_PIPE_ARGUMENT = 2100, // (undocumented) NG_FOR_MISSING_DIFFER = -2200, // (undocumented) - PARENT_NG_SWITCH_NOT_FOUND = 2000 + PARENT_NG_SWITCH_NOT_FOUND = 2000, + // (undocumented) + UNEXPECTED_SRC_ATTR = 2950 } // (No @packageDocumentation comment for this package) diff --git a/packages/common/src/directives/ng_image.ts b/packages/common/src/directives/ng_image.ts new file mode 100644 index 0000000000000..8edaa6642b4c1 --- /dev/null +++ b/packages/common/src/directives/ng_image.ts @@ -0,0 +1,158 @@ +/** + * @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 {Directive, Inject, InjectionToken, Input, SimpleChanges, ɵRuntimeError as RuntimeError} from '@angular/core'; + +import {RuntimeErrorCode} from '../errors'; + +/** + * Config options recognized by the image loader function. + */ +export interface ImageLoaderConfig { + src: string; + quality?: number; + width?: number; +} + +/** + * Represents an image loader function. + */ +export type ImageLoader = (config: ImageLoaderConfig) => string; + +/** + * Noop image loader that does no transformation to the original src and just returns it as is. + * This loader is used as a default one if more specific logic is not provided in an app config. + */ +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. + */ +export const IMAGE_LOADER = new InjectionToken('ImageLoader', { + providedIn: 'root', + factory: () => noopImageLoader, +}); + +/** + * ** EXPERIMENTAL ** + * + * TODO: add Image directive description. + * + * IMPORTANT: this directive should become standalone (i.e. not attached to any NgModule) once + * the `standalone` flag is implemented and available as a public API. + * + * @usageNotes + * TODO: add Image directive usage notes. + */ +@Directive({ + selector: 'img[raw-src]', + host: { + '[src]': 'getRewrittenSrc()', + }, +}) +export class NgImage { + constructor(@Inject(IMAGE_LOADER) private imageLoader: ImageLoader) {} + + // Private fields to keep normalized input values. + private _width?: number; + private _height?: number; + + /** + * Name of the source image. + * Image name will be processed by the image loader and the final URL will be applied as the `src` + * property of the image. + */ + @Input('raw-src') rawSrc!: string; + + /** + * The intrinsic width of the image in px. + * This input is required unless the 'fill-parent' is provided. + */ + @Input() + set width(value: string|number|undefined) { + this._width = inputToInteger(value); + } + get width(): number|undefined { + return this._width; + } + + /** + * The intrinsic height of the image in px. + * This input is required unless the 'fill-parent' is provided. + */ + @Input() + set height(value: string|number|undefined) { + this._height = inputToInteger(value); + } + get height(): number|undefined { + return this._height; + } + + /** + * Function that takes the name of the image, its width, and a quality %, + * then turns it into a valid image CDN URL. + */ + @Input() loader?: (config: ImageLoaderConfig) => string; + + /** + * Get a value of the `src` if it's set on a host element. + * This input is needed to verify that there are no `src` and `raw-src` provided + * at the same time (thus causing an ambiguity on which src to use). + */ + @Input() src?: string; + + ngOnInit() { + if (ngDevMode) { + assertExistingSrc(this); + } + } + + ngOnChanges(changes: SimpleChanges) { + // TODO: react to input changes. + } + + getRewrittenSrc(): string { + // If a loader is provided as an input - use it, otherwise fall back + // to the loader configured globally using the `IMAGE_LOADER` token. + const imgLoader = this.loader ?? this.imageLoader; + const imgConfig = { + src: this.rawSrc, + // TODO: if we're going to support responsive serving, we don't want to request the width + // based solely on the intrinsic width (e.g. if it's on mobile and the viewport is smaller). + // The width would require pre-processing before passing to the image loader function. + width: this.width, + }; + return imgLoader(imgConfig); + } +} + +/***** Helpers *****/ + +// Convert input value to integer. +function inputToInteger(value: string|number|undefined): number|undefined { + return typeof value === 'string' ? parseInt(value, 10) : value; +} + +function imgDirectiveDetails(dir: NgImage) { + return `The NgImage directive (activated on an element ` + + `with the \`raw-src="${dir.rawSrc}"\`)`; +} + +/***** Assert functions *****/ + +// Verifies that there is no `src` set on a host element. +function assertExistingSrc(dir: NgImage) { + if (dir.src) { + throw new RuntimeError( + RuntimeErrorCode.UNEXPECTED_SRC_ATTR, + `${imgDirectiveDetails(dir)} detected that the \`src\` is also set (to \`${dir.src}\`). ` + + `Please remove the \`src\` attribute from this image. The NgImage directive will use ` + + `the \`raw-src\` to compute the final image URL and set the \`src\` itself.`); + } +} diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts index f7841bd3f4955..dff334c4e0f28 100644 --- a/packages/common/src/errors.ts +++ b/packages/common/src/errors.ts @@ -13,8 +13,14 @@ export const enum RuntimeErrorCode { // NgSwitch errors PARENT_NG_SWITCH_NOT_FOUND = 2000, + // Pipe errors INVALID_PIPE_ARGUMENT = 2100, + // NgForOf errors NG_FOR_MISSING_DIFFER = -2200, + + // Image directive errors + UNEXPECTED_SRC_ATTR = 2950, + INVALID_INPUT = 2951, } diff --git a/packages/common/test/directives/ng_image_spec.ts b/packages/common/test/directives/ng_image_spec.ts new file mode 100644 index 0000000000000..382a972a4629b --- /dev/null +++ b/packages/common/test/directives/ng_image_spec.ts @@ -0,0 +1,102 @@ +/** + * @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 {CommonModule} from '@angular/common'; +import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgImage} from '@angular/common/src/directives/ng_image'; +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {expect} from '@angular/platform-browser/testing/src/matchers'; + +describe('Image directive', () => { + it('should set `src` to `raw-src` value if image loader is not provided', () => { + setupTestingModule(); + + const template = ''; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.src.endsWith('/path/img.png')).toBeTrue(); + }); + + it('should use an image loader provided via `IMAGE_LOADER` token', () => { + const imageLoader = (config: ImageLoaderConfig) => `${config.src}?w=${config.width}`; + setupTestingModule({imageLoader}); + + const template = ''; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.src.endsWith('/path/img.png?w=150')).toBeTrue(); + }); + + it('should use an image loader from inputs over the one provided via `IMAGE_LOADER` token', + () => { + const imageLoader = (config: ImageLoaderConfig) => + `${config.src}?w=${config.width}&source=IMAGE_LOADER`; + setupTestingModule({imageLoader}); + + const template = + ''; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.src.endsWith('/path/img.png?w=150&source=component')).toBeTrue(); + }); + + describe('setup error handling', () => { + it('should throw if both `src` and `raw-src` are present', () => { + setupTestingModule(); + + const template = ''; + expect(() => { + const fixture = createTestComponent(template); + fixture.detectChanges(); + }) + .toThrowError( + 'NG02950: The NgImage directive (activated on an element with the ' + + '`raw-src="path/img.png"`) detected that the `src` is also set (to `path/img2.png`). ' + + 'Please remove the `src` attribute from this image. The NgImage directive will use ' + + 'the `raw-src` to compute the final image URL and set the `src` itself.'); + }); + }); +}); + +// Helpers + +@Component({ + selector: 'test-cmp', + template: '', +}) +class TestComponent { + cmpImageLoader = (config: ImageLoaderConfig) => { + return `${config.src}?w=${config.width}&source=component`; + } +} + +function setupTestingModule(config?: {imageLoader: ImageLoader}) { + const providers = + config?.imageLoader ? [{provide: IMAGE_LOADER, useValue: config?.imageLoader}] : []; + TestBed.configureTestingModule({ + // Note: the `NgImage` is a part of declarations for now, + // since it's experimental and not yet added to the `CommonModule`. + declarations: [TestComponent, NgImage], + imports: [CommonModule], + providers, + }); +} + +function createTestComponent(template: string): ComponentFixture { + return TestBed.overrideComponent(TestComponent, {set: {template: template}}) + .createComponent(TestComponent); +}