55 changed files with 2764 additions and 377 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,71 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2021 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 { Injectable } from '@angular/core'; |
||||
|
import { HttpClient } from '@angular/common/http'; |
||||
|
import { TranslateService } from '@ngx-translate/core'; |
||||
|
import { Observable, of } from 'rxjs'; |
||||
|
import { catchError, tap } from 'rxjs/operators'; |
||||
|
|
||||
|
const NOT_FOUND_CONTENT = '## Not found'; |
||||
|
|
||||
|
@Injectable({ |
||||
|
providedIn: 'root' |
||||
|
}) |
||||
|
export class HelpService { |
||||
|
|
||||
|
private helpCache: {[lang: string]: {[key: string]: string}} = {}; |
||||
|
|
||||
|
constructor( |
||||
|
private translate: TranslateService, |
||||
|
private http: HttpClient |
||||
|
) {} |
||||
|
|
||||
|
getHelpContent(key: string): Observable<string> { |
||||
|
const lang = this.translate.currentLang; |
||||
|
if (this.helpCache[lang] && this.helpCache[lang][key]) { |
||||
|
return of(this.helpCache[lang][key]); |
||||
|
} else { |
||||
|
return this.loadHelpContent(lang, key).pipe( |
||||
|
catchError(() => { |
||||
|
const defaultLang = this.translate.getDefaultLang(); |
||||
|
if (lang !== defaultLang) { |
||||
|
return this.loadHelpContent(defaultLang, key).pipe( |
||||
|
catchError(() => { |
||||
|
return of(NOT_FOUND_CONTENT); |
||||
|
}) |
||||
|
); |
||||
|
} else { |
||||
|
return of(NOT_FOUND_CONTENT); |
||||
|
} |
||||
|
}), |
||||
|
tap((content) => { |
||||
|
let langContent = this.helpCache[lang]; |
||||
|
if (!langContent) { |
||||
|
langContent = {}; |
||||
|
this.helpCache[lang] = langContent; |
||||
|
} |
||||
|
langContent[key] = content; |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private loadHelpContent(lang: string, key: string): Observable<string> { |
||||
|
return this.http.get(`/assets/help/${lang}/${key}.md`, {responseType: 'text'} ); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -1,131 +0,0 @@ |
|||||
/** |
|
||||
* Copyright © 2016-2021 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-markdown-view { |
|
||||
display: block; |
|
||||
height: 100%; |
|
||||
::ng-deep { |
|
||||
h1 { |
|
||||
font-size: 32px; |
|
||||
padding-right: 60px; |
|
||||
} |
|
||||
|
|
||||
h1, h2, h3, h4, h5, h6 { |
|
||||
line-height: normal; |
|
||||
font-weight: 500; |
|
||||
margin-bottom: 30px; |
|
||||
padding-bottom: 10px; |
|
||||
border-bottom: 1px solid #ccc; |
|
||||
} |
|
||||
|
|
||||
p { |
|
||||
font-size: 16px; |
|
||||
font-weight: 400; |
|
||||
line-height: 1.25em; |
|
||||
margin: 0; |
|
||||
} |
|
||||
|
|
||||
p+p { |
|
||||
margin-top: 10px; |
|
||||
} |
|
||||
|
|
||||
table { |
|
||||
width: 100%; |
|
||||
border: 1px solid #ccc; |
|
||||
border-spacing: 0; |
|
||||
margin-top: 30px; |
|
||||
margin-bottom: 30px; |
|
||||
} |
|
||||
|
|
||||
thead { |
|
||||
background-color: #555; |
|
||||
color: #fff; |
|
||||
} |
|
||||
|
|
||||
th, td { |
|
||||
font-size: .85em; |
|
||||
padding: 8px; |
|
||||
margin: 0; |
|
||||
text-align: left; |
|
||||
} |
|
||||
|
|
||||
td[align=center], th[align=center] { |
|
||||
text-align: center; |
|
||||
} |
|
||||
|
|
||||
td[align=right], th[align=right] { |
|
||||
text-align: right; |
|
||||
} |
|
||||
|
|
||||
tr:nth-child(even) { |
|
||||
background-color: #f7f7f7; |
|
||||
} |
|
||||
|
|
||||
code:not([class*=language-]) { |
|
||||
background: #f5f5f5; |
|
||||
border-radius: 2px; |
|
||||
color: #dd4a68; |
|
||||
padding: 2px 4px; |
|
||||
} |
|
||||
|
|
||||
div.code-wrapper { |
|
||||
position: relative; |
|
||||
button.clipboard-btn { |
|
||||
cursor: pointer; |
|
||||
margin: 0; |
|
||||
border: 0; |
|
||||
outline: none; |
|
||||
position: absolute; |
|
||||
top: 5px; |
|
||||
right: 5px; |
|
||||
background: #fff; |
|
||||
box-shadow: 0 1px 8px 0 rgba(0,0,0,0.2), 0 3px 4px 0 rgba(0,0,0,0.14), 0 3px 3px -2px rgba(0,0,0,0.12); |
|
||||
border-radius: 5px; |
|
||||
opacity: 0; |
|
||||
transition: opacity .3s; |
|
||||
padding: 3px 6px; |
|
||||
line-height: 16px; |
|
||||
img { |
|
||||
width: 18px; |
|
||||
} |
|
||||
&:hover { |
|
||||
background: #f9f9f9; |
|
||||
} |
|
||||
&:active { |
|
||||
background-color: #ececec; |
|
||||
box-shadow: inset 1px -1px 4px 0px rgba(0,0,0,0.4); |
|
||||
} |
|
||||
} |
|
||||
&:hover { |
|
||||
button.clipboard-btn { |
|
||||
opacity: .85; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
th, td { |
|
||||
div.code-wrapper { |
|
||||
display: inline-block; |
|
||||
button.clipboard-btn { |
|
||||
top: -5px; |
|
||||
right: -30px; |
|
||||
padding: 0 3px; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,127 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2021 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 { |
||||
|
ComponentFactory, ComponentRef, |
||||
|
Directive, EventEmitter, Injector, |
||||
|
Input, |
||||
|
OnChanges, Output, Renderer2, |
||||
|
SimpleChange, |
||||
|
SimpleChanges, |
||||
|
TemplateRef, |
||||
|
ViewContainerRef |
||||
|
} from '@angular/core'; |
||||
|
|
||||
|
@Directive({ |
||||
|
// tslint:disable-next-line:directive-selector
|
||||
|
selector: '[tbComponentOutlet]', |
||||
|
exportAs: 'tbComponentOutlet' |
||||
|
}) |
||||
|
export class TbComponentOutletDirective<_T = unknown> implements OnChanges { |
||||
|
private componentRef: ComponentRef<any> | null = null; |
||||
|
private context = new TbComponentOutletContext(); |
||||
|
@Input() tbComponentOutletContext: any | null = null; |
||||
|
@Input() tbComponentStyle: { [klass: string]: any } | null = null; |
||||
|
@Input() tbComponentInjector: Injector | null = null; |
||||
|
@Input() tbComponentOutlet: ComponentFactory<any> = null; |
||||
|
@Output() componentChange = new EventEmitter<ComponentRef<any>>(); |
||||
|
|
||||
|
static ngTemplateContextGuard<T>( |
||||
|
// tslint:disable-next-line:variable-name
|
||||
|
_dir: TbComponentOutletDirective<T>, |
||||
|
// tslint:disable-next-line:variable-name
|
||||
|
_ctx: any |
||||
|
): _ctx is TbComponentOutletContext { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
private recreateComponent(): void { |
||||
|
this.viewContainer.clear(); |
||||
|
this.componentRef = this.viewContainer.createComponent(this.tbComponentOutlet, 0, this.tbComponentInjector); |
||||
|
this.componentChange.next(this.componentRef); |
||||
|
if (this.tbComponentOutletContext) { |
||||
|
for (const propName of Object.keys(this.tbComponentOutletContext)) { |
||||
|
this.componentRef.instance[propName] = this.tbComponentOutletContext[propName]; |
||||
|
} |
||||
|
} |
||||
|
if (this.tbComponentStyle) { |
||||
|
for (const propName of Object.keys(this.tbComponentStyle)) { |
||||
|
this.renderer.setStyle(this.componentRef.location.nativeElement, propName, this.tbComponentStyle[propName]); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private updateContext(): void { |
||||
|
const newCtx = this.tbComponentOutletContext; |
||||
|
const oldCtx = this.componentRef.instance as any; |
||||
|
if (newCtx) { |
||||
|
for (const propName of Object.keys(newCtx)) { |
||||
|
oldCtx[propName] = newCtx[propName]; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
constructor(private viewContainer: ViewContainerRef, |
||||
|
private renderer: Renderer2) {} |
||||
|
|
||||
|
ngOnChanges(changes: SimpleChanges): void { |
||||
|
const { tbComponentOutletContext, tbComponentOutlet } = changes; |
||||
|
const shouldRecreateComponent = (): boolean => { |
||||
|
let shouldOutletRecreate = false; |
||||
|
if (tbComponentOutlet) { |
||||
|
if (tbComponentOutlet.firstChange) { |
||||
|
shouldOutletRecreate = true; |
||||
|
} else { |
||||
|
const isPreviousOutletTemplate = tbComponentOutlet.previousValue instanceof ComponentFactory; |
||||
|
const isCurrentOutletTemplate = tbComponentOutlet.currentValue instanceof ComponentFactory; |
||||
|
shouldOutletRecreate = isPreviousOutletTemplate || isCurrentOutletTemplate; |
||||
|
} |
||||
|
} |
||||
|
const hasContextShapeChanged = (ctxChange: SimpleChange): boolean => { |
||||
|
const prevCtxKeys = Object.keys(ctxChange.previousValue || {}); |
||||
|
const currCtxKeys = Object.keys(ctxChange.currentValue || {}); |
||||
|
if (prevCtxKeys.length === currCtxKeys.length) { |
||||
|
for (const propName of currCtxKeys) { |
||||
|
if (prevCtxKeys.indexOf(propName) === -1) { |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
return false; |
||||
|
} else { |
||||
|
return true; |
||||
|
} |
||||
|
}; |
||||
|
const shouldContextRecreate = |
||||
|
tbComponentOutletContext && hasContextShapeChanged(tbComponentOutletContext); |
||||
|
return shouldContextRecreate || shouldOutletRecreate; |
||||
|
}; |
||||
|
|
||||
|
if (tbComponentOutlet) { |
||||
|
this.context.$implicit = tbComponentOutlet.currentValue; |
||||
|
} |
||||
|
|
||||
|
const recreateComponent = shouldRecreateComponent(); |
||||
|
if (recreateComponent) { |
||||
|
this.recreateComponent(); |
||||
|
} else { |
||||
|
this.updateContext(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class TbComponentOutletContext { |
||||
|
public $implicit: any; |
||||
|
} |
||||
@ -0,0 +1,118 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2021 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 { |
||||
|
Directive, |
||||
|
EmbeddedViewRef, |
||||
|
Input, |
||||
|
OnChanges, |
||||
|
SimpleChange, |
||||
|
SimpleChanges, |
||||
|
TemplateRef, |
||||
|
ViewContainerRef |
||||
|
} from '@angular/core'; |
||||
|
|
||||
|
@Directive({ |
||||
|
// tslint:disable-next-line:directive-selector
|
||||
|
selector: '[tbStringTemplateOutlet]', |
||||
|
exportAs: 'tbStringTemplateOutlet' |
||||
|
}) |
||||
|
export class TbStringTemplateOutletDirective<_T = unknown> implements OnChanges { |
||||
|
private embeddedViewRef: EmbeddedViewRef<any> | null = null; |
||||
|
private context = new TbStringTemplateOutletContext(); |
||||
|
@Input() tbStringTemplateOutletContext: any | null = null; |
||||
|
@Input() tbStringTemplateOutlet: any | TemplateRef<any> = null; |
||||
|
|
||||
|
static ngTemplateContextGuard<T>( |
||||
|
// tslint:disable-next-line:variable-name
|
||||
|
_dir: TbStringTemplateOutletDirective<T>, |
||||
|
// tslint:disable-next-line:variable-name
|
||||
|
_ctx: any |
||||
|
): _ctx is TbStringTemplateOutletContext { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
private recreateView(): void { |
||||
|
this.viewContainer.clear(); |
||||
|
const isTemplateRef = this.tbStringTemplateOutlet instanceof TemplateRef; |
||||
|
const templateRef = (isTemplateRef ? this.tbStringTemplateOutlet : this.templateRef) as any; |
||||
|
this.embeddedViewRef = this.viewContainer.createEmbeddedView( |
||||
|
templateRef, |
||||
|
isTemplateRef ? this.tbStringTemplateOutletContext : this.context |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private updateContext(): void { |
||||
|
const isTemplateRef = this.tbStringTemplateOutlet instanceof TemplateRef; |
||||
|
const newCtx = isTemplateRef ? this.tbStringTemplateOutletContext : this.context; |
||||
|
const oldCtx = this.embeddedViewRef.context as any; |
||||
|
if (newCtx) { |
||||
|
for (const propName of Object.keys(newCtx)) { |
||||
|
oldCtx[propName] = newCtx[propName]; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
constructor(private viewContainer: ViewContainerRef, private templateRef: TemplateRef<any>) {} |
||||
|
|
||||
|
ngOnChanges(changes: SimpleChanges): void { |
||||
|
const { tbStringTemplateOutletContext, tbStringTemplateOutlet } = changes; |
||||
|
const shouldRecreateView = (): boolean => { |
||||
|
let shouldOutletRecreate = false; |
||||
|
if (tbStringTemplateOutlet) { |
||||
|
if (tbStringTemplateOutlet.firstChange) { |
||||
|
shouldOutletRecreate = true; |
||||
|
} else { |
||||
|
const isPreviousOutletTemplate = tbStringTemplateOutlet.previousValue instanceof TemplateRef; |
||||
|
const isCurrentOutletTemplate = tbStringTemplateOutlet.currentValue instanceof TemplateRef; |
||||
|
shouldOutletRecreate = isPreviousOutletTemplate || isCurrentOutletTemplate; |
||||
|
} |
||||
|
} |
||||
|
const hasContextShapeChanged = (ctxChange: SimpleChange): boolean => { |
||||
|
const prevCtxKeys = Object.keys(ctxChange.previousValue || {}); |
||||
|
const currCtxKeys = Object.keys(ctxChange.currentValue || {}); |
||||
|
if (prevCtxKeys.length === currCtxKeys.length) { |
||||
|
for (const propName of currCtxKeys) { |
||||
|
if (prevCtxKeys.indexOf(propName) === -1) { |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
return false; |
||||
|
} else { |
||||
|
return true; |
||||
|
} |
||||
|
}; |
||||
|
const shouldContextRecreate = |
||||
|
tbStringTemplateOutletContext && hasContextShapeChanged(tbStringTemplateOutletContext); |
||||
|
return shouldContextRecreate || shouldOutletRecreate; |
||||
|
}; |
||||
|
|
||||
|
if (tbStringTemplateOutlet) { |
||||
|
this.context.$implicit = tbStringTemplateOutlet.currentValue; |
||||
|
} |
||||
|
|
||||
|
const recreateView = shouldRecreateView(); |
||||
|
if (recreateView) { |
||||
|
this.recreateView(); |
||||
|
} else { |
||||
|
this.updateContext(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class TbStringTemplateOutletContext { |
||||
|
public $implicit: any; |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2021 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 *ngIf="markdownText$ | async as text;"> |
||||
|
<markdown [data]="text" lineNumbers (ready)="onMarkdownReady()" class="tb-help-markdown tb-markdown-view" (click)="markdownClick($event)"></markdown> |
||||
|
</ng-container> |
||||
@ -0,0 +1,33 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2021 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
:host { |
||||
|
.tb-help-markdown { |
||||
|
overflow: auto; |
||||
|
max-width: 50vw; |
||||
|
max-height: 50vh; |
||||
|
margin-top: 30px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
:host ::ng-deep { |
||||
|
.tb-help-markdown.tb-markdown-view { |
||||
|
h1, h2, h3, h4, h5, h6 { |
||||
|
&:first-child { |
||||
|
padding-top: 0; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,104 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2021 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, |
||||
|
EventEmitter, |
||||
|
Input, OnChanges, |
||||
|
OnDestroy, OnInit, |
||||
|
Output, SimpleChanges |
||||
|
} from '@angular/core'; |
||||
|
import { BehaviorSubject } from 'rxjs'; |
||||
|
import { delay, share } from 'rxjs/operators'; |
||||
|
import { HelpService } from '@core/services/help.service'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-help-markdown', |
||||
|
templateUrl: './help-markdown.component.html', |
||||
|
styleUrls: ['./help-markdown.component.scss'] |
||||
|
}) |
||||
|
export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges { |
||||
|
|
||||
|
@Input() helpId: string; |
||||
|
|
||||
|
@Input() visible: boolean; |
||||
|
|
||||
|
@Output() markdownReady = new EventEmitter<void>(); |
||||
|
|
||||
|
markdownText = new BehaviorSubject<string>(null); |
||||
|
|
||||
|
markdownText$ = this.markdownText.pipe( |
||||
|
share() |
||||
|
); |
||||
|
|
||||
|
isMarkdownReady = false; |
||||
|
|
||||
|
private loadHelpPending = false; |
||||
|
|
||||
|
constructor(private help: HelpService) {} |
||||
|
|
||||
|
ngOnInit(): void { |
||||
|
this.loadHelpWhenVisible(); |
||||
|
} |
||||
|
|
||||
|
ngOnDestroy(): void { |
||||
|
this.markdownText.complete(); |
||||
|
} |
||||
|
|
||||
|
ngOnChanges(changes: SimpleChanges): void { |
||||
|
for (const propName of Object.keys(changes)) { |
||||
|
const change = changes[propName]; |
||||
|
if (!change.firstChange && change.currentValue !== change.previousValue) { |
||||
|
if (propName === 'visible') { |
||||
|
if (this.loadHelpPending) { |
||||
|
this.loadHelpPending = false; |
||||
|
this.loadHelp(); |
||||
|
} |
||||
|
} |
||||
|
if (propName === 'helpId') { |
||||
|
this.markdownText.next(null); |
||||
|
this.loadHelpWhenVisible(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private loadHelpWhenVisible() { |
||||
|
if (this.visible) { |
||||
|
this.loadHelp(); |
||||
|
} else { |
||||
|
this.loadHelpPending = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private loadHelp() { |
||||
|
if (this.helpId) { |
||||
|
this.help.getHelpContent(this.helpId).subscribe((content) => { |
||||
|
this.markdownText.next(content); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMarkdownReady() { |
||||
|
this.isMarkdownReady = true; |
||||
|
this.markdownReady.next(); |
||||
|
} |
||||
|
|
||||
|
markdownClick($event: MouseEvent) { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2021 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 style="position: relative;"> |
||||
|
<button color="primary" mat-button mat-icon-button |
||||
|
class="tb-help-popup-button tb-mat-32" |
||||
|
type="button" |
||||
|
(click)="toggleHelp()" |
||||
|
matTooltip="{{'help.show-help' | translate}}" |
||||
|
matTooltipPosition="above"> |
||||
|
<mat-icon class="material-icons">{{popoverVisible ? 'help' : 'help_outline'}}</mat-icon> |
||||
|
</button> |
||||
|
<div *ngIf="popoverVisible && !popoverReady" fxFlex fxLayoutAlign="center center" class="tb-absolute-fill tb-help-popup-button-loading"> |
||||
|
<mat-spinner mode="indeterminate" diameter="20" strokeWidth="2"></mat-spinner> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,21 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2021 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-help-popup-button { |
||||
|
} |
||||
|
.tb-help-popup-button-loading { |
||||
|
background: #fff; |
||||
|
border-radius: 50%; |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2021 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, Input, OnDestroy, Renderer2, ViewContainerRef, ViewEncapsulation } from '@angular/core'; |
||||
|
import { TbPopoverService } from '@shared/components/popover.component'; |
||||
|
|
||||
|
@Component({ |
||||
|
// tslint:disable-next-line:component-selector
|
||||
|
selector: '[tb-help-popup]', |
||||
|
templateUrl: './help-popup.component.html', |
||||
|
styleUrls: ['./help-popup.component.scss'], |
||||
|
encapsulation: ViewEncapsulation.None |
||||
|
}) |
||||
|
export class HelpPopupComponent implements OnDestroy { |
||||
|
|
||||
|
// tslint:disable-next-line:no-input-rename
|
||||
|
@Input('tb-help-popup') helpId: string; |
||||
|
|
||||
|
popoverVisible = false; |
||||
|
popoverReady = true; |
||||
|
|
||||
|
constructor(private elementRef: ElementRef, |
||||
|
private viewContainerRef: ViewContainerRef, |
||||
|
private renderer: Renderer2, |
||||
|
private popoverService: TbPopoverService) {} |
||||
|
|
||||
|
toggleHelp() { |
||||
|
this.popoverService.toggleHelpPopover(this.elementRef.nativeElement, this.renderer, this.viewContainerRef, |
||||
|
this.helpId, |
||||
|
(visible) => { |
||||
|
this.popoverVisible = visible; |
||||
|
}, (ready => { |
||||
|
this.popoverReady = ready; |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
ngOnDestroy(): void { |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -1,99 +0,0 @@ |
|||||
///
|
|
||||
/// Copyright © 2016-2021 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 { MarkedOptions, MarkedRenderer } from 'ngx-markdown'; |
|
||||
|
|
||||
export function markedOptionsFactory(): MarkedOptions { |
|
||||
const renderer = new MarkedRenderer(); |
|
||||
const renderer2 = new MarkedRenderer(); |
|
||||
|
|
||||
const copyCodeBlock = '{:copy-code}'; |
|
||||
|
|
||||
let id = 1; |
|
||||
|
|
||||
renderer.code = (code: string, language: string | undefined, isEscaped: boolean) => { |
|
||||
if (code.endsWith(copyCodeBlock)) { |
|
||||
code = code.substring(0, code.length - copyCodeBlock.length); |
|
||||
const content = renderer2.code(code, language, isEscaped); |
|
||||
id++; |
|
||||
return wrapCopyCode(id, content, code); |
|
||||
} else { |
|
||||
return renderer2.code(code, language, isEscaped); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
renderer.tablecell = (content: string, flags: { |
|
||||
header: boolean; |
|
||||
align: 'center' | 'left' | 'right' | null; |
|
||||
}) => { |
|
||||
if (content.endsWith(copyCodeBlock)) { |
|
||||
content = content.substring(0, content.length - copyCodeBlock.length); |
|
||||
id++; |
|
||||
content = wrapCopyCode(id, content, content); |
|
||||
} |
|
||||
return renderer2.tablecell(content, flags); |
|
||||
}; |
|
||||
|
|
||||
return { |
|
||||
renderer, |
|
||||
headerIds: true, |
|
||||
gfm: true, |
|
||||
breaks: false, |
|
||||
pedantic: false, |
|
||||
smartLists: true, |
|
||||
smartypants: false, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
function wrapCopyCode(id: number, content: string, code: string): string { |
|
||||
return '<div class="code-wrapper">' + content + '<span id="copyCodeId' + id + '" style="display: none;">' + code + '</span>' + |
|
||||
'<button id="copyCodeBtn' + id + '" onClick="markdownCopyCode(' + id + ')" ' + |
|
||||
'class="clipboard-btn"><img src="https://clipboardjs.com/assets/images/clippy.svg" alt="Copy to clipboard">' + |
|
||||
'</button></div>'; |
|
||||
} |
|
||||
|
|
||||
(window as any).markdownCopyCode = (id: number) => { |
|
||||
const text = $('#copyCodeId' + id).text(); |
|
||||
navigator.clipboard.writeText(text).then(() => { |
|
||||
import('tooltipster').then( |
|
||||
() => { |
|
||||
const copyBtn = $('#copyCodeBtn' + id); |
|
||||
if (!copyBtn.hasClass('tooltipstered')) { |
|
||||
copyBtn.tooltipster( |
|
||||
{ |
|
||||
content: 'Copied!', |
|
||||
theme: 'tooltipster-shadow', |
|
||||
delay: 0, |
|
||||
trigger: 'custom', |
|
||||
triggerClose: { |
|
||||
click: true, |
|
||||
tap: true, |
|
||||
scroll: true, |
|
||||
mouseleave: true |
|
||||
}, |
|
||||
side: 'bottom', |
|
||||
distance: 12, |
|
||||
trackOrigin: true |
|
||||
} |
|
||||
); |
|
||||
} |
|
||||
const tooltip = copyBtn.tooltipster('instance'); |
|
||||
tooltip.open(); |
|
||||
} |
|
||||
); |
|
||||
}); |
|
||||
}; |
|
||||
@ -0,0 +1,143 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2021 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 { MarkedOptions, MarkedRenderer } from 'ngx-markdown'; |
||||
|
import { Inject, Injectable } from '@angular/core'; |
||||
|
import { TranslateService } from '@ngx-translate/core'; |
||||
|
import { DOCUMENT } from '@angular/common'; |
||||
|
import { WINDOW } from '@core/services/window.service'; |
||||
|
|
||||
|
const copyCodeBlock = '{:copy-code}'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class MarkedOptionsService extends MarkedOptions { |
||||
|
|
||||
|
renderer = new MarkedRenderer(); |
||||
|
headerIds = true; |
||||
|
gfm = true; |
||||
|
breaks = false; |
||||
|
pedantic = false; |
||||
|
smartLists = true; |
||||
|
smartypants = false; |
||||
|
|
||||
|
private renderer2 = new MarkedRenderer(); |
||||
|
|
||||
|
private id = 1; |
||||
|
|
||||
|
constructor(private translate: TranslateService, |
||||
|
@Inject(WINDOW) private readonly window: Window, |
||||
|
@Inject(DOCUMENT) private readonly document: Document) { |
||||
|
super(); |
||||
|
this.renderer.code = (code: string, language: string | undefined, isEscaped: boolean) => { |
||||
|
if (code.endsWith(copyCodeBlock)) { |
||||
|
code = code.substring(0, code.length - copyCodeBlock.length); |
||||
|
const content = checkLineNumbers(this.renderer2.code(code, language, isEscaped), code); |
||||
|
this.id++; |
||||
|
return this.wrapCopyCode(this.id, content, code); |
||||
|
} else { |
||||
|
return checkLineNumbers(this.renderer2.code(code, language, isEscaped), code); |
||||
|
} |
||||
|
}; |
||||
|
this.renderer.tablecell = (content: string, flags: { |
||||
|
header: boolean; |
||||
|
align: 'center' | 'left' | 'right' | null; |
||||
|
}) => { |
||||
|
if (content.endsWith(copyCodeBlock)) { |
||||
|
content = content.substring(0, content.length - copyCodeBlock.length); |
||||
|
this.id++; |
||||
|
content = this.wrapCopyCode(this.id, content, content); |
||||
|
} |
||||
|
return this.renderer2.tablecell(content, flags); |
||||
|
}; |
||||
|
this.document.addEventListener('selectionchange', this.onSelectionChange.bind(this)); |
||||
|
(this.window as any).markdownCopyCode = this.markdownCopyCode.bind(this); |
||||
|
} |
||||
|
|
||||
|
private wrapCopyCode(id: number, content: string, code: string): string { |
||||
|
return `<div class="code-wrapper noChars" id="codeWrapper${id}" onClick="markdownCopyCode(${id})">${content}` + |
||||
|
`<span id="copyCodeId${id}" style="display: none;">${code}</span>` + |
||||
|
`<button class="clipboard-btn">\n` + |
||||
|
` <p>${this.translate.instant('markdown.copy-code')}</p>\n` + |
||||
|
` <div>\n` + |
||||
|
` <img src="/assets/copy-code-icon.svg" alt="${this.translate.instant('markdown.copy-code')}">\n` + |
||||
|
` </div>\n` + |
||||
|
`</button>` + |
||||
|
`</div>`; |
||||
|
} |
||||
|
|
||||
|
private onSelectionChange() { |
||||
|
const codeWrappers = $('.code-wrapper'); |
||||
|
codeWrappers.removeClass('noChars'); |
||||
|
const selectedChars = this.getSelectedText(); |
||||
|
if (!selectedChars) { |
||||
|
codeWrappers.addClass('noChars'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private getSelectedText(): string { |
||||
|
let text; |
||||
|
if (this.window.getSelection) { |
||||
|
text = this.window.getSelection().toString(); |
||||
|
} else if (this.document.getSelection) { |
||||
|
text = this.document.getSelection(); |
||||
|
} else if ((this.document as any).selection) { |
||||
|
text = (this.document as any).selection.createRange().text; |
||||
|
} |
||||
|
return text; |
||||
|
} |
||||
|
|
||||
|
private markdownCopyCode(id: number) { |
||||
|
const copyWrapper = $('#codeWrapper' + id); |
||||
|
if (copyWrapper.hasClass('noChars')) { |
||||
|
const text = $('#copyCodeId' + id).text(); |
||||
|
this.window.navigator.clipboard.writeText(text).then(() => { |
||||
|
import('tooltipster').then( |
||||
|
() => { |
||||
|
if (!copyWrapper.hasClass('tooltipstered')) { |
||||
|
copyWrapper.tooltipster( |
||||
|
{ |
||||
|
content: this.translate.instant('markdown.copied'), |
||||
|
// theme: 'tooltipster-shadow',
|
||||
|
delay: 0, |
||||
|
trigger: 'custom', |
||||
|
triggerClose: { |
||||
|
click: true, |
||||
|
tap: true, |
||||
|
scroll: true, |
||||
|
mouseleave: true |
||||
|
}, |
||||
|
side: 'top', |
||||
|
distance: 12, |
||||
|
trackOrigin: true |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
const tooltip = copyWrapper.tooltipster('instance'); |
||||
|
tooltip.open(); |
||||
|
} |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function checkLineNumbers(content: string, code: string): string { |
||||
|
const lineCount = code.trim().split('\n').length; |
||||
|
if (lineCount < 2) { |
||||
|
content = content.replace('<pre>', '<pre class="no-line-numbers">'); |
||||
|
} |
||||
|
return content; |
||||
|
} |
||||
@ -0,0 +1,203 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2021 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 '../scss/mixins'; |
||||
|
|
||||
|
$popover-arrow-width: 6; |
||||
|
$popover-distance: $popover-arrow-width + 4; |
||||
|
$zindex-popover: 1030; |
||||
|
$popover-bg: #fff; |
||||
|
$border-radius-base: 6px; |
||||
|
$popover-close-button-size: 30px; |
||||
|
$box-shadow-base: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), |
||||
|
0 9px 28px 8px rgba(0, 0, 0, 0.05), 0 0 8px rgba(0, 0, 0, 0.15); |
||||
|
|
||||
|
.tb-popover { |
||||
|
z-index: $zindex-popover; |
||||
|
font-weight: normal; |
||||
|
white-space: normal; |
||||
|
text-align: left; |
||||
|
cursor: auto; |
||||
|
user-select: text; |
||||
|
|
||||
|
&::after { |
||||
|
position: absolute; |
||||
|
background: rgba(255, 255, 255, 0.01); |
||||
|
content: ''; |
||||
|
} |
||||
|
|
||||
|
&-hidden { |
||||
|
display: none; |
||||
|
} |
||||
|
|
||||
|
// Offset the popover to account for the popover arrow |
||||
|
&-placement-top, |
||||
|
&-placement-topLeft, |
||||
|
&-placement-topRight { |
||||
|
padding-bottom: $popover-distance + px; |
||||
|
} |
||||
|
|
||||
|
&-placement-right, |
||||
|
&-placement-rightTop, |
||||
|
&-placement-rightBottom { |
||||
|
padding-left: $popover-distance + px; |
||||
|
} |
||||
|
|
||||
|
&-placement-bottom, |
||||
|
&-placement-bottomLeft, |
||||
|
&-placement-bottomRight { |
||||
|
padding-top: $popover-distance + px; |
||||
|
} |
||||
|
|
||||
|
&-placement-left, |
||||
|
&-placement-leftTop, |
||||
|
&-placement-leftBottom { |
||||
|
padding-right: $popover-distance + px; |
||||
|
} |
||||
|
|
||||
|
&-inner { |
||||
|
background-color: $popover-bg; |
||||
|
background-clip: padding-box; |
||||
|
border-radius: $border-radius-base; |
||||
|
box-shadow: $box-shadow-base; |
||||
|
overflow: hidden; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
&-close-button { |
||||
|
cursor: pointer; |
||||
|
position: absolute; |
||||
|
top: 6px; |
||||
|
right: 6px; |
||||
|
padding: 0; |
||||
|
border: none; |
||||
|
text-align: center; |
||||
|
width: $popover-close-button-size; |
||||
|
height: $popover-close-button-size; |
||||
|
font-size: $popover-close-button-size; |
||||
|
color: #8e8e8e; |
||||
|
text-decoration: none; |
||||
|
font-weight: bold; |
||||
|
background: transparent; |
||||
|
z-index: 10; |
||||
|
&:hover { |
||||
|
color: #313131; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
&-inner-content { |
||||
|
padding: 12px 16px; |
||||
|
color: rgba(0, 0, 0, 0.85); |
||||
|
} |
||||
|
|
||||
|
// Arrows |
||||
|
// .popover-arrow is outer, .popover-arrow:after is inner |
||||
|
|
||||
|
&-arrow { |
||||
|
position: absolute; |
||||
|
display: block; |
||||
|
width: sqrt($popover-arrow-width * $popover-arrow-width * 2) + px; |
||||
|
height: sqrt($popover-arrow-width * $popover-arrow-width * 2) + px; |
||||
|
background: transparent; |
||||
|
border-style: solid; |
||||
|
border-width: (sqrt($popover-arrow-width * $popover-arrow-width * 2) / 2) + px; |
||||
|
transform: rotate(45deg); |
||||
|
z-index: 10; |
||||
|
} |
||||
|
|
||||
|
&-placement-top > &-content > &-arrow, |
||||
|
&-placement-topLeft > &-content > &-arrow, |
||||
|
&-placement-topRight > &-content > &-arrow { |
||||
|
bottom: $popover-distance - $popover-arrow-width + 2.2 + px; |
||||
|
border-top-color: transparent; |
||||
|
border-right-color: $popover-bg; |
||||
|
border-bottom-color: $popover-bg; |
||||
|
border-left-color: transparent; |
||||
|
box-shadow: 3px 3px 7px rgba(0, 0, 0, 0.07); |
||||
|
} |
||||
|
&-placement-top > &-content > &-arrow { |
||||
|
left: 50%; |
||||
|
transform: translateX(-50%) rotate(45deg); |
||||
|
} |
||||
|
&-placement-topLeft > &-content > &-arrow { |
||||
|
left: 16px; |
||||
|
} |
||||
|
&-placement-topRight > &-content > &-arrow { |
||||
|
right: 16px; |
||||
|
} |
||||
|
|
||||
|
&-placement-right > &-content > &-arrow, |
||||
|
&-placement-rightTop > &-content > &-arrow, |
||||
|
&-placement-rightBottom > &-content > &-arrow { |
||||
|
left: $popover-distance - $popover-arrow-width + 2 + px; |
||||
|
border-top-color: transparent; |
||||
|
border-right-color: transparent; |
||||
|
border-bottom-color: $popover-bg; |
||||
|
border-left-color: $popover-bg; |
||||
|
box-shadow: -3px 3px 7px rgba(0, 0, 0, 0.07); |
||||
|
} |
||||
|
&-placement-right > &-content > &-arrow { |
||||
|
top: 50%; |
||||
|
transform: translateY(-50%) rotate(45deg); |
||||
|
} |
||||
|
&-placement-rightTop > &-content > &-arrow { |
||||
|
top: 12px; |
||||
|
} |
||||
|
&-placement-rightBottom > &-content > &-arrow { |
||||
|
bottom: 12px; |
||||
|
} |
||||
|
|
||||
|
&-placement-bottom > &-content > &-arrow, |
||||
|
&-placement-bottomLeft > &-content > &-arrow, |
||||
|
&-placement-bottomRight > &-content > &-arrow { |
||||
|
top: $popover-distance - $popover-arrow-width + 2 + px; |
||||
|
border-top-color: $popover-bg; |
||||
|
border-right-color: transparent; |
||||
|
border-bottom-color: transparent; |
||||
|
border-left-color: $popover-bg; |
||||
|
box-shadow: -2px -2px 5px rgba(0, 0, 0, 0.06); |
||||
|
} |
||||
|
&-placement-bottom > &-content > &-arrow { |
||||
|
left: 50%; |
||||
|
transform: translateX(-50%) rotate(45deg); |
||||
|
} |
||||
|
&-placement-bottomLeft > &-content > &-arrow { |
||||
|
left: 16px; |
||||
|
} |
||||
|
&-placement-bottomRight > &-content > &-arrow { |
||||
|
right: 16px; |
||||
|
} |
||||
|
|
||||
|
&-placement-left > &-content > &-arrow, |
||||
|
&-placement-leftTop > &-content > &-arrow, |
||||
|
&-placement-leftBottom > &-content > &-arrow { |
||||
|
right: $popover-distance - $popover-arrow-width + 2 + px; |
||||
|
border-top-color: $popover-bg; |
||||
|
border-right-color: $popover-bg; |
||||
|
border-bottom-color: transparent; |
||||
|
border-left-color: transparent; |
||||
|
box-shadow: 3px -3px 7px rgba(0, 0, 0, 0.07); |
||||
|
} |
||||
|
&-placement-left > &-content > &-arrow { |
||||
|
top: 50%; |
||||
|
transform: translateY(-50%) rotate(45deg); |
||||
|
} |
||||
|
&-placement-leftTop > &-content > &-arrow { |
||||
|
top: 12px; |
||||
|
} |
||||
|
&-placement-leftBottom > &-content > &-arrow { |
||||
|
bottom: 12px; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,725 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2021 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, |
||||
|
ChangeDetectionStrategy, ChangeDetectorRef, |
||||
|
Component, ComponentFactory, ComponentFactoryResolver, ComponentRef, |
||||
|
Directive, |
||||
|
ElementRef, |
||||
|
EventEmitter, Injectable, Injector, |
||||
|
Input, OnChanges, OnDestroy, OnInit, Optional, |
||||
|
Output, Renderer2, SimpleChanges, |
||||
|
TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation |
||||
|
} from '@angular/core'; |
||||
|
import { Direction, Directionality } from '@angular/cdk/bidi'; |
||||
|
import { |
||||
|
CdkConnectedOverlay, |
||||
|
CdkOverlayOrigin, |
||||
|
ConnectedOverlayPositionChange, |
||||
|
ConnectionPositionPair |
||||
|
} from '@angular/cdk/overlay'; |
||||
|
import { Subject, Subscription } from 'rxjs'; |
||||
|
import { |
||||
|
DEFAULT_POPOVER_POSITIONS, |
||||
|
getPlacementName, |
||||
|
popoverMotion, PopoverPlacement, PopoverWithTrigger, |
||||
|
POSITION_MAP, PropertyMapping |
||||
|
} from '@shared/components/popover.models'; |
||||
|
import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; |
||||
|
import { isDefinedAndNotNull, isNotEmptyStr, onParentScrollOrWindowResize } from '@core/utils'; |
||||
|
import { ScrollStrategyOptions } from '@angular/cdk/overlay/scroll/scroll-strategy-options'; |
||||
|
import { ScrollStrategy } from '@angular/cdk/overlay/scroll/scroll-strategy'; |
||||
|
import { taggedTemplate } from '@angular/compiler/src/output/output_ast'; |
||||
|
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component'; |
||||
|
|
||||
|
export type TbPopoverTrigger = 'click' | 'focus' | 'hover' | null; |
||||
|
|
||||
|
@Directive({ |
||||
|
selector: '[tb-popover]', |
||||
|
exportAs: 'tbPopover', |
||||
|
host: { |
||||
|
'[class.tb-popover-open]': 'visible' |
||||
|
} |
||||
|
}) |
||||
|
export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit { |
||||
|
|
||||
|
// tslint:disable:no-input-rename
|
||||
|
@Input('tbPopoverContent') content?: string | TemplateRef<void>; |
||||
|
@Input('tbPopoverTrigger') trigger?: TbPopoverTrigger = 'hover'; |
||||
|
@Input('tbPopoverPlacement') placement?: string | string[] = 'top'; |
||||
|
@Input('tbPopoverOrigin') origin?: ElementRef<HTMLElement>; |
||||
|
@Input('tbPopoverVisible') visible?: boolean; |
||||
|
@Input('tbPopoverMouseEnterDelay') mouseEnterDelay?: number; |
||||
|
@Input('tbPopoverMouseLeaveDelay') mouseLeaveDelay?: number; |
||||
|
@Input('tbPopoverOverlayClassName') overlayClassName?: string; |
||||
|
@Input('tbPopoverOverlayStyle') overlayStyle?: { [klass: string]: any }; |
||||
|
@Input() tbPopoverBackdrop = false; |
||||
|
|
||||
|
// tslint:disable-next-line:no-output-rename
|
||||
|
@Output('tbPopoverVisibleChange') readonly visibleChange = new EventEmitter<boolean>(); |
||||
|
|
||||
|
componentFactory: ComponentFactory<TbPopoverComponent> = this.resolver.resolveComponentFactory(TbPopoverComponent); |
||||
|
component?: TbPopoverComponent; |
||||
|
|
||||
|
private readonly destroy$ = new Subject<void>(); |
||||
|
private readonly triggerDisposables: Array<() => void> = []; |
||||
|
private delayTimer?; |
||||
|
private internalVisible = false; |
||||
|
|
||||
|
constructor( |
||||
|
private elementRef: ElementRef, |
||||
|
private hostView: ViewContainerRef, |
||||
|
private resolver: ComponentFactoryResolver, |
||||
|
private renderer: Renderer2 |
||||
|
) {} |
||||
|
|
||||
|
ngOnChanges(changes: SimpleChanges): void { |
||||
|
const { trigger } = changes; |
||||
|
|
||||
|
if (trigger && !trigger.isFirstChange()) { |
||||
|
this.registerTriggers(); |
||||
|
} |
||||
|
|
||||
|
if (this.component) { |
||||
|
this.updatePropertiesByChanges(changes); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
ngAfterViewInit(): void { |
||||
|
this.registerTriggers(); |
||||
|
} |
||||
|
|
||||
|
ngOnDestroy(): void { |
||||
|
this.destroy$.next(); |
||||
|
this.destroy$.complete(); |
||||
|
this.clearTogglingTimer(); |
||||
|
this.removeTriggerListeners(); |
||||
|
} |
||||
|
|
||||
|
show(): void { |
||||
|
if (!this.component) { |
||||
|
this.createComponent(); |
||||
|
} |
||||
|
this.component?.show(); |
||||
|
} |
||||
|
|
||||
|
hide(): void { |
||||
|
this.component?.hide(); |
||||
|
} |
||||
|
|
||||
|
updatePosition(): void { |
||||
|
if (this.component) { |
||||
|
this.component.updatePosition(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private createComponent(): void { |
||||
|
const componentRef = this.hostView.createComponent(this.componentFactory); |
||||
|
|
||||
|
this.component = componentRef.instance; |
||||
|
|
||||
|
this.renderer.removeChild( |
||||
|
this.renderer.parentNode(this.elementRef.nativeElement), |
||||
|
componentRef.location.nativeElement |
||||
|
); |
||||
|
this.component.setOverlayOrigin({ elementRef: this.origin || this.elementRef }); |
||||
|
|
||||
|
this.initProperties(); |
||||
|
|
||||
|
this.component.tbVisibleChange |
||||
|
.pipe(distinctUntilChanged(), takeUntil(this.destroy$)) |
||||
|
.subscribe((visible: boolean) => { |
||||
|
this.internalVisible = visible; |
||||
|
this.visibleChange.emit(visible); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private registerTriggers(): void { |
||||
|
// When the method gets invoked, all properties has been synced to the dynamic component.
|
||||
|
// After removing the old API, we can just check the directive's own `nzTrigger`.
|
||||
|
const el = this.elementRef.nativeElement; |
||||
|
const trigger = this.trigger; |
||||
|
|
||||
|
this.removeTriggerListeners(); |
||||
|
|
||||
|
if (trigger === 'hover') { |
||||
|
let overlayElement: HTMLElement; |
||||
|
this.triggerDisposables.push( |
||||
|
this.renderer.listen(el, 'mouseenter', () => { |
||||
|
this.delayEnterLeave(true, true, this.mouseEnterDelay); |
||||
|
}) |
||||
|
); |
||||
|
this.triggerDisposables.push( |
||||
|
this.renderer.listen(el, 'mouseleave', () => { |
||||
|
this.delayEnterLeave(true, false, this.mouseLeaveDelay); |
||||
|
if (this.component?.overlay.overlayRef && !overlayElement) { |
||||
|
overlayElement = this.component.overlay.overlayRef.overlayElement; |
||||
|
this.triggerDisposables.push( |
||||
|
this.renderer.listen(overlayElement, 'mouseenter', () => { |
||||
|
this.delayEnterLeave(false, true, this.mouseEnterDelay); |
||||
|
}) |
||||
|
); |
||||
|
this.triggerDisposables.push( |
||||
|
this.renderer.listen(overlayElement, 'mouseleave', () => { |
||||
|
this.delayEnterLeave(false, false, this.mouseLeaveDelay); |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
}) |
||||
|
); |
||||
|
} else if (trigger === 'focus') { |
||||
|
this.triggerDisposables.push(this.renderer.listen(el, 'focusin', () => this.show())); |
||||
|
this.triggerDisposables.push(this.renderer.listen(el, 'focusout', () => this.hide())); |
||||
|
} else if (trigger === 'click') { |
||||
|
this.triggerDisposables.push( |
||||
|
this.renderer.listen(el, 'click', (e: MouseEvent) => { |
||||
|
e.preventDefault(); |
||||
|
if (this.component?.visible) { |
||||
|
this.hide(); |
||||
|
} else { |
||||
|
this.show(); |
||||
|
} |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
// Else do nothing because user wants to control the visibility programmatically.
|
||||
|
} |
||||
|
|
||||
|
private updatePropertiesByChanges(changes: SimpleChanges): void { |
||||
|
this.updatePropertiesByKeys(Object.keys(changes)); |
||||
|
} |
||||
|
|
||||
|
private updatePropertiesByKeys(keys?: string[]): void { |
||||
|
const mappingProperties: PropertyMapping = { |
||||
|
// common mappings
|
||||
|
content: ['tbContent', () => this.content], |
||||
|
trigger: ['tbTrigger', () => this.trigger], |
||||
|
placement: ['tbPlacement', () => this.placement], |
||||
|
visible: ['tbVisible', () => this.visible], |
||||
|
mouseEnterDelay: ['tbMouseEnterDelay', () => this.mouseEnterDelay], |
||||
|
mouseLeaveDelay: ['tbMouseLeaveDelay', () => this.mouseLeaveDelay], |
||||
|
overlayClassName: ['tbOverlayClassName', () => this.overlayClassName], |
||||
|
overlayStyle: ['tbOverlayStyle', () => this.overlayStyle], |
||||
|
tbPopoverBackdrop: ['tbBackdrop', () => this.tbPopoverBackdrop] |
||||
|
}; |
||||
|
|
||||
|
(keys || Object.keys(mappingProperties).filter(key => !key.startsWith('directive'))).forEach( |
||||
|
(property: any) => { |
||||
|
if (mappingProperties[property]) { |
||||
|
const [name, valueFn] = mappingProperties[property]; |
||||
|
this.updateComponentValue(name, valueFn()); |
||||
|
} |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
this.component?.updateByDirective(); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
private initProperties(): void { |
||||
|
this.updatePropertiesByKeys(); |
||||
|
} |
||||
|
|
||||
|
private updateComponentValue(key: string, value: any): void { |
||||
|
if (typeof value !== 'undefined') { |
||||
|
// @ts-ignore
|
||||
|
this.component[key] = value; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private delayEnterLeave(isOrigin: boolean, isEnter: boolean, delay: number = -1): void { |
||||
|
if (this.delayTimer) { |
||||
|
this.clearTogglingTimer(); |
||||
|
} else if (delay > 0) { |
||||
|
this.delayTimer = setTimeout(() => { |
||||
|
this.delayTimer = undefined; |
||||
|
isEnter ? this.show() : this.hide(); |
||||
|
}, delay * 1000); |
||||
|
} else { |
||||
|
// `isOrigin` is used due to the tooltip will not hide immediately
|
||||
|
// (may caused by the fade-out animation).
|
||||
|
isEnter && isOrigin ? this.show() : this.hide(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private removeTriggerListeners(): void { |
||||
|
this.triggerDisposables.forEach(dispose => dispose()); |
||||
|
this.triggerDisposables.length = 0; |
||||
|
} |
||||
|
|
||||
|
private clearTogglingTimer(): void { |
||||
|
if (this.delayTimer) { |
||||
|
clearTimeout(this.delayTimer); |
||||
|
this.delayTimer = undefined; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Injectable() |
||||
|
export class TbPopoverService { |
||||
|
|
||||
|
private popoverWithTriggers: PopoverWithTrigger[] = []; |
||||
|
|
||||
|
componentFactory: ComponentFactory<TbPopoverComponent> = this.resolver.resolveComponentFactory(TbPopoverComponent); |
||||
|
|
||||
|
constructor(private resolver: ComponentFactoryResolver) { |
||||
|
} |
||||
|
|
||||
|
hasPopover(trigger: Element): boolean { |
||||
|
const res = this.findPopoverByTrigger(trigger); |
||||
|
return res !== null; |
||||
|
} |
||||
|
|
||||
|
hidePopover(trigger: Element): boolean { |
||||
|
const component: TbPopoverComponent = this.findPopoverByTrigger(trigger); |
||||
|
if (component && component.tbVisible) { |
||||
|
component.hide(); |
||||
|
return true; |
||||
|
} else { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
displayPopover<T>(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, |
||||
|
componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top', hideOnClickOutside = true, |
||||
|
injector?: Injector, context?: any, popoverStyle: any = {}, style?: any): TbPopoverComponent { |
||||
|
const componentRef = hostView.createComponent(this.componentFactory); |
||||
|
const component = componentRef.instance; |
||||
|
this.popoverWithTriggers.push({ |
||||
|
trigger, |
||||
|
popoverComponent: component |
||||
|
}); |
||||
|
renderer.removeChild( |
||||
|
renderer.parentNode(trigger), |
||||
|
componentRef.location.nativeElement |
||||
|
); |
||||
|
const originElementRef = new ElementRef(trigger); |
||||
|
component.setOverlayOrigin({ elementRef: originElementRef }); |
||||
|
component.tbPlacement = preferredPlacement; |
||||
|
component.tbComponentFactory = this.resolver.resolveComponentFactory(componentType); |
||||
|
component.tbComponentInjector = injector; |
||||
|
component.tbComponentContext = context; |
||||
|
component.tbPopoverInnerStyle = popoverStyle; |
||||
|
component.tbComponentStyle = style; |
||||
|
component.tbHideOnClickOutside = hideOnClickOutside; |
||||
|
component.tbVisibleChange.subscribe((visible: boolean) => { |
||||
|
if (!visible) { |
||||
|
component.tbAnimationDone.subscribe(() => { |
||||
|
componentRef.destroy(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
component.tbDestroy.subscribe(() => { |
||||
|
this.removePopoverByComponent(component); |
||||
|
}); |
||||
|
component.show(); |
||||
|
return component; |
||||
|
} |
||||
|
|
||||
|
toggleHelpPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, helpId: string, |
||||
|
visibleFn: (visible: boolean) => void, readyFn: (ready: boolean) => void) { |
||||
|
if (this.hasPopover(trigger)) { |
||||
|
this.hidePopover(trigger); |
||||
|
} else { |
||||
|
readyFn(false); |
||||
|
const injector = Injector.create({ |
||||
|
parent: hostView.injector, providers: [] |
||||
|
}); |
||||
|
const componentRef = hostView.createComponent(this.componentFactory); |
||||
|
const component = componentRef.instance; |
||||
|
this.popoverWithTriggers.push({ |
||||
|
trigger, |
||||
|
popoverComponent: component |
||||
|
}); |
||||
|
renderer.removeChild( |
||||
|
renderer.parentNode(trigger), |
||||
|
componentRef.location.nativeElement |
||||
|
); |
||||
|
const originElementRef = new ElementRef(trigger); |
||||
|
component.tbAnimationState = 'void'; |
||||
|
component.tbOverlayStyle = { opacity: '0' }; |
||||
|
component.setOverlayOrigin({ elementRef: originElementRef }); |
||||
|
component.tbPlacement = 'bottom'; |
||||
|
component.tbComponentFactory = this.resolver.resolveComponentFactory(HelpMarkdownComponent); |
||||
|
component.tbComponentInjector = injector; |
||||
|
component.tbComponentContext = { |
||||
|
helpId, |
||||
|
visible: true |
||||
|
}; |
||||
|
component.tbHideOnClickOutside = true; |
||||
|
component.tbVisibleChange.subscribe((visible: boolean) => { |
||||
|
if (!visible) { |
||||
|
visibleFn(false); |
||||
|
component.tbAnimationDone.subscribe(() => { |
||||
|
componentRef.destroy(); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
component.tbDestroy.subscribe(() => { |
||||
|
this.removePopoverByComponent(component); |
||||
|
}); |
||||
|
const showHelpMarkdownComponent = () => { |
||||
|
component.tbOverlayStyle = { opacity: '1' }; |
||||
|
component.tbAnimationState = 'active'; |
||||
|
component.updatePosition(); |
||||
|
readyFn(true); |
||||
|
setTimeout(() => { |
||||
|
component.updatePosition(); |
||||
|
}); |
||||
|
}; |
||||
|
const setupHelpMarkdownComponent = (helpMarkdownComponent: HelpMarkdownComponent) => { |
||||
|
if (helpMarkdownComponent.isMarkdownReady) { |
||||
|
showHelpMarkdownComponent(); |
||||
|
} else { |
||||
|
helpMarkdownComponent.markdownReady.subscribe(() => { |
||||
|
showHelpMarkdownComponent(); |
||||
|
}); |
||||
|
} |
||||
|
}; |
||||
|
if (component.tbComponentRef) { |
||||
|
setupHelpMarkdownComponent(component.tbComponentRef.instance); |
||||
|
} else { |
||||
|
component.tbComponentChange.subscribe((helpMarkdownComponentRef) => { |
||||
|
setupHelpMarkdownComponent(helpMarkdownComponentRef.instance); |
||||
|
}); |
||||
|
} |
||||
|
component.show(); |
||||
|
visibleFn(true); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private findPopoverByTrigger(trigger: Element): TbPopoverComponent | null { |
||||
|
const res = this.popoverWithTriggers.find(val => this.elementsAreEqualOrDescendant(trigger, val.trigger)); |
||||
|
if (res) { |
||||
|
return res.popoverComponent; |
||||
|
} else { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private removePopoverByComponent(component: TbPopoverComponent): void { |
||||
|
const index = this.popoverWithTriggers.findIndex(val => val.popoverComponent === component); |
||||
|
if (index > -1) { |
||||
|
this.popoverWithTriggers.splice(index, 1); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private elementsAreEqualOrDescendant(element1: Element, element2: Element): boolean { |
||||
|
return element1 === element2 || element1.contains(element2) || element2.contains(element1); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-popover', |
||||
|
exportAs: 'tbPopoverComponent', |
||||
|
animations: [popoverMotion], |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
encapsulation: ViewEncapsulation.None, |
||||
|
styleUrls: ['./popover.component.scss'], |
||||
|
template: ` |
||||
|
<ng-template |
||||
|
#overlay="cdkConnectedOverlay" |
||||
|
cdkConnectedOverlay |
||||
|
[cdkConnectedOverlayHasBackdrop]="hasBackdrop" |
||||
|
[cdkConnectedOverlayOrigin]="origin" |
||||
|
[cdkConnectedOverlayPositions]="positions" |
||||
|
[cdkConnectedOverlayOpen]="visible" |
||||
|
[cdkConnectedOverlayPush]="true" |
||||
|
(overlayOutsideClick)="onClickOutside($event)" |
||||
|
(detach)="hide()" |
||||
|
(positionChange)="onPositionChange($event)" |
||||
|
> |
||||
|
<div #popoverRoot [@popoverMotion]="tbAnimationState" |
||||
|
(@popoverMotion.done)="animationDone()"> |
||||
|
<div |
||||
|
class="tb-popover" |
||||
|
[class.tb-popover-rtl]="dir === 'rtl'" |
||||
|
[ngClass]="classMap" |
||||
|
[ngStyle]="tbOverlayStyle" |
||||
|
> |
||||
|
<div class="tb-popover-content"> |
||||
|
<div class="tb-popover-arrow"> |
||||
|
<span class="tb-popover-arrow-content"></span> |
||||
|
</div> |
||||
|
<div class="tb-popover-inner" [ngStyle]="tbPopoverInnerStyle" role="tooltip"> |
||||
|
<div class="tb-popover-close-button" (click)="closeButtonClick($event)">×</div> |
||||
|
<div> |
||||
|
<div class="tb-popover-inner-content"> |
||||
|
<ng-container *ngIf="tbContent"> |
||||
|
<ng-container *tbStringTemplateOutlet="tbContent">{{ tbContent }}</ng-container> |
||||
|
</ng-container> |
||||
|
<ng-container *ngIf="tbComponentFactory" |
||||
|
[tbComponentOutlet]="tbComponentFactory" |
||||
|
[tbComponentInjector]="tbComponentInjector" |
||||
|
[tbComponentOutletContext]="tbComponentContext" |
||||
|
(componentChange)="onComponentChange($event)" |
||||
|
[tbComponentStyle]="tbComponentStyle"> |
||||
|
</ng-container> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</ng-template> |
||||
|
` |
||||
|
}) |
||||
|
export class TbPopoverComponent implements OnDestroy, OnInit { |
||||
|
|
||||
|
@ViewChild('overlay', { static: false }) overlay!: CdkConnectedOverlay; |
||||
|
@ViewChild('popoverRoot', { static: false }) popoverRoot!: ElementRef<HTMLElement>; |
||||
|
|
||||
|
tbContent: string | TemplateRef<void> | null = null; |
||||
|
tbComponentFactory: ComponentFactory<any> | null = null; |
||||
|
tbComponentRef: ComponentRef<any> | null = null; |
||||
|
tbComponentContext: any; |
||||
|
tbComponentInjector: Injector | null = null; |
||||
|
tbComponentStyle: { [klass: string]: any } = {}; |
||||
|
tbOverlayClassName!: string; |
||||
|
tbOverlayStyle: { [klass: string]: any } = {}; |
||||
|
tbPopoverInnerStyle: { [klass: string]: any } = {}; |
||||
|
tbBackdrop = false; |
||||
|
tbMouseEnterDelay?: number; |
||||
|
tbMouseLeaveDelay?: number; |
||||
|
tbHideOnClickOutside = true; |
||||
|
|
||||
|
tbAnimationState = 'active'; |
||||
|
|
||||
|
tbVisibleChange = new Subject<boolean>(); |
||||
|
tbAnimationDone = new Subject<void>(); |
||||
|
tbComponentChange = new Subject<ComponentRef<any>>(); |
||||
|
tbDestroy = new Subject<void>(); |
||||
|
|
||||
|
set tbVisible(value: boolean) { |
||||
|
const visible = value; |
||||
|
if (this.visible !== visible) { |
||||
|
this.visible = visible; |
||||
|
this.tbVisibleChange.next(visible); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
get tbVisible(): boolean { |
||||
|
return this.visible; |
||||
|
} |
||||
|
|
||||
|
visible = false; |
||||
|
|
||||
|
set tbHidden(value: boolean) { |
||||
|
const hidden = value; |
||||
|
if (this.hidden !== hidden) { |
||||
|
this.hidden = hidden; |
||||
|
if (this.hidden) { |
||||
|
this.renderer.setStyle(this.popoverRoot.nativeElement, 'width', this.popoverRoot.nativeElement.offsetWidth + 'px'); |
||||
|
this.renderer.setStyle(this.popoverRoot.nativeElement, 'height', this.popoverRoot.nativeElement.offsetHeight + 'px'); |
||||
|
} else { |
||||
|
setTimeout(() => { |
||||
|
this.renderer.removeStyle(this.popoverRoot.nativeElement, 'width'); |
||||
|
this.renderer.removeStyle(this.popoverRoot.nativeElement, 'height'); |
||||
|
}); |
||||
|
} |
||||
|
this.updateStyles(); |
||||
|
this.cdr.markForCheck(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
get tbHidden(): boolean { |
||||
|
return this.hidden; |
||||
|
} |
||||
|
|
||||
|
hidden = false; |
||||
|
lastIsIntersecting = true; |
||||
|
|
||||
|
set tbTrigger(value: TbPopoverTrigger) { |
||||
|
this.trigger = value; |
||||
|
} |
||||
|
|
||||
|
get tbTrigger(): TbPopoverTrigger { |
||||
|
return this.trigger; |
||||
|
} |
||||
|
|
||||
|
protected trigger: TbPopoverTrigger = 'hover'; |
||||
|
|
||||
|
set tbPlacement(value: PopoverPlacement | PopoverPlacement[]) { |
||||
|
if (typeof value === 'string') { |
||||
|
this.positions = [POSITION_MAP[value], ...DEFAULT_POPOVER_POSITIONS]; |
||||
|
} else { |
||||
|
const preferredPosition = value.map(placement => POSITION_MAP[placement]); |
||||
|
this.positions = [...preferredPosition, ...DEFAULT_POPOVER_POSITIONS]; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
get hasBackdrop(): boolean { |
||||
|
return this.tbTrigger === 'click' ? this.tbBackdrop : false; |
||||
|
} |
||||
|
|
||||
|
preferredPlacement: PopoverPlacement = 'top'; |
||||
|
origin!: CdkOverlayOrigin; |
||||
|
public dir: Direction = 'ltr'; |
||||
|
classMap: { [klass: string]: any } = {}; |
||||
|
positions: ConnectionPositionPair[] = [...DEFAULT_POPOVER_POSITIONS]; |
||||
|
private parentScrollSubscription: Subscription = null; |
||||
|
private intersectionObserver = new IntersectionObserver((entries) => { |
||||
|
if (this.lastIsIntersecting !== entries[0].isIntersecting) { |
||||
|
this.lastIsIntersecting = entries[0].isIntersecting; |
||||
|
this.updateStyles(); |
||||
|
this.cdr.markForCheck(); |
||||
|
} |
||||
|
}, {threshold: [0.5]}); |
||||
|
|
||||
|
constructor( |
||||
|
public cdr: ChangeDetectorRef, |
||||
|
private renderer: Renderer2, |
||||
|
@Optional() private directionality: Directionality |
||||
|
) {} |
||||
|
|
||||
|
ngOnInit(): void { |
||||
|
this.directionality.change?.pipe(takeUntil(this.tbDestroy)).subscribe((direction: Direction) => { |
||||
|
this.dir = direction; |
||||
|
this.cdr.detectChanges(); |
||||
|
}); |
||||
|
|
||||
|
this.dir = this.directionality.value; |
||||
|
} |
||||
|
|
||||
|
ngOnDestroy(): void { |
||||
|
if (this.parentScrollSubscription) { |
||||
|
this.parentScrollSubscription.unsubscribe(); |
||||
|
this.parentScrollSubscription = null; |
||||
|
} |
||||
|
if (this.origin) { |
||||
|
const el = this.origin.elementRef.nativeElement; |
||||
|
this.intersectionObserver.unobserve(el); |
||||
|
} |
||||
|
this.intersectionObserver.disconnect(); |
||||
|
this.intersectionObserver = null; |
||||
|
this.tbVisibleChange.complete(); |
||||
|
this.tbAnimationDone.complete(); |
||||
|
this.tbDestroy.next(); |
||||
|
this.tbDestroy.complete(); |
||||
|
} |
||||
|
|
||||
|
closeButtonClick($event: Event) { |
||||
|
if ($event) { |
||||
|
$event.preventDefault(); |
||||
|
$event.stopPropagation(); |
||||
|
} |
||||
|
this.hide(); |
||||
|
} |
||||
|
|
||||
|
show(): void { |
||||
|
if (this.tbVisible) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!this.isEmpty()) { |
||||
|
this.tbVisible = true; |
||||
|
this.tbVisibleChange.next(true); |
||||
|
this.cdr.detectChanges(); |
||||
|
} |
||||
|
|
||||
|
if (this.origin && this.overlay && this.overlay.overlayRef) { |
||||
|
if (this.overlay.overlayRef.getDirection() === 'rtl') { |
||||
|
this.overlay.overlayRef.setDirection('ltr'); |
||||
|
} |
||||
|
const el = this.origin.elementRef.nativeElement; |
||||
|
this.parentScrollSubscription = onParentScrollOrWindowResize(el).subscribe(() => { |
||||
|
this.overlay.overlayRef.updatePosition(); |
||||
|
}); |
||||
|
this.intersectionObserver.observe(el); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
hide(): void { |
||||
|
if (!this.tbVisible) { |
||||
|
return; |
||||
|
} |
||||
|
if (this.parentScrollSubscription) { |
||||
|
this.parentScrollSubscription.unsubscribe(); |
||||
|
this.parentScrollSubscription = null; |
||||
|
} |
||||
|
if (this.origin) { |
||||
|
const el = this.origin.elementRef.nativeElement; |
||||
|
this.intersectionObserver.unobserve(el); |
||||
|
} |
||||
|
|
||||
|
this.tbVisible = false; |
||||
|
this.tbVisibleChange.next(false); |
||||
|
this.cdr.detectChanges(); |
||||
|
} |
||||
|
|
||||
|
updateByDirective(): void { |
||||
|
this.updateStyles(); |
||||
|
this.cdr.detectChanges(); |
||||
|
|
||||
|
Promise.resolve().then(() => { |
||||
|
this.updatePosition(); |
||||
|
this.updateVisibilityByContent(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
updatePosition(): void { |
||||
|
if (this.origin && this.overlay && this.overlay.overlayRef) { |
||||
|
this.overlay.overlayRef.updatePosition(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onPositionChange(position: ConnectedOverlayPositionChange): void { |
||||
|
this.preferredPlacement = getPlacementName(position); |
||||
|
this.updateStyles(); |
||||
|
this.cdr.detectChanges(); |
||||
|
} |
||||
|
|
||||
|
updateStyles(): void { |
||||
|
this.classMap = { |
||||
|
[this.tbOverlayClassName]: true, |
||||
|
[`tb-popover-placement-${this.preferredPlacement}`]: true, |
||||
|
['tb-popover-hidden']: this.tbHidden || !this.lastIsIntersecting |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
setOverlayOrigin(origin: CdkOverlayOrigin): void { |
||||
|
this.origin = origin; |
||||
|
this.cdr.markForCheck(); |
||||
|
} |
||||
|
|
||||
|
onClickOutside(event: MouseEvent): void { |
||||
|
if (this.tbHideOnClickOutside && !this.origin.elementRef.nativeElement.contains(event.target) && this.tbTrigger !== null) { |
||||
|
this.hide(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onComponentChange(component: ComponentRef<any>) { |
||||
|
this.tbComponentRef = component; |
||||
|
this.tbComponentChange.next(component); |
||||
|
} |
||||
|
|
||||
|
animationDone() { |
||||
|
this.tbAnimationDone.next(); |
||||
|
} |
||||
|
|
||||
|
private updateVisibilityByContent(): void { |
||||
|
if (this.isEmpty()) { |
||||
|
this.hide(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private isEmpty(): boolean { |
||||
|
return (this.tbComponentFactory instanceof ComponentFactory || this.tbContent instanceof TemplateRef) |
||||
|
? false : !isNotEmptyStr(this.tbContent); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,95 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2021 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 { animate, AnimationTriggerMetadata, style, transition, trigger } from '@angular/animations'; |
||||
|
import { ConnectedOverlayPositionChange, ConnectionPositionPair } from '@angular/cdk/overlay'; |
||||
|
import { TbPopoverComponent } from '@shared/components/popover.component'; |
||||
|
|
||||
|
export const popoverMotion: AnimationTriggerMetadata = trigger('popoverMotion', [ |
||||
|
transition('void => active', [ |
||||
|
style({ opacity: 0, transform: 'scale(0.8)' }), |
||||
|
animate( |
||||
|
'0.2s cubic-bezier(0.08, 0.82, 0.17, 1)', |
||||
|
style({ |
||||
|
opacity: 1, |
||||
|
transform: 'scale(1)' |
||||
|
}) |
||||
|
) |
||||
|
]), |
||||
|
transition('active => void', [ |
||||
|
style({ opacity: 1, transform: 'scale(1)' }), |
||||
|
animate( |
||||
|
'0.2s cubic-bezier(0.78, 0.14, 0.15, 0.86)', |
||||
|
style({ |
||||
|
opacity: 0, |
||||
|
transform: 'scale(0.8)' |
||||
|
}) |
||||
|
) |
||||
|
]) |
||||
|
]); |
||||
|
|
||||
|
export const PopoverPlacements = ['top', 'topLeft', 'topRight', 'right', 'rightTop', 'rightBottom', 'bottom', 'bottomLeft', 'bottomRight', 'left', 'leftTop', 'leftBottom'] as const; |
||||
|
type PopoverPlacementTuple = typeof PopoverPlacements; |
||||
|
export type PopoverPlacement = PopoverPlacementTuple[number]; |
||||
|
|
||||
|
export const POSITION_MAP: { [key: string]: ConnectionPositionPair } = { |
||||
|
top: new ConnectionPositionPair({ originX: 'center', originY: 'top' }, { overlayX: 'center', overlayY: 'bottom' }), |
||||
|
topLeft: new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'start', overlayY: 'bottom' }), |
||||
|
topRight: new ConnectionPositionPair({ originX: 'end', originY: 'top' }, { overlayX: 'end', overlayY: 'bottom' }), |
||||
|
right: new ConnectionPositionPair({ originX: 'end', originY: 'center' }, { overlayX: 'start', overlayY: 'center' }), |
||||
|
rightTop: new ConnectionPositionPair({ originX: 'end', originY: 'top' }, { overlayX: 'start', overlayY: 'top' }), |
||||
|
rightBottom: new ConnectionPositionPair( |
||||
|
{ originX: 'end', originY: 'bottom' }, |
||||
|
{ overlayX: 'start', overlayY: 'bottom' } |
||||
|
), |
||||
|
bottom: new ConnectionPositionPair({ originX: 'center', originY: 'bottom' }, { overlayX: 'center', overlayY: 'top' }), |
||||
|
bottomLeft: new ConnectionPositionPair( |
||||
|
{ originX: 'start', originY: 'bottom' }, |
||||
|
{ overlayX: 'start', overlayY: 'top' } |
||||
|
), |
||||
|
bottomRight: new ConnectionPositionPair({ originX: 'end', originY: 'bottom' }, { overlayX: 'end', overlayY: 'top' }), |
||||
|
left: new ConnectionPositionPair({ originX: 'start', originY: 'center' }, { overlayX: 'end', overlayY: 'center' }), |
||||
|
leftTop: new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'end', overlayY: 'top' }), |
||||
|
leftBottom: new ConnectionPositionPair( |
||||
|
{ originX: 'start', originY: 'bottom' }, |
||||
|
{ overlayX: 'end', overlayY: 'bottom' } |
||||
|
) |
||||
|
}; |
||||
|
|
||||
|
export const DEFAULT_POPOVER_POSITIONS = [POSITION_MAP.top, POSITION_MAP.right, POSITION_MAP.bottom, POSITION_MAP.left]; |
||||
|
|
||||
|
export function getPlacementName(position: ConnectedOverlayPositionChange): PopoverPlacement | undefined { |
||||
|
for (const placement in POSITION_MAP) { |
||||
|
if ( |
||||
|
position.connectionPair.originX === POSITION_MAP[placement].originX && |
||||
|
position.connectionPair.originY === POSITION_MAP[placement].originY && |
||||
|
position.connectionPair.overlayX === POSITION_MAP[placement].overlayX && |
||||
|
position.connectionPair.overlayY === POSITION_MAP[placement].overlayY |
||||
|
) { |
||||
|
return placement as PopoverPlacement; |
||||
|
} |
||||
|
} |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
export interface PropertyMapping { |
||||
|
[key: string]: [string, () => unknown]; |
||||
|
} |
||||
|
|
||||
|
export interface PopoverWithTrigger { |
||||
|
trigger: Element; |
||||
|
popoverComponent: TbPopoverComponent; |
||||
|
} |
||||
|
After Width: | Height: | Size: 500 B |
@ -0,0 +1,5 @@ |
|||||
|
#### Show cell button action JavaScript Function |
||||
|
|
||||
|
```javascript |
||||
|
return data.entityName === 'Test device'; {:copy-code} |
||||
|
``` |
||||
@ -0,0 +1,41 @@ |
|||||
|
#### Show widget header action function |
||||
|
|
||||
|
<div class="divider"></div> |
||||
|
<br/> |
||||
|
|
||||
|
*function (widgetContext, data): boolean* |
||||
|
|
||||
|
JavaScript function evaluating whether to display particular widget header action. |
||||
|
|
||||
|
**Parameters:** |
||||
|
|
||||
|
<ul> |
||||
|
<li><b>widgetContext</b> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API<br/> |
||||
|
and data used by widget instance. |
||||
|
</li> |
||||
|
<li><b>data</b> - An array of <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts#L108" target="_blank">FormattedData</a> objects.<br/> |
||||
|
Each object represents basic entity properties (ex. <code>entityId</code>, <code>entityName</code>)<br/>and provides access to other entity attributes/timeseries declared in widget datasource configuration. |
||||
|
</li> |
||||
|
</ul> |
||||
|
|
||||
|
**Returns:** |
||||
|
|
||||
|
`true` if header action should be displayed, `false` otherwise. |
||||
|
|
||||
|
<div class="divider"></div> |
||||
|
|
||||
|
##### Examples |
||||
|
|
||||
|
* Display action only for customer users: |
||||
|
|
||||
|
```javascript |
||||
|
return widgetContext.currentUser.authority === 'CUSTOMER_USER'; |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
* Display action only if the first entity is device and has type `thermostat`: |
||||
|
|
||||
|
```javascript |
||||
|
return data[0] && data[0].entityType === 'DEVICE' && data[0].Type === 'thermostat'; |
||||
|
{:copy-code} |
||||
|
``` |
||||
@ -0,0 +1,12 @@ |
|||||
|
#### Tooltip value format JavaScript Function |
||||
|
|
||||
|
##### Input arguments |
||||
|
|
||||
|
- value - value to format |
||||
|
|
||||
|
```javascript |
||||
|
return value; |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
##### Examples |
||||
Loading…
Reference in new issue