diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 8d46ede134..35c6fde3f1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -71,6 +71,7 @@ diff --git a/ui-ngx/src/app/shared/components/icon.component.ts b/ui-ngx/src/app/shared/components/icon.component.ts index 2b6170b71c..20ee9c4a72 100644 --- a/ui-ngx/src/app/shared/components/icon.component.ts +++ b/ui-ngx/src/app/shared/components/icon.component.ts @@ -33,6 +33,9 @@ import { Subscription } from 'rxjs'; import { take } from 'rxjs/operators'; import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; import { ContentObserver } from '@angular/cdk/observers'; +import { isTbImage } from '@shared/models/resource.models'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; const _TbIconBase = mixinColor( class { @@ -70,7 +73,7 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/; host: { role: 'img', class: 'mat-icon notranslate', - '[attr.data-mat-icon-type]': '!_useSvgIcon ? "font" : "svg"', + '[attr.data-mat-icon-type]': '_useSvgIcon ? "svg" : (_useImageIcon ? null : "font")', '[attr.data-mat-icon-name]': '_svgName', '[attr.data-mat-icon-namespace]': '_svgNamespace', '[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"', @@ -99,6 +102,9 @@ export class TbIconComponent extends _TbIconBase private _textElement = null; + _useImageIcon = false; + private _imageElement = null; + private _previousPath?: string; private _elementsWithExternalReferences?: Map; @@ -109,6 +115,8 @@ export class TbIconComponent extends _TbIconBase private contentObserver: ContentObserver, private renderer: Renderer2, private _iconRegistry: MatIconRegistry, + private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, @Inject(MAT_ICON_LOCATION) private _location: MatIconLocation, private readonly _errorHandler: ErrorHandler) { super(elementRef); @@ -148,16 +156,29 @@ export class TbIconComponent extends _TbIconBase private _updateIcon() { const useSvgIcon = isSvgIcon(this.icon); + const useImageIcon = isTbImage(this.icon); if (this._useSvgIcon !== useSvgIcon) { this._useSvgIcon = useSvgIcon; if (!this._useSvgIcon) { this._updateSvgIcon(undefined); } else { this._updateFontIcon(undefined); + this._updateImageIcon(undefined); + } + } + if (this._useImageIcon !== useImageIcon) { + this._useImageIcon = useImageIcon; + if (!this._useImageIcon) { + this._updateImageIcon(undefined); + } else { + this._updateFontIcon(undefined); + this._updateSvgIcon(undefined); } } if (this._useSvgIcon) { this._updateSvgIcon(this.icon); + } else if (this._useImageIcon) { + this._updateImageIcon(this.icon); } else { this._updateFontIcon(this.icon); } @@ -278,4 +299,49 @@ export class TbIconComponent extends _TbIconBase } } + private _updateImageIcon(rawName: string | undefined) { + if (rawName) { + this._clearImageIcon(); + this.imagePipe.transform(rawName, { asString: true, ignoreLoadingImage: true }).subscribe( + imageUrl => { + const urlStr = imageUrl as string; + const isSvg = urlStr?.startsWith('data:image/svg+xml') || urlStr?.endsWith('.svg'); + if (isSvg) { + const safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(urlStr); + this._iconRegistry + .getSvgIconFromUrl(safeUrl) + .pipe(take(1)) + .subscribe({ + next: (svg) => { + this.renderer.insertBefore(this._elementRef.nativeElement, svg, this._iconNameContent.nativeElement); + this._imageElement = svg; + }, + error: () => this._setImageElement(urlStr) + }); + } else { + this._setImageElement(urlStr); + } + } + ); + } else { + this._clearImageIcon(); + } + } + + private _setImageElement(urlStr: string) { + const imgElement = this.renderer.createElement('img'); + this.renderer.addClass(imgElement, 'mat-icon'); + this.renderer.setAttribute(imgElement, 'alt', 'Image icon'); + this.renderer.setAttribute(imgElement, 'src', urlStr); + this.renderer.insertBefore(this._elementRef.nativeElement, imgElement, this._iconNameContent.nativeElement); + this._imageElement = imgElement; + } + + private _clearImageIcon() { + const elem: HTMLElement = this._elementRef.nativeElement; + if (this._imageElement !== null) { + this.renderer.removeChild(elem, this._imageElement); + this._imageElement = null; + } + } } diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.ts b/ui-ngx/src/app/shared/components/material-icon-select.component.ts index 9432b3c96e..2bbc5b7528 100644 --- a/ui-ngx/src/app/shared/components/material-icon-select.component.ts +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.ts @@ -71,6 +71,10 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit @coerceBoolean() iconClearButton = false; + @Input() + @coerceBoolean() + allowedCustomIcon = false; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; @@ -169,7 +173,8 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit this.viewContainerRef, MaterialIconsComponent, 'left', true, null, { selectedIcon: this.materialIconFormGroup.get('icon').value, - iconClearButton: this.iconClearButton + iconClearButton: this.iconClearButton, + allowedCustomIcon: this.allowedCustomIcon, }, {}, {}, {}, true); diff --git a/ui-ngx/src/app/shared/components/material-icons.component.html b/ui-ngx/src/app/shared/components/material-icons.component.html index d4d9fe93a6..b6880e4ffe 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.html +++ b/ui-ngx/src/app/shared/components/material-icons.component.html @@ -16,63 +16,86 @@ -->
-
icon.icons
- - search - - - - -
- - - - -
-
- -
-
-
{{ 'icon.no-icons-found' | translate:{iconSearch: searchIconControl.value} }}
-
-
-
- - - +
+ icon.icons + @if (allowedCustomIcon) { + + {{ 'resource.system' | translate }} + {{ 'icon.custom' | translate }} + + + }
+ @if (!isCustomIcon) { + + search + + + + +
+ + + + +
+
+ +
+
+
{{ 'icon.no-icons-found' | translate:{iconSearch: searchIconControl.value} }}
+
+
+
+ + + +
+ } @else { + + +
+ + +
+ }
diff --git a/ui-ngx/src/app/shared/components/material-icons.component.ts b/ui-ngx/src/app/shared/components/material-icons.component.ts index ece1a99108..d144fd45e6 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.ts +++ b/ui-ngx/src/app/shared/components/material-icons.component.ts @@ -37,6 +37,7 @@ import { TbPopoverComponent } from '@shared/components/popover.component'; import { BreakpointObserver } from '@angular/cdk/layout'; import { MediaBreakpoints } from '@shared/models/constants'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { isTbImage } from '@shared/models/resource.models'; @Component({ selector: 'tb-material-icons', @@ -61,6 +62,10 @@ export class MaterialIconsComponent extends PageComponent implements OnInit { @coerceBoolean() showTitle = true; + @Input() + @coerceBoolean() + allowedCustomIcon = false; + @Input() popover: TbPopoverComponent; @@ -71,6 +76,8 @@ export class MaterialIconsComponent extends PageComponent implements OnInit { showAllSubject = new BehaviorSubject(false); searchIconControl: UntypedFormControl; + isCustomIcon = false; + iconsRowHeight = 48; iconsPanelHeight: string; @@ -122,14 +129,15 @@ export class MaterialIconsComponent extends PageComponent implements OnInit { map((data) => data.iconRows), share() ); + this.isCustomIcon = isTbImage(this.selectedIcon) } clearSearch() { this.searchIconControl.patchValue('', {emitEvent: true}); } - selectIcon(icon: MaterialIcon) { - this.iconSelected.emit(icon.name); + selectIcon(icon: string) { + this.iconSelected.emit(icon); } clearIcon() { diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 0419e40a7c..c1e692e04f 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -188,6 +188,7 @@ export const isImageResourceUrl = (url: string): boolean => url && IMAGES_URL_RE export const isJSResourceUrl = (url: string): boolean => url && RESOURCES_URL_REGEXP.test(url); export const isJSResource = (url: string): boolean => url?.startsWith(TB_RESOURCE_PREFIX); +export const isTbImage = (url: string): boolean => url?.startsWith(TB_IMAGE_PREFIX); export const extractParamsFromImageResourceUrl = (url: string): {type: ImageResourceType; key: string} => { const res = url.match(IMAGES_URL_REGEXP); diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 838ab4fd96..9ee555a838 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -9553,6 +9553,7 @@ "icon": { "icon": "Icon", "icons": "Icons", + "custom": "Custom", "select-icon": "Select icon", "material-icons": "Material icons", "show-all": "Show all icons", diff --git a/ui-ngx/src/scss/mixins.scss b/ui-ngx/src/scss/mixins.scss index 8e60483cb1..fb4a51ebce 100644 --- a/ui-ngx/src/scss/mixins.scss +++ b/ui-ngx/src/scss/mixins.scss @@ -26,6 +26,10 @@ width: #{$size}px; height: #{$size}px; } + img { + width: #{$size}px; + height: #{$size}px; + } } @mixin tb-mat-icon-button-size($size) { diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index a8cf53534e..6d49e9f698 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -896,7 +896,7 @@ pre.tb-highlight { } .mat-icon { - svg { + svg, img { vertical-align: inherit; } &.tb-mat-12 {