Merge pull request #13972 from deaflynx/feature/custom-icons

Feature custom icon image
This commit is contained in:
Igor Kulikov 2025-09-10 12:03:41 +03:00 committed by GitHub
commit 5d024e1277
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 172 additions and 63 deletions

View File

@ -71,6 +71,7 @@
</mat-form-field> </mat-form-field>
<tb-material-icon-select asBoxInput <tb-material-icon-select asBoxInput
iconClearButton iconClearButton
allowedCustomIcon
[color]="widgetSettings.get('iconColor').value" [color]="widgetSettings.get('iconColor').value"
formControlName="titleIcon"> formControlName="titleIcon">
</tb-material-icon-select> </tb-material-icon-select>

View File

@ -33,6 +33,9 @@ import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; import { isSvgIcon, splitIconName } from '@shared/models/icon.models';
import { ContentObserver } from '@angular/cdk/observers'; 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( const _TbIconBase = mixinColor(
class { class {
@ -70,7 +73,7 @@ const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/;
host: { host: {
role: 'img', role: 'img',
class: 'mat-icon notranslate', 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-name]': '_svgName',
'[attr.data-mat-icon-namespace]': '_svgNamespace', '[attr.data-mat-icon-namespace]': '_svgNamespace',
'[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"', '[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"',
@ -99,6 +102,9 @@ export class TbIconComponent extends _TbIconBase
private _textElement = null; private _textElement = null;
_useImageIcon = false;
private _imageElement = null;
private _previousPath?: string; private _previousPath?: string;
private _elementsWithExternalReferences?: Map<Element, {name: string; value: string}[]>; private _elementsWithExternalReferences?: Map<Element, {name: string; value: string}[]>;
@ -109,6 +115,8 @@ export class TbIconComponent extends _TbIconBase
private contentObserver: ContentObserver, private contentObserver: ContentObserver,
private renderer: Renderer2, private renderer: Renderer2,
private _iconRegistry: MatIconRegistry, private _iconRegistry: MatIconRegistry,
private imagePipe: ImagePipe,
private sanitizer: DomSanitizer,
@Inject(MAT_ICON_LOCATION) private _location: MatIconLocation, @Inject(MAT_ICON_LOCATION) private _location: MatIconLocation,
private readonly _errorHandler: ErrorHandler) { private readonly _errorHandler: ErrorHandler) {
super(elementRef); super(elementRef);
@ -148,16 +156,29 @@ export class TbIconComponent extends _TbIconBase
private _updateIcon() { private _updateIcon() {
const useSvgIcon = isSvgIcon(this.icon); const useSvgIcon = isSvgIcon(this.icon);
const useImageIcon = isTbImage(this.icon);
if (this._useSvgIcon !== useSvgIcon) { if (this._useSvgIcon !== useSvgIcon) {
this._useSvgIcon = useSvgIcon; this._useSvgIcon = useSvgIcon;
if (!this._useSvgIcon) { if (!this._useSvgIcon) {
this._updateSvgIcon(undefined); this._updateSvgIcon(undefined);
} else { } else {
this._updateFontIcon(undefined); 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) { if (this._useSvgIcon) {
this._updateSvgIcon(this.icon); this._updateSvgIcon(this.icon);
} else if (this._useImageIcon) {
this._updateImageIcon(this.icon);
} else { } else {
this._updateFontIcon(this.icon); 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;
}
}
} }

View File

@ -71,6 +71,10 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit
@coerceBoolean() @coerceBoolean()
iconClearButton = false; iconClearButton = false;
@Input()
@coerceBoolean()
allowedCustomIcon = false;
private requiredValue: boolean; private requiredValue: boolean;
get required(): boolean { get required(): boolean {
return this.requiredValue; return this.requiredValue;
@ -169,7 +173,8 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit
this.viewContainerRef, MaterialIconsComponent, 'left', true, null, this.viewContainerRef, MaterialIconsComponent, 'left', true, null,
{ {
selectedIcon: this.materialIconFormGroup.get('icon').value, selectedIcon: this.materialIconFormGroup.get('icon').value,
iconClearButton: this.iconClearButton iconClearButton: this.iconClearButton,
allowedCustomIcon: this.allowedCustomIcon,
}, },
{}, {},
{}, {}, true); {}, {}, true);

View File

@ -16,63 +16,86 @@
--> -->
<div class="tb-material-icons-panel"> <div class="tb-material-icons-panel">
<div *ngIf="showTitle" class="tb-material-icons-title" translate>icon.icons</div> <div class="flex w-full">
<mat-form-field class="tb-material-icons-search tb-inline-field" appearance="outline" subscriptSizing="dynamic"> <span *ngIf="showTitle" class="tb-material-icons-title flex-1" translate>icon.icons</span>
<mat-icon matPrefix>search</mat-icon> @if (allowedCustomIcon) {
<input matInput [formControl]="searchIconControl" placeholder="{{ 'icon.search-icon' | translate }}"/> <tb-toggle-select [(ngModel)]="isCustomIcon" [ngModelOptions]="{standalone: true}" (ngModelChange)="selectedIcon = null">
<button *ngIf="searchIconControl.value" <tb-toggle-option [value]="false">{{ 'resource.system' | translate }}</tb-toggle-option>
type="button" <tb-toggle-option [value]="true">{{ 'icon.custom' | translate }}</tb-toggle-option>
matSuffix mat-icon-button aria-label="Clear" </tb-toggle-select>
(click)="clearSearch()"> <span class="flex-1"></span>
<mat-icon class="material-icons">close</mat-icon> }
</button>
</mat-form-field>
<cdk-virtual-scroll-viewport [class.!hidden]="notFound" #iconsPanel
[itemSize]="iconsRowHeight" class="tb-material-icons-viewport"
[style.width]="iconsPanelWidth"
[style.height]="iconsPanelHeight">
<div *cdkVirtualFor="let iconRow of iconRows$ | async" class="tb-material-icons-row">
<ng-container *ngFor="let icon of iconRow">
<button *ngIf="icon.name === selectedIcon"
class="tb-select-icon-button"
mat-raised-button
color="primary"
(click)="selectIcon(icon)"
matTooltip="{{ icon.displayName }}"
matTooltipPosition="above"
type="button">
<tb-icon matButtonIcon>{{icon.name}}</tb-icon>
</button>
<button *ngIf="icon.name !== selectedIcon"
class="tb-select-icon-button"
mat-button
(click)="selectIcon(icon)"
matTooltip="{{ icon.displayName }}"
matTooltipPosition="above"
type="button">
<tb-icon matButtonIcon>{{icon.name}}</tb-icon>
</button>
</ng-container>
</div>
</cdk-virtual-scroll-viewport>
<ng-container *ngIf="notFound">
<div class="tb-no-data-available" [style.width]="iconsPanelWidth">
<div class="tb-no-data-bg"></div>
<div class="tb-no-data-text">{{ 'icon.no-icons-found' | translate:{iconSearch: searchIconControl.value} }}</div>
</div>
</ng-container>
<div class="tb-material-icons-panel-buttons" *ngIf="iconClearButton || !showAllSubject.value">
<button *ngIf="iconClearButton"
mat-button
color="primary"
type="button"
(click)="clearIcon()"
[disabled]="!selectedIcon">
{{ 'action.clear' | translate }}
</button>
<span class="flex-1"></span>
<button *ngIf="!showAllSubject.value" class="tb-material-icons-show-more" mat-button color="primary" (click)="showAllSubject.next(true)">
{{ 'action.show-more' | translate }}
</button>
</div> </div>
@if (!isCustomIcon) {
<mat-form-field class="tb-material-icons-search tb-inline-field" appearance="outline" subscriptSizing="dynamic">
<mat-icon matPrefix>search</mat-icon>
<input matInput [formControl]="searchIconControl" placeholder="{{ 'icon.search-icon' | translate }}"/>
<button *ngIf="searchIconControl.value"
type="button"
matSuffix mat-icon-button aria-label="Clear"
(click)="clearSearch()">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-form-field>
<cdk-virtual-scroll-viewport [class.!hidden]="notFound" #iconsPanel
[itemSize]="iconsRowHeight" class="tb-material-icons-viewport"
[style.width]="iconsPanelWidth"
[style.height]="iconsPanelHeight">
<div *cdkVirtualFor="let iconRow of iconRows$ | async" class="tb-material-icons-row">
<ng-container *ngFor="let icon of iconRow">
<button *ngIf="icon.name === selectedIcon"
class="tb-select-icon-button"
mat-raised-button
color="primary"
(click)="selectIcon(icon.name)"
matTooltip="{{ icon.displayName }}"
matTooltipPosition="above"
type="button">
<tb-icon matButtonIcon>{{icon.name}}</tb-icon>
</button>
<button *ngIf="icon.name !== selectedIcon"
class="tb-select-icon-button"
mat-button
(click)="selectIcon(icon.name)"
matTooltip="{{ icon.displayName }}"
matTooltipPosition="above"
type="button">
<tb-icon matButtonIcon>{{icon.name}}</tb-icon>
</button>
</ng-container>
</div>
</cdk-virtual-scroll-viewport>
<ng-container *ngIf="notFound">
<div class="tb-no-data-available" [style.width]="iconsPanelWidth">
<div class="tb-no-data-bg"></div>
<div class="tb-no-data-text">{{ 'icon.no-icons-found' | translate:{iconSearch: searchIconControl.value} }}</div>
</div>
</ng-container>
<div class="tb-material-icons-panel-buttons" *ngIf="iconClearButton || !showAllSubject.value">
<button *ngIf="iconClearButton"
mat-button
color="primary"
type="button"
(click)="clearIcon()"
[disabled]="!selectedIcon">
{{ 'action.clear' | translate }}
</button>
<span class="flex-1"></span>
<button *ngIf="!showAllSubject.value" class="tb-material-icons-show-more" mat-button color="primary" (click)="showAllSubject.next(true)">
{{ 'action.show-more' | translate }}
</button>
</div>
} @else {
<tb-gallery-image-input [style.width]="iconsPanelWidth"
[(ngModel)]="selectedIcon">
</tb-gallery-image-input>
<div class="tb-material-icons-panel-buttons">
<span class="flex-1"></span>
<button class="tb-material-icons-show-more" mat-button color="primary"
[disabled]="!selectedIcon"
(click)="selectIcon(selectedIcon)">
{{ 'action.set' | translate }}
</button>
</div>
}
</div> </div>

View File

@ -37,6 +37,7 @@ import { TbPopoverComponent } from '@shared/components/popover.component';
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver } from '@angular/cdk/layout';
import { MediaBreakpoints } from '@shared/models/constants'; import { MediaBreakpoints } from '@shared/models/constants';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { isTbImage } from '@shared/models/resource.models';
@Component({ @Component({
selector: 'tb-material-icons', selector: 'tb-material-icons',
@ -61,6 +62,10 @@ export class MaterialIconsComponent extends PageComponent implements OnInit {
@coerceBoolean() @coerceBoolean()
showTitle = true; showTitle = true;
@Input()
@coerceBoolean()
allowedCustomIcon = false;
@Input() @Input()
popover: TbPopoverComponent; popover: TbPopoverComponent;
@ -71,6 +76,8 @@ export class MaterialIconsComponent extends PageComponent implements OnInit {
showAllSubject = new BehaviorSubject<boolean>(false); showAllSubject = new BehaviorSubject<boolean>(false);
searchIconControl: UntypedFormControl; searchIconControl: UntypedFormControl;
isCustomIcon = false;
iconsRowHeight = 48; iconsRowHeight = 48;
iconsPanelHeight: string; iconsPanelHeight: string;
@ -122,14 +129,15 @@ export class MaterialIconsComponent extends PageComponent implements OnInit {
map((data) => data.iconRows), map((data) => data.iconRows),
share() share()
); );
this.isCustomIcon = isTbImage(this.selectedIcon)
} }
clearSearch() { clearSearch() {
this.searchIconControl.patchValue('', {emitEvent: true}); this.searchIconControl.patchValue('', {emitEvent: true});
} }
selectIcon(icon: MaterialIcon) { selectIcon(icon: string) {
this.iconSelected.emit(icon.name); this.iconSelected.emit(icon);
} }
clearIcon() { clearIcon() {

View File

@ -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 isJSResourceUrl = (url: string): boolean => url && RESOURCES_URL_REGEXP.test(url);
export const isJSResource = (url: string): boolean => url?.startsWith(TB_RESOURCE_PREFIX); 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} => { export const extractParamsFromImageResourceUrl = (url: string): {type: ImageResourceType; key: string} => {
const res = url.match(IMAGES_URL_REGEXP); const res = url.match(IMAGES_URL_REGEXP);

View File

@ -9553,6 +9553,7 @@
"icon": { "icon": {
"icon": "Icon", "icon": "Icon",
"icons": "Icons", "icons": "Icons",
"custom": "Custom",
"select-icon": "Select icon", "select-icon": "Select icon",
"material-icons": "Material icons", "material-icons": "Material icons",
"show-all": "Show all icons", "show-all": "Show all icons",

View File

@ -26,6 +26,10 @@
width: #{$size}px; width: #{$size}px;
height: #{$size}px; height: #{$size}px;
} }
img {
width: #{$size}px;
height: #{$size}px;
}
} }
@mixin tb-mat-icon-button-size($size) { @mixin tb-mat-icon-button-size($size) {

View File

@ -896,7 +896,7 @@ pre.tb-highlight {
} }
.mat-icon { .mat-icon {
svg { svg, img {
vertical-align: inherit; vertical-align: inherit;
} }
&.tb-mat-12 { &.tb-mat-12 {