Skip to content

Commit 51cc914

Browse files
SkyZeroZxAndrewKushnir
authored andcommitted
feat(common): support height in ImageLoaderConfig and built-in loaders
Introduces an optional `height` property in `ImageLoaderConfig`, allowing built-in image loaders to generate URLs with explicit height parameters. This improves layout control and enables better support for loaders that require height-based transformations. Closes #51723
1 parent f56bb07 commit 51cc914

9 files changed

Lines changed: 184 additions & 0 deletions

File tree

goldens/public-api/common/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ export type ImageLoader = (config: ImageLoaderConfig) => string;
334334

335335
// @public
336336
export interface ImageLoaderConfig {
337+
height?: number;
337338
isPlaceholder?: boolean;
338339
loaderParams?: {
339340
[key: string]: any;

packages/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ function createCloudflareUrl(path: string, config: ImageLoaderConfig) {
3434
params += `,width=${config.width}`;
3535
}
3636

37+
if (config.height) {
38+
params += `,height=${config.height}`;
39+
}
40+
3741
// When requesting a placeholder image we ask for a low quality image to reduce the load time.
3842
if (config.isPlaceholder) {
3943
params += `,quality=${PLACEHOLDER_QUALITY}`;

packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ function createCloudinaryUrl(path: string, config: ImageLoaderConfig) {
6565
params += `,w_${config.width}`;
6666
}
6767

68+
if (config.height) {
69+
params += `,h_${config.height}`;
70+
}
71+
6872
if (config.loaderParams?.['rounded']) {
6973
params += `,r_max`;
7074
}

packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export interface ImageLoaderConfig {
2727
* Width of the requested image (to be used when generating srcset).
2828
*/
2929
width?: number;
30+
/**
31+
* Height of the requested image (to be used when generating srcset).
32+
*/
33+
height?: number;
3034
/**
3135
* Whether the loader should generate a URL for a small image placeholder instead of a full-sized
3236
* image.

packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export function createImagekitUrl(path: string, config: ImageLoaderConfig): stri
5454
params.push(`w-${width}`);
5555
}
5656

57+
if (config.height) {
58+
params.push(`h-${config.height}`);
59+
}
60+
5761
// When requesting a placeholder image we ask for a low quality image to reduce the load time.
5862
if (config.isPlaceholder) {
5963
params.push(`q-${PLACEHOLDER_QUALITY}`);

packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ function createImgixUrl(path: string, config: ImageLoaderConfig) {
5252
params.push(`w=${config.width}`);
5353
}
5454

55+
if (config.height) {
56+
params.push(`h=${config.height}`);
57+
}
58+
5559
// When requesting a placeholder image we ask a low quality image to reduce the load time.
5660
if (config.isPlaceholder) {
5761
params.push(`q=${PLACEHOLDER_QUALITY}`);

packages/common/src/directives/ng_optimized_image/image_loaders/netlify_loader.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ function createNetlifyUrl(config: ImageLoaderConfig, path?: string) {
9191
url.searchParams.set('w', config.width.toString());
9292
}
9393

94+
if (config.height) {
95+
url.searchParams.set('h', config.height.toString());
96+
}
97+
9498
// When requesting a placeholder image we ask for a low quality image to reduce the load time.
9599
// If the quality is specified in the loader config - always use provided value.
96100
const configQuality = config.loaderParams?.['quality'] ?? config.loaderParams?.['q'];

packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,13 +562,29 @@ export class NgOptimizedImage implements OnInit, OnChanges {
562562
}
563563
}
564564

565+
/**
566+
* Calculates the aspect ratio of the image based on width and height.
567+
* Returns null if the aspect ratio cannot be calculated (missing dimensions or height is 0).
568+
*/
569+
private getAspectRatio(): number | null {
570+
if (this.width && this.height && this.height !== 0) {
571+
return this.width / this.height;
572+
}
573+
return null;
574+
}
575+
565576
private callImageLoader(
566577
configWithoutCustomParams: Omit<ImageLoaderConfig, 'loaderParams'>,
567578
): string {
568579
let augmentedConfig: ImageLoaderConfig = configWithoutCustomParams;
569580
if (this.loaderParams) {
570581
augmentedConfig.loaderParams = this.loaderParams;
571582
}
583+
// Calculate height if width is provided and aspect ratio is available
584+
const ratio = this.getAspectRatio();
585+
if (ratio !== null && augmentedConfig.width) {
586+
augmentedConfig.height = Math.round(augmentedConfig.width / ratio);
587+
}
572588
return this.imageLoader(augmentedConfig);
573589
}
574590

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,33 @@ describe('Image directive', () => {
13311331
);
13321332
});
13331333

1334+
it('should pass calculated height to placeholder loader based on aspect ratio', () => {
1335+
const placeholderLoaderWithHeight = (config: ImageLoaderConfig) => {
1336+
const widthStr = config.width ? `w=${config.width}` : '';
1337+
const heightStr = config.height ? `h=${config.height}` : '';
1338+
const phStr = config.isPlaceholder ? 'ph=true' : '';
1339+
const params = [widthStr, heightStr, phStr].filter((p) => p).join('&');
1340+
return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`;
1341+
};
1342+
const imageConfig = {
1343+
placeholderResolution: 30,
1344+
};
1345+
setupTestingModule({imageLoader: placeholderLoaderWithHeight, imageConfig});
1346+
const template = '<img ngSrc="path/img.png" width="400" height="200" placeholder />';
1347+
1348+
const fixture = createTestComponent(template);
1349+
fixture.detectChanges();
1350+
const nativeElement = fixture.nativeElement as HTMLElement;
1351+
const img = nativeElement.querySelector('img')!;
1352+
const styles = parseInlineStyles(img);
1353+
// Aspect ratio is 400/200 = 2, placeholderResolution is 30
1354+
// Expected height: 30 / 2 = 15
1355+
// Double quotes removed to account for different browser behavior.
1356+
expect(styles.get('background-image')?.replace(/"/g, '')).toBe(
1357+
`url(${IMG_BASE_URL}/path/img.png?w=30&h=15&ph=true)`,
1358+
);
1359+
});
1360+
13341361
it('should apply a background blur to images with a placeholder', () => {
13351362
setupTestingModule({imageLoader});
13361363
const template =
@@ -1981,6 +2008,122 @@ describe('Image directive', () => {
19812008
);
19822009
});
19832010

2011+
it('should pass height to custom image loader based on aspect ratio', () => {
2012+
const imageLoader = (config: ImageLoaderConfig) => {
2013+
const widthStr = config.width ? `w=${config.width}` : '';
2014+
const heightStr = config.height ? `h=${config.height}` : '';
2015+
const params = [widthStr, heightStr].filter((p) => p).join('&');
2016+
return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`;
2017+
};
2018+
setupTestingModule({imageLoader});
2019+
2020+
const template = '<img ngSrc="img.png" width="150" height="50">';
2021+
const fixture = createTestComponent(template);
2022+
fixture.detectChanges();
2023+
2024+
const nativeElement = fixture.nativeElement as HTMLElement;
2025+
const img = nativeElement.querySelector('img')!;
2026+
// For src without width, height should not be passed
2027+
expect(img.src).toBe(`${IMG_BASE_URL}/img.png`);
2028+
});
2029+
2030+
it('should pass calculated height to custom image loader when generating srcsets', () => {
2031+
const imageLoader = (config: ImageLoaderConfig) => {
2032+
const widthStr = config.width ? `w=${config.width}` : '';
2033+
const heightStr = config.height ? `h=${config.height}` : '';
2034+
const params = [widthStr, heightStr].filter((p) => p).join('&');
2035+
return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`;
2036+
};
2037+
setupTestingModule({imageLoader});
2038+
2039+
const template = '<img ngSrc="img.png" width="150" height="50">';
2040+
const fixture = createTestComponent(template);
2041+
fixture.detectChanges();
2042+
2043+
const nativeElement = fixture.nativeElement as HTMLElement;
2044+
const img = nativeElement.querySelector('img')!;
2045+
// Aspect ratio is 150/50 = 3, so for widths 150 and 300:
2046+
// height should be 50 and 100 respectively
2047+
expect(img.srcset).toBe(
2048+
`${IMG_BASE_URL}/img.png?w=150&h=50 1x, ${IMG_BASE_URL}/img.png?w=300&h=100 2x`,
2049+
);
2050+
});
2051+
2052+
it('should pass calculated height to custom image loader when generating responsive srcsets', () => {
2053+
const imageLoader = (config: ImageLoaderConfig) => {
2054+
const widthStr = config.width ? `w=${config.width}` : '';
2055+
const heightStr = config.height ? `h=${config.height}` : '';
2056+
const params = [widthStr, heightStr].filter((p) => p).join('&');
2057+
return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`;
2058+
};
2059+
setupTestingModule({imageLoader});
2060+
2061+
const template = '<img ngSrc="img.png" width="150" height="50" sizes="100vw">';
2062+
const fixture = createTestComponent(template);
2063+
fixture.detectChanges();
2064+
2065+
const nativeElement = fixture.nativeElement as HTMLElement;
2066+
const img = nativeElement.querySelector('img')!;
2067+
// Aspect ratio is 150/50 = 3
2068+
// Expected heights: 640/3=213, 750/3=250, etc.
2069+
expect(img.srcset).toBe(
2070+
`${IMG_BASE_URL}/img.png?w=640&h=213 640w, ${IMG_BASE_URL}/img.png?w=750&h=250 750w, ${IMG_BASE_URL}/img.png?w=828&h=276 828w, ${IMG_BASE_URL}/img.png?w=1080&h=360 1080w, ${IMG_BASE_URL}/img.png?w=1200&h=400 1200w, ${IMG_BASE_URL}/img.png?w=1920&h=640 1920w, ${IMG_BASE_URL}/img.png?w=2048&h=683 2048w, ${IMG_BASE_URL}/img.png?w=3840&h=1280 3840w`,
2071+
);
2072+
});
2073+
2074+
it('should not pass height to custom image loader when height is not provided', () => {
2075+
const imageLoader = (config: ImageLoaderConfig) => {
2076+
const widthStr = config.width ? `w=${config.width}` : '';
2077+
const heightStr = config.height ? `h=${config.height}` : '';
2078+
const params = [widthStr, heightStr].filter((p) => p).join('&');
2079+
return `${IMG_BASE_URL}/${config.src}${params ? '?' + params : ''}`;
2080+
};
2081+
setupTestingModule({imageLoader});
2082+
2083+
const template = '<img ngSrc="img.png" fill>';
2084+
const fixture = createTestComponent(template);
2085+
fixture.detectChanges();
2086+
2087+
const nativeElement = fixture.nativeElement as HTMLElement;
2088+
const img = nativeElement.querySelector('img')!;
2089+
// No height provided (fill mode), so aspect ratio cannot be calculated
2090+
// In fill mode, a responsive srcset is generated but without height parameters
2091+
expect(img.srcset).toBe(
2092+
`${IMG_BASE_URL}/img.png?w=640 640w, ${IMG_BASE_URL}/img.png?w=750 750w, ${IMG_BASE_URL}/img.png?w=828 828w, ${IMG_BASE_URL}/img.png?w=1080 1080w, ${IMG_BASE_URL}/img.png?w=1200 1200w, ${IMG_BASE_URL}/img.png?w=1920 1920w, ${IMG_BASE_URL}/img.png?w=2048 2048w, ${IMG_BASE_URL}/img.png?w=3840 3840w`,
2093+
);
2094+
});
2095+
2096+
it('should pass height to custom image loaders', () => {
2097+
@Component({
2098+
selector: 'test-cmp',
2099+
standalone: false,
2100+
template: `<img [ngSrc]="ngSrc" width="300" height="150" sizes="100vw" />`,
2101+
})
2102+
class TestComponent {
2103+
ngSrc = `img.png`;
2104+
}
2105+
const imageLoader = (config: ImageLoaderConfig) => {
2106+
const params: string[] = [];
2107+
if (config.width) {
2108+
params.push(`w=${config.width}`);
2109+
}
2110+
if (config.height) {
2111+
params.push(`h=${config.height}`);
2112+
}
2113+
const query = params.length ? `?${params.join('&')}` : '';
2114+
return `${IMG_BASE_URL}/${config.src}${query}`;
2115+
};
2116+
setupTestingModule({imageLoader, component: TestComponent});
2117+
const fixture = TestBed.createComponent(TestComponent);
2118+
fixture.detectChanges();
2119+
2120+
let nativeElement = fixture.nativeElement as HTMLElement;
2121+
let imgs = nativeElement.querySelectorAll('img')!;
2122+
expect(imgs[0].getAttribute('srcset')).toBe(
2123+
`${IMG_BASE_URL}/img.png?w=640&h=320 640w, ${IMG_BASE_URL}/img.png?w=750&h=375 750w, ${IMG_BASE_URL}/img.png?w=828&h=414 828w, ${IMG_BASE_URL}/img.png?w=1080&h=540 1080w, ${IMG_BASE_URL}/img.png?w=1200&h=600 1200w, ${IMG_BASE_URL}/img.png?w=1920&h=960 1920w, ${IMG_BASE_URL}/img.png?w=2048&h=1024 2048w, ${IMG_BASE_URL}/img.png?w=3840&h=1920 3840w`,
2124+
);
2125+
});
2126+
19842127
it('should set `src` to an image URL that does not include a default width parameter', () => {
19852128
const imageLoader = (config: ImageLoaderConfig) => {
19862129
const widthStr = config.width ? `?w=${config.width}` : ``;

0 commit comments

Comments
 (0)