Browse Source

UI: Add Embed Image dialog.

pull/9846/head
Igor Kulikov 2 years ago
parent
commit
562838cfdb
  1. 16
      common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java
  2. 4
      dao/src/main/java/org/thingsboard/server/dao/resource/ImageCacheKey.java
  3. 10
      ui-ngx/src/app/core/http/image.service.ts
  4. 60
      ui-ngx/src/app/shared/components/image/embed-image-dialog.component.html
  5. 71
      ui-ngx/src/app/shared/components/image/embed-image-dialog.component.scss
  6. 94
      ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts
  7. 19
      ui-ngx/src/app/shared/components/image/image-dialog.component.html
  8. 33
      ui-ngx/src/app/shared/components/image/image-dialog.component.ts
  9. 22
      ui-ngx/src/app/shared/components/image/image-gallery.component.html
  10. 20
      ui-ngx/src/app/shared/components/image/image-gallery.component.ts
  11. 7
      ui-ngx/src/app/shared/models/resource.models.ts
  12. 7
      ui-ngx/src/app/shared/shared.module.ts
  13. 12
      ui-ngx/src/assets/locale/locale.constant-en_US.json

16
common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java

@ -113,12 +113,16 @@ public class TbResourceInfo extends BaseData<TbResourceId> implements HasName, H
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
public String getLink() {
if (resourceType == ResourceType.IMAGE) {
if (isPublic) {
return "/api/images/public/" + getPublicResourceKey();
} else {
String type = (tenantId != null && tenantId.isSysTenantId()) ? "system" : "tenant"; // tenantId is null in case of export to git
return "/api/images/" + type + "/" + resourceKey;
}
String type = (tenantId != null && tenantId.isSysTenantId()) ? "system" : "tenant"; // tenantId is null in case of export to git
return "/api/images/" + type + "/" + resourceKey;
}
return null;
}
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
public String getPublicLink() {
if (resourceType == ResourceType.IMAGE && isPublic) {
return "/api/images/public/" + getPublicResourceKey();
}
return null;
}

4
dao/src/main/java/org/thingsboard/server/dao/resource/ImageCacheKey.java

@ -55,4 +55,8 @@ public class ImageCacheKey {
return msg.build();
}
public boolean isPublic() {
return this.publicResourceKey != null;
}
}

10
ui-ngx/src/app/core/http/image.service.ts

@ -41,13 +41,14 @@ export class ImageService {
) {
}
public uploadImage(file: File, title: string, config?: RequestConfig): Observable<ImageResourceInfo> {
public uploadImage(file: File, title: string, isPublic = true, config?: RequestConfig): Observable<ImageResourceInfo> {
if (!config) {
config = {};
}
const formData = new FormData();
formData.append('file', file);
formData.append('title', title);
formData.append('isPublic', isPublic ? 'true' : 'false');
return this.http.post<ImageResourceInfo>('/api/image', formData,
defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest));
}
@ -69,6 +70,13 @@ export class ImageService {
imageInfo, defaultHttpOptionsFromConfig(config));
}
public updateImagePublicStatus(imageInfo: ImageResourceInfo, isPublic: boolean, config?: RequestConfig): Observable<ImageResourceInfo> {
const type = imageResourceType(imageInfo);
const key = encodeURIComponent(imageInfo.resourceKey);
return this.http.put<ImageResourceInfo>(`${IMAGES_URL_PREFIX}/${type}/${key}/public/${isPublic}`,
imageInfo, defaultHttpOptionsFromConfig(config));
}
public getImages(pageLink: PageLink, includeSystemImages = false, config?: RequestConfig): Observable<PageData<ImageResourceInfo>> {
return this.http.get<PageData<ImageResourceInfo>>(
`${IMAGES_URL_PREFIX}${pageLink.toQuery()}&includeSystemImages=${includeSystemImages}`,

60
ui-ngx/src/app/shared/components/image/embed-image-dialog.component.html

@ -0,0 +1,60 @@
<!--
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.
-->
<mat-toolbar color="primary">
<h2>{{ 'image.embed-image' | translate }}</h2>
<span fxFlex></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<div class="tb-form-panel stroked tb-slide-toggle" *ngIf="!readonly || image.public">
<mat-expansion-panel class="tb-settings" [expanded]="image.public" disabled>
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<div *ngIf="!readonly" tb-hint-tooltip-icon="{{ 'image.embed-to-html-hint' | translate }}"
class="tb-form-row no-border no-padding">
<mat-slide-toggle class="mat-slide" [formControl]="publicStatusControl" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
<div class="tb-form-panel-title">{{ 'image.embed-to-html' | translate }}</div>
</mat-slide-toggle>
</div>
<div *ngIf="readonly" class="tb-form-panel-title" translate>image.embed-to-html</div>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-embed-image-text" [innerHTML]="'image.embed-to-html-text' | translate"></div>
<tb-markdown usePlainMarkdown
containerClass="tb-embed-image-code"
[data]="embedToHtmlCode()"></tb-markdown>
</ng-template>
</mat-expansion-panel>
</div>
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>image.embed-to-angular-template</div>
<div class="tb-embed-image-text" [innerHTML]="'image.embed-to-angular-template-text' | translate"></div>
<tb-markdown usePlainMarkdown
containerClass="tb-embed-image-code"
[data]="embedToAngularTemplateCode()"></tb-markdown>
</div>
</div>

71
ui-ngx/src/app/shared/components/image/embed-image-dialog.component.scss

@ -0,0 +1,71 @@
/**
* 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.
*/
:host {
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
.tb-embed-image-text {
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: rgba(0,0,0,0.54);
letter-spacing: 0.2px;
}
.tb-form-panel-title {
color: rgba(0, 0, 0, 0.87);
}
}
}
:host ::ng-deep {
.tb-markdown-view {
max-width: 700px;
.tb-embed-image-code {
.code-wrapper {
padding: 0;
pre[class*=language-] {
margin: 0;
padding: 9px 38px 9px 16px;
}
code[class*="language-"], pre[class*="language-"] {
font-size: 12px;
overflow: hidden;
white-space: normal;
word-break: break-word;
}
button.clipboard-btn {
right: 0;
height: 36px;
p, div {
background: transparent;
}
p {
margin: 0;
padding: 6px;
font-size: 14px;
}
div {
top: 0;
padding: 8px;
height: 38px;
width: 38px;
}
}
}
}
}
}

94
ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts

@ -0,0 +1,94 @@
///
/// 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 { ImageResourceInfo } from '@shared/models/resource.models';
import { Component, Inject, OnInit } from '@angular/core';
import { DialogComponent } from '@shared/components/dialog.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { ImageService } from '@core/http/image.service';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormControl, UntypedFormBuilder } from '@angular/forms';
export interface EmbedImageDialogData {
readonly: boolean;
image: ImageResourceInfo;
}
@Component({
selector: 'tb-embed-image-dialog',
templateUrl: './embed-image-dialog.component.html',
styleUrls: ['./embed-image-dialog.component.scss']
})
export class EmbedImageDialogComponent extends
DialogComponent<EmbedImageDialogComponent, ImageResourceInfo> implements OnInit {
image = this.data.image;
readonly = this.data.readonly;
imageChanged = false;
publicStatusControl = new FormControl(this.image.public);
constructor(protected store: Store<AppState>,
protected router: Router,
private imageService: ImageService,
@Inject(MAT_DIALOG_DATA) private data: EmbedImageDialogData,
public dialogRef: MatDialogRef<EmbedImageDialogComponent, ImageResourceInfo>,
public fb: UntypedFormBuilder) {
super(store, router, dialogRef);
}
ngOnInit(): void {
if (!this.readonly) {
this.publicStatusControl.valueChanges.subscribe(
(isPublic) => {
this.updateImagePublicStatus(isPublic);
}
);
}
}
cancel(): void {
this.dialogRef.close(this.imageChanged ? this.image : null);
}
embedToHtmlCode(): string {
return '```html\n' +
'<img src="'+this.image.publicLink+'" alt="'+this.image.title.replace(/"/g, '&quot;')+'" />' +
'{:copy-code}\n' +
'```';
}
embedToAngularTemplateCode(): string {
return '```html\n' +
'<img [src]="\''+this.image.link+'\' | image | async" />' +
'{:copy-code}\n' +
'```';
}
private updateImagePublicStatus(isPublic: boolean): void {
this.imageService.updateImagePublicStatus(this.image, isPublic).subscribe(
(image) => {
this.image = image;
this.imageChanged = true;
}
);
}
}

19
ui-ngx/src/app/shared/components/image/image-dialog.component.html

@ -56,18 +56,6 @@
<mat-icon>check</mat-icon>
</button>
</mat-form-field>
<mat-form-field>
<mat-label translate>image.link</mat-label>
<input matInput formControlName="link">
<tb-copy-button
matSuffix
miniButton="false"
[copyText]="imageFormGroup.get('link').value"
tooltipText="{{ 'image.copy-image-link' | translate }}"
tooltipPosition="above"
icon="content_copy">
</tb-copy-button>
</mat-form-field>
<div class="tb-image-container tb-primary-fill">
<div class="tb-image-content">
<div class="tb-image-actions">
@ -86,6 +74,13 @@
(click)="exportImage($event)">
<tb-icon>mdi:file-export</tb-icon>
</button>
<button type="button"
mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'image.embed-image' | translate }}"
matTooltipPosition="above"
(click)="embedImage($event)">
<mat-icon>code</mat-icon>
</button>
</div>
<button *ngIf="!readonly"
type="button"

33
ui-ngx/src/app/shared/components/image/image-dialog.component.ts

@ -30,6 +30,7 @@ import {
} from '@shared/components/image/upload-image-dialog.component';
import { UrlHolder } from '@shared/pipe/image.pipe';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { EmbedImageDialogComponent, EmbedImageDialogData } from '@shared/components/image/embed-image-dialog.component';
export interface ImageDialogData {
readonly: boolean;
@ -66,19 +67,16 @@ export class ImageDialogComponent extends
this.image = data.image;
this.readonly = data.readonly;
this.imagePreviewData = {
url: this.image.link
url: this.image.public ? this.image.publicLink : this.image.link
};
}
ngOnInit(): void {
this.imageFormGroup = this.fb.group({
title: [this.image.title, [Validators.required]],
link: [this.image.link, []],
title: [this.image.title, [Validators.required]]
});
if (this.data.readonly) {
this.imageFormGroup.disable();
} else {
this.imageFormGroup.get('link').disable();
}
}
@ -117,6 +115,29 @@ export class ImageDialogComponent extends
this.importExportService.exportImage(imageResourceType(this.image), this.image.resourceKey);
}
embedImage($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.dialog.open<EmbedImageDialogComponent, EmbedImageDialogData,
ImageResourceInfo>(EmbedImageDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
image: this.image,
readonly: this.readonly
}
}).afterClosed().subscribe((result) => {
if (result) {
this.imageChanged = true;
this.image = result;
this.imagePreviewData = {
url: this.image.public ? this.image.publicLink : this.image.link
};
}
});
}
updateImage($event): void {
if ($event) {
$event.stopPropagation();
@ -133,7 +154,7 @@ export class ImageDialogComponent extends
this.imageChanged = true;
this.image = result;
this.imagePreviewData = {
url: this.image.link
url: this.image.public ? this.image.publicLink : this.image.link
};
}
});

22
ui-ngx/src/app/shared/components/image/image-gallery.component.html

@ -185,9 +185,9 @@
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef [ngStyle.gt-md]="{ minWidth: '192px', maxWidth: '192px', width: '192px' }">
<mat-header-cell *matHeaderCellDef [ngStyle.gt-md]="{ minWidth: '240px', maxWidth: '240px', width: '240px' }">
</mat-header-cell>
<mat-cell *matCellDef="let image" [ngStyle.gt-md]="{ minWidth: '192px', maxWidth: '192px', width: '192px' }">
<mat-cell *matCellDef="let image" [ngStyle.gt-md]="{ minWidth: '240px', maxWidth: '240px', width: '240px' }">
<div fxHide fxShow.gt-md fxFlex fxLayout="row" fxLayoutAlign="end">
<button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'image.download-image' | translate }}"
@ -201,6 +201,12 @@
(click)="exportImage($event, image)">
<tb-icon>mdi:file-export</tb-icon>
</button>
<button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'image.embed-image' | translate }}"
matTooltipPosition="above"
(click)="embedImage($event, image)">
<mat-icon>code</mat-icon>
</button>
<button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ (readonly(image) ? 'image.image-details' : 'image.edit-image') | translate }}"
matTooltipPosition="above"
@ -233,6 +239,12 @@
<tb-icon matMenuItemIcon>mdi:file-export</tb-icon>
<span translate>image.export-image</span>
</button>
<button mat-menu-item
[disabled]="isLoading$ | async"
(click)="embedImage($event, image)">
<mat-icon>code</mat-icon>
<span>{{ 'image.embed-image' | translate }}</span>
</button>
<button mat-menu-item
[disabled]="isLoading$ | async"
(click)="editImage($event, image)">
@ -323,6 +335,12 @@
(click)="exportImage($event, item)">
<tb-icon>mdi:file-export</tb-icon>
</button>
<button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'image.embed-image' | translate }}"
matTooltipPosition="above"
(click)="embedImage($event, item, itemIndex)">
<mat-icon>code</mat-icon>
</button>
<button *ngIf="deleteEnabled(item)"
mat-icon-button [disabled]="(isLoading$ | async)"
matTooltip="{{ 'image.delete-image' | translate }}"

20
ui-ngx/src/app/shared/components/image/image-gallery.component.ts

@ -68,6 +68,7 @@ import {
ImagesInUseDialogData
} from '@shared/components/image/images-in-use-dialog.component';
import { ImagesDatasource } from '@shared/components/image/images-datasource';
import { EmbedImageDialogComponent, EmbedImageDialogData } from '@shared/components/image/embed-image-dialog.component';
interface GridImagesFilter {
search: string;
@ -658,6 +659,25 @@ export class ImageGalleryComponent extends PageComponent implements OnInit, OnDe
});
}
embedImage($event: Event, image: ImageResourceInfo, itemIndex = -1) {
if ($event) {
$event.stopPropagation();
}
this.dialog.open<EmbedImageDialogComponent, EmbedImageDialogData,
ImageResourceInfo>(EmbedImageDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
image,
readonly: this.readonly(image)
}
}).afterClosed().subscribe((result) => {
if (result) {
this.imageUpdated(result, itemIndex);
}
});
}
protected updatedRouterParamsAndData(queryParams: object, queryParamsHandling: QueryParamsHandling = 'merge') {
if (this.pageMode) {
this.router.navigate([], {

7
ui-ngx/src/app/shared/models/resource.models.ts

@ -60,6 +60,8 @@ export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' |
title?: string;
resourceType: ResourceType;
fileName: string;
public: boolean;
publicResourceKey?: string;
descriptor?: D;
}
@ -81,6 +83,7 @@ export interface ImageDescriptor {
export interface ImageResourceInfo extends TbResourceInfo<ImageDescriptor> {
link?: string;
publicLink?: string;
}
export interface ImageExportData {
@ -88,6 +91,8 @@ export interface ImageExportData {
fileName: string;
title: string;
resourceKey: string;
public: boolean;
publicResourceKey: string;
data: string;
}
@ -129,6 +134,8 @@ export const TB_IMAGE_PREFIX = 'tb-image;';
export const IMAGES_URL_REGEXP = /\/api\/images\/(tenant|system)\/(.*)/;
export const IMAGES_URL_PREFIX = '/api/images';
export const PUBLIC_IMAGES_URL_PREFIX = '/api/images/public';
export const IMAGE_BASE64_URL_PREFIX = 'data:image/';
export const removeTbImagePrefix = (url: string): string => url ? url.replace(TB_IMAGE_PREFIX, '') : url;

7
ui-ngx/src/app/shared/shared.module.ts

@ -214,6 +214,7 @@ import { ImageReferencesComponent } from '@shared/components/image/image-referen
import { ImagesInUseDialogComponent } from '@shared/components/image/images-in-use-dialog.component';
import { GalleryImageInputComponent } from '@shared/components/image/gallery-image-input.component';
import { MultipleGalleryImageInputComponent } from '@shared/components/image/multiple-gallery-image-input.component';
import { EmbedImageDialogComponent } from '@shared/components/image/embed-image-dialog.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -408,7 +409,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ImageReferencesComponent,
ImagesInUseDialogComponent,
GalleryImageInputComponent,
MultipleGalleryImageInputComponent
MultipleGalleryImageInputComponent,
EmbedImageDialogComponent
],
imports: [
CommonModule,
@ -657,7 +659,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ImageReferencesComponent,
ImagesInUseDialogComponent,
GalleryImageInputComponent,
MultipleGalleryImageInputComponent
MultipleGalleryImageInputComponent,
EmbedImageDialogComponent
]
})
export class SharedModule { }

12
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -2963,8 +2963,8 @@
"size": "Size",
"system": "System",
"download-image": "Download image",
"export-image": "Export image",
"import-image": "Import image",
"export-image": "Export image to JSON",
"import-image": "Import image from JSON",
"upload-image": "Upload image",
"edit-image": "Edit image",
"image-details": "Image details",
@ -2997,7 +2997,13 @@
"set-link": "Set link",
"image-link": "Image link",
"link": "Link",
"copy-image-link": "Copy image link"
"copy-image-link": "Copy image link",
"embed-image": "Embed image",
"embed-to-html": "Embed to HTML",
"embed-to-html-hint": "This feature will make link available to any unauthorized user.",
"embed-to-html-text": "Using the following code snippet, you may embed an image into the components based on the plain HTML.<br/>Such components include HTML card widgets, cell content functions, etc.",
"embed-to-angular-template": "Embed to Angular HTML template",
"embed-to-angular-template-text": "Using the following code snippet, you may embed an image into the Angular HTML template.<br/>Such components include the Markdown widget, HTML section in the widget editor, custom actions, etc."
},
"image-input": {
"drop-images-or": "Drag and drop an images or",

Loading…
Cancel
Save