diff --git a/ui-ngx/src/app/core/http/image.service.ts b/ui-ngx/src/app/core/http/image.service.ts new file mode 100644 index 0000000000..f52f55eb27 --- /dev/null +++ b/ui-ngx/src/app/core/http/image.service.ts @@ -0,0 +1,139 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { PageLink } from '@shared/models/page/page-link'; +import { defaultHttpOptionsFromConfig, defaultHttpUploadOptions, RequestConfig } from '@core/http/http-utils'; +import { Observable, of } from 'rxjs'; +import { PageData } from '@shared/models/page/page-data'; +import { + NO_IMAGE_DATA_URI, + ImageResourceInfo, + imageResourceType, + ImageResourceType, + IMAGES_URL_PREFIX, isImageResourceUrl +} from '@shared/models/resource.models'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { blobToBase64 } from '@core/utils'; + +@Injectable({ + providedIn: 'root' +}) +export class ImageService { + constructor( + private http: HttpClient, + private sanitizer: DomSanitizer + ) { + } + + public uploadImage(file: File, config?: RequestConfig): Observable { + if (!config) { + config = {}; + } + const formData = new FormData(); + formData.append('file', file); + return this.http.post('/api/image', formData, + defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest)); + } + + public updateImage(type: ImageResourceType, key: string, file: File, config?: RequestConfig): Observable { + if (!config) { + config = {}; + } + const formData = new FormData(); + formData.append('file', file); + return this.http.post(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`, formData, + defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest)); + } + + public updateImageInfo(imageInfo: ImageResourceInfo, config?: RequestConfig): Observable { + const type = imageResourceType(imageInfo); + const key = encodeURIComponent(imageInfo.resourceKey); + return this.http.put(`${IMAGES_URL_PREFIX}/${type}/${key}/info`, + imageInfo, defaultHttpOptionsFromConfig(config)); + } + + public getImages(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`${IMAGES_URL_PREFIX}${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } + + public getImageInfo(type: ImageResourceType, key: string, config?: RequestConfig): Observable { + return this.http.get(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}/info`, + defaultHttpOptionsFromConfig(config)); + } + + public getImageDataUrl(imageUrl: string, preview = false, asString = false): Observable { + const parts = imageUrl.split('/'); + const key = parts[parts.length - 1]; + parts[parts.length - 1] = encodeURIComponent(key); + const encodedUrl = parts.join('/'); + const imageLink = preview ? (encodedUrl + '/preview') : encodedUrl; + const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true}); + return this.http + .get(imageLink, {...options, ...{ responseType: 'blob' } }).pipe( + switchMap(val => blobToBase64(val).pipe( + map((dataUrl) => asString ? dataUrl : this.sanitizer.bypassSecurityTrustUrl(dataUrl)) + )), + catchError(() => of(asString ? NO_IMAGE_DATA_URI : this.sanitizer.bypassSecurityTrustUrl(NO_IMAGE_DATA_URI))) + ); + } + + public resolveImageUrl(imageUrl: string, preview = false, asString = false): Observable { + if (isImageResourceUrl(imageUrl)) { + return this.getImageDataUrl(imageUrl, preview, asString); + } else { + return of(asString ? imageUrl : this.sanitizer.bypassSecurityTrustUrl(imageUrl)); + } + } + + public downloadImage(type: ImageResourceType, key: string): Observable { + return this.http.get(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`, { + responseType: 'arraybuffer', + observe: 'response' + }).pipe( + map((response) => { + const headers = response.headers; + const filename = headers.get('x-filename'); + const contentType = headers.get('content-type'); + const linkElement = document.createElement('a'); + try { + const blob = new Blob([response.body], {type: contentType}); + const url = URL.createObjectURL(blob); + linkElement.setAttribute('href', url); + linkElement.setAttribute('download', filename); + const clickEvent = new MouseEvent('click', + { + view: window, + bubbles: true, + cancelable: false + } + ); + linkElement.dispatchEvent(clickEvent); + return null; + } catch (e) { + throw e; + } + }) + ); + } + + public deleteImage(type: ImageResourceType, key: string, config?: RequestConfig) { + return this.http.delete(`${IMAGES_URL_PREFIX}${type}/${encodeURIComponent}`, defaultHttpOptionsFromConfig(config)); + } + +} diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index b810689b69..feeb5de02f 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -15,7 +15,7 @@ /// import _ from 'lodash'; -import { Observable, Subject } from 'rxjs'; +import { from, Observable, Subject } from 'rxjs'; import { finalize, share } from 'rxjs/operators'; import { Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models'; import { EntityId } from '@shared/models/id/entity-id'; @@ -209,6 +209,13 @@ export function stringToBase64(value: string): string { })); } +export const blobToBase64 = (blob: Blob): Observable => from(new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(blob); + } + )); + const scrollRegex = /(auto|scroll)/; function parentNodes(node: Node, nodes: Node[]): Node[] { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html index 4431820808..a32dc812cd 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html @@ -51,7 +51,7 @@
- {{ item.title }} + {{ item.title }}
@@ -95,7 +95,7 @@
- {{ item.title }} + {{ item.title }}
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts index 7e8f31578b..ff9afe6920 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts @@ -28,8 +28,7 @@ import { } from '@shared/models/widget.models'; import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators'; import { BehaviorSubject, combineLatest } from 'rxjs'; -import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; -import { isDefinedAndNotNull, isObject } from '@core/utils'; +import { isObject } from '@core/utils'; import { PageLink } from '@shared/models/page/page-link'; import { Direction } from '@shared/models/page/sort-order'; import { GridEntitiesFetchFunction, ScrollGridColumns } from '@home/models/datasource/scroll-grid-datasource'; @@ -158,8 +157,7 @@ export class DashboardWidgetSelectComponent implements OnInit { widgetsFilter: BundleWidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL, widgetsBundleId: null}; constructor(private widgetsService: WidgetService, - private cd: ChangeDetectorRef, - private sanitizer: DomSanitizer) { + private cd: ChangeDetectorRef) { this.widgetBundlesFetchFunction = (pageSize, page, filter) => { const pageLink = new PageLink(pageSize, page, filter, { @@ -241,13 +239,6 @@ export class DashboardWidgetSelectComponent implements OnInit { } } - getPreviewImage(imageUrl: string | null): SafeUrl | string { - if (isDefinedAndNotNull(imageUrl)) { - return this.sanitizer.bypassSecurityTrustUrl(imageUrl); - } - return '/assets/widget-preview-empty.svg'; - } - isObject(value: any): boolean { return isObject(value); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts index 934f9be6fe..476a755bc2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts @@ -25,10 +25,11 @@ import { isUndefined, padValue } from '@core/utils'; -import { Observable, Observer, of } from 'rxjs'; +import { Observable, Observer, of, switchMap } from 'rxjs'; import { map } from 'rxjs/operators'; import { FormattedData } from '@shared/models/widget.models'; import L from 'leaflet'; +import { ImagePipe } from '@shared/pipe/image.pipe'; export function getRatio(firsMoment: number, secondMoment: number, intermediateMoment: number): number { return (intermediateMoment - firsMoment) / (secondMoment - firsMoment); @@ -71,44 +72,59 @@ export function getDefCenterPosition(position): [number, number] { } -const imageAspectMap = {}; - -function imageLoader(imageUrl: string): Observable { - return new Observable((observer: Observer) => { - const image = document.createElement('img'); // support IE - image.style.position = 'absolute'; - image.style.left = '-99999px'; - image.style.top = '-99999px'; - image.onload = () => { - observer.next(image); - document.body.removeChild(image); - observer.complete(); - }; - image.onerror = err => { - observer.error(err); - document.body.removeChild(image); - observer.complete(); - }; - document.body.appendChild(image); - image.src = imageUrl; - }); +const imageAspectMap: {[key: string]: ImageWithAspect} = {}; + +const imageLoader = (imageUrl: string): Observable => new Observable((observer: Observer) => { + const image = document.createElement('img'); // support IE + image.style.position = 'absolute'; + image.style.left = '-99999px'; + image.style.top = '-99999px'; + image.onload = () => { + observer.next(image); + document.body.removeChild(image); + observer.complete(); + }; + image.onerror = err => { + observer.error(err); + document.body.removeChild(image); + observer.complete(); + }; + document.body.appendChild(image); + image.src = imageUrl; +}); + +const loadImageAspect = (imageUrl: string): Observable => + imageLoader(imageUrl).pipe(map(image => image.width / image.height)); + +export interface ImageWithAspect { + url: string; + aspect: number; } -export function aspectCache(imageUrl: string): Observable { +export const loadImageWithAspect = (imagePipe: ImagePipe, imageUrl: string): Observable => { if (imageUrl?.length) { const hash = hashCode(imageUrl); - let aspect = imageAspectMap[hash]; - if (aspect) { - return of(aspect); + let imageWithAspect = imageAspectMap[hash]; + if (imageWithAspect) { + return of(imageWithAspect); + } else { + return imagePipe.transform(imageUrl, {asString: true, ignoreLoadingImage: true}).pipe( + switchMap((res) => { + const url = res as string; + return loadImageAspect(url).pipe( + map((aspect) => { + imageWithAspect = {url, aspect}; + imageAspectMap[hash] = imageWithAspect; + return imageWithAspect; + }) + ); + }) + ); } - return imageLoader(imageUrl).pipe(map(image => { - aspect = image.width / image.height; - imageAspectMap[hash] = aspect; - return aspect; - })); + } else { + return of(null); } - return of(0); -} +}; export type TranslateFunc = (key: string, defaultTranslation?: string) => string; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts index 5a4c80b12c..29d92b2057 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts @@ -17,11 +17,12 @@ import L, { LeafletMouseEvent } from 'leaflet'; import { MarkerIconInfo, MarkerIconReadyFunction, MarkerImageInfo, WidgetMarkersSettings, } from './map-models'; import { bindPopupActions, createTooltip } from './maps-utils'; -import { aspectCache, parseWithTranslation } from './common-maps-utils'; +import { loadImageWithAspect, parseWithTranslation } from './common-maps-utils'; import tinycolor from 'tinycolor2'; import { fillDataPattern, isDefined, isDefinedAndNotNull, processDataPattern, safeExecute } from '@core/utils'; import LeafletMap from './leaflet-map'; import { FormattedData } from '@shared/models/widget.models'; +import { ImagePipe } from '@shared/pipe/image.pipe'; export class Marker { @@ -167,16 +168,16 @@ export class Marker { } } if (currentImage && currentImage.url) { - aspectCache(currentImage.url).subscribe( - (aspect) => { - if (aspect) { + loadImageWithAspect(this.map.ctx.$injector.get(ImagePipe), currentImage.url).subscribe( + (aspectImage) => { + if (aspectImage?.aspect) { let width; let height; - if (aspect > 1) { + if (aspectImage.aspect > 1) { width = currentImage.size; - height = currentImage.size / aspect; + height = currentImage.size / aspectImage.aspect; } else { - width = currentImage.size * aspect; + width = currentImage.size * aspectImage.aspect; height = currentImage.size; } let iconAnchor = currentImage.markerOffset; @@ -188,7 +189,7 @@ export class Marker { popupAnchor = [width * this.tooltipOffset[0], height * this.tooltipOffset[1]]; } const icon = L.icon({ - iconUrl: currentImage.url, + iconUrl: aspectImage.url, iconSize: [width, height], iconAnchor, popupAnchor @@ -237,7 +238,15 @@ export class Marker { } createColorIconURI(color: tinycolor.Instance): string { - const svg = ``; + const svg = `` + + ``; return 'data:image/svg+xml;base64,' + btoa(svg); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts index be6ee64753..1a05ec547b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts @@ -23,11 +23,10 @@ import { PosFunction, WidgetUnitedMapSettings } from '../map-models'; -import { Observable, ReplaySubject } from 'rxjs'; +import { Observable, of, ReplaySubject, switchMap } from 'rxjs'; import { catchError, map, mergeMap } from 'rxjs/operators'; import { - aspectCache, - calculateNewPointCoordinate + calculateNewPointCoordinate, loadImageWithAspect } from '@home/components/widget/lib/maps/common-maps-utils'; import { WidgetContext } from '@home/models/widget-component.models'; import { DataSet, DatasourceType, FormattedData, widgetType } from '@shared/models/widget.models'; @@ -35,6 +34,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { WidgetSubscriptionOptions } from '@core/api/widget-api.models'; import { isDefinedAndNotNull, isEmptyStr, isNotEmptyStr, parseFunction } from '@core/utils'; import { EntityDataPageLink } from '@shared/models/query/query.models'; +import { ImagePipe } from '@shared/pipe/image.pipe'; const maxZoom = 4; // ? @@ -122,39 +122,43 @@ export class ImageMap extends LeafletMap { } private imageFromUrl(url: string): Observable { - return aspectCache(url).pipe( - map( aspect => { - const mapImage: MapImage = { - imageUrl: url, - aspect, - update: false - }; - return mapImage; + return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( + switchMap( aspectImage => { + if (aspectImage) { + return of({ + imageUrl: aspectImage.url, + aspect: aspectImage.aspect, + update: false + }); + } else { + return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl); + } } ), - catchError((e) => { - return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl); - }) + catchError((e) => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl)) ); } private imageFromAlias(alias: Observable<[DataSet, boolean]>): Observable { return alias.pipe( - mergeMap(res => { + switchMap(res => { + const url = res[0][0][1]; const mapImage: MapImage = { - imageUrl: res[0][0][1], + imageUrl: null, aspect: null, update: res[1] }; - return aspectCache(mapImage.imageUrl).pipe( - map((aspect) => { - mapImage.aspect = aspect; - return mapImage; - } - ), - catchError((e) => { - return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl); - }) + return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( + switchMap((aspectImage) => { + if (aspectImage) { + mapImage.aspect = aspectImage.aspect; + mapImage.imageUrl = aspectImage.url; + return of(mapImage); + } else { + return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl); + } + }), + catchError((e) => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl)) ); }) ); @@ -249,7 +253,7 @@ export class ImageMap extends LeafletMap { } } - extractPosition(data: FormattedData): {x: number, y: number} { + extractPosition(data: FormattedData): {x: number; y: number} { if (!data) { return null; } @@ -261,7 +265,7 @@ export class ImageMap extends LeafletMap { return {x: xPos, y: yPos}; } - positionToLatLng(position: {x: number, y: number}): L.LatLng { + positionToLatLng(position: {x: number; y: number}): L.LatLng { return this.pointToLatLng( position.x * this.width, position.y * this.height); diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html index fdd49c4a61..f9f4990d21 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html @@ -62,7 +62,7 @@
- {{ widget.name }} + {{ widget.name }}
{{ widget.name }}
{{ 'widget.deprecated' | translate }}
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts index cc54c53901..23cc43bada 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts @@ -28,8 +28,6 @@ import { widgetType as WidgetDataType, WidgetTypeInfo } from '@shared/models/wid import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ImportExportService } from '@home/components/import-export/import-export.service'; import { WidgetService } from '@core/http/widget.service'; -import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; -import { isDefinedAndNotNull } from '@core/utils'; import { FormControl, Validators } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component'; @@ -61,7 +59,6 @@ export class WidgetsBundleWidgetsComponent extends PageComponent implements OnIn private route: ActivatedRoute, private widgetsService: WidgetService, private importExport: ImportExportService, - private sanitizer: DomSanitizer, private cd: ChangeDetectorRef, private dialog: MatDialog) { super(store); @@ -86,13 +83,6 @@ export class WidgetsBundleWidgetsComponent extends PageComponent implements OnIn ngOnInit(): void { } - getPreviewImage(imageUrl: string | null): SafeUrl | string { - if (isDefinedAndNotNull(imageUrl)) { - return this.sanitizer.bypassSecurityTrustUrl(imageUrl); - } - return '/assets/widget-preview-empty.svg'; - } - trackByWidget(index: number, widget: WidgetTypeBundle): any { return widget; } diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 3014711d57..e494e3842e 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -17,6 +17,8 @@ import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { TenantId } from '@shared/models/id/tenant-id'; import { TbResourceId } from '@shared/models/id/tb-resource-id'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { BaseWidgetType } from '@shared/models/widget.models'; export enum ResourceType { LWM2M_MODEL = 'LWM2M_MODEL', @@ -55,15 +57,40 @@ export const ResourceTypeTranslationMap = new Map( ] ); -export interface ResourceInfo extends Omit, 'name' | 'label'>, ExportableEntity { +export interface TbResourceInfo extends Omit, 'name' | 'label'>, ExportableEntity { tenantId?: TenantId; resourceKey?: string; title?: string; resourceType: ResourceType; fileName: string; + descriptor?: D; } +export type ResourceInfo = TbResourceInfo; + export interface Resource extends ResourceInfo { data: string; name?: string; } + +export interface ImageDescriptor { + mediaType: string; + width: number; + height: number; + size: number; + etag: string; + previewDescriptor: ImageDescriptor; +} + +export type ImageResourceInfo = TbResourceInfo; + +export type ImageResourceType = 'tenant' | 'system'; + +export const imageResourceType = (imageInfo: ImageResourceInfo): ImageResourceType => + (!imageInfo.tenantId || imageInfo.tenantId?.id === NULL_UUID) ? 'system' : 'tenant'; + +export const IMAGES_URL_PREFIX = '/api/images'; + +export const isImageResourceUrl = (url: string): boolean => url && url.startsWith(IMAGES_URL_PREFIX); + +export const NO_IMAGE_DATA_URI = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; diff --git a/ui-ngx/src/app/shared/pipe/image.pipe.ts b/ui-ngx/src/app/shared/pipe/image.pipe.ts new file mode 100644 index 0000000000..fb54cb6985 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/image.pipe.ts @@ -0,0 +1,52 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Pipe, PipeTransform } from '@angular/core'; +import { ImageService } from '@core/http/image.service'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; +import { isDefinedAndNotNull } from '@core/utils'; +import { NO_IMAGE_DATA_URI } from '@shared/models/resource.models'; + +const LOADING_IMAGE_DATA_URI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPgo='; + +@Pipe({ + name: 'image' +}) +export class ImagePipe implements PipeTransform { + + constructor(private imageService: ImageService, + private sanitizer: DomSanitizer) { } + + transform(url: string, args?: any): Observable { + const ignoreLoadingImage = !!args?.ignoreLoadingImage; + const asString = !!args?.asString; + const image$ = ignoreLoadingImage ? new Subject() : new BehaviorSubject(LOADING_IMAGE_DATA_URI); + if (isDefinedAndNotNull(url)) { + const preview = !!args?.preview; + this.imageService.resolveImageUrl(url, preview, asString).subscribe((imageUrl) => { + image$.next(imageUrl); + image$.complete(); + }); + } else { + const emptyUrl = args?.emptyUrl || NO_IMAGE_DATA_URI; + image$.next(asString ? emptyUrl : this.sanitizer.bypassSecurityTrustUrl(emptyUrl)); + image$.complete(); + } + return image$.asObservable(); + } + +} diff --git a/ui-ngx/src/app/shared/pipe/public-api.ts b/ui-ngx/src/app/shared/pipe/public-api.ts index 9ba3be3610..f03d669345 100644 --- a/ui-ngx/src/app/shared/pipe/public-api.ts +++ b/ui-ngx/src/app/shared/pipe/public-api.ts @@ -23,3 +23,4 @@ export * from './nospace.pipe'; export * from './truncate.pipe'; export * from './file-size.pipe'; export * from './selectable-columns.pipe'; +export * from './image.pipe'; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 042e453fe1..f1302e0b24 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -200,6 +200,7 @@ import { ColorPickerPanelComponent } from '@shared/components/color-picker/color import { TbIconComponent } from '@shared/components/icon.component'; import { HintTooltipIconComponent } from '@shared/components/hint-tooltip-icon.component'; import { StringAutocompleteComponent } from '@shared/components/string-autocomplete.component'; +import { ImagePipe } from '@shared/pipe/image.pipe'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -217,6 +218,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) DateAgoPipe, SafePipe, ShortNumberPipe, + ImagePipe, { provide: FlowInjectionToken, useValue: Flow @@ -340,6 +342,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TbJsonPipe, FileSizePipe, DateAgoPipe, + ImagePipe, SafePipe, ShortNumberPipe, SelectableColumnsPipe, @@ -576,6 +579,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) KeyboardShortcutPipe, FileSizePipe, DateAgoPipe, + ImagePipe, SafePipe, ShortNumberPipe, SelectableColumnsPipe,