1/* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import { 18 ChangeDetectorRef, 19 Component, 20 ElementRef, 21 EventEmitter, 22 HostListener, 23 Inject, 24 Input, 25 Output, 26 ViewChild, 27 ViewEncapsulation, 28} from '@angular/core'; 29import { 30 AbstractControl, 31 FormControl, 32 FormGroup, 33 ValidationErrors, 34 ValidatorFn, 35 Validators, 36} from '@angular/forms'; 37import {DomSanitizer, SafeUrl} from '@angular/platform-browser'; 38import {TimelineData} from 'app/timeline_data'; 39import {assertDefined} from 'common/assert_utils'; 40import {FunctionUtils} from 'common/function_utils'; 41import {PersistentStore} from 'common/store/persistent_store'; 42import {StringUtils} from 'common/string_utils'; 43import {TimeRange, Timestamp, TimestampFormatType} from 'common/time/time'; 44import {TimestampUtils} from 'common/time/timestamp_utils'; 45import {Analytics} from 'logging/analytics'; 46import { 47 ActiveTraceChanged, 48 ExpandedTimelineToggled, 49 TracePositionUpdate, 50 WinscopeEvent, 51 WinscopeEventType, 52} from 'messaging/winscope_event'; 53import { 54 EmitEvent, 55 WinscopeEventEmitter, 56} from 'messaging/winscope_event_emitter'; 57import {WinscopeEventListener} from 'messaging/winscope_event_listener'; 58import {Trace} from 'trace/trace'; 59import {Traces} from 'trace/traces'; 60import {TRACE_INFO} from 'trace/trace_info'; 61import {TracePosition} from 'trace/trace_position'; 62import {TraceType, TraceTypeUtils} from 'trace/trace_type'; 63import {multlineTooltip} from 'viewers/components/styles/tooltip.styles'; 64import {MiniTimelineComponent} from './mini-timeline/mini_timeline_component'; 65 66@Component({ 67 selector: 'timeline', 68 encapsulation: ViewEncapsulation.None, 69 template: ` 70 <div 71 *ngIf="isDisabled" 72 class="disabled-message user-notification mat-body-1"> Timeline disabled due to ongoing search query </div> 73 <div [class.disabled-component]="isDisabled"> 74 <div id="toggle" *ngIf="timelineData.hasMoreThanOneDistinctTimestamp()"> 75 <button 76 mat-icon-button 77 [class]="TOGGLE_BUTTON_CLASS" 78 color="basic" 79 aria-label="Toggle Expanded Timeline" 80 (click)="toggleExpand()"> 81 <mat-icon *ngIf="!expanded" class="material-symbols-outlined">expand_circle_up</mat-icon> 82 <mat-icon *ngIf="expanded" class="material-symbols-outlined">expand_circle_down</mat-icon> 83 </button> 84 </div> 85 <div id="expanded-nav" *ngIf="expanded"> 86 <div id="video-content" *ngIf="videoUrl !== undefined"> 87 <video 88 *ngIf="getVideoCurrentTime() !== undefined" 89 id="video" 90 [currentTime]="getVideoCurrentTime()" 91 [src]="videoUrl"></video> 92 <div *ngIf="getVideoCurrentTime() === undefined" class="no-video-message"> 93 <p>No screenrecording frame to show</p> 94 <p>Current timestamp before first screenrecording frame.</p> 95 </div> 96 </div> 97 <expanded-timeline 98 [timelineData]="timelineData" 99 (onTracePositionUpdate)="updatePosition($event)" 100 (onScrollEvent)="updateScrollEvent($event)" 101 (onTraceClicked)="onExpandedTimelineTraceClicked($event)" 102 (onMouseXRatioUpdate)="updateExpandedTimelineMouseXRatio($event)" 103 id="expanded-timeline"></expanded-timeline> 104 </div> 105 <div class="navbar-toggle"> 106 <div class="navbar" #collapsedTimeline> 107 <ng-template [ngIf]="timelineData.hasTimestamps()"> 108 <div id="time-selector"> 109 <form [formGroup]="timestampForm" class="time-selector-form"> 110 <mat-form-field 111 class="time-input human" 112 appearance="fill" 113 (keydown.esc)="$event.target.blur()" 114 (keydown.enter)="onKeydownEnterTimeInputField($event)" 115 (change)="onHumanTimeInputChange($event)"> 116 <mat-icon 117 [matTooltip]="getHumanTimeTooltip()" 118 matTooltipClass="multline-tooltip" 119 matPrefix>schedule</mat-icon> 120 <input 121 matInput 122 name="humanTimeInput" 123 [formControl]="selectedTimeFormControl" /> 124 <div class="field-suffix" matSuffix> 125 <span class="time-difference"> {{ getUTCOffset() }} </span> 126 <button 127 mat-icon-button 128 [matTooltip]="getCopyHumanTimeTooltip()" 129 matTooltipClass="multline-tooltip" 130 [cdkCopyToClipboard]="getHumanTime()" 131 (cdkCopyToClipboardCopied)="onTimeCopied('human')" 132 matSuffix> 133 <mat-icon>content_copy</mat-icon> 134 </button> 135 </div> 136 </mat-form-field> 137 <mat-form-field 138 class="time-input nano" 139 appearance="fill" 140 (keydown.esc)="$event.target.blur()" 141 (keydown.enter)="onKeydownEnterNanosecondsTimeInputField($event)" 142 (change)="onNanosecondsInputTimeChange($event)"> 143 <mat-icon 144 class="bookmark-icon" 145 [class.material-symbols-outlined]="!currentPositionBookmarked()" 146 matTooltip="bookmark timestamp" 147 (click)="toggleBookmarkCurrentPosition($event)" 148 matPrefix>flag</mat-icon> 149 <input matInput name="nsTimeInput" [formControl]="selectedNsFormControl" /> 150 <div class="field-suffix" matSuffix> 151 <button 152 mat-icon-button 153 [matTooltip]="getCopyPositionTooltip(selectedNsFormControl.value)" 154 matTooltipClass="multline-tooltip" 155 [cdkCopyToClipboard]="selectedNsFormControl.value" 156 (cdkCopyToClipboardCopied)="onTimeCopied('ns')" 157 matSuffix> 158 <mat-icon>content_copy</mat-icon> 159 </button> 160 </div> 161 </mat-form-field> 162 </form> 163 <div class="time-controls"> 164 <button 165 mat-icon-button 166 id="prev_entry_button" 167 matTooltip="Go to previous entry" 168 (click)="moveToPreviousEntry()" 169 [class.disabled]="!hasPrevEntry()" 170 [disabled]="!hasPrevEntry()"> 171 <mat-icon>chevron_left</mat-icon> 172 </button> 173 <button 174 mat-icon-button 175 id="next_entry_button" 176 matTooltip="Go to next entry" 177 (click)="moveToNextEntry()" 178 [class.disabled]="!hasNextEntry()" 179 [disabled]="!hasNextEntry()"> 180 <mat-icon>chevron_right</mat-icon> 181 </button> 182 </div> 183 </div> 184 <div id="trace-selector"> 185 <mat-form-field appearance="none"> 186 <mat-select #traceSelector [formControl]="selectedTracesFormControl" multiple> 187 <div class="select-traces-panel"> 188 <div class="tip">Filter traces in the timeline</div> 189 <mat-option 190 *ngFor="let trace of sortedTraces" 191 [value]="trace" 192 [matTooltip]="trace.getDescriptors().join(', ')" 193 matTooltipPosition="right" 194 [style]="{ 195 color: 'var(--blue-text-color)', 196 opacity: isOptionDisabled(trace) ? 0.5 : 1.0 197 }" 198 [disabled]="isOptionDisabled(trace)" 199 (click)="applyNewTraceSelection(trace)"> 200 <mat-icon 201 [style]="{ 202 color: TRACE_INFO[trace.type].color 203 }" 204 >{{ TRACE_INFO[trace.type].icon }}</mat-icon> 205 {{ getTitle(trace) }} 206 </mat-option> 207 <div class="actions"> 208 <button mat-flat-button color="primary" (click)="traceSelector.close()"> 209 Done 210 </button> 211 </div> 212 </div> 213 <mat-select-trigger class="shown-selection"> 214 <div class="filter-header"> 215 <span class="mat-body-2"> Filter </span> 216 <mat-icon class="material-symbols-outlined">expand_circle_up</mat-icon> 217 </div> 218 219 <div class="trace-icons"> 220 <mat-icon 221 class="trace-icon" 222 *ngFor="let selectedTrace of getSelectedTracesToShow()" 223 [style]="{color: TRACE_INFO[selectedTrace.type].color}" 224 [matTooltip]="getTraceTooltip(selectedTrace)" 225 #tooltip="matTooltip" 226 (mouseenter)="tooltip.disabled = false" 227 (mouseleave)="tooltip.disabled = true"> 228 {{ TRACE_INFO[selectedTrace.type].icon }} 229 </mat-icon> 230 <mat-icon 231 class="trace-icon" 232 *ngIf="selectedTraces.length > 8"> 233 more_horiz 234 </mat-icon> 235 </div> 236 </mat-select-trigger> 237 </mat-select> 238 </mat-form-field> 239 </div> 240 <mini-timeline 241 *ngIf="timelineData.hasMoreThanOneDistinctTimestamp()" 242 [timelineData]="timelineData" 243 [currentTracePosition]="getCurrentTracePosition()" 244 [selectedTraces]="selectedTraces" 245 [initialZoom]="initialZoom" 246 [expandedTimelineScrollEvent]="expandedTimelineScrollEvent" 247 [expandedTimelineMouseXRatio]="expandedTimelineMouseXRatio" 248 [bookmarks]="bookmarks" 249 [store]="store" 250 (onTracePositionUpdate)="updatePosition($event)" 251 (onSeekTimestampUpdate)="updateSeekTimestamp($event)" 252 (onRemoveAllBookmarks)="removeAllBookmarks()" 253 (onToggleBookmark)="toggleBookmarkRange($event.range, $event.rangeContainsBookmark)" 254 (onTraceClicked)="onMiniTimelineTraceClicked($event)" 255 id="mini-timeline" 256 #miniTimeline></mini-timeline> 257 </ng-template> 258 <div 259 *ngIf="!timelineData.hasMoreThanOneDistinctTimestamp()" 260 class="no-timeline-msg"> 261 <p class="mat-body-2">No timeline to show!</p> 262 <p 263 *ngIf="timelineData.hasTimestamps()" 264 class="mat-body-1">Only a single timestamp has been recorded.</p> 265 <p 266 *ngIf="!timelineData.hasTimestamps()" 267 class="mat-body-1">All loaded traces contain no timestamps.</p> 268 </div> 269 </div> 270 </div> 271 </div> 272 `, 273 styles: [ 274 ` 275 .navbar-toggle { 276 display: flex; 277 flex-direction: column; 278 align-items: end; 279 position: relative; 280 max-height: 20vh; 281 overflow: auto; 282 } 283 #toggle { 284 width: fit-content; 285 position: absolute; 286 top: -41px; 287 right: 0px; 288 z-index: 1000; 289 border: 1px solid #3333; 290 border-bottom: 0px; 291 border-right: 0px; 292 border-top-left-radius: 6px; 293 border-top-right-radius: 6px; 294 background-color: var(--drawer-color); 295 } 296 .navbar { 297 display: flex; 298 width: 100%; 299 flex-direction: row; 300 align-items: center; 301 justify-content: center; 302 } 303 #expanded-nav { 304 display: flex; 305 flex-direction: row; 306 border-bottom: 1px solid #3333; 307 border-top: 1px solid #3333; 308 max-height: 60vh; 309 overflow: hidden; 310 } 311 #time-selector { 312 display: flex; 313 flex-direction: column; 314 align-items: center; 315 justify-content: center; 316 border-radius: 10px; 317 margin-left: 0.5rem; 318 height: 116px; 319 width: 282px; 320 background-color: var(--drawer-block-primary); 321 } 322 #time-selector .mat-form-field-wrapper { 323 width: 100%; 324 } 325 #time-selector .mat-form-field-infix, #trace-selector .mat-form-field-infix { 326 padding: 0 0.75rem 0 0.5rem !important; 327 border-top: unset; 328 } 329 #time-selector .mat-form-field-flex, #time-selector .field-suffix { 330 border-radius: 0; 331 padding: 0; 332 display: flex; 333 align-items: center; 334 } 335 .bookmark-icon { 336 cursor: pointer; 337 } 338 .time-selector-form { 339 display: flex; 340 flex-direction: column; 341 height: 60px; 342 width: 90%; 343 justify-content: center; 344 align-items: center; 345 gap: 5px; 346 } 347 .time-selector-form mat-form-field { 348 margin-bottom: -1.34375em; 349 display: flex; 350 width: 100%; 351 font-size: 12px; 352 } 353 .time-selector-form input { 354 text-overflow: ellipsis; 355 font-weight: bold; 356 } 357 .time-selector-form .time-difference { 358 padding-right: 2px; 359 } 360 #time-selector .time-controls { 361 border-radius: 10px; 362 margin: 0.5rem; 363 display: flex; 364 flex-direction: row; 365 justify-content: space-between; 366 width: 90%; 367 background-color: var(--drawer-block-secondary); 368 } 369 #time-selector .mat-icon-button { 370 width: 24px; 371 height: 24px; 372 padding-left: 3px; 373 padding-right: 3px; 374 } 375 #time-selector .mat-icon { 376 font-size: 18px; 377 width: 18px; 378 height: 18px; 379 line-height: 18px; 380 display: flex; 381 } 382 .shown-selection .trace-icon { 383 font-size: 18px; 384 width: 18px; 385 height: 18px; 386 padding-left: 4px; 387 padding-right: 4px; 388 padding-top: 2px; 389 } 390 #mini-timeline { 391 flex-grow: 1; 392 align-self: stretch; 393 } 394 #video-content { 395 position: relative; 396 min-width: 20rem; 397 max-height: 60vh; 398 align-self: stretch; 399 text-align: center; 400 border: 2px solid black; 401 flex-basis: 0px; 402 flex-grow: 1; 403 display: flex; 404 align-items: center; 405 } 406 #video { 407 position: absolute; 408 left: 0; 409 top: 0; 410 height: 100%; 411 width: 100%; 412 } 413 #expanded-timeline { 414 flex-grow: 1; 415 overflow-y: auto; 416 overflow-x: hidden; 417 } 418 #trace-selector .mat-form-field-infix { 419 width: 80px; 420 } 421 #trace-selector .shown-selection { 422 height: 116px; 423 border-radius: 10px; 424 display: flex; 425 justify-content: center; 426 flex-wrap: wrap; 427 align-content: flex-start; 428 background-color: var(--drawer-block-primary); 429 } 430 #trace-selector .filter-header { 431 padding-top: 4px; 432 display: flex; 433 gap: 2px; 434 } 435 .shown-selection .trace-icons { 436 display: flex; 437 justify-content: center; 438 flex-wrap: wrap; 439 align-content: flex-start; 440 width: 70%; 441 } 442 #trace-selector .mat-select-trigger { 443 height: unset; 444 flex-direction: column-reverse; 445 } 446 #trace-selector .mat-select-arrow-wrapper { 447 display: none; 448 } 449 #trace-selector .mat-form-field-wrapper { 450 padding: 0; 451 } 452 :has(>.select-traces-panel) { 453 max-height: unset !important; 454 font-family: 'Roboto', sans-serif; 455 position: relative; 456 bottom: 120px; 457 } 458 .select-traces-panel { 459 max-height: 60vh; 460 overflow-y: auto; 461 overflow-x: hidden; 462 } 463 .tip { 464 padding: 16px; 465 font-weight: 300; 466 } 467 .actions { 468 width: 100%; 469 padding: 1.5rem; 470 float: right; 471 display: flex; 472 justify-content: flex-end; 473 } 474 .no-video-message { 475 padding: 1rem; 476 font-family: 'Roboto', sans-serif; 477 } 478 .no-timeline-msg { 479 padding: 1rem; 480 align-items: center; 481 display: flex; 482 flex-direction: column; 483 width: 100%; 484 } 485 .disabled-message { 486 z-index: 100; 487 position: absolute; 488 top: 10%; 489 left: 50%; 490 opacity: 1; 491 } 492 `, 493 multlineTooltip, 494 ], 495}) 496export class TimelineComponent 497 implements WinscopeEventEmitter, WinscopeEventListener 498{ 499 readonly TOGGLE_BUTTON_CLASS: string = 'button-toggle-expansion'; 500 readonly MAX_SELECTED_TRACES = 3; 501 502 @Input() timelineData: TimelineData | undefined; 503 @Input() allTraces: Traces | undefined; 504 @Input() store: PersistentStore | undefined; 505 506 @Output() readonly collapsedTimelineSizeChanged = new EventEmitter<number>(); 507 508 @ViewChild('collapsedTimeline') private collapsedTimelineRef: 509 | ElementRef 510 | undefined; 511 512 @ViewChild('miniTimeline') miniTimeline: MiniTimelineComponent | undefined; 513 514 videoUrl: SafeUrl | undefined; 515 516 initialZoom: TimeRange | undefined = undefined; 517 selectedTraces: Array<Trace<object>> = []; 518 sortedTraces: Array<Trace<object>> = []; 519 selectedTracesFormControl = new FormControl<Array<Trace<object>>>([]); 520 selectedTimeFormControl = new FormControl('undefined'); 521 selectedNsFormControl = new FormControl( 522 'undefined', 523 Validators.compose([Validators.required, this.validateNsFormat]), 524 ); 525 timestampForm = new FormGroup({ 526 selectedTime: this.selectedTimeFormControl, 527 selectedNs: this.selectedNsFormControl, 528 }); 529 TRACE_INFO = TRACE_INFO; 530 isInputFormFocused = false; 531 storeKeyDeselectedTraces = 'miniTimeline.deselectedTraces'; 532 bookmarks: Timestamp[] = []; 533 isDisabled = false; 534 535 private expanded = false; 536 private emitEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC; 537 private expandedTimelineScrollEvent: WheelEvent | undefined; 538 private expandedTimelineMouseXRatio: number | undefined; 539 private seekTracePosition?: TracePosition; 540 541 constructor( 542 @Inject(DomSanitizer) private sanitizer: DomSanitizer, 543 @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef, 544 ) {} 545 546 ngOnInit() { 547 const timelineData = assertDefined(this.timelineData); 548 if (timelineData.hasTimestamps()) { 549 this.updateTimeInputValuesToCurrentTimestamp(); 550 } 551 const converter = assertDefined(timelineData.getTimestampConverter()); 552 const validatorFn: ValidatorFn = (control: AbstractControl) => { 553 const valid = converter.validateHumanInput(control.value ?? ''); 554 return !valid ? {invalidInput: control.value} : null; 555 }; 556 this.selectedTimeFormControl.addValidators( 557 assertDefined(Validators.compose([Validators.required, validatorFn])), 558 ); 559 560 const screenRecordingVideo = timelineData.getScreenRecordingVideo(); 561 if (screenRecordingVideo) { 562 this.videoUrl = this.sanitizer.bypassSecurityTrustUrl( 563 URL.createObjectURL(screenRecordingVideo), 564 ); 565 } 566 567 // sorted to be displayed in order corresponding to viewer tabs 568 this.sortedTraces = 569 this.allTraces 570 ?.mapTrace((trace) => trace) 571 .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)) ?? 572 []; 573 574 const storedDeselectedTraces = this.getStoredDeselectedTraceTypes(); 575 this.selectedTraces = this.sortedTraces.filter((trace) => { 576 return ( 577 timelineData.hasTrace(trace) && 578 (!storedDeselectedTraces.includes(trace.type) || 579 timelineData.getActiveTrace() === trace || 580 !timelineData.hasMoreThanOneDistinctTimestamp()) 581 ); 582 }); 583 this.selectedTracesFormControl = new FormControl<Array<Trace<object>>>( 584 this.selectedTraces, 585 ); 586 587 const initialTraceToCropZoom = this.selectedTraces.find((trace) => { 588 return ( 589 trace.type !== TraceType.SCREEN_RECORDING && 590 TraceTypeUtils.isTraceTypeWithViewer(trace.type) && 591 trace.lengthEntries > 0 592 ); 593 }); 594 if (initialTraceToCropZoom) { 595 this.initialZoom = new TimeRange( 596 initialTraceToCropZoom.getEntry(0).getTimestamp(), 597 timelineData.getFullTimeRange().to, 598 ); 599 } 600 } 601 602 ngAfterViewInit() { 603 const height = assertDefined(this.collapsedTimelineRef).nativeElement 604 .offsetHeight; 605 this.collapsedTimelineSizeChanged.emit(height); 606 } 607 608 setEmitEvent(callback: EmitEvent) { 609 this.emitEvent = callback; 610 } 611 612 getVideoCurrentTime() { 613 return assertDefined( 614 this.timelineData, 615 ).searchCorrespondingScreenRecordingTimeSeconds( 616 this.getCurrentTracePosition(), 617 ); 618 } 619 620 getCurrentTracePosition(): TracePosition { 621 if (this.seekTracePosition) { 622 return this.seekTracePosition; 623 } 624 625 const position = assertDefined(this.timelineData).getCurrentPosition(); 626 if (position === undefined) { 627 throw new Error( 628 'A trace position should be available by the time the timeline is loaded', 629 ); 630 } 631 632 return position; 633 } 634 635 getSelectedTracesToShow(): Array<Trace<object>> { 636 const sortedSelectedTraces = this.getSelectedTracesSortedByDisplayOrder(); 637 return sortedSelectedTraces.length > 8 638 ? sortedSelectedTraces.slice(0, 7) 639 : sortedSelectedTraces.slice(0, 8); 640 } 641 642 async onWinscopeEvent(event: WinscopeEvent) { 643 await event.visit(WinscopeEventType.TRACE_POSITION_UPDATE, async () => { 644 this.updateTimeInputValuesToCurrentTimestamp(); 645 }); 646 await event.visit(WinscopeEventType.ACTIVE_TRACE_CHANGED, async (event) => { 647 await this.miniTimeline?.drawer?.draw(); 648 this.updateSelectedTraces(event.trace); 649 }); 650 await event.visit(WinscopeEventType.DARK_MODE_TOGGLED, async (event) => { 651 const activeTrace = this.timelineData?.getActiveTrace(); 652 if (activeTrace === undefined) { 653 return; 654 } 655 await this.miniTimeline?.drawer?.draw(); 656 }); 657 await event.visit(WinscopeEventType.TRACE_ADD_REQUEST, async (event) => { 658 this.sortedTraces.unshift(event.trace); 659 this.sortedTraces.sort((a, b) => 660 TraceTypeUtils.compareByDisplayOrder(a.type, b.type), 661 ); 662 this.selectedTracesFormControl.setValue(this.sortedTraces); 663 this.applyNewTraceSelection(event.trace); 664 await this.miniTimeline?.drawer?.draw(); 665 }); 666 await event.visit(WinscopeEventType.TRACE_REMOVE_REQUEST, async (event) => { 667 this.sortedTraces = this.sortedTraces.filter( 668 (trace) => trace !== event.trace, 669 ); 670 this.selectedTracesFormControl.setValue( 671 this.selectedTracesFormControl.value?.filter( 672 (trace) => trace !== event.trace, 673 ) ?? [], 674 ); 675 this.applyNewTraceSelection(event.trace); 676 await this.miniTimeline?.drawer?.draw(); 677 }); 678 await event.visit( 679 WinscopeEventType.INITIALIZE_TRACE_SEARCH_REQUEST, 680 async () => this.setIsDisabled(true), 681 ); 682 await event.visit(WinscopeEventType.TRACE_SEARCH_REQUEST, async () => 683 this.setIsDisabled(true), 684 ); 685 await event.visit(WinscopeEventType.TRACE_SEARCH_INITIALIZED, async () => 686 this.setIsDisabled(false), 687 ); 688 await event.visit(WinscopeEventType.TRACE_SEARCH_COMPLETED, async () => 689 this.setIsDisabled(false), 690 ); 691 } 692 693 async toggleExpand() { 694 this.expanded = !this.expanded; 695 this.changeDetectorRef.detectChanges(); 696 if (this.expanded) { 697 Analytics.Navigation.logExpandedTimelineOpened(); 698 } 699 await this.emitEvent(new ExpandedTimelineToggled(this.expanded)); 700 } 701 702 async updatePosition(position: TracePosition) { 703 assertDefined(this.timelineData).setPosition(position); 704 await this.emitEvent(new TracePositionUpdate(position)); 705 } 706 707 updateSeekTimestamp(timestamp: Timestamp | undefined) { 708 if (timestamp) { 709 this.seekTracePosition = assertDefined( 710 this.timelineData, 711 ).makePositionFromActiveTrace(timestamp); 712 } else { 713 this.seekTracePosition = undefined; 714 } 715 this.updateTimeInputValuesToCurrentTimestamp(); 716 } 717 718 isOptionDisabled(trace: Trace<object>) { 719 const timelineData = assertDefined(this.timelineData); 720 return ( 721 !timelineData.hasTrace(trace) || timelineData.getActiveTrace() === trace 722 ); 723 } 724 725 applyNewTraceSelection(clickedTrace: Trace<object>) { 726 this.selectedTraces = 727 this.selectedTracesFormControl.value ?? 728 this.sortedTraces.filter((trace) => { 729 return assertDefined(this.timelineData).hasTrace(trace); 730 }); 731 this.updateStoredDeselectedTraceTypes(clickedTrace); 732 } 733 734 getTitle(trace: Trace<object>): string { 735 if ( 736 trace.type === TraceType.VIEW_CAPTURE || 737 trace.type === TraceType.SEARCH 738 ) { 739 return TRACE_INFO[trace.type].name + ' ' + trace.getDescriptors()[0]; 740 } 741 return TRACE_INFO[trace.type].name + (trace.isDump() ? ' Dump' : ''); 742 } 743 744 @HostListener('document:focusin', ['$event']) 745 handleFocusInEvent(event: FocusEvent) { 746 if ( 747 (event.target as HTMLInputElement)?.tagName === 'INPUT' && 748 (event.target as HTMLInputElement)?.type === 'text' 749 ) { 750 //check if text input field focused 751 this.isInputFormFocused = true; 752 } 753 } 754 755 @HostListener('document:focusout', ['$event']) 756 handleFocusOutEvent(event: FocusEvent) { 757 if ( 758 (event.target as HTMLInputElement)?.tagName === 'INPUT' && 759 (event.target as HTMLInputElement)?.type === 'text' 760 ) { 761 //check if text input field focused 762 this.isInputFormFocused = false; 763 } 764 } 765 766 @HostListener('document:keydown', ['$event']) 767 async handleKeyboardEvent(event: KeyboardEvent) { 768 if ( 769 this.isDisabled || 770 this.isInputFormFocused || 771 !assertDefined(this.timelineData).hasMoreThanOneDistinctTimestamp() 772 ) { 773 return; 774 } 775 if (event.key === 'ArrowLeft') { 776 event.preventDefault(); 777 await this.moveToPreviousEntry(); 778 } else if (event.key === 'ArrowRight') { 779 event.preventDefault(); 780 await this.moveToNextEntry(); 781 } 782 } 783 784 hasPrevEntry(): boolean { 785 const activeTrace = this.timelineData?.getActiveTrace(); 786 if (!activeTrace) { 787 return false; 788 } 789 return ( 790 assertDefined(this.timelineData).getPreviousEntryFor(activeTrace) !== 791 undefined 792 ); 793 } 794 795 hasNextEntry(): boolean { 796 const activeTrace = this.timelineData?.getActiveTrace(); 797 if (!activeTrace) { 798 return false; 799 } 800 return ( 801 assertDefined(this.timelineData).getNextEntryFor(activeTrace) !== 802 undefined 803 ); 804 } 805 806 async moveToPreviousEntry() { 807 const activeTrace = this.timelineData?.getActiveTrace(); 808 if (!activeTrace) { 809 return; 810 } 811 const timelineData = assertDefined(this.timelineData); 812 timelineData.moveToPreviousEntryFor(activeTrace); 813 const position = assertDefined(timelineData.getCurrentPosition()); 814 await this.emitEvent(new TracePositionUpdate(position)); 815 } 816 817 async moveToNextEntry() { 818 const activeTrace = this.timelineData?.getActiveTrace(); 819 if (!activeTrace) { 820 return; 821 } 822 const timelineData = assertDefined(this.timelineData); 823 timelineData.moveToNextEntryFor(activeTrace); 824 const position = assertDefined(timelineData.getCurrentPosition()); 825 await this.emitEvent(new TracePositionUpdate(position)); 826 } 827 828 async onHumanTimeInputChange(event: Event) { 829 if (event.type !== 'change' || !this.selectedTimeFormControl.valid) { 830 return; 831 } 832 const target = event.target as HTMLInputElement; 833 let input = target.value; 834 // if hh:mm:ss.zz format, append date of current timestamp 835 if (TimestampUtils.isRealTimeOnlyFormat(input)) { 836 const date = assertDefined( 837 TimestampUtils.extractDateFromHumanTimestamp( 838 this.getCurrentTracePosition().timestamp.format(), 839 ), 840 ); 841 input = date + 'T' + input; 842 } 843 const timelineData = assertDefined(this.timelineData); 844 const timestamp = assertDefined( 845 timelineData.getTimestampConverter(), 846 ).makeTimestampFromHuman(input); 847 848 Analytics.Navigation.logTimeInput('human'); 849 await this.updatePosition( 850 timelineData.makePositionFromActiveTrace(timestamp), 851 ); 852 this.updateTimeInputValuesToCurrentTimestamp(); 853 } 854 855 async onNanosecondsInputTimeChange(event: Event) { 856 if (event.type !== 'change' || !this.selectedNsFormControl.valid) { 857 return; 858 } 859 const target = event.target as HTMLInputElement; 860 const timelineData = assertDefined(this.timelineData); 861 862 const timestamp = assertDefined( 863 timelineData.getTimestampConverter(), 864 ).makeTimestampFromNs(StringUtils.parseBigIntStrippingUnit(target.value)); 865 866 Analytics.Navigation.logTimeInput('ns'); 867 await this.updatePosition( 868 timelineData.makePositionFromActiveTrace(timestamp), 869 ); 870 this.updateTimeInputValuesToCurrentTimestamp(); 871 } 872 873 onKeydownEnterTimeInputField(event: KeyboardEvent) { 874 if (this.selectedTimeFormControl.valid) { 875 (event.target as HTMLInputElement).blur(); 876 } 877 } 878 879 onKeydownEnterNanosecondsTimeInputField(event: KeyboardEvent) { 880 if (this.selectedNsFormControl.valid) { 881 (event.target as HTMLInputElement).blur(); 882 } 883 } 884 885 updateScrollEvent(event: WheelEvent) { 886 this.expandedTimelineScrollEvent = event; 887 this.changeDetectorRef.detectChanges(); 888 } 889 890 updateExpandedTimelineMouseXRatio(mouseXRatio: number | undefined) { 891 this.expandedTimelineMouseXRatio = mouseXRatio; 892 } 893 894 getCopyPositionTooltip(position: string): string { 895 return `Copy current position:\n${position}`; 896 } 897 898 getHumanTimeTooltip(): string { 899 const [date, time] = this.getCurrentTracePosition() 900 .timestamp.format() 901 .split(', '); 902 return ` 903 Date: ${date} 904 Time: ${time}\xa0\xa0\xa0\xa0${this.getUTCOffset()} 905 906 Edit field to update position by inputting time as 907 "hh:mm:ss.zz", "YYYY-MM-DDThh:mm:ss.zz", or "YYYY-MM-DD, hh:mm:ss.zz" 908 `; 909 } 910 911 getCopyHumanTimeTooltip(): string { 912 return this.getCopyPositionTooltip(this.getHumanTime()); 913 } 914 915 getHumanTime(): string { 916 return this.getCurrentTracePosition().timestamp.format(); 917 } 918 919 onTimeCopied(type: 'ns' | 'human') { 920 Analytics.Navigation.logTimeCopied(type); 921 } 922 923 getUTCOffset(): string { 924 return assertDefined( 925 this.timelineData?.getTimestampConverter(), 926 ).getUTCOffset(); 927 } 928 929 currentPositionBookmarked(): boolean { 930 const currentTimestampNs = 931 this.getCurrentTracePosition().timestamp.getValueNs(); 932 return this.bookmarks.some((bm) => bm.getValueNs() === currentTimestampNs); 933 } 934 935 toggleBookmarkCurrentPosition(event: PointerEvent) { 936 const currentTimestamp = this.getCurrentTracePosition().timestamp; 937 this.toggleBookmarkRange(new TimeRange(currentTimestamp, currentTimestamp)); 938 event.stopPropagation(); 939 } 940 941 toggleBookmarkRange(range: TimeRange, rangeContainsBookmark?: boolean) { 942 if (rangeContainsBookmark === undefined) { 943 rangeContainsBookmark = this.bookmarks.some((bookmark) => 944 range.containsTimestamp(bookmark), 945 ); 946 } 947 const clickedNs = (range.from.getValueNs() + range.to.getValueNs()) / 2n; 948 if (rangeContainsBookmark) { 949 const closestBookmark = this.bookmarks.reduce((prev, curr) => { 950 if (clickedNs - curr.getValueNs() < 0) return prev; 951 return Math.abs(Number(curr.getValueNs() - clickedNs)) < 952 Math.abs(Number(prev.getValueNs() - clickedNs)) 953 ? curr 954 : prev; 955 }); 956 this.bookmarks = this.bookmarks.filter( 957 (bm) => bm.getValueNs() !== closestBookmark.getValueNs(), 958 ); 959 } else { 960 this.bookmarks = this.bookmarks.concat([ 961 assertDefined( 962 this.timelineData?.getTimestampConverter(), 963 ).makeTimestampFromNs(clickedNs), 964 ]); 965 } 966 Analytics.Navigation.logTimeBookmark(); 967 } 968 969 removeAllBookmarks() { 970 this.bookmarks = []; 971 } 972 973 async onMiniTimelineTraceClicked(eventData: [Trace<object>, Timestamp]) { 974 const [trace, timestamp] = eventData; 975 await this.emitEvent(new ActiveTraceChanged(trace)); 976 await this.updatePosition( 977 assertDefined(this.timelineData).makePositionFromActiveTrace(timestamp), 978 ); 979 this.changeDetectorRef.detectChanges(); 980 } 981 982 async onExpandedTimelineTraceClicked(trace: Trace<object>) { 983 await this.emitEvent(new ActiveTraceChanged(trace)); 984 this.changeDetectorRef.detectChanges(); 985 } 986 987 getTraceTooltip(trace: Trace<object>) { 988 let tooltip = TRACE_INFO[trace.type].name; 989 if (trace.type === TraceType.SCREEN_RECORDING) { 990 tooltip += ' ' + trace.getDescriptors()[0].split('.')[0]; 991 } 992 if (trace.type === TraceType.VIEW_CAPTURE) { 993 tooltip += ' ' + trace.getDescriptors()[0]; 994 } 995 if (trace.type === TraceType.SEARCH) { 996 tooltip += ' ' + trace.getDescriptors()[0]; 997 } 998 return tooltip; 999 } 1000 1001 private updateSelectedTraces(trace: Trace<object> | undefined) { 1002 if (!trace) { 1003 return; 1004 } 1005 1006 if (!this.selectedTraces.includes(trace)) { 1007 // Create new object to make sure we trigger an update on Mini Timeline child component 1008 this.selectedTraces = [...this.selectedTraces, trace]; 1009 this.selectedTracesFormControl.setValue(this.selectedTraces); 1010 } 1011 } 1012 1013 private updateTimeInputValuesToCurrentTimestamp() { 1014 const currentTimestampNs = 1015 this.getCurrentTracePosition().timestamp.getValueNs(); 1016 const timelineData = assertDefined(this.timelineData); 1017 1018 const formattedCurrentTimestamp = assertDefined( 1019 timelineData.getTimestampConverter(), 1020 ) 1021 .makeTimestampFromNs(currentTimestampNs) 1022 .format(TimestampFormatType.DROP_DATE); 1023 this.selectedTimeFormControl.setValue(formattedCurrentTimestamp); 1024 this.selectedNsFormControl.setValue(`${currentTimestampNs} ns`); 1025 } 1026 1027 private getSelectedTracesSortedByDisplayOrder(): Array<Trace<object>> { 1028 return this.selectedTraces 1029 .slice() 1030 .sort((a, b) => TraceTypeUtils.compareByDisplayOrder(a.type, b.type)); 1031 } 1032 1033 private getStoredDeselectedTraceTypes(): TraceType[] { 1034 const storedDeselectedTraces = this.store?.get( 1035 this.storeKeyDeselectedTraces, 1036 ); 1037 return JSON.parse(storedDeselectedTraces ?? '[]'); 1038 } 1039 1040 private updateStoredDeselectedTraceTypes(clickedTrace: Trace<object>) { 1041 if (!this.store) { 1042 return; 1043 } 1044 1045 let storedDeselected = this.getStoredDeselectedTraceTypes(); 1046 if ( 1047 this.selectedTraces.includes(clickedTrace) && 1048 storedDeselected.includes(clickedTrace.type) 1049 ) { 1050 storedDeselected = storedDeselected.filter( 1051 (stored) => stored !== clickedTrace.type, 1052 ); 1053 } else if ( 1054 !this.selectedTraces.includes(clickedTrace) && 1055 !storedDeselected.includes(clickedTrace.type) 1056 ) { 1057 Analytics.Navigation.logTraceTimelineDeselected( 1058 TRACE_INFO[clickedTrace.type].name, 1059 ); 1060 storedDeselected.push(clickedTrace.type); 1061 } 1062 1063 this.store.add( 1064 this.storeKeyDeselectedTraces, 1065 JSON.stringify(storedDeselected), 1066 ); 1067 } 1068 1069 private validateNsFormat(control: FormControl): ValidationErrors | null { 1070 const valid = TimestampUtils.isNsFormat(control.value ?? ''); 1071 return !valid ? {invalidInput: control.value} : null; 1072 } 1073 1074 private setIsDisabled(value: boolean) { 1075 this.isDisabled = value; 1076 this.changeDetectorRef.detectChanges(); 1077 } 1078} 1079