[3.0] Add inputs widgets (#2526)

* Add location widget

* Fix translate, clear code

* Fix translate

* Add date input widgets

* Add image input widgets

* Init web camera input widget

* Add functional web camera input widget

* Add styles to webcamera iputs widget

* Add link code
This commit is contained in:
Vladyslav 2020-03-20 16:23:49 +02:00 committed by GitHub
parent adc7194669
commit 1af55e8400
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 515 additions and 94 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,74 @@
<!--
Copyright © 2016-2020 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.
-->
<div fxLayout="column" fxLayoutAlign="center center" class="tb-web-camera" tb-fullscreen [fullscreen]="isShowCamera">
<div [fxShow]="isEntityDetected && dataKeyDetected && isCameraSupport && isDeviceDetect" fxFlexFill>
<div [fxShow]="!isShowCamera" fxLayout="column" fxLayoutAlign="space-between center" fxFlexFill>
<div class="tb-web-camera__last-photo" fxFlex>
<span [fxShow]="!lastPhoto" class="tb-web-camera__last-photo_text" translate>widgets.input-widgets.no-image</span>
<img [fxShow]="lastPhoto" class="tb-web-camera__last-photo_img" [src]="lastPhoto" alt="last photo"/>
</div>
<button mat-raised-button color="primary" (click)="takePhoto()">
{{ "widgets.input-widgets.take-photo" | translate }}
</button>
</div>
<div [fxShow]="isShowCamera" fxLayout="column" fxLayoutAlign="center center" class="camera-container">
<div class="camera" [fxShow]="!isPreviewPhoto">
<video autoplay muted playsinline class="camera-stream" #videoStream></video>
<div class="camera-controls" fxLayout="row wrap" fxLayoutAlign="space-between end">
<div fxFlex></div>
<button mat-mini-fab color="primary" (click)="switchWebCamera()" [disabled]="singleDevice">
<mat-icon>switch_camera</mat-icon>
</button>
<button mat-fab color="accent" (click)="createPhoto()">
<mat-icon>photo_camera</mat-icon>
</button>
<button mat-mini-fab color="primary" (click)="closeCamera()">
<mat-icon>close</mat-icon>
</button>
<div fxFlex></div>
</div>
</div>
<div class="camera" [fxShow]="isPreviewPhoto">
<img alt="preview photo" class="camera-stream" [src]="previewPhoto">
<canvas #canvas style="display:none;"></canvas>
<div class="camera-controls" fxLayout="row" fxLayoutAlign="space-between end">
<div fxFlex></div>
<button mat-fab color="primary" [disabled]="updatePhoto" (click)="cancelPhoto()">
<mat-icon>close</mat-icon>
</button>
<button mat-fab color="accent" [disabled]="updatePhoto" (click)="savePhoto()">
<mat-icon>check</mat-icon>
</button>
<div fxFlex></div>
</div>
</div>
</div>
</div>
<div class="message-text" [fxHide]="isEntityDetected">
{{ 'widgets.input-widgets.no-entity-selected' | translate }}
</div>
<div class="message-text" [fxShow]="isEntityDetected && !dataKeyDetected">
{{ 'widgets.input-widgets.no-datakey-selected' | translate }}
</div>
<div class="message-text" [fxShow]="isEntityDetected && dataKeyDetected && !isCameraSupport">
{{ 'widgets.input-widgets.no-support-web-camera' | translate }}
</div>
<div class="message-text" [fxShow]="isEntityDetected && dataKeyDetected && isCameraSupport && !isDeviceDetect">
{{ 'widgets.input-widgets.no-support-web-camera' | translate }}
</div>
</div>

View File

@ -0,0 +1,74 @@
/**
* Copyright © 2016-2020 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.
*/
.tb-web-camera {
height: 100%;
&__last-photo {
width: 100%;
margin: 5px 0;
text-align: center;
border: solid 1px;
&_text {
position: absolute;
top: 50%;
left: 50%;
margin-top: -.625em;
transform: translate(-50%, -50%);
}
&_img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.camera-container{
height: 100%;
}
.camera {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
.camera-stream {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.camera-controls {
position: absolute;
bottom: 0;
width: 100%;
padding: 0 5px 5px;
.mat-button-base{
margin: 6px 8px;
}
}
}
.message-text {
font-size: 18px;
color: #a0a0a0;
text-align: center;
}
}

View File

@ -0,0 +1,274 @@
///
/// Copyright © 2016-2020 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 {
Component,
ElementRef,
Inject,
Input,
NgZone,
OnDestroy,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { WidgetContext } from '@home/models/widget-component.models';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Overlay } from '@angular/cdk/overlay';
import { UtilsService } from '@core/services/utils.service';
import { Datasource, DatasourceData, DatasourceType } from '@shared/models/widget.models';
import { WINDOW } from '@core/services/window.service';
import { AttributeService } from '@core/http/attribute.service';
import { EntityId } from '@shared/models/id/entity-id';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { Observable } from 'rxjs';
interface WebCameraInputWidgetSettings {
widgetTitle: string;
imageQuality: number;
imageFormat: string;
maxWidth: number;
maxHeight: number;
}
@Component({
selector: 'tb-web-camera-widget',
templateUrl: './web-camera-input.component.html',
styleUrls: ['./web-camera-input.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class WebCameraInputWidgetComponent extends PageComponent implements OnInit, OnDestroy {
constructor(@Inject(WINDOW) private window: Window,
protected store: Store<AppState>,
private elementRef: ElementRef,
private ngZone: NgZone,
private overlay: Overlay,
private utils: UtilsService,
private attributeService: AttributeService,
) {
super(store);
}
public get videoElement() {
return this.videoStreamRef.nativeElement;
}
public get canvasElement() {
return this.canvasRef.nativeElement;
}
public get videoWidth() {
const videoRatio = this.getVideoAspectRatio();
return Math.min(this.width, this.height * videoRatio);
}
public get videoHeight() {
const videoRatio = this.getVideoAspectRatio();
return Math.min(this.height, this.width / videoRatio);
}
private static DEFAULT_IMAGE_TYPE = 'image/jpeg';
private static DEFAULT_IMAGE_QUALITY = 0.92;
@Input()
ctx: WidgetContext;
@ViewChild('videoStream', {static: true}) videoStreamRef: ElementRef<HTMLVideoElement>;
@ViewChild('canvas', {static: true}) canvasRef: ElementRef<HTMLCanvasElement>;
private videoInputsIndex = 0;
private settings: WebCameraInputWidgetSettings;
private datasource: Datasource;
private width = 640;
private height = 480;
private availableVideoInputs: MediaDeviceInfo[];
private mediaStream: MediaStream;
isEntityDetected = false;
dataKeyDetected = false;
isCameraSupport = false;
isDeviceDetect = false;
isShowCamera = false;
isPreviewPhoto = false;
singleDevice = true;
updatePhoto = false;
previewPhoto: any;
lastPhoto: any;
private static hasGetUserMedia(): boolean {
return !!(window.navigator.mediaDevices && window.navigator.mediaDevices.getUserMedia);
}
private static getAvailableVideoInputs(): Promise<MediaDeviceInfo[]> {
return new Promise((resolve, reject) => {
navigator.mediaDevices.enumerateDevices()
.then((devices: MediaDeviceInfo[]) => {
resolve(devices.filter((device: MediaDeviceInfo) => device.kind === 'videoinput'));
})
.catch(err => {
reject(err.message || err);
});
});
}
ngOnInit(): void {
this.ctx.$scope.webCameraInputWidget = this;
this.settings = this.ctx.settings;
this.datasource = this.ctx.datasources[0];
if (this.settings.widgetTitle && this.settings.widgetTitle.length) {
this.ctx.widgetTitle = this.utils.customTranslation(this.settings.widgetTitle, this.settings.widgetTitle);
} else {
this.ctx.widgetTitle = this.ctx.widgetConfig.title;
}
this.width = this.settings.maxWidth ? this.settings.maxWidth : 640;
this.height = this.settings.maxHeight ? this.settings.maxWidth : 480;
if (this.datasource.type === DatasourceType.entity) {
if (this.datasource.entityType && this.datasource.entityId) {
this.isEntityDetected = true;
}
}
if (this.datasource.dataKeys.length) {
this.dataKeyDetected = true;
}
this.detectAvailableDevices();
}
ngOnDestroy(): void {
this.stopMediaTracks();
}
private updateWidgetData(data: Array<DatasourceData>) {
const keyData = data[0].data;
if (keyData && keyData.length) {
this.lastPhoto = keyData[0][1];
}
}
public onDataUpdated() {
this.ngZone.run(() => {
this.updateWidgetData(this.ctx.defaultSubscription.data);
this.ctx.detectChanges();
});
}
private detectAvailableDevices(): void {
if (WebCameraInputWidgetComponent.hasGetUserMedia()) {
this.isCameraSupport = true;
WebCameraInputWidgetComponent.getAvailableVideoInputs().then((devices) => {
this.isDeviceDetect = !!devices.length;
this.singleDevice = devices.length < 2;
this.availableVideoInputs = devices;
this.ctx.detectChanges();
}, () => {
this.availableVideoInputs = [];
}
)
}
}
private getVideoAspectRatio(): number {
if (this.videoElement.videoWidth && this.videoElement.videoWidth > 0 &&
this.videoElement.videoHeight && this.videoElement.videoHeight > 0) {
return this.videoElement.videoWidth / this.videoElement.videoHeight;
}
return this.width / this.height;
}
private stopMediaTracks() {
if (this.mediaStream && this.mediaStream.getTracks) {
this.mediaStream.getTracks()
.forEach((track: MediaStreamTrack) => track.stop());
}
}
takePhoto() {
this.isShowCamera = true;
this.initWebCamera(this.availableVideoInputs[this.videoInputsIndex].deviceId);
}
closeCamera() {
this.stopMediaTracks();
this.videoElement.srcObject = null;
this.isShowCamera = false;
}
cancelPhoto() {
this.isPreviewPhoto = false;
this.previewPhoto = '';
}
savePhoto() {
this.updatePhoto = true;
let task: Observable<any>;
const entityId: EntityId = {
entityType: this.datasource.entityType,
id: this.datasource.entityId
};
const saveData = [{
key: this.datasource.dataKeys[0].name,
value: this.previewPhoto
}];
if (this.datasource.dataKeys[0].type === DataKeyType.attribute) {
task = this.attributeService.saveEntityAttributes(entityId, AttributeScope.SERVER_SCOPE, saveData);
} else if (this.datasource.dataKeys[0].type === DataKeyType.timeseries) {
task = this.attributeService.saveEntityTimeseries(entityId, 'scope', saveData);
}
task.subscribe(() => {
this.isPreviewPhoto = false;
this.updatePhoto = false;
this.closeCamera();
}, () => {
this.updatePhoto = false;
})
}
switchWebCamera() {
this.videoInputsIndex = (this.videoInputsIndex + 1) % this.availableVideoInputs.length;
this.stopMediaTracks();
this.initWebCamera(this.availableVideoInputs[this.videoInputsIndex].deviceId)
}
createPhoto() {
this.canvasElement.width = this.videoWidth;
this.canvasElement.height = this.videoHeight;
this.canvasElement.getContext('2d').drawImage(this.videoElement, 0, 0, this.videoWidth, this.videoHeight);
const mimeType: string = this.settings.imageFormat ? this.settings.imageFormat : WebCameraInputWidgetComponent.DEFAULT_IMAGE_TYPE;
const quality: number = this.settings.imageQuality ? this.settings.imageQuality : WebCameraInputWidgetComponent.DEFAULT_IMAGE_QUALITY;
this.previewPhoto = this.canvasElement.toDataURL(mimeType, quality);
this.isPreviewPhoto = true;
}
private initWebCamera(deviceId?: string) {
if (window.navigator.mediaDevices && window.navigator.mediaDevices.getUserMedia) {
const videoTrackConstraints = {
video: {deviceId: deviceId !== '' ? {exact: deviceId} : undefined}
};
window.navigator.mediaDevices.getUserMedia(videoTrackConstraints).then((stream: MediaStream) => {
this.mediaStream = stream;
this.videoElement.srcObject = stream;
})
}
}
}

View File

@ -31,6 +31,7 @@ import {
DateRangeNavigatorWidgetComponent
} from '@home/components/widget/lib/date-range-navigator/date-range-navigator.component';
import { MultipleInputWidgetComponent } from './lib/multiple-input-widget.component';
import { WebCameraInputWidgetComponent } from './lib/web-camera-input.component';
@NgModule({
declarations:
@ -43,7 +44,8 @@ import { MultipleInputWidgetComponent } from './lib/multiple-input-widget.compon
EntitiesHierarchyWidgetComponent,
DateRangeNavigatorWidgetComponent,
DateRangeNavigatorPanelComponent,
MultipleInputWidgetComponent
MultipleInputWidgetComponent,
WebCameraInputWidgetComponent
],
imports: [
CommonModule,
@ -58,7 +60,8 @@ import { MultipleInputWidgetComponent } from './lib/multiple-input-widget.compon
EntitiesHierarchyWidgetComponent,
RpcWidgetsModule,
DateRangeNavigatorWidgetComponent,
MultipleInputWidgetComponent
MultipleInputWidgetComponent,
WebCameraInputWidgetComponent
],
providers: [
CustomDialogService

View File

@ -16,15 +16,15 @@
-->
<div class="tb-container">
<label class="tb-title">{{label}}</label>
<label class="tb-title" *ngIf="label">{{label}}</label>
<ng-container #flow="flow"
[flowConfig]="{singleFile: true, allowDuplicateUploads: true}">
<div class="tb-image-select-container">
<div class="tb-image-preview-container">
<div *ngIf="!safeImageUrl" translate>dashboard.no-image</div>
<img *ngIf="safeImageUrl" class="tb-image-preview" [src]="safeImageUrl" />
<div *ngIf="showPreview" class="tb-image-preview-container">
<div *ngIf="!safeImageUrl;else elseBlock" translate>dashboard.no-image</div>
<ng-template #elseBlock><img class="tb-image-preview" [src]="safeImageUrl" /></ng-template>
</div>
<div class="tb-image-clear-container">
<div *ngIf="showClearButton" class="tb-image-clear-container">
<button mat-button mat-icon-button color="primary"
type="button"
(click)="clearImage()"
@ -37,8 +37,8 @@
<div class="drop-area tb-flow-drop"
flowDrop
[flow]="flow.flowJs">
<label for="select" translate>dashboard.drop-image</label>
<input class="file-input" flowButton type="file" [flow]="flow.flowJs" [flowAttributes]="{accept: 'image/*'}" id="select">
<label for="{{inputId}}" translate>dashboard.drop-image</label>
<input class="file-input" flowButton type="file" [flow]="flow.flowJs" [flowAttributes]="{accept: 'image/*'}" id="{{inputId}}">
</div>
</div>
</ng-container>

View File

@ -35,9 +35,9 @@ $previewSize: 100px !default;
.tb-image-preview {
width: auto;
max-width: $previewSize;
max-width: $previewSize - 2;
height: auto;
max-height: $previewSize;
max-height: $previewSize - 2;
}
.tb-image-preview-container {

View File

@ -14,34 +14,16 @@
/// limitations under the License.
///
import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
import { AfterViewInit, Component, forwardRef, Input, OnDestroy, ViewChild } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { DataKey, DatasourceType } from '@shared/models/widget.models';
import {
ControlValueAccessor,
FormBuilder,
FormControl,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
Validator,
Validators
} from '@angular/forms';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { EntityService } from '@core/http/entity.service';
import { DataKeysCallbacks } from '@home/components/widget/data-keys.component.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { Observable, of, Subscription } from 'rxjs';
import { map, mergeMap, tap } from 'rxjs/operators';
import { alarmFields } from '@shared/models/alarm.models';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, } from '@angular/forms';
import { Subscription } from 'rxjs';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { DialogService } from '@core/services/dialog.service';
import { FlowDirective } from '@flowjs/ngx-flow';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { UtilsService } from '@core/services/utils.service';
@Component({
selector: 'tb-image-input',
@ -61,9 +43,11 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit,
label: string;
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
const newVal = coerceBooleanProperty(value);
@ -75,6 +59,15 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit,
@Input()
disabled: boolean;
@Input()
showClearButton = true;
@Input()
showPreview = true;
@Input()
inputId = this.utils.guid();
imageUrl: string;
safeImageUrl: SafeUrl;
@ -86,6 +79,7 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit,
private propagateChange = null;
constructor(protected store: Store<AppState>,
private utils: UtilsService,
private sanitizer: DomSanitizer) {
super(store);
}

View File

@ -75,6 +75,7 @@
import './zone-flags';
import 'zone.js/dist/zone'; // Included with Angular CLI.
import 'core-js/es/array';
import moment from 'moment';
/***************************************************************************************************
* APPLICATION IMPORTS
@ -99,6 +100,7 @@ const tinycolor = tinycolor_;
(window as any).tinycolor = tinycolor;
(window as any).cssjs = cssjs;
(window as any).moment = moment;
(window as any).TbFlot = TbFlot;
(window as any).TbAnalogueCompass = TbAnalogueCompass;
(window as any).TbAnalogueRadialGauge = TbAnalogueRadialGauge;