committed by
GitHub
24 changed files with 2155 additions and 10 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,197 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2024 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. |
||||
|
|
||||
|
--> |
||||
|
<ng-container [formGroup]="powerButtonWidgetConfigForm"> |
||||
|
<tb-target-device formControlName="targetDevice"></tb-target-device> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" translate>widgets.power-button.behavior</div> |
||||
|
<div class="tb-form-row"> |
||||
|
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.initial-state-hint' | translate}}" translate>widgets.rpc-state.initial-state</div> |
||||
|
<tb-get-value-action-settings fxFlex |
||||
|
panelTitle="widgets.rpc-state.initial-state" |
||||
|
[valueType]="valueType.BOOLEAN" |
||||
|
trueLabel="widgets.rpc-state.on" |
||||
|
falseLabel="widgets.rpc-state.off" |
||||
|
stateLabel="widgets.rpc-state.on" |
||||
|
[aliasController]="aliasController" |
||||
|
[targetDevice]="targetDevice" |
||||
|
[widgetType]="widgetType" |
||||
|
formControlName="initialState"></tb-get-value-action-settings> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.power-button.power-on-hint' | translate}}" translate>widgets.power-button.power-on</div> |
||||
|
<tb-set-value-action-settings fxFlex |
||||
|
panelTitle="widgets.power-button.power-on " |
||||
|
[aliasController]="aliasController" |
||||
|
[targetDevice]="targetDevice" |
||||
|
[widgetType]="widgetType" |
||||
|
formControlName="onUpdateState"></tb-set-value-action-settings> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.power-button.power-off-hint' | translate}}" translate>widgets.power-button.power-off</div> |
||||
|
<tb-set-value-action-settings fxFlex |
||||
|
panelTitle="widgets.power-button.power-off" |
||||
|
[aliasController]="aliasController" |
||||
|
[targetDevice]="targetDevice" |
||||
|
[widgetType]="widgetType" |
||||
|
formControlName="offUpdateState"></tb-set-value-action-settings> |
||||
|
</div> |
||||
|
<div class="tb-form-row"> |
||||
|
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.disabled-state-hint' | translate}}" translate>widgets.rpc-state.disabled-state</div> |
||||
|
<tb-get-value-action-settings fxFlex |
||||
|
panelTitle="widgets.rpc-state.disabled-state" |
||||
|
[valueType]="valueType.BOOLEAN" |
||||
|
stateLabel="widgets.rpc-state.disabled" |
||||
|
[aliasController]="aliasController" |
||||
|
[targetDevice]="targetDevice" |
||||
|
[widgetType]="widgetType" |
||||
|
formControlName="disabledState"></tb-get-value-action-settings> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" translate>widget-config.appearance</div> |
||||
|
<tb-image-cards-select rowHeight="1:1" |
||||
|
[cols]="{columns: 3, |
||||
|
breakpoints: { |
||||
|
'lt-sm': 1, |
||||
|
'lt-md': 2 |
||||
|
}}" |
||||
|
label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout"> |
||||
|
<tb-image-cards-select-option *ngFor="let layout of powerButtonLayouts" |
||||
|
[value]="layout" |
||||
|
[image]="powerButtonLayoutImageMap.get(layout)"> |
||||
|
{{ powerButtonLayoutTranslationMap.get(layout) | translate }} |
||||
|
</tb-image-cards-select-option> |
||||
|
</tb-image-cards-select> |
||||
|
<div class="tb-form-row column-xs"> |
||||
|
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle"> |
||||
|
{{ 'widget-config.title' | translate }} |
||||
|
</mat-slide-toggle> |
||||
|
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic"> |
||||
|
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}"> |
||||
|
</mat-form-field> |
||||
|
<tb-font-settings formControlName="titleFont" |
||||
|
clearButton |
||||
|
[previewText]="powerButtonWidgetConfigForm.get('title').value" |
||||
|
[initialPreviewStyle]="widgetConfig.config.titleStyle"> |
||||
|
</tb-font-settings> |
||||
|
<tb-color-input asBoxInput |
||||
|
colorClearButton |
||||
|
formControlName="titleColor"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-row column-xs"> |
||||
|
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon"> |
||||
|
{{ 'widget-config.card-icon' | translate }} |
||||
|
</mat-slide-toggle> |
||||
|
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic"> |
||||
|
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}"> |
||||
|
</mat-form-field> |
||||
|
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select> |
||||
|
<tb-material-icon-select asBoxInput |
||||
|
iconClearButton |
||||
|
[color]="powerButtonWidgetConfigForm.get('iconColor').value" |
||||
|
formControlName="icon"> |
||||
|
</tb-material-icon-select> |
||||
|
<tb-color-input asBoxInput |
||||
|
colorClearButton |
||||
|
formControlName="iconColor"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widgets.power-button.power-on-colors' | translate }}</div> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px"> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.main</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="mainColorOn"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
<mat-divider vertical></mat-divider> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.background</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="backgroundColorOn"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widgets.power-button.power-off-colors' | translate }}</div> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px"> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.main</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="mainColorOff"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
<mat-divider vertical></mat-divider> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.background</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="backgroundColorOff"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widgets.power-button.disabled-colors' | translate }}</div> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px"> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.main</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="mainColorDisabled"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
<mat-divider vertical></mat-divider> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.background</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="backgroundColorDisabled"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widgets.background.background' | translate }}</div> |
||||
|
<tb-background-settings formControlName="background"> |
||||
|
</tb-background-settings> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between column-lt-md"> |
||||
|
<div translate>widget-config.show-card-buttons</div> |
||||
|
<mat-chip-listbox multiple formControlName="cardButtons"> |
||||
|
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option> |
||||
|
</mat-chip-listbox> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widget-config.card-border-radius' | translate }}</div> |
||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
||||
|
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}"> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
<tb-widget-actions-panel |
||||
|
formControlName="actions"> |
||||
|
</tb-widget-actions-panel> |
||||
|
</ng-container> |
||||
@ -0,0 +1,195 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 } from '@angular/core'; |
||||
|
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
||||
|
import { Store } from '@ngrx/store'; |
||||
|
import { AppState } from '@core/core.state'; |
||||
|
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; |
||||
|
import { WidgetConfigComponentData } from '@home/models/widget-component.models'; |
||||
|
import { TargetDevice, WidgetConfig, } from '@shared/models/widget.models'; |
||||
|
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
||||
|
import { isUndefined } from '@core/utils'; |
||||
|
import { ValueType } from '@shared/models/constants'; |
||||
|
import { |
||||
|
powerButtonDefaultSettings, |
||||
|
powerButtonLayoutImages, |
||||
|
powerButtonLayouts, |
||||
|
powerButtonLayoutTranslations, |
||||
|
PowerButtonWidgetSettings |
||||
|
} from '@home/components/widget/lib/rpc/power-button-widget.models'; |
||||
|
import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings.models'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-power-button-basic-config', |
||||
|
templateUrl: './power-button-basic-config.component.html', |
||||
|
styleUrls: ['../basic-config.scss'] |
||||
|
}) |
||||
|
export class PowerButtonBasicConfigComponent extends BasicWidgetConfigComponent { |
||||
|
|
||||
|
get targetDevice(): TargetDevice { |
||||
|
return this.powerButtonWidgetConfigForm.get('targetDevice').value; |
||||
|
} |
||||
|
|
||||
|
powerButtonLayouts = powerButtonLayouts; |
||||
|
|
||||
|
powerButtonLayoutTranslationMap = powerButtonLayoutTranslations; |
||||
|
powerButtonLayoutImageMap = powerButtonLayoutImages; |
||||
|
|
||||
|
valueType = ValueType; |
||||
|
|
||||
|
powerButtonWidgetConfigForm: UntypedFormGroup; |
||||
|
|
||||
|
constructor(protected store: Store<AppState>, |
||||
|
protected widgetConfigComponent: WidgetConfigComponent, |
||||
|
private fb: UntypedFormBuilder) { |
||||
|
super(store, widgetConfigComponent); |
||||
|
} |
||||
|
|
||||
|
protected configForm(): UntypedFormGroup { |
||||
|
return this.powerButtonWidgetConfigForm; |
||||
|
} |
||||
|
|
||||
|
protected onConfigSet(configData: WidgetConfigComponentData) { |
||||
|
const settings: PowerButtonWidgetSettings = {...powerButtonDefaultSettings, ...(configData.config.settings || {})}; |
||||
|
const iconSize = resolveCssSize(configData.config.iconSize); |
||||
|
this.powerButtonWidgetConfigForm = this.fb.group({ |
||||
|
targetDevice: [configData.config.targetDevice, []], |
||||
|
|
||||
|
initialState: [settings.initialState, []], |
||||
|
onUpdateState: [settings.onUpdateState, []], |
||||
|
offUpdateState: [settings.offUpdateState, []], |
||||
|
disabledState: [settings.disabledState, []], |
||||
|
|
||||
|
layout: [settings.layout, []], |
||||
|
|
||||
|
showTitle: [configData.config.showTitle, []], |
||||
|
title: [configData.config.title, []], |
||||
|
titleFont: [configData.config.titleFont, []], |
||||
|
titleColor: [configData.config.titleColor, []], |
||||
|
|
||||
|
showIcon: [configData.config.showTitleIcon, []], |
||||
|
iconSize: [iconSize[0], [Validators.min(0)]], |
||||
|
iconSizeUnit: [iconSize[1], []], |
||||
|
icon: [configData.config.titleIcon, []], |
||||
|
iconColor: [configData.config.iconColor, []], |
||||
|
|
||||
|
mainColorOn: [settings.mainColorOn, []], |
||||
|
backgroundColorOn: [settings.backgroundColorOn, []], |
||||
|
|
||||
|
mainColorOff: [settings.mainColorOff, []], |
||||
|
backgroundColorOff: [settings.backgroundColorOff, []], |
||||
|
|
||||
|
mainColorDisabled: [settings.mainColorDisabled, []], |
||||
|
backgroundColorDisabled: [settings.backgroundColorDisabled, []], |
||||
|
|
||||
|
background: [settings.background, []], |
||||
|
|
||||
|
cardButtons: [this.getCardButtons(configData.config), []], |
||||
|
borderRadius: [configData.config.borderRadius, []], |
||||
|
|
||||
|
actions: [configData.config.actions || {}, []] |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
protected prepareOutputConfig(config: any): WidgetConfigComponentData { |
||||
|
this.widgetConfig.config.targetDevice = config.targetDevice; |
||||
|
|
||||
|
this.widgetConfig.config.showTitle = config.showTitle; |
||||
|
this.widgetConfig.config.title = config.title; |
||||
|
this.widgetConfig.config.titleFont = config.titleFont; |
||||
|
this.widgetConfig.config.titleColor = config.titleColor; |
||||
|
|
||||
|
this.widgetConfig.config.showTitleIcon = config.showIcon; |
||||
|
this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit); |
||||
|
this.widgetConfig.config.titleIcon = config.icon; |
||||
|
this.widgetConfig.config.iconColor = config.iconColor; |
||||
|
|
||||
|
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; |
||||
|
|
||||
|
this.widgetConfig.config.settings.initialState = config.initialState; |
||||
|
this.widgetConfig.config.settings.onUpdateState = config.onUpdateState; |
||||
|
this.widgetConfig.config.settings.offUpdateState = config.offUpdateState; |
||||
|
this.widgetConfig.config.settings.disabledState = config.disabledState; |
||||
|
|
||||
|
this.widgetConfig.config.settings.layout = config.layout; |
||||
|
|
||||
|
this.widgetConfig.config.settings.mainColorOn = config.mainColorOn; |
||||
|
this.widgetConfig.config.settings.backgroundColorOn = config.backgroundColorOn; |
||||
|
|
||||
|
this.widgetConfig.config.settings.mainColorOff = config.mainColorOff; |
||||
|
this.widgetConfig.config.settings.backgroundColorOff = config.backgroundColorOff; |
||||
|
|
||||
|
this.widgetConfig.config.settings.mainColorDisabled = config.mainColorDisabled; |
||||
|
this.widgetConfig.config.settings.backgroundColorDisabled = config.backgroundColorDisabled; |
||||
|
|
||||
|
this.widgetConfig.config.settings.background = config.background; |
||||
|
|
||||
|
this.setCardButtons(config.cardButtons, this.widgetConfig.config); |
||||
|
this.widgetConfig.config.borderRadius = config.borderRadius; |
||||
|
|
||||
|
this.widgetConfig.config.actions = config.actions; |
||||
|
return this.widgetConfig; |
||||
|
} |
||||
|
|
||||
|
protected validatorTriggers(): string[] { |
||||
|
return ['showTitle', 'showIcon']; |
||||
|
} |
||||
|
|
||||
|
protected updateValidators(emitEvent: boolean, trigger?: string) { |
||||
|
const showTitle: boolean = this.powerButtonWidgetConfigForm.get('showTitle').value; |
||||
|
const showIcon: boolean = this.powerButtonWidgetConfigForm.get('showIcon').value; |
||||
|
if (showTitle) { |
||||
|
this.powerButtonWidgetConfigForm.get('title').enable(); |
||||
|
this.powerButtonWidgetConfigForm.get('titleFont').enable(); |
||||
|
this.powerButtonWidgetConfigForm.get('titleColor').enable(); |
||||
|
this.powerButtonWidgetConfigForm.get('showIcon').enable({emitEvent: false}); |
||||
|
if (showIcon) { |
||||
|
this.powerButtonWidgetConfigForm.get('iconSize').enable(); |
||||
|
this.powerButtonWidgetConfigForm.get('iconSizeUnit').enable(); |
||||
|
this.powerButtonWidgetConfigForm.get('icon').enable(); |
||||
|
this.powerButtonWidgetConfigForm.get('iconColor').enable(); |
||||
|
} else { |
||||
|
this.powerButtonWidgetConfigForm.get('iconSize').disable(); |
||||
|
this.powerButtonWidgetConfigForm.get('iconSizeUnit').disable(); |
||||
|
this.powerButtonWidgetConfigForm.get('icon').disable(); |
||||
|
this.powerButtonWidgetConfigForm.get('iconColor').disable(); |
||||
|
} |
||||
|
} else { |
||||
|
this.powerButtonWidgetConfigForm.get('title').disable(); |
||||
|
this.powerButtonWidgetConfigForm.get('titleFont').disable(); |
||||
|
this.powerButtonWidgetConfigForm.get('titleColor').disable(); |
||||
|
this.powerButtonWidgetConfigForm.get('showIcon').disable({emitEvent: false}); |
||||
|
this.powerButtonWidgetConfigForm.get('iconSize').disable(); |
||||
|
this.powerButtonWidgetConfigForm.get('iconSizeUnit').disable(); |
||||
|
this.powerButtonWidgetConfigForm.get('icon').disable(); |
||||
|
this.powerButtonWidgetConfigForm.get('iconColor').disable(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private getCardButtons(config: WidgetConfig): string[] { |
||||
|
const buttons: string[] = []; |
||||
|
if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { |
||||
|
buttons.push('fullscreen'); |
||||
|
} |
||||
|
return buttons; |
||||
|
} |
||||
|
|
||||
|
private setCardButtons(buttons: string[], config: WidgetConfig) { |
||||
|
config.enableFullscreen = buttons.includes('fullscreen'); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2024 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 class="tb-power-button-panel" [style]="backgroundStyle$ | async"> |
||||
|
<div class="tb-power-button-overlay" [style]="overlayStyle"></div> |
||||
|
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> |
||||
|
<div class="tb-power-button-content"> |
||||
|
<div #powerButtonShape class="tb-power-button-shape" [class.tb-power-button-pointer]="!disabled && (loading$ | async) === false"> |
||||
|
</div> |
||||
|
</div> |
||||
|
<mat-progress-bar class="tb-action-widget-progress" style="height: 4px;" color="accent" mode="indeterminate" *ngIf="loading$ | async"></mat-progress-bar> |
||||
|
<div *ngIf="error" class="tb-action-widget-error-container"> |
||||
|
<div class="tb-action-widget-error-panel"> |
||||
|
<div class="tb-action-widget-error-text" [innerHTML]="error | safe: 'html'"></div> |
||||
|
<button class="tb-action-widget-error-clear tb-mat-20" mat-icon-button (click)="clearError()"><mat-icon>close</mat-icon></button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,64 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2024 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-power-button-panel { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
position: relative; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 16px; |
||||
|
padding: 20px 24px 24px 24px; |
||||
|
> div:not(.tb-power-button-overlay) { |
||||
|
z-index: 1; |
||||
|
} |
||||
|
.tb-power-button-overlay { |
||||
|
position: absolute; |
||||
|
top: 12px; |
||||
|
left: 12px; |
||||
|
bottom: 12px; |
||||
|
right: 12px; |
||||
|
} |
||||
|
div.tb-widget-title { |
||||
|
padding: 0; |
||||
|
} |
||||
|
.tb-power-button-content { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
min-height: 0; |
||||
|
.tb-power-button-shape { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
svg { |
||||
|
.tb-small-shadow { |
||||
|
filter: drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.2)); |
||||
|
} |
||||
|
.tb-shadow { |
||||
|
filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.15)); |
||||
|
} |
||||
|
} |
||||
|
&.tb-power-button-pointer { |
||||
|
svg { |
||||
|
.tb-hover-circle { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,203 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 { |
||||
|
AfterViewInit, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
ElementRef, |
||||
|
OnDestroy, |
||||
|
OnInit, |
||||
|
Renderer2, |
||||
|
ViewChild, |
||||
|
ViewEncapsulation |
||||
|
} from '@angular/core'; |
||||
|
import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; |
||||
|
import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; |
||||
|
import { Observable } from 'rxjs'; |
||||
|
import { ResizeObserver } from '@juggle/resize-observer'; |
||||
|
import { ImagePipe } from '@shared/pipe/image.pipe'; |
||||
|
import { DomSanitizer } from '@angular/platform-browser'; |
||||
|
import { ValueType } from '@shared/models/constants'; |
||||
|
import { |
||||
|
powerButtonDefaultSettings, |
||||
|
PowerButtonShape, |
||||
|
powerButtonShapeSize, |
||||
|
PowerButtonWidgetSettings |
||||
|
} from '@home/components/widget/lib/rpc/power-button-widget.models'; |
||||
|
import { SVG, Svg } from '@svgdotjs/svg.js'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-power-button-widget', |
||||
|
templateUrl: './power-button-widget.component.html', |
||||
|
styleUrls: ['../action/action-widget.scss', './power-button-widget.component.scss'], |
||||
|
encapsulation: ViewEncapsulation.None |
||||
|
}) |
||||
|
export class PowerButtonWidgetComponent extends |
||||
|
BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { |
||||
|
|
||||
|
@ViewChild('powerButtonShape', {static: false}) |
||||
|
powerButtonShape: ElementRef<HTMLElement>; |
||||
|
|
||||
|
settings: PowerButtonWidgetSettings; |
||||
|
|
||||
|
backgroundStyle$: Observable<ComponentStyle>; |
||||
|
overlayStyle: ComponentStyle = {}; |
||||
|
|
||||
|
value = false; |
||||
|
disabled = false; |
||||
|
|
||||
|
private shapeResize$: ResizeObserver; |
||||
|
private drawSvgShapePending = false; |
||||
|
private svgShape: Svg; |
||||
|
private powerButtonSvgShape: PowerButtonShape; |
||||
|
private disabledState = false; |
||||
|
|
||||
|
private onValueSetter: ValueSetter<boolean>; |
||||
|
private offValueSetter: ValueSetter<boolean>; |
||||
|
|
||||
|
constructor(protected imagePipe: ImagePipe, |
||||
|
protected sanitizer: DomSanitizer, |
||||
|
private renderer: Renderer2, |
||||
|
protected cd: ChangeDetectorRef) { |
||||
|
super(cd); |
||||
|
} |
||||
|
|
||||
|
ngOnInit(): void { |
||||
|
super.ngOnInit(); |
||||
|
this.settings = {...powerButtonDefaultSettings, ...this.ctx.settings}; |
||||
|
|
||||
|
this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); |
||||
|
this.overlayStyle = overlayStyle(this.settings.background.overlay); |
||||
|
|
||||
|
const getInitialStateSettings = |
||||
|
{...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.initial-state')}; |
||||
|
this.createValueGetter(getInitialStateSettings, ValueType.BOOLEAN, { |
||||
|
next: (value) => this.onValue(value) |
||||
|
}); |
||||
|
|
||||
|
const disabledStateSettings = |
||||
|
{...this.settings.disabledState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.disabled-state')}; |
||||
|
this.createValueGetter(disabledStateSettings, ValueType.BOOLEAN, { |
||||
|
next: (value) => this.onDisabled(value) |
||||
|
}); |
||||
|
|
||||
|
const onUpdateStateSettings = {...this.settings.onUpdateState, |
||||
|
actionLabel: this.ctx.translate.instant('widgets.power-button.power-on')}; |
||||
|
this.onValueSetter = this.createValueSetter(onUpdateStateSettings); |
||||
|
|
||||
|
const offUpdateStateSettings = {...this.settings.offUpdateState, |
||||
|
actionLabel: this.ctx.translate.instant('widgets.power-button.power-off')}; |
||||
|
this.offValueSetter = this.createValueSetter(offUpdateStateSettings); |
||||
|
|
||||
|
this.loading$.subscribe((loading) => { |
||||
|
this.updateDisabledState(loading || this.disabled); |
||||
|
this.cd.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
ngAfterViewInit(): void { |
||||
|
if (this.drawSvgShapePending) { |
||||
|
this.drawSvg(); |
||||
|
} |
||||
|
super.ngAfterViewInit(); |
||||
|
} |
||||
|
|
||||
|
ngOnDestroy() { |
||||
|
if (this.shapeResize$) { |
||||
|
this.shapeResize$.disconnect(); |
||||
|
} |
||||
|
super.ngOnDestroy(); |
||||
|
} |
||||
|
|
||||
|
public onInit() { |
||||
|
super.onInit(); |
||||
|
const borderRadius = this.ctx.$widgetElement.css('borderRadius'); |
||||
|
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; |
||||
|
if (this.powerButtonShape) { |
||||
|
this.drawSvg(); |
||||
|
} else { |
||||
|
this.drawSvgShapePending = true; |
||||
|
} |
||||
|
this.cd.detectChanges(); |
||||
|
} |
||||
|
|
||||
|
private onValue(value: boolean): void { |
||||
|
const newValue = !!value; |
||||
|
if (this.value !== newValue) { |
||||
|
this.value = newValue; |
||||
|
this.powerButtonSvgShape?.setValue(this.value); |
||||
|
this.cd.markForCheck(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private onDisabled(value: boolean): void { |
||||
|
const newDisabled = !!value; |
||||
|
if (this.disabled !== newDisabled) { |
||||
|
this.disabled = newDisabled; |
||||
|
this.updateDisabledState(this.disabled); |
||||
|
this.cd.markForCheck(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private onClick() { |
||||
|
if (!this.ctx.isEdit && !this.ctx.isPreview && !this.disabledState) { |
||||
|
this.onValue(!this.value); |
||||
|
const targetValue = this.value; |
||||
|
const targetSetter = targetValue ? this.onValueSetter : this.offValueSetter; |
||||
|
this.powerButtonSvgShape?.setPressed(true); |
||||
|
this.updateValue(targetSetter, targetValue, { |
||||
|
next: () => { |
||||
|
this.powerButtonSvgShape?.setPressed(false); |
||||
|
this.onValue(targetValue); |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.powerButtonSvgShape?.setPressed(false); |
||||
|
this.onValue(!targetValue); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private drawSvg() { |
||||
|
this.svgShape = SVG().addTo(this.powerButtonShape.nativeElement).size(powerButtonShapeSize, powerButtonShapeSize); |
||||
|
this.renderer.setStyle(this.svgShape.node, 'overflow', 'visible'); |
||||
|
this.renderer.setStyle(this.svgShape.node, 'user-select', 'none'); |
||||
|
|
||||
|
this.powerButtonSvgShape = PowerButtonShape.fromSettings(this.ctx, this.svgShape, |
||||
|
this.settings, this.value, this.disabledState, () => this.onClick()); |
||||
|
|
||||
|
this.shapeResize$ = new ResizeObserver(() => { |
||||
|
this.onResize(); |
||||
|
}); |
||||
|
this.shapeResize$.observe(this.powerButtonShape.nativeElement); |
||||
|
this.onResize(); |
||||
|
} |
||||
|
|
||||
|
private updateDisabledState(disabled: boolean) { |
||||
|
this.disabledState = disabled; |
||||
|
this.powerButtonSvgShape?.setDisabled(this.disabledState); |
||||
|
} |
||||
|
|
||||
|
private onResize() { |
||||
|
const shapeWidth = this.powerButtonShape.nativeElement.getBoundingClientRect().width; |
||||
|
const shapeHeight = this.powerButtonShape.nativeElement.getBoundingClientRect().height; |
||||
|
const size = Math.min(shapeWidth, shapeHeight); |
||||
|
const scale = size / powerButtonShapeSize; |
||||
|
this.renderer.setStyle(this.svgShape.node, 'transform', `scale(${scale})`); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,953 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models'; |
||||
|
import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; |
||||
|
import { |
||||
|
DataToValueType, |
||||
|
GetValueAction, |
||||
|
GetValueSettings, |
||||
|
SetValueAction, |
||||
|
SetValueSettings, |
||||
|
ValueToDataType |
||||
|
} from '@shared/models/action-widget-settings.models'; |
||||
|
import { Circle, Effect, Element, G, Gradient, Runner, Svg, Text, Timeline } from '@svgdotjs/svg.js'; |
||||
|
import '@svgdotjs/svg.filter.js'; |
||||
|
import tinycolor from 'tinycolor2'; |
||||
|
import { WidgetContext } from '@home/models/widget-component.models'; |
||||
|
|
||||
|
export enum PowerButtonLayout { |
||||
|
default = 'default', |
||||
|
simplified = 'simplified', |
||||
|
outlined = 'outlined', |
||||
|
default_volume = 'default_volume', |
||||
|
simplified_volume = 'simplified_volume', |
||||
|
outlined_volume = 'outlined_volume' |
||||
|
} |
||||
|
|
||||
|
export const powerButtonLayouts = Object.keys(PowerButtonLayout) as PowerButtonLayout[]; |
||||
|
|
||||
|
export const powerButtonLayoutTranslations = new Map<PowerButtonLayout, string>( |
||||
|
[ |
||||
|
[PowerButtonLayout.default, 'widgets.power-button.layout-default'], |
||||
|
[PowerButtonLayout.simplified, 'widgets.power-button.layout-simplified'], |
||||
|
[PowerButtonLayout.outlined, 'widgets.power-button.layout-outlined'], |
||||
|
[PowerButtonLayout.default_volume, 'widgets.power-button.layout-default-volume'], |
||||
|
[PowerButtonLayout.simplified_volume, 'widgets.power-button.layout-simplified-volume'], |
||||
|
[PowerButtonLayout.outlined_volume, 'widgets.power-button.layout-outlined-volume'] |
||||
|
] |
||||
|
); |
||||
|
|
||||
|
export const powerButtonLayoutImages = new Map<PowerButtonLayout, string>( |
||||
|
[ |
||||
|
[PowerButtonLayout.default, 'assets/widget/power-button/default-layout.svg'], |
||||
|
[PowerButtonLayout.simplified, 'assets/widget/power-button/simplified-layout.svg'], |
||||
|
[PowerButtonLayout.outlined, 'assets/widget/power-button/outlined-layout.svg'], |
||||
|
[PowerButtonLayout.default_volume, 'assets/widget/power-button/default-volume-layout.svg'], |
||||
|
[PowerButtonLayout.simplified_volume, 'assets/widget/power-button/simplified-volume-layout.svg'], |
||||
|
[PowerButtonLayout.outlined_volume, 'assets/widget/power-button/outlined-volume-layout.svg'] |
||||
|
] |
||||
|
); |
||||
|
|
||||
|
export interface PowerButtonWidgetSettings { |
||||
|
initialState: GetValueSettings<boolean>; |
||||
|
disabledState: GetValueSettings<boolean>; |
||||
|
onUpdateState: SetValueSettings; |
||||
|
offUpdateState: SetValueSettings; |
||||
|
layout: PowerButtonLayout; |
||||
|
mainColorOn: string; |
||||
|
backgroundColorOn: string; |
||||
|
mainColorOff: string; |
||||
|
backgroundColorOff: string; |
||||
|
mainColorDisabled: string; |
||||
|
backgroundColorDisabled: string; |
||||
|
background: BackgroundSettings; |
||||
|
} |
||||
|
|
||||
|
export const powerButtonDefaultSettings: PowerButtonWidgetSettings = { |
||||
|
initialState: { |
||||
|
action: GetValueAction.EXECUTE_RPC, |
||||
|
defaultValue: false, |
||||
|
executeRpc: { |
||||
|
method: 'getState', |
||||
|
requestTimeout: 5000, |
||||
|
requestPersistent: false, |
||||
|
persistentPollingInterval: 1000 |
||||
|
}, |
||||
|
getAttribute: { |
||||
|
key: 'state', |
||||
|
scope: null |
||||
|
}, |
||||
|
getTimeSeries: { |
||||
|
key: 'state' |
||||
|
}, |
||||
|
dataToValue: { |
||||
|
type: DataToValueType.NONE, |
||||
|
compareToValue: true, |
||||
|
dataToValueFunction: '/* Should return boolean value */\nreturn data;' |
||||
|
} |
||||
|
}, |
||||
|
disabledState: { |
||||
|
action: GetValueAction.DO_NOTHING, |
||||
|
defaultValue: false, |
||||
|
getAttribute: { |
||||
|
key: 'state', |
||||
|
scope: null |
||||
|
}, |
||||
|
getTimeSeries: { |
||||
|
key: 'state' |
||||
|
}, |
||||
|
dataToValue: { |
||||
|
type: DataToValueType.NONE, |
||||
|
compareToValue: true, |
||||
|
dataToValueFunction: '/* Should return boolean value */\nreturn data;' |
||||
|
} |
||||
|
}, |
||||
|
onUpdateState: { |
||||
|
action: SetValueAction.EXECUTE_RPC, |
||||
|
executeRpc: { |
||||
|
method: 'setState', |
||||
|
requestTimeout: 5000, |
||||
|
requestPersistent: false, |
||||
|
persistentPollingInterval: 1000 |
||||
|
}, |
||||
|
setAttribute: { |
||||
|
key: 'state', |
||||
|
scope: AttributeScope.SHARED_SCOPE |
||||
|
}, |
||||
|
putTimeSeries: { |
||||
|
key: 'state' |
||||
|
}, |
||||
|
valueToData: { |
||||
|
type: ValueToDataType.CONSTANT, |
||||
|
constantValue: true, |
||||
|
valueToDataFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' |
||||
|
} |
||||
|
}, |
||||
|
offUpdateState: { |
||||
|
action: SetValueAction.EXECUTE_RPC, |
||||
|
executeRpc: { |
||||
|
method: 'setState', |
||||
|
requestTimeout: 5000, |
||||
|
requestPersistent: false, |
||||
|
persistentPollingInterval: 1000 |
||||
|
}, |
||||
|
setAttribute: { |
||||
|
key: 'state', |
||||
|
scope: AttributeScope.SHARED_SCOPE |
||||
|
}, |
||||
|
putTimeSeries: { |
||||
|
key: 'state' |
||||
|
}, |
||||
|
valueToData: { |
||||
|
type: ValueToDataType.CONSTANT, |
||||
|
constantValue: false, |
||||
|
valueToDataFunction: '/* Convert input boolean value to RPC parameters or attribute/time-series value */ \n return value;' |
||||
|
} |
||||
|
}, |
||||
|
layout: PowerButtonLayout.default, |
||||
|
mainColorOn: '#3F52DD', |
||||
|
backgroundColorOn: '#FFFFFF', |
||||
|
mainColorOff: '#A2A2A2', |
||||
|
backgroundColorOff: '#FFFFFF', |
||||
|
mainColorDisabled: 'rgba(0,0,0,0.12)', |
||||
|
backgroundColorDisabled: '#FFFFFF', |
||||
|
background: { |
||||
|
type: BackgroundType.color, |
||||
|
color: '#fff', |
||||
|
overlay: { |
||||
|
enabled: false, |
||||
|
color: 'rgba(255,255,255,0.72)', |
||||
|
blur: 3 |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
interface PowerButtonColor { |
||||
|
hex: string; |
||||
|
opacity: number; |
||||
|
} |
||||
|
|
||||
|
type PowerButtonState = 'on' | 'off' | 'disabled'; |
||||
|
|
||||
|
interface PowerButtonColorState { |
||||
|
mainColor: PowerButtonColor; |
||||
|
backgroundColor: PowerButtonColor; |
||||
|
} |
||||
|
|
||||
|
type PowerButtonShapeColors = Record<PowerButtonState, PowerButtonColorState>; |
||||
|
|
||||
|
const createPowerButtonShapeColors = (settings: PowerButtonWidgetSettings): PowerButtonShapeColors => { |
||||
|
const mainColorOn = tinycolor(settings.mainColorOn); |
||||
|
const backgroundColorOn = tinycolor(settings.backgroundColorOn); |
||||
|
const mainColorOff = tinycolor(settings.mainColorOff); |
||||
|
const backgroundColorOff = tinycolor(settings.backgroundColorOff); |
||||
|
const mainColorDisabled = tinycolor(settings.mainColorDisabled); |
||||
|
const backgroundColorDisabled = tinycolor(settings.backgroundColorDisabled); |
||||
|
return { |
||||
|
on: { |
||||
|
mainColor: {hex: mainColorOn.toHexString(), opacity: mainColorOn.getAlpha()}, |
||||
|
backgroundColor: {hex: backgroundColorOn.toHexString(), opacity: backgroundColorOn.getAlpha()}, |
||||
|
}, |
||||
|
off: { |
||||
|
mainColor: {hex: mainColorOff.toHexString(), opacity: mainColorOff.getAlpha()}, |
||||
|
backgroundColor: {hex: backgroundColorOff.toHexString(), opacity: backgroundColorOff.getAlpha()}, |
||||
|
}, |
||||
|
disabled: { |
||||
|
mainColor: {hex: mainColorDisabled.toHexString(), opacity: mainColorDisabled.getAlpha()}, |
||||
|
backgroundColor: {hex: backgroundColorDisabled.toHexString(), opacity: backgroundColorDisabled.getAlpha()}, |
||||
|
} |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
export const powerButtonShapeSize = 110; |
||||
|
const cx = powerButtonShapeSize / 2; |
||||
|
const cy = powerButtonShapeSize / 2; |
||||
|
|
||||
|
const powerButtonAnimation = (element: Element): Runner => element.animate(200, 0, 'now'); |
||||
|
|
||||
|
export abstract class PowerButtonShape { |
||||
|
|
||||
|
static fromSettings(ctx: WidgetContext, |
||||
|
svgShape: Svg, |
||||
|
settings: PowerButtonWidgetSettings, |
||||
|
value: boolean, |
||||
|
disabled: boolean, |
||||
|
onClick: () => void): PowerButtonShape { |
||||
|
switch (settings.layout) { |
||||
|
case PowerButtonLayout.default: |
||||
|
return new DefaultPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); |
||||
|
case PowerButtonLayout.simplified: |
||||
|
return new SimplifiedPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); |
||||
|
case PowerButtonLayout.outlined: |
||||
|
return new OutlinedPowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); |
||||
|
case PowerButtonLayout.default_volume: |
||||
|
return new DefaultVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); |
||||
|
case PowerButtonLayout.simplified_volume: |
||||
|
return new SimplifiedVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); |
||||
|
case PowerButtonLayout.outlined_volume: |
||||
|
return new OutlinedVolumePowerButtonShape(ctx, svgShape, settings, value, disabled, onClick); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected readonly colors: PowerButtonShapeColors; |
||||
|
protected readonly onLabel: string; |
||||
|
protected readonly offLabel: string; |
||||
|
|
||||
|
protected backgroundShape: Circle; |
||||
|
protected hoverShape: Circle; |
||||
|
protected hovered = false; |
||||
|
protected pressed = false; |
||||
|
protected forcePressed = false; |
||||
|
|
||||
|
protected constructor(protected widgetContext: WidgetContext, |
||||
|
protected svgShape: Svg, |
||||
|
protected settings: PowerButtonWidgetSettings, |
||||
|
protected value: boolean, |
||||
|
protected disabled: boolean, |
||||
|
protected onClick: () => void) { |
||||
|
this.colors = createPowerButtonShapeColors(this.settings); |
||||
|
this.onLabel = this.widgetContext.translate.instant('widgets.power-button.on-label').toUpperCase(); |
||||
|
this.offLabel = this.widgetContext.translate.instant('widgets.power-button.off-label').toUpperCase(); |
||||
|
this._drawShape(); |
||||
|
} |
||||
|
|
||||
|
public setValue(value: boolean) { |
||||
|
if (this.value !== value) { |
||||
|
this.value = value; |
||||
|
this._drawState(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public setDisabled(disabled: boolean) { |
||||
|
if (this.disabled !== disabled) { |
||||
|
this.disabled = disabled; |
||||
|
this._drawState(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public setPressed(pressed: boolean) { |
||||
|
if (this.forcePressed !== pressed) { |
||||
|
this.forcePressed = pressed; |
||||
|
if (this.forcePressed && !this.pressed) { |
||||
|
this.onPressStart(); |
||||
|
} else if (!this.forcePressed && !this.pressed) { |
||||
|
this.onPressEnd(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private _drawShape() { |
||||
|
|
||||
|
this.backgroundShape = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) |
||||
|
.fill({opacity: 0}).stroke({width: 0}); |
||||
|
|
||||
|
this.drawShape(); |
||||
|
|
||||
|
this.hoverShape = this.svgShape.circle(powerButtonShapeSize).center(cx, cy).addClass('tb-hover-circle') |
||||
|
.fill({color: '#000000', opacity: 0}); |
||||
|
this.hoverShape.on('mouseover', () => { |
||||
|
this.hovered = true; |
||||
|
if (!this.disabled) { |
||||
|
this.hoverShape.timeline().finish(); |
||||
|
this.hoverShape.animate(200).attr({'fill-opacity': 0.06}); |
||||
|
} |
||||
|
}); |
||||
|
this.hoverShape.on('mouseout', () => { |
||||
|
this.hovered = false; |
||||
|
this.hoverShape.timeline().finish(); |
||||
|
this.hoverShape.animate(200).attr({'fill-opacity': 0}); |
||||
|
this._cancelPressed(); |
||||
|
}); |
||||
|
this.hoverShape.on('touchmove', (event: TouchEvent) => { |
||||
|
const touch = event.touches[0]; |
||||
|
const element = document.elementFromPoint(touch.pageX,touch.pageY); |
||||
|
if (this.hoverShape.node !== element) { |
||||
|
this._cancelPressed(); |
||||
|
} |
||||
|
}); |
||||
|
this.hoverShape.on('touchcancel', () => { |
||||
|
this._cancelPressed(); |
||||
|
}); |
||||
|
this.hoverShape.on('mousedown touchstart', (event: Event) => { |
||||
|
if (event.type === 'mousedown') { |
||||
|
if ((event as MouseEvent).button !== 0) { |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
if (!this.disabled && !this.pressed) { |
||||
|
this.pressed = true; |
||||
|
if (!this.forcePressed) { |
||||
|
this.onPressStart(); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
this.hoverShape.on('mouseup touchend touchcancel', () => { |
||||
|
if (this.pressed && !this.disabled) { |
||||
|
this.onClick(); |
||||
|
} |
||||
|
this._cancelPressed(); |
||||
|
}); |
||||
|
this._drawState(); |
||||
|
} |
||||
|
|
||||
|
private _cancelPressed() { |
||||
|
if (this.pressed) { |
||||
|
this.pressed = false; |
||||
|
if (!this.forcePressed) { |
||||
|
this.onPressEnd(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private _drawState() { |
||||
|
let colorState: PowerButtonColorState; |
||||
|
if (this.disabled) { |
||||
|
colorState = this.colors.disabled; |
||||
|
} else { |
||||
|
colorState = this.value ? this.colors.on : this.colors.off; |
||||
|
} |
||||
|
this.drawBackgroundState(colorState.backgroundColor); |
||||
|
this.drawColorState(colorState.mainColor); |
||||
|
if (this.value) { |
||||
|
this.drawOn(); |
||||
|
} else { |
||||
|
this.drawOff(); |
||||
|
} |
||||
|
if (this.disabled) { |
||||
|
this.hoverShape.timeline().finish(); |
||||
|
this.hoverShape.attr({'fill-opacity': 0}); |
||||
|
} else if (this.hovered) { |
||||
|
this.hoverShape.timeline().finish(); |
||||
|
this.hoverShape.animate(200).attr({'fill-opacity': 0.06}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private drawBackgroundState(backgroundColor: PowerButtonColor) { |
||||
|
this.backgroundShape.attr({ fill: backgroundColor.hex, 'fill-opacity': backgroundColor.opacity}); |
||||
|
} |
||||
|
|
||||
|
protected drawShape() {} |
||||
|
|
||||
|
protected drawColorState(_mainColor: PowerButtonColor) {} |
||||
|
|
||||
|
protected drawOff() {} |
||||
|
|
||||
|
protected drawOn() {} |
||||
|
|
||||
|
protected onPressStart() {} |
||||
|
|
||||
|
protected onPressEnd() {} |
||||
|
|
||||
|
protected createMask(shape: Element, maskElements: Element[]) { |
||||
|
const mask = |
||||
|
this.svgShape.mask().add(this.svgShape.rect().width('100%').height('100%').fill('#fff')); |
||||
|
maskElements.forEach(e => { |
||||
|
mask.add(e.fill('#000').attr({'fill-opacity': 1})); |
||||
|
}); |
||||
|
shape.maskWith(mask); |
||||
|
} |
||||
|
|
||||
|
protected createOnLabel(fontWeight = '500'): Text { |
||||
|
return this.createLabel(this.onLabel, fontWeight); |
||||
|
} |
||||
|
|
||||
|
protected createOffLabel(fontWeight = '500'): Text { |
||||
|
return this.createLabel(this.offLabel, fontWeight); |
||||
|
} |
||||
|
|
||||
|
private createLabel(text: string, fontWeight = '500'): Text { |
||||
|
return this.svgShape.text(text).font({ |
||||
|
family: 'Roboto', |
||||
|
weight: fontWeight, |
||||
|
style: 'normal', |
||||
|
size: '22px' |
||||
|
}).attr({x: '50%', y: '50%', 'text-anchor': 'middle', 'dominant-baseline': 'middle'}); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
class InnerShadowCircle { |
||||
|
|
||||
|
private shadowCircle: Circle; |
||||
|
private blurEffect: Effect; |
||||
|
private offsetEffect: Effect; |
||||
|
private floodEffect: Effect; |
||||
|
|
||||
|
constructor(private svgShape: Svg, |
||||
|
private diameter: number, |
||||
|
private centerX: number, |
||||
|
private centerY: number, |
||||
|
private blur = 6, |
||||
|
private shadowOpacity = 0.6, |
||||
|
private dx = 0, |
||||
|
private dy = 0, |
||||
|
private shadowColor = '#000') { |
||||
|
|
||||
|
this.shadowCircle = this.svgShape.circle(this.diameter).center(this.centerX, this.centerY) |
||||
|
.fill({color: '#fff', opacity: 1}).stroke({width: 0}); |
||||
|
|
||||
|
this.shadowCircle.filterWith(add => { |
||||
|
add.x('-50%').y('-50%').width('200%').height('200%'); |
||||
|
let effect: Effect = add.componentTransfer(components => { |
||||
|
components.funcA({ type: 'table', tableValues: '1 0' }); |
||||
|
}).in(add.$fill); |
||||
|
effect = effect.gaussianBlur(this.blur, this.blur).attr({stdDeviation: this.blur}); |
||||
|
this.blurEffect = effect; |
||||
|
effect = effect.offset(this.dx, this.dy); |
||||
|
this.offsetEffect = effect; |
||||
|
effect = effect.flood(this.shadowColor, this.shadowOpacity); |
||||
|
this.floodEffect = effect; |
||||
|
effect = effect.composite(this.offsetEffect, 'in'); |
||||
|
effect.composite(add.$sourceAlpha, 'in'); |
||||
|
add.merge(m => { |
||||
|
m.mergeNode(add.$fill); |
||||
|
m.mergeNode(); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public timeline(tl: Timeline): void { |
||||
|
this.blurEffect.timeline(tl); |
||||
|
this.offsetEffect.timeline(tl); |
||||
|
this.floodEffect.timeline(tl); |
||||
|
} |
||||
|
|
||||
|
public animate(blur: number, opacity: number, dx = 0, dy = 0): Runner { |
||||
|
powerButtonAnimation(this.blurEffect).attr({stdDeviation: blur}); |
||||
|
powerButtonAnimation(this.offsetEffect).attr({dx, dy}); |
||||
|
return powerButtonAnimation(this.floodEffect).attr({'flood-opacity': opacity}); |
||||
|
} |
||||
|
|
||||
|
public animateRestore(): Runner { |
||||
|
return this.animate(this.blur, this.shadowOpacity, this.dx, this.dy); |
||||
|
} |
||||
|
|
||||
|
public show(): void { |
||||
|
this.shadowCircle.show(); |
||||
|
} |
||||
|
|
||||
|
public hide(): void { |
||||
|
this.shadowCircle.hide(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
class DefaultPowerButtonShape extends PowerButtonShape { |
||||
|
|
||||
|
private outerBorder: Circle; |
||||
|
private outerBorderMask: Circle; |
||||
|
private offLabelShape: Text; |
||||
|
private onCircleShape: Circle; |
||||
|
private onLabelShape: Text; |
||||
|
private pressedShadow: InnerShadowCircle; |
||||
|
private pressedTimeline: Timeline; |
||||
|
private centerGroup: G; |
||||
|
|
||||
|
protected drawShape() { |
||||
|
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) |
||||
|
.fill({opacity: 0}).stroke({width: 0}); |
||||
|
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy); |
||||
|
this.createMask(this.outerBorder, [this.outerBorderMask]); |
||||
|
this.centerGroup = this.svgShape.group(); |
||||
|
this.offLabelShape = this.createOffLabel().addTo(this.centerGroup); |
||||
|
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 20) |
||||
|
.center(cx, cy); |
||||
|
this.onLabelShape = this.createOnLabel(); |
||||
|
this.createMask(this.onCircleShape, [this.onLabelShape]); |
||||
|
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 20, cx, cy, 0, 0); |
||||
|
|
||||
|
this.pressedTimeline = new Timeline(); |
||||
|
this.centerGroup.timeline(this.pressedTimeline); |
||||
|
this.onLabelShape.timeline(this.pressedTimeline); |
||||
|
this.pressedShadow.timeline(this.pressedTimeline); |
||||
|
} |
||||
|
|
||||
|
protected drawColorState(mainColor: PowerButtonColor) { |
||||
|
this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
} |
||||
|
|
||||
|
protected drawOff() { |
||||
|
this.outerBorderMask.radius((powerButtonShapeSize - 20)/2); |
||||
|
this.onCircleShape.hide(); |
||||
|
this.centerGroup.show(); |
||||
|
} |
||||
|
|
||||
|
protected drawOn() { |
||||
|
this.outerBorderMask.radius((powerButtonShapeSize - 2)/2); |
||||
|
this.centerGroup.hide(); |
||||
|
this.onCircleShape.show(); |
||||
|
} |
||||
|
|
||||
|
protected onPressStart() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
const pressedScale = 0.75; |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale}); |
||||
|
this.pressedShadow.animate(6, 0.6); |
||||
|
} |
||||
|
|
||||
|
protected onPressEnd() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: 1}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: 1}); |
||||
|
this.pressedShadow.animateRestore(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
class SimplifiedPowerButtonShape extends PowerButtonShape { |
||||
|
|
||||
|
private outerBorder: Circle; |
||||
|
private outerBorderMask: Circle; |
||||
|
private onCircleShape: Circle; |
||||
|
private offLabelShape: Text; |
||||
|
private onLabelShape: Text; |
||||
|
private pressedShadow: InnerShadowCircle; |
||||
|
private pressedTimeline: Timeline; |
||||
|
private centerGroup: G; |
||||
|
|
||||
|
protected drawShape() { |
||||
|
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) |
||||
|
.fill({opacity: 0}).stroke({width: 0}); |
||||
|
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 4).center(cx, cy); |
||||
|
this.createMask(this.outerBorder, [this.outerBorderMask]); |
||||
|
this.centerGroup = this.svgShape.group(); |
||||
|
this.offLabelShape = this.createOffLabel().addTo(this.centerGroup); |
||||
|
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize).center(cx, cy); |
||||
|
this.onLabelShape = this.createOnLabel(); |
||||
|
this.createMask(this.onCircleShape, [this.onLabelShape]); |
||||
|
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 0, 0); |
||||
|
|
||||
|
this.pressedTimeline = new Timeline(); |
||||
|
this.centerGroup.timeline(this.pressedTimeline); |
||||
|
this.onLabelShape.timeline(this.pressedTimeline); |
||||
|
this.pressedShadow.timeline(this.pressedTimeline); |
||||
|
} |
||||
|
|
||||
|
protected drawColorState(mainColor: PowerButtonColor) { |
||||
|
this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
} |
||||
|
|
||||
|
protected drawOff() { |
||||
|
this.onCircleShape.hide(); |
||||
|
this.outerBorder.show(); |
||||
|
this.centerGroup.show(); |
||||
|
} |
||||
|
|
||||
|
protected drawOn() { |
||||
|
this.centerGroup.hide(); |
||||
|
this.outerBorder.hide(); |
||||
|
this.onCircleShape.show(); |
||||
|
} |
||||
|
|
||||
|
protected onPressStart() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
const pressedScale = 0.75; |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale}); |
||||
|
this.pressedShadow.animate(6, 0.6); |
||||
|
} |
||||
|
|
||||
|
protected onPressEnd() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: 1}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: 1}); |
||||
|
this.pressedShadow.animateRestore(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class OutlinedPowerButtonShape extends PowerButtonShape { |
||||
|
private outerBorder: Circle; |
||||
|
private outerBorderMask: Circle; |
||||
|
private innerBorder: Circle; |
||||
|
private innerBorderMask: Circle; |
||||
|
private offLabelShape: Text; |
||||
|
private onCircleShape: Circle; |
||||
|
private onLabelShape: Text; |
||||
|
private pressedShadow: InnerShadowCircle; |
||||
|
private pressedTimeline: Timeline; |
||||
|
private centerGroup: G; |
||||
|
private onCenterGroup: G; |
||||
|
|
||||
|
protected drawShape() { |
||||
|
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) |
||||
|
.fill({opacity: 0}).stroke({width: 0}); |
||||
|
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 2).center(cx, cy); |
||||
|
this.createMask(this.outerBorder, [this.outerBorderMask]); |
||||
|
this.innerBorder = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy) |
||||
|
.fill({opacity: 0}).stroke({width: 0}); |
||||
|
this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy); |
||||
|
this.createMask(this.innerBorder, [this.innerBorderMask]); |
||||
|
this.centerGroup = this.svgShape.group(); |
||||
|
this.offLabelShape = this.createOffLabel().addTo(this.centerGroup); |
||||
|
this.onCenterGroup = this.svgShape.group(); |
||||
|
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 28).center(cx, cy) |
||||
|
.addTo(this.onCenterGroup); |
||||
|
this.onLabelShape = this.createOnLabel(); |
||||
|
this.createMask(this.onCircleShape, [this.onLabelShape]); |
||||
|
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 24, cx, cy, 0, 0); |
||||
|
|
||||
|
this.pressedTimeline = new Timeline(); |
||||
|
this.centerGroup.timeline(this.pressedTimeline); |
||||
|
this.onCenterGroup.timeline(this.pressedTimeline); |
||||
|
this.onLabelShape.timeline(this.pressedTimeline); |
||||
|
this.pressedShadow.timeline(this.pressedTimeline); |
||||
|
} |
||||
|
|
||||
|
protected drawColorState(mainColor: PowerButtonColor) { |
||||
|
this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.innerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
} |
||||
|
|
||||
|
protected drawOff() { |
||||
|
this.onCenterGroup.hide(); |
||||
|
this.centerGroup.show(); |
||||
|
} |
||||
|
|
||||
|
protected drawOn() { |
||||
|
this.centerGroup.hide(); |
||||
|
this.onCenterGroup.show(); |
||||
|
} |
||||
|
|
||||
|
protected onPressStart() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
const pressedScale = 0.75; |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); |
||||
|
powerButtonAnimation(this.onCenterGroup).transform({scale: 0.98}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale / 0.98}); |
||||
|
this.pressedShadow.animate(6, 0.6); |
||||
|
} |
||||
|
|
||||
|
protected onPressEnd() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: 1}); |
||||
|
powerButtonAnimation(this.onCenterGroup).transform({scale: 1}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: 1}); |
||||
|
this.pressedShadow.animateRestore(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class DefaultVolumePowerButtonShape extends PowerButtonShape { |
||||
|
private outerBorder: Circle; |
||||
|
private outerBorderMask: Circle; |
||||
|
private outerBorderGradient: Gradient; |
||||
|
private innerBorder: Circle; |
||||
|
private innerBorderMask: Circle; |
||||
|
private innerBorderGradient: Gradient; |
||||
|
private innerShadow: InnerShadowCircle; |
||||
|
//private innerShadowGradient: Gradient;
|
||||
|
//private innerShadowGradientStop: Stop;
|
||||
|
private offLabelShape: Text; |
||||
|
private onCircleShape: Circle; |
||||
|
private onLabelShape: Text; |
||||
|
private pressedTimeline: Timeline; |
||||
|
private centerGroup: G; |
||||
|
|
||||
|
protected drawShape() { |
||||
|
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) |
||||
|
.fill({opacity: 0}).stroke({width: 0}); |
||||
|
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy); |
||||
|
this.createMask(this.outerBorder, [this.outerBorderMask]); |
||||
|
this.outerBorderGradient = this.svgShape.gradient('linear', (add) => { |
||||
|
add.stop(0, '#CCCCCC', 1); |
||||
|
add.stop(1, '#FFFFFF', 1); |
||||
|
}).from(0.268, 0.92).to(0.832, 0.1188); |
||||
|
this.innerBorder = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy) |
||||
|
.fill({opacity: 0}).stroke({width: 0}); |
||||
|
this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy); |
||||
|
this.createMask(this.innerBorder, [this.innerBorderMask]); |
||||
|
this.innerBorderGradient = this.svgShape.gradient('linear', (add) => { |
||||
|
add.stop(0, '#CCCCCC', 1); |
||||
|
add.stop(1, '#FFFFFF', 1); |
||||
|
}).from(0.832, 0.1188).to(0.268, 0.92); |
||||
|
this.centerGroup = this.svgShape.group(); |
||||
|
this.offLabelShape = this.createOffLabel('400').addTo(this.centerGroup); |
||||
|
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 24).center(cx, cy); |
||||
|
this.onLabelShape = this.createOnLabel('400'); |
||||
|
this.createMask(this.onCircleShape, [this.onLabelShape]); |
||||
|
this.innerShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 24, cx, cy, 3, 0.3); |
||||
|
|
||||
|
this.pressedTimeline = new Timeline(); |
||||
|
this.centerGroup.timeline(this.pressedTimeline); |
||||
|
this.onLabelShape.timeline(this.pressedTimeline); |
||||
|
this.innerShadow.timeline(this.pressedTimeline); |
||||
|
} |
||||
|
|
||||
|
protected drawColorState(mainColor: PowerButtonColor){ |
||||
|
if (this.disabled) { |
||||
|
this.backgroundShape.removeClass('tb-small-shadow'); |
||||
|
if (!this.forcePressed) { |
||||
|
this.innerShadow.hide(); |
||||
|
} |
||||
|
this.outerBorder.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.innerBorder.attr({fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
} else { |
||||
|
this.backgroundShape.addClass('tb-small-shadow'); |
||||
|
this.innerShadow.show(); |
||||
|
this.outerBorder.fill(this.outerBorderGradient); |
||||
|
this.outerBorder.attr({ 'fill-opacity': 1 }); |
||||
|
this.innerBorder.fill(this.innerBorderGradient); |
||||
|
this.innerBorder.attr({ 'fill-opacity': 1 }); |
||||
|
} |
||||
|
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
} |
||||
|
|
||||
|
protected drawOff() { |
||||
|
this.onCircleShape.hide(); |
||||
|
this.centerGroup.show(); |
||||
|
this.innerBorder.show(); |
||||
|
} |
||||
|
|
||||
|
protected drawOn() { |
||||
|
if (this.disabled) { |
||||
|
this.innerBorder.hide(); |
||||
|
} else { |
||||
|
this.innerBorder.show(); |
||||
|
} |
||||
|
this.centerGroup.hide(); |
||||
|
this.onCircleShape.show(); |
||||
|
} |
||||
|
|
||||
|
protected onPressStart() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
this.innerShadow.show(); |
||||
|
const pressedScale = 0.75; |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale}); |
||||
|
this.innerShadow.animate(6, 0.6); |
||||
|
} |
||||
|
|
||||
|
protected onPressEnd() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: 1}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: 1}); |
||||
|
this.innerShadow.animateRestore().after(() => { |
||||
|
if (this.disabled) { |
||||
|
this.innerShadow.hide(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
class SimplifiedVolumePowerButtonShape extends PowerButtonShape { |
||||
|
|
||||
|
private outerBorder: Circle; |
||||
|
private outerBorderMask: Circle; |
||||
|
private offLabelShape: Text; |
||||
|
private onLabelShape: Text; |
||||
|
private innerShadow: InnerShadowCircle; |
||||
|
private pressedShadow: InnerShadowCircle; |
||||
|
private pressedTimeline: Timeline; |
||||
|
private centerGroup: G; |
||||
|
private onCenterGroup: G; |
||||
|
|
||||
|
|
||||
|
protected drawShape() { |
||||
|
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) |
||||
|
.fill({color: '#FAFAFA', opacity: 1}).stroke({width: 0}); |
||||
|
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 4).center(cx, cy); |
||||
|
this.createMask(this.outerBorder, [this.outerBorderMask]); |
||||
|
this.centerGroup = this.svgShape.group(); |
||||
|
this.offLabelShape = this.createOffLabel().addTo(this.centerGroup); |
||||
|
this.onCenterGroup = this.svgShape.group(); |
||||
|
this.onLabelShape = this.createOnLabel().addTo(this.onCenterGroup); |
||||
|
this.innerShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 3, 0.3); |
||||
|
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 4, cx, cy, 0, 0); |
||||
|
this.pressedTimeline = new Timeline(); |
||||
|
this.centerGroup.timeline(this.pressedTimeline); |
||||
|
this.onCenterGroup.timeline(this.pressedTimeline); |
||||
|
this.pressedShadow.timeline(this.pressedTimeline); |
||||
|
} |
||||
|
|
||||
|
protected drawColorState(mainColor: PowerButtonColor){ |
||||
|
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.onLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
} |
||||
|
|
||||
|
protected drawOff() { |
||||
|
if (!this.pressed) { |
||||
|
this.backgroundShape.addClass('tb-shadow'); |
||||
|
} |
||||
|
this.innerShadow.hide(); |
||||
|
this.onCenterGroup.hide(); |
||||
|
this.centerGroup.show(); |
||||
|
} |
||||
|
|
||||
|
protected drawOn() { |
||||
|
this.backgroundShape.removeClass('tb-shadow'); |
||||
|
this.centerGroup.hide(); |
||||
|
this.onCenterGroup.show(); |
||||
|
this.innerShadow.show(); |
||||
|
} |
||||
|
|
||||
|
protected onPressStart() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
const pressedScale = 0.75; |
||||
|
if (!this.value) { |
||||
|
this.backgroundShape.removeClass('tb-shadow'); |
||||
|
} |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); |
||||
|
powerButtonAnimation(this.onCenterGroup).transform({scale: pressedScale}); |
||||
|
this.pressedShadow.animate(8, 0.4); |
||||
|
} |
||||
|
|
||||
|
protected onPressEnd() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: 1}); |
||||
|
powerButtonAnimation(this.onCenterGroup).transform({scale: 1}); |
||||
|
this.pressedShadow.animateRestore().after(() => { |
||||
|
if (!this.value) { |
||||
|
this.backgroundShape.addClass('tb-shadow'); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class OutlinedVolumePowerButtonShape extends PowerButtonShape { |
||||
|
private outerBorder: Circle; |
||||
|
private outerBorderMask: Circle; |
||||
|
private outerBorderGradient: Gradient; |
||||
|
private innerBorder: Circle; |
||||
|
private innerBorderMask: Circle; |
||||
|
private offLabelShape: Text; |
||||
|
private onCircleShape: Circle; |
||||
|
private onLabelShape: Text; |
||||
|
private pressedShadow: InnerShadowCircle; |
||||
|
private pressedTimeline: Timeline; |
||||
|
private centerGroup: G; |
||||
|
private onCenterGroup: G; |
||||
|
|
||||
|
protected drawShape() { |
||||
|
this.outerBorder = this.svgShape.circle(powerButtonShapeSize).center(cx, cy) |
||||
|
.fill({opacity: 0}).stroke({width: 0}); |
||||
|
this.outerBorderMask = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy); |
||||
|
this.createMask(this.outerBorder, [this.outerBorderMask]); |
||||
|
this.outerBorderGradient = this.svgShape.gradient('linear', (add) => { |
||||
|
add.stop(0, '#CCCCCC', 1); |
||||
|
add.stop(1, '#FFFFFF', 1); |
||||
|
}).from(0.268, 0.92).to(0.832, 0.1188); |
||||
|
this.innerBorder = this.svgShape.circle(powerButtonShapeSize - 20).center(cx, cy) |
||||
|
.fill({opacity: 0}).stroke({width: 0}); |
||||
|
this.innerBorderMask = this.svgShape.circle(powerButtonShapeSize - 30).center(cx, cy); |
||||
|
this.createMask(this.innerBorder, [this.innerBorderMask]); |
||||
|
this.centerGroup = this.svgShape.group(); |
||||
|
this.offLabelShape = this.createOffLabel('800').addTo(this.centerGroup); |
||||
|
this.onCenterGroup = this.svgShape.group(); |
||||
|
this.onCircleShape = this.svgShape.circle(powerButtonShapeSize - 30).center(cx, cy) |
||||
|
.addTo(this.onCenterGroup); |
||||
|
this.onLabelShape = this.createOnLabel('800'); |
||||
|
this.createMask(this.onCircleShape, [this.onLabelShape]); |
||||
|
this.pressedShadow = new InnerShadowCircle(this.svgShape, powerButtonShapeSize - 30, cx, cy, 0, 0); |
||||
|
this.backgroundShape.addClass('tb-small-shadow'); |
||||
|
|
||||
|
this.pressedTimeline = new Timeline(); |
||||
|
this.centerGroup.timeline(this.pressedTimeline); |
||||
|
this.onCenterGroup.timeline(this.pressedTimeline); |
||||
|
this.onLabelShape.timeline(this.pressedTimeline); |
||||
|
this.pressedShadow.timeline(this.pressedTimeline); |
||||
|
} |
||||
|
|
||||
|
protected drawColorState(mainColor: PowerButtonColor){ |
||||
|
if (this.disabled) { |
||||
|
this.outerBorder.attr({ fill: '#000000', 'fill-opacity': 0.03}); |
||||
|
} else { |
||||
|
this.outerBorder.fill(this.outerBorderGradient); |
||||
|
this.outerBorder.attr({ 'fill-opacity': 1 }); |
||||
|
} |
||||
|
this.innerBorder.attr({fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
this.onCircleShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); |
||||
|
} |
||||
|
|
||||
|
protected drawOff() { |
||||
|
this.onCenterGroup.hide(); |
||||
|
this.centerGroup.show(); |
||||
|
this.innerBorder.show(); |
||||
|
} |
||||
|
|
||||
|
protected drawOn() { |
||||
|
this.innerBorder.hide(); |
||||
|
this.centerGroup.hide(); |
||||
|
this.onCenterGroup.show(); |
||||
|
} |
||||
|
|
||||
|
protected onPressStart() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
const pressedScale = 0.75; |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: pressedScale}); |
||||
|
powerButtonAnimation(this.onCenterGroup).transform({scale: 0.98}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: pressedScale / 0.98}); |
||||
|
this.pressedShadow.animate(6, 0.6); |
||||
|
} |
||||
|
|
||||
|
protected onPressEnd() { |
||||
|
this.pressedTimeline.finish(); |
||||
|
powerButtonAnimation(this.centerGroup).transform({scale: 1}); |
||||
|
powerButtonAnimation(this.onCenterGroup).transform({scale: 1}); |
||||
|
powerButtonAnimation(this.onLabelShape).transform({scale: 1}); |
||||
|
this.pressedShadow.animateRestore(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,142 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2024 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. |
||||
|
|
||||
|
--> |
||||
|
<ng-container [formGroup]="powerButtonWidgetSettingsForm"> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" translate>widgets.power-button.behavior</div> |
||||
|
<div class="tb-form-row"> |
||||
|
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.initial-state-hint' | translate}}" translate>widgets.rpc-state.initial-state</div> |
||||
|
<tb-get-value-action-settings fxFlex |
||||
|
panelTitle="widgets.rpc-state.initial-state" |
||||
|
[valueType]="valueType.BOOLEAN" |
||||
|
trueLabel="widgets.rpc-state.on" |
||||
|
falseLabel="widgets.rpc-state.off" |
||||
|
stateLabel="widgets.rpc-state.on" |
||||
|
[aliasController]="aliasController" |
||||
|
[targetDevice]="targetDevice" |
||||
|
[widgetType]="widgetType" |
||||
|
formControlName="initialState"></tb-get-value-action-settings> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.power-button.power-on-hint' | translate}}" translate>widgets.power-button.power-on</div> |
||||
|
<tb-set-value-action-settings fxFlex |
||||
|
panelTitle="widgets.power-button.power-on " |
||||
|
[aliasController]="aliasController" |
||||
|
[targetDevice]="targetDevice" |
||||
|
[widgetType]="widgetType" |
||||
|
formControlName="onUpdateState"></tb-set-value-action-settings> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.power-button.power-off-hint' | translate}}" translate>widgets.power-button.power-off</div> |
||||
|
<tb-set-value-action-settings fxFlex |
||||
|
panelTitle="widgets.power-button.power-off" |
||||
|
[aliasController]="aliasController" |
||||
|
[targetDevice]="targetDevice" |
||||
|
[widgetType]="widgetType" |
||||
|
formControlName="offUpdateState"></tb-set-value-action-settings> |
||||
|
</div> |
||||
|
<div class="tb-form-row"> |
||||
|
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'widgets.rpc-state.disabled-state-hint' | translate}}" translate>widgets.rpc-state.disabled-state</div> |
||||
|
<tb-get-value-action-settings fxFlex |
||||
|
panelTitle="widgets.rpc-state.disabled-state" |
||||
|
[valueType]="valueType.BOOLEAN" |
||||
|
stateLabel="widgets.rpc-state.disabled" |
||||
|
[aliasController]="aliasController" |
||||
|
[targetDevice]="targetDevice" |
||||
|
[widgetType]="widgetType" |
||||
|
formControlName="disabledState"></tb-get-value-action-settings> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" translate>widget-config.card-style</div> |
||||
|
<tb-image-cards-select rowHeight="1:1" |
||||
|
[cols]="{columns: 3, |
||||
|
breakpoints: { |
||||
|
'lt-sm': 1, |
||||
|
'lt-md': 2 |
||||
|
}}" |
||||
|
label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout"> |
||||
|
<tb-image-cards-select-option *ngFor="let layout of powerButtonLayouts" |
||||
|
[value]="layout" |
||||
|
[image]="powerButtonLayoutImageMap.get(layout)"> |
||||
|
{{ powerButtonLayoutTranslationMap.get(layout) | translate }} |
||||
|
</tb-image-cards-select-option> |
||||
|
</tb-image-cards-select> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widgets.background.background' | translate }}</div> |
||||
|
<tb-background-settings formControlName="background"> |
||||
|
</tb-background-settings> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" translate>widgets.power-button.button</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widgets.power-button.power-on-colors' | translate }}</div> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px"> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.main</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="mainColorOn"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
<mat-divider vertical></mat-divider> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.background</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="backgroundColorOn"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widgets.power-button.power-off-colors' | translate }}</div> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px"> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.main</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="mainColorOff"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
<mat-divider vertical></mat-divider> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.background</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="backgroundColorOff"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widgets.power-button.disabled-colors' | translate }}</div> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px"> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.main</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="mainColorDisabled"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
<mat-divider vertical></mat-divider> |
||||
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
||||
|
<div translate>widgets.power-button.background</div> |
||||
|
<tb-color-input asBoxInput |
||||
|
formControlName="backgroundColorDisabled"> |
||||
|
</tb-color-input> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</ng-container> |
||||
@ -0,0 +1,88 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2024 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 } from '@angular/core'; |
||||
|
import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; |
||||
|
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; |
||||
|
import { Store } from '@ngrx/store'; |
||||
|
import { AppState } from '@core/core.state'; |
||||
|
import { ValueType } from '@shared/models/constants'; |
||||
|
import { |
||||
|
powerButtonDefaultSettings, |
||||
|
powerButtonLayoutImages, |
||||
|
powerButtonLayouts, |
||||
|
powerButtonLayoutTranslations |
||||
|
} from '@home/components/widget/lib/rpc/power-button-widget.models'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-power-button-widget-settings', |
||||
|
templateUrl: './power-button-widget-settings.component.html', |
||||
|
styleUrls: ['./../widget-settings.scss'] |
||||
|
}) |
||||
|
export class PowerButtonWidgetSettingsComponent extends WidgetSettingsComponent { |
||||
|
|
||||
|
get targetDevice(): TargetDevice { |
||||
|
return this.widgetConfig?.config?.targetDevice; |
||||
|
} |
||||
|
|
||||
|
get widgetType(): widgetType { |
||||
|
return this.widgetConfig?.widgetType; |
||||
|
} |
||||
|
|
||||
|
powerButtonLayouts = powerButtonLayouts; |
||||
|
|
||||
|
powerButtonLayoutTranslationMap = powerButtonLayoutTranslations; |
||||
|
powerButtonLayoutImageMap = powerButtonLayoutImages; |
||||
|
|
||||
|
valueType = ValueType; |
||||
|
|
||||
|
powerButtonWidgetSettingsForm: UntypedFormGroup; |
||||
|
|
||||
|
constructor(protected store: Store<AppState>, |
||||
|
private fb: UntypedFormBuilder) { |
||||
|
super(store); |
||||
|
} |
||||
|
|
||||
|
protected settingsForm(): UntypedFormGroup { |
||||
|
return this.powerButtonWidgetSettingsForm; |
||||
|
} |
||||
|
|
||||
|
protected defaultSettings(): WidgetSettings { |
||||
|
return {...powerButtonDefaultSettings}; |
||||
|
} |
||||
|
|
||||
|
protected onSettingsSet(settings: WidgetSettings) { |
||||
|
this.powerButtonWidgetSettingsForm = this.fb.group({ |
||||
|
initialState: [settings.initialState, []], |
||||
|
onUpdateState: [settings.onUpdateState, []], |
||||
|
offUpdateState: [settings.offUpdateState, []], |
||||
|
disabledState: [settings.disabledState, []], |
||||
|
|
||||
|
layout: [settings.layout, []], |
||||
|
|
||||
|
mainColorOn: [settings.mainColorOn, []], |
||||
|
backgroundColorOn: [settings.backgroundColorOn, []], |
||||
|
|
||||
|
mainColorOff: [settings.mainColorOff, []], |
||||
|
backgroundColorOff: [settings.backgroundColorOff, []], |
||||
|
|
||||
|
mainColorDisabled: [settings.mainColorDisabled, []], |
||||
|
backgroundColorDisabled: [settings.backgroundColorDisabled, []], |
||||
|
|
||||
|
background: [settings.background, []] |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
Loading…
Reference in new issue