Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
This commit adds Image directive skeleton as well as a set of basic tests. PR Close #47082
- Loading branch information
1 parent
31429ea
commit 86e77a5
Showing
4 changed files
with
271 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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>('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 <img> 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 <img> 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.`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = '<img raw-src="path/img.png" width="100" height="50">'; | ||
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 = '<img raw-src="path/img.png" width="150" height="50">'; | ||
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 = | ||
'<img raw-src="path/img.png" [loader]="cmpImageLoader" width="150" height="50">'; | ||
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 = '<img raw-src="path/img.png" src="path/img2.png" width="100" height="50">'; | ||
expect(() => { | ||
const fixture = createTestComponent(template); | ||
fixture.detectChanges(); | ||
}) | ||
.toThrowError( | ||
'NG02950: The NgImage directive (activated on an <img> 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<TestComponent> { | ||
return TestBed.overrideComponent(TestComponent, {set: {template: template}}) | ||
.createComponent(TestComponent); | ||
} |