Skip to content

Commit

Permalink
New [naturalBackgroundDensity] for Retina support in background images
Browse files Browse the repository at this point in the history
Usage:

```html
<div naturalBackgroundDensity="/api/image/123/200"></div>
<div naturalBackgroundDensity="/non-responsive.jpg"></div>
<div naturalBackgroundDensity="url(data:image/png;base64,aabbcc)"></div>
```

Will generate something like:

```html
<div style="background-image: image-set(url("/api/image/123/200") 1x, url("/api/image/123/300") 1.5x, ...);"></div>
<div style="background-image: url("/non-responsive.jpg");"></div>
<div style="background-image: url(data:image/png;base64,aabbcc)"></div>
```
  • Loading branch information
PowerKiKi committed Sep 6, 2024
1 parent 0bc61c4 commit 67df53b
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {Component, DebugElement} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {NaturalBackgroundDensityDirective} from './background-density.directive';

@Component({
template: `
<div naturalBackgroundDensity></div>
<div naturalBackgroundDensity="https://example.com/api/image/123/200" i18n-naturalBackgroundDensity></div>
<div [naturalBackgroundDensity]="'https://example.com/api/image/123/200'"></div>
<div [naturalBackgroundDensity]="'https://example.com/api/image/123.jpg'"></div>
<div
style="color: red; background-image: url('foo.jpg');"
naturalBackgroundDensity="https://example.com/api/image/123/200" i18n-naturalBackgroundDensity
></div>
<div
style="color: red; background-image: url('foo.jpg');"
naturalBackgroundDensity="https://example.com/api/image/123.jpg" i18n-naturalBackgroundDensity
></div>
<div naturalBackgroundDensity="https://example.com/api/image/123/201" i18n-naturalBackgroundDensity></div>
<div naturalBackgroundDensity="url(data:image/png;base64,aabbcc)" i18n-naturalBackgroundDensity></div>
`,
standalone: true,
imports: [NaturalBackgroundDensityDirective],
})
class TestComponent {}

describe('NaturalBackgroundDensity', () => {
let fixture: ComponentFixture<TestComponent>;
let elements: DebugElement[]; // the elements with the directive

const expected =
'image-set(url("https://example.com/api/image/123/200") 1x, url("https://example.com/api/image/123/300") 1.5x, url("https://example.com/api/image/123/400") 2x, url("https://example.com/api/image/123/600") 3x, url("https://example.com/api/image/123/800") 4x)';

beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);

fixture.detectChanges(); // initial binding

// all elements with an attached NaturalBackgroundDensity
elements = fixture.debugElement.queryAll(By.directive(NaturalBackgroundDensityDirective));
});

it('should have 8 active elements', () => {
expect(elements.length).toBe(8);
});

it('1st should be kept empty and should not be used in real world', () => {
expect(elements[0].nativeElement.style.backgroundImage).toBe('');
});

it('2nd should work', () => {
expect(elements[1].nativeElement.style.backgroundImage).toBe(expected);
});

it('3rd should work', () => {
expect(elements[2].nativeElement.style.backgroundImage).toBe(expected);
});

it('4th should support a non-responsive URL', () => {
expect(elements[3].nativeElement.style.backgroundImage).toBe('url("https://example.com/api/image/123.jpg")');
});

it('5th will completely discard original src and srcset attributes', () => {
expect(elements[4].nativeElement.style.backgroundImage).toBe(expected);
});

it('6th will completely discard original src and srcset attributes while keeping naturalBackgroundDensity as-is', () => {
expect(elements[5].nativeElement.style.backgroundImage).toBe('url("https://example.com/api/image/123.jpg")');
});

it('7th will round dimensions', () => {
const expectedSrcsetUneven =
'image-set(url("https://example.com/api/image/123/201") 1x, url("https://example.com/api/image/123/302") 1.5x, url("https://example.com/api/image/123/402") 2x, url("https://example.com/api/image/123/603") 3x, url("https://example.com/api/image/123/804") 4x)';

expect(elements[6].nativeElement.style.backgroundImage).toBe(expectedSrcsetUneven);
});

it('8th will allow data url', () => {
expect(elements[7].nativeElement.style.backgroundImage).toBe('url("data:image/png;base64,aabbcc")');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {Directive, ElementRef, Input} from '@angular/core';
import {densities} from './src-density.directive';

@Directive({
selector: '[naturalBackgroundDensity]',
standalone: true,
})
export class NaturalBackgroundDensityDirective {
/**
* Automatically apply background image selection based on screen density.
*
* The given URL **MUST** be the normal density URL. And it **MUST** include
* the size as last path segment. That size will automatically be changed
* for other screen densities. That means that the server **MUST** be able to
* serve an image of the given size.
*
* If the given URL starts with `url(`, or is not ending with a number, then
* it will be set as-is, without any processing. This allows using url data,
* such as `url(data:image/png;base64,aabbcc)`.
*
* Usage:
*
* ```html
* <div naturalBackgroundDensity="/api/image/123/200"></div>
* <div naturalBackgroundDensity="/non-responsive.jpg"></div>
* <div naturalBackgroundDensity="url(data:image/png;base64,aabbcc)"></div>
* ```
*
* Will generate something like:
*
* ```html
* <div style="background-image: image-set(url("/api/image/123/200") 1x, url("/api/image/123/300") 1.5x, url("/api/image/123/400") 2x, url("/api/image/123/600") 3x, url("/api/image/123/800") 4x);"></div>
* <div style="background-image: url("/non-responsive.jpg");"></div>
* <div style="background-image: url(data:image/png;base64,aabbcc)"></div>
* ```
*
* See https://developer.mozilla.org/en-US/docs/Web/CSS/image/image-set
*/
@Input({required: true})
public set naturalBackgroundDensity(src: string) {
if (src.startsWith('url(')) {
this.elementRef.nativeElement.style.backgroundImage = src;
return;
}

// Always include a fallback with standard syntax for browsers that don't support at all, or don't support without
// prefixes (eg: Chrome v88 that we still see in production)
const fallback = src ? `url(${src})` : '';
this.elementRef.nativeElement.style.backgroundImage = fallback;

const responsive = densities(src, true);
if (responsive) {
this.elementRef.nativeElement.style.backgroundImage = responsive;
}
}

public constructor(private readonly elementRef: ElementRef<HTMLElement>) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('NaturalSrcDensityDirective', () => {

const expectedSrc = 'https://example.com/api/image/123/200';
const expectedSrcset =
'https://example.com/api/image/123/200, https://example.com/api/image/123/300 1.5x, https://example.com/api/image/123/400 2x, https://example.com/api/image/123/600 3x, https://example.com/api/image/123/800 4x';
'https://example.com/api/image/123/200 1x, https://example.com/api/image/123/300 1.5x, https://example.com/api/image/123/400 2x, https://example.com/api/image/123/600 3x, https://example.com/api/image/123/800 4x';
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);

Expand Down Expand Up @@ -68,9 +68,9 @@ describe('NaturalSrcDensityDirective', () => {
expect(elements[5].nativeElement.srcset).toBe('');
});

it('6th will completely discard original src and srcset attributes while keeping naturalSrcDensity as-is', () => {
it('7th will round dimensions', () => {
const expectedSrcsetUneven =
'https://example.com/api/image/123/201, https://example.com/api/image/123/302 1.5x, https://example.com/api/image/123/402 2x, https://example.com/api/image/123/603 3x, https://example.com/api/image/123/804 4x';
'https://example.com/api/image/123/201 1x, https://example.com/api/image/123/302 1.5x, https://example.com/api/image/123/402 2x, https://example.com/api/image/123/603 3x, https://example.com/api/image/123/804 4x';

expect(elements[6].nativeElement.src).toBe('https://example.com/api/image/123/201');
expect(elements[6].nativeElement.srcset).toBe(expectedSrcsetUneven);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import {Directive, ElementRef, Input} from '@angular/core';

export function densities(src: string, forImageSet: boolean): string {
const match = /^(.*\/)(\d+)$/.exec(src);
const base = match?.[1];
const size = +(match?.[2] ?? 0);

if (!base || !size) {
return '';
}

// This should cover most common densities according to https://www.mydevice.io/#tab1
let result = [1, 1.5, 2, 3, 4]
.map(density => {
let url = `${base}${Math.round(size * density)}`;
if (forImageSet) {
url = `url("${url}")`;
}

return `${url} ${density}x`;
})
.join(', ');

if (forImageSet) {
result = `image-set(${result})`;
}

return result;
}

@Directive({
selector: 'img[naturalSrcDensity]',
standalone: true,
Expand All @@ -16,7 +44,7 @@ export class NaturalSrcDensityDirective {
* Usage:
*
* ```html
* <img [naturalSrcDensity]="'/api/image/123/200'" />
* <img naturalSrcDensity="/api/image/123/200" />
* ```
*
* Will generate something like:
Expand All @@ -36,23 +64,8 @@ export class NaturalSrcDensityDirective {
return;
}

const match = /^(.*\/)(\d+)$/.exec(src);
const base = match?.[1];
const size = +(match?.[2] ?? 0);

let srcset = '';
if (base && size) {
// This should cover most common densities according to https://www.mydevice.io/#tab1
const size15 = Math.round(size * 1.5);
const size2 = size * 2;
const size3 = size * 3;
const size4 = size * 4;

srcset = `${base}${size}, ${base}${size15} 1.5x, ${base}${size2} 2x, ${base}${size3} 3x, ${base}${size4} 4x`;
}

this.elementRef.nativeElement.src = src;
this.elementRef.nativeElement.srcset = srcset;
this.elementRef.nativeElement.srcset = densities(src, false);
}

public constructor(private readonly elementRef: ElementRef<HTMLImageElement>) {}
Expand Down
1 change: 1 addition & 0 deletions projects/natural/src/lib/modules/common/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './pipes/enum.pipe';
export {NaturalTimeAgoPipe} from './pipes/time-ago.pipe';
export * from './services/memory-storage';
export {NaturalSrcDensityDirective} from './directives/src-density.directive';
export {NaturalBackgroundDensityDirective} from './directives/background-density.directive';
export {
NATURAL_SEO_CONFIG,
NaturalSeoConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
[class.suggest-upload]="!model && action === 'upload'"
[fileSelectionDisabled]="action !== 'upload'"
[matRippleDisabled]="!action"
[style.backgroundImage]="imagePreview"
[naturalBackgroundDensity]="imagePreview"
[style.backgroundSize]="backgroundSize"
matRipple
target="_blank"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
SimpleChanges,
} from '@angular/core';
import {AbstractControl} from '@angular/forms';
import {DomSanitizer, SafeStyle} from '@angular/platform-browser';
import {Observable, of, Subject, tap} from 'rxjs';
import {NaturalFileService} from '../file.service';
import {CommonModule, DOCUMENT} from '@angular/common';
Expand All @@ -21,6 +20,7 @@ import {NaturalIconDirective} from '../../icon/icon.directive';
import {MatIconModule} from '@angular/material/icon';
import {MatRippleModule} from '@angular/material/core';
import {NaturalFileDropDirective} from '../file-drop.directive';
import {NaturalBackgroundDensityDirective} from '../../common/directives/background-density.directive';

// @dynamic
@Component({
Expand All @@ -35,6 +35,7 @@ import {NaturalFileDropDirective} from '../file-drop.directive';
MatIconModule,
NaturalIconDirective,
NaturalCapitalizePipe,
NaturalBackgroundDensityDirective,
],
})
export class NaturalFileComponent implements OnInit, OnChanges {
Expand Down Expand Up @@ -82,13 +83,12 @@ export class NaturalFileComponent implements OnInit, OnChanges {
*/
@Output() public readonly modelChange = new EventEmitter<FileModel>();

public imagePreview: SafeStyle | null = null;
public imagePreview = '';
public filePreview: string | null = null;

public constructor(
private readonly naturalFileService: NaturalFileService,
private readonly alertService: NaturalAlertService,
private readonly sanitizer: DomSanitizer,
@Inject(DOCUMENT) private readonly document: Document,
) {}

Expand Down Expand Up @@ -132,7 +132,7 @@ export class NaturalFileComponent implements OnInit, OnChanges {
}

private updateImage(): void {
this.imagePreview = null;
this.imagePreview = '';
this.filePreview = null;
if (!this.model) {
return;
Expand All @@ -143,7 +143,7 @@ export class NaturalFileComponent implements OnInit, OnChanges {
this.getBase64(this.model.file).subscribe(result => {
if (this.model?.file?.type) {
const content = 'url(data:' + this.model?.file?.type + ';base64,' + result + ')';
this.imagePreview = this.sanitizer.bypassSecurityTrustStyle(content);
this.imagePreview = content;
}
});
} else if (this.model.file) {
Expand All @@ -160,12 +160,12 @@ export class NaturalFileComponent implements OnInit, OnChanges {

// create image url without port to stay compatible with dev mode
const image = loc.protocol + '//' + loc.hostname + '/api/image/' + this.model.id + height;
this.imagePreview = this.sanitizer.bypassSecurityTrustStyle('url(' + image + ')');
this.imagePreview = image;
} else if (this.model?.mime && ['File', 'AccountingDocument'].includes(this.model.__typename || '')) {
this.filePreview = this.model.mime.split('/')[1];
} else if (this.model.src) {
// external url
this.imagePreview = this.sanitizer.bypassSecurityTrustStyle('url(' + this.model.src + ')');
this.imagePreview = this.model.src;
}
}

Expand Down

0 comments on commit 67df53b

Please sign in to comment.