Browse Source

Merge pull request #14153 from mtsymbarov-del/fix/camera-widget

Added ability to save pictures from Photo Camera widget to Image library
pull/14268/head
Igor Kulikov 9 months ago
committed by GitHub
parent
commit
aa017696d5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      application/src/main/data/json/system/widget_types/photo_camera_input.json
  2. 2
      ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html
  3. 105
      ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts
  4. 84
      ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.html
  5. 27
      ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts
  6. 13
      ui-ngx/src/assets/locale/locale.constant-en_US.json

2
application/src/main/data/json/system/widget_types/photo_camera_input.json

@ -15,7 +15,7 @@
"settingsSchema": "", "settingsSchema": "",
"dataKeySettingsSchema": "{}\n", "dataKeySettingsSchema": "{}\n",
"settingsDirective": "tb-photo-camera-input-widget-settings", "settingsDirective": "tb-photo-camera-input-widget-settings",
"defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Photo camera input\",\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"widgetTitle\":\"\",\"saveToGallery\":true,\"usePublicGalleryLink\":false,\"imageFormat\":\"image/png\",\"imageQuality\":0.92,\"maxWidth\":640,\"maxHeight\":480},\"title\":\"Photo camera input\",\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false,\"actions\":{}}"
}, },
"resources": [ "resources": [
{ {

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 }}

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

@ -19,29 +19,30 @@ import {
ElementRef, ElementRef,
Inject, Inject,
Input, Input,
NgZone,
OnDestroy, OnDestroy,
OnInit, OnInit,
ViewChild, ViewChild,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import { PageComponent } from '@shared/components/page.component'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { WidgetContext } from '@home/models/widget-component.models'; import { ImageService } from '@app/core/public-api';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { Overlay } from '@angular/cdk/overlay'; import { AttributeService } from '@core/http/attribute.service';
import { UtilsService } from '@core/services/utils.service'; import { UtilsService } from '@core/services/utils.service';
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 { isString } from '@core/utils';
import { WidgetContext } from '@home/models/widget-component.models';
import { Store } from '@ngrx/store';
import { PageComponent } from '@shared/components/page.component';
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 { Datasource, DatasourceData, DatasourceType } from '@shared/models/widget.models';
import { isString } from '@core/utils'; import { map, Observable, of, switchMap } from 'rxjs';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
interface PhotoCameraInputWidgetSettings { interface PhotoCameraInputWidgetSettings {
widgetTitle: string; widgetTitle: string;
saveToGallery: boolean;
usePublicGalleryLink: 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,36 @@ 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; this.previewPhoto = this.canvasElement.toDataURL(this.mimeType, this.quality);
const quality: number = this.settings.imageQuality ? this.settings.imageQuality : PhotoCameraInputWidgetComponent.DEFAULT_IMAGE_QUALITY;
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.usePublicGalleryLink ? 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="usePublicGalleryLink">
{{ '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>

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

@ -15,10 +15,10 @@
/// ///
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { Store } from '@ngrx/store';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
@Component({ @Component({
selector: 'tb-photo-camera-input-widget-settings', selector: 'tb-photo-camera-input-widget-settings',
@ -42,6 +42,8 @@ export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsCompo
return { return {
widgetTitle: '', widgetTitle: '',
saveToGallery: false,
usePublicGalleryLink: true,
imageFormat: 'image/png', imageFormat: 'image/png',
imageQuality: 0.92, imageQuality: 0.92,
maxWidth: 640, maxWidth: 640,
@ -57,11 +59,28 @@ export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsCompo
widgetTitle: [settings.widgetTitle, []], widgetTitle: [settings.widgetTitle, []],
// Image settings // Image settings
saveToGallery: [settings.saveToGallery],
usePublicGalleryLink: [settings.usePublicGalleryLink],
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,
usePublicGalleryLink: settings.usePublicGalleryLink ?? false,
imageQuality: settings.imageQuality * 100
}
}
protected prepareOutputSettings(settings: WidgetSettings): WidgetSettings {
return {
...settings,
imageQuality: settings.imageQuality / 100
}
}
} }

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

@ -8101,14 +8101,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",
@ -8189,7 +8189,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