Browse Source

added save to image gallery

pull/14153/head
Maksym Tsymbarov 12 months ago
parent
commit
04fcbd36dc
  1. 2
      ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html
  2. 94
      ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts
  3. 84
      ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.html
  4. 24
      ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts
  5. 13
      ui-ngx/src/assets/locale/locale.constant-en_US.json

2
ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html

@ -20,7 +20,7 @@
<div [class.!hidden]="isShowCamera" class="flex size-full flex-col items-center justify-between"> <div [class.!hidden]="isShowCamera" class="flex size-full flex-col items-center justify-between">
<div class="tb-web-camera__last-photo flex-1"> <div class="tb-web-camera__last-photo flex-1">
<span [class.!hidden]="lastPhoto" class="tb-web-camera__last-photo_text" translate>widgets.input-widgets.no-image</span> <span [class.!hidden]="lastPhoto" class="tb-web-camera__last-photo_text" translate>widgets.input-widgets.no-image</span>
<img [class.!hidden]="!lastPhoto" class="tb-web-camera__last-photo_img" [src]="lastPhoto" alt="last photo"/> <img [class.!hidden]="!lastPhoto" class="tb-web-camera__last-photo_img" [src]="lastPhoto | image | async" alt="last photo"/>
</div> </div>
<button mat-raised-button color="primary" (click)="takePhoto()" *ngIf="!textMessage"> <button mat-raised-button color="primary" (click)="takePhoto()" *ngIf="!textMessage">
{{ "widgets.input-widgets.take-photo" | translate }} {{ "widgets.input-widgets.take-photo" | translate }}

94
ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts

@ -19,7 +19,6 @@ import {
ElementRef, ElementRef,
Inject, Inject,
Input, Input,
NgZone,
OnDestroy, OnDestroy,
OnInit, OnInit,
ViewChild, ViewChild,
@ -29,19 +28,21 @@ import { PageComponent } from '@shared/components/page.component';
import { WidgetContext } from '@home/models/widget-component.models'; import { WidgetContext } from '@home/models/widget-component.models';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { Overlay } from '@angular/cdk/overlay';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
import { Datasource, DatasourceData, DatasourceType } from '@shared/models/widget.models'; import { Datasource, DatasourceData, DatasourceType } from '@shared/models/widget.models';
import { WINDOW } from '@core/services/window.service'; import { WINDOW } from '@core/services/window.service';
import { AttributeService } from '@core/http/attribute.service'; import { AttributeService } from '@core/http/attribute.service';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { Observable } from 'rxjs'; import { map, Observable, of, switchMap, tap } from 'rxjs';
import { isString } from '@core/utils'; import { isFile, isString } from '@core/utils';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { ImageService } from '@app/core/public-api';
interface PhotoCameraInputWidgetSettings { interface PhotoCameraInputWidgetSettings {
widgetTitle: string; widgetTitle: string;
saveToGallery: boolean;
imageVisibility: boolean;
imageQuality: number; imageQuality: number;
imageFormat: string; imageFormat: string;
maxWidth: number; maxWidth: number;
@ -59,9 +60,7 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
constructor(@Inject(WINDOW) private window: Window, constructor(@Inject(WINDOW) private window: Window,
protected store: Store<AppState>, protected store: Store<AppState>,
private elementRef: ElementRef, private imageService: ImageService,
private ngZone: NgZone,
private overlay: Overlay,
private utils: UtilsService, private utils: UtilsService,
private attributeService: AttributeService, private attributeService: AttributeService,
private sanitizer: DomSanitizer private sanitizer: DomSanitizer
@ -119,6 +118,9 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
lastPhoto: SafeUrl; lastPhoto: SafeUrl;
datasourceDetected = false; datasourceDetected = false;
private mimeType: string;
private quality: number;
private static hasGetUserMedia(): boolean { private static hasGetUserMedia(): boolean {
return !!(window.navigator.mediaDevices && window.navigator.mediaDevices.getUserMedia); return !!(window.navigator.mediaDevices && window.navigator.mediaDevices.getUserMedia);
} }
@ -160,6 +162,9 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
this.dataKeyDetected = true; this.dataKeyDetected = true;
} }
} }
this.mimeType = this.settings.imageFormat || PhotoCameraInputWidgetComponent.DEFAULT_IMAGE_TYPE;
this.quality = this.settings.imageQuality || PhotoCameraInputWidgetComponent.DEFAULT_IMAGE_QUALITY;
this.detectAvailableDevices(); this.detectAvailableDevices();
} }
@ -169,8 +174,8 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
private updateWidgetData(data: Array<DatasourceData>) { private updateWidgetData(data: Array<DatasourceData>) {
const keyData = data[0].data; const keyData = data[0].data;
if (keyData?.length && isString(keyData[0][1]) && keyData[0][1].startsWith('data:image/')) { if (keyData?.length && isString(keyData[0][1])) {
this.lastPhoto = this.sanitizer.bypassSecurityTrustUrl(keyData[0][1]); this.lastPhoto = keyData[0][1].startsWith('data:image/') ? this.sanitizer.bypassSecurityTrustUrl(keyData[0][1]) : keyData[0][1];
} }
} }
@ -237,26 +242,34 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
savePhoto() { savePhoto() {
this.updatePhoto = true; this.updatePhoto = true;
let task: Observable<any>;
const entityId: EntityId = { const entityId: EntityId = {
entityType: this.datasource.entityType, entityType: this.datasource.entityType,
id: this.datasource.entityId id: this.datasource.entityId
}; };
const saveData = [{
key: this.datasource.dataKeys[0].name, const uploadImage$ = this.settings.saveToGallery ? this.saveImageToGallery() : of(this.previewPhoto);
value: this.previewPhoto
}]; uploadImage$.pipe(
if (this.datasource.dataKeys[0].type === DataKeyType.attribute) { switchMap((image) => {
task = this.attributeService.saveEntityAttributes(entityId, AttributeScope.SERVER_SCOPE, saveData); const saveData = [{
} else if (this.datasource.dataKeys[0].type === DataKeyType.timeseries) { key: this.datasource.dataKeys[0].name,
task = this.attributeService.saveEntityTimeseries(entityId, 'scope', saveData); value: image
} }];
task.subscribe(() => { if (this.datasource.dataKeys[0].type === DataKeyType.attribute) {
this.isPreviewPhoto = false; return this.attributeService.saveEntityAttributes(entityId, AttributeScope.SERVER_SCOPE, saveData);
this.updatePhoto = false; } else if (this.datasource.dataKeys[0].type === DataKeyType.timeseries) {
this.closeCamera(); return this.attributeService.saveEntityTimeseries(entityId, 'scope', saveData);
}, () => { }
this.updatePhoto = false; })
).subscribe({
next: () => {
this.isPreviewPhoto = false;
this.updatePhoto = false;
this.closeCamera();
},
error: () => {
this.updatePhoto = false;
}
}); });
} }
@ -271,12 +284,37 @@ export class PhotoCameraInputWidgetComponent extends PageComponent implements On
this.canvasElement.height = this.videoHeight; this.canvasElement.height = this.videoHeight;
this.canvasElement.getContext('2d').drawImage(this.videoElement, 0, 0, this.videoWidth, this.videoHeight); this.canvasElement.getContext('2d').drawImage(this.videoElement, 0, 0, this.videoWidth, this.videoHeight);
const mimeType: string = this.settings.imageFormat ? this.settings.imageFormat : PhotoCameraInputWidgetComponent.DEFAULT_IMAGE_TYPE; const previewDataUrl = this.canvasElement.toDataURL(this.mimeType, this.quality);
const quality: number = this.settings.imageQuality ? this.settings.imageQuality : PhotoCameraInputWidgetComponent.DEFAULT_IMAGE_QUALITY; this.previewPhoto = previewDataUrl;
this.previewPhoto = this.canvasElement.toDataURL(mimeType, quality);
this.isPreviewPhoto = true; this.isPreviewPhoto = true;
} }
private saveImageToGallery(): Observable<string> {
return new Observable<Blob>((observer) => {
this.canvasElement.toBlob(
(blob) => {
if (blob) {
observer.next(blob);
observer.complete();
} else {
observer.error('Failed to create image blob');
}
},
this.mimeType,
this.quality
);
}).pipe(
switchMap((blob) => {
const fileName = this.datasource.dataKeys[0].name;
const file = new File([blob], fileName, { type: this.mimeType });
return this.imageService.uploadImage(file, fileName);
}),
map((imageInfo) =>
this.settings.imageVisibility ? imageInfo.publicLink : imageInfo.link
)
);
}
private inititedVideoStream(deviceId?: string, init = false) { private inititedVideoStream(deviceId?: string, init = false) {
if (window.navigator.mediaDevices && window.navigator.mediaDevices.getUserMedia) { if (window.navigator.mediaDevices && window.navigator.mediaDevices.getUserMedia) {
const videoTrackConstraints = { const videoTrackConstraints = {

84
ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.html

@ -15,19 +15,32 @@
limitations under the License. limitations under the License.
--> -->
<section class="tb-widget-settings" [formGroup]="photoCameraInputWidgetSettingsForm"> <section class="flex flex-col gap-4" [formGroup]="photoCameraInputWidgetSettingsForm">
<fieldset class="fields-group"> <div class="tb-form-panel">
<legend class="group-title" translate>widgets.input-widgets.general-settings</legend> <div class="tb-form-panel-title" translate>widgets.input-widgets.general-settings</div>
<mat-form-field class="mat-block flex-1"> <div class="tb-form-row space-between">
<mat-label translate>widgets.input-widgets.widget-title</mat-label> <div class="fixed-title-width" translate>widgets.input-widgets.widget-title</div>
<input matInput formControlName="widgetTitle"> <mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
</mat-form-field> <input matInput formControlName="widgetTitle" placeholder="{{ 'widget-config.set' | translate }}">
</fieldset> </mat-form-field>
<fieldset class="fields-group"> </div>
<legend class="group-title" translate>widgets.input-widgets.image-settings</legend> </div>
<section class="flex flex-col gt-xs:flex-row gt-xs:items-center gt-xs:justify-start gt-xs:gap-2"> <div class="tb-form-panel">
<mat-form-field class="mat-block flex-1"> <div class="tb-form-panel-title" translate>widgets.input-widgets.save-image</div>
<mat-label translate>widgets.input-widgets.image-format</mat-label> <mat-slide-toggle class="mat-slide" formControlName="saveToGallery">
{{ 'widgets.input-widgets.save-to-gallery' | translate }}
</mat-slide-toggle>
@if (photoCameraInputWidgetSettingsForm.get('saveToGallery').value) {
<mat-slide-toggle class="mat-slide" formControlName="imageVisibility">
{{ 'widgets.input-widgets.public-image' | translate }}
</mat-slide-toggle>
}
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.input-widgets.image-settings</div>
<div class="tb-form-row space-between">
<div translate>widgets.input-widgets.image-format</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="imageFormat"> <mat-select formControlName="imageFormat">
<mat-option [value]="'image/jpeg'"> <mat-option [value]="'image/jpeg'">
{{ 'widgets.input-widgets.image-format-jpeg' | translate }} {{ 'widgets.input-widgets.image-format-jpeg' | translate }}
@ -40,20 +53,33 @@
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field class="mat-block flex-1"> </div>
<mat-label translate>widgets.input-widgets.image-quality</mat-label>
<input matInput type="number" min="0" max="1" formControlName="imageQuality"> @if (photoCameraInputWidgetSettingsForm.get('imageFormat').value !== 'image/png') {
</mat-form-field> <div class="tb-form-row space-between">
</section> <div translate>widgets.input-widgets.image-quality</div>
<section class="flex flex-col gt-xs:flex-row gt-xs:items-center gt-xs:justify-start gt-xs:gap-2"> <mat-form-field class="number" appearance="outline" subscriptSizing="dynamic">
<mat-form-field class="mat-block flex-1"> <input matInput type="number" min="0" max="100" formControlName="imageQuality">
<mat-label translate>widgets.input-widgets.max-image-width</mat-label> <span matSuffix>%</span>
<input matInput type="number" min="1" formControlName="maxWidth"> </mat-form-field>
</mat-form-field> </div>
<mat-form-field class="mat-block flex-1"> }
<mat-label translate>widgets.input-widgets.max-image-height</mat-label>
<input matInput type="number" min="1" formControlName="maxHeight"> <div class="tb-form-row space-between column-xs">
</mat-form-field> <div class="fixed-title-width !min-w-30">Size</div>
</section> <div class="flex flex-row items-center justify-star gap-2">
</fieldset> <div class="tb-small-label" translate>widgets.input-widgets.max-image-width</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput type="number" min="1" formControlName="maxWidth">
<span matSuffix>px</span>
</mat-form-field>
<mat-divider vertical class="xs:hidden"></mat-divider>
<div class="tb-small-label" translate>widgets.input-widgets.max-image-height</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput type="number" min="1" formControlName="maxHeight">
<span matSuffix>px</span>
</mat-form-field>
</div>
</div>
</div>
</section> </section>

24
ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts

@ -19,6 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { deepClone } from '@app/core/utils';
@Component({ @Component({
selector: 'tb-photo-camera-input-widget-settings', selector: 'tb-photo-camera-input-widget-settings',
@ -42,6 +43,8 @@ export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsCompo
return { return {
widgetTitle: '', widgetTitle: '',
saveToGallery: false,
imageVisibility: true,
imageFormat: 'image/png', imageFormat: 'image/png',
imageQuality: 0.92, imageQuality: 0.92,
maxWidth: 640, maxWidth: 640,
@ -57,11 +60,28 @@ export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsCompo
widgetTitle: [settings.widgetTitle, []], widgetTitle: [settings.widgetTitle, []],
// Image settings // Image settings
saveToGallery: [settings.saveToGallery],
imageVisibility: [settings.imageVisibility],
imageFormat: [settings.imageFormat, []], imageFormat: [settings.imageFormat, []],
imageQuality: [settings.imageQuality, [Validators.min(0), Validators.max(1)]], imageQuality: [settings.imageQuality, [Validators.min(0), Validators.max(100)]],
maxWidth: [settings.maxWidth, [Validators.min(1)]], maxWidth: [settings.maxWidth, [Validators.min(1)]],
maxHeight: [settings.maxHeight, [Validators.min(1)]] maxHeight: [settings.maxHeight, [Validators.min(1)]]
}); });
} }
protected prepareInputSettings(settings: WidgetSettings): WidgetSettings {
return {
...settings,
saveToGallery: settings.saveToGallery || false,
imageQuality: settings.imageQuality * 100
}
}
protected prepareOutputSettings(settings: WidgetSettings): WidgetSettings {
return {
...settings,
saveToGallery: settings.saveToGallery || false,
imageQuality: settings.imageQuality / 100
}
}
} }

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

@ -8049,14 +8049,14 @@
"attribute-scope-server": "Server attribute", "attribute-scope-server": "Server attribute",
"attribute-scope-shared": "Shared attribute", "attribute-scope-shared": "Shared attribute",
"value-required": "Value required", "value-required": "Value required",
"image-settings": "Image settings", "image-settings": "Image output settings",
"image-format": "Image format", "image-format": "Image format",
"image-format-jpeg": "JPEG", "image-format-jpeg": "JPEG",
"image-format-png": "PNG", "image-format-png": "PNG",
"image-format-webp": "WEBP", "image-format-webp": "WEBP",
"image-quality": "Image quality that use lossy compression such as jpeg and webp", "image-quality": "Image quality",
"max-image-width": "Maximum image width", "max-image-width": "Max width",
"max-image-height": "Maximum image height", "max-image-height": "Max height",
"action-buttons": "Action buttons", "action-buttons": "Action buttons",
"show-action-buttons": "Show action buttons", "show-action-buttons": "Show action buttons",
"update-all-values": "Update all values, not only modified", "update-all-values": "Update all values, not only modified",
@ -8137,7 +8137,10 @@
"add-radio-option": "Add radio option", "add-radio-option": "Add radio option",
"radio-label-position": "Label position", "radio-label-position": "Label position",
"radio-label-position-before": "Before", "radio-label-position-before": "Before",
"radio-label-position-after": "After" "radio-label-position-after": "After",
"save-image": "Save image",
"save-to-gallery": "Automatically store captured images in Image Gallery",
"public-image": "Makes image avaliable for any unauthorized user"
}, },
"invalid-qr-code-text": "Invalid input text for QR code. Input should have a string type", "invalid-qr-code-text": "Invalid input text for QR code. Input should have a string type",
"qr-code": { "qr-code": {

Loading…
Cancel
Save